synabun 2.0.9 → 2.0.10
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 +29 -0
- package/README.md +1 -1
- package/THIRD-PARTY-LICENSES.md +46 -4
- package/launcher.mjs +9 -3
- package/lib/start-launcher.js +6 -1
- package/mcp-server/package-lock.json +2 -2
- package/mcp-server/package.json +1 -1
- package/neural-interface/i18n/en.json +218 -7
- package/neural-interface/i18n/pt-BR.json +220 -9
- package/neural-interface/lib/account-usage.js +5 -3
- package/neural-interface/lib/app-controls-api.js +161 -0
- package/neural-interface/lib/assistant-api.js +21 -7
- package/neural-interface/lib/assistant-brains/opencode.js +13 -1
- package/neural-interface/lib/assistant-brains/opencode2-browser-policy/index.js +44 -0
- package/neural-interface/lib/assistant-brains/opencode2-route-gate/index.js +57 -0
- package/neural-interface/lib/assistant-catalog.js +17 -2
- package/neural-interface/lib/assistant-dispatch.js +3 -0
- package/neural-interface/lib/assistant-envelope.js +19 -1
- package/neural-interface/lib/assistant-runtime.js +127 -8
- package/neural-interface/lib/claude-model-catalog.js +64 -14
- package/neural-interface/lib/codex-settings-schema.js +109 -47
- package/neural-interface/lib/editor-completion-opencode.js +132 -35
- package/neural-interface/lib/exec-loop-command.js +8 -1
- package/neural-interface/lib/launch-cache.js +293 -0
- package/neural-interface/lib/login-start.js +524 -0
- package/neural-interface/lib/native-loop-providers.js +18 -3
- package/neural-interface/lib/native-loop-runtime.js +19 -0
- package/neural-interface/lib/opencode-db-schema.js +75 -0
- package/neural-interface/lib/opencode-history.js +16 -1
- package/neural-interface/lib/opencode-protocol.js +173 -0
- package/neural-interface/lib/opencode-provider-view.js +81 -0
- package/neural-interface/lib/opencode-runtime-config.js +10 -3
- package/neural-interface/lib/opencode-serve-records.js +606 -0
- package/neural-interface/lib/opencode-v2-client.js +33 -6
- package/neural-interface/lib/opencode-v2-ws-requests.js +19 -5
- package/neural-interface/lib/opencode-worker-activity.js +229 -0
- package/neural-interface/lib/opencode2-client.js +760 -0
- package/neural-interface/lib/opencode2-proxy.js +116 -0
- package/neural-interface/lib/opencode2-translate.js +1008 -0
- package/neural-interface/lib/process-identity.js +361 -0
- package/neural-interface/lib/session-cache-builder.js +26 -5
- package/neural-interface/lib/session-title-generator.js +18 -0
- package/neural-interface/lib/style-guide/api.js +4 -3
- package/neural-interface/lib/untrusted-file-headers.js +27 -0
- package/neural-interface/package-lock.json +1343 -45
- package/neural-interface/package.json +5 -1
- package/neural-interface/public/index.html +2 -2
- package/neural-interface/public/shared/agent-runtime-options.js +55 -11
- package/neural-interface/public/shared/assistant/asst-agents-dock.js +172 -17
- package/neural-interface/public/shared/assistant/asst-api.js +11 -3
- package/neural-interface/public/shared/assistant/asst-brain-picker.js +195 -80
- package/neural-interface/public/shared/assistant/asst-composer.js +1 -1
- package/neural-interface/public/shared/assistant/asst-hosts.js +49 -2
- package/neural-interface/public/shared/assistant/asst-menu.js +77 -6
- package/neural-interface/public/shared/assistant/asst-panel.js +197 -33
- package/neural-interface/public/shared/assistant/asst-rack-styles.js +28 -10
- package/neural-interface/public/shared/assistant/asst-redact.js +186 -0
- package/neural-interface/public/shared/assistant/asst-render.js +382 -36
- package/neural-interface/public/shared/assistant/asst-run-stage.js +231 -0
- package/neural-interface/public/shared/assistant/asst-sanitize.js +193 -3
- package/neural-interface/public/shared/assistant/asst-scene-styles.js +273 -0
- package/neural-interface/public/shared/assistant/asst-scenes.js +528 -0
- package/neural-interface/public/shared/assistant/asst-sidepanel-styles.js +3 -15
- package/neural-interface/public/shared/assistant/asst-state.js +14 -0
- package/neural-interface/public/shared/assistant/asst-styles.js +43 -0
- package/neural-interface/public/shared/assistant/asst-tool-kinds.js +13 -1
- package/neural-interface/public/shared/cc-dropdown.js +480 -0
- package/neural-interface/public/shared/cdx/cdx-panel.js +13 -1
- package/neural-interface/public/shared/cdx/cdx-render.js +15 -3
- package/neural-interface/public/shared/cdx/cdx-styles.js +4 -0
- package/neural-interface/public/shared/cdx/cdx-tabs.js +149 -35
- package/neural-interface/public/shared/cp/cp-markdown.js +4 -57
- package/neural-interface/public/shared/data-cache.js +223 -0
- package/neural-interface/public/shared/desktop-bridge.js +11 -0
- package/neural-interface/public/shared/desktop-titlebar-view.js +78 -1
- package/neural-interface/public/shared/desktop-titlebar.js +214 -28
- package/neural-interface/public/shared/desktop-update-view.js +15 -5
- package/neural-interface/public/shared/file-links.js +73 -8
- package/neural-interface/public/shared/html-shell.js +74 -80
- package/neural-interface/public/shared/launch-prefetch.js +59 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-caps.js +3 -2
- package/neural-interface/public/shared/ocp-v2/ocp-v2-childpanel.js +7 -2
- package/neural-interface/public/shared/ocp-v2/ocp-v2-modelpicker.js +54 -17
- package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +93 -2
- package/neural-interface/public/shared/ocp-v2/ocp-v2-projectbar.js +3 -2
- package/neural-interface/public/shared/ocp-v2/ocp-v2-providers.js +11 -9
- package/neural-interface/public/shared/ocp-v2/ocp-v2-rehydrate.js +15 -4
- package/neural-interface/public/shared/ocp-v2/ocp-v2-render.js +1 -1
- package/neural-interface/public/shared/ocp-v2/ocp-v2-send-logic.js +401 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +16 -9
- package/neural-interface/public/shared/ocp-v2/ocp-v2-sessions-logic.js +60 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +48 -19
- package/neural-interface/public/shared/ocp-v2/ocp-v2-ws.js +195 -19
- package/neural-interface/public/shared/opencode-providers.js +39 -0
- package/neural-interface/public/shared/schedules-view.js +318 -0
- package/neural-interface/public/shared/settings/settings-app-pane.js +335 -0
- package/neural-interface/public/shared/settings/settings-app.js +243 -0
- package/neural-interface/public/shared/settings/settings-data.js +88 -0
- package/neural-interface/public/shared/settings/settings-ia.js +17 -0
- package/neural-interface/public/shared/sidepanel-session-status.js +49 -0
- package/neural-interface/public/shared/styles.css +540 -371
- package/neural-interface/public/shared/ui-assistant.js +27 -5
- package/neural-interface/public/shared/ui-automation-studio.js +67 -27
- package/neural-interface/public/shared/ui-bookmarks.js +2 -1
- package/neural-interface/public/shared/ui-claude-panel.js +76 -8
- package/neural-interface/public/shared/ui-codex-panel.js +1 -0
- package/neural-interface/public/shared/ui-detail.js +6 -6
- package/neural-interface/public/shared/ui-file-links.js +1 -0
- package/neural-interface/public/shared/ui-image-gallery.js +6 -5
- package/neural-interface/public/shared/ui-invite.js +18 -2
- package/neural-interface/public/shared/ui-link.js +9 -9
- package/neural-interface/public/shared/ui-menubar.js +232 -33
- package/neural-interface/public/shared/ui-native-loop-router.js +2 -0
- package/neural-interface/public/shared/ui-opencode-panel-v2.js +1 -0
- package/neural-interface/public/shared/ui-schedules-studio.js +2099 -869
- package/neural-interface/public/shared/ui-sessions.js +15 -4
- package/neural-interface/public/shared/ui-settings.js +87 -36
- package/neural-interface/public/shared/ui-sidebar.js +4 -4
- package/neural-interface/public/shared/ui-sidepanel-session-entry.js +13 -0
- package/neural-interface/public/shared/ui-sidepanel-sessions.js +55 -4
- package/neural-interface/public/shared/ui-sidepanel-tray.js +84 -8
- package/neural-interface/public/shared/ui-skills.js +70 -12
- package/neural-interface/public/shared/ui-sync.js +7 -0
- package/neural-interface/public/shared/ui-terminal.js +36 -20
- package/neural-interface/public/shared/ui-trash.js +8 -1
- package/neural-interface/public/shared/ui-tutorial-steps.js +7 -3
- package/neural-interface/public/shared/ui-tutorial.js +15 -5
- package/neural-interface/public/shared/ui-update.js +83 -14
- package/neural-interface/public/shared/ui-whiteboard.js +2 -1
- package/neural-interface/public/variant/2d/main.js +4 -1
- package/neural-interface/public/variant/3d/main.js +3 -0
- package/neural-interface/public/vendor/highlight/LICENSE +29 -0
- package/neural-interface/public/vendor/highlight/github-dark-dimmed-11.12.0.min.css +9 -0
- package/neural-interface/public/vendor/highlight/highlight-11.12.0.min.js +1262 -0
- package/neural-interface/public/vendor/highlight/manifest.json +5 -0
- package/neural-interface/public/vendor/html2canvas/LICENSE +22 -0
- package/neural-interface/public/vendor/html2canvas/html2canvas-1.4.1.min.js +20 -0
- package/neural-interface/public/vendor/html2canvas/manifest.json +4 -0
- package/neural-interface/public/vendor/marked/LICENSE.md +44 -0
- package/neural-interface/public/vendor/marked/manifest.json +4 -0
- package/neural-interface/public/vendor/marked/marked-14.1.4.esm.js +2502 -0
- package/neural-interface/public/vendor/mermaid/LICENSE +21 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/abnfDiagram-PAR5IXJI.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/architecture-7GRP2DOG-M7G3SWCO.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/architectureDiagram-2PNWDENW.mjs +36 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/blockDiagram-PUWGUBOA.mjs +129 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/c4Diagram-3NLNHZP5.mjs +38 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-2NMSUPJH.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-32MCUW2P.mjs +206 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-3NPNU5GP.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-3SMGFRGC.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-4CAJECRU.mjs +14 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-4OADUZ5Q.mjs +10 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-5IMINLNL.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-5IQCK5RY.mjs +3 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-5MVFYWFU.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-AQ6EADP3.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-AZZRMDJM.mjs +15 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-CLS4B6BI.mjs +168 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-DZP67EKU.mjs +70 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-EICP7K5X.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-GDY2UAB4.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-H7VHZCWX.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-IJBDOHL6.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-JFFVMJIA.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-JQRUD6KW.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-JZGDI5JK.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-KD6SPIPN.mjs +2 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-NF366YI6.mjs +231 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-NUDPKBEF.mjs +32 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-OJINMHTS.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-Q6NGPIAH.mjs +2 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-QJSWEUOL.mjs +132 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-R7JDMUKV.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-RCXAM2GK.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-RT4MAWR3.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-SFMINX4W.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-SLDQ463B.mjs +88 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-STOV2HOB.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-T5OCTHI4.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-TCEHX7R3.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-VQC2EELJ.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-VRZKL3EH.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-VTHDQ7BV.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-VY2OSTJR.mjs +2 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-WUBWJARI.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-YBMQ5JGJ.mjs +2 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-YDMLL4PJ.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-Z6V44ARX.mjs +62 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-ZJJ7VQJP.mjs +339 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-ZNLK7HRR.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-ZQN7UHIL.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-ZS6VBONO.mjs +174 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-ZUNWM646.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/classDiagram-FGAMPOII.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/classDiagram-v2-AI5RJEK4.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/cose-bilkent-Q72C2OJ3.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/cynefin-OW5HDTMX-6OCTWOGG.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/cynefinDiagram-2OOWUY4M.mjs +62 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/dagre-MPVFI544.mjs +4 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/diagram-AE4NWXFW.mjs +24 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/diagram-BUJID5UG.mjs +41 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/diagram-C44CZ5NI.mjs +24 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/diagram-EYOLEXXS.mjs +30 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/diagram-P7B6PWOJ.mjs +3 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/ebnfDiagram-FTPZ3PV6.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/erDiagram-H3ERR26V.mjs +99 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/eventmodeling-NTZA5JFV-WKSWQHFS.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/flowDiagram-YHGXBVSY.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/ganttDiagram-RWFTB7ED.mjs +292 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/gitGraph-4MIJSDKK-JH2HKYUO.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/gitGraphDiagram-MAR6GKOP.mjs +106 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/info-A6RAGUB7-3TX3OFWW.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/infoDiagram-LV547AFH.mjs +2 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/ishikawaDiagram-PUJQGNDS.mjs +70 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/journeyDiagram-EW5QJT4W.mjs +139 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/kanban-definition-JSQERAZB.mjs +89 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/katex-CZ4GXH2S.mjs +257 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/mindmap-definition-VBJCLQLM.mjs +96 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/packet-AYTQ26CC-MKII6P3Y.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/pegDiagram-7WILOQTJ.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/pie-WAS4IAKB-6B4OPMJ5.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/pieDiagram-EFA7CBSB.mjs +39 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/quadrantDiagram-WARVYA7P.mjs +7 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/radar-RG4KPBEZ-UPTR3XUL.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/railroad-74A4TZTK-MEFUPFMS.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/railroad-abnf-HS5TGJTU-R5YG2S5D.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/railroad-ebnf-LZEXJU2U-G5QEX2L6.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/railroad-peg-WCYAUIDC-H5I2ZRY2.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/railroadDiagram-5NIQVNLQ.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/requirementDiagram-SXREGOKZ.mjs +84 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/sankeyDiagram-VV74M2VP.mjs +40 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/sequenceDiagram-52ZFIFDL.mjs +162 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/sizeCapture-FGA5AGYP.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/stateDiagram-3QAKPIVI.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/stateDiagram-v2-HN2P4RDZ.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/swimlanes-QLVP42QD.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/swimlanesDiagram-JKPDBFCN.mjs +8 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/timeline-definition-GS45XAXW.mjs +120 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/treeView-Q6P3EWNA-ZRJGANNQ.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/treemap-WGGIJYW6-K64VN5AS.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/vennDiagram-QIQU2ZRJ.mjs +34 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/wardley-WFR3VGLG-D67OWCCH.mjs +1 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/wardleyDiagram-U6YNM66N.mjs +78 -0
- package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/xychartDiagram-XJAZEDNM.mjs +7 -0
- package/neural-interface/public/vendor/mermaid/manifest.json +4 -0
- package/neural-interface/public/vendor/mermaid/mermaid-11.17.2.esm.min.mjs +14 -0
- package/neural-interface/scripts/build-vendor.mjs +84 -3
- package/neural-interface/server.js +1099 -286
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/skills/synabun/modules/schedule.md +1 -1
|
@@ -125,6 +125,8 @@ import { createCliResolver, spawnArgsFor } from './lib/cli-resolver.js';
|
|
|
125
125
|
import { osPath } from './lib/os-path.js';
|
|
126
126
|
import { createCliInstaller, CLI_INSTALL_GUIDES } from './lib/cli-install.js';
|
|
127
127
|
import { createCliInstallApi, createCliPathApi, createGitBashApi } from './lib/cli-install-api.js';
|
|
128
|
+
import { createAppControlsApi, createAppSettingsStore } from './lib/app-controls-api.js';
|
|
129
|
+
import { createLoginStart } from './lib/login-start.js';
|
|
128
130
|
import { createCliPathManager } from './lib/cli-path.js';
|
|
129
131
|
import { composeTerminalPath, terminalLaunchCommand, terminalArgument } from './lib/terminal-path.js';
|
|
130
132
|
import {
|
|
@@ -145,6 +147,11 @@ import {
|
|
|
145
147
|
} from './lib/backup-zip-reader.js';
|
|
146
148
|
import { planRestoreWrites, removeRestoreUpload, saveRestoreUpload } from './lib/system-restore.js';
|
|
147
149
|
import * as opencodeV2Client from './lib/opencode-v2-client.js';
|
|
150
|
+
import { basicAuthHeader, checkOpencodeServer, opencodeProblemText, protocolForVersion, serveSpecFor } from './lib/opencode-protocol.js';
|
|
151
|
+
import { SERVE_TOKEN_ENV } from './lib/process-identity.js';
|
|
152
|
+
import { createServeRecords } from './lib/opencode-serve-records.js';
|
|
153
|
+
import { opencode2Proxy } from './lib/opencode2-proxy.js';
|
|
154
|
+
import { opencodeDbFlavor, sessionTablesFor } from './lib/opencode-db-schema.js';
|
|
148
155
|
import {
|
|
149
156
|
handleOpencodeV2Request, opencodeV2WsCapabilities, opencodeSdkVersion, buildOpencodeRuntimeMcpConfig, shouldRelayOpencodeV2Message,
|
|
150
157
|
buildSessionListQuery, mapSessionListRow, OPENCODE_V2_WS_MUTATION_TYPES, guardOpencodeV2Request,
|
|
@@ -160,6 +167,7 @@ import {
|
|
|
160
167
|
createCodexNativeLoopAdapter,
|
|
161
168
|
createOpenCodeNativeLoopAdapter,
|
|
162
169
|
} from './lib/native-loop-providers.js';
|
|
170
|
+
import { createOpenCodeActivityRelay } from './lib/opencode-worker-activity.js';
|
|
163
171
|
import {
|
|
164
172
|
mergeCodexModelContextCapabilities,
|
|
165
173
|
resolveCodexExtendedContext,
|
|
@@ -238,15 +246,20 @@ import { generateSessionTitle } from './lib/session-title-generator.js';
|
|
|
238
246
|
import {
|
|
239
247
|
CLAUDE_EFFORT_LEVELS,
|
|
240
248
|
CLAUDE_FALLBACK_MODELS,
|
|
249
|
+
binaryIdentity,
|
|
241
250
|
checkClaudeCliSkew,
|
|
242
251
|
discoverClaudeModels,
|
|
252
|
+
markClaudeModelsStale,
|
|
243
253
|
onClaudeModelsChanged,
|
|
254
|
+
setClaudeModelStore,
|
|
244
255
|
shellCommand,
|
|
245
256
|
} from './lib/claude-model-catalog.js';
|
|
257
|
+
import { createLaunchCache, createLimiter, createSnapshotStore, launchCacheDir } from './lib/launch-cache.js';
|
|
258
|
+
import { compactProviderCatalog, isProviderCatalog, providerCatalogSignature } from './lib/opencode-provider-view.js';
|
|
246
259
|
import { resolveClaudeSdkExecutable } from './lib/claude-executable.js';
|
|
247
260
|
import { collectInstallDetails } from './lib/install-details.js';
|
|
248
261
|
import { withPathEnv, pathEnvValue, getAugmentedPath, rememberedOsPath, rememberPackagedPath, rememberedPackagedPath, withPackagedPathLast } from './lib/augmented-path.js';
|
|
249
|
-
import { readCodexSettingsSchema, readCodexFeatureSupport, validateCodexSettings } from './lib/codex-settings-schema.js';
|
|
262
|
+
import { cachedCodexSettingsSchema, readCodexSettingsSchema, readCodexFeatureSupport, setCodexSettingsStore, validateCodexSettings } from './lib/codex-settings-schema.js';
|
|
250
263
|
import { semverNewer, synabunReleaseChannel } from './lib/synabun-version.js';
|
|
251
264
|
import { selectSynabunUpdate, buildSynabunInstallPlan, refuseStaleUpdateClick } from './lib/synabun-update-plan.js';
|
|
252
265
|
import { collectActivity, countWhere, recentCliSessions } from './lib/system-activity.js';
|
|
@@ -277,7 +290,7 @@ import {
|
|
|
277
290
|
import { ensureProjectCategories as ensureProjectCategoriesInFile, ensureCategories as ensureCategoriesInFile, FEATURE_CATEGORIES } from '../hooks/claude-code/shared.mjs';
|
|
278
291
|
import { getDataHome, getDataHomeDiagnostics, isGlobalInstall, pathIsInside } from '../lib/paths.js';
|
|
279
292
|
import { launcherLogPath } from '../lib/first-launch-protection.js';
|
|
280
|
-
import { registerStartLauncher, startLauncherState } from '../lib/start-launcher.js';
|
|
293
|
+
import { launcherRegistrationAllowed, registerStartLauncher, startLauncherState } from '../lib/start-launcher.js';
|
|
281
294
|
import { effectiveNpmPrefix } from '../lib/child-path.js';
|
|
282
295
|
import { checkRuntimeDependencies, fallbackInstallArgs, fallbackInstallEnv, findInstalled, mcpCompiler, npmCommand, npmInvocation } from '../lib/runtime-dependencies.js';
|
|
283
296
|
import { redactDeep, redactSecrets } from '../lib/log-redact.js';
|
|
@@ -307,6 +320,7 @@ import {
|
|
|
307
320
|
clearOpenCodeHistoryInWorker,
|
|
308
321
|
getOpenCodeHistoryStats,
|
|
309
322
|
OPENCODE_HISTORY_CONFIRMATION,
|
|
323
|
+
OPENCODE2_HISTORY_REFUSAL,
|
|
310
324
|
} from './lib/opencode-history.js';
|
|
311
325
|
import {
|
|
312
326
|
clearProjectStorageInWorker,
|
|
@@ -461,6 +475,8 @@ import {
|
|
|
461
475
|
import { createOpencodeCompletionEngine } from './lib/editor-completion-opencode.js';
|
|
462
476
|
import { createEndpointCompletionEngine } from './lib/editor-completion-endpoint.js';
|
|
463
477
|
import { ownerRouteGuard } from './lib/http-guards.js';
|
|
478
|
+
import { setUntrustedFileHeaders } from './lib/untrusted-file-headers.js';
|
|
479
|
+
import { BUNDLED_LOGO_FILES, UPLOADED_IMAGE_PATH } from './lib/request-gate.js';
|
|
464
480
|
import { createCloudflaredManager } from './lib/cloudflared/manager.js';
|
|
465
481
|
import { createQuickTunnel } from './lib/cloudflared/tunnel.js';
|
|
466
482
|
import { createGitBashManager } from './lib/git-bash/manager.js';
|
|
@@ -471,6 +487,9 @@ const PORT = process.env.NEURAL_PORT || 3344;
|
|
|
471
487
|
const EMBEDDING_DIMS = getEmbeddingDims();
|
|
472
488
|
const PACKAGE_ROOT = resolve(__dirname, '..');
|
|
473
489
|
const DATA_HOME = getDataHome();
|
|
490
|
+
// Where the slow lists windows and menus wait for (model lists, OpenCode's providers, Codex probes) are kept
|
|
491
|
+
// across restarts (lib/launch-cache.js): disposable, so outside data/ (no backup carries them) and outside the package.
|
|
492
|
+
const LAUNCH_CACHE_DIR = launchCacheDir(DATA_HOME);
|
|
474
493
|
registerCodexPlanRoutes(app, resolve(DATA_HOME, 'data', 'codex-plans'));
|
|
475
494
|
const DATA_HOME_DIAGNOSTICS = getDataHomeDiagnostics(DATA_HOME, PACKAGE_ROOT);
|
|
476
495
|
const CODEX_SIDEPANEL_PERMISSIONS_PATH = resolve(DATA_HOME, 'data', 'codex-sidepanel-permissions.json');
|
|
@@ -833,11 +852,19 @@ app.use('/skins', express.static(join(__dirname, 'skins'), {
|
|
|
833
852
|
lastModified: true,
|
|
834
853
|
setHeaders: (res) => res.setHeader('Cache-Control', 'no-cache'),
|
|
835
854
|
}));
|
|
855
|
+
// The user's own file icons (POST /api/file-icons, SVG among them): an <img> shows them, opened by their address
|
|
856
|
+
// they run nothing and an SVG is a download (lib/untrusted-file-headers.js).
|
|
836
857
|
app.use('/custom-icons', express.static(CUSTOM_ICONS_DIR, {
|
|
837
858
|
etag: true,
|
|
838
859
|
lastModified: true,
|
|
839
|
-
setHeaders: (res) => res.setHeader('Cache-Control', 'no-cache'),
|
|
860
|
+
setHeaders: (res, path) => { res.setHeader('Cache-Control', 'no-cache'); setUntrustedFileHeaders(res, path); },
|
|
840
861
|
}));
|
|
862
|
+
// A category's uploaded logo sits beside the app's own files ("/logo-<name>.<ext>"): it gets the same headers.
|
|
863
|
+
app.use(function uploadedLogoHeaders(req, res, next) {
|
|
864
|
+
const path = String(req.path || '').toLowerCase();
|
|
865
|
+
if (UPLOADED_IMAGE_PATH.test(path) && (!BUNDLED_LOGO_FILES.includes(path) || isCategoryUpload(path))) setUntrustedFileHeaders(res, path);
|
|
866
|
+
next();
|
|
867
|
+
});
|
|
841
868
|
app.use(express.static(join(__dirname, 'public'), {
|
|
842
869
|
etag: true,
|
|
843
870
|
lastModified: true,
|
|
@@ -3473,22 +3500,60 @@ function getNativeCodexBin() {
|
|
|
3473
3500
|
// its full path; on Windows the opencode.exe found, started directly with the
|
|
3474
3501
|
// PATH a new terminal has (a command file that leads nowhere: through cmd.exe,
|
|
3475
3502
|
// every word escaped). A command line in Settings is run as written.
|
|
3476
|
-
|
|
3503
|
+
// `protocol` is the OpenCode major it is (detectOpencodeProtocol): OpenCode 2
|
|
3504
|
+
// also gets its loopback host and a fresh password (lib/opencode-protocol.js
|
|
3505
|
+
// serveSpecFor), which the answer carries as `password`.
|
|
3506
|
+
function opencodeServeLaunch(port, { env = process.env, protocol = 1, password = null } = {}) {
|
|
3477
3507
|
const found = cliResolver.launcher('opencode');
|
|
3478
3508
|
_noteCliLookup('opencode', found, 'OpenCode');
|
|
3479
3509
|
const line = configuredCommandLine('opencode');
|
|
3510
|
+
const spec = serveSpecFor(protocol, port, { env, password });
|
|
3511
|
+
const words = spec.args.join(' ');
|
|
3480
3512
|
if (IS_WIN) {
|
|
3481
|
-
const spawnEnv = withPathEnv(env, getAugmentedPath());
|
|
3513
|
+
const spawnEnv = withPathEnv(spec.env, getAugmentedPath());
|
|
3482
3514
|
if (!line && found.spec) {
|
|
3483
|
-
const launch = spawnArgsFor(found.spec,
|
|
3484
|
-
return { command: launch.command, args: launch.args, options: { ...launch.options, env: spawnEnv, windowsHide: true }, label: `${found.launchPath}
|
|
3515
|
+
const launch = spawnArgsFor(found.spec, spec.args, { env: spawnEnv });
|
|
3516
|
+
return { command: launch.command, args: launch.args, options: { ...launch.options, env: spawnEnv, windowsHide: true }, label: `${found.launchPath} ${words}`, password: spec.password };
|
|
3485
3517
|
}
|
|
3486
|
-
const text = `${line || 'opencode'}
|
|
3487
|
-
return { command: DEFAULT_SHELL, args: ['/d', '/s', '/c', text], options: { env: spawnEnv, windowsHide: true }, label: text };
|
|
3518
|
+
const text = `${line || 'opencode'} ${words}`;
|
|
3519
|
+
return { command: DEFAULT_SHELL, args: ['/d', '/s', '/c', text], options: { env: spawnEnv, windowsHide: true }, label: text, password: spec.password };
|
|
3488
3520
|
}
|
|
3489
3521
|
const cmd = line || (found.path ? shellSingleQuote(found.path) : 'opencode');
|
|
3490
3522
|
// A packaged application's runtime and npm after the user's own (the login shell adds the user's profile on top).
|
|
3491
|
-
return { command: DEFAULT_SHELL, args: ['-lc', `exec ${cmd}
|
|
3523
|
+
return { command: DEFAULT_SHELL, args: ['-lc', `exec ${cmd} ${words}`], options: { env: withPackagedPathLast(spec.env) }, label: `${cmd} ${words}`, password: spec.password };
|
|
3524
|
+
}
|
|
3525
|
+
|
|
3526
|
+
// Which OpenCode major the binary SynaBun starts is: the resolver's `--version`
|
|
3527
|
+
// of it (cached by the file, so an upgrade in place is read again), then
|
|
3528
|
+
// lib/opencode-protocol.js protocolForVersion. `{ protocol: 1 | 2 | null,
|
|
3529
|
+
// major, version, supported, reason, path }`; `protocol` null with `supported`
|
|
3530
|
+
// false and a `major` is an OpenCode SynaBun does not speak (3 and later). A
|
|
3531
|
+
// command line in Settings has no version to read (`protocol` null, `major`
|
|
3532
|
+
// null): its serve is told apart by what answers (checkOpencodeServer).
|
|
3533
|
+
async function detectOpencodeProtocol({ force = false } = {}) {
|
|
3534
|
+
let found = cliResolver.launcher('opencode');
|
|
3535
|
+
if (configuredCommandLine('opencode')) {
|
|
3536
|
+
return { protocol: null, major: null, version: null, supported: true, reason: null, path: null, commandLine: true };
|
|
3537
|
+
}
|
|
3538
|
+
let version = found.version || null;
|
|
3539
|
+
if (!version || force || found.state === 'unverified') {
|
|
3540
|
+
try {
|
|
3541
|
+
const status = await cliResolver.resolve('opencode', { force });
|
|
3542
|
+
version = status?.version || version;
|
|
3543
|
+
found = cliResolver.launcher('opencode');
|
|
3544
|
+
version = found.version || version;
|
|
3545
|
+
} catch { /* the launch below says what is wrong */ }
|
|
3546
|
+
}
|
|
3547
|
+
if (!found.path) return { protocol: null, major: null, version: null, supported: false, reason: 'OpenCode is not installed.', path: null };
|
|
3548
|
+
return { ...protocolForVersion(version), path: found.path };
|
|
3549
|
+
}
|
|
3550
|
+
|
|
3551
|
+
// The OpenCode major as far as it is known now, without starting anything (for
|
|
3552
|
+
// a command line built at once): the resolver's cached --version, else the
|
|
3553
|
+
// major the shared serve answered with, else 1.
|
|
3554
|
+
function knownOpencodeMajor() {
|
|
3555
|
+
const fromVersion = protocolForVersion(cliResolver.launcher('opencode').version).protocol;
|
|
3556
|
+
return fromVersion || _ocpProtocol || 1;
|
|
3492
3557
|
}
|
|
3493
3558
|
|
|
3494
3559
|
function extractCodexConfigValues(result) {
|
|
@@ -3784,6 +3849,8 @@ function _codexAccounts() {
|
|
|
3784
3849
|
broadcastSync({ type: 'codex:accounts-changed', accountId: event.accountId || null, removed: !!event.removed, created: !!event.created });
|
|
3785
3850
|
broadcastSync({ type: 'assistant:accounts-changed', provider: 'codex', accountId: event.accountId || null, removed: !!event.removed });
|
|
3786
3851
|
_assistantCatalog?.invalidate?.();
|
|
3852
|
+
// The account's model list is read again (another sign-in is another key; a removed account's is dropped).
|
|
3853
|
+
codexModelListsChanged(event.accountId || null, { removed: !!event.removed });
|
|
3787
3854
|
} catch {}
|
|
3788
3855
|
});
|
|
3789
3856
|
}
|
|
@@ -4036,7 +4103,7 @@ CRITICAL — When you need clarification during planning:
|
|
|
4036
4103
|
codexSessionSettings.set(activeAccountId, threadId, settings);
|
|
4037
4104
|
}
|
|
4038
4105
|
if (['skills_config_write', 'experimental_features_set'].includes(type)) {
|
|
4039
|
-
if (type === 'experimental_features_set') codexContextModels = [];
|
|
4106
|
+
if (type === 'experimental_features_set') { codexContextModels = []; codexModelListsChanged(null); }
|
|
4040
4107
|
sendToClient({ type: 'catalogs_changed', catalogs: type === 'skills_config_write' ? ['skills'] : ['models', 'skills', 'apps', 'mcp', 'features'] });
|
|
4041
4108
|
}
|
|
4042
4109
|
}
|
|
@@ -4547,9 +4614,10 @@ CRITICAL — When you need clarification during planning:
|
|
|
4547
4614
|
}
|
|
4548
4615
|
|
|
4549
4616
|
async function readSettingsMetadata() {
|
|
4550
|
-
|
|
4617
|
+
// Both probes start Codex itself, off the event loop, once per binary (kept across restarts).
|
|
4618
|
+
const schema = await readCodexSettingsSchema(getCodexBin());
|
|
4551
4619
|
const features = await listCodexPages(request, 'experimentalFeature/list', { limit: 100 });
|
|
4552
|
-
const featureSupport = readCodexFeatureSupport(getCodexBin(), features.data);
|
|
4620
|
+
const featureSupport = await readCodexFeatureSupport(getCodexBin(), features.data);
|
|
4553
4621
|
// The catalog's enabled field is a process-start snapshot. config/read is
|
|
4554
4622
|
// authoritative for saved defaults after reloadUserConfig writes.
|
|
4555
4623
|
return { ...schema, features: features.data.map(feature => ({ ...feature,
|
|
@@ -5540,12 +5608,12 @@ CRITICAL — When you need clarification during planning:
|
|
|
5540
5608
|
// Sync tool approvals only after the base transport is known to be valid.
|
|
5541
5609
|
syncCodexToolPermissions([..._codexPermittedTools], activeCodexHome);
|
|
5542
5610
|
const codexBin = getCodexBin();
|
|
5611
|
+
// The version the resolver read (its `--version`, cached per binary and asked off the request): a
|
|
5612
|
+
// blocking spawn here froze the whole server for up to 5 s. Before the first probe of a binary it is
|
|
5613
|
+
// null, and the app-server's own userAgent fills it in at initialize (below).
|
|
5543
5614
|
let cliVersion = null;
|
|
5544
5615
|
let sdkVersion = null;
|
|
5545
|
-
try {
|
|
5546
|
-
const versionResult = spawnSync(codexBin, ['--version'], { encoding: 'utf-8', timeout: 5000, windowsHide: true });
|
|
5547
|
-
cliVersion = parseCodexVersion(versionResult.stdout);
|
|
5548
|
-
} catch {}
|
|
5616
|
+
try { cliVersion = parseCodexVersion(cliResolver.launcher('codex').version); } catch {}
|
|
5549
5617
|
sdkVersion = findInstalled(__dirname, '@openai/codex-sdk', { stopAt: PACKAGE_ROOT })?.version || null;
|
|
5550
5618
|
codexCapabilities.reset({ codexBin, codexBinSource: _codexBinSource, cliVersion, sdkVersion });
|
|
5551
5619
|
startChild();
|
|
@@ -6433,11 +6501,18 @@ CRITICAL — When you need clarification during planning:
|
|
|
6433
6501
|
}
|
|
6434
6502
|
return;
|
|
6435
6503
|
}
|
|
6504
|
+
// The sandbox check below is synchronous: the schema it validates against is read first (asynchronously,
|
|
6505
|
+
// once per Codex binary), so the check itself never starts Codex.
|
|
6506
|
+
if (msg.type === 'thread_settings_update' && Object.prototype.hasOwnProperty.call(msg, 'sandboxMode') && msg.sandboxMode !== null) {
|
|
6507
|
+
await readCodexSettingsSchema(getCodexBin()).catch(() => null);
|
|
6508
|
+
}
|
|
6436
6509
|
if (await handleCodexCompatMessage(msg, {
|
|
6437
6510
|
activeThreadId, activeTurnId, cwd: PACKAGE_ROOT,
|
|
6438
6511
|
role: codexRole, getActiveThreadId: () => activeThreadId,
|
|
6439
6512
|
sandboxPolicyForMode: (mode, cwd) => {
|
|
6440
|
-
|
|
6513
|
+
const schema = cachedCodexSettingsSchema(getCodexBin());
|
|
6514
|
+
if (!schema) throw new Error('Codex could not report its settings schema');
|
|
6515
|
+
validateCodexSettings({ sandbox_mode: mode }, schema);
|
|
6441
6516
|
return buildCodexSandboxPolicy(cwd, mode);
|
|
6442
6517
|
},
|
|
6443
6518
|
limitAppCall: message => appBoundary.consume(activeThreadId, message.originCallId),
|
|
@@ -6784,6 +6859,8 @@ CRITICAL — When you need clarification during planning:
|
|
|
6784
6859
|
await request('config/batchWrite', { edits, reloadUserConfig: true }, 10000);
|
|
6785
6860
|
const config = await readConfigState(msg.cwd || PACKAGE_ROOT);
|
|
6786
6861
|
codexContextModels = [];
|
|
6862
|
+
// config.toml is every account's (shared): their kept model lists are read again.
|
|
6863
|
+
codexModelListsChanged(null);
|
|
6787
6864
|
sendToClient({ type: 'config_saved', requestId: msg.requestId || null, config });
|
|
6788
6865
|
sendToClient({ type: 'catalogs_changed', catalogs: ['models', 'skills', 'apps', 'mcp', 'features', 'config'] });
|
|
6789
6866
|
} catch (err) {
|
|
@@ -7434,8 +7511,63 @@ CRITICAL — When you need clarification during planning:
|
|
|
7434
7511
|
ws.on('close', handleCodexSocketClose);
|
|
7435
7512
|
}
|
|
7436
7513
|
|
|
7437
|
-
|
|
7438
|
-
|
|
7514
|
+
// The Codex model list of each account (GET /api/codex/models, the Studios' model check, the Assistant's
|
|
7515
|
+
// catalog through the route): read from the account's app-server, answered at once from the last good list
|
|
7516
|
+
// (lib/launch-cache.js, kept across restarts) and read again in the background once it is old. Keyed on what
|
|
7517
|
+
// it depends on: the account and its home, the Codex binary (an update is another binary) and the account's
|
|
7518
|
+
// sign-in, so one account's list is never given for another. An empty list is what a failure leaves (Codex
|
|
7519
|
+
// missing, signed out): never kept.
|
|
7520
|
+
const CODEX_MODEL_LIST_CACHE_MS = 10 * 60_000;
|
|
7521
|
+
|
|
7522
|
+
function codexModelListKey(accountId) {
|
|
7523
|
+
const id = String(accountId || 'default');
|
|
7524
|
+
const account = findCodexAccount(id);
|
|
7525
|
+
let signIn = '';
|
|
7526
|
+
try {
|
|
7527
|
+
const row = _codexAccounts().describe(id);
|
|
7528
|
+
signIn = createHash('sha256').update(`${row?.mode || ''}\n${row?.identity || ''}\n${row?.signedIn}`).digest('hex').slice(0, 16);
|
|
7529
|
+
} catch { /* unreadable: keyed on the rest */ }
|
|
7530
|
+
return JSON.stringify([id, account?.home || CODEX_DEFAULT_HOME, binaryIdentity(configuredCommandLine('codex') || getCodexBin()), signIn]);
|
|
7531
|
+
}
|
|
7532
|
+
|
|
7533
|
+
const _codexModelListCache = createLaunchCache({
|
|
7534
|
+
name: 'codex-models',
|
|
7535
|
+
dir: LAUNCH_CACHE_DIR,
|
|
7536
|
+
ttlMs: CODEX_MODEL_LIST_CACHE_MS,
|
|
7537
|
+
maxEntries: 24,
|
|
7538
|
+
load: async (key) => {
|
|
7539
|
+
const [accountId] = JSON.parse(key);
|
|
7540
|
+
const account = findCodexAccount(accountId);
|
|
7541
|
+
if (!account) throw new Error(`Codex account not found: ${accountId}`);
|
|
7542
|
+
const codexHome = account.home || CODEX_DEFAULT_HOME;
|
|
7543
|
+
return mergeCodexModelContextCapabilities(
|
|
7544
|
+
await queryCodexAppServerModels(codexHome, _codexAccounts().launchFor(account.id)),
|
|
7545
|
+
readCodexRuntimeModelCatalog(codexHome),
|
|
7546
|
+
);
|
|
7547
|
+
},
|
|
7548
|
+
accept: (models) => Array.isArray(models) && models.length > 0,
|
|
7549
|
+
// The Assistant's catalog is built from the default account's list: when that list changes, its copy is
|
|
7550
|
+
// dropped and rebuilt (which tells the open windows).
|
|
7551
|
+
onChange: (key) => {
|
|
7552
|
+
try { if (JSON.parse(key)[0] === 'default') _assistantCatalog?.refresh?.().catch(() => {}); } catch {}
|
|
7553
|
+
},
|
|
7554
|
+
log: (message) => console.warn(message),
|
|
7555
|
+
});
|
|
7556
|
+
|
|
7557
|
+
/**
|
|
7558
|
+
* A Codex account changed (sign-in, mode, removal) or its config was saved (`accountId` null: every
|
|
7559
|
+
* account): its kept lists are asked for again in the background, a removed account's are dropped.
|
|
7560
|
+
*/
|
|
7561
|
+
function codexModelListsChanged(accountId = null, { removed = false } = {}) {
|
|
7562
|
+
for (const key of _codexModelListCache.keys()) {
|
|
7563
|
+
let id = null;
|
|
7564
|
+
try { id = JSON.parse(key)[0]; } catch { continue; }
|
|
7565
|
+
if (accountId && id !== accountId) continue;
|
|
7566
|
+
if (removed) _codexModelListCache.invalidate(key);
|
|
7567
|
+
else _codexModelListCache.markStale(key);
|
|
7568
|
+
}
|
|
7569
|
+
if (!removed) scheduleLaunchRefresh('codex-models');
|
|
7570
|
+
}
|
|
7439
7571
|
|
|
7440
7572
|
// `launch` (lib/codex-accounts.js launchFor()): the list is read under the account's own credential,
|
|
7441
7573
|
// with no inherited one, as its sessions would run (in API-key mode, the key it holds over stdin).
|
|
@@ -7563,22 +7695,11 @@ app.get('/api/codex/models', async (req, res) => {
|
|
|
7563
7695
|
const accountId = String(req.query?.accountId || 'default');
|
|
7564
7696
|
const account = findCodexAccount(accountId);
|
|
7565
7697
|
if (!account) return res.status(404).json({ ok: false, error: `Codex account not found: ${accountId}`, models: [] });
|
|
7566
|
-
|
|
7698
|
+
// The kept list at once (read again in the background when old); a first read, another sign-in or
|
|
7699
|
+
// binary, or ?refresh=1 waits for the account's app-server.
|
|
7567
7700
|
const force = req.query?.refresh === '1';
|
|
7568
|
-
const cached = _codexModelListCache.
|
|
7569
|
-
|
|
7570
|
-
return res.json({ ok: true, source: 'cache', models: cached.models });
|
|
7571
|
-
}
|
|
7572
|
-
const models = mergeCodexModelContextCapabilities(
|
|
7573
|
-
await queryCodexAppServerModels(codexHome, _codexAccounts().launchFor(account.id)),
|
|
7574
|
-
readCodexRuntimeModelCatalog(codexHome),
|
|
7575
|
-
);
|
|
7576
|
-
// The Assistant's catalog is built from the default account's list: when that list
|
|
7577
|
-
// changes, its copy is dropped and rebuilt (which tells the open windows).
|
|
7578
|
-
const listChanged = codexHome === CODEX_DEFAULT_HOME && !!cached && JSON.stringify(cached.models) !== JSON.stringify(models);
|
|
7579
|
-
_codexModelListCache.set(codexHome, { at: Date.now(), models });
|
|
7580
|
-
if (listChanged) _assistantCatalog?.refresh?.().catch(() => {});
|
|
7581
|
-
res.json({ ok: true, source: 'codex-app-server', models });
|
|
7701
|
+
const { value: models, cached } = await _codexModelListCache.read(codexModelListKey(account.id), { refresh: force });
|
|
7702
|
+
res.json({ ok: true, source: cached ? 'cache' : 'codex-app-server', models: Array.isArray(models) ? models : [] });
|
|
7582
7703
|
} catch (err) {
|
|
7583
7704
|
res.json({ ok: false, source: 'fallback', error: err.message, models: [] });
|
|
7584
7705
|
}
|
|
@@ -7614,6 +7735,12 @@ let _ocpBaseUrl = () => `http://127.0.0.1:${_ocpPort}`;
|
|
|
7614
7735
|
let _ocpStarting = false;
|
|
7615
7736
|
let _ocpReady = false;
|
|
7616
7737
|
let _ocpVersion = null;
|
|
7738
|
+
// The OpenCode major the shared serve speaks (1 or 2; null until one answered),
|
|
7739
|
+
// the password SynaBun gave the OpenCode 2 serve it started, and the sentence
|
|
7740
|
+
// that says why OpenCode cannot be used (shown by the panel and the tray).
|
|
7741
|
+
let _ocpProtocol = null;
|
|
7742
|
+
let _ocpPassword = null;
|
|
7743
|
+
let _ocpProblem = null;
|
|
7617
7744
|
let _ocpHistoryClearInProgress = false;
|
|
7618
7745
|
|
|
7619
7746
|
// ── OpenCode V2 (clean-slate sidepanel) — client set & bridge ──
|
|
@@ -7621,9 +7748,18 @@ const _ocpV2WsClients = new Set(); // all connected /ws/opencode-v2 clients
|
|
|
7621
7748
|
let _ocpV2ListenerInstalled = false;
|
|
7622
7749
|
let _ocpV2ClientSeq = 0;
|
|
7623
7750
|
const OCP_MANAGED_PID_PATH = resolve(DATA_HOME, 'data', 'opencode-managed.pid');
|
|
7751
|
+
// Every OpenCode serve SynaBun starts, on record while it runs (lib/opencode-serve-records.js): what a crash left
|
|
7752
|
+
// running is found by these records alone, and stopped only when it is provably SynaBun's and nothing uses it.
|
|
7753
|
+
// Process state of this machine, so outside data/ (no backup or restore carries it).
|
|
7754
|
+
const opencodeServeRecords = createServeRecords({
|
|
7755
|
+
dir: resolve(DATA_HOME, 'runtime', 'opencode-serves'),
|
|
7756
|
+
log: (message) => console.log(`[opencode] ${message}`),
|
|
7757
|
+
});
|
|
7758
|
+
migrateOpencodeManagedPid();
|
|
7624
7759
|
let _ocpBootReconcileTried = false;
|
|
7625
7760
|
let _ocpLastBootAt = 0; // Date.now() when serve process last became ready
|
|
7626
7761
|
let _ocpAuthReconciling = false; // prevent concurrent reconciliation
|
|
7762
|
+
let _ocpAuthStaleNoted = 0; // the boot whose server SynaBun said it would not restart (once per boot)
|
|
7627
7763
|
|
|
7628
7764
|
// ── Verbose trace logging (opt-in: OCP_VERBOSE=1 to enable) ──
|
|
7629
7765
|
const OCP_VERBOSE = process.env.OCP_VERBOSE === '1';
|
|
@@ -7688,24 +7824,95 @@ function persistMcpToOpencodeConfig(name, mcpConfig) {
|
|
|
7688
7824
|
console.log(`[opencode] Persisted MCP server "${name}" to ${configPath}`);
|
|
7689
7825
|
}
|
|
7690
7826
|
|
|
7691
|
-
|
|
7692
|
-
|
|
7693
|
-
|
|
7694
|
-
|
|
7695
|
-
} catch {}
|
|
7696
|
-
|
|
7827
|
+
// opencode-managed.pid is where an older SynaBun kept its shared serve: a bare pid, or a record without the token,
|
|
7828
|
+
// machine, user, boot and owner every record holds now. Neither can ever prove which process it names, so neither
|
|
7829
|
+
// is ever acted on: the file goes, and a serve it named that still runs is left running.
|
|
7830
|
+
function migrateOpencodeManagedPid() {
|
|
7831
|
+
try { unlinkSync(OCP_MANAGED_PID_PATH); } catch {}
|
|
7832
|
+
}
|
|
7833
|
+
|
|
7834
|
+
// Every serve SynaBun starts is on record from its spawn until its process has ended (lib/opencode-serve-records.js):
|
|
7835
|
+
// the process SynaBun spawned at once, under the token SynaBun put in its environment (SERVE_TOKEN_ENV); and, once
|
|
7836
|
+
// something answers on its port, the process that answers when the spawned one started it (proved through the
|
|
7837
|
+
// parent chain). No other process is ever put on record. `answering()` says whether the serve that answers on the
|
|
7838
|
+
// port is this start's: 'ours', 'foreign' (another server took the port: a start collision, which is left running and
|
|
7839
|
+
// never recorded) or 'unknown' (the OS could not say who listens: only the spawned process stays on record). The
|
|
7840
|
+
// spawned process's record goes with its exit, which Node has seen; a process it started that outlives it stays on
|
|
7841
|
+
// record until it has provably ended. Every serve SynaBun starts comes through here: the shared serve, the isolated
|
|
7842
|
+
// serves, the completion serve.
|
|
7843
|
+
function trackOpencodeServe(proc, { kind, id = null, port, token }) {
|
|
7844
|
+
const pid = Number(proc?.pid) || null;
|
|
7845
|
+
const exited = () => !proc || proc.exitCode != null || proc.signalCode != null;
|
|
7846
|
+
const spawned = pid ? opencodeServeRecords.record({ kind, id, port, pid, token }).catch(() => null) : Promise.resolve(null);
|
|
7847
|
+
let started = Promise.resolve(null);
|
|
7848
|
+
const forget = () => {
|
|
7849
|
+
spawned.then((record) => { if (record) opencodeServeRecords.remove(record); }).catch(() => {});
|
|
7850
|
+
started.then((record) => { if (record && opencodeServeRecords.verifySync(record) === 'gone') opencodeServeRecords.remove(record); }).catch(() => {});
|
|
7851
|
+
};
|
|
7852
|
+
if (exited()) forget();
|
|
7853
|
+
else proc.once?.('exit', forget);
|
|
7854
|
+
return {
|
|
7855
|
+
spawned,
|
|
7856
|
+
async answering() {
|
|
7857
|
+
await spawned;
|
|
7858
|
+
const who = await opencodeServeRecords.answering({ port, pid, exited });
|
|
7859
|
+
if (who.state !== 'ours') return who.state;
|
|
7860
|
+
// Read again now that it answers (its login shell has exec'd OpenCode by now), and the process that answers
|
|
7861
|
+
// when the spawned one started it.
|
|
7862
|
+
if (!exited()) await opencodeServeRecords.record({ kind, id, port, pid, token }).catch(() => null);
|
|
7863
|
+
if (who.pid !== pid) started = opencodeServeRecords.record({ kind, id, port, pid: who.pid, token }).catch(() => null);
|
|
7864
|
+
await started;
|
|
7865
|
+
return 'ours';
|
|
7866
|
+
},
|
|
7867
|
+
};
|
|
7697
7868
|
}
|
|
7698
7869
|
|
|
7699
|
-
|
|
7700
|
-
|
|
7701
|
-
|
|
7702
|
-
|
|
7703
|
-
|
|
7704
|
-
|
|
7870
|
+
// Look for the OpenCode serves an earlier SynaBun left running (lib/opencode-serve-records.js), when SynaBun
|
|
7871
|
+
// starts and before a port is picked for a serve: one look at a time, shared by everyone who waits for it.
|
|
7872
|
+
function sweepLeftoverOpencodeServes() {
|
|
7873
|
+
return opencodeServeRecords.sweep().then((result) => {
|
|
7874
|
+
if (result.stopped.length) console.log(`[opencode] stopped ${result.stopped.length} serve(s) an earlier SynaBun left running: ${result.stopped.map((r) => `${r.kind} on port ${r.port} (pid ${r.pid})`).join(', ')}`);
|
|
7875
|
+
return result;
|
|
7876
|
+
}, () => null);
|
|
7705
7877
|
}
|
|
7706
7878
|
|
|
7707
|
-
|
|
7708
|
-
|
|
7879
|
+
// SynaBun's shared serve, on record (kind 'shared'), on `port` when one is named, the newest first: only a record
|
|
7880
|
+
// this server may act on (lib/opencode-serve-records.js claimable): written on this machine, for this user, in this
|
|
7881
|
+
// boot, and its own or one whose SynaBun has positively ended. Never another live SynaBun's (a data home two servers
|
|
7882
|
+
// share), one whose owner was never recorded or cannot be read now, or one of another machine or user: their serves
|
|
7883
|
+
// are servers SynaBun did not start, as far as this server is concerned.
|
|
7884
|
+
function readOpencodeManagedRecord({ port = null } = {}) {
|
|
7885
|
+
return opencodeServeRecords.list({ kind: 'shared', port }).find((record) => opencodeServeRecords.claimable(record)) || null;
|
|
7886
|
+
}
|
|
7887
|
+
|
|
7888
|
+
// A record goes once its process has provably ended (or its pid is another process's by now); one that cannot be
|
|
7889
|
+
// read now may still run, and stays.
|
|
7890
|
+
function clearOpencodeManagedPid(record = readOpencodeManagedRecord()) {
|
|
7891
|
+
if (record && opencodeServeRecords.verifySync(record) === 'gone') opencodeServeRecords.remove(record);
|
|
7892
|
+
}
|
|
7893
|
+
|
|
7894
|
+
// The recorded serves this server may act on while the OS still reports each as that very process: the same pid
|
|
7895
|
+
// and start time, the user's own, the token of its spawn in its environment (and, given a port, while it listens
|
|
7896
|
+
// there). Only such a process, or SynaBun's own child, is ever stopped.
|
|
7897
|
+
function verifiedOpencodeManagedProcesses({ port = null } = {}) {
|
|
7898
|
+
return opencodeServeRecords.list({ kind: 'shared', port }).filter((record) => record.pid !== process.pid
|
|
7899
|
+
&& opencodeServeRecords.claimable(record)
|
|
7900
|
+
&& opencodeServeRecords.verifySync(record) === 'same'
|
|
7901
|
+
&& (port == null || process.platform === 'win32' || listPortListenerPids(port).includes(record.pid)));
|
|
7902
|
+
}
|
|
7903
|
+
|
|
7904
|
+
function verifiedOpencodeManagedProcess({ port = null } = {}) {
|
|
7905
|
+
return verifiedOpencodeManagedProcesses({ port })[0] || null;
|
|
7906
|
+
}
|
|
7907
|
+
|
|
7908
|
+
// Signal SynaBun's recorded serve, checked once more right before: a pid that is another process by now, or one
|
|
7909
|
+
// that cannot be read, is left alone. Windows ends the serve's tree with taskkill.
|
|
7910
|
+
function signalOwnedOpencodeProcess(record, signal) {
|
|
7911
|
+
if (!record || opencodeServeRecords.verifySync(record) !== 'same') return false;
|
|
7912
|
+
if (process.platform === 'win32') {
|
|
7913
|
+
try { spawn('taskkill', ['/pid', String(record.pid), '/T', '/F'], { shell: true, windowsHide: true, stdio: 'ignore' }); return true; } catch { return false; }
|
|
7914
|
+
}
|
|
7915
|
+
return signalPids([record.pid], signal).length > 0;
|
|
7709
7916
|
}
|
|
7710
7917
|
|
|
7711
7918
|
function parseOpencodeLogTimestamp(fileName) {
|
|
@@ -7748,7 +7955,9 @@ function shouldReconcileOpencodeForStoredAuth() {
|
|
|
7748
7955
|
return false;
|
|
7749
7956
|
}
|
|
7750
7957
|
|
|
7751
|
-
// Runtime auth reconciliation: restart serve if auth.json changed since last boot
|
|
7958
|
+
// Runtime auth reconciliation: restart serve if auth.json changed since last boot. Automatic, so only a serve
|
|
7959
|
+
// SynaBun can prove is its own is restarted (its child, or its recorded serve that is still that process); a
|
|
7960
|
+
// server someone else runs is used as it is, and said once.
|
|
7752
7961
|
async function reconcileAuthIfStale() {
|
|
7753
7962
|
if (_ocpAuthReconciling || !_ocpReady || !_ocpLastBootAt) return false;
|
|
7754
7963
|
try {
|
|
@@ -7756,6 +7965,13 @@ async function reconcileAuthIfStale() {
|
|
|
7756
7965
|
if (!existsSync(authPath)) return false;
|
|
7757
7966
|
const authMtime = statSync(authPath).mtimeMs;
|
|
7758
7967
|
if (authMtime <= _ocpLastBootAt) return false;
|
|
7968
|
+
if (!_ocpProc && !verifiedOpencodeManagedProcess({ port: _ocpPort })) {
|
|
7969
|
+
if (_ocpAuthStaleNoted !== _ocpLastBootAt) {
|
|
7970
|
+
_ocpAuthStaleNoted = _ocpLastBootAt;
|
|
7971
|
+
console.warn(`[opencode] auth.json changed, but the OpenCode server on port ${_ocpPort} was not started by SynaBun: it is not restarted (it shows the new credentials once whoever runs it restarts it)`);
|
|
7972
|
+
}
|
|
7973
|
+
return false;
|
|
7974
|
+
}
|
|
7759
7975
|
_ocpAuthReconciling = true;
|
|
7760
7976
|
console.log('[opencode] auth.json modified after last boot — restarting to pick up new credentials');
|
|
7761
7977
|
await takeoverOpencodeServer();
|
|
@@ -7779,15 +7995,7 @@ function listPortListenerPids(port) {
|
|
|
7779
7995
|
return [];
|
|
7780
7996
|
}
|
|
7781
7997
|
|
|
7782
|
-
|
|
7783
|
-
if (process.platform === 'win32') return [];
|
|
7784
|
-
try {
|
|
7785
|
-
const out = execSync('pgrep -f "opencode.*serve"', { encoding: 'utf8' }).trim();
|
|
7786
|
-
return out.split('\n').filter(Boolean).map((pid) => Number(pid)).filter((pid) => Number.isFinite(pid) && pid > 0 && pid !== process.pid);
|
|
7787
|
-
} catch {}
|
|
7788
|
-
return [];
|
|
7789
|
-
}
|
|
7790
|
-
|
|
7998
|
+
// The raw signal. Only signalOwnedOpencodeProcess calls it, for a process it has just checked is SynaBun's.
|
|
7791
7999
|
function signalPids(pids, signal) {
|
|
7792
8000
|
const targeted = [];
|
|
7793
8001
|
for (const pid of pids) {
|
|
@@ -7799,19 +8007,6 @@ function signalPids(pids, signal) {
|
|
|
7799
8007
|
return targeted;
|
|
7800
8008
|
}
|
|
7801
8009
|
|
|
7802
|
-
async function waitForOpencodeShutdown(timeoutMs = 6000) {
|
|
7803
|
-
const deadline = Date.now() + timeoutMs;
|
|
7804
|
-
while (Date.now() < deadline) {
|
|
7805
|
-
const listenerPids = listPortListenerPids(_ocpPort);
|
|
7806
|
-
if (!listenerPids.length) {
|
|
7807
|
-
const healthy = await checkOpencodeHealth();
|
|
7808
|
-
if (!healthy) return true;
|
|
7809
|
-
}
|
|
7810
|
-
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
7811
|
-
}
|
|
7812
|
-
return false;
|
|
7813
|
-
}
|
|
7814
|
-
|
|
7815
8010
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
7816
8011
|
// OpenCode V2 — clean-slate broadcast bridge & WS handler
|
|
7817
8012
|
// Pass-through: forwards opencodeV2Client envelopes verbatim to /ws/opencode-v2
|
|
@@ -7846,7 +8041,15 @@ const OPENCODE_HISTORY_WRITE_TYPES = new Set([
|
|
|
7846
8041
|
...OPENCODE_V2_WS_MUTATION_TYPES,
|
|
7847
8042
|
]);
|
|
7848
8043
|
|
|
8044
|
+
// Set while the provider cache announces its own change (it is not a reason to read the list again).
|
|
8045
|
+
let _announcingOpencodeProviders = false;
|
|
8046
|
+
|
|
7849
8047
|
function broadcastToOpencodeV2Clients(data, { nativeRunId = null } = {}) {
|
|
8048
|
+
// The kept provider list follows what the panels are told: a credential change, the serve becoming
|
|
8049
|
+
// ready, a catalog event (the same ones the panel re-reads on, ocp-v2-status-logic.js).
|
|
8050
|
+
if (data?.type === 'providers:changed' && !_announcingOpencodeProviders) noteOpencodeProvidersChanged({ announce: true });
|
|
8051
|
+
else if (data?.type === 'server:status' && data.status === 'ready') noteOpencodeProvidersChanged();
|
|
8052
|
+
else if (data?.type === 'event' && /^(?:catalog\.updated|models-dev\.refreshed|integration\.)/.test(String(data.eventType || ''))) noteOpencodeProvidersChanged();
|
|
7850
8053
|
// OpenCode 1.18 mirrors every bus event as a `sync` envelope; the panel acts
|
|
7851
8054
|
// on the bus event, so relaying the mirror only doubles the traffic.
|
|
7852
8055
|
if (!shouldRelayOpencodeV2Message(data)) return;
|
|
@@ -7888,6 +8091,7 @@ function installOpencodeV2Listener() {
|
|
|
7888
8091
|
type: 'server:status',
|
|
7889
8092
|
status: event?.status || 'unknown',
|
|
7890
8093
|
version: _ocpVersion,
|
|
8094
|
+
protocol: _ocpProtocol,
|
|
7891
8095
|
port: event?.port || _ocpPort,
|
|
7892
8096
|
managed: !!_ocpProc,
|
|
7893
8097
|
});
|
|
@@ -7924,6 +8128,78 @@ function allocOpencodeIsoPort() {
|
|
|
7924
8128
|
return null;
|
|
7925
8129
|
}
|
|
7926
8130
|
|
|
8131
|
+
// OpenCode 2 cannot open its database while another serve of the same data
|
|
8132
|
+
// folder is opening it ("database is locked", and the serve exits): SynaBun
|
|
8133
|
+
// starts its OpenCode 2 serves one at a time, each until it answers. Every
|
|
8134
|
+
// start goes through here: the shared serve (ensureOpencodeServer), the
|
|
8135
|
+
// isolated serves of panel sessions, loops, workers and brains
|
|
8136
|
+
// (ensureIsolatedServe) and the editor's completion serve
|
|
8137
|
+
// (lib/editor-completion-opencode.js `startOneAtATime`). A start holds its turn
|
|
8138
|
+
// from the spawn until its serve answers, or until a failed start's process is
|
|
8139
|
+
// gone. OpenCode 1 serves start as they always did, side by side.
|
|
8140
|
+
let _ocpStartChain = Promise.resolve();
|
|
8141
|
+
function oneOpencodeStartAtATime(protocol, run) {
|
|
8142
|
+
if (protocol === 1) return run();
|
|
8143
|
+
const next = _ocpStartChain.then(run, run);
|
|
8144
|
+
_ocpStartChain = next.then(() => {}, () => {});
|
|
8145
|
+
return next;
|
|
8146
|
+
}
|
|
8147
|
+
|
|
8148
|
+
// Resolves once a child process has exited (true), or after `timeoutMs` (false): a failed start keeps its turn
|
|
8149
|
+
// until its process has let go of the database.
|
|
8150
|
+
function opencodeChildExited(proc, timeoutMs = 3000) {
|
|
8151
|
+
if (!proc || proc.exitCode != null || proc.signalCode != null) return Promise.resolve(true);
|
|
8152
|
+
return new Promise((done) => {
|
|
8153
|
+
const timer = setTimeout(() => done(false), timeoutMs);
|
|
8154
|
+
timer.unref?.();
|
|
8155
|
+
proc.once?.('exit', () => { clearTimeout(timer); done(true); });
|
|
8156
|
+
});
|
|
8157
|
+
}
|
|
8158
|
+
|
|
8159
|
+
// A start that failed ends its own process before its turn is over. It was sent SIGTERM (taskkill /T /F on Windows);
|
|
8160
|
+
// one still running after `timeoutMs` gets SIGKILL through its handle, which only ever reaches SynaBun's own child
|
|
8161
|
+
// (Node stops signalling a child once it has seen its exit, so never a pid another program has by now), and is
|
|
8162
|
+
// waited for once more. True once its exit is seen. Its record stays until then (trackOpencodeServe removes it on
|
|
8163
|
+
// that exit): one that outlives even SIGKILL stays on record for the look after this server has ended.
|
|
8164
|
+
async function endOpencodeChild(proc, timeoutMs = 3000) {
|
|
8165
|
+
if (await opencodeChildExited(proc, timeoutMs)) return true;
|
|
8166
|
+
console.warn(`[opencode] serve process ${proc.pid} did not end within ${Math.round(timeoutMs / 1000)} s of SIGTERM: SIGKILL`);
|
|
8167
|
+
try {
|
|
8168
|
+
if (IS_WIN && proc.pid) spawn('taskkill', ['/pid', String(proc.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' }).on?.('error', () => {});
|
|
8169
|
+
else proc.kill('SIGKILL');
|
|
8170
|
+
} catch {}
|
|
8171
|
+
if (await opencodeChildExited(proc, timeoutMs)) return true;
|
|
8172
|
+
console.warn(`[opencode] serve process ${proc.pid} still runs after SIGKILL: it stays on record`);
|
|
8173
|
+
return false;
|
|
8174
|
+
}
|
|
8175
|
+
|
|
8176
|
+
function killIsolatedServeProcess(proc) {
|
|
8177
|
+
try {
|
|
8178
|
+
if (proc && !proc.killed && proc.exitCode == null) {
|
|
8179
|
+
if (IS_WIN && proc.pid) {
|
|
8180
|
+
spawn('taskkill', ['/pid', String(proc.pid), '/T', '/F'], {
|
|
8181
|
+
shell: true,
|
|
8182
|
+
windowsHide: true,
|
|
8183
|
+
stdio: 'ignore',
|
|
8184
|
+
});
|
|
8185
|
+
} else {
|
|
8186
|
+
proc.kill('SIGTERM');
|
|
8187
|
+
}
|
|
8188
|
+
}
|
|
8189
|
+
} catch {}
|
|
8190
|
+
}
|
|
8191
|
+
|
|
8192
|
+
// An isolated start that is no longer wanted ends what it started. While its entry is still the one under its
|
|
8193
|
+
// term id, that is a stop like any other; a start that was cancelled (stopIsolatedServe) or replaced (a scoped
|
|
8194
|
+
// restart under the same term id) meanwhile ends only its own process and client: the replacement keeps its
|
|
8195
|
+
// bindings and its config folder, which has the same path.
|
|
8196
|
+
function endIsolatedStart(termId, entry) {
|
|
8197
|
+
if (_ocpIsoServes.get(termId) === entry) { stopIsolatedServe(termId); return; }
|
|
8198
|
+
try { entry.unsub?.(); } catch {}
|
|
8199
|
+
try { entry.client?.stop(); } catch {}
|
|
8200
|
+
killIsolatedServeProcess(entry.proc);
|
|
8201
|
+
}
|
|
8202
|
+
|
|
7927
8203
|
// Sidepanel variant of setupOpencodeLoopConfig: inject the terminal identity and
|
|
7928
8204
|
// pinned profile into the MCP env (no browser pins → the child acquires its own
|
|
7929
8205
|
// tab) and do not add the loop permission bypass — the panel is user-supervised.
|
|
@@ -8040,13 +8316,18 @@ async function ensureIsolatedServe(termId, { xdgRoot: requestedXdgRoot = null, k
|
|
|
8040
8316
|
for (let i = 0; i < 60 && !entry.ready; i++) await new Promise(r => setTimeout(r, 250));
|
|
8041
8317
|
return entry.ready ? entry : null;
|
|
8042
8318
|
}
|
|
8043
|
-
const port = allocOpencodeIsoPort();
|
|
8044
|
-
if (port == null) { console.warn('[ocp-iso] no free port for isolated serve'); return null; }
|
|
8045
8319
|
let xdgRoot;
|
|
8046
8320
|
try { xdgRoot = requestedXdgRoot || setupOpencodeSidepanelConfig(termId, requestedProfile); }
|
|
8047
8321
|
catch (err) { console.warn(`[ocp-iso] config setup failed: ${err.message}`); return null; }
|
|
8048
|
-
|
|
8322
|
+
// Registered before anything is waited for: a second start for the same term id waits for this one.
|
|
8323
|
+
entry = { termId, port: null, xdgRoot, mcpProfile: requestedProfile, mcpProfileRefreshFailed: false, proc: null, client: null, ready: false, starting: true, keepAlive: !!keepAlive, unsub: null, sessions: new Set(), lastUsed: Date.now() };
|
|
8049
8324
|
_ocpIsoServes.set(termId, entry);
|
|
8325
|
+
// What an earlier SynaBun left running goes first (its ports with it); then the port is picked.
|
|
8326
|
+
await sweepLeftoverOpencodeServes();
|
|
8327
|
+
if (_ocpIsoServes.get(termId) !== entry) return null;
|
|
8328
|
+
let port = allocOpencodeIsoPort();
|
|
8329
|
+
if (port == null) { console.warn('[ocp-iso] no free port for isolated serve'); endIsolatedStart(termId, entry); return null; }
|
|
8330
|
+
entry.port = port;
|
|
8050
8331
|
try {
|
|
8051
8332
|
const isolatedEnv = {
|
|
8052
8333
|
...process.env,
|
|
@@ -8065,33 +8346,85 @@ async function ensureIsolatedServe(termId, { xdgRoot: requestedXdgRoot = null, k
|
|
|
8065
8346
|
};
|
|
8066
8347
|
delete isolatedEnv.CLAUDECODE;
|
|
8067
8348
|
if (!brainCredential) delete isolatedEnv.SYNABUN_BRAIN_TOKEN;
|
|
8068
|
-
|
|
8069
|
-
|
|
8070
|
-
|
|
8071
|
-
...launch.options,
|
|
8072
|
-
detached: false, shell: false,
|
|
8073
|
-
});
|
|
8074
|
-
entry.proc = proc;
|
|
8349
|
+
// The OpenCode major decides how the serve starts (OpenCode 2: a password of
|
|
8350
|
+
// its own) and how it is spoken to; one SynaBun does not speak never starts.
|
|
8351
|
+
const detected = await detectOpencodeProtocol();
|
|
8075
8352
|
const tag = termId.slice(-8);
|
|
8076
|
-
|
|
8077
|
-
|
|
8078
|
-
|
|
8079
|
-
|
|
8080
|
-
|
|
8081
|
-
|
|
8353
|
+
if (detected.major != null && !detected.supported) { console.warn(`[ocp-iso] serve ${tag}: ${detected.reason}`); endIsolatedStart(termId, entry); return null; }
|
|
8354
|
+
// This start is wanted while its entry is the one under its term id: a stop cancels it, a scoped restart replaces it.
|
|
8355
|
+
const wanted = () => _ocpIsoServes.get(termId) === entry;
|
|
8356
|
+
let check = null;
|
|
8357
|
+
let started = 'cancelled';
|
|
8358
|
+
// Another server that takes the port while this serve boots is a start collision: it is left running (and never
|
|
8359
|
+
// put on record), this start's own process is ended, and the start tries the next free port (three at most).
|
|
8360
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
8361
|
+
started = await oneOpencodeStartAtATime(detected.protocol, async () => {
|
|
8362
|
+
// Its turn may come after it was cancelled or replaced: then nothing is started for it.
|
|
8363
|
+
if (!wanted()) return 'cancelled';
|
|
8364
|
+
// A token of this spawn in its environment: what tells its processes from any other (lib/opencode-serve-records.js).
|
|
8365
|
+
const token = opencodeServeRecords.newToken();
|
|
8366
|
+
const launch = opencodeServeLaunch(port, { env: { ...isolatedEnv, [SERVE_TOKEN_ENV]: token }, protocol: detected.protocol });
|
|
8367
|
+
const proc = spawn(launch.command, launch.args, {
|
|
8368
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
8369
|
+
...launch.options,
|
|
8370
|
+
detached: false, shell: false,
|
|
8371
|
+
});
|
|
8372
|
+
entry.proc = proc;
|
|
8373
|
+
entry.password = launch.password;
|
|
8374
|
+
// On record from its spawn: a crash of SynaBun from here on does not leave it running for good.
|
|
8375
|
+
const tracked = trackOpencodeServe(proc, { kind: 'isolated', id: termId, port, token });
|
|
8376
|
+
// While it boots, its start sees an exit itself (another server on the port is a collision); afterwards
|
|
8377
|
+
// its exit ends the serve.
|
|
8378
|
+
let booting = true;
|
|
8379
|
+
proc.stdout?.on('data', (d) => console.log(`[ocp-iso:${tag}:out] ${String(d).trim()}`));
|
|
8380
|
+
proc.stderr?.on('data', (d) => console.log(`[ocp-iso:${tag}:err] ${String(d).trim()}`));
|
|
8381
|
+
proc.on('exit', (code) => {
|
|
8382
|
+
console.log(`[ocp-iso] serve ${tag} exited code=${code}`);
|
|
8383
|
+
if (!booting && _ocpIsoServes.get(termId)?.proc === proc) stopIsolatedServe(termId);
|
|
8384
|
+
});
|
|
8082
8385
|
|
|
8083
|
-
|
|
8084
|
-
|
|
8085
|
-
|
|
8086
|
-
|
|
8087
|
-
|
|
8088
|
-
|
|
8089
|
-
|
|
8090
|
-
|
|
8386
|
+
// Healthy only when the right API answers JSON (an OpenCode 2 web page never is), and only from this start's
|
|
8387
|
+
// own process (the spawned one, or one it started).
|
|
8388
|
+
let outcome = 'unhealthy';
|
|
8389
|
+
for (let i = 0; i < 60; i++) {
|
|
8390
|
+
await new Promise(r => setTimeout(r, 250));
|
|
8391
|
+
if (!wanted()) { outcome = 'cancelled'; break; } // cancelled or replaced mid-boot
|
|
8392
|
+
check = await checkOpencodeServer({ port, protocol: detected.protocol, password: launch.password, timeoutMs: 3000 });
|
|
8393
|
+
if (check.ok) {
|
|
8394
|
+
if (await tracked.answering() !== 'foreign') { booting = false; return 'ready'; }
|
|
8395
|
+
outcome = 'collision';
|
|
8396
|
+
break;
|
|
8397
|
+
}
|
|
8398
|
+
if (proc.exitCode != null || proc.signalCode != null) { outcome = 'exited'; break; }
|
|
8399
|
+
if (['mismatch', 'html', 'locked'].includes(check.state) && i >= 3) break;
|
|
8400
|
+
}
|
|
8401
|
+
// Not started, no longer wanted, or another server's port: this start's own process is gone before the next
|
|
8402
|
+
// start may open the database (SIGTERM, then SIGKILL), and on record until its exit is seen. Its exit no
|
|
8403
|
+
// longer speaks for the entry.
|
|
8404
|
+
if (entry.proc === proc) entry.proc = null;
|
|
8405
|
+
killIsolatedServeProcess(proc);
|
|
8406
|
+
await endOpencodeChild(proc);
|
|
8407
|
+
return outcome;
|
|
8408
|
+
});
|
|
8409
|
+
if (started !== 'collision' || !wanted() || attempt === 3) break;
|
|
8410
|
+
const taken = port;
|
|
8411
|
+
port = allocOpencodeIsoPort();
|
|
8412
|
+
if (port == null) { console.warn(`[ocp-iso] serve ${tag}: another server answers on port ${taken}, and no other port is free`); break; }
|
|
8413
|
+
entry.port = port;
|
|
8414
|
+
console.warn(`[ocp-iso] serve ${tag}: another server answers on port ${taken}; it is left running, and this serve starts on port ${port}`);
|
|
8415
|
+
}
|
|
8416
|
+
if (started !== 'ready') {
|
|
8417
|
+
if (started === 'unhealthy') console.warn(`[ocp-iso] serve ${tag} not healthy within 15s${check && check.state !== 'down' ? `: ${opencodeProblemText({ check, port })}` : ''}`);
|
|
8418
|
+
if (started === 'exited') console.warn(`[ocp-iso] serve ${tag} exited before it answered`);
|
|
8419
|
+
if (started === 'collision') console.warn(`[ocp-iso] serve ${tag}: another server took every port it tried`);
|
|
8420
|
+
endIsolatedStart(termId, entry);
|
|
8421
|
+
return null;
|
|
8091
8422
|
}
|
|
8092
|
-
if (!
|
|
8423
|
+
if (!wanted()) { endIsolatedStart(termId, entry); return null; }
|
|
8424
|
+
entry.protocol = check.protocol;
|
|
8425
|
+
entry.version = check.version || null;
|
|
8093
8426
|
|
|
8094
|
-
const client = opencodeV2Client.createClientInstance({ port });
|
|
8427
|
+
const client = opencodeV2Client.createClientInstance({ port, protocol: entry.protocol, password: entry.password, version: entry.version || '' });
|
|
8095
8428
|
entry.client = client;
|
|
8096
8429
|
entry.unsub = client.onEvent((envelope) => {
|
|
8097
8430
|
if (!envelope) return;
|
|
@@ -8119,6 +8452,7 @@ async function ensureIsolatedServe(termId, { xdgRoot: requestedXdgRoot = null, k
|
|
|
8119
8452
|
});
|
|
8120
8453
|
client.start();
|
|
8121
8454
|
await client.waitUntilConnected?.(10_000);
|
|
8455
|
+
if (!wanted()) { endIsolatedStart(termId, entry); return null; }
|
|
8122
8456
|
entry.ready = true;
|
|
8123
8457
|
entry.starting = false;
|
|
8124
8458
|
entry.lastUsed = Date.now();
|
|
@@ -8126,7 +8460,7 @@ async function ensureIsolatedServe(termId, { xdgRoot: requestedXdgRoot = null, k
|
|
|
8126
8460
|
return entry;
|
|
8127
8461
|
} catch (err) {
|
|
8128
8462
|
console.warn(`[ocp-iso] spawn failed: ${err.message}`);
|
|
8129
|
-
|
|
8463
|
+
endIsolatedStart(termId, entry);
|
|
8130
8464
|
return null;
|
|
8131
8465
|
}
|
|
8132
8466
|
}
|
|
@@ -8145,19 +8479,7 @@ function stopIsolatedServe(termId, { preserveBindings = false } = {}) {
|
|
|
8145
8479
|
}
|
|
8146
8480
|
try { entry.unsub?.(); } catch {}
|
|
8147
8481
|
try { entry.client?.stop(); } catch {}
|
|
8148
|
-
|
|
8149
|
-
if (entry.proc && !entry.proc.killed) {
|
|
8150
|
-
if (IS_WIN && entry.proc.pid) {
|
|
8151
|
-
spawn('taskkill', ['/pid', String(entry.proc.pid), '/T', '/F'], {
|
|
8152
|
-
shell: true,
|
|
8153
|
-
windowsHide: true,
|
|
8154
|
-
stdio: 'ignore',
|
|
8155
|
-
});
|
|
8156
|
-
} else {
|
|
8157
|
-
entry.proc.kill('SIGTERM');
|
|
8158
|
-
}
|
|
8159
|
-
}
|
|
8160
|
-
} catch {}
|
|
8482
|
+
killIsolatedServeProcess(entry.proc);
|
|
8161
8483
|
try { if (entry.xdgRoot && existsSync(entry.xdgRoot)) rmSync(entry.xdgRoot, { recursive: true, force: true }); } catch {}
|
|
8162
8484
|
}
|
|
8163
8485
|
|
|
@@ -8380,7 +8702,7 @@ function handleOpencodeV2Ws(ws) {
|
|
|
8380
8702
|
}
|
|
8381
8703
|
if (type === 'identify') {
|
|
8382
8704
|
// Sent on every (re)connect: the panel gates features on this list.
|
|
8383
|
-
send({ type: 'capabilities', capabilities: opencodeV2WsCapabilities() });
|
|
8705
|
+
send({ type: 'capabilities', capabilities: opencodeV2WsCapabilities({ protocol: _ocpProtocol }) });
|
|
8384
8706
|
return;
|
|
8385
8707
|
}
|
|
8386
8708
|
// Canonical session id first (msg.sessionId is rewritten in place), then
|
|
@@ -8406,11 +8728,14 @@ function handleOpencodeV2Ws(ws) {
|
|
|
8406
8728
|
id,
|
|
8407
8729
|
ready,
|
|
8408
8730
|
version: _ocpVersion,
|
|
8731
|
+
// 1 or 2: the OpenCode API the shared serve speaks; `problem` why OpenCode cannot be used.
|
|
8732
|
+
protocol: _ocpProtocol,
|
|
8733
|
+
...(ready ? {} : { problem: _ocpProblem || null }),
|
|
8409
8734
|
sdkVersion: opencodeSdkVersion(),
|
|
8410
8735
|
port: _ocpPort,
|
|
8411
8736
|
managed: !!_ocpProc,
|
|
8412
8737
|
connected: opencodeV2Client.isConnected(),
|
|
8413
|
-
capabilities: opencodeV2WsCapabilities(),
|
|
8738
|
+
capabilities: opencodeV2WsCapabilities({ protocol: _ocpProtocol }),
|
|
8414
8739
|
});
|
|
8415
8740
|
break;
|
|
8416
8741
|
}
|
|
@@ -8421,11 +8746,14 @@ function handleOpencodeV2Ws(ws) {
|
|
|
8421
8746
|
// registered project. Reading the DB directly returns ALL sessions
|
|
8422
8747
|
// so the sidepanel resume dropdown and boot tab-restore can
|
|
8423
8748
|
// surface sessions outside SynaBun (every other registered project).
|
|
8749
|
+
// OpenCode 2 keeps them in other tables of the same file
|
|
8750
|
+
// (lib/opencode-db-schema.js); neither set of tables: the API.
|
|
8424
8751
|
const db = getOpencodeDb();
|
|
8425
|
-
|
|
8752
|
+
const flavor = db ? opencodeDbFlavor(db, _ocpProtocol) : null;
|
|
8753
|
+
if (db && flavor) {
|
|
8426
8754
|
try {
|
|
8427
8755
|
// msg.options: search / archived / roots / limit (see buildSessionListQuery).
|
|
8428
|
-
const query = buildSessionListQuery(msg.options || {});
|
|
8756
|
+
const query = buildSessionListQuery(msg.options || {}, { flavor });
|
|
8429
8757
|
const rows = db.prepare(query.sql).all(...query.params);
|
|
8430
8758
|
const sessions = rows.filter((row) => {
|
|
8431
8759
|
const run = activeNativeOpenCodeRunForSession(row.id);
|
|
@@ -8648,30 +8976,37 @@ function handleOpencodeV2Ws(ws) {
|
|
|
8648
8976
|
});
|
|
8649
8977
|
}
|
|
8650
8978
|
|
|
8979
|
+
// Healthy only when the API of the shared serve's major answers JSON
|
|
8980
|
+
// (lib/opencode-protocol.js checkOpencodeServer): OpenCode 2 answers every
|
|
8981
|
+
// OpenCode 1 path with its web app and status 200, which is never healthy.
|
|
8982
|
+
// `_ocpLastCheck` keeps the whole answer (what answered, and why it is not usable).
|
|
8983
|
+
let _ocpLastCheck = null;
|
|
8651
8984
|
async function checkOpencodeHealth() {
|
|
8652
|
-
|
|
8653
|
-
|
|
8654
|
-
|
|
8655
|
-
|
|
8656
|
-
|
|
8657
|
-
|
|
8658
|
-
|
|
8659
|
-
} catch {}
|
|
8660
|
-
return false;
|
|
8985
|
+
const check = await checkOpencodeServer({ port: _ocpPort, protocol: _ocpProtocol, password: _ocpPassword, timeoutMs: 3000 });
|
|
8986
|
+
_ocpLastCheck = check;
|
|
8987
|
+
if (check.ok) {
|
|
8988
|
+
_ocpVersion = check.version || null;
|
|
8989
|
+
if (check.protocol) _ocpProtocol = check.protocol;
|
|
8990
|
+
}
|
|
8991
|
+
return check.ok;
|
|
8661
8992
|
}
|
|
8662
8993
|
|
|
8994
|
+
// At the first start: a server already on the port that is SynaBun's own serve of an earlier run (its recorded
|
|
8995
|
+
// process, still that process) is taken over, and so is one SynaBun owns whose credentials are older than
|
|
8996
|
+
// auth.json. Automatic, so a server someone else runs is never stopped: it is used as it is.
|
|
8663
8997
|
async function reconcileOpencodeServerIfStale() {
|
|
8664
|
-
|
|
8665
|
-
const
|
|
8666
|
-
const
|
|
8667
|
-
|
|
8668
|
-
shouldReconcileOpencodeForStoredAuth()
|
|
8669
|
-
);
|
|
8670
|
-
if (!shouldReconcile) return false;
|
|
8998
|
+
if (_ocpBootReconcileTried) return false;
|
|
8999
|
+
const owned = verifiedOpencodeManagedProcess({ port: _ocpPort });
|
|
9000
|
+
const staleAuth = shouldReconcileOpencodeForStoredAuth();
|
|
9001
|
+
if (!owned && !staleAuth) return false;
|
|
8671
9002
|
_ocpBootReconcileTried = true;
|
|
9003
|
+
if (!owned) {
|
|
9004
|
+
console.warn(`[opencode] The OpenCode server on port ${_ocpPort} was not started by SynaBun: it is used as it is, not restarted (it may not have the newest provider credentials until whoever runs it restarts it)`);
|
|
9005
|
+
return false;
|
|
9006
|
+
}
|
|
8672
9007
|
_ocpReady = false;
|
|
8673
9008
|
_ocpStarting = false;
|
|
8674
|
-
console.log(`[opencode]
|
|
9009
|
+
console.log(`[opencode] SynaBun's own earlier server (pid ${owned.pid}) holds port ${_ocpPort}; taking it over`);
|
|
8675
9010
|
await takeoverOpencodeServer();
|
|
8676
9011
|
return true;
|
|
8677
9012
|
}
|
|
@@ -8703,6 +9038,65 @@ async function prewarmOpencodeMcpServers() {
|
|
|
8703
9038
|
}
|
|
8704
9039
|
}
|
|
8705
9040
|
|
|
9041
|
+
// Is something listening on 127.0.0.1:port? A TCP connect, so it answers on Windows too (no lsof there).
|
|
9042
|
+
function opencodePortAnswers(port, timeoutMs = 500) {
|
|
9043
|
+
return new Promise((resolveAnswer) => {
|
|
9044
|
+
const socket = netConnect({ host: '127.0.0.1', port });
|
|
9045
|
+
const done = (value) => { try { socket.destroy(); } catch {} resolveAnswer(value); };
|
|
9046
|
+
socket.setTimeout(timeoutMs, () => done(false));
|
|
9047
|
+
socket.once('connect', () => done(true));
|
|
9048
|
+
socket.once('error', () => done(false));
|
|
9049
|
+
});
|
|
9050
|
+
}
|
|
9051
|
+
|
|
9052
|
+
// Stop SynaBun's own recorded serve (verifiedOpencodeManagedProcess) on a port: SIGTERM, then SIGKILL while it
|
|
9053
|
+
// is still there, each checked against the record right before (signalOwnedOpencodeProcess). True once the
|
|
9054
|
+
// port is free.
|
|
9055
|
+
async function stopOwnedOpencodeServe(owned, port) {
|
|
9056
|
+
signalOwnedOpencodeProcess(owned, 'SIGTERM');
|
|
9057
|
+
for (let i = 0; i < 24 && listPortListenerPids(port).length; i++) await new Promise((r) => setTimeout(r, 250));
|
|
9058
|
+
if (listPortListenerPids(port).includes(owned.pid)) signalOwnedOpencodeProcess(owned, 'SIGKILL');
|
|
9059
|
+
for (let i = 0; i < 12 && listPortListenerPids(port).length; i++) await new Promise((r) => setTimeout(r, 250));
|
|
9060
|
+
// Its record goes once it has provably ended; one that still runs, or cannot be read, stays on record for the next look.
|
|
9061
|
+
clearOpencodeManagedPid(owned);
|
|
9062
|
+
return !listPortListenerPids(port).length && !(await opencodePortAnswers(port));
|
|
9063
|
+
}
|
|
9064
|
+
|
|
9065
|
+
// The shared serve moves to one of the next free ports, never into the isolated serves' range (4120–4400):
|
|
9066
|
+
// from 4096, 4097 to 4119. `why` is said with the move. False when no port could be had.
|
|
9067
|
+
async function moveSharedOpencodePort(why) {
|
|
9068
|
+
for (let port = _ocpPort + 1; port <= _ocpPort + 23; port++) {
|
|
9069
|
+
if ((port >= 4120 && port <= 4400) || port > 65535) continue;
|
|
9070
|
+
if (listPortListenerPids(port).length || await opencodePortAnswers(port)) continue;
|
|
9071
|
+
console.warn(`[opencode] ${why} The shared OpenCode serve uses port ${port} instead.`);
|
|
9072
|
+
_ocpPort = port;
|
|
9073
|
+
return true;
|
|
9074
|
+
}
|
|
9075
|
+
return false;
|
|
9076
|
+
}
|
|
9077
|
+
|
|
9078
|
+
// The shared serve's port is held by a server SynaBun cannot use: an OpenCode
|
|
9079
|
+
// 2 whose password SynaBun does not hold, a web page, something else. SynaBun's
|
|
9080
|
+
// own serve from an earlier run (its recorded process, while the OS still
|
|
9081
|
+
// reports that very process) is stopped and the port reused; anything else,
|
|
9082
|
+
// and a recorded pid that is another process by now, is left running, and the
|
|
9083
|
+
// shared serve moves to another port.
|
|
9084
|
+
async function freeSharedOpencodePortIfHeld(check) {
|
|
9085
|
+
const owned = verifiedOpencodeManagedProcess({ port: _ocpPort });
|
|
9086
|
+
if (owned) {
|
|
9087
|
+
console.log(`[opencode] SynaBun's earlier serve (pid ${owned.pid}) holds port ${_ocpPort} and cannot be used (${check?.error || check?.state}); stopping it`);
|
|
9088
|
+
if (await stopOwnedOpencodeServe(owned, _ocpPort)) return true;
|
|
9089
|
+
}
|
|
9090
|
+
return moveSharedOpencodePort(opencodeProblemText({ check, port: _ocpPort }));
|
|
9091
|
+
}
|
|
9092
|
+
|
|
9093
|
+
// Tell every OpenCode panel that OpenCode cannot be used, and why.
|
|
9094
|
+
function reportOpencodeProblem(problem) {
|
|
9095
|
+
_ocpProblem = problem || 'OpenCode could not be started.';
|
|
9096
|
+
console.warn(`[opencode] ${_ocpProblem}`);
|
|
9097
|
+
broadcastToOpencodeV2Clients({ type: 'server:status', status: 'error', error: _ocpProblem, version: _ocpVersion, protocol: _ocpProtocol, port: _ocpPort, managed: !!_ocpProc });
|
|
9098
|
+
}
|
|
9099
|
+
|
|
8706
9100
|
async function ensureOpencodeServer({ allowDuringHistoryClear = false } = {}) {
|
|
8707
9101
|
if (_ocpHistoryClearInProgress && !allowDuringHistoryClear) return false;
|
|
8708
9102
|
if (_ocpReady) return true;
|
|
@@ -8715,6 +9109,23 @@ async function ensureOpencodeServer({ allowDuringHistoryClear = false } = {}) {
|
|
|
8715
9109
|
}
|
|
8716
9110
|
_ocpStarting = true;
|
|
8717
9111
|
|
|
9112
|
+
// 0. Which OpenCode is installed: its major decides how the serve starts and
|
|
9113
|
+
// how SynaBun speaks to it. A major SynaBun does not know is refused here, with
|
|
9114
|
+
// a sentence the panel shows, instead of failing call by call later.
|
|
9115
|
+
const detected = await detectOpencodeProtocol();
|
|
9116
|
+
if (detected.major != null && !detected.supported) {
|
|
9117
|
+
_ocpStarting = false;
|
|
9118
|
+
reportOpencodeProblem(detected.reason);
|
|
9119
|
+
return false;
|
|
9120
|
+
}
|
|
9121
|
+
_ocpProblem = null;
|
|
9122
|
+
// A serve already running is adopted whatever is installed: an OpenCode 1
|
|
9123
|
+
// answers SynaBun as it is, an OpenCode 2 only to the password SynaBun gave it.
|
|
9124
|
+
if (!_ocpProc) _ocpProtocol = null;
|
|
9125
|
+
// A serve an earlier SynaBun left running (on this port or any other it used) goes before anything here is
|
|
9126
|
+
// adopted, and its port with it.
|
|
9127
|
+
await sweepLeftoverOpencodeServes();
|
|
9128
|
+
|
|
8718
9129
|
// 1. Check if already running externally
|
|
8719
9130
|
if (await checkOpencodeHealth()) {
|
|
8720
9131
|
if (await reconcileOpencodeServerIfStale()) return true;
|
|
@@ -8725,66 +9136,111 @@ async function ensureOpencodeServer({ allowDuringHistoryClear = false } = {}) {
|
|
|
8725
9136
|
// One event stream on this serve: the v2 client's. MCP prewarm runs on
|
|
8726
9137
|
// the v2 client too.
|
|
8727
9138
|
installOpencodeV2Listener();
|
|
8728
|
-
await opencodeV2Client.connect({ port: _ocpPort });
|
|
8729
|
-
broadcastToOpencodeV2Clients({ type: 'server:status', status: 'ready', version: _ocpVersion, port: _ocpPort, managed: false });
|
|
8730
|
-
console.log(`[opencode] External server detected on port ${_ocpPort} (v${_ocpVersion || '?'})`);
|
|
9139
|
+
await opencodeV2Client.connect({ port: _ocpPort, protocol: _ocpProtocol, password: _ocpPassword, version: _ocpVersion });
|
|
9140
|
+
broadcastToOpencodeV2Clients({ type: 'server:status', status: 'ready', version: _ocpVersion, protocol: _ocpProtocol, port: _ocpPort, managed: false });
|
|
9141
|
+
console.log(`[opencode] External server detected on port ${_ocpPort} (v${_ocpVersion || '?'}, OpenCode ${_ocpProtocol || '?'} API)`);
|
|
8731
9142
|
prewarmOpencodeMcpServers();
|
|
8732
9143
|
return true;
|
|
8733
9144
|
}
|
|
9145
|
+
if (_ocpLastCheck && _ocpLastCheck.state !== 'down' && !(await freeSharedOpencodePortIfHeld(_ocpLastCheck))) {
|
|
9146
|
+
_ocpStarting = false;
|
|
9147
|
+
reportOpencodeProblem(opencodeProblemText({ check: _ocpLastCheck, port: _ocpPort }));
|
|
9148
|
+
return false;
|
|
9149
|
+
}
|
|
8734
9150
|
|
|
8735
|
-
// 2. Spawn opencode serve
|
|
8736
|
-
|
|
8737
|
-
|
|
8738
|
-
|
|
8739
|
-
|
|
8740
|
-
|
|
8741
|
-
|
|
8742
|
-
|
|
8743
|
-
|
|
8744
|
-
|
|
8745
|
-
|
|
8746
|
-
|
|
8747
|
-
|
|
8748
|
-
|
|
8749
|
-
|
|
8750
|
-
|
|
8751
|
-
|
|
8752
|
-
|
|
8753
|
-
|
|
9151
|
+
// 2. Spawn opencode serve, and 3. poll until ready (max 15s): one start at a time with every other serve
|
|
9152
|
+
// SynaBun starts (oneOpencodeStartAtATime), its turn held until the serve answers or a failed start's process
|
|
9153
|
+
// is gone. null for a command line in Settings: what answers says which major it is.
|
|
9154
|
+
// Another server that takes the port while SynaBun's serve starts is a start collision: it is left running (and
|
|
9155
|
+
// never put on record), SynaBun's own process is ended, and the shared serve tries the next free port (three at most).
|
|
9156
|
+
let outcome = null;
|
|
9157
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
9158
|
+
_ocpProtocol = detected.protocol;
|
|
9159
|
+
outcome = await oneOpencodeStartAtATime(detected.protocol, async () => {
|
|
9160
|
+
let proc = null;
|
|
9161
|
+
let tracked = null;
|
|
9162
|
+
try {
|
|
9163
|
+
// A token of this spawn in its environment: what tells its processes from any other (lib/opencode-serve-records.js).
|
|
9164
|
+
const token = opencodeServeRecords.newToken();
|
|
9165
|
+
const launch = opencodeServeLaunch(_ocpPort, { env: { ...process.env, FORCE_COLOR: '0', OPENCODE_DISABLE_CLAUDE_CODE: '1', [SERVE_TOKEN_ENV]: token }, protocol: detected.protocol });
|
|
9166
|
+
_ocpPassword = launch.password;
|
|
9167
|
+
console.log(`[opencode] Spawning: ${launch.label}`);
|
|
9168
|
+
proc = spawn(launch.command, launch.args, {
|
|
9169
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
9170
|
+
...launch.options,
|
|
9171
|
+
detached: false,
|
|
9172
|
+
shell: false,
|
|
9173
|
+
});
|
|
9174
|
+
_ocpProc = proc;
|
|
9175
|
+
// On record from its spawn: a crash of SynaBun from here on does not leave it running for good.
|
|
9176
|
+
tracked = trackOpencodeServe(proc, { kind: 'shared', id: 'shared', port: _ocpPort, token });
|
|
9177
|
+
proc.stdout?.on('data', (d) => console.log(`[opencode:stdout] ${d.toString().trim()}`));
|
|
9178
|
+
proc.stderr?.on('data', (d) => console.log(`[opencode:stderr] ${d.toString().trim()}`));
|
|
9179
|
+
proc.on('exit', (code) => {
|
|
9180
|
+
console.log(`[opencode] Process exited with code ${code}`);
|
|
9181
|
+
// A serve stopped or replaced meanwhile says nothing about the one running now. (Its record goes with
|
|
9182
|
+
// its process either way: trackOpencodeServe.)
|
|
9183
|
+
if (_ocpProc !== proc) return;
|
|
9184
|
+
_ocpProc = null;
|
|
9185
|
+
_ocpReady = false;
|
|
9186
|
+
opencodeV2Client.disconnect().catch(() => {});
|
|
9187
|
+
broadcastToOpencodeV2Clients({ type: 'server:status', status: 'offline', port: _ocpPort });
|
|
9188
|
+
});
|
|
9189
|
+
} catch (err) {
|
|
9190
|
+
console.error(`[opencode] Failed to spawn: ${err.message}`);
|
|
9191
|
+
return 'not-spawned';
|
|
9192
|
+
}
|
|
9193
|
+
for (let i = 0; i < 30; i++) {
|
|
9194
|
+
await new Promise(r => setTimeout(r, 500));
|
|
9195
|
+
if (await checkOpencodeHealth()) {
|
|
9196
|
+
// Only SynaBun's own process counts (the spawned one, or one it started): another server that took the
|
|
9197
|
+
// port meanwhile answers for itself.
|
|
9198
|
+
if (await tracked.answering() !== 'foreign') return 'ready';
|
|
9199
|
+
if (_ocpProc === proc) _ocpProc = null;
|
|
9200
|
+
killIsolatedServeProcess(proc);
|
|
9201
|
+
await endOpencodeChild(proc);
|
|
9202
|
+
return 'collision';
|
|
9203
|
+
}
|
|
9204
|
+
// Something answers, but not the API SynaBun expects (another major, a web page): no use waiting.
|
|
9205
|
+
if (_ocpLastCheck && ['mismatch', 'html', 'locked'].includes(_ocpLastCheck.state) && i >= 3) break;
|
|
9206
|
+
}
|
|
9207
|
+
stopOpencodeServer();
|
|
9208
|
+
await endOpencodeChild(proc);
|
|
9209
|
+
return 'unhealthy';
|
|
8754
9210
|
});
|
|
8755
|
-
|
|
8756
|
-
|
|
9211
|
+
if (outcome !== 'collision' || attempt === 3) break;
|
|
9212
|
+
if (!(await moveSharedOpencodePort(`Another server took port ${_ocpPort} while SynaBun's OpenCode serve was starting: it is left running.`))) break;
|
|
9213
|
+
}
|
|
9214
|
+
if (outcome === 'not-spawned') {
|
|
8757
9215
|
_ocpStarting = false;
|
|
8758
9216
|
return false;
|
|
8759
9217
|
}
|
|
8760
|
-
|
|
8761
|
-
|
|
8762
|
-
|
|
8763
|
-
|
|
8764
|
-
|
|
8765
|
-
|
|
8766
|
-
|
|
8767
|
-
|
|
8768
|
-
|
|
8769
|
-
|
|
8770
|
-
|
|
8771
|
-
await opencodeV2Client.connect({ port: _ocpPort });
|
|
8772
|
-
broadcastToOpencodeV2Clients({ type: 'server:status', status: 'ready', version: _ocpVersion, port: _ocpPort, managed: true });
|
|
8773
|
-
console.log(`[opencode] Server ready on port ${_ocpPort} (v${_ocpVersion || '?'})`);
|
|
8774
|
-
prewarmOpencodeMcpServers();
|
|
8775
|
-
return true;
|
|
8776
|
-
}
|
|
9218
|
+
if (outcome === 'ready') {
|
|
9219
|
+
_ocpReady = true;
|
|
9220
|
+
_ocpStarting = false;
|
|
9221
|
+
_ocpBootReconcileTried = true;
|
|
9222
|
+
_ocpLastBootAt = Date.now();
|
|
9223
|
+
installOpencodeV2Listener();
|
|
9224
|
+
await opencodeV2Client.connect({ port: _ocpPort, protocol: _ocpProtocol, password: _ocpPassword, version: _ocpVersion });
|
|
9225
|
+
broadcastToOpencodeV2Clients({ type: 'server:status', status: 'ready', version: _ocpVersion, protocol: _ocpProtocol, port: _ocpPort, managed: true });
|
|
9226
|
+
console.log(`[opencode] Server ready on port ${_ocpPort} (v${_ocpVersion || '?'}, OpenCode ${_ocpProtocol || '?'} API)`);
|
|
9227
|
+
prewarmOpencodeMcpServers();
|
|
9228
|
+
return true;
|
|
8777
9229
|
}
|
|
8778
9230
|
|
|
8779
|
-
|
|
8780
|
-
|
|
9231
|
+
const why = outcome === 'collision'
|
|
9232
|
+
? `Another server took port ${_ocpPort} while SynaBun's OpenCode serve was starting, on every port it tried; it is left running.`
|
|
9233
|
+
: _ocpLastCheck && _ocpLastCheck.state !== 'down'
|
|
9234
|
+
? opencodeProblemText({ check: _ocpLastCheck, port: _ocpPort })
|
|
9235
|
+
: 'OpenCode did not start within 15 seconds.';
|
|
9236
|
+
console.error(`[opencode] Server did not become healthy: ${why}`);
|
|
8781
9237
|
_ocpStarting = false;
|
|
9238
|
+
reportOpencodeProblem(why);
|
|
8782
9239
|
return false;
|
|
8783
9240
|
}
|
|
8784
9241
|
|
|
8785
9242
|
function stopOpencodeServer({ stopManagedOllama = true } = {}) {
|
|
8786
9243
|
opencodeV2Client.disconnect().catch(() => {});
|
|
8787
|
-
const managedPid = readOpencodeManagedPid();
|
|
8788
9244
|
const procPid = _ocpProc?.pid || null;
|
|
8789
9245
|
const isWin = process.platform === 'win32';
|
|
8790
9246
|
|
|
@@ -8810,14 +9266,16 @@ function stopOpencodeServer({ stopManagedOllama = true } = {}) {
|
|
|
8810
9266
|
}
|
|
8811
9267
|
_ocpProc = null;
|
|
8812
9268
|
}
|
|
8813
|
-
|
|
8814
|
-
|
|
8815
|
-
|
|
8816
|
-
|
|
8817
|
-
|
|
8818
|
-
|
|
8819
|
-
|
|
8820
|
-
|
|
9269
|
+
// SynaBun's recorded serves (a process its child started that answers on the port; one of an earlier run whose
|
|
9270
|
+
// SynaBun has ended), only while the OS still reports each as that very process: a pid another program has by now,
|
|
9271
|
+
// or one that cannot be read, is never signalled (signalOwnedOpencodeProcess checks once more right before;
|
|
9272
|
+
// taskkill on Windows).
|
|
9273
|
+
const owned = verifiedOpencodeManagedProcesses();
|
|
9274
|
+
for (const record of owned) if (record.pid !== procPid) signalOwnedOpencodeProcess(record, 'SIGTERM');
|
|
9275
|
+
// A record whose process has provably ended goes. One whose serve was just signalled stays until that serve has
|
|
9276
|
+
// ended (its exit removes it, or the next look for leftovers does): a crash meanwhile does not leave it running
|
|
9277
|
+
// unrecorded.
|
|
9278
|
+
if (!owned.length) clearOpencodeManagedPid();
|
|
8821
9279
|
_ocpReady = false;
|
|
8822
9280
|
_ocpStarting = false;
|
|
8823
9281
|
broadcastToOpencodeV2Clients({ type: 'server:status', status: 'offline', port: _ocpPort });
|
|
@@ -8827,7 +9285,13 @@ function stopOpencodeServer({ stopManagedOllama = true } = {}) {
|
|
|
8827
9285
|
}
|
|
8828
9286
|
}
|
|
8829
9287
|
|
|
8830
|
-
// Takeover:
|
|
9288
|
+
// Takeover: a fresh shared serve that boots from the updated auth/config files. A serve is stopped only when it
|
|
9289
|
+
// is SynaBun's own: its child (a plain restart), or its recorded serve of an earlier run while the OS still
|
|
9290
|
+
// reports that very process, checked again before each signal. No other process is ever signalled: not every
|
|
9291
|
+
// `opencode … serve` of the machine (the user's own, OpenCode 2's background service, SynaBun's isolated serves),
|
|
9292
|
+
// not a recorded pid another program has by now. A server SynaBun did not start is left running, and SynaBun
|
|
9293
|
+
// starts its own on another port. The automatic callers (reconcileOpencodeServerIfStale, reconcileAuthIfStale)
|
|
9294
|
+
// call this only for a serve that is SynaBun's.
|
|
8831
9295
|
async function takeoverOpencodeServer() {
|
|
8832
9296
|
if (_ocpHistoryClearInProgress) throw new Error('OpenCode history is being cleared');
|
|
8833
9297
|
if (_ocpProc) {
|
|
@@ -8836,32 +9300,23 @@ async function takeoverOpencodeServer() {
|
|
|
8836
9300
|
await new Promise(r => setTimeout(r, 1000));
|
|
8837
9301
|
return ensureOpencodeServer();
|
|
8838
9302
|
}
|
|
8839
|
-
// External server — terminate the actual port listener so we can take over with
|
|
8840
|
-
// a managed process that will boot from the updated auth/config files.
|
|
8841
9303
|
_ocpReady = false;
|
|
8842
9304
|
_ocpStarting = false;
|
|
9305
|
+
const owned = verifiedOpencodeManagedProcess({ port: _ocpPort });
|
|
9306
|
+
if (!owned) {
|
|
9307
|
+
await opencodeV2Client.disconnect().catch(() => {});
|
|
9308
|
+
if (!(await moveSharedOpencodePort(`The OpenCode server on port ${_ocpPort} was not started by SynaBun: it is left running.`))) {
|
|
9309
|
+
throw new Error(`The OpenCode server on port ${_ocpPort} was not started by SynaBun and is left running; no other port is free for SynaBun's own`);
|
|
9310
|
+
}
|
|
9311
|
+
return ensureOpencodeServer();
|
|
9312
|
+
}
|
|
8843
9313
|
try {
|
|
8844
9314
|
await ocpProxy('POST', '/global/dispose');
|
|
8845
9315
|
} catch {}
|
|
8846
|
-
|
|
8847
|
-
|
|
8848
|
-
|
|
8849
|
-
|
|
8850
|
-
const terminated = signalPids(candidatePids, 'SIGTERM');
|
|
8851
|
-
console.log(`[opencode] Sent SIGTERM to external server PIDs: ${terminated.join(', ') || 'none'}`);
|
|
8852
|
-
} else {
|
|
8853
|
-
console.log(`[opencode] No external listener PIDs found on port ${_ocpPort}; waiting for shutdown`);
|
|
8854
|
-
}
|
|
8855
|
-
if (!(await waitForOpencodeShutdown())) {
|
|
8856
|
-
const stubbornPids = [...new Set([...listPortListenerPids(_ocpPort), ...listOpencodeServePids()])];
|
|
8857
|
-
if (stubbornPids.length) {
|
|
8858
|
-
const killed = signalPids(stubbornPids, 'SIGKILL');
|
|
8859
|
-
console.log(`[opencode] Sent SIGKILL to stubborn external PIDs: ${killed.join(', ') || 'none'}`);
|
|
8860
|
-
}
|
|
8861
|
-
await waitForOpencodeShutdown(4000);
|
|
8862
|
-
}
|
|
8863
|
-
if (await checkOpencodeHealth()) {
|
|
8864
|
-
throw new Error(`External OpenCode server on port ${_ocpPort} survived takeover`);
|
|
9316
|
+
await opencodeV2Client.disconnect().catch(() => {});
|
|
9317
|
+
console.log(`[opencode] Stopping SynaBun's earlier server (pid ${owned.pid}) on port ${_ocpPort}`);
|
|
9318
|
+
if (!(await stopOwnedOpencodeServe(owned, _ocpPort)) && await checkOpencodeHealth()) {
|
|
9319
|
+
throw new Error(`SynaBun's OpenCode server on port ${_ocpPort} survived takeover`);
|
|
8865
9320
|
}
|
|
8866
9321
|
await new Promise(r => setTimeout(r, 500));
|
|
8867
9322
|
return ensureOpencodeServer();
|
|
@@ -8897,7 +9352,43 @@ function getOpencodeDb() {
|
|
|
8897
9352
|
|
|
8898
9353
|
// ── Proxy helper: forward REST calls to opencode serve ──
|
|
8899
9354
|
|
|
9355
|
+
// The OpenCode 2 serve behind a base URL (the shared one or an isolated one):
|
|
9356
|
+
// `{ client, password }`, or null for an OpenCode 1 serve.
|
|
9357
|
+
function opencode2ServeFor(baseUrl) {
|
|
9358
|
+
let port = null;
|
|
9359
|
+
try { port = Number(new URL(baseUrl).port) || null; } catch { return null; }
|
|
9360
|
+
if (port === _ocpPort && _ocpProtocol === 2) {
|
|
9361
|
+
const client = opencodeV2Client.getClient();
|
|
9362
|
+
return client ? { client, password: _ocpPassword } : null;
|
|
9363
|
+
}
|
|
9364
|
+
for (const entry of _ocpIsoServes.values()) {
|
|
9365
|
+
if (entry.port === port && entry.protocol === 2 && entry.client) return { client: entry.client.getClient(), password: entry.password };
|
|
9366
|
+
}
|
|
9367
|
+
return null;
|
|
9368
|
+
}
|
|
9369
|
+
|
|
8900
9370
|
async function ocpProxy(method, path, body = null, timeoutMs = 30000, baseUrl = _ocpBaseUrl()) {
|
|
9371
|
+
// OpenCode 2 answers these OpenCode 1 paths with its web app: each is
|
|
9372
|
+
// translated onto its API instead (lib/opencode2-proxy.js), /api/* passes
|
|
9373
|
+
// through with the serve's password.
|
|
9374
|
+
const v2 = opencode2ServeFor(baseUrl);
|
|
9375
|
+
if (v2?.client) {
|
|
9376
|
+
ocpTrace('proxy:req', { tid: _ocpTraceId(), method, path, opencode: 2, timeoutMs });
|
|
9377
|
+
return opencode2Proxy(v2.client, method, path, body, {
|
|
9378
|
+
rawFetch: async (apiPath, init = {}) => {
|
|
9379
|
+
const resp = await fetch(`${baseUrl}${apiPath}`, {
|
|
9380
|
+
method: init.method || method,
|
|
9381
|
+
headers: { 'Content-Type': 'application/json', authorization: basicAuthHeader(v2.password || '') },
|
|
9382
|
+
...(init.body != null && (init.method || method) !== 'GET' ? { body: JSON.stringify(init.body) } : {}),
|
|
9383
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
9384
|
+
});
|
|
9385
|
+
const text = await resp.text();
|
|
9386
|
+
let data;
|
|
9387
|
+
try { data = JSON.parse(text); } catch { data = text; }
|
|
9388
|
+
return { status: resp.status, data };
|
|
9389
|
+
},
|
|
9390
|
+
});
|
|
9391
|
+
}
|
|
8901
9392
|
const url = `${baseUrl}${path}`;
|
|
8902
9393
|
const opts = {
|
|
8903
9394
|
method,
|
|
@@ -9188,40 +9679,29 @@ async function opencodePortIsAlive(port) {
|
|
|
9188
9679
|
}
|
|
9189
9680
|
}
|
|
9190
9681
|
|
|
9191
|
-
function
|
|
9192
|
-
if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid) return false;
|
|
9193
|
-
try {
|
|
9194
|
-
process.kill(pid, 0);
|
|
9195
|
-
return true;
|
|
9196
|
-
} catch {
|
|
9197
|
-
return false;
|
|
9198
|
-
}
|
|
9199
|
-
}
|
|
9200
|
-
|
|
9201
|
-
async function waitForOpencodeProcessesToStop(ports, pids, timeoutMs = 8000) {
|
|
9682
|
+
async function waitForOpencodeProcessesToStop(ports, stopped, timeoutMs = 8000) {
|
|
9202
9683
|
const uniquePorts = [...new Set(ports.filter((port) => Number.isFinite(port) && port > 0))];
|
|
9203
|
-
const uniquePids = [...new Set(pids.filter((pid) => Number.isFinite(pid) && pid > 0 && pid !== process.pid))];
|
|
9204
9684
|
const deadline = Date.now() + timeoutMs;
|
|
9205
9685
|
while (Date.now() < deadline) {
|
|
9206
9686
|
const alive = await Promise.all(uniquePorts.map(opencodePortIsAlive));
|
|
9207
|
-
if (!alive.some(Boolean) &&
|
|
9687
|
+
if (!alive.some(Boolean) && stopped()) return true;
|
|
9208
9688
|
await new Promise((resolveWait) => setTimeout(resolveWait, 250));
|
|
9209
9689
|
}
|
|
9210
9690
|
return false;
|
|
9211
9691
|
}
|
|
9212
9692
|
|
|
9693
|
+
// Only what SynaBun started is stopped for a history clear: its children, by their handles (which never signal a
|
|
9694
|
+
// process that has exited), and its recorded serve while the OS still reports that very process. A server someone
|
|
9695
|
+
// else runs on one of these ports is left running, and the clear then says so instead of killing it.
|
|
9213
9696
|
async function stopOpenCodeForHistoryClear() {
|
|
9214
|
-
const isolated = [..._ocpIsoServes.values()].map((entry) => ({
|
|
9215
|
-
port: entry.port,
|
|
9216
|
-
pid: entry.proc?.pid || null,
|
|
9217
|
-
}));
|
|
9697
|
+
const isolated = [..._ocpIsoServes.values()].map((entry) => ({ port: entry.port, proc: entry.proc || null }));
|
|
9218
9698
|
const ports = [_ocpPort, ...isolated.map((entry) => entry.port)];
|
|
9219
|
-
const
|
|
9220
|
-
|
|
9221
|
-
|
|
9222
|
-
|
|
9223
|
-
|
|
9224
|
-
|
|
9699
|
+
const children = [_ocpProc, ...isolated.map((entry) => entry.proc)].filter(Boolean);
|
|
9700
|
+
const owned = verifiedOpencodeManagedProcess();
|
|
9701
|
+
const gone = (child) => child.exitCode != null || child.signalCode != null;
|
|
9702
|
+
// Still running until it has provably ended: one that cannot be read now counts as running.
|
|
9703
|
+
const ownedRuns = () => !!owned && opencodeServeRecords.verifySync(owned) !== 'gone';
|
|
9704
|
+
const stopped = () => children.every(gone) && !ownedRuns();
|
|
9225
9705
|
|
|
9226
9706
|
await Promise.allSettled(isolated.map(({ port }) => fetch(
|
|
9227
9707
|
`http://127.0.0.1:${port}/global/dispose`,
|
|
@@ -9231,21 +9711,23 @@ async function stopOpenCodeForHistoryClear() {
|
|
|
9231
9711
|
stopAllIsolatedServes();
|
|
9232
9712
|
stopOpencodeServer({ stopManagedOllama: false });
|
|
9233
9713
|
|
|
9234
|
-
if (await waitForOpencodeProcessesToStop(ports,
|
|
9235
|
-
if (process.platform !== 'win32'
|
|
9236
|
-
|
|
9237
|
-
|
|
9714
|
+
if (await waitForOpencodeProcessesToStop(ports, stopped)) return;
|
|
9715
|
+
if (process.platform !== 'win32') {
|
|
9716
|
+
for (const child of children) if (!gone(child)) { try { child.kill('SIGKILL'); } catch {} }
|
|
9717
|
+
if (owned) signalOwnedOpencodeProcess(owned, 'SIGKILL');
|
|
9718
|
+
await waitForOpencodeProcessesToStop(ports, stopped, 4000);
|
|
9238
9719
|
}
|
|
9239
9720
|
const stillAlive = (await Promise.all(ports.map(async (port) => (
|
|
9240
9721
|
(await opencodePortIsAlive(port)) ? port : null
|
|
9241
9722
|
)))).filter(Boolean);
|
|
9242
|
-
const stillRunningPids = [...
|
|
9723
|
+
const stillRunningPids = [...children.filter((child) => !gone(child)).map((child) => child.pid), ...(ownedRuns() ? [owned.pid] : [])];
|
|
9243
9724
|
if (stillAlive.length || stillRunningPids.length) {
|
|
9244
9725
|
const details = [
|
|
9245
9726
|
...stillAlive.map((port) => `port ${port}`),
|
|
9246
9727
|
...stillRunningPids.map((pid) => `process ${pid}`),
|
|
9247
9728
|
];
|
|
9248
|
-
|
|
9729
|
+
const someoneElse = stillAlive.length && !stillRunningPids.length ? ' (an OpenCode server SynaBun did not start answers there: stop it, then clear again)' : '';
|
|
9730
|
+
throw new Error(`OpenCode did not release ${details.join(', ')}${someoneElse}`);
|
|
9249
9731
|
}
|
|
9250
9732
|
}
|
|
9251
9733
|
|
|
@@ -9288,6 +9770,12 @@ app.delete('/api/opencode/history', async (req, res) => {
|
|
|
9288
9770
|
blockers,
|
|
9289
9771
|
});
|
|
9290
9772
|
}
|
|
9773
|
+
// OpenCode 2's database is not cleared here (lib/opencode-history.js): refused before anything is stopped.
|
|
9774
|
+
let historyStats = null;
|
|
9775
|
+
try { historyStats = getOpenCodeHistoryStats({ dbPath: getOpencodeDbPath(), dataRoot: dirname(getOpencodeDbPath()) }); } catch { /* the clear itself says */ }
|
|
9776
|
+
if (historyStats?.opencode2 || _ocpProtocol === 2) {
|
|
9777
|
+
return res.status(409).json({ ok: false, opencode2: true, error: historyStats?.reason || OPENCODE2_HISTORY_REFUSAL });
|
|
9778
|
+
}
|
|
9291
9779
|
|
|
9292
9780
|
wasRunning = await checkOpencodeHealth();
|
|
9293
9781
|
await stopOpenCodeForHistoryClear();
|
|
@@ -9363,7 +9851,7 @@ app.get('/api/opencode/status', async (req, res) => {
|
|
|
9363
9851
|
} else if (!running && _ocpReady) {
|
|
9364
9852
|
_ocpReady = false;
|
|
9365
9853
|
}
|
|
9366
|
-
res.json({ ok: true, running, port: _ocpPort, version: _ocpVersion, managed: !!_ocpProc });
|
|
9854
|
+
res.json({ ok: true, running, port: _ocpPort, version: _ocpVersion, protocol: _ocpProtocol, managed: !!_ocpProc, ...(running ? {} : { problem: _ocpProblem || (_ocpLastCheck && _ocpLastCheck.state !== 'down' ? opencodeProblemText({ check: _ocpLastCheck, port: _ocpPort }) : null) }) });
|
|
9367
9855
|
} catch (err) {
|
|
9368
9856
|
res.status(500).json({ ok: false, error: err.message });
|
|
9369
9857
|
}
|
|
@@ -9420,14 +9908,108 @@ app.post('/api/opencode/snapshot', (req, res) => {
|
|
|
9420
9908
|
}
|
|
9421
9909
|
});
|
|
9422
9910
|
|
|
9911
|
+
// ── OpenCode's provider list, kept (lib/launch-cache.js) ──
|
|
9912
|
+
// The shared serve's /provider is the whole models.dev catalog (about 7 MB, 231 providers on a typical
|
|
9913
|
+
// install) and took 1.3-1.5 s on every call. It is read once and kept: answered at once, read again in the
|
|
9914
|
+
// background when it is old, when the serve restarts and when a catalog event says it changed. Keyed on what
|
|
9915
|
+
// it depends on: the OpenCode major, the binary (an update), its credentials file and its config files (a
|
|
9916
|
+
// provider connected or removed rewrites auth.json: another key, so the next read waits for the new list).
|
|
9917
|
+
// Only a provider list is kept: a web page (an OpenCode 2 serve answering an OpenCode 1 path) or an error
|
|
9918
|
+
// never is. ?view=compact (the connected providers, lib/opencode-provider-view.js) is what the pickers read,
|
|
9919
|
+
// and the only part kept across restarts: the whole catalog is megabytes.
|
|
9920
|
+
const OPENCODE_PROVIDERS_TTL_MS = 5 * 60_000;
|
|
9921
|
+
|
|
9922
|
+
function opencodeFileStamp(file) {
|
|
9923
|
+
try { const stat = statSync(file); return `${Math.round(stat.mtimeMs)}:${stat.size}`; } catch { return '-'; }
|
|
9924
|
+
}
|
|
9925
|
+
|
|
9926
|
+
function opencodeProvidersKey() {
|
|
9927
|
+
const configDir = dirname(getOpencodeConfigPath());
|
|
9928
|
+
return [
|
|
9929
|
+
knownOpencodeMajor(),
|
|
9930
|
+
configuredCommandLine('opencode') || binaryIdentity(cliResolver.launcher('opencode').path || ''),
|
|
9931
|
+
opencodeFileStamp(getOpencodeAuthPath()),
|
|
9932
|
+
opencodeFileStamp(getOpencodeConfigPath()),
|
|
9933
|
+
opencodeFileStamp(join(configDir, 'opencode.json')),
|
|
9934
|
+
opencodeFileStamp(join(configDir, 'opencode.jsonc')),
|
|
9935
|
+
].join('|');
|
|
9936
|
+
}
|
|
9937
|
+
|
|
9938
|
+
const _opencodeProviders = createLaunchCache({
|
|
9939
|
+
name: 'opencode-providers',
|
|
9940
|
+
dir: LAUNCH_CACHE_DIR,
|
|
9941
|
+
ttlMs: OPENCODE_PROVIDERS_TTL_MS,
|
|
9942
|
+
maxEntries: 2,
|
|
9943
|
+
load: async () => {
|
|
9944
|
+
if (!_ocpReady) throw new Error('OpenCode server not running');
|
|
9945
|
+
await reconcileAuthIfStale();
|
|
9946
|
+
const r = await ocpProxy('GET', '/provider');
|
|
9947
|
+
if (r.status !== 200 || !isProviderCatalog(r.data)) {
|
|
9948
|
+
throw Object.assign(new Error(r.status === 200 ? 'OpenCode did not answer with a provider list' : `OpenCode answered ${r.status}`), { status: r.status === 200 ? 502 : r.status, data: r.data });
|
|
9949
|
+
}
|
|
9950
|
+
return { full: r.data, compact: compactProviderCatalog(r.data), signature: providerCatalogSignature(r.data) };
|
|
9951
|
+
},
|
|
9952
|
+
accept: (value) => !!value && (isProviderCatalog(value.full) || isProviderCatalog(value.compact)),
|
|
9953
|
+
equals: (a, b) => !!a?.signature && a.signature === b?.signature && !!a.full === !!b.full,
|
|
9954
|
+
persistValue: (value) => (value?.compact ? { compact: value.compact, signature: value.signature } : undefined),
|
|
9955
|
+
// A refresh found another list than the kept one: every window's pickers read it again.
|
|
9956
|
+
onChange: (_key, value, previous) => { if (value?.signature !== previous?.signature) announceOpencodeProvidersChanged(); },
|
|
9957
|
+
log: (message) => console.warn(message),
|
|
9958
|
+
});
|
|
9959
|
+
|
|
9960
|
+
// The serialized answer of each view, made once per list (the whole catalog is megabytes of JSON), with an
|
|
9961
|
+
// ETag of its own so Express does not hash megabytes for every answer.
|
|
9962
|
+
const _opencodeProviderTexts = new WeakMap();
|
|
9963
|
+
function sendOpencodeProviders(res, value, view) {
|
|
9964
|
+
let texts = _opencodeProviderTexts.get(value);
|
|
9965
|
+
if (!texts) { texts = {}; _opencodeProviderTexts.set(value, texts); }
|
|
9966
|
+
if (!texts[view]) {
|
|
9967
|
+
const text = `{"ok":true,"data":${JSON.stringify(view === 'compact' ? value.compact : value.full)}}`;
|
|
9968
|
+
texts[view] = { text, etag: `W/"ocp-${view}-${createHash('sha1').update(text).digest('hex').slice(0, 20)}"` };
|
|
9969
|
+
}
|
|
9970
|
+
res.set('ETag', texts[view].etag);
|
|
9971
|
+
res.type('json').send(texts[view].text);
|
|
9972
|
+
}
|
|
9973
|
+
|
|
9974
|
+
/**
|
|
9975
|
+
* The provider list for a view ('full' | 'compact'): the kept one at once, else the serve's (one read for
|
|
9976
|
+
* every caller). `refresh` waits for a new read. The whole catalog is never kept across a restart, so a
|
|
9977
|
+
* restored entry answers 'compact' only and 'full' reads the serve.
|
|
9978
|
+
*/
|
|
9979
|
+
async function readOpencodeProviders(view = 'full', { refresh = false } = {}) {
|
|
9980
|
+
const key = opencodeProvidersKey();
|
|
9981
|
+
let { value } = await _opencodeProviders.read(key, { refresh });
|
|
9982
|
+
if (view === 'full' && !value?.full) ({ value } = await _opencodeProviders.read(key, { refresh: true }));
|
|
9983
|
+
if (!value || !(view === 'full' ? value.full : value.compact)) throw new Error('OpenCode did not answer with a provider list');
|
|
9984
|
+
return value;
|
|
9985
|
+
}
|
|
9986
|
+
|
|
9987
|
+
// Every window learns the list changed (an OpenCode panel's own socket says so only where one is open).
|
|
9988
|
+
function announceOpencodeProvidersChanged() {
|
|
9989
|
+
try { broadcastSync({ type: 'opencode:providers-changed' }); } catch {}
|
|
9990
|
+
_announcingOpencodeProviders = true;
|
|
9991
|
+
try { broadcastToOpencodeV2Clients({ type: 'providers:changed' }); } finally { _announcingOpencodeProviders = false; }
|
|
9992
|
+
}
|
|
9993
|
+
|
|
9994
|
+
// The serve restarted, a credential changed or OpenCode said its catalog changed: the kept list is read
|
|
9995
|
+
// again in the background (still answered meanwhile; a credential change is another key anyway). `announce`:
|
|
9996
|
+
// the OpenCode panels were told already; every other window is told too.
|
|
9997
|
+
function noteOpencodeProvidersChanged({ announce = false } = {}) {
|
|
9998
|
+
try {
|
|
9999
|
+
_opencodeProviders.markStale();
|
|
10000
|
+
scheduleLaunchRefresh('opencode-providers');
|
|
10001
|
+
if (announce) broadcastSync({ type: 'opencode:providers-changed' });
|
|
10002
|
+
} catch { /* before the cache exists (start-up): nothing is kept yet */ }
|
|
10003
|
+
}
|
|
10004
|
+
|
|
9423
10005
|
app.get('/api/opencode/providers', async (req, res) => {
|
|
9424
10006
|
try {
|
|
9425
10007
|
if (!_ocpReady) return res.json({ ok: false, error: 'OpenCode server not running', data: [] });
|
|
9426
|
-
await
|
|
9427
|
-
|
|
9428
|
-
res.status(r.status).json({ ok: r.status === 200, data: r.data });
|
|
10008
|
+
const value = await readOpencodeProviders('full', { refresh: req.query?.refresh === '1' });
|
|
10009
|
+
sendOpencodeProviders(res, value, 'full');
|
|
9429
10010
|
} catch (err) {
|
|
9430
|
-
res.status(
|
|
10011
|
+
if (err?.status && err.status !== 502) return res.status(err.status).json({ ok: false, data: err.data });
|
|
10012
|
+
res.status(err?.status || 500).json({ ok: false, error: err.message });
|
|
9431
10013
|
}
|
|
9432
10014
|
});
|
|
9433
10015
|
|
|
@@ -9467,8 +10049,10 @@ app.get('/api/opencode-v2/status', async (req, res) => {
|
|
|
9467
10049
|
running,
|
|
9468
10050
|
port: _ocpPort,
|
|
9469
10051
|
version: _ocpVersion,
|
|
10052
|
+
protocol: _ocpProtocol,
|
|
9470
10053
|
managed: !!_ocpProc,
|
|
9471
10054
|
connected: opencodeV2Client.isConnected(),
|
|
10055
|
+
...(running ? {} : { problem: _ocpProblem || (_ocpLastCheck && _ocpLastCheck.state !== 'down' ? opencodeProblemText({ check: _ocpLastCheck, port: _ocpPort }) : null) }),
|
|
9472
10056
|
});
|
|
9473
10057
|
} catch (err) {
|
|
9474
10058
|
res.status(500).json({ ok: false, error: err.message });
|
|
@@ -9500,15 +10084,17 @@ app.post('/api/opencode-v2/serve/stop', (req, res) => {
|
|
|
9500
10084
|
// Models disabled in Settings → OpenCode (GET / PUT, broadcasts opencode:hidden-models-changed)
|
|
9501
10085
|
app.use('/api/opencode/hidden-models', createOpencodeHiddenModelsRouter({ store: opencodeHiddenModels, isGuestRequest, broadcastSync }));
|
|
9502
10086
|
|
|
9503
|
-
// Full provider data including connected[] array and auth methods
|
|
10087
|
+
// Full provider data including connected[] array and auth methods. ?view=compact: only the connected
|
|
10088
|
+
// providers, with what the pickers read (lib/opencode-provider-view.js); ?refresh=1 reads the serve again.
|
|
9504
10089
|
app.get('/api/opencode/providers/full', async (req, res) => {
|
|
9505
10090
|
try {
|
|
9506
10091
|
if (!_ocpReady) return res.json({ ok: false, error: 'OpenCode server not running', data: {} });
|
|
9507
|
-
|
|
9508
|
-
const
|
|
9509
|
-
res
|
|
10092
|
+
const view = req.query?.view === 'compact' ? 'compact' : 'full';
|
|
10093
|
+
const value = await readOpencodeProviders(view, { refresh: req.query?.refresh === '1' });
|
|
10094
|
+
sendOpencodeProviders(res, value, view);
|
|
9510
10095
|
} catch (err) {
|
|
9511
|
-
res.status(
|
|
10096
|
+
if (err?.status && err.status !== 502) return res.status(err.status).json({ ok: false, data: err.data });
|
|
10097
|
+
res.status(err?.status || 500).json({ ok: false, error: err.message });
|
|
9512
10098
|
}
|
|
9513
10099
|
});
|
|
9514
10100
|
|
|
@@ -9860,6 +10446,8 @@ function _panelClaudeAccounts() {
|
|
|
9860
10446
|
// The Assistant's account list and catalog listen to their own event.
|
|
9861
10447
|
broadcastSync({ type: 'assistant:accounts-changed', provider: 'claude-code', accountId: event.accountId || null, removed: !!event.removed });
|
|
9862
10448
|
_assistantCatalog?.invalidate?.();
|
|
10449
|
+
// The model list is read under the default account: asked again (another sign-in is another key).
|
|
10450
|
+
if (!event.accountId || event.accountId === 'default') { markClaudeModelsStale(); scheduleLaunchRefresh('claude-models'); }
|
|
9863
10451
|
} catch {}
|
|
9864
10452
|
});
|
|
9865
10453
|
}
|
|
@@ -10060,6 +10648,31 @@ function handleClaudeSkinWebSocket(ws) {
|
|
|
10060
10648
|
|
|
10061
10649
|
// Ask the installed CLI which models this account can run. Falls back to a small
|
|
10062
10650
|
// static list so the picker still renders when the CLI is missing or errors.
|
|
10651
|
+
// The sign-in the CLI reads its model list under (the default account: discovery runs with no
|
|
10652
|
+
// CLAUDE_CONFIG_DIR of its own), as a hash: another account can be entitled to other models, so the
|
|
10653
|
+
// kept list is keyed on it and never given for another account.
|
|
10654
|
+
function claudeModelAccountKey() {
|
|
10655
|
+
try {
|
|
10656
|
+
const row = _panelClaudeAccounts().describe('default');
|
|
10657
|
+
if (!row) return '';
|
|
10658
|
+
return createHash('sha256').update(`${row.mode || ''}\n${row.identity || ''}\n${row.signedIn}`).digest('hex').slice(0, 16);
|
|
10659
|
+
} catch { return ''; }
|
|
10660
|
+
}
|
|
10661
|
+
|
|
10662
|
+
// The last verified Claude list survives a restart (DATA_HOME/cache/launch/claude-models.json).
|
|
10663
|
+
setClaudeModelStore(createSnapshotStore({ dir: LAUNCH_CACHE_DIR, name: 'claude-models', log: (message) => console.warn(message) }));
|
|
10664
|
+
|
|
10665
|
+
// What Codex said about its settings schema and feature switches (lib/codex-settings-schema.js), per binary
|
|
10666
|
+
// (path, mtime, size: an update is another binary), kept across restarts: asking costs a Codex start per value.
|
|
10667
|
+
const _codexSettingsProbes = createLaunchCache({
|
|
10668
|
+
name: 'codex-settings',
|
|
10669
|
+
dir: LAUNCH_CACHE_DIR,
|
|
10670
|
+
ttlMs: Number.MAX_SAFE_INTEGER,
|
|
10671
|
+
maxEntries: 8,
|
|
10672
|
+
load: async () => { throw new Error('asked by lib/codex-settings-schema.js'); },
|
|
10673
|
+
});
|
|
10674
|
+
setCodexSettingsStore({ get: (key) => _codexSettingsProbes.peek(key), set: (key, value) => _codexSettingsProbes.set(key, value) });
|
|
10675
|
+
|
|
10063
10676
|
async function getClaudeModelsForClient(force = false) {
|
|
10064
10677
|
try {
|
|
10065
10678
|
const bin = getClaudeBin();
|
|
@@ -10068,7 +10681,9 @@ async function getClaudeModelsForClient(force = false) {
|
|
|
10068
10681
|
// installation is worth one line in the log. Cached per binary (a spawn only
|
|
10069
10682
|
// when the CLI changes).
|
|
10070
10683
|
checkClaudeCliSkew(bin).catch(() => {});
|
|
10071
|
-
|
|
10684
|
+
// Answered from the kept list at once (asked again in the background when it is old);
|
|
10685
|
+
// only a first read, another binary or account, or ?refresh=1 waits for the CLI.
|
|
10686
|
+
const result = await discoverClaudeModels(bin, { force, cwd: PACKAGE_ROOT, account: claudeModelAccountKey() });
|
|
10072
10687
|
if (result?.models?.length) return result;
|
|
10073
10688
|
} catch (err) {
|
|
10074
10689
|
console.warn('[claude-models] discovery failed:', err.message);
|
|
@@ -10833,11 +11448,15 @@ app.get('/api/images', (req, res) => {
|
|
|
10833
11448
|
}
|
|
10834
11449
|
});
|
|
10835
11450
|
|
|
10836
|
-
// GET /api/images/file/:filename — serve an image file
|
|
11451
|
+
// GET /api/images/file/:filename — serve an image file. These are the user's files (screenshots, pastes,
|
|
11452
|
+
// attachments, whiteboard images), and an SVG is a document: opened as a page it runs its own script in this origin.
|
|
11453
|
+
// So every answer runs nothing (a CSP with no script source, a sandbox) and is read only as the type it is sent as,
|
|
11454
|
+
// and an SVG is a download when it is opened as a page. An <img> in the app ignores all three and shows the image.
|
|
10837
11455
|
app.get('/api/images/file/:filename', (req, res) => {
|
|
10838
11456
|
const filename = basename(req.params.filename);
|
|
10839
11457
|
const fp = join(IMAGES_DIR, filename);
|
|
10840
11458
|
if (!existsSync(fp)) return res.status(404).json({ error: 'Not found' });
|
|
11459
|
+
setUntrustedFileHeaders(res, filename);
|
|
10841
11460
|
res.sendFile(fp);
|
|
10842
11461
|
});
|
|
10843
11462
|
|
|
@@ -12676,15 +13295,17 @@ function unknownAutomationContextMode(mode) {
|
|
|
12676
13295
|
return mode != null && mode !== '' && !['default', 'extended', '1m'].includes(mode);
|
|
12677
13296
|
}
|
|
12678
13297
|
|
|
13298
|
+
// The model list of the account that lives in `codexHome` (Default when none does): the one the
|
|
13299
|
+
// Codex panel's selector reads, from the same cache as GET /api/codex/models.
|
|
12679
13300
|
async function codexAutomationModels(codexHome) {
|
|
12680
13301
|
const home = codexHome || CODEX_DEFAULT_HOME;
|
|
12681
|
-
|
|
12682
|
-
|
|
12683
|
-
|
|
12684
|
-
|
|
12685
|
-
|
|
12686
|
-
|
|
12687
|
-
return models;
|
|
13302
|
+
let accountId = 'default';
|
|
13303
|
+
try {
|
|
13304
|
+
const owner = _codexAccounts().list().find((row) => row?.home && samePath(row.home, home));
|
|
13305
|
+
if (owner?.id) accountId = owner.id;
|
|
13306
|
+
} catch { /* the default account */ }
|
|
13307
|
+
const models = await _codexModelListCache.get(codexModelListKey(accountId));
|
|
13308
|
+
return Array.isArray(models) ? models : [];
|
|
12688
13309
|
}
|
|
12689
13310
|
|
|
12690
13311
|
async function resolveAutomationLaunchChoice(profile, model, contextMode, codexHome) {
|
|
@@ -16660,18 +17281,52 @@ async function checkSynabunUpdate() {
|
|
|
16660
17281
|
}
|
|
16661
17282
|
|
|
16662
17283
|
app.get('/api/system/version', async (req, res) => {
|
|
16663
|
-
|
|
16664
|
-
|
|
17284
|
+
// Settings > App: with automatic updates off the server looks only when the user asks (?force=1). A page's own
|
|
17285
|
+
// timer or a drawer that refreshes itself says `passive=1`, and never makes it look then.
|
|
17286
|
+
const auto = automaticUpdateCheck();
|
|
17287
|
+
const force = req.query.force === '1' && (auto || req.query.passive !== '1');
|
|
17288
|
+
if (force || (auto && !_synabunUpdateCache.checkedAt)) {
|
|
16665
17289
|
try { await checkSynabunUpdate(); } catch {}
|
|
16666
|
-
} else {
|
|
17290
|
+
} else if (auto) {
|
|
16667
17291
|
const age = Date.now() - new Date(_synabunUpdateCache.checkedAt).getTime();
|
|
16668
17292
|
if (age > 60 * 60 * 1000) {
|
|
16669
17293
|
checkSynabunUpdate().catch(() => {});
|
|
16670
17294
|
}
|
|
16671
17295
|
}
|
|
16672
|
-
res.json(_synabunUpdateCache);
|
|
17296
|
+
res.json({ ..._synabunUpdateCache, current: _synabunUpdateCache.current || installedSynabunVersion(), autoCheck: auto });
|
|
16673
17297
|
});
|
|
16674
17298
|
|
|
17299
|
+
// ── Settings > App (lib/app-controls-api.js): automatic update checks, start at login, the data and logs folders ──
|
|
17300
|
+
// The desktop app keeps its own (packaging/desktop/src/app-prefs.mjs): there this only says so.
|
|
17301
|
+
const appSettings = createAppSettingsStore({ path: join(DATA_HOME, 'data', 'app-settings.json') });
|
|
17302
|
+
/** Does the server look for updates by itself (at start, and when the page asks without ?force=1)? */
|
|
17303
|
+
function automaticUpdateCheck() {
|
|
17304
|
+
if (PACKAGED_RUNTIME?.shell) return true;
|
|
17305
|
+
try { return appSettings.get().autoUpdateCheck !== false; } catch { return true; }
|
|
17306
|
+
}
|
|
17307
|
+
function installedSynabunVersion() {
|
|
17308
|
+
try { return JSON.parse(readFileSync(resolve(PACKAGE_ROOT, 'package.json'), 'utf-8')).version || null; } catch { return null; }
|
|
17309
|
+
}
|
|
17310
|
+
const loginStart = createLoginStart({
|
|
17311
|
+
packageRoot: PACKAGE_ROOT,
|
|
17312
|
+
home: os.homedir(),
|
|
17313
|
+
dataHome: DATA_HOME,
|
|
17314
|
+
entry: PACKAGED_RUNTIME && !PACKAGED_RUNTIME.shell ? durableEntry(PACKAGED_RUNTIME) : null,
|
|
17315
|
+
desktopShell: !!PACKAGED_RUNTIME?.shell,
|
|
17316
|
+
// Never from a test run or a throwaway data folder (the same rule as the synabun:// handler).
|
|
17317
|
+
allowed: () => launcherRegistrationAllowed({ env: process.env, dataHome: DATA_HOME }),
|
|
17318
|
+
log: (line) => console.log(`[app] ${line}`),
|
|
17319
|
+
});
|
|
17320
|
+
app.use('/api/system/app', createAppControlsApi({
|
|
17321
|
+
store: appSettings,
|
|
17322
|
+
loginStart,
|
|
17323
|
+
folders: { data: DATA_HOME, logs: join(DATA_HOME, 'data') },
|
|
17324
|
+
install: PACKAGED_RUNTIME?.shell ? 'desktop' : INSTALL_KIND,
|
|
17325
|
+
isGuestRequest,
|
|
17326
|
+
port: () => PORT,
|
|
17327
|
+
log: (line) => console.warn(line),
|
|
17328
|
+
}));
|
|
17329
|
+
|
|
16675
17330
|
// GET /api/system/about — Settings → About (lib/system-about.js): the app, the runtime, the project's links and
|
|
16676
17331
|
// every dependency of the product's packages with the version installed, read from the files on disk on the first
|
|
16677
17332
|
// request and kept for the life of the process. No environment, no token, no setting. Owner only (/api/system is
|
|
@@ -17490,6 +18145,8 @@ const editorConfigStore = createEditorConfigStore({
|
|
|
17490
18145
|
// the first request, stopped after ten idle minutes and at shutdown, never the shared serve), or a
|
|
17491
18146
|
// user-configured endpoint is, whose key is SYNABUN_EDITOR_AI_KEY in the data home's .env.
|
|
17492
18147
|
const editorEndpointKey = createEndpointKeyStore({ envPath: ENV_PATH, parseEnvFile, writeEnvFile });
|
|
18148
|
+
// The completion serve's process → its record (trackOpencodeServe), from its spawn until it has ended.
|
|
18149
|
+
const editorCompletionServeTracks = new WeakMap();
|
|
17493
18150
|
const editorCompletionServe = createOpencodeCompletionEngine({
|
|
17494
18151
|
dataDir: resolve(DATA_HOME, 'data', 'editor-complete'),
|
|
17495
18152
|
system: COMPLETION_SYSTEM,
|
|
@@ -17499,18 +18156,30 @@ const editorCompletionServe = createOpencodeCompletionEngine({
|
|
|
17499
18156
|
},
|
|
17500
18157
|
// A provider's {file:./key} is relative to that configuration: it is written whole for the serve's own folder.
|
|
17501
18158
|
userConfigDir: () => dirname(getOpencodeConfigPath()),
|
|
17502
|
-
|
|
18159
|
+
// What an earlier SynaBun left running goes first (lib/opencode-serve-records.js), then a port is picked.
|
|
18160
|
+
allocPort: async () => { await sweepLeftoverOpencodeServes(); return allocOpencodeIsoPort(); },
|
|
18161
|
+
// On record from its spawn (spawnServe), its record gone with its process: a crash of SynaBun does not leave it
|
|
18162
|
+
// running. Once something answers on its port: is it this start's own serve ('foreign': another server took the
|
|
18163
|
+
// port, which the engine leaves running while it tries another)?
|
|
18164
|
+
trackServe: ({ proc }) => editorCompletionServeTracks.get(proc)?.answering(),
|
|
17503
18165
|
// As the isolated serves are started (opencodeServeLaunch): the CLI the resolver found, through the login shell.
|
|
17504
|
-
|
|
17505
|
-
|
|
18166
|
+
// OpenCode 2 gets the password the engine made for this start, and its start waits its turn among every serve
|
|
18167
|
+
// SynaBun starts (one OpenCode 2 database cannot be opened by two serves at once).
|
|
18168
|
+
detectProtocol: () => detectOpencodeProtocol(),
|
|
18169
|
+
startOneAtATime: oneOpencodeStartAtATime,
|
|
18170
|
+
spawnServe: ({ port, env, cwd, protocol = 1, password = null }) => {
|
|
18171
|
+
const token = opencodeServeRecords.newToken();
|
|
18172
|
+
const launch = opencodeServeLaunch(port, { env: { ...env, [SERVE_TOKEN_ENV]: token }, protocol, password });
|
|
17506
18173
|
const proc = spawn(launch.command, launch.args, { stdio: ['ignore', 'ignore', 'pipe'], ...launch.options, cwd, detached: false, shell: false, windowsHide: true });
|
|
18174
|
+
editorCompletionServeTracks.set(proc, trackOpencodeServe(proc, { kind: 'completion', id: 'editor-complete', port, token }));
|
|
17507
18175
|
proc.stderr?.on('data', (d) => console.log(`[editor-complete:err] ${String(d).trim().slice(0, 500)}`));
|
|
17508
18176
|
return proc;
|
|
17509
18177
|
},
|
|
17510
|
-
|
|
18178
|
+
// SIGTERM to stop it; SIGKILL, through its handle, for a failed start's process that did not end (Windows: taskkill /T /F).
|
|
18179
|
+
killServe: (proc, signal = 'SIGTERM') => {
|
|
17511
18180
|
try {
|
|
17512
18181
|
if (IS_WIN && proc.pid) spawn('taskkill', ['/pid', String(proc.pid), '/T', '/F'], { shell: true, windowsHide: true, stdio: 'ignore' });
|
|
17513
|
-
else proc.kill(
|
|
18182
|
+
else proc.kill(signal);
|
|
17514
18183
|
} catch { /* already gone */ }
|
|
17515
18184
|
},
|
|
17516
18185
|
createClient: opencodeV2Client.createClientInstance,
|
|
@@ -17523,8 +18192,8 @@ const editorCompletion = createEditorCompletionService({
|
|
|
17523
18192
|
// The model list comes from the shared serve while it runs: listing models starts nothing.
|
|
17524
18193
|
sharedProviders: async () => {
|
|
17525
18194
|
if (!_ocpReady) return null;
|
|
17526
|
-
|
|
17527
|
-
return
|
|
18195
|
+
// The connected providers, from the kept list (the same read as the pickers').
|
|
18196
|
+
try { return (await readOpencodeProviders('compact')).compact; } catch { return null; }
|
|
17528
18197
|
},
|
|
17529
18198
|
isHiddenModel: (id) => opencodeHiddenModels.has(id),
|
|
17530
18199
|
usage: createUsageStore({
|
|
@@ -20077,11 +20746,13 @@ app.get('/api/opencode/sessions', (req, res) => {
|
|
|
20077
20746
|
|
|
20078
20747
|
let rows;
|
|
20079
20748
|
try {
|
|
20749
|
+
// OpenCode 1's tables, or OpenCode 2's (lib/opencode-db-schema.js).
|
|
20750
|
+
const tables = sessionTablesFor(opencodeDbFlavor(db, _ocpProtocol));
|
|
20080
20751
|
const stmt = db.prepare(`
|
|
20081
20752
|
SELECT s.id, s.title, s.directory, s.time_created, s.time_updated,
|
|
20082
20753
|
p.worktree as project_worktree,
|
|
20083
|
-
|
|
20084
|
-
FROM session s
|
|
20754
|
+
${tables.messageCount} as message_count
|
|
20755
|
+
FROM ${tables.session} s
|
|
20085
20756
|
LEFT JOIN project p ON s.project_id = p.id
|
|
20086
20757
|
WHERE s.time_archived IS NULL
|
|
20087
20758
|
ORDER BY s.time_updated DESC
|
|
@@ -22386,6 +23057,8 @@ app.get('/api/skills-studio/artifact/:id/icon', (req, res) => {
|
|
|
22386
23057
|
const mimeMap = { '.png': 'image/png', '.svg': 'image/svg+xml', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp' };
|
|
22387
23058
|
res.setHeader('Content-Type', mimeMap[ext] || 'application/octet-stream');
|
|
22388
23059
|
res.setHeader('Cache-Control', 'no-cache');
|
|
23060
|
+
// A skill's icon is whatever file the skill folder holds: opened by its address it runs nothing.
|
|
23061
|
+
setUntrustedFileHeaders(res, iconPath, mimeMap[ext]);
|
|
22389
23062
|
res.send(readConfinedFile(resolveArtifactFile(id).realBase, iconPath, { maxBytes: ICON_MAX_BYTES }));
|
|
22390
23063
|
} catch (err) {
|
|
22391
23064
|
res.status(skillsStudioStatus(err)).json({ error: err.message });
|
|
@@ -24358,7 +25031,8 @@ function getTerminalProfile(profileId, opts = {}) {
|
|
|
24358
25031
|
'claude-code': (m) => `${cmd} --model ${m}`,
|
|
24359
25032
|
'codex': (m) => `${cmd} --model ${m}`,
|
|
24360
25033
|
'gemini': (m) => `${cmd} --model ${m}`,
|
|
24361
|
-
'
|
|
25034
|
+
// OpenCode 2's terminal app has no --model (it prints its help and exits): the model is picked in it.
|
|
25035
|
+
'opencode': (m) => (knownOpencodeMajor() >= 2 ? cmd : `${cmd} --model ${m}`),
|
|
24362
25036
|
};
|
|
24363
25037
|
const builder = modelMap[profileId];
|
|
24364
25038
|
if (builder) cmd = builder(quotedModel);
|
|
@@ -25213,6 +25887,9 @@ async function releaseNativeLoopResources(run) {
|
|
|
25213
25887
|
reconcileKeepAwake();
|
|
25214
25888
|
}
|
|
25215
25889
|
|
|
25890
|
+
// An OpenCode worker's tool calls, for the Assistant's run stages and tray: a compact copy, at a bounded rate.
|
|
25891
|
+
const openCodeWorkerActivity = createOpenCodeActivityRelay({ send: (message) => broadcastSync(message) });
|
|
25892
|
+
|
|
25216
25893
|
_nativeLoopRuntime = new NativeLoopRuntime({
|
|
25217
25894
|
stateDir: LOOP_DIR,
|
|
25218
25895
|
ledgerPath: resolve(LOOP_DATA_DIR, 'native-loop-runs.json'),
|
|
@@ -25223,11 +25900,10 @@ _nativeLoopRuntime = new NativeLoopRuntime({
|
|
|
25223
25900
|
judgeGoal: createLoopGoalJudge({ judgeLoopGoal, surfaceConfig: typesafeSurfaceConfig }),
|
|
25224
25901
|
log: (runId, tag, message, meta) => loopLog(runId, tag, message, meta),
|
|
25225
25902
|
onEvent: (message) => {
|
|
25226
|
-
// OpenCode already streams from its isolated serve into the V2 panel
|
|
25227
|
-
//
|
|
25228
|
-
|
|
25229
|
-
|
|
25230
|
-
}
|
|
25903
|
+
// OpenCode already streams from its isolated serve into the V2 panel: only its
|
|
25904
|
+
// tool calls go to the sync socket, compact (lib/opencode-worker-activity.js).
|
|
25905
|
+
// Claude and Codex SDK events need the shared sync socket to reach their renderers.
|
|
25906
|
+
if (!openCodeWorkerActivity.observe(message)) broadcastSync(message);
|
|
25231
25907
|
},
|
|
25232
25908
|
onFinish: releaseNativeLoopResources,
|
|
25233
25909
|
providerFactories: {
|
|
@@ -25366,6 +26042,8 @@ _nativeLoopRuntime = new NativeLoopRuntime({
|
|
|
25366
26042
|
return codexAccountAuth().rateLimits(id, opts);
|
|
25367
26043
|
} });
|
|
25368
26044
|
_assistantAccountUsage = accountUsage;
|
|
26045
|
+
// The quotas the account menus show, read once now that they can be (the launch warm-up may have run first).
|
|
26046
|
+
scheduleLaunchRefresh('account-usage', 5000);
|
|
25369
26047
|
claudeAccounts.subscribe(({ accountId }) => accountUsage.invalidate('claude-code', accountId));
|
|
25370
26048
|
_codexAccounts().subscribe(({ accountId }) => accountUsage.invalidate('codex', accountId));
|
|
25371
26049
|
// A Claude Code worker whose dispatch names no account runs under this one (Assistant → accounts).
|
|
@@ -25433,6 +26111,12 @@ _nativeLoopRuntime = new NativeLoopRuntime({
|
|
|
25433
26111
|
});
|
|
25434
26112
|
const assistantUsage = createUsageLedger({ dataDir: resolve(DATA_HOME, 'data'), log: assistantLog, judgments });
|
|
25435
26113
|
const catalogFetch = async (path) => {
|
|
26114
|
+
// OpenCode's providers are read in process, the connected ones only (the whole models.dev catalog is
|
|
26115
|
+
// megabytes and the catalog lists connected providers alone): what the route answers for ?view=compact.
|
|
26116
|
+
if (path === '/api/opencode/providers/full') {
|
|
26117
|
+
if (!_ocpReady) return { ok: false, error: 'OpenCode server not running', data: {} };
|
|
26118
|
+
try { return { ok: true, data: (await readOpencodeProviders('compact')).compact }; } catch { return null; }
|
|
26119
|
+
}
|
|
25436
26120
|
try {
|
|
25437
26121
|
const response = await fetch(`http://127.0.0.1:${PORT}${path}`, { signal: AbortSignal.timeout(4000) });
|
|
25438
26122
|
return response.ok ? await response.json() : null;
|
|
@@ -25460,9 +26144,13 @@ _nativeLoopRuntime = new NativeLoopRuntime({
|
|
|
25460
26144
|
reconcileHidden: (args) => assistantHiddenModels.reconcile(args),
|
|
25461
26145
|
// A provider lists other models than before: every open window reloads its menus.
|
|
25462
26146
|
onChange: ({ providers }) => broadcastSync({ type: 'assistant:catalog-changed', providers }),
|
|
26147
|
+
// An old build answers at once while the next is made (its lists are kept by their own caches).
|
|
26148
|
+
staleWhileRevalidate: true,
|
|
25463
26149
|
log: (tag, message) => assistantLog(tag, message),
|
|
25464
26150
|
});
|
|
25465
26151
|
_assistantCatalog = assistantCatalog;
|
|
26152
|
+
// Built once the provider lists are warm (the launch warm-up may have run before the Assistant existed).
|
|
26153
|
+
scheduleLaunchRefresh('assistant-catalog', 8000);
|
|
25466
26154
|
// A Models toggle or a Settings → OpenCode checkbox reaches routing on the next call.
|
|
25467
26155
|
assistantHiddenModels.onChange((change) => {
|
|
25468
26156
|
// What a catalog build settled about a hidden alias is already in that build. When it
|
|
@@ -25632,7 +26320,8 @@ _nativeLoopRuntime = new NativeLoopRuntime({
|
|
|
25632
26320
|
app.use('/api/assistant/hidden-models', createAssistantHiddenModelsRouter({
|
|
25633
26321
|
hiddenModels: assistantHiddenModels, catalog: assistantCatalog, opencodeStore: opencodeHiddenModels, isGuestRequest, broadcastSync,
|
|
25634
26322
|
}));
|
|
25635
|
-
|
|
26323
|
+
// Quotas as last read (read again in the background): a window's menu never waits for the quota probe.
|
|
26324
|
+
const buildCatalog = async (opts = {}) => { accountUsage.refresh().catch(() => {}); return { ...(await assistantCatalog.get(opts)), accounts: accountUsage.list({ stale: true }) }; };
|
|
25636
26325
|
// Clarification: a materially ambiguous request gets 1-3 questions before it
|
|
25637
26326
|
// is routed; the user's words and answers become the brief every worker of
|
|
25638
26327
|
// that request receives. Knobs: assistant-config.json `clarify` (read live).
|
|
@@ -26145,6 +26834,7 @@ function prepareExecWrapperFile(terminalSessionId, loopState, taskFile, sentinel
|
|
|
26145
26834
|
mcpEnvOverrides,
|
|
26146
26835
|
// The Admin sandbox setting the loop started with (a loop file from before it: the current one).
|
|
26147
26836
|
codexConfigArgs: codexWindowsSandboxArgs(typeof loopState.adminSandbox === 'boolean' ? loopState.adminSandbox : adminSandboxOn()),
|
|
26837
|
+
...(profile === 'opencode' ? { opencodeMajor: knownOpencodeMajor() } : {}),
|
|
26148
26838
|
});
|
|
26149
26839
|
const wrapperFile = join(
|
|
26150
26840
|
os.tmpdir(),
|
|
@@ -27028,6 +27718,7 @@ const IS_WINDOWS = process.platform === 'win32';
|
|
|
27028
27718
|
let _gitMissingLogged = false;
|
|
27029
27719
|
let _gitExePath = null; // Cached absolute path — preferring .exe on Windows
|
|
27030
27720
|
let _gitResolved = false;
|
|
27721
|
+
let _gitResolving = null; // the lookup resolveGitPathInBackground() runs, while it runs
|
|
27031
27722
|
|
|
27032
27723
|
// Resolve git's absolute path at first use. On Windows, `where git` lists all matches;
|
|
27033
27724
|
// prefer .exe so spawnSync works without cmd.exe quoting issues. On Unix, use `command -v`.
|
|
@@ -27093,15 +27784,39 @@ function logGitFailure(where, err) {
|
|
|
27093
27784
|
console.warn(`[git] '${where}' failed:`, msg.trim().split('\n')[0]);
|
|
27094
27785
|
}
|
|
27095
27786
|
|
|
27096
|
-
//
|
|
27787
|
+
// resolveGitPath()'s lookup without blocking: `where git` (Windows) or `command -v git`, run once in the
|
|
27788
|
+
// background at start-up, so the first git call of a request finds the answer kept instead of running the
|
|
27789
|
+
// lookup itself (a synchronous spawn through cmd.exe holds every request on a Windows PC without git).
|
|
27790
|
+
function resolveGitPathInBackground() {
|
|
27791
|
+
if (_gitResolved) return Promise.resolve(_gitExePath || gitBash.gitExecutable());
|
|
27792
|
+
if (_gitResolving) return _gitResolving;
|
|
27793
|
+
_gitResolving = new Promise((done) => {
|
|
27794
|
+
const [command, args] = IS_WINDOWS ? ['where', ['git']] : ['/bin/sh', ['-c', 'command -v git']];
|
|
27795
|
+
execFile(command, args, { encoding: 'utf8', timeout: 3000, windowsHide: true }, (error, stdout) => {
|
|
27796
|
+
if (!_gitResolved) {
|
|
27797
|
+
_gitResolved = true;
|
|
27798
|
+
const lines = error ? [] : String(stdout || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
27799
|
+
_gitExePath = (IS_WINDOWS ? lines.find((line) => /\.exe$/i.test(line)) : null) || lines[0] || null;
|
|
27800
|
+
if (_gitExePath) console.log(`[git] resolved: ${_gitExePath}`);
|
|
27801
|
+
else console.warn('[git] resolution failed:', error?.message || 'resolver returned no paths');
|
|
27802
|
+
}
|
|
27803
|
+
done(_gitExePath || gitBash.gitExecutable());
|
|
27804
|
+
});
|
|
27805
|
+
}).finally(() => { _gitResolving = null; });
|
|
27806
|
+
return _gitResolving;
|
|
27807
|
+
}
|
|
27808
|
+
|
|
27809
|
+
// Startup probe — always logs git status so Windows users can see whether git works at all. Asynchronous:
|
|
27810
|
+
// after the lookup above, one `git --version` (no folder, no repository's configuration) off the event loop.
|
|
27097
27811
|
setImmediate(() => {
|
|
27098
|
-
|
|
27099
|
-
|
|
27100
|
-
|
|
27101
|
-
|
|
27102
|
-
|
|
27103
|
-
|
|
27104
|
-
|
|
27812
|
+
resolveGitPathInBackground().then((gitPath) => new Promise((done) => {
|
|
27813
|
+
const shell = IS_WINDOWS && (!gitPath || /\.(cmd|bat)$/i.test(gitPath));
|
|
27814
|
+
execFile(gitPath || 'git', ['--version'], { encoding: 'utf8', timeout: 3000, windowsHide: true, shell }, (error, stdout, stderr) => {
|
|
27815
|
+
if (!error) console.log(`[git] ${String(stdout || '').trim()} — OK`);
|
|
27816
|
+
else console.warn(`[git] startup probe FAILED: ${String(stderr || '').trim() || error.message || 'unknown error'}. File explorer git UI will be hidden.`);
|
|
27817
|
+
done();
|
|
27818
|
+
});
|
|
27819
|
+
})).catch(() => {});
|
|
27105
27820
|
});
|
|
27106
27821
|
|
|
27107
27822
|
// Git "dubious ownership" detection — CVE-2022-24765 safety check refuses to operate on
|
|
@@ -33477,6 +34192,83 @@ app.get('/api/browser/detect-browsers', (req, res) => {
|
|
|
33477
34192
|
res.json({ browsers });
|
|
33478
34193
|
});
|
|
33479
34194
|
|
|
34195
|
+
// --- Launch warm-up ---
|
|
34196
|
+
// After the server listens (never before it, and never in front of the first page), the slow lists the
|
|
34197
|
+
// windows, menus and Settings wait for are read once in the background: one step at a time, a pause between
|
|
34198
|
+
// two, so a first click finds them kept (lib/launch-cache.js) and nothing competes with the page. Every step
|
|
34199
|
+
// is asynchronous: a CLI runs as a child process, never through a synchronous spawn. A step that is not ready
|
|
34200
|
+
// yet (the Assistant still starting, OpenCode's serve not up) is skipped: its own ready event runs it again.
|
|
34201
|
+
// scheduleLaunchRefresh(name) runs one step again after an event (an account switch, a provider connected,
|
|
34202
|
+
// a serve restart), debounced, behind the same one-at-a-time queue.
|
|
34203
|
+
const _launchQueue = createLimiter(1);
|
|
34204
|
+
const _launchTimers = new Map();
|
|
34205
|
+
const LAUNCH_STEP_GAP_MS = 250;
|
|
34206
|
+
const LAUNCH_TASKS = {
|
|
34207
|
+
// Where git is: the lookup a first git call would otherwise run synchronously.
|
|
34208
|
+
'git-path': () => resolveGitPathInBackground(),
|
|
34209
|
+
// Claude Code's model list (one CLI start; the kept list answers meanwhile). Only where Claude Code is found.
|
|
34210
|
+
'claude-models': async () => { if (cliFoundForWarmup('claude-code')) await getClaudeModelsForClient(true); },
|
|
34211
|
+
// OpenCode's providers, once its serve answers (the serve's ready event runs this step again).
|
|
34212
|
+
'opencode-providers': async () => { if (_ocpReady) await readOpencodeProviders('compact', { refresh: true }); },
|
|
34213
|
+
// Every signed-in Codex account's model list (one app-server each, one after another).
|
|
34214
|
+
'codex-models': async () => {
|
|
34215
|
+
// Only where Codex is found: listing prepares the account's config.toml, which a machine without Codex never gets.
|
|
34216
|
+
if (!cliFoundForWarmup('codex')) return;
|
|
34217
|
+
let rows = [];
|
|
34218
|
+
try { rows = _codexAccounts().list().filter((row) => row?.signedIn !== false); } catch { rows = [{ id: 'default' }]; }
|
|
34219
|
+
for (const row of rows) {
|
|
34220
|
+
try { await _codexModelListCache.read(codexModelListKey(row.id), { refresh: true }); } catch { /* that account's list stays as kept */ }
|
|
34221
|
+
}
|
|
34222
|
+
},
|
|
34223
|
+
// The Assistant's catalog, from the lists above (every view the windows ask for is made from it).
|
|
34224
|
+
'assistant-catalog': async () => { await _assistantCatalog?.full?.({ force: true }); },
|
|
34225
|
+
// Subscription quotas the account menus show.
|
|
34226
|
+
'account-usage': async () => { await _assistantAccountUsage?.refresh?.(); },
|
|
34227
|
+
// What the Codex panel's settings and cog menu validate against (a Codex start per setting value, once per version).
|
|
34228
|
+
'codex-settings': async () => {
|
|
34229
|
+
const found = cliResolver.launcher('codex');
|
|
34230
|
+
const bin = found.launchPath || found.path;
|
|
34231
|
+
// A file the resolver checked (a bare command name or a command line has no binary identity to key on).
|
|
34232
|
+
if (found.state === 'installed' && bin && /[\\/]/.test(bin)) await readCodexSettingsSchema(bin).catch(() => null);
|
|
34233
|
+
},
|
|
34234
|
+
};
|
|
34235
|
+
|
|
34236
|
+
// A CLI the resolver found (probed or not yet): the warm-up starts nothing for a tool that is not installed.
|
|
34237
|
+
function cliFoundForWarmup(toolKey) {
|
|
34238
|
+
try {
|
|
34239
|
+
const found = cliResolver.launcher(toolKey);
|
|
34240
|
+
return !!found.path && found.state !== 'broken' && found.state !== 'missing';
|
|
34241
|
+
} catch { return false; }
|
|
34242
|
+
}
|
|
34243
|
+
|
|
34244
|
+
function runLaunchStep(name) {
|
|
34245
|
+
const task = LAUNCH_TASKS[name];
|
|
34246
|
+
if (!task) return Promise.resolve();
|
|
34247
|
+
return _launchQueue(async () => {
|
|
34248
|
+
const started = Date.now();
|
|
34249
|
+
try { await task(); } catch (error) { console.warn(`[launch-cache] ${name}: ${error?.message || error}`); }
|
|
34250
|
+
const took = Date.now() - started;
|
|
34251
|
+
if (took > 2000) console.log(`[launch-cache] ${name} warmed in ${(took / 1000).toFixed(1)} s`);
|
|
34252
|
+
await new Promise((done) => { const timer = setTimeout(done, LAUNCH_STEP_GAP_MS); timer.unref?.(); });
|
|
34253
|
+
});
|
|
34254
|
+
}
|
|
34255
|
+
|
|
34256
|
+
/** Run one warm-up step again soon (an event changed what it reads); bursts of events run it once. */
|
|
34257
|
+
function scheduleLaunchRefresh(name, delayMs = 600) {
|
|
34258
|
+
if (!LAUNCH_TASKS[name] || _launchTimers.has(name)) return;
|
|
34259
|
+
const timer = setTimeout(() => { _launchTimers.delete(name); runLaunchStep(name); }, delayMs);
|
|
34260
|
+
timer.unref?.();
|
|
34261
|
+
_launchTimers.set(name, timer);
|
|
34262
|
+
}
|
|
34263
|
+
|
|
34264
|
+
function startLaunchWarmup() {
|
|
34265
|
+
if (process.env.SYNABUN_LAUNCH_WARMUP === 'off') return;
|
|
34266
|
+
const timer = setTimeout(() => {
|
|
34267
|
+
for (const name of Object.keys(LAUNCH_TASKS)) runLaunchStep(name);
|
|
34268
|
+
}, 3000);
|
|
34269
|
+
timer.unref?.();
|
|
34270
|
+
}
|
|
34271
|
+
|
|
33480
34272
|
// --- Start ---
|
|
33481
34273
|
reloadConfig();
|
|
33482
34274
|
|
|
@@ -33509,6 +34301,15 @@ const httpServer = app.listen(PORT, async () => {
|
|
|
33509
34301
|
else if (!launcher.ok) console.warn(`[server] Start Server launcher not registered: ${launcher.errors[0]}`);
|
|
33510
34302
|
} catch {}
|
|
33511
34303
|
|
|
34304
|
+
// Start at login (Settings > App): while it is on, its entry starts this install (another Node, a moved
|
|
34305
|
+
// install). One that is off, or that the user turned off in the system, is left as it is.
|
|
34306
|
+
try {
|
|
34307
|
+
const healed = loginStart.heal();
|
|
34308
|
+
if (healed.changed) console.log('[server] start at login: the entry now starts this install');
|
|
34309
|
+
} catch (err) {
|
|
34310
|
+
console.warn(`[server] start at login: ${err.message}`);
|
|
34311
|
+
}
|
|
34312
|
+
|
|
33512
34313
|
// Auto-heal legacy synabun-symlink plugin state and reinstall via CC CLI
|
|
33513
34314
|
try {
|
|
33514
34315
|
const repair = pluginInstaller.repairSynabunPlugins();
|
|
@@ -33556,11 +34357,19 @@ const httpServer = app.listen(PORT, async () => {
|
|
|
33556
34357
|
// opencode-<sid> node_modules that never got their per-loop cleanup.
|
|
33557
34358
|
setTimeout(() => { try { cleanupStaleLoopConfigs(); } catch {} }, 1500);
|
|
33558
34359
|
|
|
34360
|
+
// OpenCode serves an earlier SynaBun left running (it crashed, or was killed): found by their records alone and
|
|
34361
|
+
// stopped only when they are provably its own and nothing uses them (lib/opencode-serve-records.js). An
|
|
34362
|
+
// OpenCode start that comes earlier makes the same look first (and this one then finds nothing left).
|
|
34363
|
+
setTimeout(() => { sweepLeftoverOpencodeServes(); }, 1000);
|
|
34364
|
+
|
|
33559
34365
|
// Compare the installed Claude CLI with the release the Agent SDK was built
|
|
33560
34366
|
// against, once, so an older installation is named in the log before the
|
|
33561
34367
|
// first session runs on it (checkClaudeCliSkew in lib/claude-model-catalog.js).
|
|
33562
34368
|
setTimeout(() => { checkClaudeCliSkew(getClaudeBin()).catch(() => {}); }, 1000);
|
|
33563
34369
|
|
|
34370
|
+
// The lists windows, menus and Settings wait for, read once in the background (see LAUNCH_TASKS above).
|
|
34371
|
+
startLaunchWarmup();
|
|
34372
|
+
|
|
33564
34373
|
// Windows: the Git Bash every Claude Code gets (lib/git-bash/). Looked for now (the settings of the user's own
|
|
33565
34374
|
// claude follow it); a PC with none, and an app that carries none, gets SynaBun's copy, fetched in the background.
|
|
33566
34375
|
setTimeout(() => {
|
|
@@ -33595,7 +34404,7 @@ const httpServer = app.listen(PORT, async () => {
|
|
|
33595
34404
|
},
|
|
33596
34405
|
() => {
|
|
33597
34406
|
try {
|
|
33598
|
-
const oc = rebuildOpencodeCache({ getOpencodeDb });
|
|
34407
|
+
const oc = rebuildOpencodeCache({ getOpencodeDb, protocol: _ocpProtocol });
|
|
33599
34408
|
if (oc.updated > 0) console.log(` Session cache (opencode): indexed ${oc.updated}/${oc.sessions}`);
|
|
33600
34409
|
} catch (err) { console.warn(' Session cache (opencode) error:', err.message); }
|
|
33601
34410
|
},
|
|
@@ -33680,8 +34489,12 @@ const httpServer = app.listen(PORT, async () => {
|
|
|
33680
34489
|
}
|
|
33681
34490
|
} catch { /* ok */ }
|
|
33682
34491
|
|
|
33683
|
-
// Check for SynaBun updates (npm + github releases)
|
|
33684
|
-
|
|
34492
|
+
// Check for SynaBun updates (npm + github releases), unless Settings > App turned the automatic check off
|
|
34493
|
+
if (automaticUpdateCheck()) {
|
|
34494
|
+
try { await checkSynabunUpdate(); } catch {}
|
|
34495
|
+
} else {
|
|
34496
|
+
console.log(' Updates: automatic check off (Settings > App)');
|
|
34497
|
+
}
|
|
33685
34498
|
|
|
33686
34499
|
// Prune any external CLI tool left in our node_modules before the version
|
|
33687
34500
|
// check runs — defends against `npm install` not auto-pruning packages that
|