synabun 2.0.0 → 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 (334) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/NOTICE +5 -0
  3. package/README.md +1 -1
  4. package/THIRD-PARTY-LICENSES.md +46 -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/lib/start-launcher.js +88 -1
  14. package/mcp-server/package-lock.json +2 -2
  15. package/mcp-server/package.json +1 -3
  16. package/neural-interface/i18n/en.json +1500 -153
  17. package/neural-interface/i18n/pt-BR.json +1498 -152
  18. package/neural-interface/lib/assistant-api.js +62 -6
  19. package/neural-interface/lib/assistant-brains/claude.js +4 -0
  20. package/neural-interface/lib/assistant-catalog.js +179 -27
  21. package/neural-interface/lib/assistant-config.js +31 -2
  22. package/neural-interface/lib/assistant-dispatch.js +62 -13
  23. package/neural-interface/lib/assistant-hidden-models.js +393 -66
  24. package/neural-interface/lib/assistant-memory.js +24 -10
  25. package/neural-interface/lib/assistant-persona.js +6 -2
  26. package/neural-interface/lib/assistant-pricing.js +21 -6
  27. package/neural-interface/lib/assistant-runtime.js +181 -12
  28. package/neural-interface/lib/assistant-task-prompt.js +13 -3
  29. package/neural-interface/lib/assistant-test-density.js +142 -0
  30. package/neural-interface/lib/assistant-user-context-budgets.js +123 -0
  31. package/neural-interface/lib/assistant-user-context.js +908 -0
  32. package/neural-interface/lib/assistant-user-knowledge.js +328 -0
  33. package/neural-interface/lib/assistant-user-learn.js +404 -0
  34. package/neural-interface/lib/assistant-user-threads.js +226 -0
  35. package/neural-interface/lib/assistant-user-writer.js +181 -0
  36. package/neural-interface/lib/browser-background-tab.js +134 -0
  37. package/neural-interface/lib/browser-screenshot.js +678 -61
  38. package/neural-interface/lib/claude-agent-bridge.js +33 -6
  39. package/neural-interface/lib/claude-model-catalog.js +30 -2
  40. package/neural-interface/lib/claude-temporary.js +46 -7
  41. package/neural-interface/lib/cloudflared/install.js +533 -0
  42. package/neural-interface/lib/cloudflared/manager.js +545 -0
  43. package/neural-interface/lib/cloudflared/pins.js +115 -0
  44. package/neural-interface/lib/cloudflared/pins.json +63 -0
  45. package/neural-interface/lib/cloudflared/tunnel.js +265 -0
  46. package/neural-interface/lib/editor-completion-endpoint.js +264 -0
  47. package/neural-interface/lib/editor-completion-opencode.js +436 -0
  48. package/neural-interface/lib/editor-completion.js +714 -0
  49. package/neural-interface/lib/editor-config.js +149 -0
  50. package/neural-interface/lib/editor-files-api.js +268 -0
  51. package/neural-interface/lib/editor-git-api.js +134 -0
  52. package/neural-interface/lib/file-explorer-api.js +1039 -0
  53. package/neural-interface/lib/file-explorer-system.js +355 -0
  54. package/neural-interface/lib/file-explorer-trash.ps1 +85 -0
  55. package/neural-interface/lib/file-explorer-watch.js +239 -0
  56. package/neural-interface/lib/git-diff-api.js +103 -0
  57. package/neural-interface/lib/git-read.js +202 -0
  58. package/neural-interface/lib/greeting/api.js +89 -0
  59. package/neural-interface/lib/greeting/budgets.js +90 -0
  60. package/neural-interface/lib/greeting/engine.js +461 -0
  61. package/neural-interface/lib/greeting/facts.js +320 -0
  62. package/neural-interface/lib/greeting/sources.js +252 -0
  63. package/neural-interface/lib/greeting/writer.js +139 -0
  64. package/neural-interface/lib/http-guards.js +189 -13
  65. package/neural-interface/lib/machine-names.js +88 -0
  66. package/neural-interface/lib/memory-api.js +31 -7
  67. package/neural-interface/lib/native-loop-providers.js +10 -5
  68. package/neural-interface/lib/plugin-installer.js +4 -2
  69. package/neural-interface/lib/project-storage.js +5 -7
  70. package/neural-interface/lib/request-gate.js +589 -0
  71. package/neural-interface/lib/ui-blob-api.js +108 -0
  72. package/neural-interface/lib/ui-blob-store.js +68 -0
  73. package/neural-interface/package-lock.json +701 -2
  74. package/neural-interface/package.json +13 -2
  75. package/neural-interface/public/install-app.html +9 -6
  76. package/neural-interface/public/offline.html +3 -2
  77. package/neural-interface/public/onboarding.html +12 -1
  78. package/neural-interface/public/shared/agent-runtime-options.js +22 -6
  79. package/neural-interface/public/shared/assistant/asst-automation.js +286 -0
  80. package/neural-interface/public/shared/assistant/asst-brain-picker.js +312 -62
  81. package/neural-interface/public/shared/assistant/asst-composer.js +11 -1
  82. package/neural-interface/public/shared/assistant/asst-mascot.js +18 -58
  83. package/neural-interface/public/shared/assistant/asst-menu.js +12 -8
  84. package/neural-interface/public/shared/assistant/asst-panel.js +150 -28
  85. package/neural-interface/public/shared/assistant/asst-render.js +162 -19
  86. package/neural-interface/public/shared/assistant/asst-route-models.js +108 -0
  87. package/neural-interface/public/shared/assistant/asst-route.js +23 -329
  88. package/neural-interface/public/shared/assistant/asst-routes-editor.js +495 -0
  89. package/neural-interface/public/shared/assistant/asst-sanitize.js +8 -1
  90. package/neural-interface/public/shared/assistant/asst-sidepanel.js +31 -9
  91. package/neural-interface/public/shared/assistant/asst-state.js +185 -1
  92. package/neural-interface/public/shared/assistant/asst-styles.js +158 -4
  93. package/neural-interface/public/shared/blob-store.js +111 -22
  94. package/neural-interface/public/shared/cdx/cdx-panel.js +32 -11
  95. package/neural-interface/public/shared/cdx/cdx-render.js +25 -89
  96. package/neural-interface/public/shared/cdx/cdx-styles.js +36 -0
  97. package/neural-interface/public/shared/cdx/cdx-tabs.js +87 -4
  98. package/neural-interface/public/shared/constants.js +6 -1
  99. package/neural-interface/public/shared/cp/cp-rehydrate.js +2 -1
  100. package/neural-interface/public/shared/cp/cp-styles.js +15 -0
  101. package/neural-interface/public/shared/editor/editor-ai-logic.js +409 -0
  102. package/neural-interface/public/shared/editor/editor-ai.js +619 -0
  103. package/neural-interface/public/shared/editor/editor-commands.js +212 -0
  104. package/neural-interface/public/shared/editor/editor-compare.js +103 -0
  105. package/neural-interface/public/shared/editor/editor-completion-list.js +172 -0
  106. package/neural-interface/public/shared/editor/editor-doc.js +257 -0
  107. package/neural-interface/public/shared/editor/editor-git-logic.js +131 -0
  108. package/neural-interface/public/shared/editor/editor-git.js +95 -0
  109. package/neural-interface/public/shared/editor/editor-languages.js +156 -0
  110. package/neural-interface/public/shared/editor/editor-lint.js +229 -0
  111. package/neural-interface/public/shared/editor/editor-loader.js +51 -0
  112. package/neural-interface/public/shared/editor/editor-markdown.js +90 -0
  113. package/neural-interface/public/shared/editor/editor-menu.js +233 -0
  114. package/neural-interface/public/shared/editor/editor-minimap.js +371 -0
  115. package/neural-interface/public/shared/editor/editor-palette-logic.js +192 -0
  116. package/neural-interface/public/shared/editor/editor-palette.js +125 -0
  117. package/neural-interface/public/shared/editor/editor-panel-logic.js +288 -0
  118. package/neural-interface/public/shared/editor/editor-panel.js +2099 -0
  119. package/neural-interface/public/shared/editor/editor-prefs.js +224 -0
  120. package/neural-interface/public/shared/editor/editor-safety.js +500 -0
  121. package/neural-interface/public/shared/editor/editor-search.js +352 -0
  122. package/neural-interface/public/shared/editor/editor-statusbar.js +144 -0
  123. package/neural-interface/public/shared/editor/editor-tabs-logic.js +331 -0
  124. package/neural-interface/public/shared/editor/editor-tabs.js +233 -0
  125. package/neural-interface/public/shared/editor/editor-theme.js +244 -0
  126. package/neural-interface/public/shared/editor/editor-view.js +332 -0
  127. package/neural-interface/public/shared/explorer/fx-api.js +216 -0
  128. package/neural-interface/public/shared/explorer/fx-links.js +204 -0
  129. package/neural-interface/public/shared/explorer/fx-logic.js +537 -0
  130. package/neural-interface/public/shared/explorer/fx-main.js +865 -0
  131. package/neural-interface/public/shared/explorer/fx-names.js +87 -0
  132. package/neural-interface/public/shared/explorer/fx-ops.js +370 -0
  133. package/neural-interface/public/shared/explorer/fx-popups.js +490 -0
  134. package/neural-interface/public/shared/explorer/fx-tree.js +790 -0
  135. package/neural-interface/public/shared/file-links.js +339 -0
  136. package/neural-interface/public/shared/greeting/greeting-card.js +178 -0
  137. package/neural-interface/public/shared/greeting/greeting-view.js +148 -0
  138. package/neural-interface/public/shared/html-shell.js +160 -165
  139. package/neural-interface/public/shared/ocp-v2/ocp-v2-childpanel.js +4 -0
  140. package/neural-interface/public/shared/ocp-v2/ocp-v2-greeting.js +47 -0
  141. package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +99 -17
  142. package/neural-interface/public/shared/ocp-v2/ocp-v2-render.js +7 -1
  143. package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +5 -0
  144. package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +40 -0
  145. package/neural-interface/public/shared/ocp-v2/ocp-v2-ws.js +3 -2
  146. package/neural-interface/public/shared/pwa-install-page.js +29 -1
  147. package/neural-interface/public/shared/pwa-install.css +3 -3
  148. package/neural-interface/public/shared/pwa-install.js +100 -1
  149. package/neural-interface/public/shared/settings/settings-code-editor-pane.js +412 -0
  150. package/neural-interface/public/shared/settings/settings-code-editor.js +328 -0
  151. package/neural-interface/public/shared/settings/settings-greeting.js +133 -0
  152. package/neural-interface/public/shared/settings/settings-ia.js +18 -4
  153. package/neural-interface/public/shared/settings/settings-user-context.js +154 -0
  154. package/neural-interface/public/shared/start-bridge.js +238 -19
  155. package/neural-interface/public/shared/styles.css +1414 -721
  156. package/neural-interface/public/shared/synabun-mascot.js +20 -56
  157. package/neural-interface/public/shared/tunnel-view.js +167 -0
  158. package/neural-interface/public/shared/ui-claude-panel.js +157 -16
  159. package/neural-interface/public/shared/ui-detail.js +15 -0
  160. package/neural-interface/public/shared/ui-explorer.js +22 -0
  161. package/neural-interface/public/shared/ui-file-explorer.js +481 -2496
  162. package/neural-interface/public/shared/ui-file-links.js +103 -0
  163. package/neural-interface/public/shared/ui-help.js +26 -0
  164. package/neural-interface/public/shared/ui-invite.js +126 -116
  165. package/neural-interface/public/shared/ui-keybinds.js +4 -0
  166. package/neural-interface/public/shared/ui-loading.js +159 -15
  167. package/neural-interface/public/shared/ui-menubar.js +12 -30
  168. package/neural-interface/public/shared/ui-native-loop-router-state.js +11 -0
  169. package/neural-interface/public/shared/ui-native-loop-router.js +15 -2
  170. package/neural-interface/public/shared/ui-navbar.js +45 -21
  171. package/neural-interface/public/shared/ui-settings.js +455 -488
  172. package/neural-interface/public/shared/ui-sidepanel-runtime.js +2 -1
  173. package/neural-interface/public/shared/ui-sync.js +16 -0
  174. package/neural-interface/public/shared/ui-terminal.js +23 -5
  175. package/neural-interface/public/shared/ui-tunnel.js +130 -0
  176. package/neural-interface/public/shared/ui-tutorial-steps.js +6 -2
  177. package/neural-interface/public/shared/ui-whiteboard.js +12 -0
  178. package/neural-interface/public/shared/ui-worker-end.js +245 -0
  179. package/neural-interface/public/shared/ui-workspaces.js +41 -27
  180. package/neural-interface/public/shared/worker-end.js +251 -0
  181. package/neural-interface/public/sw.js +2 -2
  182. package/neural-interface/public/vendor/codemirror/chunks/apl-CSDKT72Z.js +1 -0
  183. package/neural-interface/public/vendor/codemirror/chunks/asciiarmor-JYYPU5BM.js +1 -0
  184. package/neural-interface/public/vendor/codemirror/chunks/asn1-NRWCZDA6.js +1 -0
  185. package/neural-interface/public/vendor/codemirror/chunks/asterisk-5OU6D4ZQ.js +1 -0
  186. package/neural-interface/public/vendor/codemirror/chunks/brainfuck-ID4N4SYC.js +1 -0
  187. package/neural-interface/public/vendor/codemirror/chunks/chunk-4RZWGCS2.js +1 -0
  188. package/neural-interface/public/vendor/codemirror/chunks/chunk-ACTJM3JR.js +11 -0
  189. package/neural-interface/public/vendor/codemirror/chunks/chunk-S6BMAXLK.js +1 -0
  190. package/neural-interface/public/vendor/codemirror/chunks/chunk-TO2RDOHQ.js +3 -0
  191. package/neural-interface/public/vendor/codemirror/chunks/chunk-US2GMZZQ.js +13 -0
  192. package/neural-interface/public/vendor/codemirror/chunks/chunk-WYYO5ZAJ.js +1 -0
  193. package/neural-interface/public/vendor/codemirror/chunks/chunk-XALZTDJC.js +1 -0
  194. package/neural-interface/public/vendor/codemirror/chunks/chunk-YRNUTRDV.js +1 -0
  195. package/neural-interface/public/vendor/codemirror/chunks/chunk-Z3QOKH62.js +1 -0
  196. package/neural-interface/public/vendor/codemirror/chunks/clike-OWQBSOGL.js +1 -0
  197. package/neural-interface/public/vendor/codemirror/chunks/clojure-KV75UQWC.js +1 -0
  198. package/neural-interface/public/vendor/codemirror/chunks/cmake-GTEP5Y3C.js +1 -0
  199. package/neural-interface/public/vendor/codemirror/chunks/cobol-WCZ7QEGJ.js +1 -0
  200. package/neural-interface/public/vendor/codemirror/chunks/coffeescript-TGLBOT6B.js +1 -0
  201. package/neural-interface/public/vendor/codemirror/chunks/commonlisp-WVVI432V.js +1 -0
  202. package/neural-interface/public/vendor/codemirror/chunks/crystal-S6R6HLKQ.js +1 -0
  203. package/neural-interface/public/vendor/codemirror/chunks/css-PDA2RINP.js +1 -0
  204. package/neural-interface/public/vendor/codemirror/chunks/cypher-HL5QPL4C.js +1 -0
  205. package/neural-interface/public/vendor/codemirror/chunks/d-HHJH2HAJ.js +1 -0
  206. package/neural-interface/public/vendor/codemirror/chunks/diff-MN3A35BY.js +1 -0
  207. package/neural-interface/public/vendor/codemirror/chunks/dist-25SQLBVS.js +3 -0
  208. package/neural-interface/public/vendor/codemirror/chunks/dist-27ZGGJJS.js +1 -0
  209. package/neural-interface/public/vendor/codemirror/chunks/dist-4E52OJIF.js +1 -0
  210. package/neural-interface/public/vendor/codemirror/chunks/dist-6FT73XDC.js +2 -0
  211. package/neural-interface/public/vendor/codemirror/chunks/dist-BE6YGRMW.js +1 -0
  212. package/neural-interface/public/vendor/codemirror/chunks/dist-CJSGKW2O.js +1 -0
  213. package/neural-interface/public/vendor/codemirror/chunks/dist-CRPH2WLI.js +1 -0
  214. package/neural-interface/public/vendor/codemirror/chunks/dist-CTG7JEQN.js +1 -0
  215. package/neural-interface/public/vendor/codemirror/chunks/dist-I3FEVELX.js +1 -0
  216. package/neural-interface/public/vendor/codemirror/chunks/dist-IM5BBSWZ.js +1 -0
  217. package/neural-interface/public/vendor/codemirror/chunks/dist-KERUKTIF.js +1 -0
  218. package/neural-interface/public/vendor/codemirror/chunks/dist-LXTMWZAM.js +1 -0
  219. package/neural-interface/public/vendor/codemirror/chunks/dist-MABASSET.js +1 -0
  220. package/neural-interface/public/vendor/codemirror/chunks/dist-MJVBUZLL.js +3 -0
  221. package/neural-interface/public/vendor/codemirror/chunks/dist-MNB3WZVU.js +1 -0
  222. package/neural-interface/public/vendor/codemirror/chunks/dist-QYWXOE3U.js +7 -0
  223. package/neural-interface/public/vendor/codemirror/chunks/dist-TEBOFWLZ.js +1 -0
  224. package/neural-interface/public/vendor/codemirror/chunks/dist-UNXFROM3.js +1 -0
  225. package/neural-interface/public/vendor/codemirror/chunks/dist-XL2SLQO6.js +1 -0
  226. package/neural-interface/public/vendor/codemirror/chunks/dist-XO42Y3AP.js +6 -0
  227. package/neural-interface/public/vendor/codemirror/chunks/dist-Y2WWDWJK.js +1 -0
  228. package/neural-interface/public/vendor/codemirror/chunks/dist-YFEZKWMU.js +1 -0
  229. package/neural-interface/public/vendor/codemirror/chunks/dist-ZY7SWQO6.js +1 -0
  230. package/neural-interface/public/vendor/codemirror/chunks/dockerfile-3Y4R4NCH.js +1 -0
  231. package/neural-interface/public/vendor/codemirror/chunks/dtd-5343GJ4P.js +1 -0
  232. package/neural-interface/public/vendor/codemirror/chunks/dylan-FI2CXSW2.js +1 -0
  233. package/neural-interface/public/vendor/codemirror/chunks/ebnf-GNQ77YWA.js +1 -0
  234. package/neural-interface/public/vendor/codemirror/chunks/ecl-MRPUAE2L.js +1 -0
  235. package/neural-interface/public/vendor/codemirror/chunks/eiffel-DZROES27.js +1 -0
  236. package/neural-interface/public/vendor/codemirror/chunks/elm-4KICRFKQ.js +1 -0
  237. package/neural-interface/public/vendor/codemirror/chunks/erlang-3IHYC263.js +1 -0
  238. package/neural-interface/public/vendor/codemirror/chunks/factor-C6D3E5UK.js +1 -0
  239. package/neural-interface/public/vendor/codemirror/chunks/fcl-VYTWUJMI.js +1 -0
  240. package/neural-interface/public/vendor/codemirror/chunks/forth-ZBO2EH5M.js +1 -0
  241. package/neural-interface/public/vendor/codemirror/chunks/fortran-5NDCRWIK.js +1 -0
  242. package/neural-interface/public/vendor/codemirror/chunks/gas-SKRMOXFP.js +1 -0
  243. package/neural-interface/public/vendor/codemirror/chunks/gherkin-L45YXZLW.js +1 -0
  244. package/neural-interface/public/vendor/codemirror/chunks/groovy-GLPTLN44.js +1 -0
  245. package/neural-interface/public/vendor/codemirror/chunks/haskell-E7NF7P4Y.js +1 -0
  246. package/neural-interface/public/vendor/codemirror/chunks/haxe-K2NVDOB6.js +1 -0
  247. package/neural-interface/public/vendor/codemirror/chunks/http-Q6DRQIJB.js +1 -0
  248. package/neural-interface/public/vendor/codemirror/chunks/idl-MZYNJVLN.js +1 -0
  249. package/neural-interface/public/vendor/codemirror/chunks/javascript-6YRYK2HR.js +1 -0
  250. package/neural-interface/public/vendor/codemirror/chunks/julia-HXEL2T5G.js +1 -0
  251. package/neural-interface/public/vendor/codemirror/chunks/livescript-MV7CI2JZ.js +1 -0
  252. package/neural-interface/public/vendor/codemirror/chunks/lua-E36NHOOD.js +1 -0
  253. package/neural-interface/public/vendor/codemirror/chunks/mathematica-3XOZZCMC.js +1 -0
  254. package/neural-interface/public/vendor/codemirror/chunks/mbox-ODYGFKS7.js +1 -0
  255. package/neural-interface/public/vendor/codemirror/chunks/mirc-WB43AWF5.js +1 -0
  256. package/neural-interface/public/vendor/codemirror/chunks/mllike-ZT6ET7ZH.js +1 -0
  257. package/neural-interface/public/vendor/codemirror/chunks/modelica-LLMZPFGA.js +1 -0
  258. package/neural-interface/public/vendor/codemirror/chunks/mscgen-2OPNNYFH.js +1 -0
  259. package/neural-interface/public/vendor/codemirror/chunks/mumps-6P3HWJH6.js +1 -0
  260. package/neural-interface/public/vendor/codemirror/chunks/nginx-TCGKQTRW.js +1 -0
  261. package/neural-interface/public/vendor/codemirror/chunks/nsis-ATIGIXTJ.js +1 -0
  262. package/neural-interface/public/vendor/codemirror/chunks/ntriples-5PLBI5OV.js +1 -0
  263. package/neural-interface/public/vendor/codemirror/chunks/octave-QF2S3NL6.js +1 -0
  264. package/neural-interface/public/vendor/codemirror/chunks/oz-JFIVFFH2.js +1 -0
  265. package/neural-interface/public/vendor/codemirror/chunks/pascal-JG6SW76V.js +1 -0
  266. package/neural-interface/public/vendor/codemirror/chunks/perl-4B3IWVVO.js +1 -0
  267. package/neural-interface/public/vendor/codemirror/chunks/pig-ABEBH4MI.js +1 -0
  268. package/neural-interface/public/vendor/codemirror/chunks/powershell-H32XE7TL.js +1 -0
  269. package/neural-interface/public/vendor/codemirror/chunks/properties-NN644MMA.js +1 -0
  270. package/neural-interface/public/vendor/codemirror/chunks/protobuf-YKWQK2J3.js +1 -0
  271. package/neural-interface/public/vendor/codemirror/chunks/pug-RIYXEKFW.js +1 -0
  272. package/neural-interface/public/vendor/codemirror/chunks/puppet-JZQ5WPEV.js +1 -0
  273. package/neural-interface/public/vendor/codemirror/chunks/python-EBCGGHAC.js +1 -0
  274. package/neural-interface/public/vendor/codemirror/chunks/q-5UMH2YQY.js +1 -0
  275. package/neural-interface/public/vendor/codemirror/chunks/r-7XMGG453.js +1 -0
  276. package/neural-interface/public/vendor/codemirror/chunks/rpm-IEV7TSLR.js +1 -0
  277. package/neural-interface/public/vendor/codemirror/chunks/ruby-T4SPFPNM.js +1 -0
  278. package/neural-interface/public/vendor/codemirror/chunks/sas-GEDX67ET.js +1 -0
  279. package/neural-interface/public/vendor/codemirror/chunks/scheme-FTLOWKPC.js +1 -0
  280. package/neural-interface/public/vendor/codemirror/chunks/shell-HUFRKFSJ.js +1 -0
  281. package/neural-interface/public/vendor/codemirror/chunks/sieve-P5QUDVYO.js +1 -0
  282. package/neural-interface/public/vendor/codemirror/chunks/smalltalk-2SORJNQI.js +1 -0
  283. package/neural-interface/public/vendor/codemirror/chunks/solr-M22TGAR5.js +1 -0
  284. package/neural-interface/public/vendor/codemirror/chunks/sparql-4M3KL752.js +1 -0
  285. package/neural-interface/public/vendor/codemirror/chunks/spreadsheet-3K23LA4N.js +1 -0
  286. package/neural-interface/public/vendor/codemirror/chunks/sql-VHRFMUBI.js +1 -0
  287. package/neural-interface/public/vendor/codemirror/chunks/stex-QZDAQUY2.js +1 -0
  288. package/neural-interface/public/vendor/codemirror/chunks/stylus-7ZDKAKBD.js +1 -0
  289. package/neural-interface/public/vendor/codemirror/chunks/swift-WCLCLGMR.js +1 -0
  290. package/neural-interface/public/vendor/codemirror/chunks/tcl-JLMU6WOK.js +1 -0
  291. package/neural-interface/public/vendor/codemirror/chunks/textile-4CHDKZ4T.js +1 -0
  292. package/neural-interface/public/vendor/codemirror/chunks/tiddlywiki-DKMCZZQ4.js +1 -0
  293. package/neural-interface/public/vendor/codemirror/chunks/tiki-F4JIG36Q.js +1 -0
  294. package/neural-interface/public/vendor/codemirror/chunks/toml-BHMHTIM2.js +1 -0
  295. package/neural-interface/public/vendor/codemirror/chunks/troff-YQ2KV5NT.js +1 -0
  296. package/neural-interface/public/vendor/codemirror/chunks/ttcn-ZHRCVFJY.js +1 -0
  297. package/neural-interface/public/vendor/codemirror/chunks/ttcn-cfg-KJOVTY4X.js +1 -0
  298. package/neural-interface/public/vendor/codemirror/chunks/turtle-535P5N6I.js +1 -0
  299. package/neural-interface/public/vendor/codemirror/chunks/vb-76UMQHOT.js +1 -0
  300. package/neural-interface/public/vendor/codemirror/chunks/vbscript-CGNDTK6Z.js +1 -0
  301. package/neural-interface/public/vendor/codemirror/chunks/velocity-VSJDO5T5.js +1 -0
  302. package/neural-interface/public/vendor/codemirror/chunks/verilog-4T6ETXIP.js +1 -0
  303. package/neural-interface/public/vendor/codemirror/chunks/vhdl-T7FNAXUN.js +1 -0
  304. package/neural-interface/public/vendor/codemirror/chunks/webidl-P6MJSJ6J.js +1 -0
  305. package/neural-interface/public/vendor/codemirror/chunks/xquery-UOVILRGN.js +1 -0
  306. package/neural-interface/public/vendor/codemirror/chunks/yacas-CV2APROL.js +1 -0
  307. package/neural-interface/public/vendor/codemirror/chunks/z80-3KFNVM4M.js +1 -0
  308. package/neural-interface/public/vendor/codemirror/codemirror-6.43.14-75f05e67.min.js +2 -0
  309. package/neural-interface/public/vendor/codemirror/manifest.json +60 -0
  310. package/neural-interface/scripts/build-vendor.mjs +78 -2
  311. package/neural-interface/scripts/vendor-codemirror-entry.js +121 -0
  312. package/neural-interface/server.js +873 -1028
  313. package/neural-interface/templates/rulesets/README.md +1 -1
  314. package/package.json +2 -8
  315. package/setup.js +44 -7
  316. package/benchmarks/browser-live-results.json +0 -79
  317. package/benchmarks/browser-v2-results.json +0 -258
  318. package/neural-interface/lib/codex-capabilities.d.ts +0 -41
  319. package/neural-interface/public/category-logos/claude.svg +0 -1
  320. package/neural-interface/public/category-logos/gemini-color.svg +0 -1
  321. package/neural-interface/public/category-logos/openai.svg +0 -1
  322. package/neural-interface/public/category-logos/synabun.png +0 -0
  323. package/neural-interface/public/claude.svg +0 -1
  324. package/neural-interface/public/gallery.svg +0 -6
  325. package/neural-interface/public/leonardoai.svg +0 -1
  326. package/neural-interface/public/mcp.svg +0 -4
  327. package/neural-interface/public/opencode-logo-dark-square.svg +0 -1
  328. package/neural-interface/public/pin-svgrepo-com.svg +0 -25
  329. package/neural-interface/public/sessionicon.svg +0 -8
  330. package/neural-interface/public/toolbar-album.svg +0 -8
  331. package/neural-interface/public/toolbar-question.svg +0 -6
  332. package/neural-interface/public/toolbar-sessions.svg +0 -6
  333. package/neural-interface/scripts/codex-protocol-fixture.mjs +0 -83
  334. package/skills/synabun/synabunicon.png +0 -0
