synabun 2.0.0-beta → 2.0.5

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 (374) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/NOTICE +5 -0
  3. package/README.md +1 -1
  4. package/THIRD-PARTY-LICENSES.md +51 -0
  5. package/benchmarks/fixtures/browser/README.md +1 -1
  6. package/benchmarks/fixtures/desktop/README.md +1 -1
  7. package/docs/whatsapp.md +2 -2
  8. package/hooks/claude-code/pre-compact.mjs +11 -0
  9. package/hooks/claude-code/prompt-submit.mjs +60 -184
  10. package/hooks/claude-code/session-start.mjs +73 -16
  11. package/hooks/claude-code/shared.mjs +20 -0
  12. package/hooks/claude-code/stop.mjs +5 -5
  13. package/launcher.mjs +40 -0
  14. package/lib/claude-hooks.js +61 -8
  15. package/lib/client-config-repair.js +4 -3
  16. package/lib/data-home-migration.js +4 -0
  17. package/lib/desktop-pwa.js +206 -0
  18. package/lib/external-tools.js +221 -0
  19. package/lib/first-launch-protection.js +5 -0
  20. package/lib/packaged-runtime.js +110 -0
  21. package/lib/start-launcher.js +1396 -0
  22. package/mcp-server/package-lock.json +2 -2
  23. package/mcp-server/package.json +1 -3
  24. package/neural-interface/i18n/en.json +1540 -154
  25. package/neural-interface/i18n/pt-BR.json +1538 -153
  26. package/neural-interface/lib/assistant-api.js +62 -6
  27. package/neural-interface/lib/assistant-brains/claude.js +4 -0
  28. package/neural-interface/lib/assistant-catalog.js +179 -27
  29. package/neural-interface/lib/assistant-config.js +31 -2
  30. package/neural-interface/lib/assistant-dispatch.js +62 -13
  31. package/neural-interface/lib/assistant-envelope.js +9 -6
  32. package/neural-interface/lib/assistant-hidden-models.js +393 -66
  33. package/neural-interface/lib/assistant-memory.js +24 -10
  34. package/neural-interface/lib/assistant-persona.js +6 -2
  35. package/neural-interface/lib/assistant-pricing.js +21 -6
  36. package/neural-interface/lib/assistant-runtime.js +181 -12
  37. package/neural-interface/lib/assistant-task-prompt.js +13 -3
  38. package/neural-interface/lib/assistant-test-density.js +142 -0
  39. package/neural-interface/lib/assistant-user-context-budgets.js +123 -0
  40. package/neural-interface/lib/assistant-user-context.js +908 -0
  41. package/neural-interface/lib/assistant-user-knowledge.js +328 -0
  42. package/neural-interface/lib/assistant-user-learn.js +404 -0
  43. package/neural-interface/lib/assistant-user-threads.js +226 -0
  44. package/neural-interface/lib/assistant-user-writer.js +181 -0
  45. package/neural-interface/lib/augmented-path.js +23 -3
  46. package/neural-interface/lib/browser-background-tab.js +134 -0
  47. package/neural-interface/lib/browser-language.js +83 -0
  48. package/neural-interface/lib/browser-screenshot.js +678 -61
  49. package/neural-interface/lib/claude-agent-bridge.js +87 -153
  50. package/neural-interface/lib/claude-executable.js +241 -0
  51. package/neural-interface/lib/claude-model-catalog.js +82 -93
  52. package/neural-interface/lib/claude-temporary.js +46 -7
  53. package/neural-interface/lib/cloudflared/install.js +533 -0
  54. package/neural-interface/lib/cloudflared/manager.js +545 -0
  55. package/neural-interface/lib/cloudflared/pins.js +115 -0
  56. package/neural-interface/lib/cloudflared/pins.json +63 -0
  57. package/neural-interface/lib/cloudflared/tunnel.js +265 -0
  58. package/neural-interface/lib/editor-completion-endpoint.js +264 -0
  59. package/neural-interface/lib/editor-completion-opencode.js +436 -0
  60. package/neural-interface/lib/editor-completion.js +714 -0
  61. package/neural-interface/lib/editor-config.js +149 -0
  62. package/neural-interface/lib/editor-files-api.js +268 -0
  63. package/neural-interface/lib/editor-git-api.js +134 -0
  64. package/neural-interface/lib/file-explorer-api.js +1039 -0
  65. package/neural-interface/lib/file-explorer-system.js +355 -0
  66. package/neural-interface/lib/file-explorer-trash.ps1 +85 -0
  67. package/neural-interface/lib/file-explorer-watch.js +239 -0
  68. package/neural-interface/lib/git-diff-api.js +103 -0
  69. package/neural-interface/lib/git-read.js +202 -0
  70. package/neural-interface/lib/greeting/api.js +89 -0
  71. package/neural-interface/lib/greeting/budgets.js +90 -0
  72. package/neural-interface/lib/greeting/engine.js +461 -0
  73. package/neural-interface/lib/greeting/facts.js +320 -0
  74. package/neural-interface/lib/greeting/sources.js +252 -0
  75. package/neural-interface/lib/greeting/writer.js +139 -0
  76. package/neural-interface/lib/http-guards.js +189 -13
  77. package/neural-interface/lib/machine-names.js +88 -0
  78. package/neural-interface/lib/memory-api.js +31 -7
  79. package/neural-interface/lib/native-binary-runtime.js +69 -337
  80. package/neural-interface/lib/native-loop-providers.js +40 -49
  81. package/neural-interface/lib/offline-page.js +54 -0
  82. package/neural-interface/lib/plugin-installer.js +4 -2
  83. package/neural-interface/lib/project-storage.js +5 -7
  84. package/neural-interface/lib/request-gate.js +589 -0
  85. package/neural-interface/lib/session-title-generator.js +5 -0
  86. package/neural-interface/lib/synabun-update-plan.js +19 -0
  87. package/neural-interface/lib/ui-blob-api.js +108 -0
  88. package/neural-interface/lib/ui-blob-store.js +68 -0
  89. package/neural-interface/package-lock.json +701 -253
  90. package/neural-interface/package.json +13 -2
  91. package/neural-interface/public/install-app.html +34 -0
  92. package/neural-interface/public/manifest.json +2 -0
  93. package/neural-interface/public/offline.html +402 -66
  94. package/neural-interface/public/onboarding.html +264 -147
  95. package/neural-interface/public/shared/agent-runtime-options.js +22 -6
  96. package/neural-interface/public/shared/assistant/asst-automation.js +286 -0
  97. package/neural-interface/public/shared/assistant/asst-brain-picker.js +312 -62
  98. package/neural-interface/public/shared/assistant/asst-composer.js +11 -1
  99. package/neural-interface/public/shared/assistant/asst-mascot.js +26 -64
  100. package/neural-interface/public/shared/assistant/asst-menu.js +12 -8
  101. package/neural-interface/public/shared/assistant/asst-panel.js +150 -28
  102. package/neural-interface/public/shared/assistant/asst-rack-styles.js +0 -3
  103. package/neural-interface/public/shared/assistant/asst-render.js +219 -67
  104. package/neural-interface/public/shared/assistant/asst-route-models.js +108 -0
  105. package/neural-interface/public/shared/assistant/asst-route.js +23 -329
  106. package/neural-interface/public/shared/assistant/asst-routes-editor.js +495 -0
  107. package/neural-interface/public/shared/assistant/asst-sanitize.js +8 -1
  108. package/neural-interface/public/shared/assistant/asst-sidepanel.js +31 -9
  109. package/neural-interface/public/shared/assistant/asst-state.js +185 -1
  110. package/neural-interface/public/shared/assistant/asst-styles.js +162 -6
  111. package/neural-interface/public/shared/blob-store.js +111 -22
  112. package/neural-interface/public/shared/cdx/cdx-panel.js +32 -11
  113. package/neural-interface/public/shared/cdx/cdx-render.js +25 -89
  114. package/neural-interface/public/shared/cdx/cdx-styles.js +36 -0
  115. package/neural-interface/public/shared/cdx/cdx-tabs.js +87 -4
  116. package/neural-interface/public/shared/command-runner-model.js +30 -0
  117. package/neural-interface/public/shared/constants.js +6 -1
  118. package/neural-interface/public/shared/cp/cp-rehydrate.js +2 -1
  119. package/neural-interface/public/shared/cp/cp-styles.js +15 -0
  120. package/neural-interface/public/shared/editor/editor-ai-logic.js +409 -0
  121. package/neural-interface/public/shared/editor/editor-ai.js +619 -0
  122. package/neural-interface/public/shared/editor/editor-commands.js +212 -0
  123. package/neural-interface/public/shared/editor/editor-compare.js +103 -0
  124. package/neural-interface/public/shared/editor/editor-completion-list.js +172 -0
  125. package/neural-interface/public/shared/editor/editor-doc.js +257 -0
  126. package/neural-interface/public/shared/editor/editor-git-logic.js +131 -0
  127. package/neural-interface/public/shared/editor/editor-git.js +95 -0
  128. package/neural-interface/public/shared/editor/editor-languages.js +156 -0
  129. package/neural-interface/public/shared/editor/editor-lint.js +229 -0
  130. package/neural-interface/public/shared/editor/editor-loader.js +51 -0
  131. package/neural-interface/public/shared/editor/editor-markdown.js +90 -0
  132. package/neural-interface/public/shared/editor/editor-menu.js +233 -0
  133. package/neural-interface/public/shared/editor/editor-minimap.js +371 -0
  134. package/neural-interface/public/shared/editor/editor-palette-logic.js +192 -0
  135. package/neural-interface/public/shared/editor/editor-palette.js +125 -0
  136. package/neural-interface/public/shared/editor/editor-panel-logic.js +288 -0
  137. package/neural-interface/public/shared/editor/editor-panel.js +2099 -0
  138. package/neural-interface/public/shared/editor/editor-prefs.js +224 -0
  139. package/neural-interface/public/shared/editor/editor-safety.js +500 -0
  140. package/neural-interface/public/shared/editor/editor-search.js +352 -0
  141. package/neural-interface/public/shared/editor/editor-statusbar.js +144 -0
  142. package/neural-interface/public/shared/editor/editor-tabs-logic.js +331 -0
  143. package/neural-interface/public/shared/editor/editor-tabs.js +233 -0
  144. package/neural-interface/public/shared/editor/editor-theme.js +244 -0
  145. package/neural-interface/public/shared/editor/editor-view.js +332 -0
  146. package/neural-interface/public/shared/explorer/fx-api.js +216 -0
  147. package/neural-interface/public/shared/explorer/fx-links.js +204 -0
  148. package/neural-interface/public/shared/explorer/fx-logic.js +537 -0
  149. package/neural-interface/public/shared/explorer/fx-main.js +865 -0
  150. package/neural-interface/public/shared/explorer/fx-names.js +87 -0
  151. package/neural-interface/public/shared/explorer/fx-ops.js +370 -0
  152. package/neural-interface/public/shared/explorer/fx-popups.js +490 -0
  153. package/neural-interface/public/shared/explorer/fx-tree.js +790 -0
  154. package/neural-interface/public/shared/file-links.js +339 -0
  155. package/neural-interface/public/shared/greeting/greeting-card.js +178 -0
  156. package/neural-interface/public/shared/greeting/greeting-view.js +148 -0
  157. package/neural-interface/public/shared/html-shell.js +169 -170
  158. package/neural-interface/public/shared/ocp-v2/ocp-v2-childpanel.js +4 -0
  159. package/neural-interface/public/shared/ocp-v2/ocp-v2-greeting.js +47 -0
  160. package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +99 -17
  161. package/neural-interface/public/shared/ocp-v2/ocp-v2-render.js +7 -1
  162. package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +5 -0
  163. package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +40 -0
  164. package/neural-interface/public/shared/ocp-v2/ocp-v2-ws.js +3 -2
  165. package/neural-interface/public/shared/pwa-install-page.js +62 -0
  166. package/neural-interface/public/shared/pwa-install.css +12 -0
  167. package/neural-interface/public/shared/pwa-install.js +127 -0
  168. package/neural-interface/public/shared/settings/settings-code-editor-pane.js +412 -0
  169. package/neural-interface/public/shared/settings/settings-code-editor.js +328 -0
  170. package/neural-interface/public/shared/settings/settings-greeting.js +133 -0
  171. package/neural-interface/public/shared/settings/settings-ia.js +18 -4
  172. package/neural-interface/public/shared/settings/settings-rules.js +160 -0
  173. package/neural-interface/public/shared/settings/settings-user-context.js +154 -0
  174. package/neural-interface/public/shared/start-bridge.js +802 -0
  175. package/neural-interface/public/shared/styleguide/sg-colors.js +5 -2
  176. package/neural-interface/public/shared/styleguide/sg-import-export.js +4 -3
  177. package/neural-interface/public/shared/styleguide/sg-kit.js +4 -1
  178. package/neural-interface/public/shared/styleguide/sg-panel.js +9 -1
  179. package/neural-interface/public/shared/styleguide/sg-typography.js +1 -0
  180. package/neural-interface/public/shared/styles.css +2219 -1117
  181. package/neural-interface/public/shared/synabun-mascot.js +20 -56
  182. package/neural-interface/public/shared/term-ready.js +307 -0
  183. package/neural-interface/public/shared/tunnel-view.js +167 -0
  184. package/neural-interface/public/shared/ui-claude-panel.js +160 -19
  185. package/neural-interface/public/shared/ui-command-runner.js +251 -468
  186. package/neural-interface/public/shared/ui-detail.js +15 -0
  187. package/neural-interface/public/shared/ui-explorer.js +22 -0
  188. package/neural-interface/public/shared/ui-file-explorer.js +481 -2496
  189. package/neural-interface/public/shared/ui-file-links.js +103 -0
  190. package/neural-interface/public/shared/ui-help.js +26 -0
  191. package/neural-interface/public/shared/ui-invite.js +126 -116
  192. package/neural-interface/public/shared/ui-judgments.js +1 -1
  193. package/neural-interface/public/shared/ui-keybinds.js +4 -0
  194. package/neural-interface/public/shared/ui-loading.js +367 -12
  195. package/neural-interface/public/shared/ui-menubar.js +21 -55
  196. package/neural-interface/public/shared/ui-native-loop-router-state.js +11 -0
  197. package/neural-interface/public/shared/ui-native-loop-router.js +15 -2
  198. package/neural-interface/public/shared/ui-navbar.js +45 -21
  199. package/neural-interface/public/shared/ui-settings.js +491 -547
  200. package/neural-interface/public/shared/ui-sidepanel-runtime.js +2 -1
  201. package/neural-interface/public/shared/ui-sync.js +16 -0
  202. package/neural-interface/public/shared/ui-terminal.js +153 -149
  203. package/neural-interface/public/shared/ui-tunnel.js +130 -0
  204. package/neural-interface/public/shared/ui-tutorial-steps.js +6 -2
  205. package/neural-interface/public/shared/ui-whiteboard.js +12 -0
  206. package/neural-interface/public/shared/ui-worker-end.js +245 -0
  207. package/neural-interface/public/shared/ui-workspaces.js +41 -27
  208. package/neural-interface/public/shared/worker-end.js +251 -0
  209. package/neural-interface/public/sw.js +3 -1
  210. package/neural-interface/public/variant/2d/main.js +2 -1
  211. package/neural-interface/public/variant/3d/main.js +2 -1
  212. package/neural-interface/public/vendor/codemirror/chunks/apl-CSDKT72Z.js +1 -0
  213. package/neural-interface/public/vendor/codemirror/chunks/asciiarmor-JYYPU5BM.js +1 -0
  214. package/neural-interface/public/vendor/codemirror/chunks/asn1-NRWCZDA6.js +1 -0
  215. package/neural-interface/public/vendor/codemirror/chunks/asterisk-5OU6D4ZQ.js +1 -0
  216. package/neural-interface/public/vendor/codemirror/chunks/brainfuck-ID4N4SYC.js +1 -0
  217. package/neural-interface/public/vendor/codemirror/chunks/chunk-4RZWGCS2.js +1 -0
  218. package/neural-interface/public/vendor/codemirror/chunks/chunk-ACTJM3JR.js +11 -0
  219. package/neural-interface/public/vendor/codemirror/chunks/chunk-S6BMAXLK.js +1 -0
  220. package/neural-interface/public/vendor/codemirror/chunks/chunk-TO2RDOHQ.js +3 -0
  221. package/neural-interface/public/vendor/codemirror/chunks/chunk-US2GMZZQ.js +13 -0
  222. package/neural-interface/public/vendor/codemirror/chunks/chunk-WYYO5ZAJ.js +1 -0
  223. package/neural-interface/public/vendor/codemirror/chunks/chunk-XALZTDJC.js +1 -0
  224. package/neural-interface/public/vendor/codemirror/chunks/chunk-YRNUTRDV.js +1 -0
  225. package/neural-interface/public/vendor/codemirror/chunks/chunk-Z3QOKH62.js +1 -0
  226. package/neural-interface/public/vendor/codemirror/chunks/clike-OWQBSOGL.js +1 -0
  227. package/neural-interface/public/vendor/codemirror/chunks/clojure-KV75UQWC.js +1 -0
  228. package/neural-interface/public/vendor/codemirror/chunks/cmake-GTEP5Y3C.js +1 -0
  229. package/neural-interface/public/vendor/codemirror/chunks/cobol-WCZ7QEGJ.js +1 -0
  230. package/neural-interface/public/vendor/codemirror/chunks/coffeescript-TGLBOT6B.js +1 -0
  231. package/neural-interface/public/vendor/codemirror/chunks/commonlisp-WVVI432V.js +1 -0
  232. package/neural-interface/public/vendor/codemirror/chunks/crystal-S6R6HLKQ.js +1 -0
  233. package/neural-interface/public/vendor/codemirror/chunks/css-PDA2RINP.js +1 -0
  234. package/neural-interface/public/vendor/codemirror/chunks/cypher-HL5QPL4C.js +1 -0
  235. package/neural-interface/public/vendor/codemirror/chunks/d-HHJH2HAJ.js +1 -0
  236. package/neural-interface/public/vendor/codemirror/chunks/diff-MN3A35BY.js +1 -0
  237. package/neural-interface/public/vendor/codemirror/chunks/dist-25SQLBVS.js +3 -0
  238. package/neural-interface/public/vendor/codemirror/chunks/dist-27ZGGJJS.js +1 -0
  239. package/neural-interface/public/vendor/codemirror/chunks/dist-4E52OJIF.js +1 -0
  240. package/neural-interface/public/vendor/codemirror/chunks/dist-6FT73XDC.js +2 -0
  241. package/neural-interface/public/vendor/codemirror/chunks/dist-BE6YGRMW.js +1 -0
  242. package/neural-interface/public/vendor/codemirror/chunks/dist-CJSGKW2O.js +1 -0
  243. package/neural-interface/public/vendor/codemirror/chunks/dist-CRPH2WLI.js +1 -0
  244. package/neural-interface/public/vendor/codemirror/chunks/dist-CTG7JEQN.js +1 -0
  245. package/neural-interface/public/vendor/codemirror/chunks/dist-I3FEVELX.js +1 -0
  246. package/neural-interface/public/vendor/codemirror/chunks/dist-IM5BBSWZ.js +1 -0
  247. package/neural-interface/public/vendor/codemirror/chunks/dist-KERUKTIF.js +1 -0
  248. package/neural-interface/public/vendor/codemirror/chunks/dist-LXTMWZAM.js +1 -0
  249. package/neural-interface/public/vendor/codemirror/chunks/dist-MABASSET.js +1 -0
  250. package/neural-interface/public/vendor/codemirror/chunks/dist-MJVBUZLL.js +3 -0
  251. package/neural-interface/public/vendor/codemirror/chunks/dist-MNB3WZVU.js +1 -0
  252. package/neural-interface/public/vendor/codemirror/chunks/dist-QYWXOE3U.js +7 -0
  253. package/neural-interface/public/vendor/codemirror/chunks/dist-TEBOFWLZ.js +1 -0
  254. package/neural-interface/public/vendor/codemirror/chunks/dist-UNXFROM3.js +1 -0
  255. package/neural-interface/public/vendor/codemirror/chunks/dist-XL2SLQO6.js +1 -0
  256. package/neural-interface/public/vendor/codemirror/chunks/dist-XO42Y3AP.js +6 -0
  257. package/neural-interface/public/vendor/codemirror/chunks/dist-Y2WWDWJK.js +1 -0
  258. package/neural-interface/public/vendor/codemirror/chunks/dist-YFEZKWMU.js +1 -0
  259. package/neural-interface/public/vendor/codemirror/chunks/dist-ZY7SWQO6.js +1 -0
  260. package/neural-interface/public/vendor/codemirror/chunks/dockerfile-3Y4R4NCH.js +1 -0
  261. package/neural-interface/public/vendor/codemirror/chunks/dtd-5343GJ4P.js +1 -0
  262. package/neural-interface/public/vendor/codemirror/chunks/dylan-FI2CXSW2.js +1 -0
  263. package/neural-interface/public/vendor/codemirror/chunks/ebnf-GNQ77YWA.js +1 -0
  264. package/neural-interface/public/vendor/codemirror/chunks/ecl-MRPUAE2L.js +1 -0
  265. package/neural-interface/public/vendor/codemirror/chunks/eiffel-DZROES27.js +1 -0
  266. package/neural-interface/public/vendor/codemirror/chunks/elm-4KICRFKQ.js +1 -0
  267. package/neural-interface/public/vendor/codemirror/chunks/erlang-3IHYC263.js +1 -0
  268. package/neural-interface/public/vendor/codemirror/chunks/factor-C6D3E5UK.js +1 -0
  269. package/neural-interface/public/vendor/codemirror/chunks/fcl-VYTWUJMI.js +1 -0
  270. package/neural-interface/public/vendor/codemirror/chunks/forth-ZBO2EH5M.js +1 -0
  271. package/neural-interface/public/vendor/codemirror/chunks/fortran-5NDCRWIK.js +1 -0
  272. package/neural-interface/public/vendor/codemirror/chunks/gas-SKRMOXFP.js +1 -0
  273. package/neural-interface/public/vendor/codemirror/chunks/gherkin-L45YXZLW.js +1 -0
  274. package/neural-interface/public/vendor/codemirror/chunks/groovy-GLPTLN44.js +1 -0
  275. package/neural-interface/public/vendor/codemirror/chunks/haskell-E7NF7P4Y.js +1 -0
  276. package/neural-interface/public/vendor/codemirror/chunks/haxe-K2NVDOB6.js +1 -0
  277. package/neural-interface/public/vendor/codemirror/chunks/http-Q6DRQIJB.js +1 -0
  278. package/neural-interface/public/vendor/codemirror/chunks/idl-MZYNJVLN.js +1 -0
  279. package/neural-interface/public/vendor/codemirror/chunks/javascript-6YRYK2HR.js +1 -0
  280. package/neural-interface/public/vendor/codemirror/chunks/julia-HXEL2T5G.js +1 -0
  281. package/neural-interface/public/vendor/codemirror/chunks/livescript-MV7CI2JZ.js +1 -0
  282. package/neural-interface/public/vendor/codemirror/chunks/lua-E36NHOOD.js +1 -0
  283. package/neural-interface/public/vendor/codemirror/chunks/mathematica-3XOZZCMC.js +1 -0
  284. package/neural-interface/public/vendor/codemirror/chunks/mbox-ODYGFKS7.js +1 -0
  285. package/neural-interface/public/vendor/codemirror/chunks/mirc-WB43AWF5.js +1 -0
  286. package/neural-interface/public/vendor/codemirror/chunks/mllike-ZT6ET7ZH.js +1 -0
  287. package/neural-interface/public/vendor/codemirror/chunks/modelica-LLMZPFGA.js +1 -0
  288. package/neural-interface/public/vendor/codemirror/chunks/mscgen-2OPNNYFH.js +1 -0
  289. package/neural-interface/public/vendor/codemirror/chunks/mumps-6P3HWJH6.js +1 -0
  290. package/neural-interface/public/vendor/codemirror/chunks/nginx-TCGKQTRW.js +1 -0
  291. package/neural-interface/public/vendor/codemirror/chunks/nsis-ATIGIXTJ.js +1 -0
  292. package/neural-interface/public/vendor/codemirror/chunks/ntriples-5PLBI5OV.js +1 -0
  293. package/neural-interface/public/vendor/codemirror/chunks/octave-QF2S3NL6.js +1 -0
  294. package/neural-interface/public/vendor/codemirror/chunks/oz-JFIVFFH2.js +1 -0
  295. package/neural-interface/public/vendor/codemirror/chunks/pascal-JG6SW76V.js +1 -0
  296. package/neural-interface/public/vendor/codemirror/chunks/perl-4B3IWVVO.js +1 -0
  297. package/neural-interface/public/vendor/codemirror/chunks/pig-ABEBH4MI.js +1 -0
  298. package/neural-interface/public/vendor/codemirror/chunks/powershell-H32XE7TL.js +1 -0
  299. package/neural-interface/public/vendor/codemirror/chunks/properties-NN644MMA.js +1 -0
  300. package/neural-interface/public/vendor/codemirror/chunks/protobuf-YKWQK2J3.js +1 -0
  301. package/neural-interface/public/vendor/codemirror/chunks/pug-RIYXEKFW.js +1 -0
  302. package/neural-interface/public/vendor/codemirror/chunks/puppet-JZQ5WPEV.js +1 -0
  303. package/neural-interface/public/vendor/codemirror/chunks/python-EBCGGHAC.js +1 -0
  304. package/neural-interface/public/vendor/codemirror/chunks/q-5UMH2YQY.js +1 -0
  305. package/neural-interface/public/vendor/codemirror/chunks/r-7XMGG453.js +1 -0
  306. package/neural-interface/public/vendor/codemirror/chunks/rpm-IEV7TSLR.js +1 -0
  307. package/neural-interface/public/vendor/codemirror/chunks/ruby-T4SPFPNM.js +1 -0
  308. package/neural-interface/public/vendor/codemirror/chunks/sas-GEDX67ET.js +1 -0
  309. package/neural-interface/public/vendor/codemirror/chunks/scheme-FTLOWKPC.js +1 -0
  310. package/neural-interface/public/vendor/codemirror/chunks/shell-HUFRKFSJ.js +1 -0
  311. package/neural-interface/public/vendor/codemirror/chunks/sieve-P5QUDVYO.js +1 -0
  312. package/neural-interface/public/vendor/codemirror/chunks/smalltalk-2SORJNQI.js +1 -0
  313. package/neural-interface/public/vendor/codemirror/chunks/solr-M22TGAR5.js +1 -0
  314. package/neural-interface/public/vendor/codemirror/chunks/sparql-4M3KL752.js +1 -0
  315. package/neural-interface/public/vendor/codemirror/chunks/spreadsheet-3K23LA4N.js +1 -0
  316. package/neural-interface/public/vendor/codemirror/chunks/sql-VHRFMUBI.js +1 -0
  317. package/neural-interface/public/vendor/codemirror/chunks/stex-QZDAQUY2.js +1 -0
  318. package/neural-interface/public/vendor/codemirror/chunks/stylus-7ZDKAKBD.js +1 -0
  319. package/neural-interface/public/vendor/codemirror/chunks/swift-WCLCLGMR.js +1 -0
  320. package/neural-interface/public/vendor/codemirror/chunks/tcl-JLMU6WOK.js +1 -0
  321. package/neural-interface/public/vendor/codemirror/chunks/textile-4CHDKZ4T.js +1 -0
  322. package/neural-interface/public/vendor/codemirror/chunks/tiddlywiki-DKMCZZQ4.js +1 -0
  323. package/neural-interface/public/vendor/codemirror/chunks/tiki-F4JIG36Q.js +1 -0
  324. package/neural-interface/public/vendor/codemirror/chunks/toml-BHMHTIM2.js +1 -0
  325. package/neural-interface/public/vendor/codemirror/chunks/troff-YQ2KV5NT.js +1 -0
  326. package/neural-interface/public/vendor/codemirror/chunks/ttcn-ZHRCVFJY.js +1 -0
  327. package/neural-interface/public/vendor/codemirror/chunks/ttcn-cfg-KJOVTY4X.js +1 -0
  328. package/neural-interface/public/vendor/codemirror/chunks/turtle-535P5N6I.js +1 -0
  329. package/neural-interface/public/vendor/codemirror/chunks/vb-76UMQHOT.js +1 -0
  330. package/neural-interface/public/vendor/codemirror/chunks/vbscript-CGNDTK6Z.js +1 -0
  331. package/neural-interface/public/vendor/codemirror/chunks/velocity-VSJDO5T5.js +1 -0
  332. package/neural-interface/public/vendor/codemirror/chunks/verilog-4T6ETXIP.js +1 -0
  333. package/neural-interface/public/vendor/codemirror/chunks/vhdl-T7FNAXUN.js +1 -0
  334. package/neural-interface/public/vendor/codemirror/chunks/webidl-P6MJSJ6J.js +1 -0
  335. package/neural-interface/public/vendor/codemirror/chunks/xquery-UOVILRGN.js +1 -0
  336. package/neural-interface/public/vendor/codemirror/chunks/yacas-CV2APROL.js +1 -0
  337. package/neural-interface/public/vendor/codemirror/chunks/z80-3KFNVM4M.js +1 -0
  338. package/neural-interface/public/vendor/codemirror/codemirror-6.43.14-75f05e67.min.js +2 -0
  339. package/neural-interface/public/vendor/codemirror/manifest.json +60 -0
  340. package/neural-interface/scripts/build-vendor.mjs +78 -2
  341. package/neural-interface/scripts/rebuild-pty.js +15 -12
  342. package/neural-interface/scripts/vendor-codemirror-entry.js +121 -0
  343. package/neural-interface/server.js +1092 -1212
  344. package/neural-interface/templates/rulesets/README.md +1 -1
  345. package/package.json +13 -7
  346. package/postinstall.js +11 -81
  347. package/setup.js +111 -6
  348. package/benchmarks/browser-live-results.json +0 -79
  349. package/benchmarks/browser-v2-results.json +0 -258
  350. package/docs/browser-v2.md +0 -109
  351. package/docs/hook-integrity.md +0 -89
  352. package/docs/judgments.md +0 -182
  353. package/docs/style-guide.md +0 -246
  354. package/neural-interface/lib/codex-capabilities.d.ts +0 -41
  355. package/neural-interface/public/category-logos/claude.svg +0 -1
  356. package/neural-interface/public/category-logos/gemini-color.svg +0 -1
  357. package/neural-interface/public/category-logos/openai.svg +0 -1
  358. package/neural-interface/public/category-logos/synabun.png +0 -0
  359. package/neural-interface/public/claude.svg +0 -1
  360. package/neural-interface/public/gallery.svg +0 -6
  361. package/neural-interface/public/leonardoai.svg +0 -1
  362. package/neural-interface/public/mcp.svg +0 -4
  363. package/neural-interface/public/opencode-logo-dark-square.svg +0 -1
  364. package/neural-interface/public/pin-svgrepo-com.svg +0 -25
  365. package/neural-interface/public/sessionicon.svg +0 -8
  366. package/neural-interface/public/toolbar-album.svg +0 -8
  367. package/neural-interface/public/toolbar-question.svg +0 -6
  368. package/neural-interface/public/toolbar-sessions.svg +0 -6
  369. package/neural-interface/scripts/codex-protocol-fixture.mjs +0 -83
  370. package/scripts/audit-claude-hook-usage.mjs +0 -12
  371. package/scripts/audit-memory-duplicates.mjs +0 -44
  372. package/scripts/measure-claude-hook-overhead.mjs +0 -77
  373. package/scripts/recover-user-config.mjs +0 -254
  374. package/skills/synabun/synabunicon.png +0 -0
