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