@@ -24,13 +24,17 @@ import { runBrowserExtraction, readBrowserContentHtml } from './lib/browser-extr
24
24
  import { attachConsoleBuffer, readConsoleBuffer, parseConsoleSince } from './lib/browser-console.js';
25
25
  import { resolveBrowserLanguage, validateBrowserLanguageConfig } from './lib/browser-language.js';
26
26
  import { parseScreenshotOptions, captureScreenshot, screenshotPathRefusal, defaultScreenshotPath, writeScreenshotFile } from './lib/browser-screenshot.js';
27
+ import { openBackgroundPage, selectedTabReading } from './lib/browser-background-tab.js';
27
28
  import { memoryRevision } from '../mcp-server/dist/services/memory-maintenance.js';
28
29
  import { startGatedMemoryMaintenance } from './lib/memory-timers.js';
29
30
  import { createEventLoopMonitor } from './lib/event-loop-monitor.js';
30
31
  import { createRulesetInstaller } from './lib/rulesets/installer.js';
31
32
  import { registerRulesetRoutes, registerLegacyRulesetRoutes } from './lib/rulesets/api.js';
32
33
  import { createPtyHostManager } from './lib/pty-host/manager.js';
33
- 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';
34
38
  import { createPhoneAuthority } from './lib/remote-policy.js';
35
39
  import { buildTerminalEnv, interactiveShellArgs, cliWrapperArgs } from './lib/terminal-env.js';
36
40
  import { startJudgeBackfill, stopJudgeBackfill } from '../mcp-server/dist/services/memory-judge-backfill.js';
@@ -172,6 +176,7 @@ import * as moreLogin from './lib/morelogin.js';
172
176
  import { createStyleGuideStore } from './lib/style-guide/store.js';
173
177
  import { registerStyleGuideRoutes } from './lib/style-guide/api.js';
174
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';
175
180
  import { memoCache, asyncPool } from './lib/memo-cache.js';