@@ -10,7 +10,7 @@ import { findSessionTranscript, sessionProjects, confineArtifact, confineSubPath
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
12
  import { claudeLegacyPricing, claudeUsageCostUsd } from './lib/assistant-pricing.js';
13
- import { HOOK_SCRIPTS, hookDefinitionsForEvent, hookCommandString, isSynaBunHookCommand, sweepSettingsHooks, ensureHookRoot } from '../lib/claude-hooks.js';
13
+ import { HOOK_SCRIPTS, hookDefinitionsForEvent, hookCommandString, hookInstallBlocker, hookInstallPlan, isSynaBunHookCommand, sweepSettingsHooks, ensureHookRoot } from '../lib/claude-hooks.js';
14
14
  import { browserV2Enabled, browserRequestMiddleware, assertBrowserRequestActive, browserActionTimeout, markBrowserActionStarted, runBrowserRequest, cancellableBrowserDelay, measureBrowserPhase, createEngagementScheduler, engagementTargetSelector, isEngagementWriteSelector, isXShortcutPublish, isXScriptedWrite } from './lib/browser-execution.js';
15
15
  import { describeLocatorMatches, healLocatorByText, validNthMatch, captureTargetIdentity, matchesTargetIdentity } from './lib/browser-targets.js';
16
16
  import { getInteractiveHints, collectSemanticContext, buildAssist, describeTargetFacts, verifyHealTarget, createAssistContextStore } from './lib/browser-semantic-context.js';
@@ -22,14 +22,19 @@ import { createMemoryMapService, createMemoryMapApi } from './lib/memory-map-api
22
22
  import { createAiSnapshotStore, settleBrowserPage } from './lib/browser-snapshots.js';
23
23
  import { runBrowserExtraction, readBrowserContentHtml } from './lib/browser-extraction.js';
24
24
  import { attachConsoleBuffer, readConsoleBuffer, parseConsoleSince } from './lib/browser-console.js';
25
+ import { resolveBrowserLanguage, validateBrowserLanguageConfig } from './lib/browser-language.js';
25
26
  import { parseScreenshotOptions, captureScreenshot, screenshotPathRefusal, defaultScreenshotPath, writeScreenshotFile } from './lib/browser-screenshot.js';
27
+ import { openBackgroundPage, selectedTabReading } from './lib/browser-background-tab.js';
26
28
  import { memoryRevision } from '../mcp-server/dist/services/memory-maintenance.js';
27
29
  import { startGatedMemoryMaintenance } from './lib/memory-timers.js';
28
30
  import { createEventLoopMonitor } from './lib/event-loop-monitor.js';
29
31
  import { createRulesetInstaller } from './lib/rulesets/installer.js';
30
32
  import { registerRulesetRoutes, registerLegacyRulesetRoutes } from './lib/rulesets/api.js';
31
33
  import { createPtyHostManager } from './lib/pty-host/manager.js';
32
- import { isAllowedHost, isAllowedWebSocketOrigin, refuseUpgrade } from './lib/http-guards.js';
34
+ import { hostNameOf, isAllowedHost, isAllowedWebSocketOrigin, parseCookies, refuseUpgrade } from './lib/http-guards.js';
35
+ import { createRequestGates, extraHostNames, hostsOf, savedValueOf, startedValueOf } from './lib/request-gate.js';
36
+ import { createGitDiffRouter } from './lib/git-diff-api.js';
37
+ import { gitRead, gitReadSync } from './lib/git-read.js';
33
38
  import { createPhoneAuthority } from './lib/remote-policy.js';
34
39
  import { buildTerminalEnv, interactiveShellArgs, cliWrapperArgs } from './lib/terminal-env.js';
35
40
  import { startJudgeBackfill, stopJudgeBackfill } from '../mcp-server/dist/services/memory-judge-backfill.js';
@@ -155,10 +160,7 @@ import {
155
160
  resolveAutomationOverrides,
156
161
  validAutomationContextMode,
157
162
  } from './public/shared/automation-context.js';
158
- import {
159
- claudeRuntime,
160
- ensureVendoredExecutables,
161
- } from './lib/native-binary-runtime.js';
163
+ import { ensureVendoredExecutables } from './lib/native-binary-runtime.js';
162
164
  import {
163
165
  buildExecInvocation,
164
166
  execWrapperExtension,
@@ -174,6 +176,7 @@ import * as moreLogin from './lib/morelogin.js';
174
176
  import { createStyleGuideStore } from './lib/style-guide/store.js';
175
177
  import { registerStyleGuideRoutes } from './lib/style-guide/api.js';
176
178
  import { initBlobStore, blobGetNamespace, blobGet, blobPut, blobDelete, blobClearNamespace, blobPrune, closeBlobStore } from './lib/ui-blob-store.js';
179
+ import { createUiBlobRouter } from './lib/ui-blob-api.js';
177
180
  import { memoCache, asyncPool } from './lib/memo-cache.js';
178
181
  import {
179
182
  buildClaudeMcpEntry,
@@ -225,10 +228,12 @@ import { generateSessionTitle } from './lib/session-title-generator.js';
225
228
  import {
226
229
  CLAUDE_EFFORT_LEVELS,
227
230
  CLAUDE_FALLBACK_MODELS,
228
- alignedClaudeExecutable,
229
231
  checkClaudeCliSkew,
230
232
  discoverClaudeModels,
233
+ onClaudeModelsChanged,
234
+ shellCommand,
231
235
  } from './lib/claude-model-catalog.js';
236
+ import { resolveClaudeLauncher, resolveClaudeSdkExecutable } from './lib/claude-executable.js';
232
237
  import { getAugmentedPath } from './lib/augmented-path.js';
233
238
  import { semverNewer, synabunReleaseChannel } from './lib/synabun-version.js';
234
239
  import { selectSynabunUpdate, buildSynabunInstallPlan, refuseStaleUpdateClick } from './lib/synabun-update-plan.js';
@@ -256,8 +261,12 @@ import {
256
261
  convertPctInPlace as wbConvertPctInPlace,
257
262
  } from '../mcp-server/dist/services/whiteboard-geometry.js';
258
263
  import { ensureProjectCategories } from '../hooks/claude-code/shared.mjs';
259
- import { getDataHome, getDataHomeDiagnostics, pathIsInside } from '../lib/paths.js';
264
+ import { getDataHome, getDataHomeDiagnostics, isGlobalInstall, pathIsInside } from '../lib/paths.js';
260
265
  import { launcherLogPath } from '../lib/first-launch-protection.js';
266
+ import { registerStartLauncher, startLauncherState } from '../lib/start-launcher.js';
267
+ import { durableEntry, mcpStdioCommand, packagedRuntime } from '../lib/packaged-runtime.js';
268
+ import { pruneExternalTools } from '../lib/external-tools.js';
269
+ import { renderOfflinePage } from './lib/offline-page.js';
261
270
  import {
262
271
  getDb, closeDb, getDbPath, getEmbedding, getEmbeddingBatch, getEmbeddingDims, warmupEmbeddings,
263
272
  encodeVector, decodeVector, cosineSimilarity,
@@ -295,11 +304,11 @@ const execFileAsync = promisify(execFile);
295
304
  const __filename = fileURLToPath(import.meta.url);
296
305
  const __dirname = dirname(__filename);
297
306
 
298
- // Restore execute permissions on every vendored native binary (Claude Agent SDK,
299
- // Codex, node-pty). npm and cpSync drop the execute bit, and this repo commits
300
- // node_modules — historically with core.filemode=false, so binaries checked out
301
- // 0644 and failed to spawn. The Claude sidepanel surfaced that as the SDK's
302
- // misleading "does not match this system's libc" error.
307
+ // Restore execute permissions on SynaBun's own native helpers (node-pty's
308
+ // spawn-helper). npm and cpSync drop the execute bit, and a checkout with
309
+ // core.filemode=false materializes them 0644, so terminals failed to spawn.
310
+ // The agent tools are not here: Claude Code and Codex are the user's own
311
+ // installations (lib/claude-executable.js, getCodexBin).
303
312
  //
304
313
  // This sweeps ALL platform directories, not just this host's: a vendored
305
314
  // checkout can carry several, and repairing only the current one left e.g.
@@ -317,7 +326,12 @@ try {
317
326
  // Load only the canonical data-home environment. Importing `dotenv/config`
318
327
  // above would first read PACKAGE_ROOT/.env from the server cwd, allowing a
319
328
  // stale checkout-local SQLITE_DB_PATH to override the external data home.
329
+ // SYNABUN_ALLOWED_HOSTS is the one value that is not kept from the file: a name taken out of the file would stay
330
+ // allowed until a restart. What the environment itself gives is noted first and put back after the load; the
331
+ // file's own line is read when a request asks (publicWebOrigins).
332
+ const startedAllowedHosts = startedValueOf(process.env);
320
333
  dotenvConfig({ path: resolve(getDataHome(), '.env') });
334
+ startedAllowedHosts.restore();
321
335
 
322
336
  const app = express();
323
337
  const SERVER_BOOT_ID = randomUUID();
@@ -325,9 +339,32 @@ let _nativeLoopRuntime = null;
325
339
  // Main assistant (bottom CLI region): brains + orchestration. Wired after the
326
340
  // native loop runtime exists; see the "Main assistant" block below.
327
341
  let _assistantRuntime = null;
342
+ // The greeting engine (lib/greeting/): one greeting for every agent. null until it is built (or when it did not start).
343
+ let _greeting = null;
344
+ let _greetingRouter = null;
345
+ let _greetingError = null;
328
346
  let _assistantDispatcher = null;
329
347
  // The Assistant's model catalog (lib/assistant-catalog.js): Settings → WhatsApp checks its brain choice against it.
330
348
  let _assistantCatalog = null;
349
+ // Who may ask, in front of every route (lib/request-gate.js). A request for the owner's data must name this
350
+ // server: everything under /api and /mcp, the invite sign-in, the rendered offline page and the uploaded icons
351
+ // answer 403 BAD_HOST to any other Host (a page whose name was re-pointed at this machine sends its own); the
352
+ // app's static files stay open, the images uploaded for categories excepted. A request from outside (Cloudflare's
353
+ // tunnel, or anything that arrives under the tunnel's or the invite proxy's name) needs an invite session, and
354
+ // with one it is a guest: `remote`, below. An invite guest is refused on the admin-only routes and on the
355
+ // features the owner has not opened, however the path is spelled: those two checks are mounted after it.
356
+ // Everything the gates read is read when a request arrives, never while this file is still being evaluated.
357
+ const requestGates = createRequestGates({
358
+ isGuestRequest,
359
+ permissions: () => invitePermissions,
360
+ allowedHosts: publicWebOrigins,
361
+ publicHosts: remoteWebOrigins,
362
+ hasInviteSession: isValidInviteSession,
363
+ uploadedImages: isCategoryUpload,
364
+ mcpKey: () => activeMcpKey,
365
+ settingsHint: () => ENV_PATH,
366
+ });
367
+ app.use(requestGates.host);
331
368
  app.use(express.json({ limit: '25mb' }));
332
369
 
333
370
  // Clean body-parser error handler: ui-state / whiteboard persist base64 images and can
@@ -343,20 +380,8 @@ app.use((err, req, res, next) => {
343
380
  next(err);
344
381
  });
345
382
 
346
- // Block tunnel traffic from accessing anything except /mcp and /invite
347
- // Cloudflared adds CF-Connecting-IP header on proxied requests
348
- const TUNNEL_ALLOWED = ['/mcp', '/invite'];
349
-
350
- // Simple cookie parser (no dependency)
351
- function parseCookies(cookieHeader) {
352
- const cookies = {};
353
- if (!cookieHeader) return cookies;
354
- cookieHeader.split(';').forEach(part => {
355
- const [k, ...v] = part.trim().split('=');
356
- if (k) cookies[k.trim()] = decodeURIComponent(v.join('=').trim());
357
- });
358
- return cookies;
359
- }
383
+ // Cookies are read with parseCookies (lib/http-guards.js): one that cannot be decoded is kept as it was written,
384
+ // so a cookie another app left on this host name never fails a request.
360
385
 
361
386
  // Check if request has a valid invite session cookie
362
387
  function isValidInviteSession(req) {
@@ -375,16 +400,11 @@ function isValidInviteSession(req) {
375
400
  return true;
376
401
  }
377
402
 
378
- app.use((req, res, next) => {
379
- if (req.headers['cf-connecting-ip']) {
380
- // Allow explicitly permitted paths
381
- if (TUNNEL_ALLOWED.some(p => req.path.startsWith(p))) return next();
382
- // Allow cookie-authenticated invite sessions (full UI access)
383
- if (isValidInviteSession(req)) return next();
384
- return res.status(403).json({ error: 'Forbidden' });
385
- }
386
- next();
387
- });
403
+ // What comes in from outside reaches /mcp and /invite, and with an invite session whatever a guest may reach:
404
+ // 403 Forbidden for the rest (lib/request-gate.js `remote`). From outside is what cloudflared marks with
405
+ // CF-Connecting-IP, and whatever arrives under the tunnel's or the invite proxy's name, with that header or
406
+ // without it: a proxy that does not set it is no way around the invite.
407
+ app.use(requestGates.remote);
388
408
 
389
409
  // Guest detection — works for both Cloudflare tunnel and custom proxy
390
410
  function isGuestRequest(req) {
@@ -393,67 +413,28 @@ function isGuestRequest(req) {
393
413
  return !!token && inviteSessions.has(token);
394
414
  }
395
415
 
396
- // Admin-only protection — block guests from sensitive endpoints
397
- const ADMIN_ONLY_PREFIXES = [
398
- '/api/settings', '/api/display-settings',
399
- '/api/connections', '/api/setup', '/api/system',
400
- '/api/invite/key', '/api/invite/sessions', '/api/invite/proxy', '/api/invite/permissions',
401
- '/api/claude-code', '/api/mcp-key', '/api/mcp/runtime-profile',
402
- '/api/tunnel/start', '/api/tunnel/stop',
403
- '/api/bridges', '/api/keybinds',
404
- '/api/cli',
405
- '/api/file-content',
406
- '/api/codex/plans',
407
- '/api/sidepanel',
408
- '/api/sidepanel-runs',
409
- '/api/loop',
410
- '/api/schedules',
411
- '/api/schedule-groups',
412
- '/api/quick-timer',
413
- '/api/diagnostics',
414
- '/api/desktop',
415
- // WhatsApp Link: linking, the owner and the permission level (its router
416
- // refuses guests and remote callers on its own too: lib/http-guards.js).
417
- '/api/whatsapp',
418
- // TypeSafe key/config/backfill, and the hook routes: /hook-recall returns
419
- // memory content and /hook-judge spends the host's judgment budget.
420
- '/api/typesafe',
421
- '/api/hook-',
422
- '/api/map/rebuild',
423
- ];
424
-
425
- app.use((req, res, next) => {
426
- if (isGuestRequest(req) && ADMIN_ONLY_PREFIXES.some(p => req.path.startsWith(p))) {
427
- return res.status(403).json({ error: 'Admin only' });
428
- }
429
- next();
430
- });
431
-
432
- // Feature permission enforcement — block guest mutations for disabled features
433
- const FEATURE_PERMISSION_MAP = [
434
- { prefix: '/api/terminal', perm: 'terminal' },
435
- { prefix: '/api/whiteboard', perm: 'whiteboard' },
436
- { prefix: '/api/memory', perm: 'memories' },
437
- { prefix: '/api/categories', perm: 'memories' },
438
- { prefix: '/api/trash', perm: 'memories' },
439
- { prefix: '/api/skills-studio', perm: 'skills' },
440
- { prefix: '/api/browser', perm: 'browser' },
441
- { prefix: '/api/cards', perm: 'cards' },
442
- { prefix: '/api/style-guide', perm: 'styleGuide' },
443
- ];
444
-
445
- app.use((req, res, next) => {
446
- if (req.method !== 'GET' && isGuestRequest(req)) {
447
- const match = FEATURE_PERMISSION_MAP.find(m => req.path.startsWith(m.prefix));
448
- if (match && !invitePermissions[match.perm]) {
449
- return res.status(403).json({ error: 'Feature not enabled' });
450
- }
451
- }
452
- next();
453
- });
416
+ // Admin-only protection (guests never reach the sensitive endpoints) and feature permissions (a guest changes
417
+ // nothing of a feature that is not opened to guests). The two lists, ADMIN_ONLY_PREFIXES and
418
+ // FEATURE_PERMISSION_MAP, and the checks are lib/request-gate.js: they decide on the path as the router will
419
+ // match it (letter case, percent-escapes, a doubled slash, `;` parameters), not on the path as it was written.
420
+ app.use(requestGates.adminOnly);
421
+ app.use(requestGates.features);
454
422
 
455
423
  import { registerCodexPlanRoutes } from './lib/codex-plan-store.js';
456
424
  import { createOpencodeHiddenModelsRouter, createOpencodeHiddenModelsStore } from './lib/opencode-hidden-models.js';
425
+ import { createEditorFilesRouter, EDITOR_BACKUP_CAPABILITIES, EDITOR_BACKUP_NAMESPACE } from './lib/editor-files-api.js';
426
+ import { createEditorGitRouter, EDITOR_GIT_CAPABILITIES } from './lib/editor-git-api.js';
427
+ import { confinedFolder, createFileExplorerRouter, EXPLORER_CAPABILITIES } from './lib/file-explorer-api.js';
428
+ import { folderPickerCommand } from './lib/file-explorer-system.js';
429
+ import { createEditorConfigRouter, createEditorConfigStore, createEndpointKeyStore, EDITOR_CONFIG_CAPABILITIES } from './lib/editor-config.js';
430
+ import {
431
+ COMPLETION_SYSTEM, EDITOR_COMPLETION_CAPABILITIES, createEditorCompletionRouter, createEditorCompletionService, createUsageStore,
432
+ } from './lib/editor-completion.js';
433
+ import { createOpencodeCompletionEngine } from './lib/editor-completion-opencode.js';
434
+ import { createEndpointCompletionEngine } from './lib/editor-completion-endpoint.js';
435
+ import { ownerRouteGuard } from './lib/http-guards.js';
436
+ import { createCloudflaredManager } from './lib/cloudflared/manager.js';
437
+ import { createQuickTunnel } from './lib/cloudflared/tunnel.js';
457
438
 
458
439
  const PORT = process.env.NEURAL_PORT || 3344;
459
440
  // SQLite database is used directly via lib/db.js
@@ -603,6 +584,16 @@ function saveCategories(categories) {
603
584
  }
604
585
  }
605
586
 
587
+ // Is this root file ("/logo-<name>.<ext>", lower-case) the image a category recorded as its upload? Uploads are
588
+ // written beside the app's own files (POST /api/categories/:name/logo) and are the owner's: the Host rule
589
+ // (lib/request-gate.js) refuses them to a foreign Host by their name, and asks this for the one name the app
590
+ // ships a file under, which stays open until a category's upload takes it.
591
+ function isCategoryUpload(path) {
592
+ // (asked for a request under a foreign Host: it reads the store when there is one, and never creates it)
593
+ if (!existsSync(GLOBAL_CATEGORIES_PATH)) return false;
594
+ return loadCategories().some((category) => typeof category.logo === 'string' && category.logo.toLowerCase() === path);
595
+ }
596
+
606
597
  // SQLite queries use direct WHERE clauses
607
598
 
608
599
  // --- .env Helpers ---
@@ -746,13 +737,34 @@ app.get('/', (req, res, next) => {
746
737
  res.redirect('/onboarding.html');
747
738
  });
748
739
 
749
- // Serve offline.html dynamically with the real project path injected
740
+ // The Start Server button on the offline page hands the OS a synabun:// link.
741
+ // Whether the handler for it is registered is read once (after the boot-time
742
+ // repair below) and told to the pages, which remember it for when we are down.
743
+ let _startLauncherState = null;
744
+ function getStartLauncherState() {
745
+ if (_startLauncherState === null) {
746
+ try { _startLauncherState = startLauncherState({ packageRoot: PACKAGE_ROOT }); } catch { _startLauncherState = 'missing'; }
747
+ }
748
+ return _startLauncherState;
749
+ }
750
+ // Set when this server runs inside a packaged application (packaging/): the
751
+ // code is then replaced by installing a newer build, never by npm or git.
752
+ const PACKAGED_RUNTIME = packagedRuntime();
753
+ const INSTALL_KIND = PACKAGED_RUNTIME ? 'app' : isGlobalInstall() ? 'npm' : 'git';
754
+ // What a person types to start a packaged application: its entry executable,
755
+ // when that is a path that will still be there once this run is over.
756
+ const START_ENTRY = durableEntry(PACKAGED_RUNTIME) || '';
757
+
758
+ // Serve offline.html with what it needs once we are down: the install path,
759
+ // how it was installed, the launcher's state and the bridge code itself.
750
760
  app.get('/offline.html', (req, res) => {
751
761
  const html = readFileSync(join(__dirname, 'public', 'offline.html'), 'utf-8');
752
- res.type('html').send(html.replace(
753
- `const projectDir = localStorage.getItem('synabun-project-dir');`,
754
- `const projectDir = localStorage.getItem('synabun-project-dir') || ${JSON.stringify(PACKAGE_ROOT)};`
755
- ));
762
+ let bridgeSource = '';
763
+ try { bridgeSource = readFileSync(join(__dirname, 'public', 'shared', 'start-bridge.js'), 'utf-8'); } catch {}
764
+ res.type('html').send(renderOfflinePage(html, {
765
+ bridgeSource,
766
+ facts: { projectDir: PACKAGE_ROOT, install: INSTALL_KIND, launcher: getStartLauncherState(), entry: START_ENTRY },
767
+ }));
756
768
  });
757
769
 
758
770
  // The standalone Claude chat page was retired (the sidepanel replaced it): its
@@ -2124,6 +2136,12 @@ async function runGracefulShutdown(reason) {
2124
2136
  // not represented by the terminal or agent registries below.
2125
2137
  try { await _nativeLoopRuntime?.shutdown(reason); } catch (err) { console.warn(' native loop stop err:', err?.message); }
2126
2138
  try { stopAllIsolatedServes(); } catch (err) { console.warn(' isolated opencode stop err:', err?.message); }
2139
+ // The code editor's completion serve, and today's autocomplete counters.
2140
+ try { await editorCompletion.stop(); } catch (err) { console.warn(' editor completion stop err:', err?.message); }
2141
+
2142
+ // The public address: its cloudflared is ended with the server that owns the address (killed when it does not
2143
+ // end by itself; never waited for more than a moment).
2144
+ try { await tunnel.shutdown({ timeoutMs: 1500 }); } catch (err) { console.warn(' tunnel stop err:', err?.message); }
2127
2145
 
2128
2146
  // 1. opencode sidecar — has its own teardown that handles managed PID + ollama.
2129
2147
  try { stopOpencodeServer(); } catch (err) { console.warn(' opencode stop err:', err?.message); }
@@ -2209,10 +2227,20 @@ const RESTART_EXIT_CODE = 75;
2209
2227
  // onnxruntime at-exit abort. See the `process.on('exit')` handler below.
2210
2228
  let _preserveExitCode = false;
2211
2229
  const RESTART_MARKER = resolve(DATA_HOME, 'restart-requested');
2230
+ // When this server accepted a restart (0: it has not). From then on
2231
+ // /api/health says `restarting`, and a second request starts nothing more.
2232
+ let _restartRequestedAt = 0;
2212
2233
  app.post('/api/server/restart', async (req, res) => {
2213
- console.log('[server] Restart requested.');
2214
2234
  const supervised = process.env.SYNABUN_SUPERVISED === '1';
2215
- res.json({ ok: true, message: 'Server restarting...', supervised });
2235
+ if (_restartRequestedAt) {
2236
+ return res.json({ ok: true, message: 'Server restarting...', supervised, bootId: SERVER_BOOT_ID, at: _restartRequestedAt, already: true });
2237
+ }
2238
+ console.log('[server] Restart requested.');
2239
+ _restartRequestedAt = Date.now();
2240
+ res.json({ ok: true, message: 'Server restarting...', supervised, bootId: SERVER_BOOT_ID, at: _restartRequestedAt });
2241
+ // Every open window is told before anything is torn down, so each one shows
2242
+ // the restart at once instead of finding its sockets closed (ui-loading.js).
2243
+ try { broadcastSync({ type: 'server:restarting', bootId: SERVER_BOOT_ID, supervised, at: _restartRequestedAt }); } catch {}
2216
2244
 
2217
2245
  // Drop a marker so the supervisor respawns even if our exit code is not 75.
2218
2246
  // Native module teardown (sqlite/playwright/node-pty) can crash with
@@ -2229,6 +2257,9 @@ app.post('/api/server/restart', async (req, res) => {
2229
2257
  const setupPath = resolve(PACKAGE_ROOT, 'setup.js');
2230
2258
  const respawnEnv = { ...process.env };
2231
2259
  delete respawnEnv.SYNABUN_SUPERVISED;
2260
+ // That supervisor reports its start on the start beacon, like the one
2261
+ // that respawns a supervised server: the pages are waiting on it.
2262
+ respawnEnv.SYNABUN_RESTART_REPORT = '1';
2232
2263
  const child = spawn(process.execPath, [setupPath], {
2233
2264
  cwd: PACKAGE_ROOT,
2234
2265
  detached: true,
@@ -2428,6 +2459,15 @@ function cancelUpdateHandoff(handoffPath, reason) {
2428
2459
  app.post('/api/system/run-update', async (req, res) => {
2429
2460
  let preparedHandoffPath = null;
2430
2461
  try {
2462
+ // Nothing is ever installed into the resources of a packaged application.
2463
+ if (PACKAGED_RUNTIME) {
2464
+ const openUrl = `${SYNABUN_GITHUB_REPO_URL}/releases`;
2465
+ return res.status(409).json({
2466
+ 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
+ packaged: true,
2468
+ openUrl,
2469
+ });
2470
+ }
2431
2471
  // Auto-restart is always on — running the click-to-update flow implies the
2432
2472
  // user wants the new version live without a manual relaunch step.
2433
2473
  const autoRestart = true;
@@ -3207,88 +3247,53 @@ function _heartbeatLock(sessionId, windowId) {
3207
3247
  }
3208
3248
  }
3209
3249
 
3210
- // Cache resolved claude binary path (avoids running `where` every query)
3250
+ // Cache resolved claude binary path (avoids a PATH walk on every query)
3211
3251
  let _claudeBinPath = null;
3212
-
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;
3256
+
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).
3213
3261
  function getClaudeBin() {
3214
- if (_claudeBinPath) return _claudeBinPath;
3215
- // 0. User-configured path from cli-config.json (set via Settings > Terminal)
3216
- try {
3217
- const cfg = loadCliConfig();
3218
- const userCmd = cfg['claude-code']?.command?.trim();
3219
- if (userCmd && userCmd !== 'claude') {
3220
- // Absolute or relative path — verify it exists
3221
- if (existsSync(userCmd)) {
3222
- _claudeBinPath = userCmd;
3223
- return _claudeBinPath;
3224
- }
3225
- // Might be a bare command in PATH — try which/where
3226
- try {
3227
- const envWithPath = { ...process.env, PATH: getAugmentedPath() };
3228
- let lookup;
3229
- if (process.platform === 'win32') {
3230
- const lines = execSync(`where "${userCmd}"`, { encoding: 'utf-8', env: envWithPath })
3231
- .split(/\r?\n/).map(l => l.trim()).filter(Boolean);
3232
- lookup = lines.find(l => /\.(cmd|exe|bat|ps1)$/i.test(l)) || lines[0];
3233
- } else {
3234
- lookup = execSync(`which "${userCmd}"`, { encoding: 'utf-8', env: envWithPath }).trim();
3235
- }
3236
- if (lookup) { _claudeBinPath = lookup; return _claudeBinPath; }
3237
- } catch {}
3238
- }
3239
- } catch {}
3240
- // 1. Try bundled @anthropic-ai/claude-code in node_modules/.bin
3241
- const bundledBase = resolve(__dirname, 'node_modules', '.bin', 'claude');
3242
- // On Windows npm creates .cmd shims — check for those too
3243
- const bundledCandidates = process.platform === 'win32'
3244
- ? [bundledBase + '.cmd', bundledBase + '.ps1', bundledBase]
3245
- : [bundledBase];
3246
- for (const bundled of bundledCandidates) {
3247
- if (!existsSync(bundled)) continue;
3248
- const st = statSync(bundled);
3249
- // npm sometimes creates a plain text stub instead of a symlink — resolve through to cli.js
3250
- if (st.isFile() && st.size < 256) {
3251
- const target = readFileSync(bundled, 'utf-8').trim();
3252
- if (target && !target.startsWith('#')) {
3253
- const resolved = resolve(__dirname, 'node_modules', '.bin', target);
3254
- if (existsSync(resolved)) {
3255
- if (process.platform !== 'win32') {
3256
- try { const rs = statSync(resolved); if (!(rs.mode & 0o111)) chmodSync(resolved, rs.mode | 0o755); } catch {}
3257
- }
3258
- _claudeBinPath = resolved;
3259
- return _claudeBinPath;
3260
- }
3261
- }
3262
- }
3263
- // Normal symlink or executable — ensure +x
3264
- if (process.platform !== 'win32' && !(st.mode & 0o111)) {
3265
- try { chmodSync(bundled, st.mode | 0o755); } catch {}
3266
- }
3267
- _claudeBinPath = bundled;
3268
- return _claudeBinPath;
3269
- }
3270
- // 2. Fall back to global install via which/where (with augmented PATH)
3271
- const envWithPath = { ...process.env, PATH: getAugmentedPath() };
3272
- if (process.platform === 'win32') {
3273
- try {
3274
- const lines = execSync('where claude', { encoding: 'utf-8', env: envWithPath })
3275
- .split(/\r?\n/).map(l => l.trim()).filter(Boolean);
3276
- // Prefer .cmd/.exe/.bat/.ps1 over the bare extensionless shim (which is a sh script Windows can't spawn)
3277
- _claudeBinPath = lines.find(l => /\.(cmd|exe|bat|ps1)$/i.test(l)) || lines[0] || null;
3278
- } catch { _claudeBinPath = null; }
3279
- } else {
3280
- try { _claudeBinPath = execSync('which claude', { encoding: 'utf-8', env: envWithPath }).trim(); }
3281
- catch { _claudeBinPath = null; }
3282
- }
3283
- if (!_claudeBinPath) {
3284
- console.warn('[claude-skin] Claude CLI not found. Install: npm install -g @anthropic-ai/claude-code');
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();
3285
3273
  _claudeBinPath = 'claude'; // last resort — will fail at spawn time with a clear error
3274
+ return _claudeBinPath;
3286
3275
  }
3276
+ if (_claudeBinMissingAt) console.log(`[claude-skin] Claude CLI found: ${found.path}`);
3277
+ _claudeBinMissingAt = 0;
3278
+ _claudeBinPath = found.path;
3287
3279
  return _claudeBinPath;
3288
3280
  }
3289
3281
 
3282
+ // The executable every Agent SDK call is given (pathToClaudeCodeExecutable):
3283
+ // the override from cli-config.json ("claude-skin": { "sdkExecutable": "…" }),
3284
+ // else the installation above, followed through an npm command file on
3285
+ // Windows. `path: null` when no Claude Code is installed; SynaBun carries none.
3286
+ function getClaudeSdkExecutable() {
3287
+ return resolveClaudeSdkExecutable({
3288
+ launcher: getClaudeBin(),
3289
+ sdkExecutable: _readClaudeSkinConfig().sdkExecutable || null,
3290
+ isInsideSynabun,
3291
+ });
3292
+ }
3293
+
3290
3294
  let _codexBinPath = null;
3291
3295
  let _codexBinSource = 'missing';
3296
+ let _codexBinMissingAt = 0;
3292
3297
  let _nativeCodexBinPath = null;
3293
3298
  // The SDK is statically imported at module load; reaching this point is the
3294
3299
  // authoritative capability check. Its package.json is intentionally not an
@@ -3337,10 +3342,11 @@ function resolveWindowsGlobalCodexRuntime() {
3337
3342
  }
3338
3343
 
3339
3344
  function getCodexBin() {
3340
- if (_codexBinPath) return _codexBinPath;
3341
- // All Codex surfaces follow the user's trusted global install. Never fall
3342
- // back to the SDK's bundled platform binary: macOS may quarantine or block
3343
- // that copied payload, and unattended launches must not try to repair it.
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.
3344
3350
  const globalPath = getAugmentedPath().split(delimiter)
3345
3351
  .filter((entry) => !/[\\/]node_modules[\\/]\.bin[\\/]?$/i.test(entry))
3346
3352
  .join(delimiter);
@@ -3360,14 +3366,17 @@ function getCodexBin() {
3360
3366
  try { _codexBinPath = execSync('which codex', { encoding: 'utf-8', env: envWithPath }).trim(); }
3361
3367
  catch { _codexBinPath = null; }
3362
3368
  }
3363
- if (_codexBinPath && !isInsideSynabun(_codexBinPath)) {
3369
+ if (_codexBinPath && _codexBinPath !== 'codex' && !isInsideSynabun(_codexBinPath)) {
3370
+ if (_codexBinMissingAt) console.log(`[codex-skin] Codex CLI found: ${_codexBinPath}`);
3371
+ _codexBinMissingAt = 0;
3364
3372
  _codexBinSource = 'global';
3365
3373
  return _codexBinPath;
3366
3374
  }
3367
- if (_codexBinPath) {
3368
- console.warn(`[codex-skin] Ignoring Codex CLI inside the SynaBun install: ${_codexBinPath}`);
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');
3369
3378
  }
3370
- console.warn('[codex-skin] Codex CLI not found. Install: npm install -g @openai/codex');
3379
+ _codexBinMissingAt = Date.now();
3371
3380
  _codexBinPath = 'codex';
3372
3381
  _codexBinSource = 'missing';
3373
3382
  return _codexBinPath;
@@ -3825,7 +3834,6 @@ CRITICAL — When you need clarification during planning:
3825
3834
  const appBoundary = createCodexAppBoundary();
3826
3835
  let supportsUserVerification = false;
3827
3836
  let codexJournalStorageDegraded = false;
3828
- const pendingGreetingThreads = new Set();
3829
3837
  const _cachedConfig = {}; // effective config from config/read + config/batchWrite
3830
3838
  let codexCapabilities = new CodexCapabilityRegistry();
3831
3839
  let activeTurnAutoAccept = false;
@@ -4231,7 +4239,6 @@ CRITICAL — When you need clarification during planning:
4231
4239
  for (const threadId of targets) {
4232
4240
  subscribedThreadIds.delete(threadId);
4233
4241
  threadOwnerRoots.delete(threadId);
4234
- pendingGreetingThreads.delete(threadId);
4235
4242
  }
4236
4243
  }
4237
4244
 
@@ -5044,7 +5051,7 @@ CRITICAL — When you need clarification during planning:
5044
5051
  };
5045
5052
  delete env.FORCE_COLOR;
5046
5053
  delete env.CLICOLOR_FORCE;
5047
- const proc = spawn(codexBin, args, {
5054
+ const proc = spawn(shellCommand(codexBin, useShell, { isFile: existsSync }), args, {
5048
5055
  cwd: PACKAGE_ROOT,
5049
5056
  env,
5050
5057
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -5207,7 +5214,6 @@ CRITICAL — When you need clarification during planning:
5207
5214
  lastTurnLocalImages = [];
5208
5215
  } else if (method === 'thread/closed' && params.threadId) {
5209
5216
  const closedThreadId = String(params.threadId);
5210
- pendingGreetingThreads.delete(closedThreadId);
5211
5217
  subscribedThreadIds.delete(closedThreadId);
5212
5218
  threadOwnerRoots.delete(closedThreadId);
5213
5219
  if (closedThreadId === String(activeThreadId || '')) {
@@ -5363,7 +5369,6 @@ CRITICAL — When you need clarification during planning:
5363
5369
  const old = child;
5364
5370
  const codexHome = activeCodexHome;
5365
5371
  const ownedThreadIds = ownedWriterThreadIds();
5366
- const greetingPending = pendingGreetingThreads.has(targetThreadId);
5367
5372
  initialized = false;
5368
5373
  bootPromise = null;
5369
5374
  activeThreadId = null;
@@ -5374,7 +5379,6 @@ CRITICAL — When you need clarification during planning:
5374
5379
  subscribedThreadIds.clear();
5375
5380
  threadOwnerRoots.clear();
5376
5381
  activeTurnAutoAccept = false;
5377
- pendingGreetingThreads.clear();
5378
5382
  for (const [, entry] of pending) {
5379
5383
  try { clearTimeout(entry.timer); entry.reject(new Error(reason)); } catch {}
5380
5384
  }
@@ -5397,12 +5401,9 @@ CRITICAL — When you need clarification during planning:
5397
5401
  });
5398
5402
  await activatePrimaryThread(resumed.thread?.id || targetThreadId);
5399
5403
  rememberActiveThreadRuntime(runtime);
5400
- if (greetingPending) pendingGreetingThreads.add(activeThreadId || targetThreadId);
5401
5404
  return resumed;
5402
5405
  } catch (err) {
5403
5406
  // A first-turn restart can fail after the old process has already exited.
5404
- // Keep the one-time SynaBun greeting eligible for the next activation.
5405
- if (greetingPending) pendingGreetingThreads.add(targetThreadId);
5406
5407
  throw err;
5407
5408
  }
5408
5409
  }
@@ -5427,7 +5428,6 @@ CRITICAL — When you need clarification during planning:
5427
5428
  subscribedThreadIds.clear();
5428
5429
  threadOwnerRoots.clear();
5429
5430
  activeTurnAutoAccept = false;
5430
- pendingGreetingThreads.clear();
5431
5431
  for (const [, entry] of pending) {
5432
5432
  try { clearTimeout(entry.timer); entry.reject(new Error('Codex account switched')); } catch {}
5433
5433
  }
@@ -5476,7 +5476,6 @@ CRITICAL — When you need clarification during planning:
5476
5476
  subscribedThreadIds.clear();
5477
5477
  threadOwnerRoots.clear();
5478
5478
  activeTurnAutoAccept = false;
5479
- pendingGreetingThreads.clear();
5480
5479
  for (const [, entry] of pending) {
5481
5480
  try { clearTimeout(entry.timer); entry.reject(new Error('Codex MCP profile switched')); } catch {}
5482
5481
  }
@@ -5580,7 +5579,6 @@ CRITICAL — When you need clarification during planning:
5580
5579
  const started = await request('thread/start', getDefaultThreadParams(cwd, runtime));
5581
5580
  await activatePrimaryThread(started.thread?.id || null);
5582
5581
  rememberActiveThreadRuntime(runtime);
5583
- if (activeThreadId) pendingGreetingThreads.add(activeThreadId);
5584
5582
  activeTurnId = null;
5585
5583
  const thread = sanitizeCodexThreadSummary(started.thread || null);
5586
5584
  if (opts.emitThreadEvent !== false) {
@@ -5852,7 +5850,6 @@ CRITICAL — When you need clarification during planning:
5852
5850
  const runtime = await resolveCodexThreadRuntimeOptions(opts);
5853
5851
  const desiredRuntimeFingerprint = codexThreadRuntimeFingerprint(runtime);
5854
5852
  let threadId = opts.fresh ? null : (opts.threadId || activeThreadId);
5855
- let startedFreshThread = false;
5856
5853
  if (threadId && threadId !== activeThreadId) {
5857
5854
  const resumed = await requestCodexThreadResume(threadId, cwd, runtime);
5858
5855
  await activatePrimaryThread(resumed.thread?.id || threadId);
@@ -5868,7 +5865,6 @@ CRITICAL — When you need clarification during planning:
5868
5865
  runtimeOptions: runtime,
5869
5866
  });
5870
5867
  threadId = startedThread?.id || null;
5871
- startedFreshThread = true;
5872
5868
  }
5873
5869
  activeTurnAutoAccept = !!opts.autoAccept;
5874
5870
  const originalPrompt = prompt;
@@ -5878,11 +5874,14 @@ CRITICAL — When you need clarification during planning:
5878
5874
  );
5879
5875
  const isPlanModeTurn = !!opts.planMode || isCodexPlanModePrompt(originalPrompt);
5880
5876
  const isSlashCommand = /^\/\w/.test(String(originalPrompt || '').trim());
5881
- const shouldInjectGreeting = !!threadId && !isSlashCommand && (startedFreshThread || pendingGreetingThreads.has(threadId));
5882
- const turnPrompt = shouldInjectGreeting
5883
- ? buildCodexGreetingUserPrompt({ cwd, userPrompt: prompt }) || prompt
5877
+ // The greeting SynaBun showed when this tab opened (lib/greeting/): the panel echoes its id
5878
+ // with the first prompt and the model is told once what was said, between the markers the
5879
+ // transcript strips. Nothing for a slash command (its own flow comes first), nothing when no
5880
+ // greeting was shown, and the model is never asked to greet.
5881
+ const greetingNote = opts.greetingId && _greeting ? _greeting.consumeNote(String(opts.greetingId), { skip: isSlashCommand }) : '';
5882
+ const turnPrompt = greetingNote
5883
+ ? `${CODEX_GREETING_MARKER_START}\n${greetingNote}\n${CODEX_GREETING_MARKER_END}\n\n${prompt}`
5884
5884
  : prompt;
5885
- if (threadId) pendingGreetingThreads.delete(threadId);
5886
5885
  const input = buildCodexInputItems(turnPrompt, {
5887
5886
  mentions: Array.isArray(opts.mentions) ? opts.mentions : [],
5888
5887
  localImages: Array.isArray(opts.localImages) ? opts.localImages : [],
@@ -6081,8 +6080,6 @@ CRITICAL — When you need clarification during planning:
6081
6080
  for (const [threadId, ownerRoot] of orphan.threadOwnerRoots || []) threadOwnerRoots.set(threadId, ownerRoot);
6082
6081
  nextRequestId = orphan.nextRequestId;
6083
6082
  Object.assign(_cachedConfig, orphan.cachedConfig || {});
6084
- pendingGreetingThreads.clear();
6085
- for (const threadId of orphan.pendingGreetingThreads || []) pendingGreetingThreads.add(threadId);
6086
6083
  writerLifecycleState = orphan.writerLifecycleState || writerLifecycleState;
6087
6084
  writerLifecycleState.releaseRequested = false;
6088
6085
  shuttingDown = false;
@@ -6376,9 +6373,15 @@ CRITICAL — When you need clarification during planning:
6376
6373
  prompt = injected.prompt;
6377
6374
  }
6378
6375
  }
6376
+ // A person typed this in the Codex panel (the Assistant's brain, a loop and a run never
6377
+ // say so): the greeting engine keeps when they were last here.
6378
+ if (msg.human === true && _greeting) {
6379
+ try { _greeting.noteActivity({ cwd: msg.cwd || PACKAGE_ROOT, surface: 'codex-panel', words: sanitizeCodexUserFacingText(typeof msg.prompt === 'string' ? msg.prompt : '') }); } catch {}
6380
+ }
6379
6381
  await withCodexWriterOperation('query', () => startTurn(prompt, {
6380
6382
  fresh: !!msg.fresh,
6381
6383
  threadId: msg.threadId || null,
6384
+ greetingId: typeof msg.greetingId === 'string' ? msg.greetingId : null,
6382
6385
  cwd: msg.cwd || PACKAGE_ROOT,
6383
6386
  model: msg.model || null,
6384
6387
  contextMode: msg.contextMode || 'default',
@@ -7155,7 +7158,6 @@ CRITICAL — When you need clarification during planning:
7155
7158
  get subscribedThreadIds() { return new Set(subscribedThreadIds); },
7156
7159
  get threadOwnerRoots() { return new Map(threadOwnerRoots); },
7157
7160
  get nextRequestId() { return nextRequestId; },
7158
- get pendingGreetingThreads() { return new Set(pendingGreetingThreads); },
7159
7161
  cachedConfig: { ..._cachedConfig },
7160
7162
  get pending() { return new Map(pending); },
7161
7163
  get pendingServerRequests() { return new Map(pendingServerRequests); },
@@ -7226,7 +7228,8 @@ async function queryCodexAppServerModels(codexHome = CODEX_DEFAULT_HOME) {
7226
7228
  delete env.CLICOLOR_FORCE;
7227
7229
 
7228
7230
  return new Promise((resolve, reject) => {
7229
- const child = spawn(codexBin, args, {
7231
+ // (A configured command may be a command line; a file with a space in its path is one word.)
7232
+ const child = spawn(shellCommand(codexBin, useShell, { isFile: existsSync }), args, {
7230
7233
  cwd: PACKAGE_ROOT,
7231
7234
  env,
7232
7235
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -7265,6 +7268,10 @@ async function queryCodexAppServerModels(codexHome = CODEX_DEFAULT_HOME) {
7265
7268
  child.stderr.setEncoding('utf-8');
7266
7269
  child.stderr.on('data', chunk => { stderrBuf += chunk; });
7267
7270
  child.on('error', fail);
7271
+ // Codex not installed: the shell asked to run it is gone before the first
7272
+ // request is written, and that write fails on the stream (EPIPE). Unhandled,
7273
+ // it ends the whole server.
7274
+ child.stdin.on('error', fail);
7268
7275
  child.on('exit', (code) => {
7269
7276
  if (pending.size) {
7270
7277
  const err = new Error(`Codex app-server exited while listing models (code ${code ?? 'unknown'})`);
@@ -7330,7 +7337,11 @@ app.get('/api/codex/models', async (req, res) => {
7330
7337
  await queryCodexAppServerModels(codexHome),
7331
7338
  readCodexRuntimeModelCatalog(codexHome),
7332
7339
  );
7340
+ // The Assistant's catalog is built from the default account's list: when that list
7341
+ // changes, its copy is dropped and rebuilt (which tells the open windows).
7342
+ const listChanged = codexHome === CODEX_DEFAULT_HOME && !!cached && JSON.stringify(cached.models) !== JSON.stringify(models);
7333
7343
  _codexModelListCache.set(codexHome, { at: Date.now(), models });
7344
+ if (listChanged) _assistantCatalog?.refresh?.().catch(() => {});
7334
7345
  res.json({ ok: true, source: 'codex-app-server', models });
7335
7346
  } catch (err) {
7336
7347
  res.json({ ok: false, source: 'fallback', error: err.message, models: [] });
@@ -8295,9 +8306,21 @@ function handleOpencodeV2Ws(ws) {
8295
8306
  const promptFn = useAsync
8296
8307
  ? turnClient.session.promptAsync
8297
8308
  : turnClient.session.prompt;
8309
+ // Greeting (lib/greeting/): a prompt the person typed is noted, and the greeting the
8310
+ // panel showed when this session opened is told to the model once, as a part OpenCode
8311
+ // marks synthetic (the transcript does not draw it). Nothing for a slash command.
8312
+ let turnParts = msg.parts;
8313
+ if (_greeting && Array.isArray(msg.parts)) {
8314
+ const typed = msg.parts.find((part) => part?.type === 'text' && typeof part.text === 'string' && !part.synthetic)?.text || '';
8315
+ if (msg.human === true) { try { _greeting.noteActivity({ cwd: msg.cwd || PACKAGE_ROOT, surface: 'opencode-panel', words: typed }); } catch {} }
8316
+ if (typeof msg.greetingId === 'string') {
8317
+ const greetingNote = _greeting.consumeNote(msg.greetingId, { skip: /^\s*\//.test(typed) });
8318
+ if (greetingNote) turnParts = [{ type: 'text', text: greetingNote, synthetic: true }, ...msg.parts];
8319
+ }
8320
+ }
8298
8321
  const r = await promptFn({
8299
8322
  sessionID: sid,
8300
- parts: msg.parts,
8323
+ parts: turnParts,
8301
8324
  model: msg.model,
8302
8325
  agent,
8303
8326
  variant: msg.variant || undefined,
@@ -8861,6 +8884,7 @@ app.post('/api/sidepanel/session-title', async (req, res) => {
8861
8884
  input.cwd = cwd;
8862
8885
  }
8863
8886
  const result = await generateSessionTitle(input, {
8887
+ claudeExecutable: getClaudeSdkExecutable,
8864
8888
  execFile: execFileAsync,
8865
8889
  getCodexBin,
8866
8890
  getCodexAccountHome,
@@ -9394,10 +9418,10 @@ app.post('/api/opencode/mcp', async (req, res) => {
9394
9418
  let { name, config: mcpConfig } = req.body || {};
9395
9419
  if (!name) {
9396
9420
  name = 'SynaBun';
9397
- const { mcpIndexPath, envPath } = getMcpPaths();
9421
+ const { envPath, mcpCommand, mcpArgs } = getMcpPaths();
9398
9422
  mcpConfig = {
9399
- command: 'node',
9400
- args: [mcpIndexPath],
9423
+ command: mcpCommand,
9424
+ args: mcpArgs,
9401
9425
  env: { DOTENV_PATH: envPath, SYNABUN_DATA_HOME: DATA_HOME, MEMORY_DATA_DIR: resolve(DATA_HOME, 'mcp-data') },
9402
9426
  };
9403
9427
  }
@@ -9492,6 +9516,10 @@ const _claudeBridgeReady = (async () => {
9492
9516
  try {
9493
9517
  _claudeBridge = await import('./lib/claude-agent-bridge.js');
9494
9518
  _claudeBridge.configureClaudeBridge({
9519
+ // The greeting a panel conversation opened with, as the one note its model gets (lib/greeting/).
9520
+ greetingNote: (id, opts) => _greeting?.consumeNote(id, opts) || '',
9521
+ // A prompt typed in the panel: when the person was last here (a temporary chat is named to nobody).
9522
+ greetingActivity: ({ cwd, words, temporary }) => { if (!temporary) _greeting?.noteActivity({ cwd: cwd || PACKAGE_ROOT, surface: 'claude-panel', words }); },
9495
9523
  addCost,
9496
9524
  getSessionCost,
9497
9525
  acquireSessionLock: _acquireSessionLock,
@@ -9522,17 +9550,12 @@ const _claudeBridgeReady = (async () => {
9522
9550
  if (typeof cfg.partials === 'boolean') return cfg.partials;
9523
9551
  return process.platform !== 'win32';
9524
9552
  },
9525
- // Optional explicit executable override; bundled SDK CLI is the default.
9526
- // Accepts a .js entrypoint (launched via node) or a native binary path —
9527
- // see resolveClaudeExecutableOverride() in lib/native-binary-runtime.js.
9528
- get sdkExecutable() { return _readClaudeSkinConfig().sdkExecutable || null; },
9529
- // Last-resort runtime for the bridge: if the SDK's bundled native binary
9530
- // cannot be launched or repaired, fall back to the user's installed CLI.
9531
- // Lazy so the (memoized) resolution happens on demand, not at boot.
9532
- getClaudeBin: () => getClaudeBin(),
9533
- // The installed CLI when it is newer than the bundled one, else null — the
9534
- // model picker lists the installed CLI's models, so sessions must run it.
9535
- alignedClaudeBin: () => alignedClaudeExecutable(getClaudeBin()),
9553
+ // The Claude Code every session runs: the user's own installation, or the
9554
+ // explicit override from cli-config.json ("claude-skin": { "sdkExecutable":
9555
+ // "…" }, a .js entrypoint or a native binary path). SynaBun carries none, so
9556
+ // a session without one is refused with the install hint. Lazy, so the
9557
+ // (memoized) resolution happens on demand, not at boot.
9558
+ claudeExecutable: () => getClaudeSdkExecutable(),
9536
9559
  // Sidepanel tabs can run under another Claude account (its own config
9537
9560
  // directory). The default account adds nothing to the environment.
9538
9561
  claudeAccountEnv: (accountId) => _panelClaudeAccounts().envFor(accountId),
@@ -9617,10 +9640,10 @@ function handleClaudeSkinWebSocket(ws) {
9617
9640
  async function getClaudeModelsForClient(force = false) {
9618
9641
  try {
9619
9642
  const bin = getClaudeBin();
9620
- // Sidepanel sessions and native loops default to the SDK's bundled CLI, not
9621
- // this one. The comparison is cached per binary (a spawn only when the CLI
9622
- // changes); it warns about a skew and, when this CLI is the newer one, makes
9623
- // sessions run it instead — see alignedClaudeExecutable().
9643
+ // Sidepanel sessions and native loops run this same installation. The Agent
9644
+ // SDK that starts it was built against one Claude Code release; an older
9645
+ // installation is worth one line in the log. Cached per binary (a spawn only
9646
+ // when the CLI changes).
9624
9647
  checkClaudeCliSkew(bin).catch(() => {});
9625
9648
  const result = await discoverClaudeModels(bin, { force, cwd: PACKAGE_ROOT });
9626
9649
  if (result?.models?.length) return result;
@@ -10234,7 +10257,7 @@ const FAT_KEY_ROUTES = {
10234
10257
  'synabun-node-positions': { ns: 'kv', split: false },
10235
10258
  'synabun-node-positions-2d': { ns: 'kv', split: false },
10236
10259
  };
10237
- const BLOB_NS_ALLOWLIST = new Set(['claude-snapshots', 'codex-snapshots', 'ocp-snapshots', 'kv']);
10260
+ const BLOB_NS_ALLOWLIST = new Set(['claude-snapshots', 'codex-snapshots', 'ocp-snapshots', 'kv', EDITOR_BACKUP_NAMESPACE]);
10238
10261
 
10239
10262
  function routeFatKeyToBlobStore(key, value) {
10240
10263
  const fat = FAT_KEY_ROUTES[key];
@@ -10333,53 +10356,22 @@ app.post('/api/ui-state', (req, res) => {
10333
10356
  }
10334
10357
  });
10335
10358
 
10336
- // ── UI blob store — per-entry storage for fat keys (transcript snapshots,
10337
- // node positions). PUT writes only the changed entry instead of the whole map.
10338
- function _blobNs(req, res) {
10339
- const ns = req.params.ns;
10340
- if (!BLOB_NS_ALLOWLIST.has(ns)) { res.status(400).json({ error: `unknown namespace: ${ns}` }); return null; }
10341
- return ns;
10342
- }
10343
-
10344
- app.get('/api/ui-blobs/:ns', (req, res) => {
10345
- const ns = _blobNs(req, res);
10346
- if (!ns) return;
10347
- try { res.json(blobGetNamespace(ns)); }
10348
- catch (err) { res.status(500).json({ error: err.message }); }
10349
- });
10350
-
10351
- app.get('/api/ui-blobs/:ns/:id', (req, res) => {
10352
- const ns = _blobNs(req, res);
10353
- if (!ns) return;
10354
- try {
10355
- const value = blobGet(ns, req.params.id);
10356
- if (value === null) return res.status(404).json({ error: 'not found' });
10357
- res.json(value);
10358
- } catch (err) { res.status(500).json({ error: err.message }); }
10359
- });
10359
+ // The code editor's routes (the files, their committed versions, the preferences, AI autocomplete) and the
10360
+ // editor's namespace of the blob store below refuse everybody but the owner by themselves: an invite guest, a
10361
+ // Host that is not this server's (DNS rebinding), a write from another site's page. See lib/http-guards.js.
10362
+ // The checks in front of the routes (lib/request-gate.js) refuse the same callers; these routes do not rely on them.
10363
+ const editorOwnerGuard = ownerRouteGuard({ isGuestRequest, port: PORT, allowedOrigins: publicWebOrigins });
10360
10364
 
10361
- function _blobPutHandler(req, res) {
10362
- const ns = _blobNs(req, res);
10363
- if (!ns) return;
10364
- try {
10365
- blobPut(ns, req.params.id, req.body);
10366
- const maxEntries = parseInt(req.query.maxEntries, 10) || 0;
10367
- const maxChars = parseInt(req.query.maxChars, 10) || 0;
10368
- let evicted = [];
10369
- if (maxEntries || maxChars) evicted = blobPrune(ns, { maxEntries, maxTotalChars: maxChars });
10370
- res.json({ ok: true, evicted });
10371
- } catch (err) { res.status(500).json({ error: err.message }); }
10372
- }
10373
- app.put('/api/ui-blobs/:ns/:id', _blobPutHandler);
10374
- // POST alias — sendBeacon can only POST
10375
- app.post('/api/ui-blobs/:ns/:id', _blobPutHandler);
10376
-
10377
- app.delete('/api/ui-blobs/:ns/:id', (req, res) => {
10378
- const ns = _blobNs(req, res);
10379
- if (!ns) return;
10380
- try { blobDelete(ns, req.params.id); res.json({ ok: true }); }
10381
- catch (err) { res.status(500).json({ error: err.message }); }
10382
- });
10365
+ // ── UI blob store — per-entry storage for fat keys (transcript snapshots, node positions, the editor's copies
10366
+ // of unsaved buffers). lib/ui-blob-api.js: GET a namespace or an entry, PUT / POST / DELETE one entry. A write
10367
+ // sends only the changed entry; of two requests of one page for an entry, the newer one is what stays, in
10368
+ // whatever order they arrive; the editor's namespace asks the owner's guard on every route.
10369
+ app.use(createUiBlobRouter({
10370
+ namespaces: BLOB_NS_ALLOWLIST,
10371
+ store: { getNamespace: blobGetNamespace, get: blobGet, put: blobPut, delete: blobDelete, prune: blobPrune },
10372
+ ownerNamespace: EDITOR_BACKUP_NAMESPACE,
10373
+ ownerGuard: editorOwnerGuard,
10374
+ }));
10383
10375
 
10384
10376
 
10385
10377
  // ═══════════════════════════════════════════
@@ -15460,7 +15452,7 @@ function spawnAgentProcess(agent, prompt, extraEnv = {}) {
15460
15452
 
15461
15453
  console.log(`[agent] ${agent.id} spawn: ${claudeBin} ${args.map(a => a.length > 80 ? a.slice(0, 80) + '…' : a).join(' ')}`);
15462
15454
 
15463
- const child = spawn(claudeBin, args, {
15455
+ const child = spawn(shellCommand(claudeBin, AGENT_IS_WIN), args, {
15464
15456
  cwd: agent.cwd,
15465
15457
  env: { ...cleanEnv, FORCE_COLOR: '0', NO_COLOR: '1', TERM: 'dumb', ...extraEnv },
15466
15458
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -16094,37 +16086,22 @@ function isSynabunGitHubRemote(remoteText) {
16094
16086
  }
16095
16087
 
16096
16088
  function detectSynabunInstallSource() {
16089
+ if (PACKAGED_RUNTIME) return { kind: 'packaged-app' };
16097
16090
  const normalizedRoot = PACKAGE_ROOT.replace(/\\/g, '/');
16098
16091
  if (/\/node_modules\/synabun$/i.test(normalizedRoot)) {
16099
16092
  return { kind: 'npm-global' };
16100
16093
  }
16101
16094
 
16102
16095
  try {
16103
- const inside = execSync('git rev-parse --is-inside-work-tree', {
16104
- cwd: PACKAGE_ROOT,
16105
- encoding: 'utf8',
16106
- stdio: ['ignore', 'pipe', 'ignore'],
16107
- timeout: 2000,
16108
- }).trim();
16109
- if (inside === 'true') {
16110
- let remote = '';
16111
- let remotes = '';
16112
- try {
16113
- remote = execSync('git remote get-url origin', {
16114
- cwd: PACKAGE_ROOT,
16115
- encoding: 'utf8',
16116
- stdio: ['ignore', 'pipe', 'ignore'],
16117
- timeout: 2000,
16118
- }).trim();
16119
- } catch {}
16120
- try {
16121
- remotes = execSync('git remote -v', {
16122
- cwd: PACKAGE_ROOT,
16123
- encoding: 'utf8',
16124
- stdio: ['ignore', 'pipe', 'ignore'],
16125
- timeout: 2000,
16126
- }).trim();
16127
- } catch {}
16096
+ // Run as reads (lib/git-read.js): the app's own checkout is a folder like any other, and can be a registered
16097
+ // project. What git could not say is an empty answer.
16098
+ const git = (args) => {
16099
+ const result = spawnGitRead(args, { cwd: PACKAGE_ROOT, timeout: 2000 });
16100
+ return result.status === 0 ? result.stdout.toString().trim() : '';
16101
+ };
16102
+ if (git(['rev-parse', '--is-inside-work-tree']) === 'true') {
16103
+ const remote = git(['remote', 'get-url', 'origin']);
16104
+ const remotes = git(['remote', '-v']);
16128
16105
  if (isSynabunGitHubRemote(`${remote}\n${remotes}`)) {
16129
16106
  const firstRemote = remote || remotes.split(/\s+/).find(part => isSynabunGitHubRemote(part)) || null;
16130
16107
  return { kind: 'github-clone', remote: firstRemote };
@@ -16345,6 +16322,10 @@ const SYNABUN_ROOTS = (() => {
16345
16322
  const roots = new Set();
16346
16323
  try { roots.add(realpathSync(__dirname).replace(/\\/g, '/').toLowerCase()); } catch {}
16347
16324
  try { roots.add(realpathSync(resolve(__dirname, '..')).replace(/\\/g, '/').toLowerCase()); } catch {}
16325
+ // A packaged application: everything it ships, its own Node and npm included.
16326
+ if (PACKAGED_RUNTIME?.resources) {
16327
+ try { roots.add(realpathSync(PACKAGED_RUNTIME.resources).replace(/\\/g, '/').toLowerCase()); } catch {}
16328
+ }
16348
16329
  return [...roots];
16349
16330
  })();
16350
16331
 
@@ -16366,35 +16347,23 @@ function isInsideSynabun(binPath) {
16366
16347
  // to come from. Add new versions here if SynaBun ever ships claude-code again.
16367
16348
  const KNOWN_STALE_CLAUDE_VERSIONS = new Set(['2.1.71', '2.1.89', '2.1.91']);
16368
16349
 
16369
- // Aggressively delete any stale bundled CLI tool packages left over in our
16370
- // own node_modules. Idempotent — runs on every startup. Belt-and-suspenders
16371
- // against `npm install` not auto-pruning packages removed from package.json.
16372
- // Logs every found package (deleted or not) so Windows users can confirm the
16373
- // prune is actually firing on their box.
16350
+ // Remove any external tool that ended up in our own node_modules: Claude Code,
16351
+ // Codex, OpenCode and Gemini CLI are installed by the user, never by SynaBun,
16352
+ // and the agent SDKs are installed without the executables they would carry
16353
+ // (the list and the reasons: lib/external-tools.js at the package root).
16354
+ // Idempotent, runs on every startup, and touches nothing when there is nothing
16355
+ // to remove — a packaged application ships without them and is never written to.
16356
+ // Catches what `npm install` leaves behind: packages an older SynaBun depended
16357
+ // on, and payloads a regenerated lockfile brought back. Logs each removal so a
16358
+ // user can see the prune fire on their machine.
16374
16359
  function pruneBundledCliTools() {
16375
- // @openai/codex is a live transitive dependency of @openai/codex-sdk, so it
16376
- // must remain installed even though SynaBun never executes its bundled binary.
16377
- const stalePkgs = ['@anthropic-ai/claude-code', 'opencode-ai', '@google/gemini-cli'];
16378
16360
  const moduleRoots = [
16379
16361
  join(__dirname, 'node_modules'),
16380
16362
  join(__dirname, '..', 'node_modules'),
16381
16363
  ];
16382
16364
  for (const root of moduleRoots) {
16383
- for (const pkg of stalePkgs) {
16384
- const target = join(root, pkg);
16385
- try {
16386
- if (existsSync(target)) {
16387
- rmSync(target, { recursive: true, force: true });
16388
- console.log(` Pruned stale bundled CLI: ${target}`);
16389
- }
16390
- } catch { /* permission or in-use — ignore */ }
16391
- // Also remove .bin shims that point at the deleted package
16392
- const binBase = pkg.split('/').pop().replace(/-cli$|-ai$/, '');
16393
- for (const ext of ['', '.cmd', '.ps1', '.exe']) {
16394
- const shim = join(root, '.bin', binBase + ext);
16395
- try { if (existsSync(shim)) rmSync(shim, { force: true }); } catch {}
16396
- }
16397
- }
16365
+ const result = pruneExternalTools(root);
16366
+ for (const item of result.removed) console.log(` Removed ${item.name} from SynaBun's own node_modules (${item.tool} is installed separately): ${item.path}`);
16398
16367
  }
16399
16368
  }
16400
16369
 
@@ -16972,130 +16941,110 @@ app.get('/api/system/backups', (req, res) => {
16972
16941
  });
16973
16942
 
16974
16943
  // GET /api/projects — List registered projects for file explorer selector
16944
+ // `capabilities` tells the File Explorer, in the request it makes anyway, that this server has its routes
16945
+ // (/api/explorer, lib/file-explorer-api.js): a page loaded against an older server finds none and asks for none.
16975
16946
  app.get('/api/projects', (req, res) => {
16976
16947
  const projects = loadHookProjects();
16977
- res.json({ ok: true, projects: projects.map(p => ({ path: p.path, label: p.label || basename(p.path) })) });
16948
+ res.json({ ok: true, projects: projects.map(p => ({ path: p.path, label: p.label || basename(p.path) })), capabilities: [...EXPLORER_CAPABILITIES] });
16978
16949
  });
16979
16950
 
16980
16951
  // ── Path validation helper (reused by project-files, file-content) ──
16981
- function validateProjectPath(filePath) {
16982
- const normalized = resolve(filePath);
16952
+ // The roots a file path may be under: folders, and single files allowed by their exact path.
16953
+ function allowedFileRoots() {
16983
16954
  const registeredProjects = loadHookProjects();
16984
16955
  const claudePlansDir = resolve(join(process.env.USERPROFILE || process.env.HOME || '', '.claude', 'plans'));
16985
16956
  const localPlansDir = resolve(join(DATA_HOME, 'data', 'plans'));
16986
16957
  const opencodeConfigPath = resolve(getOpencodeConfigPath());
16987
- const allowedRoots = [resolve(DATA_HOME), ...registeredProjects.map(p => resolve(p.path)), localPlansDir, claudePlansDir, opencodeConfigPath];
16988
- const matched = allowedRoots.find(r => normalized === r || normalized.startsWith(r + sep));
16989
- return matched ? normalized : null;
16958
+ return {
16959
+ dirs: [resolve(DATA_HOME), ...registeredProjects.map(p => resolve(p.path)), localPlansDir, claudePlansDir],
16960
+ files: [opencodeConfigPath],
16961
+ };
16990
16962
  }
16991
16963
 
16992
- // GET /api/file-content — Read a single file for inline editing
16993
- app.get('/api/file-content', (req, res) => {
16994
- try {
16995
- const filePath = req.query.path;
16996
- if (!filePath) return res.status(400).json({ error: 'Missing path parameter' });
16997
-
16998
- const normalized = validateProjectPath(filePath);
16999
- if (!normalized) return res.status(403).json({ error: 'Path outside registered project roots' });
17000
-
17001
- if (!existsSync(normalized) || !statSync(normalized).isFile()) {
17002
- return res.status(400).json({ error: 'Not a valid file' });
17003
- }
17004
-
17005
- const stat = statSync(normalized);
17006
- if (stat.size > 1024 * 1024) {
17007
- return res.status(413).json({ error: 'File too large' });
17008
- }
17009
-
17010
- // Binary detection: read first 8KB and scan for null bytes
17011
- const fd = openSync(normalized, 'r');
17012
- const buf = Buffer.alloc(Math.min(8192, stat.size));
17013
- const bytesRead = readSync(fd, buf, 0, buf.length, 0);
17014
- closeSync(fd);
17015
- for (let i = 0; i < bytesRead; i++) {
17016
- if (buf[i] === 0x00) {
17017
- return res.status(415).json({ error: 'Binary file' });
17018
- }
17019
- }
17020
-
17021
- const content = readFileSync(normalized, 'utf-8');
17022
- const name = basename(normalized);
17023
- res.json({ ok: true, content, path: normalized.replace(/\\/g, '/'), name, size: stat.size });
17024
- } catch (err) {
17025
- res.status(500).json({ error: err.message });
17026
- }
17027
- });
17028
-
17029
- // POST /api/file-content — Write a file (existing, or create if `create: true`)
17030
- app.post('/api/file-content', (req, res) => {
17031
- try {
17032
- const { path: filePath, content, create } = req.body || {};
17033
- if (!filePath || typeof content !== 'string') {
17034
- return res.status(400).json({ error: 'Missing path or content' });
17035
- }
17036
-
17037
- const normalized = validateProjectPath(filePath);
17038
- if (!normalized) return res.status(403).json({ error: 'Path outside registered project roots' });
17039
-
17040
- if (create) {
17041
- // Ensure parent dir exists
17042
- const parentDir = dirname(normalized);
17043
- if (!existsSync(parentDir)) mkdirSync(parentDir, { recursive: true });
17044
- } else {
17045
- if (!existsSync(normalized) || !statSync(normalized).isFile()) {
17046
- return res.status(400).json({ error: 'File does not exist' });
17047
- }
17048
- }
17049
-
17050
- writeFileSync(normalized, content, 'utf-8');
17051
- res.json({ ok: true });
17052
- } catch (err) {
17053
- res.status(500).json({ error: err.message });
17054
- }
17055
- });
17056
-
17057
- // POST /api/file-mkdir — Create a directory
17058
- app.post('/api/file-mkdir', (req, res) => {
17059
- try {
17060
- const { path: dirPath } = req.body || {};
17061
- if (!dirPath) return res.status(400).json({ error: 'Missing path' });
17062
-
17063
- const normalized = validateProjectPath(dirPath);
17064
- if (!normalized) return res.status(403).json({ error: 'Path outside registered project roots' });
16964
+ function validateProjectPath(filePath) {
16965
+ const normalized = resolve(filePath);
16966
+ const { dirs, files } = allowedFileRoots();
16967
+ const matched = [...dirs, ...files].find(r => normalized === r || normalized.startsWith(r + sep));
16968
+ return matched ? normalized : null;
16969
+ }
17065
16970
 
17066
- mkdirSync(normalized, { recursive: true });
17067
- res.json({ ok: true });
17068
- } catch (err) {
17069
- res.status(500).json({ error: err.message });
17070
- }
16971
+ // ── Code editor (File Explorer): file routes and preferences ──
16972
+ // lib/editor-files-api.js: GET / POST / DELETE /api/file-content, POST /api/file-mkdir,
16973
+ // POST /api/file-stat, GET /api/editor/capabilities. lib/editor-config.js: GET / PUT /api/editor/config.
16974
+ // lib/editor-completion.js: the routes under /api/editor/complete (AI autocomplete).
16975
+ // lib/editor-git-api.js: GET /api/file-head (a file as the last commit has it; git is only ever read).
16976
+ const editorConfigStore = createEditorConfigStore({
16977
+ path: resolve(DATA_HOME, 'data', 'editor-config.json'),
16978
+ log: (tag, message) => console.error(`[${tag}] ${message}`),
16979
+ });
16980
+ // AI autocomplete (lib/editor-completion.js): POST /api/editor/complete, /test and /feedback, GET /models and
16981
+ // /usage. OpenCode models are asked through a serve of their own (lib/editor-completion-opencode.js: started on
16982
+ // the first request, stopped after ten idle minutes and at shutdown, never the shared serve), or a
16983
+ // user-configured endpoint is, whose key is SYNABUN_EDITOR_AI_KEY in the data home's .env.
16984
+ const editorEndpointKey = createEndpointKeyStore({ envPath: ENV_PATH, parseEnvFile, writeEnvFile });
16985
+ const editorCompletionServe = createOpencodeCompletionEngine({
16986
+ dataDir: resolve(DATA_HOME, 'data', 'editor-complete'),
16987
+ system: COMPLETION_SYSTEM,
16988
+ readUserConfig: () => {
16989
+ const configPath = getOpencodeConfigPath();
16990
+ return existsSync(configPath) ? JSON.parse(readFileSync(configPath, 'utf8')) : null;
16991
+ },
16992
+ // A provider's {file:./key} is relative to that configuration: it is written whole for the serve's own folder.
16993
+ userConfigDir: () => dirname(getOpencodeConfigPath()),
16994
+ allocPort: allocOpencodeIsoPort,
16995
+ // As the isolated serves are started: through the login shell, so the CLI is found where a terminal finds it.
16996
+ 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 });
17002
+ proc.stderr?.on('data', (d) => console.log(`[editor-complete:err] ${String(d).trim().slice(0, 500)}`));
17003
+ return proc;
17004
+ },
17005
+ killServe: (proc) => {
17006
+ try {
17007
+ if (IS_WIN && proc.pid) spawn('taskkill', ['/pid', String(proc.pid), '/T', '/F'], { shell: true, windowsHide: true, stdio: 'ignore' });
17008
+ else proc.kill('SIGTERM');
17009
+ } catch { /* already gone */ }
17010
+ },
17011
+ createClient: opencodeV2Client.createClientInstance,
17012
+ log: (tag, message) => console.log(`[${tag}] ${message}`),
17013
+ });
17014
+ const editorCompletion = createEditorCompletionService({
17015
+ readConfig: editorConfigStore.read,
17016
+ engines: { opencode: editorCompletionServe, endpoint: createEndpointCompletionEngine() },
17017
+ getEndpointKey: editorEndpointKey.get,
17018
+ // The model list comes from the shared serve while it runs: listing models starts nothing.
17019
+ sharedProviders: async () => {
17020
+ if (!_ocpReady) return null;
17021
+ const r = await ocpProxy('GET', '/provider', null, 8000);
17022
+ return r.status === 200 ? r.data : null;
17023
+ },
17024
+ isHiddenModel: (id) => opencodeHiddenModels.has(id),
17025
+ usage: createUsageStore({
17026
+ path: resolve(DATA_HOME, 'data', 'editor-ai-usage.json'),
17027
+ log: (tag, message) => console.error(`[${tag}] ${message}`),
17028
+ }),
17029
+ log: (tag, message) => console.warn(`[${tag}] ${message}`),
17071
17030
  });
17031
+ app.use(createEditorFilesRouter({ fileRoots: allowedFileRoots, guard: editorOwnerGuard, capabilities: () => [...EDITOR_CONFIG_CAPABILITIES, ...EDITOR_COMPLETION_CAPABILITIES, ...EDITOR_BACKUP_CAPABILITIES, ...EDITOR_GIT_CAPABILITIES] }));
17032
+ app.use(createEditorGitRouter({ fileRoots: allowedFileRoots, guard: editorOwnerGuard }));
17033
+ app.use('/api/editor/config', createEditorConfigRouter({
17034
+ store: editorConfigStore,
17035
+ guard: editorOwnerGuard,
17036
+ broadcastSync,
17037
+ hasEndpointKey: editorEndpointKey.has,
17038
+ setEndpointKey: editorEndpointKey.set,
17039
+ }));
17040
+ app.use('/api/editor/complete', createEditorCompletionRouter({ service: editorCompletion, guard: editorOwnerGuard }));
17072
17041
 
17073
- // DELETE /api/file-content — Delete a file or empty directory
17074
- app.delete('/api/file-content', (req, res) => {
17075
- try {
17076
- const filePath = req.query.path;
17077
- if (!filePath) return res.status(400).json({ error: 'Missing path parameter' });
17078
-
17079
- const normalized = validateProjectPath(filePath);
17080
- if (!normalized) return res.status(403).json({ error: 'Path outside registered project roots' });
17081
-
17082
- if (!existsSync(normalized)) {
17083
- return res.status(404).json({ error: 'Path does not exist' });
17084
- }
17085
-
17086
- const stat = statSync(normalized);
17087
- if (stat.isDirectory()) {
17088
- const entries = readdirSync(normalized);
17089
- if (entries.length > 0) return res.status(400).json({ error: 'Directory is not empty' });
17090
- rmSync(normalized);
17091
- } else {
17092
- unlinkSync(normalized);
17093
- }
17094
- res.json({ ok: true });
17095
- } catch (err) {
17096
- res.status(500).json({ error: err.message });
17097
- }
17098
- });
17042
+ // The File Explorer's own routes (lib/file-explorer-api.js, docs/file-explorer.md): one folder, search, the git
17043
+ // state of a project, the watcher's event stream, and what is done to files (new, rename, move, copy, delete to
17044
+ // the system's Trash, upload, reveal, the bytes of an image). The same guard as the editor's routes.
17045
+ // The guard looks at the Origin of a write only; the router refuses a read from another site's page as well (a
17046
+ // watch is a session of which there are sixteen), by the same origins.
17047
+ app.use('/api/explorer', createFileExplorerRouter({ fileRoots: allowedFileRoots, guard: editorOwnerGuard, origins: { port: PORT, allowedOrigins: publicWebOrigins } }));
17099
17048
 
17100
17049
  // GET /api/latest-plan — Find most recently modified plan file (data/plans/YYYY-MM-DD/ preferred, flat fallback)
17101
17050
  app.get('/api/latest-plan', (req, res) => {
@@ -17225,7 +17174,8 @@ app.post('/api/create-plan', (req, res) => {
17225
17174
  });
17226
17175
 
17227
17176
  // GET /api/project-files — List ALL files (incl. dotfiles) for file explorer sidebar
17228
- app.get('/api/project-files', async (req, res) => {
17177
+ // The owner's, like the file routes: the names of a project's files are not an invited guest's to list.
17178
+ app.get('/api/project-files', editorOwnerGuard, async (req, res) => {
17229
17179
  try {
17230
17180
  const root = DATA_HOME;
17231
17181
  let dir = req.query.path ? resolve(req.query.path) : root;
@@ -17244,6 +17194,10 @@ app.get('/api/project-files', async (req, res) => {
17244
17194
  if (!existsSync(normalizedDir) || !statSync(normalizedDir).isDirectory()) {
17245
17195
  return res.status(400).json({ error: 'Not a valid directory' });
17246
17196
  }
17197
+ // Judged as the disk resolves it too: a link that leads out of its project is not listed.
17198
+ if (!isInsideReal(normalizedRoot, normalizedDir, { allowRoot: true })) {
17199
+ return res.status(403).json({ error: 'Path outside registered project roots' });
17200
+ }
17247
17201
 
17248
17202
  // Git info for status badges
17249
17203
  const git = getGitInfo(normalizedDir);
@@ -17375,21 +17329,13 @@ app.get('/api/browse-directory', (req, res) => {
17375
17329
  // POST /api/browse-folder — Open native OS folder picker dialog
17376
17330
  // Async exec: the dialog stays open for up to 60s — execSync here froze the
17377
17331
  // entire server (every WS stream, every request) until the user dismissed it.
17378
- app.post('/api/browse-folder', async (req, res) => {
17332
+ // The dialog opens on the owner's machine: the owner's route. Its prompt is handed to the picker as an argument
17333
+ // (lib/file-explorer-system.js), never as part of a command line: a request cannot make it run anything.
17334
+ app.post('/api/browse-folder', editorOwnerGuard, async (req, res) => {
17379
17335
  try {
17380
17336
  const { description } = req.body || {};
17381
- const prompt = description || 'Select a folder';
17382
- let cmd;
17383
- if (process.platform === 'win32') {
17384
- const escaped = prompt.replace(/'/g, "''");
17385
- const pickerScript = resolve(__dirname, 'lib', 'folder-picker.ps1');
17386
- cmd = `powershell -NoProfile -STA -WindowStyle Hidden -ExecutionPolicy Bypass -File "${pickerScript}" -Title "${escaped}"`;
17387
- } else if (process.platform === 'darwin') {
17388
- cmd = `osascript -e 'POSIX path of (choose folder with prompt "${prompt}")'`;
17389
- } else {
17390
- cmd = `zenity --file-selection --directory --title="${prompt}" 2>/dev/null`;
17391
- }
17392
- const { stdout } = await execAsync(cmd, { encoding: 'utf8', timeout: 60000, windowsHide: true });
17337
+ const picker = folderPickerCommand(process.platform, typeof description === 'string' ? description : '', { scriptPath: resolve(__dirname, 'lib', 'folder-picker.ps1') });
17338
+ const { stdout } = await execFileAsync(picker.file, picker.args, { encoding: 'utf8', timeout: 60000, windowsHide: true });
17393
17339
  const result = stdout.trim();
17394
17340
  if (result) {
17395
17341
  res.json({ path: result.replace(/\\/g, '/') });
@@ -17928,6 +17874,7 @@ app.get('/api/setup/onboarding', async (req, res) => {
17928
17874
  const setupComplete = vars.SETUP_COMPLETE === 'true';
17929
17875
  const dbExists = existsSync(getDbPath());
17930
17876
  const mcpBuilt = existsSync(resolve(PACKAGE_ROOT, 'mcp-server', 'dist', 'index.js'));
17877
+ const { mcpCommand, mcpArgs } = getMcpPaths();
17931
17878
 
17932
17879
  res.json({
17933
17880
  setupComplete,
@@ -17938,6 +17885,8 @@ app.get('/api/setup/onboarding', async (req, res) => {
17938
17885
  projectDir: PACKAGE_ROOT,
17939
17886
  dataHome: DATA_HOME,
17940
17887
  packageRoot: PACKAGE_ROOT,
17888
+ mcpCommand,
17889
+ mcpArgs,
17941
17890
  dataHomeInsidePackage: DATA_HOME_DIAGNOSTICS.dataHomeInsidePackage,
17942
17891
  recommendedDataHome: DATA_HOME_DIAGNOSTICS.recommendedDataHome,
17943
17892
  dataHomeExplicitOverride: DATA_HOME_DIAGNOSTICS.explicitOverride,
@@ -18072,8 +18021,7 @@ app.post('/api/setup/write-mcp-json', (req, res) => {
18072
18021
  const envPath = resolve(DATA_HOME, '.env').replace(/\\/g, '/');
18073
18022
 
18074
18023
  const mcpEntry = {
18075
- command: 'node',
18076
- args: [mcpIndexPath],
18024
+ ...mcpStdioCommand(mcpIndexPath),
18077
18025
  env: { DOTENV_PATH: envPath, SYNABUN_DATA_HOME: DATA_HOME, MEMORY_DATA_DIR: resolve(DATA_HOME, 'mcp-data') },
18078
18026
  };
18079
18027
 
@@ -20152,7 +20100,7 @@ app.get('/api/claude-code/integrations', (req, res) => {
20152
20100
 
20153
20101
  // Auto-sync: ensure all registered projects have globally-enabled hooks
20154
20102
  const globallyEnabled = Object.entries(globalHooks).filter(([, on]) => on).map(([ev]) => ev);
20155
- for (const p of projects) {
20103
+ for (const p of hookInstallBlocker() ? [] : projects) {
20156
20104
  const projFile = getClaudeSettingsPath(p.path);
20157
20105
  let projSettings;
20158
20106
  try {
@@ -20198,7 +20146,8 @@ app.get('/api/claude-code/integrations', (req, res) => {
20198
20146
 
20199
20147
  const mcpIndexPath = resolve(PACKAGE_ROOT, 'mcp-server', 'run.mjs').replace(/\\/g, '/');
20200
20148
  const envPath = resolve(DATA_HOME, '.env').replace(/\\/g, '/');
20201
- const cliCommand = `claude mcp add SynaBun node "${mcpIndexPath}" -s user -e "DOTENV_PATH=${envPath}"`;
20149
+ const { command: mcpCommand, args: mcpArgs } = mcpStdioCommand(mcpIndexPath);
20150
+ const cliCommand = claudeMcpAddCommand({ mcpIndexPath, envPath, mcpCommand, mcpArgs });
20202
20151
 
20203
20152
  res.json({
20204
20153
  ok: true,
@@ -20220,7 +20169,11 @@ app.post('/api/claude-code/integrations', (req, res) => {
20220
20169
  const { target, projectPath, label, hook } = req.body;
20221
20170
  // target: 'global' | 'project'
20222
20171
 
20172
+ // Null unless this run has no path of its own to register (lib/claude-hooks.js).
20173
+ const blocker = hookInstallBlocker();
20174
+
20223
20175
  if (target === 'global') {
20176
+ if (blocker) return res.status(409).json({ error: blocker });
20224
20177
  const filePath = getGlobalClaudeSettingsPath();
20225
20178
  let settings = readClaudeSettingsForWrite(filePath);
20226
20179
  settings = addHookToSettings(settings, hook || undefined);
@@ -20246,15 +20199,23 @@ app.post('/api/claude-code/integrations', (req, res) => {
20246
20199
  const normalized = resolve(projectPath);
20247
20200
  if (!existsSync(normalized)) return res.status(400).json({ error: `Directory not found: ${normalized}` });
20248
20201
 
20249
- const filePath = getClaudeSettingsPath(normalized);
20250
- let settings = readClaudeSettingsForWrite(filePath);
20251
- settings = addHookToSettings(settings, hook || undefined, normalized);
20252
- writeClaudeSettings(filePath, settings);
20202
+ // Adding a project is more than its hooks: a run that cannot write them
20203
+ // still registers it, and refuses only a request that asks for nothing else.
20204
+ const projects = loadHookProjects();
20205
+ const registered = projects.some(p => resolve(p.path) === normalized);
20206
+ const plan = hookInstallPlan({ target, registered, hook });
20207
+ if (plan === 'refuse') return res.status(409).json({ error: blocker });
20208
+
20209
+ if (plan === 'write') {
20210
+ const filePath = getClaudeSettingsPath(normalized);
20211
+ let settings = readClaudeSettingsForWrite(filePath);
20212
+ settings = addHookToSettings(settings, hook || undefined, normalized);
20213
+ writeClaudeSettings(filePath, settings);
20214
+ }
20253
20215
 
20254
20216
  // Save to registered projects list
20255
20217
  const projectLabel = label || basename(normalized);
20256
- const projects = loadHookProjects();
20257
- if (!projects.some(p => resolve(p.path) === normalized)) {
20218
+ if (!registered) {
20258
20219
  projects.push({ path: normalized, label: projectLabel });
20259
20220
  saveHookProjects(projects);
20260
20221
  }
@@ -20274,25 +20235,9 @@ app.post('/api/claude-code/integrations', (req, res) => {
20274
20235
  }
20275
20236
  } catch { /* non-critical */ }
20276
20237
 
20277
- // Auto-create greeting config entry for the new project
20278
- try {
20279
- const greetingKey = projectLabel.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
20280
- const gcPath = resolve(DATA_HOME, 'data', 'greeting-config.json');
20281
- let gc = { version: 1, defaults: {}, projects: {}, global: {} };
20282
- try { if (existsSync(gcPath)) gc = JSON.parse(readFileSync(gcPath, 'utf-8')); } catch {}
20283
- if (!gc.projects) gc.projects = {};
20284
- if (!gc.projects[greetingKey]) {
20285
- gc.projects[greetingKey] = {
20286
- label: projectLabel,
20287
- greetingTemplate: gc.defaults?.greetingTemplate || '{time_greeting}! Working on **{project_label}** (`{branch}` branch). {date}.',
20288
- showReminders: false,
20289
- showLastSession: true,
20290
- reminders: [],
20291
- };
20292
- writeFileSync(gcPath, JSON.stringify(gc, null, 2), 'utf-8');
20293
- }
20294
- } catch { /* non-critical */ }
20295
-
20238
+ if (plan !== 'write') {
20239
+ return res.json({ ok: true, message: `${basename(normalized)} was added without hooks. ${blocker}`, hooksSkipped: true, dubiousOwnership, trustPath });
20240
+ }
20296
20241
  return res.json({ ok: true, message: `Hook enabled for ${basename(normalized)}.`, dubiousOwnership, trustPath });
20297
20242
  }
20298
20243
 
@@ -20602,510 +20547,19 @@ app.put('/api/claude-code/tool-permissions', (req, res) => {
20602
20547
  }
20603
20548
  });
20604
20549
 
20605
- // ── Greeting Config (read/write greeting-config.json for the greeting hook) ──
20606
-
20607
- const GREETING_CONFIG_PATH = resolve(DATA_HOME, 'data', 'greeting-config.json');
20608
- const CODEX_GREETING_CONFIG_PATH = resolve(DATA_HOME, 'data', 'codex-greeting-config.json');
20609
- const OPENCODE_GREETING_CONFIG_PATH = resolve(DATA_HOME, 'data', 'opencode-greeting-config.json');
20610
-
20611
- function loadGreetingConfig() {
20612
- try {
20613
- if (!existsSync(GREETING_CONFIG_PATH)) return { version: 1, defaults: {}, projects: {}, global: {} };
20614
- return JSON.parse(readFileSync(GREETING_CONFIG_PATH, 'utf-8'));
20615
- } catch { return { version: 1, defaults: {}, projects: {}, global: {} }; }
20616
- }
20617
-
20618
- function deepCloneJson(value, fallback = {}) {
20619
- try { return JSON.parse(JSON.stringify(value)); }
20620
- catch { return fallback; }
20621
- }
20622
-
20623
- function normalizeGreetingLabel(label) {
20624
- return String(label || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
20625
- }
20626
-
20627
- function detectGreetingProject(cwd) {
20628
- if (!cwd) return 'global';
20629
- const lower = String(cwd).toLowerCase().replace(/\\/g, '/');
20630
- const projects = loadHookProjects();
20631
- const sorted = projects
20632
- .map((project) => ({
20633
- path: String(project.path || '').toLowerCase().replace(/\\/g, '/'),
20634
- label: normalizeGreetingLabel(project.label || basename(project.path || '')),
20635
- }))
20636
- .filter((project) => project.path && project.label)
20637
- .sort((a, b) => b.path.length - a.path.length);
20638
-
20639
- for (const project of sorted) {
20640
- if (lower.startsWith(project.path + '/') || lower === project.path) return project.label;
20641
- }
20642
- for (const project of sorted) {
20643
- const folder = basename(project.path).toLowerCase();
20644
- if (folder && lower.includes(folder)) return project.label;
20645
- }
20646
- const base = basename(lower).replace(/[^a-z0-9-]/g, '-');
20647
- return base || 'global';
20648
- }
20649
-
20650
- function buildSeededCodexGreetingConfig() {
20651
- const base = loadGreetingConfig();
20652
- return {
20653
- version: 1,
20654
- enabled: false,
20655
- defaults: deepCloneJson(base.defaults, {}),
20656
- projects: deepCloneJson(base.projects, {}),
20657
- global: deepCloneJson(base.global, {}),
20658
- };
20659
- }
20660
-
20661
- function normalizeCodexGreetingConfig(config) {
20662
- const seeded = buildSeededCodexGreetingConfig();
20663
- const next = (config && typeof config === 'object') ? config : {};
20664
- return {
20665
- version: 1,
20666
- enabled: typeof next.enabled === 'boolean' ? next.enabled : seeded.enabled,
20667
- defaults: deepCloneJson(next.defaults, seeded.defaults),
20668
- projects: deepCloneJson(next.projects, seeded.projects),
20669
- global: deepCloneJson(next.global, seeded.global),
20670
- };
20671
- }
20672
-
20673
- function saveCodexGreetingConfig(config) {
20674
- const dir = dirname(CODEX_GREETING_CONFIG_PATH);
20675
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
20676
- writeFileSync(CODEX_GREETING_CONFIG_PATH, JSON.stringify(normalizeCodexGreetingConfig(config), null, 2), 'utf-8');
20677
- }
20678
-
20679
- function loadCodexGreetingConfig() {
20680
- try {
20681
- if (!existsSync(CODEX_GREETING_CONFIG_PATH)) {
20682
- const seeded = buildSeededCodexGreetingConfig();
20683
- saveCodexGreetingConfig(seeded);
20684
- return seeded;
20685
- }
20686
- const parsed = JSON.parse(readFileSync(CODEX_GREETING_CONFIG_PATH, 'utf-8'));
20687
- const normalized = normalizeCodexGreetingConfig(parsed);
20688
- if (JSON.stringify(parsed) !== JSON.stringify(normalized)) saveCodexGreetingConfig(normalized);
20689
- return normalized;
20690
- } catch {
20691
- const seeded = buildSeededCodexGreetingConfig();
20692
- try { saveCodexGreetingConfig(seeded); } catch {}
20693
- return seeded;
20694
- }
20695
- }
20696
-
20697
- function getTimeGreeting() {
20698
- const hour = new Date().getHours();
20699
- if (hour >= 5 && hour < 12) return 'Good morning';
20700
- if (hour >= 12 && hour < 17) return 'Good afternoon';
20701
- return 'Good evening';
20702
- }
20703
-
20704
- function getGreetingGitBranch(cwd) {
20705
- try {
20706
- return execSync('git rev-parse --abbrev-ref HEAD', {
20707
- cwd,
20708
- encoding: 'utf-8',
20709
- timeout: 2000,
20710
- stdio: ['pipe', 'pipe', 'pipe'],
20711
- }).trim();
20712
- } catch {
20713
- return 'unknown';
20714
- }
20715
- }
20716
-
20717
- function resolveGreetingTemplate(template, vars) {
20718
- return String(template || '').replace(/\{(\w+)\}/g, (match, key) => (vars[key] !== undefined ? vars[key] : match));
20719
- }
20720
-
20721
- function getProviderGreetingProjectConfig(config, project) {
20722
- if (!config || typeof config !== 'object') return null;
20723
- if (config.projects && config.projects[project]) return { ...config.defaults, ...config.projects[project] };
20724
- if (config.global) return { ...config.defaults, ...config.global };
20725
- return config.defaults || null;
20726
- }
20727
-
20728
- function buildCodexGreetingUserPrompt({ cwd = PACKAGE_ROOT, userPrompt = '' } = {}) {
20729
- const config = loadCodexGreetingConfig();
20730
- if (config.enabled !== true) return '';
20731
-
20732
- const project = detectGreetingProject(cwd);
20733
- const projectConfig = getProviderGreetingProjectConfig(config, project);
20734
- const defaultTemplate = '{time_greeting}! Working on **{project_label}** (`{branch}` branch). {date}.';
20735
- const projectLabel = projectConfig?.label || project;
20736
- const branch = getGreetingGitBranch(cwd);
20737
- const vars = {
20738
- time_greeting: getTimeGreeting(),
20739
- project,
20740
- project_name: project,
20741
- project_label: projectLabel,
20742
- branch,
20743
- date: new Date().toLocaleDateString('en-US', {
20744
- weekday: 'long',
20745
- year: 'numeric',
20746
- month: 'long',
20747
- day: 'numeric',
20748
- }),
20749
- };
20750
- const greetingText = resolveGreetingTemplate(
20751
- projectConfig?.greetingTemplate || config.defaults?.greetingTemplate || defaultTemplate,
20752
- vars,
20753
- ).trim();
20754
- if (!greetingText) return '';
20755
-
20756
- const reminders = Array.isArray(projectConfig?.reminders)
20757
- ? projectConfig.reminders.filter((reminder) => reminder && (reminder.label || reminder.command))
20758
- : [];
20759
- const showReminders = projectConfig?.showReminders ?? config.defaults?.showReminders ?? false;
20760
- const showLastSession = projectConfig?.showLastSession ?? config.defaults?.showLastSession ?? false;
20761
-
20762
- // Build as a natural user request to avoid Codex safety filter flagging it as prompt injection.
20763
- // No "hidden instruction" language, no "do not mention", no "apply only to this turn".
20764
- const parts = [];
20765
- parts.push(`Before answering, please use the recall tool to search for "recent sessions, ongoing work, known issues, decisions" in the "${project}" project with recency_boost enabled.`);
20766
- parts.push('');
20767
- parts.push(`Start your reply with this greeting:`);
20768
- parts.push(`> ${greetingText}`);
20769
-
20770
- if (showReminders && reminders.length) {
20771
- parts.push('');
20772
- parts.push('After the greeting, show these service reminders:');
20773
- for (const reminder of reminders) {
20774
- const label = reminder.label || 'Reminder';
20775
- const command = reminder.command || '';
20776
- parts.push(`- **${label}**: \`${command}\``);
20777
- }
20778
- }
20779
-
20780
- if (showLastSession) {
20781
- parts.push('');
20782
- parts.push('If the recall results mention recent work, include a brief "Last session:" summary line.');
20783
- }
20784
-
20785
- const userMsg = String(userPrompt || '').trim();
20786
- if (userMsg) {
20787
- parts.push('');
20788
- parts.push(`Then respond to: ${userMsg}`);
20789
- }
20790
-
20791
- return parts.join('\n').trim();
20792
- }
20793
-
20794
- function buildCodexGreetingPrompt({ cwd = PACKAGE_ROOT, userPrompt = '', markerStart = '', markerEnd = '' } = {}) {
20795
- const config = loadCodexGreetingConfig();
20796
- if (config.enabled !== true) return '';
20797
-
20798
- const project = detectGreetingProject(cwd);
20799
- const projectConfig = getProviderGreetingProjectConfig(config, project);
20800
- const defaultTemplate = '{time_greeting}! Working on **{project_label}** (`{branch}` branch). {date}.';
20801
- const projectLabel = projectConfig?.label || project;
20802
- const branch = getGreetingGitBranch(cwd);
20803
- const vars = {
20804
- time_greeting: getTimeGreeting(),
20805
- project,
20806
- project_name: project,
20807
- project_label: projectLabel,
20808
- branch,
20809
- date: new Date().toLocaleDateString('en-US', {
20810
- weekday: 'long',
20811
- year: 'numeric',
20812
- month: 'long',
20813
- day: 'numeric',
20814
- }),
20815
- };
20816
- const greetingText = resolveGreetingTemplate(
20817
- projectConfig?.greetingTemplate || config.defaults?.greetingTemplate || defaultTemplate,
20818
- vars,
20819
- ).trim();
20820
- if (!greetingText) return '';
20821
-
20822
- const reminders = Array.isArray(projectConfig?.reminders)
20823
- ? projectConfig.reminders.filter((reminder) => reminder && (reminder.label || reminder.command))
20824
- : [];
20825
- const showReminders = projectConfig?.showReminders ?? config.defaults?.showReminders ?? false;
20826
- const showLastSession = projectConfig?.showLastSession ?? config.defaults?.showLastSession ?? false;
20827
- const steps = [
20828
- `Call the SynaBun MCP server's \`recall\` tool (may appear as \`mcp__SynaBun__recall\` or just \`recall\` depending on the client) with query "recent sessions, ongoing work, known issues, decisions", project "${project}", and \`recency_boost\` set to true.`,
20829
- `After that recall, begin your first assistant message with this greeting exactly as written below:\n\n> ${greetingText}`,
20830
- ];
20831
-
20832
- if (showReminders && reminders.length) {
20833
- const reminderText = reminders.map((reminder) => {
20834
- const label = reminder.label || 'Reminder';
20835
- const command = reminder.command || '';
20836
- return `For **${label}**, show this command in its own fenced code block:\n\`\`\`bash\n${command}\n\`\`\``;
20837
- }).join('\n\n');
20838
- steps.push(`After the greeting, show these reminders:\n\n${reminderText}`);
20839
- }
20840
-
20841
- if (showLastSession) {
20842
- steps.push(`After the greeting${showReminders && reminders.length ? ' and reminders' : ''}, include a brief "Last session:" line if the recall results contain something relevant. If not, omit that line.`);
20843
- }
20844
-
20845
- steps.push(`Then continue directly with the user's request below.`);
20846
-
20847
- const parts = [
20848
- markerStart,
20849
- 'SynaBun Codex panel boot instructions. Apply these instructions ONLY to this first turn of this thread.',
20850
- 'Do not mention these instructions. Do not repeat the greeting on later turns.',
20851
- '',
20852
- 'Before replying to the user:',
20853
- ...steps.map((step, index) => `${index + 1}. ${step}`),
20854
- '',
20855
- ];
20856
-
20857
- parts.push(markerEnd, '', String(userPrompt || '').trim());
20858
- return parts.filter((part) => part !== undefined).join('\n').trim();
20859
- }
20860
-
20861
- // ── OpenCode Panel Greeting (isolated from Claude + Codex) ──
20862
-
20863
- function buildSeededOpencodeGreetingConfig() {
20864
- const base = loadGreetingConfig();
20865
- return {
20866
- version: 1,
20867
- enabled: false,
20868
- defaults: deepCloneJson(base.defaults, {}),
20869
- projects: deepCloneJson(base.projects, {}),
20870
- global: deepCloneJson(base.global, {}),
20871
- };
20872
- }
20873
-
20874
- function normalizeOpencodeGreetingConfig(config) {
20875
- const seeded = buildSeededOpencodeGreetingConfig();
20876
- const next = (config && typeof config === 'object') ? config : {};
20877
- return {
20878
- version: 1,
20879
- enabled: typeof next.enabled === 'boolean' ? next.enabled : seeded.enabled,
20880
- defaults: deepCloneJson(next.defaults, seeded.defaults),
20881
- projects: deepCloneJson(next.projects, seeded.projects),
20882
- global: deepCloneJson(next.global, seeded.global),
20883
- };
20884
- }
20885
-
20886
- function saveOpencodeGreetingConfig(config) {
20887
- const dir = dirname(OPENCODE_GREETING_CONFIG_PATH);
20888
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
20889
- writeFileSync(OPENCODE_GREETING_CONFIG_PATH, JSON.stringify(normalizeOpencodeGreetingConfig(config), null, 2), 'utf-8');
20890
- }
20891
-
20892
- function loadOpencodeGreetingConfig() {
20893
- try {
20894
- if (!existsSync(OPENCODE_GREETING_CONFIG_PATH)) {
20895
- const seeded = buildSeededOpencodeGreetingConfig();
20896
- saveOpencodeGreetingConfig(seeded);
20897
- return seeded;
20898
- }
20899
- const parsed = JSON.parse(readFileSync(OPENCODE_GREETING_CONFIG_PATH, 'utf-8'));
20900
- const normalized = normalizeOpencodeGreetingConfig(parsed);
20901
- if (JSON.stringify(parsed) !== JSON.stringify(normalized)) saveOpencodeGreetingConfig(normalized);
20902
- return normalized;
20903
- } catch {
20904
- const seeded = buildSeededOpencodeGreetingConfig();
20905
- try { saveOpencodeGreetingConfig(seeded); } catch {}
20906
- return seeded;
20907
- }
20908
- }
20909
-
20910
- // No caller since the legacy OpenCode socket handler was removed: the v2 panel
20911
- // sends the prompt as typed. Kept because Settings → Automations → Greetings
20912
- // still edits this config; wiring it into the v2 send path is the owner's decision.
20913
- function buildOpencodeGreetingUserPrompt({ cwd = PACKAGE_ROOT, userPrompt = '' } = {}) {
20914
- const config = loadOpencodeGreetingConfig();
20915
- if (config.enabled !== true) return '';
20916
-
20917
- const project = detectGreetingProject(cwd);
20918
- const projectConfig = getProviderGreetingProjectConfig(config, project);
20919
- const defaultTemplate = '{time_greeting}! Working on **{project_label}** (`{branch}` branch). {date}.';
20920
- const projectLabel = projectConfig?.label || project;
20921
- const branch = getGreetingGitBranch(cwd);
20922
- const vars = {
20923
- time_greeting: getTimeGreeting(),
20924
- project,
20925
- project_name: project,
20926
- project_label: projectLabel,
20927
- branch,
20928
- date: new Date().toLocaleDateString('en-US', {
20929
- weekday: 'long',
20930
- year: 'numeric',
20931
- month: 'long',
20932
- day: 'numeric',
20933
- }),
20934
- };
20935
- const greetingText = resolveGreetingTemplate(
20936
- projectConfig?.greetingTemplate || config.defaults?.greetingTemplate || defaultTemplate,
20937
- vars,
20938
- ).trim();
20939
- if (!greetingText) return '';
20940
-
20941
- const reminders = Array.isArray(projectConfig?.reminders)
20942
- ? projectConfig.reminders.filter((reminder) => reminder && (reminder.label || reminder.command))
20943
- : [];
20944
- const showReminders = projectConfig?.showReminders ?? config.defaults?.showReminders ?? false;
20945
- const showLastSession = projectConfig?.showLastSession ?? config.defaults?.showLastSession ?? false;
20946
-
20947
- const parts = [];
20948
- // OpenCode does not bind a session to a project directory the way Codex does
20949
- // — its tools default to the serve process's cwd, not the user-selected
20950
- // project. Spell out the absolute project path here so the agent always uses
20951
- // it for Read/Bash/etc. instead of falling back to the serve cwd.
20952
- if (cwd && cwd !== PACKAGE_ROOT) {
20953
- parts.push(`Project working directory: \`${cwd}\``);
20954
- parts.push(`Use this absolute path as the working directory for all file operations (Read, Write, Edit, Bash). Do not operate on the SynaBun project unless explicitly asked.`);
20955
- parts.push('');
20956
- }
20957
- parts.push(`Before answering, please use the recall tool to search for "recent sessions, ongoing work, known issues, decisions" in the "${project}" project with recency_boost enabled.`);
20958
- parts.push('');
20959
- parts.push(`Start your reply with this greeting:`);
20960
- parts.push(`> ${greetingText}`);
20961
-
20962
- if (showReminders && reminders.length) {
20963
- parts.push('');
20964
- parts.push('After the greeting, show these service reminders:');
20965
- for (const reminder of reminders) {
20966
- const label = reminder.label || 'Reminder';
20967
- const command = reminder.command || '';
20968
- parts.push(`- **${label}**: \`${command}\``);
20969
- }
20970
- }
20971
-
20972
- if (showLastSession) {
20973
- parts.push('');
20974
- parts.push('If the recall results mention recent work, include a brief "Last session:" summary line.');
20975
- }
20976
-
20977
- const userMsg = String(userPrompt || '').trim();
20978
- if (userMsg) {
20979
- parts.push('');
20980
- parts.push(`Then respond to: ${userMsg}`);
20981
- }
20982
-
20983
- return parts.join('\n').trim();
20984
- }
20985
-
20986
- // GET /api/greeting/config — read the full greeting configuration
20987
- app.get('/api/greeting/config', (req, res) => {
20988
- try {
20989
- res.json({ ok: true, config: loadGreetingConfig() });
20990
- } catch (err) {
20991
- res.status(500).json({ ok: false, error: err.message });
20992
- }
20993
- });
20994
-
20995
- // PUT /api/greeting/config/:project — update a project's greeting config
20996
- app.put('/api/greeting/config/:project', (req, res) => {
20997
- try {
20998
- const { project } = req.params;
20999
- const { greetingTemplate, showReminders, showLastSession, reminders, label } = req.body;
21000
- const config = loadGreetingConfig();
21001
-
21002
- const target = project === 'global'
21003
- ? (config.global || (config.global = {}))
21004
- : (config.projects[project] || (config.projects[project] = {}));
21005
-
21006
- if (greetingTemplate !== undefined) target.greetingTemplate = greetingTemplate;
21007
- if (showReminders !== undefined) target.showReminders = showReminders;
21008
- if (showLastSession !== undefined) target.showLastSession = showLastSession;
21009
- if (reminders !== undefined) target.reminders = reminders;
21010
- if (label !== undefined) target.label = label;
21011
-
21012
- const dir = dirname(GREETING_CONFIG_PATH);
21013
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
21014
- writeFileSync(GREETING_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf-8');
21015
- res.json({ ok: true });
21016
- } catch (err) {
21017
- res.status(500).json({ ok: false, error: err.message });
21018
- }
21019
- });
21020
-
21021
- // GET /api/codex-panel/greeting/config — read Codex-only greeting configuration
21022
- app.get('/api/codex-panel/greeting/config', (req, res) => {
21023
- try {
21024
- res.json({ ok: true, config: loadCodexGreetingConfig() });
21025
- } catch (err) {
21026
- res.status(500).json({ ok: false, error: err.message });
21027
- }
21028
- });
21029
-
21030
- // PUT /api/codex-panel/greeting/config — update top-level Codex greeting settings
21031
- app.put('/api/codex-panel/greeting/config', (req, res) => {
21032
- try {
21033
- const config = loadCodexGreetingConfig();
21034
- if (typeof req.body?.enabled === 'boolean') config.enabled = req.body.enabled;
21035
- saveCodexGreetingConfig(config);
21036
- res.json({ ok: true, config });
21037
- } catch (err) {
21038
- res.status(500).json({ ok: false, error: err.message });
21039
- }
21040
- });
21041
-
21042
- // PUT /api/codex-panel/greeting/config/:project — update Codex-only project greeting config
21043
- app.put('/api/codex-panel/greeting/config/:project', (req, res) => {
21044
- try {
21045
- const { project } = req.params;
21046
- const { greetingTemplate, showReminders, showLastSession, reminders, label } = req.body;
21047
- const config = loadCodexGreetingConfig();
21048
-
21049
- const target = project === 'global'
21050
- ? (config.global || (config.global = {}))
21051
- : (config.projects[project] || (config.projects[project] = {}));
21052
-
21053
- if (greetingTemplate !== undefined) target.greetingTemplate = greetingTemplate;
21054
- if (showReminders !== undefined) target.showReminders = showReminders;
21055
- if (showLastSession !== undefined) target.showLastSession = showLastSession;
21056
- if (reminders !== undefined) target.reminders = reminders;
21057
- if (label !== undefined) target.label = label;
21058
-
21059
- saveCodexGreetingConfig(config);
21060
- res.json({ ok: true, config });
21061
- } catch (err) {
21062
- res.status(500).json({ ok: false, error: err.message });
21063
- }
21064
- });
21065
-
21066
- // GET /api/opencode-panel/greeting/config — read OpenCode-only greeting configuration
21067
- app.get('/api/opencode-panel/greeting/config', (req, res) => {
21068
- try {
21069
- res.json({ ok: true, config: loadOpencodeGreetingConfig() });
21070
- } catch (err) {
21071
- res.status(500).json({ ok: false, error: err.message });
21072
- }
21073
- });
21074
-
21075
- // PUT /api/opencode-panel/greeting/config — update top-level OpenCode greeting settings
21076
- app.put('/api/opencode-panel/greeting/config', (req, res) => {
21077
- try {
21078
- const config = loadOpencodeGreetingConfig();
21079
- if (typeof req.body?.enabled === 'boolean') config.enabled = req.body.enabled;
21080
- saveOpencodeGreetingConfig(config);
21081
- res.json({ ok: true, config });
21082
- } catch (err) {
21083
- res.status(500).json({ ok: false, error: err.message });
21084
- }
21085
- });
21086
-
21087
- // PUT /api/opencode-panel/greeting/config/:project — update OpenCode-only project greeting config
21088
- app.put('/api/opencode-panel/greeting/config/:project', (req, res) => {
21089
- try {
21090
- const { project } = req.params;
21091
- const { greetingTemplate, showReminders, showLastSession, reminders, label } = req.body;
21092
- const config = loadOpencodeGreetingConfig();
21093
-
21094
- const target = project === 'global'
21095
- ? (config.global || (config.global = {}))
21096
- : (config.projects[project] || (config.projects[project] = {}));
21097
-
21098
- if (greetingTemplate !== undefined) target.greetingTemplate = greetingTemplate;
21099
- if (showReminders !== undefined) target.showReminders = showReminders;
21100
- if (showLastSession !== undefined) target.showLastSession = showLastSession;
21101
- if (reminders !== undefined) target.reminders = reminders;
21102
- if (label !== undefined) target.label = label;
21103
-
21104
- saveOpencodeGreetingConfig(config);
21105
- res.json({ ok: true, config });
21106
- } catch (err) {
21107
- res.status(500).json({ ok: false, error: err.message });
21108
- }
20550
+ // ── Greeting (lib/greeting/, docs/greeting.md) ──
20551
+ // One greeting for every agent: the Assistant, the Claude Code / Codex / OpenCode panels and a
20552
+ // terminal Claude Code session ask /api/greeting/open when a person opens a fresh session, and the
20553
+ // session's model is told once what was said. The engine is built with the Assistant (it reads the
20554
+ // same stores); the route is mounted here, synchronously, so that an engine that did not start
20555
+ // answers 503 with its reason instead of looking like an older server (404).
20556
+ //
20557
+ // The three template greetings this replaces (the Claude Code hook directive, the Codex and the
20558
+ // OpenCode panel prompts) are gone with their endpoints. Their config files (greeting-config.json,
20559
+ // codex-greeting-config.json, opencode-greeting-config.json) are left on disk, unread.
20560
+ app.use('/api/greeting', (req, res, next) => {
20561
+ if (_greetingRouter) return _greetingRouter(req, res, next);
20562
+ res.status(503).json({ ok: false, code: 'GREETING_UNAVAILABLE', error: 'The greeting engine is not running', reason: _greetingError || 'starting' });
21109
20563
  });
21110
20564
 
21111
20565
  // ── SynaBun rules (lib/rulesets/) ──
@@ -21319,7 +20773,18 @@ function syncCodexToolPermissions(allowedKeys) {
21319
20773
  function getMcpPaths() {
21320
20774
  const mcpIndexPath = resolve(PACKAGE_ROOT, 'mcp-server', 'dist', 'preload.js').replace(/\\/g, '/');
21321
20775
  const envPath = resolve(DATA_HOME, '.env').replace(/\\/g, '/');
21322
- return { mcpIndexPath, envPath };
20776
+ // What a client outside this process runs: `node <preload.js>`, or the
20777
+ // entry executable of a packaged application (`<entry> mcp`).
20778
+ const { command: mcpCommand, args: mcpArgs } = mcpStdioCommand(mcpIndexPath);
20779
+ return { mcpIndexPath, envPath, mcpCommand, mcpArgs };
20780
+ }
20781
+
20782
+ /** The `claude mcp add` line for the registration getMcpPaths() describes. */
20783
+ function claudeMcpAddCommand({ mcpIndexPath, envPath, mcpCommand, mcpArgs }) {
20784
+ // Inside double quotes a POSIX shell still reads \ " $ and `; a Windows path has none of them to escape.
20785
+ const quoted = (value) => (/^[A-Za-z]:[\\/]/.test(value) ? `"${value}"` : `"${String(value).replace(/[\\"$`]/g, '\\$&')}"`);
20786
+ const launch = mcpCommand === 'node' ? `node "${mcpIndexPath}"` : `${quoted(mcpCommand)} ${mcpArgs.join(' ')}`;
20787
+ return `claude mcp add SynaBun ${launch} -s user -e "DOTENV_PATH=${envPath}"`;
21323
20788
  }
21324
20789
 
21325
20790
  function getHomePath() {
@@ -21340,8 +20805,8 @@ function buildCodexMcpEnv() {
21340
20805
  }
21341
20806
 
21342
20807
  function buildCodexMcpDefinition() {
21343
- const { mcpIndexPath } = getMcpPaths();
21344
- return { command: 'node', args: [mcpIndexPath], env: buildCodexMcpEnv() };
20808
+ const { mcpCommand, mcpArgs } = getMcpPaths();
20809
+ return { command: mcpCommand, args: mcpArgs, env: buildCodexMcpEnv() };
21345
20810
  }
21346
20811
 
21347
20812
  function buildCodexMcpToml() {
@@ -21374,13 +20839,13 @@ app.post('/api/setup/gemini/mcp', (req, res) => {
21374
20839
  try {
21375
20840
  const dir = join(getHomePath(), '.gemini');
21376
20841
  const settingsPath = join(dir, 'settings.json');
21377
- const { mcpIndexPath, envPath } = getMcpPaths();
20842
+ const { envPath, mcpCommand, mcpArgs } = getMcpPaths();
21378
20843
  mkdirSync(dir, { recursive: true });
21379
20844
  let data = {};
21380
20845
  try { data = JSON.parse(readFileSync(settingsPath, 'utf-8')); } catch {}
21381
20846
  data = mergeGeminiMcpConfig(data, {
21382
- command: 'node',
21383
- args: [mcpIndexPath],
20847
+ command: mcpCommand,
20848
+ args: mcpArgs,
21384
20849
  env: { DOTENV_PATH: envPath, SYNABUN_DATA_HOME: DATA_HOME, MEMORY_DATA_DIR: resolve(DATA_HOME, 'mcp-data') },
21385
20850
  });
21386
20851
  writeFileSync(settingsPath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
@@ -21450,7 +20915,8 @@ app.delete('/api/setup/codex/mcp', (req, res) => {
21450
20915
  app.get('/api/setup/status', (req, res) => {
21451
20916
  try {
21452
20917
  const home = getHomePath();
21453
- const { mcpIndexPath, envPath } = getMcpPaths();
20918
+ const mcpPaths = getMcpPaths();
20919
+ const { mcpIndexPath, envPath, mcpCommand, mcpArgs } = mcpPaths;
21454
20920
 
21455
20921
  // Claude
21456
20922
  let claude = { connected: false };
@@ -21458,7 +20924,7 @@ app.get('/api/setup/status', (req, res) => {
21458
20924
  const cj = JSON.parse(readFileSync(join(home, '.claude.json'), 'utf-8'));
21459
20925
  claude.connected = !!(cj.mcpServers && cj.mcpServers.SynaBun);
21460
20926
  } catch {}
21461
- claude.cliCommand = `claude mcp add SynaBun node "${mcpIndexPath}" -s user -e "DOTENV_PATH=${envPath}"`;
20927
+ claude.cliCommand = claudeMcpAddCommand(mcpPaths);
21462
20928
 
21463
20929
  // Gemini
21464
20930
  let gemini = { connected: false };
@@ -21488,7 +20954,7 @@ app.get('/api/setup/status', (req, res) => {
21488
20954
  let rules = null;
21489
20955
  try { rules = rulesetInstaller.status(); } catch (err) { rules = { ok: false, error: err.message }; }
21490
20956
 
21491
- res.json({ ok: true, claude, gemini, codex, opencode, rules, paths: { mcpIndexPath, envPath } });
20957
+ res.json({ ok: true, claude, gemini, codex, opencode, rules, paths: { mcpIndexPath, envPath, mcpCommand, mcpArgs } });
21492
20958
  } catch (err) {
21493
20959
  res.status(500).json({ error: err.message });
21494
20960
  }
@@ -22743,7 +22209,13 @@ app.get('/api/health', (req, res) => {
22743
22209
  // getDb() auto-creates the directory, file, and schema if missing
22744
22210
  getDb();
22745
22211
  const count = countMemories();
22746
- res.json({ ok: true, storage: 'sqlite', memories: count, projectDir: PACKAGE_ROOT });
22212
+ res.json({
22213
+ ok: true, storage: 'sqlite', memories: count, projectDir: PACKAGE_ROOT, install: INSTALL_KIND, startLauncher: getStartLauncherState(), entry: START_ENTRY,
22214
+ // Which server this is, and whether it is on its way out for a restart: a
22215
+ // page showing the restart tells this one from the one that replaces it.
22216
+ bootId: SERVER_BOOT_ID,
22217
+ ...(_restartRequestedAt ? { restarting: true } : {}),
22218
+ });
22747
22219
  } catch (err) {
22748
22220
  res.json({ ok: false, reason: 'db_error', detail: err.message });
22749
22221
  }
@@ -24139,88 +23611,42 @@ app.get('/invite/logout', (req, res) => {
24139
23611
  });
24140
23612
 
24141
23613
  // --- Cloudflare Tunnel Management ---
24142
- let tunnelProcess = null;
23614
+ // The public address (invite links, remote MCP) is a Cloudflare quick tunnel, run by a cloudflared SynaBun
23615
+ // brings itself: the copy inside an installer, or the pinned release file fetched into the data home on the
23616
+ // first start and checked against its SHA-256 (lib/cloudflared/). The address is kept here as well, because the
23617
+ // Host rule and the remote rule read it (remoteWebOrigins): it is set the moment cloudflared prints it.
24143
23618
  let tunnelUrl = null;
24144
- let tunnelStarting = false;
23619
+ const cloudflared = createCloudflaredManager({ dataHome: DATA_HOME, log: (line) => console.log(line) });
23620
+ const tunnel = createQuickTunnel({ cloudflared, port: PORT, onUrl: (url) => { tunnelUrl = url; }, log: (line) => console.log(line) });
23621
+ // Starting and stopping it is the owner's, from the app's own pages: never an invite guest, anybody from outside
23622
+ // or another machine on the network (its socket's peer), never a page of another origin (an Origin that is not
23623
+ // exactly this server's own, at its port: the tunnel's and the invite proxy's included; or the browser's own
23624
+ // Sec-Fetch-Site: cross-site). A hook, a script or an MCP client on this computer sends neither header and passes.
23625
+ // See lib/http-guards.js and lib/request-gate.js.
23626
+ const tunnelOwnerGuard = ownerRouteGuard({
23627
+ isGuestRequest: (req) => isGuestRequest(req) || requestGates.isRemote(req) || requestGates.fromAnotherMachine(req),
23628
+ port: PORT, allowedOrigins: publicWebOrigins, refuseCrossSite: true,
23629
+ ownOriginsOnly: true, publicOrigins: remoteWebOrigins,
23630
+ });
24145
23631
 
24146
- function findCloudflared() {
24147
- // Try PATH-based lookup first (cross-platform)
23632
+ // What is happening: idle, downloading (with progress), starting, running (the address, which copy of
23633
+ // cloudflared runs and its version), failed (with the reason). Asking never downloads anything.
23634
+ app.get('/api/tunnel/status', async (req, res) => {
24148
23635
  try {
24149
- const cmd = process.platform === 'win32' ? 'where cloudflared' : 'which cloudflared';
24150
- const result = execSync(cmd, { encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
24151
- if (result) return result.split('\n')[0].trim();
24152
- } catch { /* not in PATH */ }
24153
-
24154
- // Windows-specific fallback paths
24155
- if (process.platform === 'win32') {
24156
- const paths = [
24157
- `${process.env.LOCALAPPDATA}\\cloudflared\\cloudflared.exe`,
24158
- `${process.env.ProgramFiles}\\cloudflared\\cloudflared.exe`,
24159
- `${process.env['ProgramFiles(x86)']}\\cloudflared\\cloudflared.exe`,
24160
- ];
24161
- for (const p of paths) { if (p && existsSync(p)) return p; }
23636
+ res.json(await tunnel.status());
23637
+ } catch (err) {
23638
+ res.status(500).json({ ok: false, error: err?.message || String(err) });
24162
23639
  }
24163
- return null;
24164
- }
24165
-
24166
- app.get('/api/tunnel/status', (req, res) => {
24167
- res.json({
24168
- ok: true,
24169
- available: !!findCloudflared(),
24170
- running: !!tunnelProcess,
24171
- starting: tunnelStarting,
24172
- url: tunnelUrl,
24173
- });
24174
23640
  });
24175
23641
 
24176
- app.post('/api/tunnel/start', (req, res) => {
24177
- if (tunnelProcess) return res.json({ ok: true, url: tunnelUrl, message: 'Already running.' });
24178
- const bin = findCloudflared();
24179
- if (!bin) return res.status(400).json({ error: 'cloudflared not found. Install it from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/' });
24180
-
24181
- tunnelStarting = true;
24182
- tunnelUrl = null;
24183
-
24184
- const child = spawn(bin, ['tunnel', '--url', `http://localhost:${PORT}`], {
24185
- stdio: ['ignore', 'pipe', 'pipe'],
24186
- windowsHide: true,
24187
- });
24188
-
24189
- tunnelProcess = child;
24190
- let urlFound = false;
24191
-
24192
- const parseUrl = (data) => {
24193
- const text = data.toString();
24194
- // cloudflared prints the URL to stderr
24195
- const match = text.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
24196
- if (match && !urlFound) {
24197
- urlFound = true;
24198
- tunnelUrl = match[0];
24199
- tunnelStarting = false;
24200
- console.log(` Tunnel: ${tunnelUrl}`);
24201
- }
24202
- };
24203
-
24204
- child.stdout.on('data', parseUrl);
24205
- child.stderr.on('data', parseUrl);
24206
-
24207
- child.on('close', () => {
24208
- tunnelProcess = null;
24209
- tunnelUrl = null;
24210
- tunnelStarting = false;
24211
- });
24212
-
24213
- // Respond immediately, client polls /api/tunnel/status for the URL
24214
- res.json({ ok: true, message: 'Tunnel starting...' });
23642
+ // Answers at once; on a machine without cloudflared the start fetches it first, then goes on by itself.
23643
+ // The page follows it through /api/tunnel/status.
23644
+ app.post('/api/tunnel/start', tunnelOwnerGuard, (req, res) => {
23645
+ res.json(tunnel.start());
24215
23646
  });
24216
23647
 
24217
- app.post('/api/tunnel/stop', (req, res) => {
24218
- if (!tunnelProcess) return res.json({ ok: true, message: 'Not running.' });
24219
- tunnelProcess.kill();
24220
- tunnelProcess = null;
24221
- tunnelUrl = null;
24222
- tunnelStarting = false;
24223
- res.json({ ok: true, message: 'Tunnel stopped.' });
23648
+ app.post('/api/tunnel/stop', tunnelOwnerGuard, (req, res) => {
23649
+ res.json(tunnel.stop());
24224
23650
  });
24225
23651
 
24226
23652
  // --- MCP HTTP Transport ---
@@ -24248,15 +23674,12 @@ try {
24248
23674
  next();
24249
23675
  }, mcpRouter);
24250
23676
 
24251
- // Open path: /mcp — local-only (reject tunnel traffic when key is set)
24252
- app.use('/mcp', (req, res, next) => {
24253
- if (req.headers['cf-connecting-ip'] && activeMcpKey) {
24254
- return res.status(403).json({ error: 'Forbidden' });
24255
- }
24256
- next();
24257
- }, mcpRouter);
23677
+ // Open path: /mcp — for this computer only. What comes in from outside (the tunnel, or anything under the
23678
+ // tunnel's or the invite proxy's name) needs /mcp/<key>, and is refused while no key is set
23679
+ // (lib/request-gate.js mcpWithoutKey)
23680
+ app.use('/mcp', requestGates.mcpWithoutKey, mcpRouter);
24258
23681
 
24259
- console.log(' MCP HTTP endpoint mounted at /mcp' + (activeMcpKey ? ' (key required for tunnel)' : ' (open)'));
23682
+ console.log(' MCP HTTP endpoint mounted at /mcp' + (activeMcpKey ? ' (from outside: /mcp/<key>)' : ' (local only: no key is set)'));
24260
23683
  } catch (err) {
24261
23684
  console.error(' MCP HTTP mount failed (run npm run build in mcp-server):', err.message);
24262
23685
  }
@@ -25248,14 +24671,11 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25248
24671
  ? `${state.model}[1m]`
25249
24672
  : state.model && state.model.includes(':') ? toCliModelName(state.model) : state.model,
25250
24673
  mcpUrl: `http://localhost:${PORT}/mcp`,
25251
- sdkExecutable: config.sdkExecutable || undefined,
25252
- // Fallback if the SDK's bundled native binary is unusable — unattended
25253
- // loops cannot surface a permission error to anyone, so they need the
25254
- // same escape hatch the sidepanel has.
25255
- claudeBin: getClaudeBin(),
25256
- // Run the installed CLI instead when it is newer than the bundled one, so
25257
- // the loop's model resolves the way the picker it was chosen from named it.
25258
- alignedClaudeBin: alignedClaudeExecutable(getClaudeBin()),
24674
+ // The user's Claude Code (or the cli-config override): the same
24675
+ // executable the model picker read its list from, so the loop's model
24676
+ // resolves the way it was named there. Without one the run fails at
24677
+ // once with the install hint, before anything is started.
24678
+ claudeExecutable: getClaudeSdkExecutable(),
25259
24679
  includePartialMessages: typeof config.partials === 'boolean'
25260
24680
  ? config.partials
25261
24681
  : process.platform !== 'win32',
@@ -25271,7 +24691,7 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25271
24691
  ...state,
25272
24692
  codexHome: state.codexHome || getCodexAccountHome(state.codexAccountId || 'default'),
25273
24693
  // Native schedules use the same trusted global CLI as the interactive
25274
- // sidepanel. The SDK-owned platform payload is never repaired or run.
24694
+ // sidepanel; SynaBun carries no Codex for the SDK to find by itself.
25275
24695
  codexPath: getNativeCodexBin(),
25276
24696
  });
25277
24697
  },
@@ -25324,7 +24744,7 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25324
24744
  try {
25325
24745
  const [
25326
24746
  { createAssistantDispatcher }, { createAssistantRuntime }, { createAssistantApi }, { createUsageLedger },
25327
- { createClaudeAccounts }, { createAssistantMemory }, { hookRecall },
24747
+ { createClaudeAccounts }, { createAssistantMemory }, { hookRecall, markInjected: assistantMarkInjected },
25328
24748
  claudeBridgeModule, { detectProject: assistantDetectProject }, { getDb: assistantGetDb },
25329
24749
  { handleRemember: assistantHandleRemember }, assistantCategories,
25330
24750
  { createAssistantCatalog }, { createAssistantConfigStore }, { createAssistantRouter, TASK_CLASS_META },
@@ -25428,11 +24848,177 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25428
24848
  listAccounts: () => ({ codex: listCodexAccountsForClient(), 'claude-code': claudeAccounts.listForClient() }),
25429
24849
  readFilter: () => assistantConfigStore.routing().catalogFilter || {},
25430
24850
  readHidden: () => assistantHiddenModels.all(),
24851
+ // A hidden alias ("haiku") hides the model it named when it was hidden: each build settles that.
24852
+ reconcileHidden: (args) => assistantHiddenModels.reconcile(args),
24853
+ // A provider lists other models than before: every open window reloads its menus.
24854
+ onChange: ({ providers }) => broadcastSync({ type: 'assistant:catalog-changed', providers }),
25431
24855
  log: (tag, message) => assistantLog(tag, message),
25432
24856
  });
25433
24857
  _assistantCatalog = assistantCatalog;
25434
24858
  // A Models toggle or a Settings → OpenCode checkbox reaches routing on the next call.
25435
- assistantHiddenModels.onChange(() => assistantCatalog.invalidate());
24859
+ assistantHiddenModels.onChange((change) => {
24860
+ // What a catalog build settled about a hidden alias is already in that build. When it
24861
+ // rewrote the list (the alias moved to another model and is shown again, its old model
24862
+ // stays hidden under its own id), every open window is told.
24863
+ if (change?.reason === 'pins') {
24864
+ if (change.moved?.length) {
24865
+ broadcastSync({ type: 'assistant:hidden-models-changed', providers: assistantHiddenModels.all(), pins: assistantHiddenModels.allPins(), changed: [change.provider], reason: 'pins' });
24866
+ }
24867
+ return;
24868
+ }
24869
+ assistantCatalog.invalidate();
24870
+ });
24871
+ // The catalog is a copy of the lists the panels' own selectors read. When Claude Code's
24872
+ // changes (a CLI update, other models on the account) the copy goes with it; Codex's
24873
+ // does the same from GET /api/codex/models.
24874
+ onClaudeModelsChanged(() => { assistantCatalog.refresh().catch(() => {}); });
24875
+ // User context (lib/assistant-user-context.js, docs/assistant.md): what the Assistant knows
24876
+ // about the user and what happened in recent conversations, for every brain, learned and
24877
+ // injected by the harness. Its own try/catch: without it the Assistant works as before.
24878
+ let assistantUserContext = null;
24879
+ // Why it did not start, for Settings → Automation behavior (the 503 of /api/assistant/user-context).
24880
+ let assistantUserContextError = null;
24881
+ try {
24882
+ const [
24883
+ { createAssistantUserContext, createUserContextStore, learningJudgeReason }, { createStatementWriter, cheapestClaudeModel },
24884
+ userTypesafe, userTypesafeKnobs, userEmbeddings, userIdentity, { query: userClaudeQuery },
24885
+ ] = await Promise.all([
24886
+ import('./lib/assistant-user-context.js'), import('./lib/assistant-user-writer.js'),
24887
+ import('../mcp-server/dist/services/typesafe.js'), import('../mcp-server/dist/services/typesafe-config.js'),
24888
+ import('../mcp-server/dist/services/local-embeddings.js'), import('../mcp-server/dist/services/identity.js'),
24889
+ import('@anthropic-ai/claude-agent-sdk'),
24890
+ ]);
24891
+ const userStore = createUserContextStore({
24892
+ getDb: assistantGetDb,
24893
+ remember: assistantHandleRemember,
24894
+ reflect: reflectMemory,
24895
+ embed: (text) => userEmbeddings.generateEmbedding(text),
24896
+ // The two categories are made the first time something is stored in them.
24897
+ ensureCategory: (name, description, parent, parentDescription) => {
24898
+ if (assistantCategories.categoryExists(name)) return;
24899
+ if (parent && !assistantCategories.categoryExists(parent)) assistantCategories.addCategory(parent, parentDescription || parent, undefined, undefined, true);
24900
+ assistantCategories.addCategory(name, description, parent);
24901
+ },
24902
+ // user-knowledge, and the seeds read in place: communication-style and its other children.
24903
+ knowledgeCategories: () => ['user-knowledge', 'communication-style', ...assistantCategories.getChildCategories('communication-style').map((category) => category.name)],
24904
+ // A write's own judgments (secret, duplicate, category) are logged against the assistant session, so the usage ledger counts them.
24905
+ withSession: (sessionId, project, fn) => userIdentity.runWithIdentity({
24906
+ key: `assistant-user-context:${sessionId || 'none'}`, source: 'assistant', clientId: 'assistant-user-context',
24907
+ pins: { terminalSessionId: null, browserSessionId: null, browserTabId: null }, state: {}, lastSeen: Date.now(),
24908
+ memoryContext: { session: sessionId || undefined, project: project || undefined },
24909
+ }, fn),
24910
+ log: (tag, message) => assistantLog(tag, message),
24911
+ });
24912
+ assistantUserContext = createAssistantUserContext({
24913
+ settings: () => assistantConfigStore.userContext(),
24914
+ store: userStore,
24915
+ // Jev answers the learning loop's questions on the existing `user-learning` surface (its switch and log), origin `assistant`.
24916
+ judge: {
24917
+ // One reading for both: the loop asks whether Jev can answer, Settings says why it cannot.
24918
+ available: () => learningJudgeReason({ typesafe: userTypesafe, knobs: userTypesafeKnobs }) === null,
24919
+ reason: () => learningJudgeReason({ typesafe: userTypesafe, knobs: userTypesafeKnobs }),
24920
+ ask: (state, questions, opts = {}) => userTypesafe.judge(state, questions, {
24921
+ surface: 'user-learning', origin: 'assistant', sessionId: opts.sessionId ?? null, project: opts.project ?? null,
24922
+ timeoutMs: opts.timeoutMs, onUsage: opts.onUsage, onLogged: opts.onLogged,
24923
+ }),
24924
+ },
24925
+ // Jev cannot write: the statement is a Claude one-shot on the cheapest Claude model, with
24926
+ // no settings, hooks, MCP servers or tools. Without a Claude login the user's own sentence is kept.
24927
+ writer: createStatementWriter({
24928
+ claudeQuery: userClaudeQuery,
24929
+ claudeExecutable: () => getClaudeSdkExecutable(),
24930
+ model: () => cheapestClaudeModel(assistantCatalog.peek?.() || null) || 'haiku',
24931
+ accountEnv: () => claudeAccounts.envFor?.('default') || {},
24932
+ cwd: DATA_HOME,
24933
+ env: process.env,
24934
+ log: (tag, message) => assistantLog(tag, message),
24935
+ }),
24936
+ usage: assistantUsage,
24937
+ markInjected: assistantMarkInjected,
24938
+ statePath: resolve(LOOP_DATA_DIR, 'assistant-user-context.json'),
24939
+ rates: () => { const cfg = typesafeConfig(); return { input: cfg.costPerMillionInput, output: cfg.costPerMillionOutput }; },
24940
+ log: (tag, message) => assistantLog(tag, message),
24941
+ });
24942
+ } catch (err) {
24943
+ // Nothing in this handler may throw (a rejection can carry null, or a value that has no string form):
24944
+ // it is what keeps an engine that did not start from taking the Assistant down with it.
24945
+ let reason = 'unknown error';
24946
+ try { reason = String(err?.message || err || reason).slice(0, 300); } catch { /* an error that cannot be read */ }
24947
+ assistantUserContext = null;
24948
+ assistantUserContextError = reason;
24949
+ console.warn('[assistant] user context unavailable:', reason);
24950
+ }
24951
+ // Greeting (lib/greeting/, docs/greeting.md): one greeting for every agent, written by the
24952
+ // harness ahead of time and shown when a person comes back and opens a fresh session. It reads
24953
+ // what SynaBun already records (thread records, session summaries, dispatched runs, schedules,
24954
+ // what is known about the person) and writes with the same isolated one-shot the statement
24955
+ // writer uses. Its own try/catch: without it every session opens quietly.
24956
+ try {
24957
+ const [
24958
+ { createGreetingEngine }, { createGreetingSources }, { createGreetingWriter }, { createGreetingApi },
24959
+ { createOneShot: createGreetingOneShot, cheapestClaudeModel: greetingCheapestModel }, greetingKnobs, { query: greetingClaudeQuery },
24960
+ ] = await Promise.all([
24961
+ import('./lib/greeting/engine.js'), import('./lib/greeting/sources.js'), import('./lib/greeting/writer.js'), import('./lib/greeting/api.js'),
24962
+ import('./lib/assistant-user-writer.js'), import('../mcp-server/dist/services/typesafe-config.js'), import('@anthropic-ai/claude-agent-sdk'),
24963
+ ]);
24964
+ const greetingLog = (tag, message) => assistantLog(tag, message);
24965
+ // Credentials out of anything read into a greeting (the same pattern list the memory writer redacts with).
24966
+ const greetingRedact = typeof greetingKnobs.redactCredentials === 'function'
24967
+ ? (text) => greetingKnobs.redactCredentials(String(text ?? ''))
24968
+ : (text) => String(text ?? '');
24969
+ _greeting = createGreetingEngine({
24970
+ settingsPath: resolve(LOOP_DATA_DIR, 'greeting.json'),
24971
+ statePath: resolve(LOOP_DATA_DIR, 'greeting-state.json'),
24972
+ sources: createGreetingSources({
24973
+ getDb: assistantGetDb,
24974
+ listRuns: () => _assistantDispatcher?.list?.({ status: 'all', limit: 500 }) || [],
24975
+ listSchedules: () => loadSchedules(),
24976
+ projects: () => loadHookProjects(),
24977
+ detectProject: assistantDetectProject,
24978
+ sessionCategories: () => ['conversations', ...assistantCategories.getChildCategories('conversations').map((category) => category.name)],
24979
+ // What is known about the person (the digest's ranking), while that layer is on.
24980
+ knowledge: (project) => (assistantUserContext && assistantUserContext.layers().knowledge ? assistantUserContext.preview({ project }).rows : []),
24981
+ // A conversation's working thread is newer than its memory (written 45 s after a turn).
24982
+ workingThreads: () => Object.values(assistantUserContext?._internals?.state?.().threads || {}),
24983
+ log: greetingLog,
24984
+ }),
24985
+ writer: createGreetingWriter({
24986
+ oneShot: createGreetingOneShot({
24987
+ claudeQuery: greetingClaudeQuery,
24988
+ claudeExecutable: () => getClaudeSdkExecutable(),
24989
+ model: () => greetingCheapestModel(assistantCatalog.peek?.() || null) || 'haiku',
24990
+ accountEnv: () => claudeAccounts.envFor?.('default') || {},
24991
+ cwd: DATA_HOME,
24992
+ env: process.env,
24993
+ label: 'greeting writer',
24994
+ logTag: 'greeting:writer-error',
24995
+ log: greetingLog,
24996
+ // Two sentences from a short list of facts need no thinking: it only costs output tokens and seconds.
24997
+ queryOptions: { effort: 'low', maxThinkingTokens: 0, thinking: { type: 'disabled' } },
24998
+ }),
24999
+ redact: greetingRedact,
25000
+ }),
25001
+ redact: greetingRedact,
25002
+ broadcast: (event) => broadcastSync(event),
25003
+ log: greetingLog,
25004
+ });
25005
+ _greetingRouter = createGreetingApi({
25006
+ getEngine: () => _greeting,
25007
+ getError: () => _greetingError,
25008
+ isGuestRequest,
25009
+ defaultProject: () => assistantDetectProject(assistantConfigStore.read().defaultBrain?.cwd || PACKAGE_ROOT) || 'global',
25010
+ log: greetingLog,
25011
+ });
25012
+ _greetingError = null;
25013
+ _greeting.start();
25014
+ } catch (err) {
25015
+ let reason = 'unknown error';
25016
+ try { reason = String(err?.message || err || reason).slice(0, 300); } catch { /* an error that cannot be read */ }
25017
+ _greeting = null;
25018
+ _greetingRouter = null;
25019
+ _greetingError = reason;
25020
+ console.warn('[greeting] unavailable:', reason);
25021
+ }
25436
25022
  app.use('/api/assistant/hidden-models', createAssistantHiddenModelsRouter({
25437
25023
  hiddenModels: assistantHiddenModels, catalog: assistantCatalog, opencodeStore: opencodeHiddenModels, isGuestRequest, broadcastSync,
25438
25024
  }));
@@ -25558,6 +25144,8 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25558
25144
  router: assistantRouter, catalog: assistantCatalog, desktop: assistantDesktop,
25559
25145
  judge: assistantJev,
25560
25146
  clarifier: assistantClarifier,
25147
+ // The session's Test density, read when a worker's prompt is composed (assistant-test-density.js).
25148
+ testDensity: (sessionId) => _assistantRuntime?.testDensityFor?.(sessionId) ?? null,
25561
25149
  });
25562
25150
  _assistantRuntime = createAssistantRuntime({
25563
25151
  dispatcher: _assistantDispatcher,
@@ -25581,6 +25169,9 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25581
25169
  pricing: assistantPricing,
25582
25170
  usage: assistantUsage,
25583
25171
  clarifier: assistantClarifier,
25172
+ userContext: assistantUserContext,
25173
+ // The greeting a conversation opened with (told to the brain once) and when the person was last here.
25174
+ greeting: _greeting,
25584
25175
  });
25585
25176
  assistantDesktop?.attach?.({ runtime: _assistantRuntime, dispatcher: _assistantDispatcher });
25586
25177
  // Warm the catalog so brain capabilities (vision, tier) are known on first attach,
@@ -25638,6 +25229,10 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25638
25229
  clarifier: assistantClarifier,
25639
25230
  // Composer uploads (the paperclip's non-inline files), next to data/images.
25640
25231
  attachments: createAttachmentStore({ root: resolve(DATA_HOME, 'data', 'attachments') }),
25232
+ // Settings → Automation behavior: the layers and the digest a new conversation would get.
25233
+ userContext: assistantUserContext,
25234
+ userContextError: () => assistantUserContextError,
25235
+ defaultProject: () => assistantDetectProject(assistantConfigStore.read().defaultBrain?.cwd || PACKAGE_ROOT) || 'global',
25641
25236
  }));
25642
25237
  const shutdownAssistant = () => {
25643
25238
  try { assistantRouter.shutdown(); } catch {}
@@ -25645,6 +25240,7 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25645
25240
  try { assistantDesktop?.shutdown?.(); } catch {}
25646
25241
  try { _assistantRuntime?.shutdown?.(); } catch {}
25647
25242
  try { _assistantDispatcher?.shutdown?.(); } catch {}
25243
+ try { _greeting?.shutdown?.(); } catch {}
25648
25244
  // Usage rows whose append failed get their last try (synchronous: an 'exit' handler runs it too).
25649
25245
  try { assistantUsage.flush(); } catch {}
25650
25246
  };
@@ -25654,6 +25250,8 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25654
25250
  console.log('[assistant] runtime ready');
25655
25251
  } catch (err) {
25656
25252
  console.warn('[assistant] unavailable:', err.message);
25253
+ // The greeting engine is built with the Assistant: Settings says why it is not there.
25254
+ if (!_greeting && !_greetingError) { try { _greetingError = `The Assistant did not start: ${String(err?.message || err).slice(0, 240)}`; } catch { _greetingError = 'The Assistant did not start'; } }
25657
25255
  }
25658
25256
  // WhatsApp Link: after the runtime and dispatcher exist (its own guard; see startWhatsAppLink).
25659
25257
  await startWhatsAppLink();
@@ -26482,6 +26080,18 @@ app.post('/api/sessions/heartbeat', express.json(), (req, res) => {
26482
26080
  const { claudeSessionId } = req.body || {};
26483
26081
  if (!claudeSessionId) return res.status(400).json({ error: 'claudeSessionId required' });
26484
26082
  const entry = sessionRegistry.get(claudeSessionId);
26083
+ // A person's own prompt (prompt-submit.mjs says so; a loop, a dispatched run, a subagent and the
26084
+ // Assistant's brain never do): the greeting engine keeps when they were last here, and their
26085
+ // last words as one short quote.
26086
+ if (req.body?.human === true && _greeting && !isGuestRequest(req)) {
26087
+ try {
26088
+ _greeting.noteActivity({
26089
+ cwd: (typeof req.body.cwd === 'string' && req.body.cwd) || entry?.cwd || null,
26090
+ surface: req.body.surface === 'claude-panel' ? 'claude-panel' : 'claude-cli',
26091
+ words: typeof req.body.words === 'string' ? req.body.words.slice(0, 400) : '',
26092
+ });
26093
+ } catch { /* the heartbeat itself never fails on it */ }
26094
+ }
26485
26095
  if (entry) { entry.lastActivity = Date.now(); res.json({ ok: true }); }
26486
26096
  else res.status(404).json({ error: 'Session not registered' });
26487
26097
  });
@@ -26774,6 +26384,13 @@ function spawnGit(args, opts = {}) {
26774
26384
  return spawnSync('git', args, baseOpts);
26775
26385
  }
26776
26386
 
26387
+ // spawnGit for a command that only reads, in a folder a request named: nothing the repository's own configuration
26388
+ // names is started for it (lib/git-read.js: no file system monitor program, no hook, no external diff, no fetch).
26389
+ function spawnGitRead(args, opts = {}) {
26390
+ const read = gitReadSync(opts.cwd, args, { env: opts.env });
26391
+ return spawnGit(read.args, { ...opts, env: read.env });
26392
+ }
26393
+
26777
26394
  function logGitFailure(where, err) {
26778
26395
  const msg = err?.stderr?.toString?.() || err?.message || String(err);
26779
26396
  if (!msg || /not a git repository/i.test(msg)) return;
@@ -26801,7 +26418,7 @@ setImmediate(() => {
26801
26418
  // repos owned by a different OS user/SID. Common on Windows external drives or repos
26802
26419
  // copied from another machine. Returns true only when git emits the specific error.
26803
26420
  function detectDubiousOwnership(dir) {
26804
- const r = spawnGit(['rev-parse', '--is-inside-work-tree'], { cwd: dir, timeout: 3000 });
26421
+ const r = spawnGitRead(['rev-parse', '--is-inside-work-tree'], { cwd: dir, timeout: 3000 });
26805
26422
  if (r.status === 0) return false;
26806
26423
  const stderr = r.stderr?.toString?.() || '';
26807
26424
  return /dubious ownership|safe\.directory/i.test(stderr);
@@ -26816,7 +26433,7 @@ function normalizeGitPath(dir) {
26816
26433
  /** Try to get git status for a directory. Returns { branch, statuses } or null. */
26817
26434
  function getGitInfo(dir) {
26818
26435
  // Check if inside a git work tree (spawnGit for consistent path resolution)
26819
- const inside = spawnGit(['rev-parse', '--is-inside-work-tree'], { cwd: dir, timeout: 3000 });
26436
+ const inside = spawnGitRead(['rev-parse', '--is-inside-work-tree'], { cwd: dir, timeout: 3000 });
26820
26437
  if (inside.status !== 0) {
26821
26438
  logGitFailure('rev-parse --is-inside-work-tree', inside.error || { stderr: inside.stderr });
26822
26439
  return null;
@@ -26827,27 +26444,27 @@ function getGitInfo(dir) {
26827
26444
  // then short SHA for detached HEAD. The chained fallback is essential for Windows users
26828
26445
  // with older Git for Windows installs where --show-current exits non-zero.
26829
26446
  let branch = '';
26830
- const showCurrent = spawnGit(['branch', '--show-current'], { cwd: dir, timeout: 3000 });
26447
+ const showCurrent = spawnGitRead(['branch', '--show-current'], { cwd: dir, timeout: 3000 });
26831
26448
  if (showCurrent.status === 0) {
26832
26449
  branch = showCurrent.stdout.toString().trim();
26833
26450
  } else {
26834
26451
  logGitFailure('branch --show-current', showCurrent.error || { stderr: showCurrent.stderr });
26835
26452
  }
26836
26453
  if (!branch) {
26837
- const abbrev = spawnGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir, timeout: 3000 });
26454
+ const abbrev = spawnGitRead(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir, timeout: 3000 });
26838
26455
  if (abbrev.status === 0) {
26839
26456
  const b = abbrev.stdout.toString().trim();
26840
26457
  if (b && b !== 'HEAD') branch = b;
26841
26458
  }
26842
26459
  }
26843
26460
  if (!branch) {
26844
- const shortSha = spawnGit(['rev-parse', '--short', 'HEAD'], { cwd: dir, timeout: 3000 });
26461
+ const shortSha = spawnGitRead(['rev-parse', '--short', 'HEAD'], { cwd: dir, timeout: 3000 });
26845
26462
  if (shortSha.status === 0) branch = shortSha.stdout.toString().trim();
26846
26463
  }
26847
26464
 
26848
26465
  try {
26849
26466
  // Get status for this directory (porcelain v1: XY <path>)
26850
- const statusResult = spawnGit(['status', '--porcelain', '-u', '.'], { cwd: dir, maxBuffer: 256 * 1024 * 1024 });
26467
+ const statusResult = spawnGitRead(['status', '--porcelain', '-u', '.'], { cwd: dir, maxBuffer: 256 * 1024 * 1024 });
26851
26468
  if (statusResult.status !== 0) {
26852
26469
  logGitFailure('status --porcelain', statusResult.error || { stderr: statusResult.stderr });
26853
26470
  return { branch, statuses: new Map() };
@@ -26973,7 +26590,7 @@ app.get('/api/terminal/branches', (req, res) => {
26973
26590
  const dir = req.query.path;
26974
26591
  if (!dir || typeof dir !== 'string') return res.status(400).json({ error: 'path required' });
26975
26592
 
26976
- const inside = spawnGit(['rev-parse', '--is-inside-work-tree'], { cwd: dir, timeout: 3000 });
26593
+ const inside = spawnGitRead(['rev-parse', '--is-inside-work-tree'], { cwd: dir, timeout: 3000 });
26977
26594
  if (inside.status !== 0) {
26978
26595
  logGitFailure('branches: rev-parse', inside.error || { stderr: inside.stderr });
26979
26596
  return res.json({ branches: [], current: null });
@@ -26982,11 +26599,11 @@ app.get('/api/terminal/branches', (req, res) => {
26982
26599
  try {
26983
26600
  // Current branch — chained fallback (older git may not support --show-current)
26984
26601
  let current = '';
26985
- const showCurrent = spawnGit(['branch', '--show-current'], { cwd: dir, timeout: 3000 });
26602
+ const showCurrent = spawnGitRead(['branch', '--show-current'], { cwd: dir, timeout: 3000 });
26986
26603
  if (showCurrent.status === 0) current = showCurrent.stdout.toString().trim();
26987
26604
  else logGitFailure('branches: show-current', showCurrent.error || { stderr: showCurrent.stderr });
26988
26605
  if (!current) {
26989
- const abbrev = spawnGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir, timeout: 3000 });
26606
+ const abbrev = spawnGitRead(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir, timeout: 3000 });
26990
26607
  if (abbrev.status === 0) {
26991
26608
  const b = abbrev.stdout.toString().trim();
26992
26609
  if (b && b !== 'HEAD') current = b;
@@ -26995,7 +26612,7 @@ app.get('/api/terminal/branches', (req, res) => {
26995
26612
 
26996
26613
  // All local branches — plain `git branch` parsed manually; avoids --format=%(refname:short)
26997
26614
  // which can get mangled by cmd.exe variable expansion on Windows even with proper quoting.
26998
- const branchResult = spawnGit(['branch'], { cwd: dir });
26615
+ const branchResult = spawnGitRead(['branch'], { cwd: dir });
26999
26616
  if (branchResult.status !== 0) logGitFailure('branch list', branchResult.error || { stderr: branchResult.stderr });
27000
26617
  const raw = branchResult.status === 0 ? branchResult.stdout.toString() : '';
27001
26618
  // Plain `git branch` output: each line is " branch" or "* branch" (current). Strip
@@ -27051,7 +26668,7 @@ app.post('/api/terminal/checkout', (req, res) => {
27051
26668
  return res.status(400).json({ error: 'Invalid branch name' });
27052
26669
  }
27053
26670
 
27054
- const inside = spawnGit(['rev-parse', '--is-inside-work-tree'], { cwd: dir, timeout: 3000 });
26671
+ const inside = spawnGitRead(['rev-parse', '--is-inside-work-tree'], { cwd: dir, timeout: 3000 });
27055
26672
  if (inside.status !== 0) {
27056
26673
  logGitFailure('checkout: rev-parse', inside.error || { stderr: inside.stderr });
27057
26674
  return res.status(400).json({ error: 'Not a git repository' });
@@ -27059,6 +26676,8 @@ app.post('/api/terminal/checkout', (req, res) => {
27059
26676
 
27060
26677
  // Branch is validated against /^[\w.\-/]+$/ above, so no shell metacharacters are possible.
27061
26678
  // No '--' separator — that would make git treat the branch name as a file pathspec.
26679
+ // (The switch itself is a write and runs plainly, with the repository's post-checkout hook; the two questions
26680
+ // around it are reads.)
27062
26681
  const result = spawnGit(['checkout', branch], { cwd: dir, timeout: 10000 });
27063
26682
  if (result.status !== 0) {
27064
26683
  const stderr = result.stderr?.toString?.()?.trim() || result.error?.message || 'Checkout failed';
@@ -27067,7 +26686,7 @@ app.post('/api/terminal/checkout', (req, res) => {
27067
26686
 
27068
26687
  // Return confirmed branch name + git's own output
27069
26688
  let current = '';
27070
- const showCurrent = spawnGit(['branch', '--show-current'], { cwd: dir, timeout: 3000 });
26689
+ const showCurrent = spawnGitRead(['branch', '--show-current'], { cwd: dir, timeout: 3000 });
27071
26690
  if (showCurrent.status === 0) current = showCurrent.stdout.toString().trim();
27072
26691
  else logGitFailure('checkout: show-current', showCurrent.error || { stderr: showCurrent.stderr });
27073
26692
  const output = result.stderr?.toString?.()?.trim() || `Switched to branch '${current}'`;
@@ -27080,18 +26699,24 @@ const _gitStatusCache = new Map(); // dir -> { at, promise }
27080
26699
  const GIT_STATUS_TTL_MS = 1500;
27081
26700
 
27082
26701
  async function computeGitStatus(dir) {
26702
+ // Each of these only reads: run as lib/git-read.js runs a read, so nothing the folder's own git configuration
26703
+ // names is started (the route takes any folder).
26704
+ const read = async (args, options) => {
26705
+ const safe = await gitRead(dir, args);
26706
+ return execFileAsync('git', safe.args, { cwd: dir, env: safe.env, ...options });
26707
+ };
27083
26708
  try {
27084
- await execFileAsync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: dir, timeout: 3000 });
26709
+ await read(['rev-parse', '--is-inside-work-tree'], { timeout: 3000 });
27085
26710
  } catch {
27086
26711
  return { ok: true, isGit: false, branch: null, changes: [] };
27087
26712
  }
27088
26713
 
27089
26714
  const resolveBranch = async () => {
27090
26715
  try {
27091
- const { stdout } = await execFileAsync('git', ['branch', '--show-current'], { cwd: dir, timeout: 3000 });
26716
+ const { stdout } = await read(['branch', '--show-current'], { timeout: 3000 });
27092
26717
  let branch = stdout.trim();
27093
26718
  if (!branch) {
27094
- const { stdout: head } = await execFileAsync('git', ['rev-parse', '--short', 'HEAD'], { cwd: dir, timeout: 3000 });
26719
+ const { stdout: head } = await read(['rev-parse', '--short', 'HEAD'], { timeout: 3000 });
27095
26720
  branch = head.trim();
27096
26721
  }
27097
26722
  return branch;
@@ -27100,7 +26725,7 @@ async function computeGitStatus(dir) {
27100
26725
 
27101
26726
  const [branch, statusOut] = await Promise.all([
27102
26727
  resolveBranch(),
27103
- execFileAsync('git', ['status', '--porcelain'], { cwd: dir, timeout: 5000, maxBuffer: 256 * 1024 * 1024 }),
26728
+ read(['status', '--porcelain'], { timeout: 5000, maxBuffer: 256 * 1024 * 1024 }),
27104
26729
  ]);
27105
26730
 
27106
26731
  const changes = [];
@@ -27128,7 +26753,29 @@ async function computeGitStatus(dir) {
27128
26753
  return { ok: true, isGit: true, branch, changes };
27129
26754
  }
27130
26755
 
27131
- app.get('/api/git/status', async (req, res) => {
26756
+ // The folder a git route is asked about: inside a registered folder, as the disk resolves it (the rule of
26757
+ // GET /api/git/diff, lib/git-diff-api.js). Answers 403 itself and returns null for any other.
26758
+ function registeredGitFolder(asked, res) {
26759
+ try { return confinedFolder(asked, allowedFileRoots()); }
26760
+ catch (err) { res.status(err instanceof PathConfineError ? err.status : 403).json({ error: err?.message || 'Path outside registered project roots' }); return null; }
26761
+ }
26762
+
26763
+ // git in a folder, by argument array and never through a shell: what it printed. It throws when git could not
26764
+ // be started, failed or took too long, as the command lines the /api/git routes used to run did. For reads only
26765
+ // (every caller is one): run as lib/git-read.js runs a read, so no program the repository's own configuration
26766
+ // names is started. `git add` and `git commit` are not run through here: a commit keeps the repository's hooks.
26767
+ function gitOutput(dir, args, { timeout = 5000, maxBuffer } = {}) {
26768
+ const read = gitReadSync(dir, args);
26769
+ const result = spawnSync('git', read.args, { cwd: dir, env: read.env, stdio: 'pipe', timeout, windowsHide: true, ...(maxBuffer ? { maxBuffer } : {}) });
26770
+ if (result.error) throw result.error;
26771
+ if (result.status !== 0) throw new Error(result.stderr?.toString?.()?.trim?.() || `git ${args[0]} failed`);
26772
+ return result.stdout.toString();
26773
+ }
26774
+
26775
+ // status, commit and generate-message are the owner's (editorOwnerGuard) and run git in the folder they are given,
26776
+ // registered or not: the MCP `git` tool asks for whatever folder an agent works in, and the Codex panel reads the
26777
+ // status before a branch switch.
26778
+ app.get('/api/git/status', editorOwnerGuard, async (req, res) => {
27132
26779
  const dir = req.query.path;
27133
26780
  if (!dir || typeof dir !== 'string') return res.status(400).json({ error: 'path required' });
27134
26781
 
@@ -27155,13 +26802,13 @@ app.get('/api/git/status', async (req, res) => {
27155
26802
  }
27156
26803
  });
27157
26804
 
27158
- app.post('/api/git/commit', (req, res) => {
26805
+ app.post('/api/git/commit', editorOwnerGuard, (req, res) => {
27159
26806
  const { path: dir, message, files } = req.body;
27160
- if (!dir || !message) return res.status(400).json({ error: 'path and message required' });
26807
+ if (!dir || typeof dir !== 'string' || !message) return res.status(400).json({ error: 'path and message required' });
27161
26808
  if (typeof message !== 'string' || message.trim().length === 0) return res.status(400).json({ error: 'Commit message cannot be empty' });
27162
26809
 
27163
26810
  try {
27164
- execSync('git rev-parse --is-inside-work-tree', { cwd: dir, stdio: 'pipe', timeout: 3000 });
26811
+ gitOutput(dir, ['rev-parse', '--is-inside-work-tree'], { timeout: 3000 });
27165
26812
  } catch {
27166
26813
  return res.status(400).json({ error: 'Not a git repository' });
27167
26814
  }
@@ -27196,30 +26843,32 @@ app.post('/api/git/commit', (req, res) => {
27196
26843
  }
27197
26844
  });
27198
26845
 
27199
- app.get('/api/git/diff-summary', (req, res) => {
27200
- const dir = req.query.path;
27201
- if (!dir || typeof dir !== 'string') return res.status(400).json({ error: 'path required' });
26846
+ app.get('/api/git/diff-summary', editorOwnerGuard, (req, res) => {
26847
+ const asked = req.query.path;
26848
+ if (!asked || typeof asked !== 'string') return res.status(400).json({ error: 'path required' });
26849
+ const dir = registeredGitFolder(asked, res);
26850
+ if (!dir) return;
27202
26851
 
27203
26852
  try {
27204
- execSync('git rev-parse --is-inside-work-tree', { cwd: dir, stdio: 'pipe', timeout: 3000 });
26853
+ gitOutput(dir, ['rev-parse', '--is-inside-work-tree'], { timeout: 3000 });
27205
26854
  } catch {
27206
26855
  return res.status(400).json({ error: 'Not a git repository' });
27207
26856
  }
27208
26857
 
27209
26858
  try {
27210
26859
  // Get diff stat for tracked files
27211
- const stat = execSync('git diff --stat --stat-width=120', { cwd: dir, stdio: 'pipe', timeout: 5000 }).toString().trim();
26860
+ const stat = gitOutput(dir, ['diff', '--stat', '--stat-width=120']).trim();
27212
26861
 
27213
26862
  // Get diff stat for staged files
27214
- const stagedStat = execSync('git diff --cached --stat --stat-width=120', { cwd: dir, stdio: 'pipe', timeout: 5000 }).toString().trim();
26863
+ const stagedStat = gitOutput(dir, ['diff', '--cached', '--stat', '--stat-width=120']).trim();
27215
26864
 
27216
26865
  // Get untracked files
27217
- const untracked = execSync('git ls-files --others --exclude-standard', { cwd: dir, stdio: 'pipe', timeout: 5000 }).toString().trim();
26866
+ const untracked = gitOutput(dir, ['ls-files', '--others', '--exclude-standard']).trim();
27218
26867
 
27219
26868
  // Get short diff for context (limited to avoid huge payloads)
27220
- const diff = execSync('git diff -U1 --no-color', { cwd: dir, stdio: 'pipe', timeout: 8000, maxBuffer: 64 * 1024 }).toString().substring(0, 4000);
26869
+ const diff = gitOutput(dir, ['diff', '-U1', '--no-color'], { timeout: 8000, maxBuffer: 64 * 1024 }).substring(0, 4000);
27221
26870
 
27222
- const stagedDiff = execSync('git diff --cached -U1 --no-color', { cwd: dir, stdio: 'pipe', timeout: 8000, maxBuffer: 64 * 1024 }).toString().substring(0, 4000);
26871
+ const stagedDiff = gitOutput(dir, ['diff', '--cached', '-U1', '--no-color'], { timeout: 8000, maxBuffer: 64 * 1024 }).substring(0, 4000);
27223
26872
 
27224
26873
  // Build a commit message from the changes
27225
26874
  const changedFiles = [];
@@ -27281,64 +26930,27 @@ app.get('/api/git/diff-summary', (req, res) => {
27281
26930
 
27282
26931
  // ── Git Diff (raw content) ──
27283
26932
 
27284
- app.get('/api/git/diff', (req, res) => {
27285
- const dir = req.query.path;
27286
- if (!dir || typeof dir !== 'string') return res.status(400).json({ error: 'path required' });
27287
- const maxLines = Math.min(Math.max(parseInt(req.query.maxLines) || 500, 50), 2000);
27288
-
27289
- try {
27290
- execSync('git rev-parse --is-inside-work-tree', { cwd: dir, stdio: 'pipe', timeout: 3000 });
27291
- } catch {
27292
- return res.status(400).json({ error: 'Not a git repository' });
27293
- }
27294
-
27295
- try {
27296
- let branch = '';
27297
- try { branch = execSync('git branch --show-current', { cwd: dir, stdio: 'pipe', timeout: 3000 }).toString().trim(); } catch {}
27298
- if (!branch) try { branch = execSync('git rev-parse --short HEAD', { cwd: dir, stdio: 'pipe', timeout: 3000 }).toString().trim(); } catch {}
27299
-
27300
- const rawDiff = execSync('git diff --no-color', { cwd: dir, stdio: 'pipe', timeout: 10000, maxBuffer: 2 * 1024 * 1024 }).toString();
27301
- const rawStaged = execSync('git diff --cached --no-color', { cwd: dir, stdio: 'pipe', timeout: 10000, maxBuffer: 2 * 1024 * 1024 }).toString();
27302
- const untracked = execSync('git ls-files --others --exclude-standard', { cwd: dir, stdio: 'pipe', timeout: 5000 }).toString().trim();
27303
-
27304
- const truncate = (text) => {
27305
- const lines = text.split('\n');
27306
- if (lines.length <= maxLines) return { text, truncated: false };
27307
- return { text: lines.slice(0, maxLines).join('\n'), truncated: true };
27308
- };
27309
-
27310
- const d = truncate(rawDiff);
27311
- const s = truncate(rawStaged);
27312
- const untrackedFiles = untracked ? untracked.split('\n').filter(Boolean) : [];
27313
-
27314
- res.json({
27315
- ok: true,
27316
- branch,
27317
- diff: d.text,
27318
- stagedDiff: s.text,
27319
- untrackedFiles,
27320
- truncated: d.truncated || s.truncated
27321
- });
27322
- } catch (err) {
27323
- res.status(500).json({ error: err.message });
27324
- }
27325
- });
26933
+ // lib/git-diff-api.js: GET /api/git/diff. The folder is one of the registered roots (as for the editor's file
26934
+ // routes), git is run by argument array and only reads, and the route refuses invite guests by itself.
26935
+ app.use(createGitDiffRouter({ fileRoots: allowedFileRoots, guard: editorOwnerGuard }));
27326
26936
 
27327
26937
  // ── Git Log ──
27328
26938
 
27329
- app.get('/api/git/log', (req, res) => {
27330
- const dir = req.query.path;
27331
- if (!dir || typeof dir !== 'string') return res.status(400).json({ error: 'path required' });
26939
+ app.get('/api/git/log', editorOwnerGuard, (req, res) => {
26940
+ const asked = req.query.path;
26941
+ if (!asked || typeof asked !== 'string') return res.status(400).json({ error: 'path required' });
26942
+ const dir = registeredGitFolder(asked, res);
26943
+ if (!dir) return;
27332
26944
  const count = Math.min(Math.max(parseInt(req.query.count) || 10, 1), 50);
27333
26945
 
27334
26946
  try {
27335
- execSync('git rev-parse --is-inside-work-tree', { cwd: dir, stdio: 'pipe', timeout: 3000 });
26947
+ gitOutput(dir, ['rev-parse', '--is-inside-work-tree'], { timeout: 3000 });
27336
26948
  } catch {
27337
26949
  return res.status(400).json({ error: 'Not a git repository' });
27338
26950
  }
27339
26951
 
27340
26952
  try {
27341
- const raw = execSync(`git log --oneline --no-decorate -n ${count}`, { cwd: dir, stdio: 'pipe', timeout: 5000 }).toString().trim();
26953
+ const raw = gitOutput(dir, ['log', '--oneline', '--no-decorate', '-n', String(count)]).trim();
27342
26954
  const commits = raw ? raw.split('\n').map(line => {
27343
26955
  const spaceIdx = line.indexOf(' ');
27344
26956
  return { hash: line.substring(0, spaceIdx), message: line.substring(spaceIdx + 1) };
@@ -27351,22 +26963,22 @@ app.get('/api/git/log', (req, res) => {
27351
26963
 
27352
26964
  // ── Git Generate Commit Message (AI-powered) ──
27353
26965
 
27354
- app.post('/api/git/generate-message', async (req, res) => {
26966
+ app.post('/api/git/generate-message', editorOwnerGuard, async (req, res) => {
27355
26967
  const dir = req.body?.path;
27356
26968
  if (!dir || typeof dir !== 'string') return res.status(400).json({ error: 'path required' });
27357
26969
 
27358
26970
  try {
27359
- execSync('git rev-parse --is-inside-work-tree', { cwd: dir, stdio: 'pipe', timeout: 3000 });
26971
+ gitOutput(dir, ['rev-parse', '--is-inside-work-tree'], { timeout: 3000 });
27360
26972
  } catch {
27361
26973
  return res.status(400).json({ error: 'Not a git repository' });
27362
26974
  }
27363
26975
 
27364
26976
  // Gather diff data — separate try/catch per command so one failure doesn't skip the rest
27365
26977
  let diffText = '', stagedText = '', untrackedList = '', recentCommits = '';
27366
- try { diffText = execSync('git diff --no-color', { cwd: dir, stdio: 'pipe', timeout: 8000, maxBuffer: 2 * 1024 * 1024 }).toString().substring(0, 4000); } catch {}
27367
- try { stagedText = execSync('git diff --cached --no-color', { cwd: dir, stdio: 'pipe', timeout: 8000, maxBuffer: 2 * 1024 * 1024 }).toString().substring(0, 4000); } catch {}
27368
- try { untrackedList = execSync('git ls-files --others --exclude-standard', { cwd: dir, stdio: 'pipe', timeout: 5000 }).toString().trim(); } catch {}
27369
- try { recentCommits = execSync('git log --oneline --no-decorate -n 5', { cwd: dir, stdio: 'pipe', timeout: 3000 }).toString().trim(); } catch {}
26978
+ try { diffText = gitOutput(dir, ['diff', '--no-color'], { timeout: 8000, maxBuffer: 2 * 1024 * 1024 }).substring(0, 4000); } catch {}
26979
+ try { stagedText = gitOutput(dir, ['diff', '--cached', '--no-color'], { timeout: 8000, maxBuffer: 2 * 1024 * 1024 }).substring(0, 4000); } catch {}
26980
+ try { untrackedList = gitOutput(dir, ['ls-files', '--others', '--exclude-standard']).trim(); } catch {}
26981
+ try { recentCommits = gitOutput(dir, ['log', '--oneline', '--no-decorate', '-n', '5'], { timeout: 3000 }).trim(); } catch {}
27370
26982
 
27371
26983
  const allDiff = (diffText + '\n' + stagedText).trim();
27372
26984
  if (!allDiff && !untrackedList) {
@@ -27419,8 +27031,8 @@ app.post('/api/git/generate-message', async (req, res) => {
27419
27031
  // Fallback: heuristic (same logic as /api/git/diff-summary)
27420
27032
  try {
27421
27033
  let stat = '', stagedStat = '';
27422
- try { stat = execSync('git diff --stat --stat-width=120', { cwd: dir, stdio: 'pipe', timeout: 5000, maxBuffer: 2 * 1024 * 1024 }).toString().trim(); } catch {}
27423
- try { stagedStat = execSync('git diff --cached --stat --stat-width=120', { cwd: dir, stdio: 'pipe', timeout: 5000, maxBuffer: 2 * 1024 * 1024 }).toString().trim(); } catch {}
27034
+ try { stat = gitOutput(dir, ['diff', '--stat', '--stat-width=120'], { maxBuffer: 2 * 1024 * 1024 }).trim(); } catch {}
27035
+ try { stagedStat = gitOutput(dir, ['diff', '--cached', '--stat', '--stat-width=120'], { maxBuffer: 2 * 1024 * 1024 }).trim(); } catch {}
27424
27036
  const changedFiles = [];
27425
27037
  for (const line of (stat + '\n' + stagedStat).split('\n').filter(l => l.includes('|'))) {
27426
27038
  const m = line.match(/^\s*(.+?)\s*\|/);
@@ -28618,7 +28230,7 @@ function isDroppableSidepanelProviderEvent(message) {
28618
28230
  function broadcastSync(message, excludeWs = null) {
28619
28231
  const data = JSON.stringify(message);
28620
28232
  const messageType = String(message?.type || '');
28621
- const ownerOnly = ['sidepanel:', 'schedule:', 'schedule-group:', 'quick-timer:', 'assistant:', 'whatsapp:']
28233
+ const ownerOnly = ['sidepanel:', 'schedule:', 'schedule-group:', 'quick-timer:', 'assistant:', 'whatsapp:', 'editor:']
28622
28234
  .some((prefix) => messageType.startsWith(prefix));
28623
28235
  const dropUnderBackpressure = isDroppableSidepanelProviderEvent(message);
28624
28236
  let sent = 0;
@@ -29916,9 +29528,11 @@ async function createBrowserSession(options = {}) {
29916
29528
  // detection on sites like Twitter. Let the automated Chrome use its own default UA.
29917
29529
  const realHeaders = options._realHeaders || {};
29918
29530
  const userAgent = savedCfg.userAgent || undefined; // only override if explicitly configured
29919
- const cfgAcceptLang = savedCfg.acceptLanguage || null;
29920
- const acceptLanguage = cfgAcceptLang || realHeaders['accept-language'] || 'en-US,en;q=0.9';
29921
- const locale = savedCfg.locale || acceptLanguage.split(',')[0].split(';')[0] || 'en-US';
29531
+ const { locale, acceptLanguage, languages, warnings: languageWarnings } =
29532
+ resolveBrowserLanguage(savedCfg, realHeaders['accept-language']);
29533
+ for (const warning of languageWarnings) {
29534
+ console.warn(`[browser] Invalid ${warning.field}; using ${JSON.stringify(warning.value)}`);
29535
+ }
29922
29536
 
29923
29537
  // Build context options from saved config
29924
29538
  const contextOpts = {
@@ -30053,7 +29667,7 @@ async function createBrowserSession(options = {}) {
30053
29667
  const cdpBrowser = await chromium.connectOverCDP(endpoint);
30054
29668
  let cdpContext = cdpBrowser.contexts()[0];
30055
29669
  if (!cdpContext) cdpContext = await cdpBrowser.newContext(contextOpts);
30056
- const cdpPage = await cdpContext.newPage(); // fresh tab; never hijack an existing one
29670
+ const cdpPage = await newSessionPage({ browser: cdpBrowser, context: cdpContext, _profileMode: 'morelogin' }); // fresh tab; never hijack an existing one
30057
29671
 
30058
29672
  browser = cdpBrowser;
30059
29673
  context = cdpContext;
@@ -30218,7 +29832,7 @@ async function createBrowserSession(options = {}) {
30218
29832
  const cdpBrowser = await chromium.connectOverCDP(endpoint);
30219
29833
  let cdpContext = cdpBrowser.contexts()[0];
30220
29834
  if (!cdpContext) cdpContext = await cdpBrowser.newContext(contextOpts);
30221
- const cdpPage = await cdpContext.newPage(); // ALWAYS a fresh tab; never hijack a user tab
29835
+ const cdpPage = await newSessionPage({ browser: cdpBrowser, context: cdpContext, _profileMode: 'cdp' }); // ALWAYS a fresh tab; never hijack a user tab
30222
29836
 
30223
29837
  browser = cdpBrowser;
30224
29838
  context = cdpContext;
@@ -30311,7 +29925,7 @@ async function createBrowserSession(options = {}) {
30311
29925
  ],
30312
29926
  });
30313
29927
  Object.defineProperty(navigator, 'languages', {
30314
- get: () => ${JSON.stringify(acceptLanguage.split(',').map(l => l.split(';')[0].trim()))},
29928
+ get: () => ${JSON.stringify(languages)},
30315
29929
  });
30316
29930
  const origQuery = window.Permissions.prototype.query;
30317
29931
  window.Permissions.prototype.query = function(params) {
@@ -30637,6 +30251,30 @@ async function stopScreencast(session) {
30637
30251
 
30638
30252
  // ── Multi-tab helpers ──
30639
30253
 
30254
+ /**
30255
+ * The page of a new tab, for every tab SynaBun opens in a browser the user
30256
+ * shares with the runs (`_profileMode` morelogin or cdp): the first tab of a
30257
+ * session (createBrowserSession, which has no session object yet and passes
30258
+ * browser, context and mode), each further tab (createSessionTab) and the
30259
+ * replacement of a wedged tab (recoverSessionTabPage).
30260
+ *
30261
+ * openTabsInBackground: true in browser-config.json (only that; not set or
30262
+ * anything else, this is context.newPage() exactly as before) opens such a tab
30263
+ * behind the selected one: the window is neither restored nor brought forward,
30264
+ * and the tab is not the selected tab when it opens. It does not cover SynaBun's
30265
+ * own windows (managed, mirror, direct and clean sessions), tabs a page opens
30266
+ * itself, or what selects a tab afterwards (the user, or the selected tab
30267
+ * closing next to it). A browser that cannot gets a normal tab.
30268
+ */
30269
+ async function newSessionPage(session, { timeoutMs } = {}) {
30270
+ if (loadBrowserConfig().openTabsInBackground === true && (session._profileMode === 'morelogin' || session._profileMode === 'cdp')) {
30271
+ const page = await openBackgroundPage(session.browser, session.context, timeoutMs ? { timeoutMs } : undefined).catch(() => null);
30272
+ if (page) return page;
30273
+ console.warn('[browser] could not open the tab in the background; opening it as the selected tab');
30274
+ }
30275
+ return session.context.newPage();
30276
+ }
30277
+
30640
30278
  /**
30641
30279
  * Create a new tab (page) in an existing browser session.
30642
30280
  * Returns { tabId, url, title }.
@@ -30645,7 +30283,7 @@ async function createSessionTab(session, sessionId, url = 'about:blank') {
30645
30283
  const tabId = randomBytes(4).toString('hex');
30646
30284
  const activeEntry = session.tabs?.get(session.activeTabId);
30647
30285
  const shouldAdoptAsActive = !activeEntry || activeEntry.page?.isClosed?.();
30648
- const page = await session.context.newPage();
30286
+ const page = await newSessionPage(session);
30649
30287
  attachConsoleBuffer(page);
30650
30288
  if (url && url !== 'about:blank') {
30651
30289
  await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {});
@@ -30696,7 +30334,8 @@ async function recoverSessionTabPage(session, sessionId, tabId, reason = 'page b
30696
30334
  };
30697
30335
  try {
30698
30336
  replacementPage = await withBrowserOperationDeadline(
30699
- session.context.newPage(),
30337
+ // A background attempt gets half of the step, so a normal tab still fits after it.
30338
+ newSessionPage(session, { timeoutMs: BROWSER_RECOVERY_STEP_DEADLINE_MS / 2 }),
30700
30339
  BROWSER_RECOVERY_STEP_DEADLINE_MS,
30701
30340
  'Creating replacement browser tab',
30702
30341
  {
@@ -30894,6 +30533,9 @@ async function _switchSessionTab(session, sessionId, tabId) {
30894
30533
 
30895
30534
  // Stop screencast on current tab
30896
30535
  await stopScreencast(session);
30536
+ // The tab may have left the session while that was awaited (closed, retired):
30537
+ // the aliases never take a page that is no longer registered.
30538
+ if (session.tabs.get(tabId) !== tabEntry) throw new Error(`Tab ${tabId} not found`);
30897
30539
 
30898
30540
  // Switch aliases
30899
30541
  session.activeTabId = tabId;
@@ -30904,6 +30546,8 @@ async function _switchSessionTab(session, sessionId, tabId) {
30904
30546
 
30905
30547
  // Start screencast on new tab
30906
30548
  await startScreencast(session);
30549
+ // Gone, or no longer the active tab, while that was awaited: nothing to announce.
30550
+ if (session.tabs.get(tabId) !== tabEntry || session.activeTabId !== tabId) return;
30907
30551
 
30908
30552
  // Notify clients of the switch
30909
30553
  const msg = JSON.stringify({
@@ -30935,15 +30579,36 @@ async function closeSessionTab(session, sessionId, tabId) {
30935
30579
  return;
30936
30580
  }
30937
30581
 
30938
- // If closing the active tab, switch to another
30582
+ // If closing the active tab, switch to another. The tab picked can itself be
30583
+ // closed while the switch is awaited (two closes in one turn, or during a
30584
+ // screencast stop): the switch then refuses it, because it never aliases a tab
30585
+ // that left the registry, and a tab that is still registered is picked instead.
30586
+ let switchError = null;
30939
30587
  if (session.activeTabId === tabId) {
30940
- const nextTabId = [...session.tabs.keys()][0];
30941
- await _switchSessionTab(session, sessionId, nextTabId);
30588
+ for (let picks = 0; picks < 8 && session.activeTabId === tabId && session.tabs.size > 0; picks += 1) {
30589
+ const nextTabId = session.tabs.keys().next().value;
30590
+ const nextEntry = session.tabs.get(nextTabId);
30591
+ try {
30592
+ await _switchSessionTab(session, sessionId, nextTabId);
30593
+ } catch (error) {
30594
+ // Gone meanwhile: pick again. Still registered: the switch itself failed, reported once the page is closed.
30595
+ if (session.tabs.get(nextTabId) === nextEntry) { switchError = error; break; }
30596
+ }
30597
+ }
30598
+ // No tab took over (every other one left meanwhile): no alias stays on the tab that is closing.
30599
+ if (session.activeTabId === tabId) {
30600
+ session.activeTabId = null;
30601
+ session.page = null;
30602
+ session.cdpSession = null;
30603
+ session.currentUrl = 'about:blank';
30604
+ session.title = '';
30605
+ }
30942
30606
  }
30943
30607
 
30944
- // Close the page
30608
+ // Close the page, whatever the switch did: the tab has left the registry already
30945
30609
  try { await tabEntry.page.close(); } catch {}
30946
30610
  console.log(`[browser] Tab ${tabId} closed in session ${sessionId} (remaining: ${session.tabs.size})`);
30611
+ if (switchError) throw switchError;
30947
30612
  }
30948
30613
 
30949
30614
  /**
@@ -31126,6 +30791,8 @@ function getTargetPage(session, req) {
31126
30791
  console.warn(`[browser] tab-pin-missing:legacy: refusing activeTab on owner-held session (id=${terminalId}, owners=${sessionOwnerCount(session)}) — returning 404 to avoid cross-automation leak`);
31127
30792
  return null;
31128
30793
  }
30794
+ // No active page at all (the last tab closed or was retired): there is nothing to route to.
30795
+ if (!session.page) return null;
31129
30796
  trackRoute(session, terminalId, session.activeTabId, 'legacy');
31130
30797
  return { page: session.page, cdpSession: session.cdpSession, tabId: session.activeTabId };
31131
30798
  }
@@ -32620,14 +32287,128 @@ app.post('/api/fetch-markdown', async (req, res) => {
32620
32287
 
32621
32288
  // GET …/screenshot-base64 — what browser_screenshot shows, plus breakpoint sizes
32622
32289
  // (width / height), full page and saving to a file (lib/browser-screenshot.js).
32623
- // A tab whose own viewport could not be confirmed back after an emulated size
32624
- // (viewportRestored: false) is closed, so no page — a MoreLogin profile's above
32625
- // all — keeps a size, DPR or mobile flag it never had; the result says so.
32290
+ // The capture, putting the tab back and the state read after it share one budget,
32291
+ // the request deadline: the answer names the phase a capture stopped in and never
32292
+ // waits past it. A tab whose own width, height and devicePixelRatio could not be
32293
+ // confirmed when the capture answers is retired: out of routing at once, then
32294
+ // closed, so no page — a MoreLogin profile's above all — keeps a size, DPR or
32295
+ // mobile flag it never had. The result says so, and says "closed" only when the
32296
+ // close was confirmed.
32626
32297
  const SCREENSHOT_TAB_CLOSED = 'SynaBun could not confirm the tab was back at its own viewport size after the capture, so it closed the tab; the next browser call opens a fresh one.';
32627
- async function retireScreenshotTab(session, sessionId, routed) {
32628
- console.warn(`[browser] closing tab ${routed.tabId} of session ${sessionId}: its viewport was not confirmed restored after a screenshot`);
32629
- const close = session.tabs?.size > 1 ? closeSessionTab(session, sessionId, routed.tabId) : routed.page.close();
32630
- await Promise.race([Promise.resolve(close).catch(() => {}), new Promise((done) => setTimeout(done, 3000))]);
32298
+ const SCREENSHOT_TAB_UNROUTED = 'SynaBun could not confirm the tab was back at its own viewport size after the capture, so it took the tab out of routing; closing it was not confirmed, so it may still be open in the browser window. The next browser call opens a fresh one.';
32299
+ // What building and sending the answer may take, out of the request deadline (a quarter of a deadline under four times this).
32300
+ const SCREENSHOT_ANSWER_MARGIN_MS = 500;
32301
+ // The longest the title read after a capture may take; never past the budget.
32302
+ const SCREENSHOT_STATE_READ_MS = 1000;
32303
+
32304
+ /**
32305
+ * Take a tab out of routing now, whatever becomes of its page: no later call
32306
+ * reaches it (getTargetPage answers 404, and the caller's next browser call
32307
+ * opens a fresh tab). What the page's close event does for a tab that closed,
32308
+ * done here because that event is ignored for a tab no longer registered. Only
32309
+ * while the tab id still names `page`: a wedged tab replaced meanwhile
32310
+ * (recoverSessionTabPage keeps the id) has a fresh page under it, which stays.
32311
+ *
32312
+ * The registry entry and every alias of the page (session.page, cdpSession,
32313
+ * activeTabId) go in the same tick, before anything is awaited or anyone is
32314
+ * told: the switch to another tab is asynchronous and re-validates its target,
32315
+ * so neither a second retirement in the same turn nor a screencast stop that
32316
+ * hangs can leave the session pointing at a retired page. A client that cannot
32317
+ * be told changes none of this. Announced as `browser:tab-retired`: the tab
32318
+ * left routing; `browser:tab-closed` follows only when the close is confirmed.
32319
+ */
32320
+ function unrouteScreenshotTab(session, sessionId, tabId, page) {
32321
+ if (session.tabs?.get(tabId)?.page !== page) return;
32322
+ session.tabs.delete(tabId);
32323
+ const wasActive = session.activeTabId === tabId || session.page === page;
32324
+ if (wasActive) {
32325
+ session.activeTabId = null;
32326
+ session.page = null;
32327
+ session.cdpSession = null;
32328
+ session.currentUrl = 'about:blank';
32329
+ session.title = '';
32330
+ }
32331
+ // What follows only tells others; none of it may undo or skip the above.
32332
+ const quietly = (what, work) => { try { work(); } catch (error) { console.warn(`[browser] retiring tab ${tabId} of session ${sessionId}: ${what} failed: ${error.message}`); } };
32333
+ quietly('clearing its snapshot cache', () => clearAiSnapshotCache(sessionId, tabId));
32334
+ // The session moves on to a tab that is left, also when an earlier retirement in this turn left it without one.
32335
+ if (wasActive || session.activeTabId == null) {
32336
+ const next = session.tabs.keys().next().value;
32337
+ if (next !== undefined) {
32338
+ _switchSessionTab(session, sessionId, next).catch(() => {});
32339
+ } else {
32340
+ session.clients?.forEach((ws) => quietly('telling a client that all tabs closed', () => {
32341
+ if (ws.readyState === 1) {
32342
+ ws.send(JSON.stringify({ type: 'error', message: 'All tabs closed' }));
32343
+ ws.close(1000, 'All tabs closed');
32344
+ }
32345
+ }));
32346
+ quietly('forgetting the clients', () => session.clients?.clear());
32347
+ }
32348
+ }
32349
+ quietly('announcing it', () => broadcastSync({ type: 'browser:tab-retired', sessionId, tabId, remainingTabs: session.tabs.size }));
32350
+ }
32351
+ /**
32352
+ * Retire a tab after a screenshot: out of routing before anything is awaited,
32353
+ * then closed. `reason` is 'size' (its viewport could not be confirmed restored)
32354
+ * or 'pending' (a capture of it may still be pending for good). The close is
32355
+ * attempted whatever taking it out of routing and telling the clients did.
32356
+ * `closed` settles true only when the page reports itself closed, and only then
32357
+ * is `browser:tab-closed` announced; a close that fails, or never answers,
32358
+ * leaves it false or pending, and the log says which.
32359
+ */
32360
+ function retireScreenshotTab(session, sessionId, routed, reason = 'size') {
32361
+ console.warn(`[browser] retiring tab ${routed.tabId} of session ${sessionId}: ${reason === 'pending'
32362
+ ? 'a screenshot of it got no answer and may still be pending (its CDP session was never confirmed detached)'
32363
+ : 'its viewport was not confirmed restored after a screenshot'}`);
32364
+ // A wedged tab replaced meanwhile keeps its id for a fresh page: that tab neither left routing nor closes.
32365
+ const registered = session.tabs?.get(routed.tabId)?.page === routed.page;
32366
+ try {
32367
+ unrouteScreenshotTab(session, sessionId, routed.tabId, routed.page);
32368
+ } catch (error) {
32369
+ console.warn(`[browser] taking retired tab ${routed.tabId} of session ${sessionId} out of routing failed part-way: ${error.message}`);
32370
+ }
32371
+ const closed = (async () => {
32372
+ try {
32373
+ await routed.page.close();
32374
+ } catch (error) {
32375
+ console.warn(`[browser] closing retired tab ${routed.tabId} of session ${sessionId} failed: ${error.message}`);
32376
+ }
32377
+ const confirmed = routed.page.isClosed?.() === true;
32378
+ if (confirmed) {
32379
+ console.log(`[browser] Retired tab ${routed.tabId} closed in session ${sessionId} (remaining: ${session.tabs.size})`);
32380
+ if (registered) try { broadcastSync({ type: 'browser:tab-closed', sessionId, tabId: routed.tabId, remainingTabs: session.tabs.size }); } catch { /* the tab is closed either way */ }
32381
+ } else {
32382
+ console.warn(`[browser] retired tab ${routed.tabId} of session ${sessionId} is out of routing but its close was not confirmed; it may still be open in the browser window`);
32383
+ }
32384
+ return confirmed;
32385
+ })();
32386
+ return { closed };
32387
+ }
32388
+ /**
32389
+ * One line per capture, success or failure, so what a capture depends on can be
32390
+ * read back later (docs/browser-v2.md, "Reading the screenshot log"): session
32391
+ * and tab, the window state CDP reported (for a failure the one read when it
32392
+ * stopped, with `windowAtStart` when the one read as the capture began was
32393
+ * another), whether the tab was the selected one as far as the server can tell
32394
+ * (selectedTabReading: inferred from the order tabs were opened in, not
32395
+ * measured), the outcome, the phase of a failure and the duration. A run's line
32396
+ * also goes to its own log file.
32397
+ */
32398
+ function logScreenshotCapture(req, sessionId, tabId, selected, record) {
32399
+ const caller = (req.get && req.get('X-Synabun-Terminal')) || req.headers?.['x-synabun-terminal'] || null;
32400
+ // The id names the log file: anything but a plain id goes to the console only.
32401
+ const owner = typeof caller === 'string' && /^[\w-]{1,120}$/.test(caller) ? caller : null;
32402
+ loopLog(owner, 'browser:screenshot',
32403
+ `session=${sessionId} tab=${tabId} window=${record.windowState || 'unknown'} selected=${selected.reading} outcome=${record.outcome}${record.phase ? ` phase=${record.phase}` : ''} ms=${record.ms}`,
32404
+ {
32405
+ selectedBasis: selected.basis,
32406
+ ...(record.code && { code: record.code }),
32407
+ ...(record.windowAtStart !== record.windowState && { windowAtStart: record.windowAtStart || 'unknown' }),
32408
+ ...(record.tab !== 'untouched' && { tabAfter: record.tab }),
32409
+ ...(record.tab === 'retired' && { tabClosed: record.tabClosed === true }),
32410
+ ...(record.capturePending && { capturePending: true }),
32411
+ });
32631
32412
  }
32632
32413
  app.get('/api/browser/sessions/:id/screenshot-base64', async (req, res) => {
32633
32414
  const session = browserSessions.get(req.params.id);
@@ -32642,38 +32423,92 @@ app.get('/api/browser/sessions/:id/screenshot-base64', async (req, res) => {
32642
32423
  } catch (err) {
32643
32424
  return res.status(err.status || 400).json({ error: err.message });
32644
32425
  }
32645
- let tabClosed = false;
32426
+ const started = Date.now();
32427
+ // One budget for the capture, putting the tab back and the state read: the
32428
+ // request deadline, less what sending the answer takes. The capture itself is
32429
+ // given up on earlier (lib), so this route says which phase stopped it; at the
32430
+ // deadline the caller would only see a bare timeout.
32431
+ const requestDeadline = req.browserRequest ? req.browserRequest.deadline : started + 60_000;
32432
+ const answerBy = requestDeadline - Math.min(SCREENSHOT_ANSWER_MARGIN_MS, Math.max(0, requestDeadline - started) / 4);
32433
+ // Read before the capture: what the tab was when it began.
32434
+ const selected = selectedTabReading(routed.page);
32435
+ const known = session.tabs?.get(routed.tabId)?.title || '';
32436
+ let record = null;
32437
+ // The log line is built and written once the answer is with the operating
32438
+ // system (or the client is gone), and guarded: a sink that throws or does not
32439
+ // return costs the line, never the answer, its status or its time.
32440
+ let logged = false;
32441
+ const logCapture = () => {
32442
+ if (logged || !record) return;
32443
+ logged = true;
32444
+ try {
32445
+ logScreenshotCapture(req, req.params.id, routed.tabId, selected, record);
32446
+ } catch (error) {
32447
+ try { console.warn(`[browser] screenshot log line not written: ${error.message}`); } catch { /* nothing left to tell */ }
32448
+ }
32449
+ };
32646
32450
  try {
32647
- const shot = await captureScreenshot(routed.page, options, { runBounded: (work) => runBrowserRequest(req, work, 60_000) });
32648
- const state = await syncPageState(session, routed);
32649
- if (shot.viewportRestored === false) {
32650
- await retireScreenshotTab(session, req.params.id, routed);
32651
- tabClosed = true;
32451
+ const shot = await captureScreenshot(routed.page, options, {
32452
+ deadline: answerBy,
32453
+ // Ends the capture early when the request is aborted; the deadline is the lib's.
32454
+ runBounded: (work) => runBrowserRequest(req, work, 120_000),
32455
+ retire: (reason) => retireScreenshotTab(session, req.params.id, routed, reason),
32456
+ onRecord: (entry) => { record = entry; },
32457
+ });
32458
+ const retired = shot.tab === 'retired';
32459
+ // The title read gets what is left of the budget; a retired tab is not asked.
32460
+ const url = routed.page.url();
32461
+ let title = known;
32462
+ if (!retired) {
32463
+ let timer;
32464
+ const read = await Promise.race([
32465
+ routed.page.title().catch(() => ''),
32466
+ new Promise((done) => { timer = setTimeout(() => done(null), Math.max(1, Math.min(SCREENSHOT_STATE_READ_MS, answerBy - Date.now()))); }),
32467
+ ]);
32468
+ clearTimeout(timer);
32469
+ if (read !== null) {
32470
+ title = read;
32471
+ if (routed.tabId === session.activeTabId) { session.currentUrl = url; session.title = title; }
32472
+ }
32652
32473
  }
32653
32474
  let savedPath = null;
32654
32475
  if (shot.fileData) {
32655
32476
  const target = options.path
32656
32477
  ? resolve(options.path)
32657
- : defaultScreenshotPath({ dataHome: DATA_HOME, pageUrl: state.url, width: shot.width, height: shot.height, format: options.fileFormat });
32478
+ : defaultScreenshotPath({ dataHome: DATA_HOME, pageUrl: url, width: shot.width, height: shot.height, format: options.fileFormat });
32658
32479
  // Checked again as it is written: the capture may have taken a minute.
32659
32480
  savedPath = writeScreenshotFile(target, Buffer.from(shot.fileData, 'base64'), { dataHome: DATA_HOME });
32660
32481
  }
32661
32482
  res.json({
32662
- ok: true, url: state.url, title: state.title,
32483
+ ok: true, url, title,
32663
32484
  data: shot.data, mime: shot.mime,
32664
32485
  width: shot.width, height: shot.height, imageWidth: shot.imageWidth, imageHeight: shot.imageHeight,
32665
32486
  fullPage: shot.fullPage,
32666
32487
  ...(shot.truncated && { truncated: true }),
32667
32488
  ...(shot.emulated && { viewport: shot.emulated }),
32668
32489
  ...(savedPath && { savedPath, format: options.fileFormat }),
32669
- ...(tabClosed && { tabClosed: true, notice: SCREENSHOT_TAB_CLOSED }),
32490
+ ...(retired && { tabRetired: true, tabClosed: shot.tabClosed === true, notice: shot.tabClosed === true ? SCREENSHOT_TAB_CLOSED : SCREENSHOT_TAB_UNROUTED }),
32670
32491
  });
32671
32492
  } catch (err) {
32672
- if (err?.viewportRestored === false && !tabClosed) {
32673
- await retireScreenshotTab(session, req.params.id, routed);
32674
- tabClosed = true;
32675
- }
32676
- res.status(err.status || 500).json({ error: tabClosed ? `${err.message}. ${SCREENSHOT_TAB_CLOSED}` : err.message, ...(tabClosed && { tabClosed: true }) });
32493
+ // A failure of the capture itself, not of what came after it (the file).
32494
+ const captureFailed = !!record && record.outcome !== 'ok';
32495
+ // What became of the tab is said either way: a capture that worked can have retired it too.
32496
+ const retired = record?.tab === 'retired';
32497
+ const closed = record?.tabClosed === true;
32498
+ const after = retired ? (closed ? SCREENSHOT_TAB_CLOSED : SCREENSHOT_TAB_UNROUTED) : captureFailed && record.tab === 'restored' ? 'The tab is back at its own size.' : '';
32499
+ res.status(err.status || 500).json({
32500
+ error: after ? `${err.message}${/[.!?]$/.test(err.message) ? '' : '.'} ${after}` : err.message,
32501
+ ...(err.code && { code: err.code }),
32502
+ ...(err.phase && { phase: err.phase }),
32503
+ // What the capture depends on, for a caller that reads fields: the window state CDP reported when the
32504
+ // capture stopped (with the one from when it began, when that was another) and the selected-tab reading.
32505
+ ...(captureFailed && { windowState: err.windowState ?? null, ...((err.windowAtStart ?? null) !== (err.windowState ?? null) && { windowAtStart: err.windowAtStart ?? null }), selectedTab: selected.reading }),
32506
+ ...(retired && { tabRetired: true, tabClosed: closed }),
32507
+ ...(err.capturePending && { capturePending: true }),
32508
+ });
32509
+ } finally {
32510
+ if (res.writableFinished || res.destroyed) logCapture();
32511
+ else { res.once('finish', logCapture); res.once('close', logCapture); }
32677
32512
  }
32678
32513
  });
32679
32514
 
@@ -32824,10 +32659,14 @@ app.get('/api/browser/config', (req, res) => {
32824
32659
  app.put('/api/browser/config', (req, res) => {
32825
32660
  try {
32826
32661
  // Full replacement — the frontend sends the complete config object
32827
- const body = req.body || {};
32662
+ const body = validateBrowserLanguageConfig(req.body || {});
32828
32663
  if (body.connectMode !== undefined) body.connectMode = ['attach', 'morelogin'].includes(body.connectMode) ? body.connectMode : 'mirror';
32829
32664
  if (body.attachSeed !== undefined) body.attachSeed = body.attachSeed === 'fresh' ? 'fresh' : 'real';
32830
32665
  if (body.moreloginEnvId !== undefined && body.moreloginEnvId !== null && body.moreloginEnvId !== '') body.moreloginEnvId = String(body.moreloginEnvId);
32666
+ // openTabsInBackground is not in the Settings form: a save from the form keeps
32667
+ // it. What it covers is said at newSessionPage, which reads it.
32668
+ const savedBackgroundTabs = loadBrowserConfig().openTabsInBackground;
32669
+ if (body.openTabsInBackground === undefined && typeof savedBackgroundTabs === 'boolean') body.openTabsInBackground = savedBackgroundTabs;
32831
32670
  saveBrowserConfig(body);
32832
32671
  // The in-process MCP drops its cached browser routes now; MCP processes of their
32833
32672
  // own do on their next /api/browser response (X-Synabun-Browser-Default).
@@ -32836,7 +32675,8 @@ app.put('/api/browser/config', (req, res) => {
32836
32675
  .catch(() => {});
32837
32676
  res.json({ ok: true });
32838
32677
  } catch (err) {
32839
- res.status(500).json({ error: err.message });
32678
+ const invalidLanguage = err.status === 400 && ['locale', 'acceptLanguage'].includes(err.field);
32679
+ res.status(invalidLanguage ? 400 : 500).json({ error: err.message, ...(invalidLanguage && { field: err.field }) });
32840
32680
  }
32841
32681
  });
32842
32682
 
@@ -32966,6 +32806,16 @@ const httpServer = app.listen(PORT, async () => {
32966
32806
  setKvConfig('embedding_dims', String(EMBEDDING_DIMS));
32967
32807
  } catch {}
32968
32808
 
32809
+ // Keep the Start Server launcher (synabun://) pointing at this install even
32810
+ // when the server was started directly rather than through setup.js. Writes
32811
+ // only what differs; skipped for test runs and throwaway data homes.
32812
+ try {
32813
+ const launcher = registerStartLauncher({ packageRoot: PACKAGE_ROOT });
32814
+ _startLauncherState = launcher.state === 'skipped' ? startLauncherState({ packageRoot: PACKAGE_ROOT }) : launcher.state;
32815
+ if (launcher.changed) console.log('[server] Start Server launcher registered (synabun://)');
32816
+ else if (!launcher.ok) console.warn(`[server] Start Server launcher not registered: ${launcher.errors[0]}`);
32817
+ } catch {}
32818
+
32969
32819
  // Auto-heal legacy synabun-symlink plugin state and reinstall via CC CLI
32970
32820
  try {
32971
32821
  const repair = pluginInstaller.repairSynabunPlugins();
@@ -33013,10 +32863,9 @@ const httpServer = app.listen(PORT, async () => {
33013
32863
  // opencode-<sid> node_modules that never got their per-loop cleanup.
33014
32864
  setTimeout(() => { try { cleanupStaleLoopConfigs(); } catch {} }, 1500);
33015
32865
 
33016
- // Compare the installed Claude CLI with the one the Agent SDK bundles now, so
33017
- // the first sidepanel session or native loop after boot already knows whether
33018
- // to run the installed CLI (it does when that one is newer — see
33019
- // alignedClaudeExecutable in lib/claude-model-catalog.js).
32866
+ // Compare the installed Claude CLI with the release the Agent SDK was built
32867
+ // against, once, so an older installation is named in the log before the
32868
+ // first session runs on it (checkClaudeCliSkew in lib/claude-model-catalog.js).
33020
32869
  setTimeout(() => { checkClaudeCliSkew(getClaudeBin()).catch(() => {}); }, 1000);
33021
32870
 
33022
32871
  // Warm the session_cache + session_fts tables so the hybrid search has data
@@ -33130,10 +32979,10 @@ const httpServer = app.listen(PORT, async () => {
33130
32979
  // Check for SynaBun updates (npm + github releases)
33131
32980
  try { await checkSynabunUpdate(); } catch {}
33132
32981
 
33133
- // Aggressively prune any stale bundled CLI tools left in our node_modules
33134
- // before the version check runs — defends against `npm install` not auto-
33135
- // pruning packages that were removed from package.json (recurring source of
33136
- // phantom-version badges, e.g. v2.1.89 from a stale @anthropic-ai/claude-code).
32982
+ // Prune any external CLI tool left in our node_modules before the version
32983
+ // check runs — defends against `npm install` not auto-pruning packages that
32984
+ // were removed from package.json (recurring source of phantom-version
32985
+ // badges, e.g. v2.1.89 from a stale @anthropic-ai/claude-code).
33137
32986
  try { pruneBundledCliTools(); } catch {}
33138
32987
 
33139
32988
  // Check for CLI tool updates — fire-and-forget so boot isn't serialized
@@ -33161,8 +33010,26 @@ const httpServer = app.listen(PORT, async () => {
33161
33010
  // zlib adds per-frame CPU + latency on the terminal hot path.
33162
33011
  const wss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
33163
33012
 
33164
- /** Public origins SynaBun is reached through: the running Cloudflare tunnel and the invite proxy in use (ui-invite.js). Their host names are the only non-local names the WebSocket Origin check accepts in a Host header. */
33013
+ /** Public origins SynaBun is reached through: the running Cloudflare tunnel, the invite proxy in use (ui-invite.js) and the names of SYNABUN_ALLOWED_HOSTS. Their host names are the only non-local names the Host rule (HTTP: lib/request-gate.js; WebSocket Origin check; the editor's routes) accepts. */
33165
33014
  function publicWebOrigins() {
33015
+ // The tunnel's and the invite proxy's addresses are the names requests from outside arrive under, never the
33016
+ // owner's own. In this list only the owner's own names stand without a scheme (lib/http-guards.js accepts the
33017
+ // pages of such a host as an Origin on any port): an invite address that was saved without one stands as its
33018
+ // https origin, and a name of SYNABUN_ALLOWED_HOSTS that is one of theirs adds nothing.
33019
+ const outside = remoteWebOrigins();
33020
+ const theirs = hostsOf(outside);
33021
+ const origins = outside.map((each) => (hostNameOf(each) ? `https://${each}` : each));
33022
+ // Names the owner added: SYNABUN_ALLOWED_HOSTS as the server was started with it (the shell's own value: it
33023
+ // keeps applying), and the line of the data home's .env as it is now, read with dotenv's own parser
33024
+ // (savedValueOf): the line counts for what dotenv gave it when the server started. A name added to the file is
33025
+ // answered at the next request, and one taken out of it is refused at the next request: no restart either way.
33026
+ const own = extraHostNames(startedAllowedHosts.value, savedValueOf(ENV_PATH));
33027
+ origins.push(...own.filter((each) => each.includes('://') || !theirs.includes(hostNameOf(each))));
33028
+ return origins;
33029
+ }
33030
+
33031
+ /** The names requests from outside arrive under: the running Cloudflare tunnel's and the invite proxy's in use. Whatever names one of them is remote, with CF-Connecting-IP or without (lib/request-gate.js isRemote): it needs an invite session and is never the owner. The names of SYNABUN_ALLOWED_HOSTS are not among them: those are the owner's own for this computer. */
33032
+ function remoteWebOrigins() {
33166
33033
  const origins = [];
33167
33034
  if (tunnelUrl) origins.push(tunnelUrl);
33168
33035
  const proxy = loadInviteProxy();
@@ -33170,9 +33037,20 @@ function publicWebOrigins() {
33170
33037
  return origins;
33171
33038
  }
33172
33039
 
33173
- httpServer.on('upgrade', (req, socket, head) => {
33174
- // Block tunnel traffic from WebSocket upgrades, except cookie-authenticated invite sessions
33175
- if (req.headers['cf-connecting-ip'] && !isValidInviteSession(req)) {
33040
+ httpServer.on('upgrade', function onUpgrade(req, socket, head) {
33041
+ // The Host rule first, as in front of every route (lib/request-gate.js): an upgrade whose Host is not one of
33042
+ // this server's names is refused, on every /ws/* path and with an Origin or without one. The Origin rule
33043
+ // below lets a client without an Origin pass, so without this a request under any name reached /ws/sync and
33044
+ // the terminal handoff. A name that is not known while this computer's names are still being read (its
33045
+ // Bonjour name right after a start) is asked again when they are, as a route's request is: the handler then
33046
+ // starts over, once. A name that is known never waits.
33047
+ if (!requestGates.ownHost(req)) {
33048
+ if (!requestGates.ownHostAfterNames(req, socket, () => onUpgrade(req, socket, head))) refuseUpgrade(socket, 403);
33049
+ return;
33050
+ }
33051
+
33052
+ // What comes in from outside (the tunnel, the invite proxy's name) opens no socket without an invite session
33053
+ if (requestGates.isRemote(req) && !isValidInviteSession(req)) {
33176
33054
  socket.destroy();
33177
33055
  return;
33178
33056
  }
@@ -33936,7 +33814,9 @@ process.on('exit', () => {
33936
33814
  try { restoreSleepSync(); } catch {}
33937
33815
  try { terminalHost.killNow(); } catch {}
33938
33816
  try { _whatsapp?.killNow(); } catch {}
33817
+ try { tunnel.killNow(); } catch {}
33939
33818
  try { stopOpencodeServer(); } catch {}
33819
+ try { editorCompletionServe.stop('exit'); } catch {}
33940
33820
  try { _claudeBridge?.shutdownAllBridges?.(); } catch {}
33941
33821
  try { saveSessionSnapshot(); } catch {}
33942
33822
  try { flushUiStateSync(); } catch {}