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.
- package/CHANGELOG.md +57 -0
- package/NOTICE +5 -0
- package/README.md +1 -1
- package/THIRD-PARTY-LICENSES.md +46 -0
- package/benchmarks/fixtures/browser/README.md +1 -1
- package/benchmarks/fixtures/desktop/README.md +1 -1
- package/docs/whatsapp.md +2 -2
- package/hooks/claude-code/pre-compact.mjs +11 -0
- package/hooks/claude-code/prompt-submit.mjs +60 -184
- package/hooks/claude-code/session-start.mjs +73 -16
- package/hooks/claude-code/shared.mjs +20 -0
- package/hooks/claude-code/stop.mjs +5 -5
- package/lib/start-launcher.js +88 -1
- package/mcp-server/package-lock.json +2 -2
- package/mcp-server/package.json +1 -3
- package/neural-interface/i18n/en.json +1500 -153
- package/neural-interface/i18n/pt-BR.json +1498 -152
- package/neural-interface/lib/assistant-api.js +62 -6
- package/neural-interface/lib/assistant-brains/claude.js +4 -0
- package/neural-interface/lib/assistant-catalog.js +179 -27
- package/neural-interface/lib/assistant-config.js +31 -2
- package/neural-interface/lib/assistant-dispatch.js +62 -13
- package/neural-interface/lib/assistant-hidden-models.js +393 -66
- package/neural-interface/lib/assistant-memory.js +24 -10
- package/neural-interface/lib/assistant-persona.js +6 -2
- package/neural-interface/lib/assistant-pricing.js +21 -6
- package/neural-interface/lib/assistant-runtime.js +181 -12
- package/neural-interface/lib/assistant-task-prompt.js +13 -3
- package/neural-interface/lib/assistant-test-density.js +142 -0
- package/neural-interface/lib/assistant-user-context-budgets.js +123 -0
- package/neural-interface/lib/assistant-user-context.js +908 -0
- package/neural-interface/lib/assistant-user-knowledge.js +328 -0
- package/neural-interface/lib/assistant-user-learn.js +404 -0
- package/neural-interface/lib/assistant-user-threads.js +226 -0
- package/neural-interface/lib/assistant-user-writer.js +181 -0
- package/neural-interface/lib/browser-background-tab.js +134 -0
- package/neural-interface/lib/browser-screenshot.js +678 -61
- package/neural-interface/lib/claude-agent-bridge.js +33 -6
- package/neural-interface/lib/claude-model-catalog.js +30 -2
- package/neural-interface/lib/claude-temporary.js +46 -7
- package/neural-interface/lib/cloudflared/install.js +533 -0
- package/neural-interface/lib/cloudflared/manager.js +545 -0
- package/neural-interface/lib/cloudflared/pins.js +115 -0
- package/neural-interface/lib/cloudflared/pins.json +63 -0
- package/neural-interface/lib/cloudflared/tunnel.js +265 -0
- package/neural-interface/lib/editor-completion-endpoint.js +264 -0
- package/neural-interface/lib/editor-completion-opencode.js +436 -0
- package/neural-interface/lib/editor-completion.js +714 -0
- package/neural-interface/lib/editor-config.js +149 -0
- package/neural-interface/lib/editor-files-api.js +268 -0
- package/neural-interface/lib/editor-git-api.js +134 -0
- package/neural-interface/lib/file-explorer-api.js +1039 -0
- package/neural-interface/lib/file-explorer-system.js +355 -0
- package/neural-interface/lib/file-explorer-trash.ps1 +85 -0
- package/neural-interface/lib/file-explorer-watch.js +239 -0
- package/neural-interface/lib/git-diff-api.js +103 -0
- package/neural-interface/lib/git-read.js +202 -0
- package/neural-interface/lib/greeting/api.js +89 -0
- package/neural-interface/lib/greeting/budgets.js +90 -0
- package/neural-interface/lib/greeting/engine.js +461 -0
- package/neural-interface/lib/greeting/facts.js +320 -0
- package/neural-interface/lib/greeting/sources.js +252 -0
- package/neural-interface/lib/greeting/writer.js +139 -0
- package/neural-interface/lib/http-guards.js +189 -13
- package/neural-interface/lib/machine-names.js +88 -0
- package/neural-interface/lib/memory-api.js +31 -7
- package/neural-interface/lib/native-loop-providers.js +10 -5
- package/neural-interface/lib/plugin-installer.js +4 -2
- package/neural-interface/lib/project-storage.js +5 -7
- package/neural-interface/lib/request-gate.js +589 -0
- package/neural-interface/lib/ui-blob-api.js +108 -0
- package/neural-interface/lib/ui-blob-store.js +68 -0
- package/neural-interface/package-lock.json +701 -2
- package/neural-interface/package.json +13 -2
- package/neural-interface/public/install-app.html +9 -6
- package/neural-interface/public/offline.html +3 -2
- package/neural-interface/public/onboarding.html +12 -1
- package/neural-interface/public/shared/agent-runtime-options.js +22 -6
- package/neural-interface/public/shared/assistant/asst-automation.js +286 -0
- package/neural-interface/public/shared/assistant/asst-brain-picker.js +312 -62
- package/neural-interface/public/shared/assistant/asst-composer.js +11 -1
- package/neural-interface/public/shared/assistant/asst-mascot.js +18 -58
- package/neural-interface/public/shared/assistant/asst-menu.js +12 -8
- package/neural-interface/public/shared/assistant/asst-panel.js +150 -28
- package/neural-interface/public/shared/assistant/asst-render.js +162 -19
- package/neural-interface/public/shared/assistant/asst-route-models.js +108 -0
- package/neural-interface/public/shared/assistant/asst-route.js +23 -329
- package/neural-interface/public/shared/assistant/asst-routes-editor.js +495 -0
- package/neural-interface/public/shared/assistant/asst-sanitize.js +8 -1
- package/neural-interface/public/shared/assistant/asst-sidepanel.js +31 -9
- package/neural-interface/public/shared/assistant/asst-state.js +185 -1
- package/neural-interface/public/shared/assistant/asst-styles.js +158 -4
- package/neural-interface/public/shared/blob-store.js +111 -22
- package/neural-interface/public/shared/cdx/cdx-panel.js +32 -11
- package/neural-interface/public/shared/cdx/cdx-render.js +25 -89
- package/neural-interface/public/shared/cdx/cdx-styles.js +36 -0
- package/neural-interface/public/shared/cdx/cdx-tabs.js +87 -4
- package/neural-interface/public/shared/constants.js +6 -1
- package/neural-interface/public/shared/cp/cp-rehydrate.js +2 -1
- package/neural-interface/public/shared/cp/cp-styles.js +15 -0
- package/neural-interface/public/shared/editor/editor-ai-logic.js +409 -0
- package/neural-interface/public/shared/editor/editor-ai.js +619 -0
- package/neural-interface/public/shared/editor/editor-commands.js +212 -0
- package/neural-interface/public/shared/editor/editor-compare.js +103 -0
- package/neural-interface/public/shared/editor/editor-completion-list.js +172 -0
- package/neural-interface/public/shared/editor/editor-doc.js +257 -0
- package/neural-interface/public/shared/editor/editor-git-logic.js +131 -0
- package/neural-interface/public/shared/editor/editor-git.js +95 -0
- package/neural-interface/public/shared/editor/editor-languages.js +156 -0
- package/neural-interface/public/shared/editor/editor-lint.js +229 -0
- package/neural-interface/public/shared/editor/editor-loader.js +51 -0
- package/neural-interface/public/shared/editor/editor-markdown.js +90 -0
- package/neural-interface/public/shared/editor/editor-menu.js +233 -0
- package/neural-interface/public/shared/editor/editor-minimap.js +371 -0
- package/neural-interface/public/shared/editor/editor-palette-logic.js +192 -0
- package/neural-interface/public/shared/editor/editor-palette.js +125 -0
- package/neural-interface/public/shared/editor/editor-panel-logic.js +288 -0
- package/neural-interface/public/shared/editor/editor-panel.js +2099 -0
- package/neural-interface/public/shared/editor/editor-prefs.js +224 -0
- package/neural-interface/public/shared/editor/editor-safety.js +500 -0
- package/neural-interface/public/shared/editor/editor-search.js +352 -0
- package/neural-interface/public/shared/editor/editor-statusbar.js +144 -0
- package/neural-interface/public/shared/editor/editor-tabs-logic.js +331 -0
- package/neural-interface/public/shared/editor/editor-tabs.js +233 -0
- package/neural-interface/public/shared/editor/editor-theme.js +244 -0
- package/neural-interface/public/shared/editor/editor-view.js +332 -0
- package/neural-interface/public/shared/explorer/fx-api.js +216 -0
- package/neural-interface/public/shared/explorer/fx-links.js +204 -0
- package/neural-interface/public/shared/explorer/fx-logic.js +537 -0
- package/neural-interface/public/shared/explorer/fx-main.js +865 -0
- package/neural-interface/public/shared/explorer/fx-names.js +87 -0
- package/neural-interface/public/shared/explorer/fx-ops.js +370 -0
- package/neural-interface/public/shared/explorer/fx-popups.js +490 -0
- package/neural-interface/public/shared/explorer/fx-tree.js +790 -0
- package/neural-interface/public/shared/file-links.js +339 -0
- package/neural-interface/public/shared/greeting/greeting-card.js +178 -0
- package/neural-interface/public/shared/greeting/greeting-view.js +148 -0
- package/neural-interface/public/shared/html-shell.js +160 -165
- package/neural-interface/public/shared/ocp-v2/ocp-v2-childpanel.js +4 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-greeting.js +47 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +99 -17
- package/neural-interface/public/shared/ocp-v2/ocp-v2-render.js +7 -1
- package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +5 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +40 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-ws.js +3 -2
- package/neural-interface/public/shared/pwa-install-page.js +29 -1
- package/neural-interface/public/shared/pwa-install.css +3 -3
- package/neural-interface/public/shared/pwa-install.js +100 -1
- package/neural-interface/public/shared/settings/settings-code-editor-pane.js +412 -0
- package/neural-interface/public/shared/settings/settings-code-editor.js +328 -0
- package/neural-interface/public/shared/settings/settings-greeting.js +133 -0
- package/neural-interface/public/shared/settings/settings-ia.js +18 -4
- package/neural-interface/public/shared/settings/settings-user-context.js +154 -0
- package/neural-interface/public/shared/start-bridge.js +238 -19
- package/neural-interface/public/shared/styles.css +1414 -721
- package/neural-interface/public/shared/synabun-mascot.js +20 -56
- package/neural-interface/public/shared/tunnel-view.js +167 -0
- package/neural-interface/public/shared/ui-claude-panel.js +157 -16
- package/neural-interface/public/shared/ui-detail.js +15 -0
- package/neural-interface/public/shared/ui-explorer.js +22 -0
- package/neural-interface/public/shared/ui-file-explorer.js +481 -2496
- package/neural-interface/public/shared/ui-file-links.js +103 -0
- package/neural-interface/public/shared/ui-help.js +26 -0
- package/neural-interface/public/shared/ui-invite.js +126 -116
- package/neural-interface/public/shared/ui-keybinds.js +4 -0
- package/neural-interface/public/shared/ui-loading.js +159 -15
- package/neural-interface/public/shared/ui-menubar.js +12 -30
- package/neural-interface/public/shared/ui-native-loop-router-state.js +11 -0
- package/neural-interface/public/shared/ui-native-loop-router.js +15 -2
- package/neural-interface/public/shared/ui-navbar.js +45 -21
- package/neural-interface/public/shared/ui-settings.js +455 -488
- package/neural-interface/public/shared/ui-sidepanel-runtime.js +2 -1
- package/neural-interface/public/shared/ui-sync.js +16 -0
- package/neural-interface/public/shared/ui-terminal.js +23 -5
- package/neural-interface/public/shared/ui-tunnel.js +130 -0
- package/neural-interface/public/shared/ui-tutorial-steps.js +6 -2
- package/neural-interface/public/shared/ui-whiteboard.js +12 -0
- package/neural-interface/public/shared/ui-worker-end.js +245 -0
- package/neural-interface/public/shared/ui-workspaces.js +41 -27
- package/neural-interface/public/shared/worker-end.js +251 -0
- package/neural-interface/public/sw.js +2 -2
- package/neural-interface/public/vendor/codemirror/chunks/apl-CSDKT72Z.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/asciiarmor-JYYPU5BM.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/asn1-NRWCZDA6.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/asterisk-5OU6D4ZQ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/brainfuck-ID4N4SYC.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/chunk-4RZWGCS2.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/chunk-ACTJM3JR.js +11 -0
- package/neural-interface/public/vendor/codemirror/chunks/chunk-S6BMAXLK.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/chunk-TO2RDOHQ.js +3 -0
- package/neural-interface/public/vendor/codemirror/chunks/chunk-US2GMZZQ.js +13 -0
- package/neural-interface/public/vendor/codemirror/chunks/chunk-WYYO5ZAJ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/chunk-XALZTDJC.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/chunk-YRNUTRDV.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/chunk-Z3QOKH62.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/clike-OWQBSOGL.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/clojure-KV75UQWC.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/cmake-GTEP5Y3C.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/cobol-WCZ7QEGJ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/coffeescript-TGLBOT6B.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/commonlisp-WVVI432V.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/crystal-S6R6HLKQ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/css-PDA2RINP.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/cypher-HL5QPL4C.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/d-HHJH2HAJ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/diff-MN3A35BY.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-25SQLBVS.js +3 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-27ZGGJJS.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-4E52OJIF.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-6FT73XDC.js +2 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-BE6YGRMW.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-CJSGKW2O.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-CRPH2WLI.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-CTG7JEQN.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-I3FEVELX.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-IM5BBSWZ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-KERUKTIF.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-LXTMWZAM.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-MABASSET.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-MJVBUZLL.js +3 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-MNB3WZVU.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-QYWXOE3U.js +7 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-TEBOFWLZ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-UNXFROM3.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-XL2SLQO6.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-XO42Y3AP.js +6 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-Y2WWDWJK.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-YFEZKWMU.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-ZY7SWQO6.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dockerfile-3Y4R4NCH.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dtd-5343GJ4P.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dylan-FI2CXSW2.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/ebnf-GNQ77YWA.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/ecl-MRPUAE2L.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/eiffel-DZROES27.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/elm-4KICRFKQ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/erlang-3IHYC263.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/factor-C6D3E5UK.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/fcl-VYTWUJMI.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/forth-ZBO2EH5M.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/fortran-5NDCRWIK.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/gas-SKRMOXFP.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/gherkin-L45YXZLW.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/groovy-GLPTLN44.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/haskell-E7NF7P4Y.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/haxe-K2NVDOB6.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/http-Q6DRQIJB.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/idl-MZYNJVLN.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/javascript-6YRYK2HR.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/julia-HXEL2T5G.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/livescript-MV7CI2JZ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/lua-E36NHOOD.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/mathematica-3XOZZCMC.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/mbox-ODYGFKS7.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/mirc-WB43AWF5.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/mllike-ZT6ET7ZH.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/modelica-LLMZPFGA.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/mscgen-2OPNNYFH.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/mumps-6P3HWJH6.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/nginx-TCGKQTRW.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/nsis-ATIGIXTJ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/ntriples-5PLBI5OV.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/octave-QF2S3NL6.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/oz-JFIVFFH2.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/pascal-JG6SW76V.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/perl-4B3IWVVO.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/pig-ABEBH4MI.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/powershell-H32XE7TL.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/properties-NN644MMA.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/protobuf-YKWQK2J3.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/pug-RIYXEKFW.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/puppet-JZQ5WPEV.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/python-EBCGGHAC.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/q-5UMH2YQY.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/r-7XMGG453.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/rpm-IEV7TSLR.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/ruby-T4SPFPNM.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/sas-GEDX67ET.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/scheme-FTLOWKPC.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/shell-HUFRKFSJ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/sieve-P5QUDVYO.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/smalltalk-2SORJNQI.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/solr-M22TGAR5.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/sparql-4M3KL752.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/spreadsheet-3K23LA4N.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/sql-VHRFMUBI.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/stex-QZDAQUY2.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/stylus-7ZDKAKBD.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/swift-WCLCLGMR.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/tcl-JLMU6WOK.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/textile-4CHDKZ4T.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/tiddlywiki-DKMCZZQ4.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/tiki-F4JIG36Q.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/toml-BHMHTIM2.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/troff-YQ2KV5NT.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/ttcn-ZHRCVFJY.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/ttcn-cfg-KJOVTY4X.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/turtle-535P5N6I.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/vb-76UMQHOT.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/vbscript-CGNDTK6Z.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/velocity-VSJDO5T5.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/verilog-4T6ETXIP.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/vhdl-T7FNAXUN.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/webidl-P6MJSJ6J.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/xquery-UOVILRGN.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/yacas-CV2APROL.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/z80-3KFNVM4M.js +1 -0
- package/neural-interface/public/vendor/codemirror/codemirror-6.43.14-75f05e67.min.js +2 -0
- package/neural-interface/public/vendor/codemirror/manifest.json +60 -0
- package/neural-interface/scripts/build-vendor.mjs +78 -2
- package/neural-interface/scripts/vendor-codemirror-entry.js +121 -0
- package/neural-interface/server.js +873 -1028
- package/neural-interface/templates/rulesets/README.md +1 -1
- package/package.json +2 -8
- package/setup.js +44 -7
- package/benchmarks/browser-live-results.json +0 -79
- package/benchmarks/browser-v2-results.json +0 -258
- package/neural-interface/lib/codex-capabilities.d.ts +0 -41
- package/neural-interface/public/category-logos/claude.svg +0 -1
- package/neural-interface/public/category-logos/gemini-color.svg +0 -1
- package/neural-interface/public/category-logos/openai.svg +0 -1
- package/neural-interface/public/category-logos/synabun.png +0 -0
- package/neural-interface/public/claude.svg +0 -1
- package/neural-interface/public/gallery.svg +0 -6
- package/neural-interface/public/leonardoai.svg +0 -1
- package/neural-interface/public/mcp.svg +0 -4
- package/neural-interface/public/opencode-logo-dark-square.svg +0 -1
- package/neural-interface/public/pin-svgrepo-com.svg +0 -25
- package/neural-interface/public/sessionicon.svg +0 -8
- package/neural-interface/public/toolbar-album.svg +0 -8
- package/neural-interface/public/toolbar-question.svg +0 -6
- package/neural-interface/public/toolbar-sessions.svg +0 -6
- package/neural-interface/scripts/codex-protocol-fixture.mjs +0 -83
- 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
|
-
//
|
|
350
|
-
//
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5884
|
-
|
|
5885
|
-
|
|
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:
|
|
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
|
-
//
|
|
10340
|
-
//
|
|
10341
|
-
|
|
10342
|
-
|
|
10343
|
-
|
|
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
|
-
|
|
10381
|
-
|
|
10382
|
-
|
|
10383
|
-
|
|
10384
|
-
|
|
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
|
-
|
|
16108
|
-
|
|
16109
|
-
|
|
16110
|
-
|
|
16111
|
-
|
|
16112
|
-
}
|
|
16113
|
-
if (inside === 'true') {
|
|
16114
|
-
|
|
16115
|
-
|
|
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
|
-
|
|
16978
|
-
|
|
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
|
-
|
|
16984
|
-
|
|
16985
|
-
|
|
16958
|
+
return {
|
|
16959
|
+
dirs: [resolve(DATA_HOME), ...registeredProjects.map(p => resolve(p.path)), localPlansDir, claudePlansDir],
|
|
16960
|
+
files: [opencodeConfigPath],
|
|
16961
|
+
};
|
|
16986
16962
|
}
|
|
16987
16963
|
|
|
16988
|
-
|
|
16989
|
-
|
|
16990
|
-
|
|
16991
|
-
|
|
16992
|
-
|
|
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
|
-
|
|
17063
|
-
|
|
17064
|
-
|
|
17065
|
-
|
|
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
|
-
//
|
|
17070
|
-
|
|
17071
|
-
|
|
17072
|
-
|
|
17073
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
17378
|
-
|
|
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
|
|
20620
|
-
|
|
20621
|
-
|
|
20622
|
-
|
|
20623
|
-
|
|
20624
|
-
|
|
20625
|
-
|
|
20626
|
-
|
|
20627
|
-
|
|
20628
|
-
|
|
20629
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24173
|
-
|
|
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
|
-
|
|
24176
|
-
|
|
24177
|
-
|
|
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
|
-
|
|
24203
|
-
|
|
24204
|
-
|
|
24205
|
-
|
|
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
|
-
|
|
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 —
|
|
24278
|
-
|
|
24279
|
-
|
|
24280
|
-
|
|
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 ? ' (
|
|
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(() =>
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
27224
|
-
if (!
|
|
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
|
-
|
|
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 =
|
|
26860
|
+
const stat = gitOutput(dir, ['diff', '--stat', '--stat-width=120']).trim();
|
|
27235
26861
|
|
|
27236
26862
|
// Get diff stat for staged files
|
|
27237
|
-
const stagedStat =
|
|
26863
|
+
const stagedStat = gitOutput(dir, ['diff', '--cached', '--stat', '--stat-width=120']).trim();
|
|
27238
26864
|
|
|
27239
26865
|
// Get untracked files
|
|
27240
|
-
const untracked =
|
|
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 =
|
|
26869
|
+
const diff = gitOutput(dir, ['diff', '-U1', '--no-color'], { timeout: 8000, maxBuffer: 64 * 1024 }).substring(0, 4000);
|
|
27244
26870
|
|
|
27245
|
-
const stagedDiff =
|
|
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
|
-
|
|
27308
|
-
|
|
27309
|
-
|
|
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
|
|
27354
|
-
if (!
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
27390
|
-
try { stagedText =
|
|
27391
|
-
try { untrackedList =
|
|
27392
|
-
try { recentCommits =
|
|
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 =
|
|
27446
|
-
try { stagedStat =
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
30966
|
-
|
|
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
|
-
//
|
|
32649
|
-
//
|
|
32650
|
-
//
|
|
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
|
-
|
|
32653
|
-
|
|
32654
|
-
|
|
32655
|
-
|
|
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
|
-
|
|
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, {
|
|
32673
|
-
|
|
32674
|
-
|
|
32675
|
-
|
|
32676
|
-
|
|
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:
|
|
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
|
|
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
|
-
...(
|
|
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
|
-
|
|
32698
|
-
|
|
32699
|
-
|
|
32700
|
-
|
|
32701
|
-
|
|
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
|
|
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
|
-
//
|
|
33210
|
-
|
|
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 {}
|