176
181
  import {
177
182
  buildClaudeMcpEntry,
@@ -225,6 +230,7 @@ import {
225
230
  CLAUDE_FALLBACK_MODELS,
226
231
  checkClaudeCliSkew,
227
232
  discoverClaudeModels,
233
+ onClaudeModelsChanged,
228
234
  shellCommand,
229
235
  } from './lib/claude-model-catalog.js';
230
236
  import { resolveClaudeLauncher, resolveClaudeSdkExecutable } from './lib/claude-executable.js';
@@ -320,7 +326,12 @@ try {
320
326
  // Load only the canonical data-home environment. Importing `dotenv/config`
321
327
  // above would first read PACKAGE_ROOT/.env from the server cwd, allowing a
322
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);
323
333
  dotenvConfig({ path: resolve(getDataHome(), '.env') });
334
+ startedAllowedHosts.restore();
324
335
 
325
336
  const app = express();
326
337
  const SERVER_BOOT_ID = randomUUID();
@@ -328,9 +339,32 @@ let _nativeLoopRuntime = null;
328
339
  // Main assistant (bottom CLI region): brains + orchestration. Wired after the
329
340
  // native loop runtime exists; see the "Main assistant" block below.
330
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;
331
346
  let _assistantDispatcher = null;
332
347
  // The Assistant's model catalog (lib/assistant-catalog.js): Settings → WhatsApp checks its brain choice against it.
333
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);
334
368
  app.use(express.json({ limit: '25mb' }));
335
369
 
336
370
  // Clean body-parser error handler: ui-state / whiteboard persist base64 images and can
@@ -346,20 +380,8 @@ app.use((err, req, res, next) => {
346
380
  next(err);
347
381
  });
348
382
 
349
- // Block tunnel traffic from accessing anything except /mcp and /invite
350
- // Cloudflared adds CF-Connecting-IP header on proxied requests
351
- const TUNNEL_ALLOWED = ['/mcp', '/invite'];
352
-
353
- // Simple cookie parser (no dependency)
354
- function parseCookies(cookieHeader) {
355
- const cookies = {};
356
- if (!cookieHeader) return cookies;
357
- cookieHeader.split(';').forEach(part => {
358
- const [k, ...v] = part.trim().split('=');
359
- if (k) cookies[k.trim()] = decodeURIComponent(v.join('=').trim());
360
- });
361
- return cookies;
362
- }
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.
363
385
 
364
386
  // Check if request has a valid invite session cookie
365
387
  function isValidInviteSession(req) {
@@ -378,16 +400,11 @@ function isValidInviteSession(req) {
378
400
  return true;
379
401
  }
380
402
 
381
- app.use((req, res, next) => {
382
- if (req.headers['cf-connecting-ip']) {
383
- // Allow explicitly permitted paths
384
- if (TUNNEL_ALLOWED.some(p => req.path.startsWith(p))) return next();
385
- // Allow cookie-authenticated invite sessions (full UI access)
386
- if (isValidInviteSession(req)) return next();
387
- return res.status(403).json({ error: 'Forbidden' });
388
- }
389
- next();
390
- });
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);
391
408
 
392
409
  // Guest detection — works for both Cloudflare tunnel and custom proxy
393
410
  function isGuestRequest(req) {
@@ -396,67 +413,28 @@ function isGuestRequest(req) {
396
413
  return !!token && inviteSessions.has(token);
397
414
  }
398
415
 
399
- // Admin-only protection — block guests from sensitive endpoints
400
- const ADMIN_ONLY_PREFIXES = [
401
- '/api/settings', '/api/display-settings',
402
- '/api/connections', '/api/setup', '/api/system',
403
- '/api/invite/key', '/api/invite/sessions', '/api/invite/proxy', '/api/invite/permissions',
404
- '/api/claude-code', '/api/mcp-key', '/api/mcp/runtime-profile',
405
- '/api/tunnel/start', '/api/tunnel/stop',
406
- '/api/bridges', '/api/keybinds',
407
- '/api/cli',
408
- '/api/file-content',
409
- '/api/codex/plans',
410
- '/api/sidepanel',
411
- '/api/sidepanel-runs',
412
- '/api/loop',
413
- '/api/schedules',
414
- '/api/schedule-groups',
415
- '/api/quick-timer',
416
- '/api/diagnostics',
417
- '/api/desktop',
418
- // WhatsApp Link: linking, the owner and the permission level (its router
419
- // refuses guests and remote callers on its own too: lib/http-guards.js).
420
- '/api/whatsapp',
421
- // TypeSafe key/config/backfill, and the hook routes: /hook-recall returns
422
- // memory content and /hook-judge spends the host's judgment budget.
423
- '/api/typesafe',
424
- '/api/hook-',
425
- '/api/map/rebuild',
426
- ];
427
-
428
- app.use((req, res, next) => {
429
- if (isGuestRequest(req) && ADMIN_ONLY_PREFIXES.some(p => req.path.startsWith(p))) {
430
- return res.status(403).json({ error: 'Admin only' });
431
- }
432
- next();
433
- });
434
-
435
- // Feature permission enforcement — block guest mutations for disabled features
436
- const FEATURE_PERMISSION_MAP = [
437
- { prefix: '/api/terminal', perm: 'terminal' },
438
- { prefix: '/api/whiteboard', perm: 'whiteboard' },
439
- { prefix: '/api/memory', perm: 'memories' },
440
- { prefix: '/api/categories', perm: 'memories' },
441
- { prefix: '/api/trash', perm: 'memories' },
442
- { prefix: '/api/skills-studio', perm: 'skills' },
443
- { prefix: '/api/browser', perm: 'browser' },
444
- { prefix: '/api/cards', perm: 'cards' },
445
- { prefix: '/api/style-guide', perm: 'styleGuide' },
446
- ];
447
-
448
- app.use((req, res, next) => {
449
- if (req.method !== 'GET' && isGuestRequest(req)) {
450
- const match = FEATURE_PERMISSION_MAP.find(m => req.path.startsWith(m.prefix));
451
- if (match && !invitePermissions[match.perm]) {
452
- return res.status(403).json({ error: 'Feature not enabled' });
453
- }
454
- }
455
- next();
456
- });
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);
457
422
 
458
423
  import { registerCodexPlanRoutes } from './lib/codex-plan-store.js';
459
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';
460
438
 
461
439
  const PORT = process.env.NEURAL_PORT || 3344;
462
440
  // SQLite database is used directly via lib/db.js
@@ -606,6 +584,16 @@ function saveCategories(categories) {
606
584
  }
607
585
  }
608
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
+
609
597
  // SQLite queries use direct WHERE clauses
610
598
 
611
599
  // --- .env Helpers ---
@@ -2148,6 +2136,12 @@ async function runGracefulShutdown(reason) {
2148
2136
  // not represented by the terminal or agent registries below.
2149
2137
  try { await _nativeLoopRuntime?.shutdown(reason); } catch (err) { console.warn(' native loop stop err:', err?.message); }
2150
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); }
2151
2145
 
2152
2146
  // 1. opencode sidecar — has its own teardown that handles managed PID + ollama.
2153
2147
  try { stopOpencodeServer(); } catch (err) { console.warn(' opencode stop err:', err?.message); }
@@ -2233,10 +2227,20 @@ const RESTART_EXIT_CODE = 75;
2233
2227
  // onnxruntime at-exit abort. See the `process.on('exit')` handler below.
2234
2228
  let _preserveExitCode = false;
2235
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;
2236
2233
  app.post('/api/server/restart', async (req, res) => {
2237
- console.log('[server] Restart requested.');
2238
2234
  const supervised = process.env.SYNABUN_SUPERVISED === '1';
2239
- 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 {}
2240
2244
 
2241
2245
  // Drop a marker so the supervisor respawns even if our exit code is not 75.
2242
2246
  // Native module teardown (sqlite/playwright/node-pty) can crash with
@@ -2253,6 +2257,9 @@ app.post('/api/server/restart', async (req, res) => {
2253
2257
  const setupPath = resolve(PACKAGE_ROOT, 'setup.js');
2254
2258
  const respawnEnv = { ...process.env };
2255
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';
2256
2263
  const child = spawn(process.execPath, [setupPath], {
2257
2264
  cwd: PACKAGE_ROOT,
2258
2265
  detached: true,
@@ -3827,7 +3834,6 @@ CRITICAL — When you need clarification during planning:
3827
3834
  const appBoundary = createCodexAppBoundary();
3828
3835
  let supportsUserVerification = false;
3829
3836
  let codexJournalStorageDegraded = false;
3830
- const pendingGreetingThreads = new Set();
3831
3837
  const _cachedConfig = {}; // effective config from config/read + config/batchWrite
3832
3838
  let codexCapabilities = new CodexCapabilityRegistry();
3833
3839
  let activeTurnAutoAccept = false;
@@ -4233,7 +4239,6 @@ CRITICAL — When you need clarification during planning:
4233
4239
  for (const threadId of targets) {
4234
4240
  subscribedThreadIds.delete(threadId);
4235
4241
  threadOwnerRoots.delete(threadId);
4236
- pendingGreetingThreads.delete(threadId);
4237
4242
  }
4238
4243
  }
4239
4244
 
@@ -5209,7 +5214,6 @@ CRITICAL — When you need clarification during planning:
5209
5214
  lastTurnLocalImages = [];
5210
5215
  } else if (method === 'thread/closed' && params.threadId) {
5211
5216
  const closedThreadId = String(params.threadId);
5212
- pendingGreetingThreads.delete(closedThreadId);
5213
5217
  subscribedThreadIds.delete(closedThreadId);
5214
5218
  threadOwnerRoots.delete(closedThreadId);
5215
5219
  if (closedThreadId === String(activeThreadId || '')) {
@@ -5365,7 +5369,6 @@ CRITICAL — When you need clarification during planning:
5365
5369
  const old = child;
5366
5370
  const codexHome = activeCodexHome;
5367
5371
  const ownedThreadIds = ownedWriterThreadIds();
5368
- const greetingPending = pendingGreetingThreads.has(targetThreadId);
5369
5372
  initialized = false;
5370
5373
  bootPromise = null;
5371
5374
  activeThreadId = null;
@@ -5376,7 +5379,6 @@ CRITICAL — When you need clarification during planning:
5376
5379
  subscribedThreadIds.clear();
5377
5380
  threadOwnerRoots.clear();
5378
5381
  activeTurnAutoAccept = false;
5379
- pendingGreetingThreads.clear();
5380
5382
  for (const [, entry] of pending) {
5381
5383
  try { clearTimeout(entry.timer); entry.reject(new Error(reason)); } catch {}
5382
5384
  }
@@ -5399,12 +5401,9 @@ CRITICAL — When you need clarification during planning:
5399
5401
  });
5400
5402
  await activatePrimaryThread(resumed.thread?.id || targetThreadId);
5401
5403
  rememberActiveThreadRuntime(runtime);
5402
- if (greetingPending) pendingGreetingThreads.add(activeThreadId || targetThreadId);
5403
5404
  return resumed;
5404
5405
  } catch (err) {
5405
5406
  // A first-turn restart can fail after the old process has already exited.
5406
- // Keep the one-time SynaBun greeting eligible for the next activation.
5407
- if (greetingPending) pendingGreetingThreads.add(targetThreadId);
5408
5407
  throw err;
5409
5408
  }
5410
5409
  }
@@ -5429,7 +5428,6 @@ CRITICAL — When you need clarification during planning:
5429
5428
  subscribedThreadIds.clear();
5430
5429
  threadOwnerRoots.clear();
5431
5430
  activeTurnAutoAccept = false;
5432
- pendingGreetingThreads.clear();
5433
5431
  for (const [, entry] of pending) {
5434
5432
  try { clearTimeout(entry.timer); entry.reject(new Error('Codex account switched')); } catch {}
5435
5433
  }
@@ -5478,7 +5476,6 @@ CRITICAL — When you need clarification during planning:
5478
5476
  subscribedThreadIds.clear();
5479
5477
  threadOwnerRoots.clear();
5480
5478
  activeTurnAutoAccept = false;
5481
- pendingGreetingThreads.clear();
5482
5479
  for (const [, entry] of pending) {
5483
5480
  try { clearTimeout(entry.timer); entry.reject(new Error('Codex MCP profile switched')); } catch {}
5484
5481
  }
@@ -5582,7 +5579,6 @@ CRITICAL — When you need clarification during planning:
5582
5579
  const started = await request('thread/start', getDefaultThreadParams(cwd, runtime));
5583
5580
  await activatePrimaryThread(started.thread?.id || null);
5584
5581
  rememberActiveThreadRuntime(runtime);
5585
- if (activeThreadId) pendingGreetingThreads.add(activeThreadId);
5586
5582
  activeTurnId = null;
5587
5583
  const thread = sanitizeCodexThreadSummary(started.thread || null);
5588
5584
  if (opts.emitThreadEvent !== false) {
@@ -5854,7 +5850,6 @@ CRITICAL — When you need clarification during planning:
5854
5850
  const runtime = await resolveCodexThreadRuntimeOptions(opts);
5855
5851
  const desiredRuntimeFingerprint = codexThreadRuntimeFingerprint(runtime);
5856
5852
  let threadId = opts.fresh ? null : (opts.threadId || activeThreadId);
5857
- let startedFreshThread = false;
5858
5853
  if (threadId && threadId !== activeThreadId) {
5859
5854
  const resumed = await requestCodexThreadResume(threadId, cwd, runtime);
5860
5855
  await activatePrimaryThread(resumed.thread?.id || threadId);
@@ -5870,7 +5865,6 @@ CRITICAL — When you need clarification during planning:
5870
5865
  runtimeOptions: runtime,
5871
5866
  });
5872
5867
  threadId = startedThread?.id || null;
5873
- startedFreshThread = true;
5874
5868
  }
5875
5869
  activeTurnAutoAccept = !!opts.autoAccept;
5876
5870
  const originalPrompt = prompt;
@@ -5880,11 +5874,14 @@ CRITICAL — When you need clarification during planning:
5880
5874
  );
5881
5875
  const isPlanModeTurn = !!opts.planMode || isCodexPlanModePrompt(originalPrompt);
5882
5876
  const isSlashCommand = /^\/\w/.test(String(originalPrompt || '').trim());
5883
- const shouldInjectGreeting = !!threadId && !isSlashCommand && (startedFreshThread || pendingGreetingThreads.has(threadId));
5884
- const turnPrompt = shouldInjectGreeting
5885
- ? 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}`
5886
5884
  : prompt;
5887
- if (threadId) pendingGreetingThreads.delete(threadId);
5888
5885
  const input = buildCodexInputItems(turnPrompt, {
5889
5886
  mentions: Array.isArray(opts.mentions) ? opts.mentions : [],
5890
5887
  localImages: Array.isArray(opts.localImages) ? opts.localImages : [],
@@ -6083,8 +6080,6 @@ CRITICAL — When you need clarification during planning:
6083
6080
  for (const [threadId, ownerRoot] of orphan.threadOwnerRoots || []) threadOwnerRoots.set(threadId, ownerRoot);
6084
6081
  nextRequestId = orphan.nextRequestId;
6085
6082
  Object.assign(_cachedConfig, orphan.cachedConfig || {});
6086
- pendingGreetingThreads.clear();
6087
- for (const threadId of orphan.pendingGreetingThreads || []) pendingGreetingThreads.add(threadId);
6088
6083
  writerLifecycleState = orphan.writerLifecycleState || writerLifecycleState;
6089
6084
  writerLifecycleState.releaseRequested = false;
6090
6085
  shuttingDown = false;
@@ -6378,9 +6373,15 @@ CRITICAL — When you need clarification during planning:
6378
6373
  prompt = injected.prompt;
6379
6374
  }
6380
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
+ }
6381
6381
  await withCodexWriterOperation('query', () => startTurn(prompt, {
6382
6382
  fresh: !!msg.fresh,
6383
6383
  threadId: msg.threadId || null,
6384
+ greetingId: typeof msg.greetingId === 'string' ? msg.greetingId : null,
6384
6385
  cwd: msg.cwd || PACKAGE_ROOT,
6385
6386
  model: msg.model || null,
6386
6387
  contextMode: msg.contextMode || 'default',
@@ -7157,7 +7158,6 @@ CRITICAL — When you need clarification during planning:
7157
7158
  get subscribedThreadIds() { return new Set(subscribedThreadIds); },
7158
7159
  get threadOwnerRoots() { return new Map(threadOwnerRoots); },
7159
7160
  get nextRequestId() { return nextRequestId; },
7160
- get pendingGreetingThreads() { return new Set(pendingGreetingThreads); },
7161
7161
  cachedConfig: { ..._cachedConfig },
7162
7162
  get pending() { return new Map(pending); },
7163
7163
  get pendingServerRequests() { return new Map(pendingServerRequests); },
@@ -7337,7 +7337,11 @@ app.get('/api/codex/models', async (req, res) => {
7337
7337
  await queryCodexAppServerModels(codexHome),
7338
7338
  readCodexRuntimeModelCatalog(codexHome),
7339
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);
7340
7343
  _codexModelListCache.set(codexHome, { at: Date.now(), models });
7344
+ if (listChanged) _assistantCatalog?.refresh?.().catch(() => {});
7341
7345
  res.json({ ok: true, source: 'codex-app-server', models });
7342
7346
  } catch (err) {
7343
7347
  res.json({ ok: false, source: 'fallback', error: err.message, models: [] });
@@ -8302,9 +8306,21 @@ function handleOpencodeV2Ws(ws) {
8302
8306
  const promptFn = useAsync
8303
8307
  ? turnClient.session.promptAsync
8304
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
+ }
8305
8321
  const r = await promptFn({
8306
8322
  sessionID: sid,
8307
- parts: msg.parts,
8323
+ parts: turnParts,
8308
8324
  model: msg.model,
8309
8325
  agent,
8310
8326
  variant: msg.variant || undefined,
@@ -9500,6 +9516,10 @@ const _claudeBridgeReady = (async () => {
9500
9516
  try {
9501
9517
  _claudeBridge = await import('./lib/claude-agent-bridge.js');
9502
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 }); },
9503
9523
  addCost,
9504
9524
  getSessionCost,
9505
9525
  acquireSessionLock: _acquireSessionLock,
@@ -10237,7 +10257,7 @@ const FAT_KEY_ROUTES = {
10237
10257
  'synabun-node-positions': { ns: 'kv', split: false },
10238
10258
  'synabun-node-positions-2d': { ns: 'kv', split: false },
10239
10259
  };
10240
- 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]);
10241
10261
 
10242
10262
  function routeFatKeyToBlobStore(key, value) {
10243
10263
  const fat = FAT_KEY_ROUTES[key];
@@ -10336,53 +10356,22 @@ app.post('/api/ui-state', (req, res) => {
10336
10356
  }
10337
10357
  });
10338
10358
 
10339
- // ── UI blob store — per-entry storage for fat keys (transcript snapshots,
10340
- // node positions). PUT writes only the changed entry instead of the whole map.
10341
- function _blobNs(req, res) {
10342
- const ns = req.params.ns;
10343
- if (!BLOB_NS_ALLOWLIST.has(ns)) { res.status(400).json({ error: `unknown namespace: ${ns}` }); return null; }
10344
- return ns;
10345
- }
10346
-
10347
- app.get('/api/ui-blobs/:ns', (req, res) => {
10348
- const ns = _blobNs(req, res);
10349
- if (!ns) return;
10350
- try { res.json(blobGetNamespace(ns)); }
10351
- catch (err) { res.status(500).json({ error: err.message }); }
10352
- });
10353
-
10354
- app.get('/api/ui-blobs/:ns/:id', (req, res) => {
10355
- const ns = _blobNs(req, res);
10356
- if (!ns) return;
10357
- try {
10358
- const value = blobGet(ns, req.params.id);
10359
- if (value === null) return res.status(404).json({ error: 'not found' });
10360
- res.json(value);
10361
- } catch (err) { res.status(500).json({ error: err.message }); }
10362
- });
10363
-
10364
- function _blobPutHandler(req, res) {
10365
- const ns = _blobNs(req, res);
10366
- if (!ns) return;
10367
- try {
10368
- blobPut(ns, req.params.id, req.body);
10369
- const maxEntries = parseInt(req.query.maxEntries, 10) || 0;
10370
- const maxChars = parseInt(req.query.maxChars, 10) || 0;
10371
- let evicted = [];
10372
- if (maxEntries || maxChars) evicted = blobPrune(ns, { maxEntries, maxTotalChars: maxChars });
10373
- res.json({ ok: true, evicted });
10374
- } catch (err) { res.status(500).json({ error: err.message }); }
10375
- }
10376
- app.put('/api/ui-blobs/:ns/:id', _blobPutHandler);
10377
- // POST alias — sendBeacon can only POST
10378
- app.post('/api/ui-blobs/:ns/:id', _blobPutHandler);
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 });
10379
10364
 
10380
- app.delete('/api/ui-blobs/:ns/:id', (req, res) => {
10381
- const ns = _blobNs(req, res);
10382
- if (!ns) return;
10383
- try { blobDelete(ns, req.params.id); res.json({ ok: true }); }
10384
- catch (err) { res.status(500).json({ error: err.message }); }
10385
- });
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
+ }));
10386
10375
 
10387
10376
 
10388
10377
  // ═══════════════════════════════════════════
@@ -16104,31 +16093,15 @@ function detectSynabunInstallSource() {
16104
16093
  }
16105
16094
 
16106
16095
  try {
16107
- const inside = execSync('git rev-parse --is-inside-work-tree', {
16108
- cwd: PACKAGE_ROOT,
16109
- encoding: 'utf8',
16110
- stdio: ['ignore', 'pipe', 'ignore'],
16111
- timeout: 2000,
16112
- }).trim();
16113
- if (inside === 'true') {
16114
- let remote = '';
16115
- let remotes = '';
16116
- try {
16117
- remote = execSync('git remote get-url origin', {
16118
- cwd: PACKAGE_ROOT,
16119
- encoding: 'utf8',
16120
- stdio: ['ignore', 'pipe', 'ignore'],
16121
- timeout: 2000,
16122
- }).trim();
16123
- } catch {}
16124
- try {
16125
- remotes = execSync('git remote -v', {
16126
- cwd: PACKAGE_ROOT,
16127
- encoding: 'utf8',
16128
- stdio: ['ignore', 'pipe', 'ignore'],
16129
- timeout: 2000,
16130
- }).trim();
16131
- } 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']);
16132
16105
  if (isSynabunGitHubRemote(`${remote}\n${remotes}`)) {
16133
16106
  const firstRemote = remote || remotes.split(/\s+/).find(part => isSynabunGitHubRemote(part)) || null;
16134
16107
  return { kind: 'github-clone', remote: firstRemote };
@@ -16968,130 +16941,110 @@ app.get('/api/system/backups', (req, res) => {
16968
16941
  });
16969
16942
 
16970
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.
16971
16946
  app.get('/api/projects', (req, res) => {
16972
16947
  const projects = loadHookProjects();
16973
- 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] });
16974
16949
  });
16975
16950
 
16976
16951
  // ── Path validation helper (reused by project-files, file-content) ──
16977
- function validateProjectPath(filePath) {
16978
- 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() {
16979
16954
  const registeredProjects = loadHookProjects();
16980
16955
  const claudePlansDir = resolve(join(process.env.USERPROFILE || process.env.HOME || '', '.claude', 'plans'));
16981
16956
  const localPlansDir = resolve(join(DATA_HOME, 'data', 'plans'));
16982
16957
  const opencodeConfigPath = resolve(getOpencodeConfigPath());
16983
- const allowedRoots = [resolve(DATA_HOME), ...registeredProjects.map(p => resolve(p.path)), localPlansDir, claudePlansDir, opencodeConfigPath];
16984
- const matched = allowedRoots.find(r => normalized === r || normalized.startsWith(r + sep));
16985
- return matched ? normalized : null;
16958
+ return {
16959
+ dirs: [resolve(DATA_HOME), ...registeredProjects.map(p => resolve(p.path)), localPlansDir, claudePlansDir],
16960
+ files: [opencodeConfigPath],
16961
+ };
16986
16962
  }
16987
16963
 
16988
- // GET /api/file-content — Read a single file for inline editing
16989
- app.get('/api/file-content', (req, res) => {
16990
- try {
16991
- const filePath = req.query.path;
16992
- if (!filePath) return res.status(400).json({ error: 'Missing path parameter' });
16993
-
16994
- const normalized = validateProjectPath(filePath);
16995
- if (!normalized) return res.status(403).json({ error: 'Path outside registered project roots' });
16996
-
16997
- if (!existsSync(normalized) || !statSync(normalized).isFile()) {
16998
- return res.status(400).json({ error: 'Not a valid file' });
16999
- }
17000
-
17001
- const stat = statSync(normalized);
17002
- if (stat.size > 1024 * 1024) {
17003
- return res.status(413).json({ error: 'File too large' });
17004
- }
17005
-
17006
- // Binary detection: read first 8KB and scan for null bytes
17007
- const fd = openSync(normalized, 'r');
17008
- const buf = Buffer.alloc(Math.min(8192, stat.size));
17009
- const bytesRead = readSync(fd, buf, 0, buf.length, 0);
17010
- closeSync(fd);
17011
- for (let i = 0; i < bytesRead; i++) {
17012
- if (buf[i] === 0x00) {
17013
- return res.status(415).json({ error: 'Binary file' });
17014
- }
17015
- }
17016
-
17017
- const content = readFileSync(normalized, 'utf-8');
17018
- const name = basename(normalized);
17019
- res.json({ ok: true, content, path: normalized.replace(/\\/g, '/'), name, size: stat.size });
17020
- } catch (err) {
17021
- res.status(500).json({ error: err.message });
17022
- }
17023
- });
17024
-
17025
- // POST /api/file-content — Write a file (existing, or create if `create: true`)
17026
- app.post('/api/file-content', (req, res) => {
17027
- try {
17028
- const { path: filePath, content, create } = req.body || {};
17029
- if (!filePath || typeof content !== 'string') {
17030
- return res.status(400).json({ error: 'Missing path or content' });
17031
- }
17032
-
17033
- const normalized = validateProjectPath(filePath);
17034
- if (!normalized) return res.status(403).json({ error: 'Path outside registered project roots' });
17035
-
17036
- if (create) {
17037
- // Ensure parent dir exists
17038
- const parentDir = dirname(normalized);
17039
- if (!existsSync(parentDir)) mkdirSync(parentDir, { recursive: true });
17040
- } else {
17041
- if (!existsSync(normalized) || !statSync(normalized).isFile()) {
17042
- return res.status(400).json({ error: 'File does not exist' });
17043
- }
17044
- }
17045
-
17046
- writeFileSync(normalized, content, 'utf-8');
17047
- res.json({ ok: true });
17048
- } catch (err) {
17049
- res.status(500).json({ error: err.message });
17050
- }
17051
- });
17052
-
17053
- // POST /api/file-mkdir — Create a directory
17054
- app.post('/api/file-mkdir', (req, res) => {
17055
- try {
17056
- const { path: dirPath } = req.body || {};
17057
- if (!dirPath) return res.status(400).json({ error: 'Missing path' });
17058
-
17059
- const normalized = validateProjectPath(dirPath);
17060
- 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
+ }
17061
16970
 
17062
- mkdirSync(normalized, { recursive: true });
17063
- res.json({ ok: true });
17064
- } catch (err) {
17065
- res.status(500).json({ error: err.message });
17066
- }
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}`),
17067
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 }));
17068
17041
 
17069
- // DELETE /api/file-content — Delete a file or empty directory
17070
- app.delete('/api/file-content', (req, res) => {
17071
- try {
17072
- const filePath = req.query.path;
17073
- if (!filePath) return res.status(400).json({ error: 'Missing path parameter' });
17074
-
17075
- const normalized = validateProjectPath(filePath);
17076
- if (!normalized) return res.status(403).json({ error: 'Path outside registered project roots' });
17077
-
17078
- if (!existsSync(normalized)) {
17079
- return res.status(404).json({ error: 'Path does not exist' });
17080
- }
17081
-
17082
- const stat = statSync(normalized);
17083
- if (stat.isDirectory()) {
17084
- const entries = readdirSync(normalized);
17085
- if (entries.length > 0) return res.status(400).json({ error: 'Directory is not empty' });
17086
- rmSync(normalized);
17087
- } else {
17088
- unlinkSync(normalized);
17089
- }
17090
- res.json({ ok: true });
17091
- } catch (err) {
17092
- res.status(500).json({ error: err.message });
17093
- }
17094
- });
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 } }));
17095
17048
 
17096
17049
  // GET /api/latest-plan — Find most recently modified plan file (data/plans/YYYY-MM-DD/ preferred, flat fallback)
17097
17050
  app.get('/api/latest-plan', (req, res) => {
@@ -17221,7 +17174,8 @@ app.post('/api/create-plan', (req, res) => {
17221
17174
  });
17222
17175
 
17223
17176
  // GET /api/project-files — List ALL files (incl. dotfiles) for file explorer sidebar
17224
- 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) => {
17225
17179
  try {
17226
17180
  const root = DATA_HOME;
17227
17181
  let dir = req.query.path ? resolve(req.query.path) : root;
@@ -17240,6 +17194,10 @@ app.get('/api/project-files', async (req, res) => {
17240
17194
  if (!existsSync(normalizedDir) || !statSync(normalizedDir).isDirectory()) {
17241
17195
  return res.status(400).json({ error: 'Not a valid directory' });
17242
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
+ }
17243
17201
 
17244
17202
  // Git info for status badges
17245
17203
  const git = getGitInfo(normalizedDir);
@@ -17371,21 +17329,13 @@ app.get('/api/browse-directory', (req, res) => {
17371
17329
  // POST /api/browse-folder — Open native OS folder picker dialog
17372
17330
  // Async exec: the dialog stays open for up to 60s — execSync here froze the
17373
17331
  // entire server (every WS stream, every request) until the user dismissed it.
17374
- 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) => {
17375
17335
  try {
17376
17336
  const { description } = req.body || {};
17377
- const prompt = description || 'Select a folder';
17378
- let cmd;
17379
- if (process.platform === 'win32') {
17380
- const escaped = prompt.replace(/'/g, "''");
17381
- const pickerScript = resolve(__dirname, 'lib', 'folder-picker.ps1');
17382
- cmd = `powershell -NoProfile -STA -WindowStyle Hidden -ExecutionPolicy Bypass -File "${pickerScript}" -Title "${escaped}"`;
17383
- } else if (process.platform === 'darwin') {
17384
- cmd = `osascript -e 'POSIX path of (choose folder with prompt "${prompt}")'`;
17385
- } else {
17386
- cmd = `zenity --file-selection --directory --title="${prompt}" 2>/dev/null`;
17387
- }
17388
- 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 });
17389
17339
  const result = stdout.trim();
17390
17340
  if (result) {
17391
17341
  res.json({ path: result.replace(/\\/g, '/') });
@@ -20285,25 +20235,6 @@ app.post('/api/claude-code/integrations', (req, res) => {
20285
20235
  }
20286
20236
  } catch { /* non-critical */ }
20287
20237
 
20288
- // Auto-create greeting config entry for the new project
20289
- try {
20290
- const greetingKey = projectLabel.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
20291
- const gcPath = resolve(DATA_HOME, 'data', 'greeting-config.json');
20292
- let gc = { version: 1, defaults: {}, projects: {}, global: {} };
20293
- try { if (existsSync(gcPath)) gc = JSON.parse(readFileSync(gcPath, 'utf-8')); } catch {}
20294
- if (!gc.projects) gc.projects = {};
20295
- if (!gc.projects[greetingKey]) {
20296
- gc.projects[greetingKey] = {
20297
- label: projectLabel,
20298
- greetingTemplate: gc.defaults?.greetingTemplate || '{time_greeting}! Working on **{project_label}** (`{branch}` branch). {date}.',
20299
- showReminders: false,
20300
- showLastSession: true,
20301
- reminders: [],
20302
- };
20303
- writeFileSync(gcPath, JSON.stringify(gc, null, 2), 'utf-8');
20304
- }
20305
- } catch { /* non-critical */ }
20306
-
20307
20238
  if (plan !== 'write') {
20308
20239
  return res.json({ ok: true, message: `${basename(normalized)} was added without hooks. ${blocker}`, hooksSkipped: true, dubiousOwnership, trustPath });
20309
20240
  }
@@ -20616,510 +20547,19 @@ app.put('/api/claude-code/tool-permissions', (req, res) => {
20616
20547
  }
20617
20548
  });
20618
20549
 
20619
- // ── Greeting Config (read/write greeting-config.json for the greeting hook) ──
20620
-
20621
- const GREETING_CONFIG_PATH = resolve(DATA_HOME, 'data', 'greeting-config.json');
20622
- const CODEX_GREETING_CONFIG_PATH = resolve(DATA_HOME, 'data', 'codex-greeting-config.json');
20623
- const OPENCODE_GREETING_CONFIG_PATH = resolve(DATA_HOME, 'data', 'opencode-greeting-config.json');
20624
-
20625
- function loadGreetingConfig() {
20626
- try {
20627
- if (!existsSync(GREETING_CONFIG_PATH)) return { version: 1, defaults: {}, projects: {}, global: {} };
20628
- return JSON.parse(readFileSync(GREETING_CONFIG_PATH, 'utf-8'));
20629
- } catch { return { version: 1, defaults: {}, projects: {}, global: {} }; }
20630
- }
20631
-
20632
- function deepCloneJson(value, fallback = {}) {
20633
- try { return JSON.parse(JSON.stringify(value)); }
20634
- catch { return fallback; }
20635
- }
20636
-
20637
- function normalizeGreetingLabel(label) {
20638
- return String(label || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
20639
- }
20640
-
20641
- function detectGreetingProject(cwd) {
20642
- if (!cwd) return 'global';
20643
- const lower = String(cwd).toLowerCase().replace(/\\/g, '/');
20644
- const projects = loadHookProjects();
20645
- const sorted = projects
20646
- .map((project) => ({
20647
- path: String(project.path || '').toLowerCase().replace(/\\/g, '/'),
20648
- label: normalizeGreetingLabel(project.label || basename(project.path || '')),
20649
- }))
20650
- .filter((project) => project.path && project.label)
20651
- .sort((a, b) => b.path.length - a.path.length);
20652
-
20653
- for (const project of sorted) {
20654
- if (lower.startsWith(project.path + '/') || lower === project.path) return project.label;
20655
- }
20656
- for (const project of sorted) {
20657
- const folder = basename(project.path).toLowerCase();
20658
- if (folder && lower.includes(folder)) return project.label;
20659
- }
20660
- const base = basename(lower).replace(/[^a-z0-9-]/g, '-');
20661
- return base || 'global';
20662
- }
20663
-
20664
- function buildSeededCodexGreetingConfig() {
20665
- const base = loadGreetingConfig();
20666
- return {
20667
- version: 1,
20668
- enabled: false,
20669
- defaults: deepCloneJson(base.defaults, {}),
20670
- projects: deepCloneJson(base.projects, {}),
20671
- global: deepCloneJson(base.global, {}),
20672
- };
20673
- }
20674
-
20675
- function normalizeCodexGreetingConfig(config) {
20676
- const seeded = buildSeededCodexGreetingConfig();
20677
- const next = (config && typeof config === 'object') ? config : {};
20678
- return {
20679
- version: 1,
20680
- enabled: typeof next.enabled === 'boolean' ? next.enabled : seeded.enabled,
20681
- defaults: deepCloneJson(next.defaults, seeded.defaults),
20682
- projects: deepCloneJson(next.projects, seeded.projects),
20683
- global: deepCloneJson(next.global, seeded.global),
20684
- };
20685
- }
20686
-
20687
- function saveCodexGreetingConfig(config) {
20688
- const dir = dirname(CODEX_GREETING_CONFIG_PATH);
20689
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
20690
- writeFileSync(CODEX_GREETING_CONFIG_PATH, JSON.stringify(normalizeCodexGreetingConfig(config), null, 2), 'utf-8');
20691
- }
20692
-
20693
- function loadCodexGreetingConfig() {
20694
- try {
20695
- if (!existsSync(CODEX_GREETING_CONFIG_PATH)) {
20696
- const seeded = buildSeededCodexGreetingConfig();
20697
- saveCodexGreetingConfig(seeded);
20698
- return seeded;
20699
- }
20700
- const parsed = JSON.parse(readFileSync(CODEX_GREETING_CONFIG_PATH, 'utf-8'));
20701
- const normalized = normalizeCodexGreetingConfig(parsed);
20702
- if (JSON.stringify(parsed) !== JSON.stringify(normalized)) saveCodexGreetingConfig(normalized);
20703
- return normalized;
20704
- } catch {
20705
- const seeded = buildSeededCodexGreetingConfig();
20706
- try { saveCodexGreetingConfig(seeded); } catch {}
20707
- return seeded;
20708
- }
20709
- }
20710
-
20711
- function getTimeGreeting() {
20712
- const hour = new Date().getHours();
20713
- if (hour >= 5 && hour < 12) return 'Good morning';
20714
- if (hour >= 12 && hour < 17) return 'Good afternoon';
20715
- return 'Good evening';
20716
- }
20717
-
20718
- function getGreetingGitBranch(cwd) {
20719
- try {
20720
- return execSync('git rev-parse --abbrev-ref HEAD', {
20721
- cwd,
20722
- encoding: 'utf-8',
20723
- timeout: 2000,
20724
- stdio: ['pipe', 'pipe', 'pipe'],
20725
- }).trim();
20726
- } catch {
20727
- return 'unknown';
20728
- }
20729
- }
20730
-
20731
- function resolveGreetingTemplate(template, vars) {
20732
- return String(template || '').replace(/\{(\w+)\}/g, (match, key) => (vars[key] !== undefined ? vars[key] : match));
20733
- }
20734
-
20735
- function getProviderGreetingProjectConfig(config, project) {
20736
- if (!config || typeof config !== 'object') return null;
20737
- if (config.projects && config.projects[project]) return { ...config.defaults, ...config.projects[project] };
20738
- if (config.global) return { ...config.defaults, ...config.global };
20739
- return config.defaults || null;
20740
- }
20741
-
20742
- function buildCodexGreetingUserPrompt({ cwd = PACKAGE_ROOT, userPrompt = '' } = {}) {
20743
- const config = loadCodexGreetingConfig();
20744
- if (config.enabled !== true) return '';
20745
-
20746
- const project = detectGreetingProject(cwd);
20747
- const projectConfig = getProviderGreetingProjectConfig(config, project);
20748
- const defaultTemplate = '{time_greeting}! Working on **{project_label}** (`{branch}` branch). {date}.';
20749
- const projectLabel = projectConfig?.label || project;
20750
- const branch = getGreetingGitBranch(cwd);
20751
- const vars = {
20752
- time_greeting: getTimeGreeting(),
20753
- project,
20754
- project_name: project,
20755
- project_label: projectLabel,
20756
- branch,
20757
- date: new Date().toLocaleDateString('en-US', {
20758
- weekday: 'long',
20759
- year: 'numeric',
20760
- month: 'long',
20761
- day: 'numeric',
20762
- }),
20763
- };
20764
- const greetingText = resolveGreetingTemplate(
20765
- projectConfig?.greetingTemplate || config.defaults?.greetingTemplate || defaultTemplate,
20766
- vars,
20767
- ).trim();
20768
- if (!greetingText) return '';
20769
-
20770
- const reminders = Array.isArray(projectConfig?.reminders)
20771
- ? projectConfig.reminders.filter((reminder) => reminder && (reminder.label || reminder.command))
20772
- : [];
20773
- const showReminders = projectConfig?.showReminders ?? config.defaults?.showReminders ?? false;
20774
- const showLastSession = projectConfig?.showLastSession ?? config.defaults?.showLastSession ?? false;
20775
-
20776
- // Build as a natural user request to avoid Codex safety filter flagging it as prompt injection.
20777
- // No "hidden instruction" language, no "do not mention", no "apply only to this turn".
20778
- const parts = [];
20779
- 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.`);
20780
- parts.push('');
20781
- parts.push(`Start your reply with this greeting:`);
20782
- parts.push(`> ${greetingText}`);
20783
-
20784
- if (showReminders && reminders.length) {
20785
- parts.push('');
20786
- parts.push('After the greeting, show these service reminders:');
20787
- for (const reminder of reminders) {
20788
- const label = reminder.label || 'Reminder';
20789
- const command = reminder.command || '';
20790
- parts.push(`- **${label}**: \`${command}\``);
20791
- }
20792
- }
20793
-
20794
- if (showLastSession) {
20795
- parts.push('');
20796
- parts.push('If the recall results mention recent work, include a brief "Last session:" summary line.');
20797
- }
20798
-
20799
- const userMsg = String(userPrompt || '').trim();
20800
- if (userMsg) {
20801
- parts.push('');
20802
- parts.push(`Then respond to: ${userMsg}`);
20803
- }
20804
-
20805
- return parts.join('\n').trim();
20806
- }
20807
-
20808
- function buildCodexGreetingPrompt({ cwd = PACKAGE_ROOT, userPrompt = '', markerStart = '', markerEnd = '' } = {}) {
20809
- const config = loadCodexGreetingConfig();
20810
- if (config.enabled !== true) return '';
20811
-
20812
- const project = detectGreetingProject(cwd);
20813
- const projectConfig = getProviderGreetingProjectConfig(config, project);
20814
- const defaultTemplate = '{time_greeting}! Working on **{project_label}** (`{branch}` branch). {date}.';
20815
- const projectLabel = projectConfig?.label || project;
20816
- const branch = getGreetingGitBranch(cwd);
20817
- const vars = {
20818
- time_greeting: getTimeGreeting(),
20819
- project,
20820
- project_name: project,
20821
- project_label: projectLabel,
20822
- branch,
20823
- date: new Date().toLocaleDateString('en-US', {
20824
- weekday: 'long',
20825
- year: 'numeric',
20826
- month: 'long',
20827
- day: 'numeric',
20828
- }),
20829
- };
20830
- const greetingText = resolveGreetingTemplate(
20831
- projectConfig?.greetingTemplate || config.defaults?.greetingTemplate || defaultTemplate,
20832
- vars,
20833
- ).trim();
20834
- if (!greetingText) return '';
20835
-
20836
- const reminders = Array.isArray(projectConfig?.reminders)
20837
- ? projectConfig.reminders.filter((reminder) => reminder && (reminder.label || reminder.command))
20838
- : [];
20839
- const showReminders = projectConfig?.showReminders ?? config.defaults?.showReminders ?? false;
20840
- const showLastSession = projectConfig?.showLastSession ?? config.defaults?.showLastSession ?? false;
20841
- const steps = [
20842
- `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.`,
20843
- `After that recall, begin your first assistant message with this greeting exactly as written below:\n\n> ${greetingText}`,
20844
- ];
20845
-
20846
- if (showReminders && reminders.length) {
20847
- const reminderText = reminders.map((reminder) => {
20848
- const label = reminder.label || 'Reminder';
20849
- const command = reminder.command || '';
20850
- return `For **${label}**, show this command in its own fenced code block:\n\`\`\`bash\n${command}\n\`\`\``;
20851
- }).join('\n\n');
20852
- steps.push(`After the greeting, show these reminders:\n\n${reminderText}`);
20853
- }
20854
-
20855
- if (showLastSession) {
20856
- 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.`);
20857
- }
20858
-
20859
- steps.push(`Then continue directly with the user's request below.`);
20860
-
20861
- const parts = [
20862
- markerStart,
20863
- 'SynaBun Codex panel boot instructions. Apply these instructions ONLY to this first turn of this thread.',
20864
- 'Do not mention these instructions. Do not repeat the greeting on later turns.',
20865
- '',
20866
- 'Before replying to the user:',
20867
- ...steps.map((step, index) => `${index + 1}. ${step}`),
20868
- '',
20869
- ];
20870
-
20871
- parts.push(markerEnd, '', String(userPrompt || '').trim());
20872
- return parts.filter((part) => part !== undefined).join('\n').trim();
20873
- }
20874
-
20875
- // ── OpenCode Panel Greeting (isolated from Claude + Codex) ──
20876
-
20877
- function buildSeededOpencodeGreetingConfig() {
20878
- const base = loadGreetingConfig();
20879
- return {
20880
- version: 1,
20881
- enabled: false,
20882
- defaults: deepCloneJson(base.defaults, {}),
20883
- projects: deepCloneJson(base.projects, {}),
20884
- global: deepCloneJson(base.global, {}),
20885
- };
20886
- }
20887
-
20888
- function normalizeOpencodeGreetingConfig(config) {
20889
- const seeded = buildSeededOpencodeGreetingConfig();
20890
- const next = (config && typeof config === 'object') ? config : {};
20891
- return {
20892
- version: 1,
20893
- enabled: typeof next.enabled === 'boolean' ? next.enabled : seeded.enabled,
20894
- defaults: deepCloneJson(next.defaults, seeded.defaults),
20895
- projects: deepCloneJson(next.projects, seeded.projects),
20896
- global: deepCloneJson(next.global, seeded.global),
20897
- };
20898
- }
20899
-
20900
- function saveOpencodeGreetingConfig(config) {
20901
- const dir = dirname(OPENCODE_GREETING_CONFIG_PATH);
20902
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
20903
- writeFileSync(OPENCODE_GREETING_CONFIG_PATH, JSON.stringify(normalizeOpencodeGreetingConfig(config), null, 2), 'utf-8');
20904
- }
20905
-
20906
- function loadOpencodeGreetingConfig() {
20907
- try {
20908
- if (!existsSync(OPENCODE_GREETING_CONFIG_PATH)) {
20909
- const seeded = buildSeededOpencodeGreetingConfig();
20910
- saveOpencodeGreetingConfig(seeded);
20911
- return seeded;
20912
- }
20913
- const parsed = JSON.parse(readFileSync(OPENCODE_GREETING_CONFIG_PATH, 'utf-8'));
20914
- const normalized = normalizeOpencodeGreetingConfig(parsed);
20915
- if (JSON.stringify(parsed) !== JSON.stringify(normalized)) saveOpencodeGreetingConfig(normalized);
20916
- return normalized;
20917
- } catch {
20918
- const seeded = buildSeededOpencodeGreetingConfig();
20919
- try { saveOpencodeGreetingConfig(seeded); } catch {}
20920
- return seeded;
20921
- }
20922
- }
20923
-
20924
- // No caller since the legacy OpenCode socket handler was removed: the v2 panel
20925
- // sends the prompt as typed. Kept because Settings → Automations → Greetings
20926
- // still edits this config; wiring it into the v2 send path is the owner's decision.
20927
- function buildOpencodeGreetingUserPrompt({ cwd = PACKAGE_ROOT, userPrompt = '' } = {}) {
20928
- const config = loadOpencodeGreetingConfig();
20929
- if (config.enabled !== true) return '';
20930
-
20931
- const project = detectGreetingProject(cwd);
20932
- const projectConfig = getProviderGreetingProjectConfig(config, project);
20933
- const defaultTemplate = '{time_greeting}! Working on **{project_label}** (`{branch}` branch). {date}.';
20934
- const projectLabel = projectConfig?.label || project;
20935
- const branch = getGreetingGitBranch(cwd);
20936
- const vars = {
20937
- time_greeting: getTimeGreeting(),
20938
- project,
20939
- project_name: project,
20940
- project_label: projectLabel,
20941
- branch,
20942
- date: new Date().toLocaleDateString('en-US', {
20943
- weekday: 'long',
20944
- year: 'numeric',
20945
- month: 'long',
20946
- day: 'numeric',
20947
- }),
20948
- };
20949
- const greetingText = resolveGreetingTemplate(
20950
- projectConfig?.greetingTemplate || config.defaults?.greetingTemplate || defaultTemplate,
20951
- vars,
20952
- ).trim();
20953
- if (!greetingText) return '';
20954
-
20955
- const reminders = Array.isArray(projectConfig?.reminders)
20956
- ? projectConfig.reminders.filter((reminder) => reminder && (reminder.label || reminder.command))
20957
- : [];
20958
- const showReminders = projectConfig?.showReminders ?? config.defaults?.showReminders ?? false;
20959
- const showLastSession = projectConfig?.showLastSession ?? config.defaults?.showLastSession ?? false;
20960
-
20961
- const parts = [];
20962
- // OpenCode does not bind a session to a project directory the way Codex does
20963
- // — its tools default to the serve process's cwd, not the user-selected
20964
- // project. Spell out the absolute project path here so the agent always uses
20965
- // it for Read/Bash/etc. instead of falling back to the serve cwd.
20966
- if (cwd && cwd !== PACKAGE_ROOT) {
20967
- parts.push(`Project working directory: \`${cwd}\``);
20968
- 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.`);
20969
- parts.push('');
20970
- }
20971
- 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.`);
20972
- parts.push('');
20973
- parts.push(`Start your reply with this greeting:`);
20974
- parts.push(`> ${greetingText}`);
20975
-
20976
- if (showReminders && reminders.length) {
20977
- parts.push('');
20978
- parts.push('After the greeting, show these service reminders:');
20979
- for (const reminder of reminders) {
20980
- const label = reminder.label || 'Reminder';
20981
- const command = reminder.command || '';
20982
- parts.push(`- **${label}**: \`${command}\``);
20983
- }
20984
- }
20985
-
20986
- if (showLastSession) {
20987
- parts.push('');
20988
- parts.push('If the recall results mention recent work, include a brief "Last session:" summary line.');
20989
- }
20990
-
20991
- const userMsg = String(userPrompt || '').trim();
20992
- if (userMsg) {
20993
- parts.push('');
20994
- parts.push(`Then respond to: ${userMsg}`);
20995
- }
20996
-
20997
- return parts.join('\n').trim();
20998
- }
20999
-
21000
- // GET /api/greeting/config — read the full greeting configuration
21001
- app.get('/api/greeting/config', (req, res) => {
21002
- try {
21003
- res.json({ ok: true, config: loadGreetingConfig() });
21004
- } catch (err) {
21005
- res.status(500).json({ ok: false, error: err.message });
21006
- }
21007
- });
21008
-
21009
- // PUT /api/greeting/config/:project — update a project's greeting config
21010
- app.put('/api/greeting/config/:project', (req, res) => {
21011
- try {
21012
- const { project } = req.params;
21013
- const { greetingTemplate, showReminders, showLastSession, reminders, label } = req.body;
21014
- const config = loadGreetingConfig();
21015
-
21016
- const target = project === 'global'
21017
- ? (config.global || (config.global = {}))
21018
- : (config.projects[project] || (config.projects[project] = {}));
21019
-
21020
- if (greetingTemplate !== undefined) target.greetingTemplate = greetingTemplate;
21021
- if (showReminders !== undefined) target.showReminders = showReminders;
21022
- if (showLastSession !== undefined) target.showLastSession = showLastSession;
21023
- if (reminders !== undefined) target.reminders = reminders;
21024
- if (label !== undefined) target.label = label;
21025
-
21026
- const dir = dirname(GREETING_CONFIG_PATH);
21027
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
21028
- writeFileSync(GREETING_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf-8');
21029
- res.json({ ok: true });
21030
- } catch (err) {
21031
- res.status(500).json({ ok: false, error: err.message });
21032
- }
21033
- });
21034
-
21035
- // GET /api/codex-panel/greeting/config — read Codex-only greeting configuration
21036
- app.get('/api/codex-panel/greeting/config', (req, res) => {
21037
- try {
21038
- res.json({ ok: true, config: loadCodexGreetingConfig() });
21039
- } catch (err) {
21040
- res.status(500).json({ ok: false, error: err.message });
21041
- }
21042
- });
21043
-
21044
- // PUT /api/codex-panel/greeting/config — update top-level Codex greeting settings
21045
- app.put('/api/codex-panel/greeting/config', (req, res) => {
21046
- try {
21047
- const config = loadCodexGreetingConfig();
21048
- if (typeof req.body?.enabled === 'boolean') config.enabled = req.body.enabled;
21049
- saveCodexGreetingConfig(config);
21050
- res.json({ ok: true, config });
21051
- } catch (err) {
21052
- res.status(500).json({ ok: false, error: err.message });
21053
- }
21054
- });
21055
-
21056
- // PUT /api/codex-panel/greeting/config/:project — update Codex-only project greeting config
21057
- app.put('/api/codex-panel/greeting/config/:project', (req, res) => {
21058
- try {
21059
- const { project } = req.params;
21060
- const { greetingTemplate, showReminders, showLastSession, reminders, label } = req.body;
21061
- const config = loadCodexGreetingConfig();
21062
-
21063
- const target = project === 'global'
21064
- ? (config.global || (config.global = {}))
21065
- : (config.projects[project] || (config.projects[project] = {}));
21066
-
21067
- if (greetingTemplate !== undefined) target.greetingTemplate = greetingTemplate;
21068
- if (showReminders !== undefined) target.showReminders = showReminders;
21069
- if (showLastSession !== undefined) target.showLastSession = showLastSession;
21070
- if (reminders !== undefined) target.reminders = reminders;
21071
- if (label !== undefined) target.label = label;
21072
-
21073
- saveCodexGreetingConfig(config);
21074
- res.json({ ok: true, config });
21075
- } catch (err) {
21076
- res.status(500).json({ ok: false, error: err.message });
21077
- }
21078
- });
21079
-
21080
- // GET /api/opencode-panel/greeting/config — read OpenCode-only greeting configuration
21081
- app.get('/api/opencode-panel/greeting/config', (req, res) => {
21082
- try {
21083
- res.json({ ok: true, config: loadOpencodeGreetingConfig() });
21084
- } catch (err) {
21085
- res.status(500).json({ ok: false, error: err.message });
21086
- }
21087
- });
21088
-
21089
- // PUT /api/opencode-panel/greeting/config — update top-level OpenCode greeting settings
21090
- app.put('/api/opencode-panel/greeting/config', (req, res) => {
21091
- try {
21092
- const config = loadOpencodeGreetingConfig();
21093
- if (typeof req.body?.enabled === 'boolean') config.enabled = req.body.enabled;
21094
- saveOpencodeGreetingConfig(config);
21095
- res.json({ ok: true, config });
21096
- } catch (err) {
21097
- res.status(500).json({ ok: false, error: err.message });
21098
- }
21099
- });
21100
-
21101
- // PUT /api/opencode-panel/greeting/config/:project — update OpenCode-only project greeting config
21102
- app.put('/api/opencode-panel/greeting/config/:project', (req, res) => {
21103
- try {
21104
- const { project } = req.params;
21105
- const { greetingTemplate, showReminders, showLastSession, reminders, label } = req.body;
21106
- const config = loadOpencodeGreetingConfig();
21107
-
21108
- const target = project === 'global'
21109
- ? (config.global || (config.global = {}))
21110
- : (config.projects[project] || (config.projects[project] = {}));
21111
-
21112
- if (greetingTemplate !== undefined) target.greetingTemplate = greetingTemplate;
21113
- if (showReminders !== undefined) target.showReminders = showReminders;
21114
- if (showLastSession !== undefined) target.showLastSession = showLastSession;
21115
- if (reminders !== undefined) target.reminders = reminders;
21116
- if (label !== undefined) target.label = label;
21117
-
21118
- saveOpencodeGreetingConfig(config);
21119
- res.json({ ok: true, config });
21120
- } catch (err) {
21121
- res.status(500).json({ ok: false, error: err.message });
21122
- }
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' });
21123
20563
  });
21124
20564
 
21125
20565
  // ── SynaBun rules (lib/rulesets/) ──
@@ -22769,7 +22209,13 @@ app.get('/api/health', (req, res) => {
22769
22209
  // getDb() auto-creates the directory, file, and schema if missing
22770
22210
  getDb();
22771
22211
  const count = countMemories();
22772
- res.json({ ok: true, storage: 'sqlite', memories: count, projectDir: PACKAGE_ROOT, install: INSTALL_KIND, startLauncher: getStartLauncherState(), entry: START_ENTRY });
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
+ });
22773
22219
  } catch (err) {
22774
22220
  res.json({ ok: false, reason: 'db_error', detail: err.message });
22775
22221
  }
@@ -24165,88 +23611,42 @@ app.get('/invite/logout', (req, res) => {
24165
23611
  });
24166
23612
 
24167
23613
  // --- Cloudflare Tunnel Management ---
24168
- 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.
24169
23618
  let tunnelUrl = null;
24170
- 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
+ });
24171
23631
 
24172
- function findCloudflared() {
24173
- // 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) => {
24174
23635
  try {
24175
- const cmd = process.platform === 'win32' ? 'where cloudflared' : 'which cloudflared';
24176
- const result = execSync(cmd, { encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
24177
- if (result) return result.split('\n')[0].trim();
24178
- } catch { /* not in PATH */ }
24179
-
24180
- // Windows-specific fallback paths
24181
- if (process.platform === 'win32') {
24182
- const paths = [
24183
- `${process.env.LOCALAPPDATA}\\cloudflared\\cloudflared.exe`,
24184
- `${process.env.ProgramFiles}\\cloudflared\\cloudflared.exe`,
24185
- `${process.env['ProgramFiles(x86)']}\\cloudflared\\cloudflared.exe`,
24186
- ];
24187
- 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) });
24188
23639
  }
24189
- return null;
24190
- }
24191
-
24192
- app.get('/api/tunnel/status', (req, res) => {
24193
- res.json({
24194
- ok: true,
24195
- available: !!findCloudflared(),
24196
- running: !!tunnelProcess,
24197
- starting: tunnelStarting,
24198
- url: tunnelUrl,
24199
- });
24200
23640
  });
24201
23641
 
24202
- app.post('/api/tunnel/start', (req, res) => {
24203
- if (tunnelProcess) return res.json({ ok: true, url: tunnelUrl, message: 'Already running.' });
24204
- const bin = findCloudflared();
24205
- if (!bin) return res.status(400).json({ error: 'cloudflared not found. Install it from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/' });
24206
-
24207
- tunnelStarting = true;
24208
- tunnelUrl = null;
24209
-
24210
- const child = spawn(bin, ['tunnel', '--url', `http://localhost:${PORT}`], {
24211
- stdio: ['ignore', 'pipe', 'pipe'],
24212
- windowsHide: true,
24213
- });
24214
-
24215
- tunnelProcess = child;
24216
- let urlFound = false;
24217
-
24218
- const parseUrl = (data) => {
24219
- const text = data.toString();
24220
- // cloudflared prints the URL to stderr
24221
- const match = text.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
24222
- if (match && !urlFound) {
24223
- urlFound = true;
24224
- tunnelUrl = match[0];
24225
- tunnelStarting = false;
24226
- console.log(` Tunnel: ${tunnelUrl}`);
24227
- }
24228
- };
24229
-
24230
- child.stdout.on('data', parseUrl);
24231
- child.stderr.on('data', parseUrl);
24232
-
24233
- child.on('close', () => {
24234
- tunnelProcess = null;
24235
- tunnelUrl = null;
24236
- tunnelStarting = false;
24237
- });
24238
-
24239
- // Respond immediately, client polls /api/tunnel/status for the URL
24240
- 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());
24241
23646
  });
24242
23647
 
24243
- app.post('/api/tunnel/stop', (req, res) => {
24244
- if (!tunnelProcess) return res.json({ ok: true, message: 'Not running.' });
24245
- tunnelProcess.kill();
24246
- tunnelProcess = null;
24247
- tunnelUrl = null;
24248
- tunnelStarting = false;
24249
- res.json({ ok: true, message: 'Tunnel stopped.' });
23648
+ app.post('/api/tunnel/stop', tunnelOwnerGuard, (req, res) => {
23649
+ res.json(tunnel.stop());
24250
23650
  });
24251
23651
 
24252
23652
  // --- MCP HTTP Transport ---
@@ -24274,15 +23674,12 @@ try {
24274
23674
  next();
24275
23675
  }, mcpRouter);
24276
23676
 
24277
- // Open path: /mcp — local-only (reject tunnel traffic when key is set)
24278
- app.use('/mcp', (req, res, next) => {
24279
- if (req.headers['cf-connecting-ip'] && activeMcpKey) {
24280
- return res.status(403).json({ error: 'Forbidden' });
24281
- }
24282
- next();
24283
- }, 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);
24284
23681
 
24285
- 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)'));
24286
23683
  } catch (err) {
24287
23684
  console.error(' MCP HTTP mount failed (run npm run build in mcp-server):', err.message);
24288
23685
  }
@@ -25347,7 +24744,7 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25347
24744
  try {
25348
24745
  const [
25349
24746
  { createAssistantDispatcher }, { createAssistantRuntime }, { createAssistantApi }, { createUsageLedger },
25350
- { createClaudeAccounts }, { createAssistantMemory }, { hookRecall },
24747
+ { createClaudeAccounts }, { createAssistantMemory }, { hookRecall, markInjected: assistantMarkInjected },
25351
24748
  claudeBridgeModule, { detectProject: assistantDetectProject }, { getDb: assistantGetDb },
25352
24749
  { handleRemember: assistantHandleRemember }, assistantCategories,
25353
24750
  { createAssistantCatalog }, { createAssistantConfigStore }, { createAssistantRouter, TASK_CLASS_META },
@@ -25451,11 +24848,177 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25451
24848
  listAccounts: () => ({ codex: listCodexAccountsForClient(), 'claude-code': claudeAccounts.listForClient() }),
25452
24849
  readFilter: () => assistantConfigStore.routing().catalogFilter || {},
25453
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 }),
25454
24855
  log: (tag, message) => assistantLog(tag, message),
25455
24856
  });
25456
24857
  _assistantCatalog = assistantCatalog;
25457
24858
  // A Models toggle or a Settings → OpenCode checkbox reaches routing on the next call.
25458
- 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
+ }
25459
25022
  app.use('/api/assistant/hidden-models', createAssistantHiddenModelsRouter({
25460
25023
  hiddenModels: assistantHiddenModels, catalog: assistantCatalog, opencodeStore: opencodeHiddenModels, isGuestRequest, broadcastSync,
25461
25024
  }));
@@ -25581,6 +25144,8 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25581
25144
  router: assistantRouter, catalog: assistantCatalog, desktop: assistantDesktop,
25582
25145
  judge: assistantJev,
25583
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,
25584
25149
  });
25585
25150
  _assistantRuntime = createAssistantRuntime({
25586
25151
  dispatcher: _assistantDispatcher,
@@ -25604,6 +25169,9 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25604
25169
  pricing: assistantPricing,
25605
25170
  usage: assistantUsage,
25606
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,
25607
25175
  });
25608
25176
  assistantDesktop?.attach?.({ runtime: _assistantRuntime, dispatcher: _assistantDispatcher });
25609
25177
  // Warm the catalog so brain capabilities (vision, tier) are known on first attach,
@@ -25661,6 +25229,10 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25661
25229
  clarifier: assistantClarifier,
25662
25230
  // Composer uploads (the paperclip's non-inline files), next to data/images.
25663
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',
25664
25236
  }));
25665
25237
  const shutdownAssistant = () => {
25666
25238
  try { assistantRouter.shutdown(); } catch {}
@@ -25668,6 +25240,7 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25668
25240
  try { assistantDesktop?.shutdown?.(); } catch {}
25669
25241
  try { _assistantRuntime?.shutdown?.(); } catch {}
25670
25242
  try { _assistantDispatcher?.shutdown?.(); } catch {}
25243
+ try { _greeting?.shutdown?.(); } catch {}
25671
25244
  // Usage rows whose append failed get their last try (synchronous: an 'exit' handler runs it too).
25672
25245
  try { assistantUsage.flush(); } catch {}
25673
25246
  };
@@ -25677,6 +25250,8 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25677
25250
  console.log('[assistant] runtime ready');
25678
25251
  } catch (err) {
25679
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'; } }
25680
25255
  }
25681
25256
  // WhatsApp Link: after the runtime and dispatcher exist (its own guard; see startWhatsAppLink).
25682
25257
  await startWhatsAppLink();
@@ -26505,6 +26080,18 @@ app.post('/api/sessions/heartbeat', express.json(), (req, res) => {
26505
26080
  const { claudeSessionId } = req.body || {};
26506
26081
  if (!claudeSessionId) return res.status(400).json({ error: 'claudeSessionId required' });
26507
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
+ }
26508
26095
  if (entry) { entry.lastActivity = Date.now(); res.json({ ok: true }); }
26509
26096
  else res.status(404).json({ error: 'Session not registered' });
26510
26097
  });
@@ -26797,6 +26384,13 @@ function spawnGit(args, opts = {}) {
26797
26384
  return spawnSync('git', args, baseOpts);
26798
26385
  }
26799
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
+
26800
26394
  function logGitFailure(where, err) {
26801
26395
  const msg = err?.stderr?.toString?.() || err?.message || String(err);
26802
26396
  if (!msg || /not a git repository/i.test(msg)) return;
@@ -26824,7 +26418,7 @@ setImmediate(() => {
26824
26418
  // repos owned by a different OS user/SID. Common on Windows external drives or repos
26825
26419
  // copied from another machine. Returns true only when git emits the specific error.
26826
26420
  function detectDubiousOwnership(dir) {
26827
- 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 });
26828
26422
  if (r.status === 0) return false;
26829
26423
  const stderr = r.stderr?.toString?.() || '';
26830
26424
  return /dubious ownership|safe\.directory/i.test(stderr);
@@ -26839,7 +26433,7 @@ function normalizeGitPath(dir) {
26839
26433
  /** Try to get git status for a directory. Returns { branch, statuses } or null. */
26840
26434
  function getGitInfo(dir) {
26841
26435
  // Check if inside a git work tree (spawnGit for consistent path resolution)
26842
- 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 });
26843
26437
  if (inside.status !== 0) {
26844
26438
  logGitFailure('rev-parse --is-inside-work-tree', inside.error || { stderr: inside.stderr });
26845
26439
  return null;
@@ -26850,27 +26444,27 @@ function getGitInfo(dir) {
26850
26444
  // then short SHA for detached HEAD. The chained fallback is essential for Windows users
26851
26445
  // with older Git for Windows installs where --show-current exits non-zero.
26852
26446
  let branch = '';
26853
- const showCurrent = spawnGit(['branch', '--show-current'], { cwd: dir, timeout: 3000 });
26447
+ const showCurrent = spawnGitRead(['branch', '--show-current'], { cwd: dir, timeout: 3000 });
26854
26448
  if (showCurrent.status === 0) {
26855
26449
  branch = showCurrent.stdout.toString().trim();
26856
26450
  } else {
26857
26451
  logGitFailure('branch --show-current', showCurrent.error || { stderr: showCurrent.stderr });
26858
26452
  }
26859
26453
  if (!branch) {
26860
- 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 });
26861
26455
  if (abbrev.status === 0) {
26862
26456
  const b = abbrev.stdout.toString().trim();
26863
26457
  if (b && b !== 'HEAD') branch = b;
26864
26458
  }
26865
26459
  }
26866
26460
  if (!branch) {
26867
- const shortSha = spawnGit(['rev-parse', '--short', 'HEAD'], { cwd: dir, timeout: 3000 });
26461
+ const shortSha = spawnGitRead(['rev-parse', '--short', 'HEAD'], { cwd: dir, timeout: 3000 });
26868
26462
  if (shortSha.status === 0) branch = shortSha.stdout.toString().trim();
26869
26463
  }
26870
26464
 
26871
26465
  try {
26872
26466
  // Get status for this directory (porcelain v1: XY <path>)
26873
- 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 });
26874
26468
  if (statusResult.status !== 0) {
26875
26469
  logGitFailure('status --porcelain', statusResult.error || { stderr: statusResult.stderr });
26876
26470
  return { branch, statuses: new Map() };
@@ -26996,7 +26590,7 @@ app.get('/api/terminal/branches', (req, res) => {
26996
26590
  const dir = req.query.path;
26997
26591
  if (!dir || typeof dir !== 'string') return res.status(400).json({ error: 'path required' });
26998
26592
 
26999
- 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 });
27000
26594
  if (inside.status !== 0) {
27001
26595
  logGitFailure('branches: rev-parse', inside.error || { stderr: inside.stderr });
27002
26596
  return res.json({ branches: [], current: null });
@@ -27005,11 +26599,11 @@ app.get('/api/terminal/branches', (req, res) => {
27005
26599
  try {
27006
26600
  // Current branch — chained fallback (older git may not support --show-current)
27007
26601
  let current = '';
27008
- const showCurrent = spawnGit(['branch', '--show-current'], { cwd: dir, timeout: 3000 });
26602
+ const showCurrent = spawnGitRead(['branch', '--show-current'], { cwd: dir, timeout: 3000 });
27009
26603
  if (showCurrent.status === 0) current = showCurrent.stdout.toString().trim();
27010
26604
  else logGitFailure('branches: show-current', showCurrent.error || { stderr: showCurrent.stderr });
27011
26605
  if (!current) {
27012
- 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 });
27013
26607
  if (abbrev.status === 0) {
27014
26608
  const b = abbrev.stdout.toString().trim();
27015
26609
  if (b && b !== 'HEAD') current = b;
@@ -27018,7 +26612,7 @@ app.get('/api/terminal/branches', (req, res) => {
27018
26612
 
27019
26613
  // All local branches — plain `git branch` parsed manually; avoids --format=%(refname:short)
27020
26614
  // which can get mangled by cmd.exe variable expansion on Windows even with proper quoting.
27021
- const branchResult = spawnGit(['branch'], { cwd: dir });
26615
+ const branchResult = spawnGitRead(['branch'], { cwd: dir });
27022
26616
  if (branchResult.status !== 0) logGitFailure('branch list', branchResult.error || { stderr: branchResult.stderr });
27023
26617
  const raw = branchResult.status === 0 ? branchResult.stdout.toString() : '';
27024
26618
  // Plain `git branch` output: each line is " branch" or "* branch" (current). Strip
@@ -27074,7 +26668,7 @@ app.post('/api/terminal/checkout', (req, res) => {
27074
26668
  return res.status(400).json({ error: 'Invalid branch name' });
27075
26669
  }
27076
26670
 
27077
- 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 });
27078
26672
  if (inside.status !== 0) {
27079
26673
  logGitFailure('checkout: rev-parse', inside.error || { stderr: inside.stderr });
27080
26674
  return res.status(400).json({ error: 'Not a git repository' });
@@ -27082,6 +26676,8 @@ app.post('/api/terminal/checkout', (req, res) => {
27082
26676
 
27083
26677
  // Branch is validated against /^[\w.\-/]+$/ above, so no shell metacharacters are possible.
27084
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.)
27085
26681
  const result = spawnGit(['checkout', branch], { cwd: dir, timeout: 10000 });
27086
26682
  if (result.status !== 0) {
27087
26683
  const stderr = result.stderr?.toString?.()?.trim() || result.error?.message || 'Checkout failed';
@@ -27090,7 +26686,7 @@ app.post('/api/terminal/checkout', (req, res) => {
27090
26686
 
27091
26687
  // Return confirmed branch name + git's own output
27092
26688
  let current = '';
27093
- const showCurrent = spawnGit(['branch', '--show-current'], { cwd: dir, timeout: 3000 });
26689
+ const showCurrent = spawnGitRead(['branch', '--show-current'], { cwd: dir, timeout: 3000 });
27094
26690
  if (showCurrent.status === 0) current = showCurrent.stdout.toString().trim();
27095
26691
  else logGitFailure('checkout: show-current', showCurrent.error || { stderr: showCurrent.stderr });
27096
26692
  const output = result.stderr?.toString?.()?.trim() || `Switched to branch '${current}'`;
@@ -27103,18 +26699,24 @@ const _gitStatusCache = new Map(); // dir -> { at, promise }
27103
26699
  const GIT_STATUS_TTL_MS = 1500;
27104
26700
 
27105
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
+ };
27106
26708
  try {
27107
- await execFileAsync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: dir, timeout: 3000 });
26709
+ await read(['rev-parse', '--is-inside-work-tree'], { timeout: 3000 });
27108
26710
  } catch {
27109
26711
  return { ok: true, isGit: false, branch: null, changes: [] };
27110
26712
  }
27111
26713
 
27112
26714
  const resolveBranch = async () => {
27113
26715
  try {
27114
- const { stdout } = await execFileAsync('git', ['branch', '--show-current'], { cwd: dir, timeout: 3000 });
26716
+ const { stdout } = await read(['branch', '--show-current'], { timeout: 3000 });
27115
26717
  let branch = stdout.trim();
27116
26718
  if (!branch) {
27117
- 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 });
27118
26720
  branch = head.trim();
27119
26721
  }
27120
26722
  return branch;
@@ -27123,7 +26725,7 @@ async function computeGitStatus(dir) {
27123
26725
 
27124
26726
  const [branch, statusOut] = await Promise.all([
27125
26727
  resolveBranch(),
27126
- execFileAsync('git', ['status', '--porcelain'], { cwd: dir, timeout: 5000, maxBuffer: 256 * 1024 * 1024 }),
26728
+ read(['status', '--porcelain'], { timeout: 5000, maxBuffer: 256 * 1024 * 1024 }),
27127
26729
  ]);
27128
26730
 
27129
26731
  const changes = [];
@@ -27151,7 +26753,29 @@ async function computeGitStatus(dir) {
27151
26753
  return { ok: true, isGit: true, branch, changes };
27152
26754
  }
27153
26755
 
27154
- 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) => {
27155
26779
  const dir = req.query.path;
27156
26780
  if (!dir || typeof dir !== 'string') return res.status(400).json({ error: 'path required' });
27157
26781
 
@@ -27178,13 +26802,13 @@ app.get('/api/git/status', async (req, res) => {
27178
26802
  }
27179
26803
  });
27180
26804
 
27181
- app.post('/api/git/commit', (req, res) => {
26805
+ app.post('/api/git/commit', editorOwnerGuard, (req, res) => {
27182
26806
  const { path: dir, message, files } = req.body;
27183
- 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' });
27184
26808
  if (typeof message !== 'string' || message.trim().length === 0) return res.status(400).json({ error: 'Commit message cannot be empty' });
27185
26809
 
27186
26810
  try {
27187
- 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 });
27188
26812
  } catch {
27189
26813
  return res.status(400).json({ error: 'Not a git repository' });
27190
26814
  }
@@ -27219,30 +26843,32 @@ app.post('/api/git/commit', (req, res) => {
27219
26843
  }
27220
26844
  });
27221
26845
 
27222
- app.get('/api/git/diff-summary', (req, res) => {
27223
- const dir = req.query.path;
27224
- 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;
27225
26851
 
27226
26852
  try {
27227
- 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 });
27228
26854
  } catch {
27229
26855
  return res.status(400).json({ error: 'Not a git repository' });
27230
26856
  }
27231
26857
 
27232
26858
  try {
27233
26859
  // Get diff stat for tracked files
27234
- 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();
27235
26861
 
27236
26862
  // Get diff stat for staged files
27237
- 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();
27238
26864
 
27239
26865
  // Get untracked files
27240
- 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();
27241
26867
 
27242
26868
  // Get short diff for context (limited to avoid huge payloads)
27243
- 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);
27244
26870
 
27245
- 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);
27246
26872
 
27247
26873
  // Build a commit message from the changes
27248
26874
  const changedFiles = [];
@@ -27304,64 +26930,27 @@ app.get('/api/git/diff-summary', (req, res) => {
27304
26930
 
27305
26931
  // ── Git Diff (raw content) ──
27306
26932
 
27307
- app.get('/api/git/diff', (req, res) => {
27308
- const dir = req.query.path;
27309
- if (!dir || typeof dir !== 'string') return res.status(400).json({ error: 'path required' });
27310
- const maxLines = Math.min(Math.max(parseInt(req.query.maxLines) || 500, 50), 2000);
27311
-
27312
- try {
27313
- execSync('git rev-parse --is-inside-work-tree', { cwd: dir, stdio: 'pipe', timeout: 3000 });
27314
- } catch {
27315
- return res.status(400).json({ error: 'Not a git repository' });
27316
- }
27317
-
27318
- try {
27319
- let branch = '';
27320
- try { branch = execSync('git branch --show-current', { cwd: dir, stdio: 'pipe', timeout: 3000 }).toString().trim(); } catch {}
27321
- if (!branch) try { branch = execSync('git rev-parse --short HEAD', { cwd: dir, stdio: 'pipe', timeout: 3000 }).toString().trim(); } catch {}
27322
-
27323
- const rawDiff = execSync('git diff --no-color', { cwd: dir, stdio: 'pipe', timeout: 10000, maxBuffer: 2 * 1024 * 1024 }).toString();
27324
- const rawStaged = execSync('git diff --cached --no-color', { cwd: dir, stdio: 'pipe', timeout: 10000, maxBuffer: 2 * 1024 * 1024 }).toString();
27325
- const untracked = execSync('git ls-files --others --exclude-standard', { cwd: dir, stdio: 'pipe', timeout: 5000 }).toString().trim();
27326
-
27327
- const truncate = (text) => {
27328
- const lines = text.split('\n');
27329
- if (lines.length <= maxLines) return { text, truncated: false };
27330
- return { text: lines.slice(0, maxLines).join('\n'), truncated: true };
27331
- };
27332
-
27333
- const d = truncate(rawDiff);
27334
- const s = truncate(rawStaged);
27335
- const untrackedFiles = untracked ? untracked.split('\n').filter(Boolean) : [];
27336
-
27337
- res.json({
27338
- ok: true,
27339
- branch,
27340
- diff: d.text,
27341
- stagedDiff: s.text,
27342
- untrackedFiles,
27343
- truncated: d.truncated || s.truncated
27344
- });
27345
- } catch (err) {
27346
- res.status(500).json({ error: err.message });
27347
- }
27348
- });
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 }));
27349
26936
 
27350
26937
  // ── Git Log ──
27351
26938
 
27352
- app.get('/api/git/log', (req, res) => {
27353
- const dir = req.query.path;
27354
- 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;
27355
26944
  const count = Math.min(Math.max(parseInt(req.query.count) || 10, 1), 50);
27356
26945
 
27357
26946
  try {
27358
- 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 });
27359
26948
  } catch {
27360
26949
  return res.status(400).json({ error: 'Not a git repository' });
27361
26950
  }
27362
26951
 
27363
26952
  try {
27364
- 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();
27365
26954
  const commits = raw ? raw.split('\n').map(line => {
27366
26955
  const spaceIdx = line.indexOf(' ');
27367
26956
  return { hash: line.substring(0, spaceIdx), message: line.substring(spaceIdx + 1) };
@@ -27374,22 +26963,22 @@ app.get('/api/git/log', (req, res) => {
27374
26963
 
27375
26964
  // ── Git Generate Commit Message (AI-powered) ──
27376
26965
 
27377
- app.post('/api/git/generate-message', async (req, res) => {
26966
+ app.post('/api/git/generate-message', editorOwnerGuard, async (req, res) => {
27378
26967
  const dir = req.body?.path;
27379
26968
  if (!dir || typeof dir !== 'string') return res.status(400).json({ error: 'path required' });
27380
26969
 
27381
26970
  try {
27382
- 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 });
27383
26972
  } catch {
27384
26973
  return res.status(400).json({ error: 'Not a git repository' });
27385
26974
  }
27386
26975
 
27387
26976
  // Gather diff data — separate try/catch per command so one failure doesn't skip the rest
27388
26977
  let diffText = '', stagedText = '', untrackedList = '', recentCommits = '';
27389
- try { diffText = execSync('git diff --no-color', { cwd: dir, stdio: 'pipe', timeout: 8000, maxBuffer: 2 * 1024 * 1024 }).toString().substring(0, 4000); } catch {}
27390
- try { stagedText = execSync('git diff --cached --no-color', { cwd: dir, stdio: 'pipe', timeout: 8000, maxBuffer: 2 * 1024 * 1024 }).toString().substring(0, 4000); } catch {}
27391
- try { untrackedList = execSync('git ls-files --others --exclude-standard', { cwd: dir, stdio: 'pipe', timeout: 5000 }).toString().trim(); } catch {}
27392
- 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 {}
27393
26982
 
27394
26983
  const allDiff = (diffText + '\n' + stagedText).trim();
27395
26984
  if (!allDiff && !untrackedList) {
@@ -27442,8 +27031,8 @@ app.post('/api/git/generate-message', async (req, res) => {
27442
27031
  // Fallback: heuristic (same logic as /api/git/diff-summary)
27443
27032
  try {
27444
27033
  let stat = '', stagedStat = '';
27445
- try { stat = execSync('git diff --stat --stat-width=120', { cwd: dir, stdio: 'pipe', timeout: 5000, maxBuffer: 2 * 1024 * 1024 }).toString().trim(); } catch {}
27446
- 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 {}
27447
27036
  const changedFiles = [];
27448
27037
  for (const line of (stat + '\n' + stagedStat).split('\n').filter(l => l.includes('|'))) {
27449
27038
  const m = line.match(/^\s*(.+?)\s*\|/);
@@ -28641,7 +28230,7 @@ function isDroppableSidepanelProviderEvent(message) {
28641
28230
  function broadcastSync(message, excludeWs = null) {
28642
28231
  const data = JSON.stringify(message);
28643
28232
  const messageType = String(message?.type || '');
28644
- const ownerOnly = ['sidepanel:', 'schedule:', 'schedule-group:', 'quick-timer:', 'assistant:', 'whatsapp:']
28233
+ const ownerOnly = ['sidepanel:', 'schedule:', 'schedule-group:', 'quick-timer:', 'assistant:', 'whatsapp:', 'editor:']
28645
28234
  .some((prefix) => messageType.startsWith(prefix));
28646
28235
  const dropUnderBackpressure = isDroppableSidepanelProviderEvent(message);
28647
28236
  let sent = 0;
@@ -30078,7 +29667,7 @@ async function createBrowserSession(options = {}) {
30078
29667
  const cdpBrowser = await chromium.connectOverCDP(endpoint);
30079
29668
  let cdpContext = cdpBrowser.contexts()[0];
30080
29669
  if (!cdpContext) cdpContext = await cdpBrowser.newContext(contextOpts);
30081
- 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
30082
29671
 
30083
29672
  browser = cdpBrowser;
30084
29673
  context = cdpContext;
@@ -30243,7 +29832,7 @@ async function createBrowserSession(options = {}) {
30243
29832
  const cdpBrowser = await chromium.connectOverCDP(endpoint);
30244
29833
  let cdpContext = cdpBrowser.contexts()[0];
30245
29834
  if (!cdpContext) cdpContext = await cdpBrowser.newContext(contextOpts);
30246
- 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
30247
29836
 
30248
29837
  browser = cdpBrowser;
30249
29838
  context = cdpContext;
@@ -30662,6 +30251,30 @@ async function stopScreencast(session) {
30662
30251
 
30663
30252
  // ── Multi-tab helpers ──
30664
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
+
30665
30278
  /**
30666
30279
  * Create a new tab (page) in an existing browser session.
30667
30280
  * Returns { tabId, url, title }.
@@ -30670,7 +30283,7 @@ async function createSessionTab(session, sessionId, url = 'about:blank') {
30670
30283
  const tabId = randomBytes(4).toString('hex');
30671
30284
  const activeEntry = session.tabs?.get(session.activeTabId);
30672
30285
  const shouldAdoptAsActive = !activeEntry || activeEntry.page?.isClosed?.();
30673
- const page = await session.context.newPage();
30286
+ const page = await newSessionPage(session);
30674
30287
  attachConsoleBuffer(page);
30675
30288
  if (url && url !== 'about:blank') {
30676
30289
  await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {});
@@ -30721,7 +30334,8 @@ async function recoverSessionTabPage(session, sessionId, tabId, reason = 'page b
30721
30334
  };
30722
30335
  try {
30723
30336
  replacementPage = await withBrowserOperationDeadline(
30724
- 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 }),
30725
30339
  BROWSER_RECOVERY_STEP_DEADLINE_MS,
30726
30340
  'Creating replacement browser tab',
30727
30341
  {
@@ -30919,6 +30533,9 @@ async function _switchSessionTab(session, sessionId, tabId) {
30919
30533
 
30920
30534
  // Stop screencast on current tab
30921
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`);
30922
30539
 
30923
30540
  // Switch aliases
30924
30541
  session.activeTabId = tabId;
@@ -30929,6 +30546,8 @@ async function _switchSessionTab(session, sessionId, tabId) {
30929
30546
 
30930
30547
  // Start screencast on new tab
30931
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;
30932
30551
 
30933
30552
  // Notify clients of the switch
30934
30553
  const msg = JSON.stringify({
@@ -30960,15 +30579,36 @@ async function closeSessionTab(session, sessionId, tabId) {
30960
30579
  return;
30961
30580
  }
30962
30581
 
30963
- // 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;
30964
30587
  if (session.activeTabId === tabId) {
30965
- const nextTabId = [...session.tabs.keys()][0];
30966
- 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
+ }
30967
30606
  }
30968
30607
 
30969
- // Close the page
30608
+ // Close the page, whatever the switch did: the tab has left the registry already
30970
30609
  try { await tabEntry.page.close(); } catch {}
30971
30610
  console.log(`[browser] Tab ${tabId} closed in session ${sessionId} (remaining: ${session.tabs.size})`);
30611
+ if (switchError) throw switchError;
30972
30612
  }
30973
30613
 
30974
30614
  /**
@@ -31151,6 +30791,8 @@ function getTargetPage(session, req) {
31151
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`);
31152
30792
  return null;
31153
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;
31154
30796
  trackRoute(session, terminalId, session.activeTabId, 'legacy');
31155
30797
  return { page: session.page, cdpSession: session.cdpSession, tabId: session.activeTabId };
31156
30798
  }
@@ -32645,14 +32287,128 @@ app.post('/api/fetch-markdown', async (req, res) => {
32645
32287
 
32646
32288
  // GET …/screenshot-base64 — what browser_screenshot shows, plus breakpoint sizes
32647
32289
  // (width / height), full page and saving to a file (lib/browser-screenshot.js).
32648
- // A tab whose own viewport could not be confirmed back after an emulated size
32649
- // (viewportRestored: false) is closed, so no page — a MoreLogin profile's above
32650
- // 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.
32651
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.';
32652
- async function retireScreenshotTab(session, sessionId, routed) {
32653
- console.warn(`[browser] closing tab ${routed.tabId} of session ${sessionId}: its viewport was not confirmed restored after a screenshot`);
32654
- const close = session.tabs?.size > 1 ? closeSessionTab(session, sessionId, routed.tabId) : routed.page.close();
32655
- 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
+ });
32656
32412
  }
32657
32413
  app.get('/api/browser/sessions/:id/screenshot-base64', async (req, res) => {
32658
32414
  const session = browserSessions.get(req.params.id);
@@ -32667,38 +32423,92 @@ app.get('/api/browser/sessions/:id/screenshot-base64', async (req, res) => {
32667
32423
  } catch (err) {
32668
32424
  return res.status(err.status || 400).json({ error: err.message });
32669
32425
  }
32670
- 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
+ };
32671
32450
  try {
32672
- const shot = await captureScreenshot(routed.page, options, { runBounded: (work) => runBrowserRequest(req, work, 60_000) });
32673
- const state = await syncPageState(session, routed);
32674
- if (shot.viewportRestored === false) {
32675
- await retireScreenshotTab(session, req.params.id, routed);
32676
- 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
+ }
32677
32473
  }
32678
32474
  let savedPath = null;
32679
32475
  if (shot.fileData) {
32680
32476
  const target = options.path
32681
32477
  ? resolve(options.path)
32682
- : 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 });
32683
32479
  // Checked again as it is written: the capture may have taken a minute.
32684
32480
  savedPath = writeScreenshotFile(target, Buffer.from(shot.fileData, 'base64'), { dataHome: DATA_HOME });
32685
32481
  }
32686
32482
  res.json({
32687
- ok: true, url: state.url, title: state.title,
32483
+ ok: true, url, title,
32688
32484
  data: shot.data, mime: shot.mime,
32689
32485
  width: shot.width, height: shot.height, imageWidth: shot.imageWidth, imageHeight: shot.imageHeight,
32690
32486
  fullPage: shot.fullPage,
32691
32487
  ...(shot.truncated && { truncated: true }),
32692
32488
  ...(shot.emulated && { viewport: shot.emulated }),
32693
32489
  ...(savedPath && { savedPath, format: options.fileFormat }),
32694
- ...(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 }),
32695
32491
  });
32696
32492
  } catch (err) {
32697
- if (err?.viewportRestored === false && !tabClosed) {
32698
- await retireScreenshotTab(session, req.params.id, routed);
32699
- tabClosed = true;
32700
- }
32701
- 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); }
32702
32512
  }
32703
32513
  });
32704
32514
 
@@ -32853,6 +32663,10 @@ app.put('/api/browser/config', (req, res) => {
32853
32663
  if (body.connectMode !== undefined) body.connectMode = ['attach', 'morelogin'].includes(body.connectMode) ? body.connectMode : 'mirror';
32854
32664
  if (body.attachSeed !== undefined) body.attachSeed = body.attachSeed === 'fresh' ? 'fresh' : 'real';
32855
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;
32856
32670
  saveBrowserConfig(body);
32857
32671
  // The in-process MCP drops its cached browser routes now; MCP processes of their
32858
32672
  // own do on their next /api/browser response (X-Synabun-Browser-Default).
@@ -33196,8 +33010,26 @@ const httpServer = app.listen(PORT, async () => {
33196
33010
  // zlib adds per-frame CPU + latency on the terminal hot path.
33197
33011
  const wss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
33198
33012
 
33199
- /** 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. */
33200
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() {
33201
33033
  const origins = [];
33202
33034
  if (tunnelUrl) origins.push(tunnelUrl);
33203
33035
  const proxy = loadInviteProxy();
@@ -33205,9 +33037,20 @@ function publicWebOrigins() {
33205
33037
  return origins;
33206
33038
  }
33207
33039
 
33208
- httpServer.on('upgrade', (req, socket, head) => {
33209
- // Block tunnel traffic from WebSocket upgrades, except cookie-authenticated invite sessions
33210
- 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)) {
33211
33054
  socket.destroy();
33212
33055
  return;
33213
33056
  }
@@ -33971,7 +33814,9 @@ process.on('exit', () => {
33971
33814
  try { restoreSleepSync(); } catch {}
33972
33815
  try { terminalHost.killNow(); } catch {}
33973
33816
  try { _whatsapp?.killNow(); } catch {}
33817
+ try { tunnel.killNow(); } catch {}
33974
33818
  try { stopOpencodeServer(); } catch {}
33819
+ try { editorCompletionServe.stop('exit'); } catch {}
33975
33820
  try { _claudeBridge?.shutdownAllBridges?.(); } catch {}
33976
33821
  try { saveSessionSnapshot(); } catch {}
33977
33822
  try { flushUiStateSync(); } catch {}