relay-ide 0.1.0
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/README.md +259 -0
- package/dist/bin/claude-remote-cli.js +390 -0
- package/dist/bin/relay-ide.js +390 -0
- package/dist/frontend/assets/abap-BdImnpbu.js +1 -0
- package/dist/frontend/assets/actionscript-3-CoDkCxhg.js +1 -0
- package/dist/frontend/assets/ada-bCR0ucgS.js +1 -0
- package/dist/frontend/assets/andromeeda-C4gqWexZ.js +1 -0
- package/dist/frontend/assets/angular-html-DA-rfuFy.js +1 -0
- package/dist/frontend/assets/angular-ts-BrjP3tb8.js +1 -0
- package/dist/frontend/assets/apache-Pmp26Uib.js +1 -0
- package/dist/frontend/assets/apex-D8_7TLub.js +1 -0
- package/dist/frontend/assets/apl-CORt7UWP.js +1 -0
- package/dist/frontend/assets/applescript-Co6uUVPk.js +1 -0
- package/dist/frontend/assets/ara-BRHolxvo.js +1 -0
- package/dist/frontend/assets/asciidoc-Ve4PFQV2.js +1 -0
- package/dist/frontend/assets/asm-D_Q5rh1f.js +1 -0
- package/dist/frontend/assets/astro-HNnZUWAn.js +1 -0
- package/dist/frontend/assets/aurora-x-D-2ljcwZ.js +1 -0
- package/dist/frontend/assets/awk-DMzUqQB5.js +1 -0
- package/dist/frontend/assets/ayu-dark-DYE7WIF3.js +1 -0
- package/dist/frontend/assets/ayu-light-BA47KaF1.js +1 -0
- package/dist/frontend/assets/ayu-mirage-32ctXXKs.js +1 -0
- package/dist/frontend/assets/ballerina-BFfxhgS-.js +1 -0
- package/dist/frontend/assets/bat-BkioyH1T.js +1 -0
- package/dist/frontend/assets/beancount-k_qm7-4y.js +1 -0
- package/dist/frontend/assets/berry-uYugtg8r.js +1 -0
- package/dist/frontend/assets/bibtex-CHM0blh-.js +1 -0
- package/dist/frontend/assets/bicep-Bmn6On1c.js +1 -0
- package/dist/frontend/assets/bird2-BIv1doCn.js +1 -0
- package/dist/frontend/assets/blade-BjGOyj-B.js +1 -0
- package/dist/frontend/assets/bsl-BO_Y6i37.js +1 -0
- package/dist/frontend/assets/c-BIGW1oBm.js +1 -0
- package/dist/frontend/assets/c3-eo99z4R2.js +1 -0
- package/dist/frontend/assets/cadence-Bv_4Rxtq.js +1 -0
- package/dist/frontend/assets/cairo-KRGpt6FW.js +1 -0
- package/dist/frontend/assets/catppuccin-frappe-DFWUc33u.js +1 -0
- package/dist/frontend/assets/catppuccin-latte-C9dUb6Cb.js +1 -0
- package/dist/frontend/assets/catppuccin-macchiato-DQyhUUbL.js +1 -0
- package/dist/frontend/assets/catppuccin-mocha-D87Tk5Gz.js +1 -0
- package/dist/frontend/assets/clarity-D53aC0YG.js +1 -0
- package/dist/frontend/assets/clojure-P80f7IUj.js +1 -0
- package/dist/frontend/assets/cmake-D1j8_8rp.js +1 -0
- package/dist/frontend/assets/cobol-nBiQ_Alo.js +1 -0
- package/dist/frontend/assets/codeowners-Bp6g37R7.js +1 -0
- package/dist/frontend/assets/codeql-DsOJ9woJ.js +1 -0
- package/dist/frontend/assets/coffee-Ch7k5sss.js +1 -0
- package/dist/frontend/assets/common-lisp-Cg-RD9OK.js +1 -0
- package/dist/frontend/assets/coq-DkFqJrB1.js +1 -0
- package/dist/frontend/assets/cpp-CofmeUqb.js +1 -0
- package/dist/frontend/assets/crystal-DNxU26gB.js +1 -0
- package/dist/frontend/assets/csharp-COcwbKMJ.js +1 -0
- package/dist/frontend/assets/css-CLj8gQPS.js +1 -0
- package/dist/frontend/assets/csv-fuZLfV_i.js +1 -0
- package/dist/frontend/assets/cue-D82EKSYY.js +1 -0
- package/dist/frontend/assets/cypher-COkxafJQ.js +1 -0
- package/dist/frontend/assets/d-85-TOEBH.js +1 -0
- package/dist/frontend/assets/dark-plus-C3mMm8J8.js +1 -0
- package/dist/frontend/assets/dart-bE4Kk8sk.js +1 -0
- package/dist/frontend/assets/dax-CEL-wOlO.js +1 -0
- package/dist/frontend/assets/desktop-BmXAJ9_W.js +1 -0
- package/dist/frontend/assets/diff-D97Zzqfu.js +1 -0
- package/dist/frontend/assets/docker-BcOcwvcX.js +1 -0
- package/dist/frontend/assets/dotenv-Da5cRb03.js +1 -0
- package/dist/frontend/assets/dracula-BzJJZx-M.js +1 -0
- package/dist/frontend/assets/dracula-soft-BXkSAIEj.js +1 -0
- package/dist/frontend/assets/dream-maker-BtqSS_iP.js +1 -0
- package/dist/frontend/assets/edge-FbVlp4U3.js +1 -0
- package/dist/frontend/assets/elixir-CkH2-t6x.js +1 -0
- package/dist/frontend/assets/elm-DbKCFpqz.js +1 -0
- package/dist/frontend/assets/emacs-lisp-CXvaQtF9.js +1 -0
- package/dist/frontend/assets/erb-BYCe7drp.js +1 -0
- package/dist/frontend/assets/erlang-DsQrWhSR.js +1 -0
- package/dist/frontend/assets/everforest-dark-BgDCqdQA.js +1 -0
- package/dist/frontend/assets/everforest-light-C8M2exoo.js +1 -0
- package/dist/frontend/assets/fennel-BYunw83y.js +1 -0
- package/dist/frontend/assets/fish-BvzEVeQv.js +1 -0
- package/dist/frontend/assets/fluent-C4IJs8-o.js +1 -0
- package/dist/frontend/assets/fortran-fixed-form-CkoXwp7k.js +1 -0
- package/dist/frontend/assets/fortran-free-form-BxgE0vQu.js +1 -0
- package/dist/frontend/assets/fsharp-CXgrBDvD.js +1 -0
- package/dist/frontend/assets/gdresource-BOOCDP_w.js +1 -0
- package/dist/frontend/assets/gdscript-C5YyOfLZ.js +1 -0
- package/dist/frontend/assets/gdshader-DkwncUOv.js +1 -0
- package/dist/frontend/assets/genie-D0YGMca9.js +1 -0
- package/dist/frontend/assets/gherkin-DyxjwDmM.js +1 -0
- package/dist/frontend/assets/git-commit-F4YmCXRG.js +1 -0
- package/dist/frontend/assets/git-rebase-r7XF79zn.js +1 -0
- package/dist/frontend/assets/github-dark-DHJKELXO.js +1 -0
- package/dist/frontend/assets/github-dark-default-Cuk6v7N8.js +1 -0
- package/dist/frontend/assets/github-dark-dimmed-DH5Ifo-i.js +1 -0
- package/dist/frontend/assets/github-dark-high-contrast-E3gJ1_iC.js +1 -0
- package/dist/frontend/assets/github-light-DAi9KRSo.js +1 -0
- package/dist/frontend/assets/github-light-default-D7oLnXFd.js +1 -0
- package/dist/frontend/assets/github-light-high-contrast-BfjtVDDH.js +1 -0
- package/dist/frontend/assets/gleam-BspZqrRM.js +1 -0
- package/dist/frontend/assets/glimmer-js-ByusRIyA.js +1 -0
- package/dist/frontend/assets/glimmer-ts-BfAWNZQY.js +1 -0
- package/dist/frontend/assets/glsl-DplSGwfg.js +1 -0
- package/dist/frontend/assets/gn-n2N0HUVH.js +1 -0
- package/dist/frontend/assets/gnuplot-DdkO51Og.js +1 -0
- package/dist/frontend/assets/go-C27-OAKa.js +1 -0
- package/dist/frontend/assets/graphql-ChdNCCLP.js +1 -0
- package/dist/frontend/assets/groovy-gcz8RCvz.js +1 -0
- package/dist/frontend/assets/gruvbox-dark-hard-CFHQjOhq.js +1 -0
- package/dist/frontend/assets/gruvbox-dark-medium-GsRaNv29.js +1 -0
- package/dist/frontend/assets/gruvbox-dark-soft-CVdnzihN.js +1 -0
- package/dist/frontend/assets/gruvbox-light-hard-CH1njM8p.js +1 -0
- package/dist/frontend/assets/gruvbox-light-medium-DRw_LuNl.js +1 -0
- package/dist/frontend/assets/gruvbox-light-soft-hJgmCMqR.js +1 -0
- package/dist/frontend/assets/hack-i7_Ulhet.js +1 -0
- package/dist/frontend/assets/haml-D5jkg6IW.js +1 -0
- package/dist/frontend/assets/handlebars-BpdQsYii.js +1 -0
- package/dist/frontend/assets/haskell-Df6bDoY_.js +1 -0
- package/dist/frontend/assets/haxe-CzTSHFRz.js +1 -0
- package/dist/frontend/assets/hcl-BWvSN4gD.js +1 -0
- package/dist/frontend/assets/hjson-D5-asLiD.js +1 -0
- package/dist/frontend/assets/hlsl-D3lLCCz7.js +1 -0
- package/dist/frontend/assets/horizon-BUw7H-hv.js +1 -0
- package/dist/frontend/assets/horizon-bright-CUuTKBJd.js +1 -0
- package/dist/frontend/assets/houston-DnULxvSX.js +1 -0
- package/dist/frontend/assets/html-derivative-DlHx6ybY.js +1 -0
- package/dist/frontend/assets/html-pp8916En.js +1 -0
- package/dist/frontend/assets/http-jrhK8wxY.js +1 -0
- package/dist/frontend/assets/hurl-irOxFIW8.js +1 -0
- package/dist/frontend/assets/hxml-Bvhsp5Yf.js +1 -0
- package/dist/frontend/assets/hy-DFXneXwc.js +1 -0
- package/dist/frontend/assets/imba-DGztddWO.js +1 -0
- package/dist/frontend/assets/ini-BEwlwnbL.js +1 -0
- package/dist/frontend/assets/java-CylS5w8V.js +1 -0
- package/dist/frontend/assets/javascript-wDzz0qaB.js +1 -0
- package/dist/frontend/assets/jinja-f2NsQr07.js +1 -0
- package/dist/frontend/assets/jison-wvAkD_A8.js +1 -0
- package/dist/frontend/assets/json-Cp-IABpG.js +1 -0
- package/dist/frontend/assets/json5-C9tS-k6U.js +1 -0
- package/dist/frontend/assets/jsonc-Des-eS-w.js +1 -0
- package/dist/frontend/assets/jsonl-DcaNXYhu.js +1 -0
- package/dist/frontend/assets/jsonnet-DFQXde-d.js +1 -0
- package/dist/frontend/assets/jssm-C2t-YnRu.js +1 -0
- package/dist/frontend/assets/jsx-g9-lgVsj.js +1 -0
- package/dist/frontend/assets/julia-CxzCAyBv.js +1 -0
- package/dist/frontend/assets/just-VxiPbLrw.js +1 -0
- package/dist/frontend/assets/kanagawa-dragon-CkXjmgJE.js +1 -0
- package/dist/frontend/assets/kanagawa-lotus-CfQXZHmo.js +1 -0
- package/dist/frontend/assets/kanagawa-wave-DWedfzmr.js +1 -0
- package/dist/frontend/assets/kdl-DV7GczEv.js +1 -0
- package/dist/frontend/assets/kotlin-BdnUsdx6.js +1 -0
- package/dist/frontend/assets/kusto-wEQ09or8.js +1 -0
- package/dist/frontend/assets/laserwave-DUszq2jm.js +1 -0
- package/dist/frontend/assets/latex-CWtU0Tv5.js +1 -0
- package/dist/frontend/assets/lean-BZvkOJ9d.js +1 -0
- package/dist/frontend/assets/less-B1dDrJ26.js +1 -0
- package/dist/frontend/assets/light-plus-B7mTdjB0.js +1 -0
- package/dist/frontend/assets/liquid-C0sCDyMI.js +1 -0
- package/dist/frontend/assets/llvm-DjAJT7YJ.js +1 -0
- package/dist/frontend/assets/log-2UxHyX5q.js +1 -0
- package/dist/frontend/assets/logo-BtOb2qkB.js +1 -0
- package/dist/frontend/assets/lua-BaeVxFsk.js +1 -0
- package/dist/frontend/assets/luau-C-HG3fhB.js +1 -0
- package/dist/frontend/assets/main-CL5_Wlhv.css +32 -0
- package/dist/frontend/assets/main-Czet4Z1x.js +371 -0
- package/dist/frontend/assets/make-CHLpvVh8.js +1 -0
- package/dist/frontend/assets/markdown-Cvjx9yec.js +1 -0
- package/dist/frontend/assets/marko-DjSrsDqO.js +1 -0
- package/dist/frontend/assets/material-theme-D5KoaKCx.js +1 -0
- package/dist/frontend/assets/material-theme-darker-BfHTSMKl.js +1 -0
- package/dist/frontend/assets/material-theme-lighter-B0m2ddpp.js +1 -0
- package/dist/frontend/assets/material-theme-ocean-CyktbL80.js +1 -0
- package/dist/frontend/assets/material-theme-palenight-Csfq5Kiy.js +1 -0
- package/dist/frontend/assets/matlab-D7o27uSR.js +1 -0
- package/dist/frontend/assets/mdc-DTYItulj.js +1 -0
- package/dist/frontend/assets/mdx-Cmh6b_Ma.js +1 -0
- package/dist/frontend/assets/mermaid-mWjccvbQ.js +1 -0
- package/dist/frontend/assets/min-dark-CafNBF8u.js +1 -0
- package/dist/frontend/assets/min-light-CTRr51gU.js +1 -0
- package/dist/frontend/assets/mipsasm-CKIfxQSi.js +1 -0
- package/dist/frontend/assets/mojo-rZm6bMo-.js +1 -0
- package/dist/frontend/assets/monokai-D4h5O-jR.js +1 -0
- package/dist/frontend/assets/moonbit-_H4v1dQx.js +1 -0
- package/dist/frontend/assets/move-IF9eRakj.js +1 -0
- package/dist/frontend/assets/narrat-DRg8JJMk.js +1 -0
- package/dist/frontend/assets/nextflow-C-mBbutL.js +1 -0
- package/dist/frontend/assets/nextflow-groovy-vE_lwT2v.js +1 -0
- package/dist/frontend/assets/nginx-BpAMiNFr.js +1 -0
- package/dist/frontend/assets/night-owl-C39BiMTA.js +1 -0
- package/dist/frontend/assets/night-owl-light-CMTm3GFP.js +1 -0
- package/dist/frontend/assets/nim-BIad80T-.js +1 -0
- package/dist/frontend/assets/nix-CwoSXNpI.js +1 -0
- package/dist/frontend/assets/nord-Ddv68eIx.js +1 -0
- package/dist/frontend/assets/nushell-Cz2AlsmD.js +1 -0
- package/dist/frontend/assets/objective-c-DXmwc3jG.js +1 -0
- package/dist/frontend/assets/objective-cpp-CLxacb5B.js +1 -0
- package/dist/frontend/assets/ocaml-C0hk2d4L.js +1 -0
- package/dist/frontend/assets/odin-BBf5iR-q.js +1 -0
- package/dist/frontend/assets/one-dark-pro-DVMEJ2y_.js +1 -0
- package/dist/frontend/assets/one-light-C3Wv6jpd.js +1 -0
- package/dist/frontend/assets/openscad-C4EeE6gA.js +1 -0
- package/dist/frontend/assets/pascal-D93ZcfNL.js +1 -0
- package/dist/frontend/assets/perl-NvoQZIq0.js +1 -0
- package/dist/frontend/assets/php-R6g_5hLQ.js +1 -0
- package/dist/frontend/assets/pkl-u5AG7uiY.js +1 -0
- package/dist/frontend/assets/plastic-3e1v2bzS.js +1 -0
- package/dist/frontend/assets/plsql-ChMvpjG-.js +1 -0
- package/dist/frontend/assets/po-BTJTHyun.js +1 -0
- package/dist/frontend/assets/poimandres-CS3Unz2-.js +1 -0
- package/dist/frontend/assets/polar-C0HS_06l.js +1 -0
- package/dist/frontend/assets/postcss-CXtECtnM.js +1 -0
- package/dist/frontend/assets/powerquery-CEu0bR-o.js +1 -0
- package/dist/frontend/assets/powershell-Dpen1YoG.js +1 -0
- package/dist/frontend/assets/prisma-Dd19v3D-.js +1 -0
- package/dist/frontend/assets/prolog-CbFg5uaA.js +1 -0
- package/dist/frontend/assets/proto-C7zT0LnQ.js +1 -0
- package/dist/frontend/assets/pug-DKIMFp6K.js +1 -0
- package/dist/frontend/assets/puppet-BMWR74SV.js +1 -0
- package/dist/frontend/assets/purescript-CklMAg4u.js +1 -0
- package/dist/frontend/assets/python-B6aJPvgy.js +1 -0
- package/dist/frontend/assets/qml-3beO22l8.js +1 -0
- package/dist/frontend/assets/qmldir-C8lEn-DE.js +1 -0
- package/dist/frontend/assets/qss-IeuSbFQv.js +1 -0
- package/dist/frontend/assets/r-Dspwwk_N.js +1 -0
- package/dist/frontend/assets/racket-BqYA7rlc.js +1 -0
- package/dist/frontend/assets/raku-DXvB9xmW.js +1 -0
- package/dist/frontend/assets/razor-BDqjjVU7.js +1 -0
- package/dist/frontend/assets/red-bN70gL4F.js +1 -0
- package/dist/frontend/assets/reg-C-SQnVFl.js +1 -0
- package/dist/frontend/assets/regexp-CDVJQ6XC.js +1 -0
- package/dist/frontend/assets/rel-C3B-1QV4.js +1 -0
- package/dist/frontend/assets/riscv-BM1_JUlF.js +1 -0
- package/dist/frontend/assets/ron-D8l8udqQ.js +1 -0
- package/dist/frontend/assets/rose-pine-dawn-DHQR4-dF.js +1 -0
- package/dist/frontend/assets/rose-pine-moon-D4_iv3hh.js +1 -0
- package/dist/frontend/assets/rose-pine-qdsjHGoJ.js +1 -0
- package/dist/frontend/assets/rosmsg-BJDFO7_C.js +1 -0
- package/dist/frontend/assets/rst-CRjBmOyv.js +1 -0
- package/dist/frontend/assets/ruby-Wjq7vjNf.js +1 -0
- package/dist/frontend/assets/rust-B1yitclQ.js +1 -0
- package/dist/frontend/assets/sas-cz2c8ADy.js +1 -0
- package/dist/frontend/assets/sass-Cj5Yp3dK.js +1 -0
- package/dist/frontend/assets/scala-C151Ov-r.js +1 -0
- package/dist/frontend/assets/scheme-C98Dy4si.js +1 -0
- package/dist/frontend/assets/scss-D5BDwBP9.js +1 -0
- package/dist/frontend/assets/sdbl-DVxCFoDh.js +1 -0
- package/dist/frontend/assets/shaderlab-Dg9Lc6iA.js +1 -0
- package/dist/frontend/assets/shellscript-Yzrsuije.js +1 -0
- package/dist/frontend/assets/shellsession-BADoaaVG.js +1 -0
- package/dist/frontend/assets/slack-dark-BthQWCQV.js +1 -0
- package/dist/frontend/assets/slack-ochin-DqwNpetd.js +1 -0
- package/dist/frontend/assets/smalltalk-BERRCDM3.js +1 -0
- package/dist/frontend/assets/snazzy-light-Bw305WKR.js +1 -0
- package/dist/frontend/assets/solarized-dark-DXbdFlpD.js +1 -0
- package/dist/frontend/assets/solarized-light-L9t79GZl.js +1 -0
- package/dist/frontend/assets/solidity-rGO070M0.js +1 -0
- package/dist/frontend/assets/soy-8wufbnw4.js +1 -0
- package/dist/frontend/assets/sparql-rVzFXLq3.js +1 -0
- package/dist/frontend/assets/splunk-BtCnVYZw.js +1 -0
- package/dist/frontend/assets/sql-BLtJtn59.js +1 -0
- package/dist/frontend/assets/ssh-config-_ykCGR6B.js +1 -0
- package/dist/frontend/assets/stata-BH5u7GGu.js +1 -0
- package/dist/frontend/assets/stylus-BEDo0Tqx.js +1 -0
- package/dist/frontend/assets/surrealql-Bq5Q-fJD.js +1 -0
- package/dist/frontend/assets/svelte-Cy7k_4gC.js +1 -0
- package/dist/frontend/assets/swift-D82vCrfD.js +1 -0
- package/dist/frontend/assets/synthwave-84-CbfX1IO0.js +1 -0
- package/dist/frontend/assets/system-verilog-CnnmHF94.js +1 -0
- package/dist/frontend/assets/systemd-4A_iFExJ.js +1 -0
- package/dist/frontend/assets/talonscript-CkByrt1z.js +1 -0
- package/dist/frontend/assets/tasl-QIJgUcNo.js +1 -0
- package/dist/frontend/assets/tcl-dwOrl1Do.js +1 -0
- package/dist/frontend/assets/templ-DhtptRzy.js +1 -0
- package/dist/frontend/assets/terraform-BETggiCN.js +1 -0
- package/dist/frontend/assets/tex-idrVyKtj.js +1 -0
- package/dist/frontend/assets/tokyo-night-hegEt444.js +1 -0
- package/dist/frontend/assets/toml-vGWfd6FD.js +1 -0
- package/dist/frontend/assets/ts-tags-DQrlYJgV.js +1 -0
- package/dist/frontend/assets/tsv-B_m7g4N7.js +1 -0
- package/dist/frontend/assets/tsx-COt5Ahok.js +1 -0
- package/dist/frontend/assets/turtle-BsS91CYL.js +1 -0
- package/dist/frontend/assets/twig-xg9kU7Mw.js +1 -0
- package/dist/frontend/assets/typescript-BPQ3VLAy.js +1 -0
- package/dist/frontend/assets/typespec-CAFt9gP4.js +1 -0
- package/dist/frontend/assets/typst-DHCkPAjA.js +1 -0
- package/dist/frontend/assets/v-BcVCzyr7.js +1 -0
- package/dist/frontend/assets/vala-CsfeWuGM.js +1 -0
- package/dist/frontend/assets/vb-D17OF-Vu.js +1 -0
- package/dist/frontend/assets/verilog-BQ8w6xss.js +1 -0
- package/dist/frontend/assets/vesper-DU1UobuO.js +1 -0
- package/dist/frontend/assets/vhdl-CeAyd5Ju.js +1 -0
- package/dist/frontend/assets/viml-CJc9bBzg.js +1 -0
- package/dist/frontend/assets/vitesse-black-Bkuqu6BP.js +1 -0
- package/dist/frontend/assets/vitesse-dark-D0r3Knsf.js +1 -0
- package/dist/frontend/assets/vitesse-light-CVO1_9PV.js +1 -0
- package/dist/frontend/assets/vue-D2xRrEX4.js +1 -0
- package/dist/frontend/assets/vue-html-AaS7Mt5G.js +1 -0
- package/dist/frontend/assets/vue-vine-BoDAl6tE.js +1 -0
- package/dist/frontend/assets/vyper-CDx5xZoG.js +1 -0
- package/dist/frontend/assets/wasm-CG6Dc4jp.js +1 -0
- package/dist/frontend/assets/wasm-MzD3tlZU.js +1 -0
- package/dist/frontend/assets/wenyan-BV7otONQ.js +1 -0
- package/dist/frontend/assets/wgsl-Dx-B1_4e.js +1 -0
- package/dist/frontend/assets/wikitext-BhOHFoWU.js +1 -0
- package/dist/frontend/assets/wit-5i3qLPDT.js +1 -0
- package/dist/frontend/assets/wolfram-lXgVvXCa.js +1 -0
- package/dist/frontend/assets/xml-sdJ4AIDG.js +1 -0
- package/dist/frontend/assets/xsl-CtQFsRM5.js +1 -0
- package/dist/frontend/assets/yaml-Buea-lGh.js +1 -0
- package/dist/frontend/assets/zenscript-DVFEvuxE.js +1 -0
- package/dist/frontend/assets/zig-VOosw3JB.js +1 -0
- package/dist/frontend/icon-192.png +0 -0
- package/dist/frontend/icon-512.png +0 -0
- package/dist/frontend/icon.svg +8 -0
- package/dist/frontend/index.html +30 -0
- package/dist/frontend/manifest.json +25 -0
- package/dist/frontend/sw.js +66 -0
- package/dist/server/agent-events.js +39 -0
- package/dist/server/analytics.js +885 -0
- package/dist/server/auth.js +65 -0
- package/dist/server/belayer/executor.js +200 -0
- package/dist/server/belayer/intake.js +27 -0
- package/dist/server/belayer/pipeline.js +97 -0
- package/dist/server/belayer/pr-lifecycle.js +69 -0
- package/dist/server/belayer/prompts.js +154 -0
- package/dist/server/belayer/types.js +23 -0
- package/dist/server/branch-linker.js +137 -0
- package/dist/server/browser-content.js +145 -0
- package/dist/server/clipboard.js +63 -0
- package/dist/server/codex-hooks-adapter.js +93 -0
- package/dist/server/config.js +325 -0
- package/dist/server/gh-routes.js +163 -0
- package/dist/server/gh.js +276 -0
- package/dist/server/git-routes.js +154 -0
- package/dist/server/git.js +694 -0
- package/dist/server/github-app.js +218 -0
- package/dist/server/github-graphql.js +178 -0
- package/dist/server/hooks.js +373 -0
- package/dist/server/index.js +1549 -0
- package/dist/server/integration-github.js +137 -0
- package/dist/server/integration-jira.js +210 -0
- package/dist/server/integration-linear.js +176 -0
- package/dist/server/logger.js +18 -0
- package/dist/server/mobile-input-pipeline.js +129 -0
- package/dist/server/opencode-relay.js +53 -0
- package/dist/server/org-dashboard.js +241 -0
- package/dist/server/output-parsers/claude-parser.js +56 -0
- package/dist/server/output-parsers/codex-parser.js +13 -0
- package/dist/server/output-parsers/index.js +14 -0
- package/dist/server/output-parsers/null-parser.js +12 -0
- package/dist/server/output-parsers/opencode-parser.js +77 -0
- package/dist/server/pty-handler.js +586 -0
- package/dist/server/push.js +84 -0
- package/dist/server/review-poller.js +237 -0
- package/dist/server/sdk-handler.js +539 -0
- package/dist/server/service.js +189 -0
- package/dist/server/sessions.js +638 -0
- package/dist/server/telemetry.js +236 -0
- package/dist/server/ticket-transitions.js +166 -0
- package/dist/server/types.js +146 -0
- package/dist/server/utils.js +23 -0
- package/dist/server/watcher.js +661 -0
- package/dist/server/webhook-manager.js +547 -0
- package/dist/server/webhooks.js +73 -0
- package/dist/server/workspace-groups.js +363 -0
- package/dist/server/workspaces.js +1207 -0
- package/dist/server/ws.js +192 -0
- package/dist/test/EmptyState.spec.js +51 -0
- package/dist/test/action-coverage.test.js +139 -0
- package/dist/test/actions/registry.test.js +59 -0
- package/dist/test/actions/shortcuts.test.js +79 -0
- package/dist/test/agent-events.test.js +151 -0
- package/dist/test/analytics.test.js +158 -0
- package/dist/test/attention.test.js +91 -0
- package/dist/test/auth.test.js +105 -0
- package/dist/test/backend-state.test.js +47 -0
- package/dist/test/belayer-executor.test.js +33 -0
- package/dist/test/belayer-intake.test.js +44 -0
- package/dist/test/belayer-pipeline.test.js +113 -0
- package/dist/test/belayer-pr-lifecycle.test.js +26 -0
- package/dist/test/belayer-prompts.test.js +60 -0
- package/dist/test/belayer-types.test.js +69 -0
- package/dist/test/bin/claude-remote-cli.js +214 -0
- package/dist/test/boot-state.test.js +133 -0
- package/dist/test/branch-lifecycle.test.js +75 -0
- package/dist/test/branch-linker.test.js +236 -0
- package/dist/test/branch-rename.test.js +45 -0
- package/dist/test/branch-watcher.test.js +115 -0
- package/dist/test/browser-cli.test.js +91 -0
- package/dist/test/browser-content.test.js +93 -0
- package/dist/test/browser-tabs-ui.test.js +39 -0
- package/dist/test/changed-files-api.test.js +140 -0
- package/dist/test/clipboard.test.js +12 -0
- package/dist/test/codex-hooks-adapter.test.js +237 -0
- package/dist/test/components/EmptyState.spec.js +51 -0
- package/dist/test/components/ErrorToast.spec.js +65 -0
- package/dist/test/components/TuiCheckbox.spec.js +120 -0
- package/dist/test/components/TuiInput.spec.js +186 -0
- package/dist/test/components/leaf-component-migration.spec.js +104 -0
- package/dist/test/config-freshness.test.js +63 -0
- package/dist/test/config.test.js +813 -0
- package/dist/test/diff-summary.test.js +98 -0
- package/dist/test/display-state.test.js +179 -0
- package/dist/test/event-message-types.test.js +32 -0
- package/dist/test/file-tree-utils.test.js +167 -0
- package/dist/test/framework-types.test.js +183 -0
- package/dist/test/frameworks-api.test.js +93 -0
- package/dist/test/frontend/src/lib/pr-state.js +114 -0
- package/dist/test/fs-browse.test.js +246 -0
- package/dist/test/fuzzy-scorer.test.js +145 -0
- package/dist/test/gh-routes.test.js +156 -0
- package/dist/test/git-changed-files.test.js +152 -0
- package/dist/test/git-routes.test.js +146 -0
- package/dist/test/git-utils.test.js +68 -0
- package/dist/test/git-watcher.test.js +110 -0
- package/dist/test/git.test.js +140 -0
- package/dist/test/github-app.test.js +455 -0
- package/dist/test/github-graphql.test.js +301 -0
- package/dist/test/greetings.test.js +83 -0
- package/dist/test/hooks-agent-event.test.js +412 -0
- package/dist/test/hooks.test.js +149 -0
- package/dist/test/integration-github.test.js +220 -0
- package/dist/test/integration-jira.test.js +238 -0
- package/dist/test/integration-linear.test.js +293 -0
- package/dist/test/mobile-input.test.js +235 -0
- package/dist/test/opencode-relay.test.js +107 -0
- package/dist/test/org-dashboard.test.js +349 -0
- package/dist/test/output-parser.test.js +217 -0
- package/dist/test/paths.test.js +32 -0
- package/dist/test/pr-state.test.js +407 -0
- package/dist/test/pr-status.test.js +82 -0
- package/dist/test/presets.test.js +242 -0
- package/dist/test/pty-handler-multi-agent.test.js +149 -0
- package/dist/test/pty-handler.test.js +146 -0
- package/dist/test/pull-requests.test.js +78 -0
- package/dist/test/review-poller.test.js +349 -0
- package/dist/test/server/analytics.js +121 -0
- package/dist/test/server/auth.js +63 -0
- package/dist/test/server/branch-linker.js +124 -0
- package/dist/test/server/clipboard.js +56 -0
- package/dist/test/server/config.js +137 -0
- package/dist/test/server/git.js +308 -0
- package/dist/test/server/hooks.js +196 -0
- package/dist/test/server/index.js +1124 -0
- package/dist/test/server/integration-github.js +117 -0
- package/dist/test/server/integration-jira.js +164 -0
- package/dist/test/server/integration-linear.js +176 -0
- package/dist/test/server/mobile-input-pipeline.js +123 -0
- package/dist/test/server/org-dashboard.js +184 -0
- package/dist/test/server/output-parsers/claude-parser.js +54 -0
- package/dist/test/server/output-parsers/codex-parser.js +13 -0
- package/dist/test/server/output-parsers/index.js +7 -0
- package/dist/test/server/pty-handler.js +310 -0
- package/dist/test/server/push.js +80 -0
- package/dist/test/server/review-poller.js +218 -0
- package/dist/test/server/service.js +169 -0
- package/dist/test/server/sessions.js +434 -0
- package/dist/test/server/ticket-transitions.js +216 -0
- package/dist/test/server/types.js +20 -0
- package/dist/test/server/utils.js +22 -0
- package/dist/test/server/watcher.js +139 -0
- package/dist/test/server/workspaces.js +657 -0
- package/dist/test/server/ws.js +152 -0
- package/dist/test/server-startup.test.js +62 -0
- package/dist/test/service.test.js +43 -0
- package/dist/test/session-analytics-api.test.js +123 -0
- package/dist/test/session-analytics.test.js +425 -0
- package/dist/test/session-intent.test.js +249 -0
- package/dist/test/sessions.test.js +1152 -0
- package/dist/test/sidebar-items.test.js +164 -0
- package/dist/test/stores/boot-state-store.test.js +165 -0
- package/dist/test/stores/sessions-logic.test.js +191 -0
- package/dist/test/stores/toasts-store.test.js +66 -0
- package/dist/test/stores/ui-store.test.js +203 -0
- package/dist/test/stores/unread-store.test.js +97 -0
- package/dist/test/telemetry-api.test.js +54 -0
- package/dist/test/telemetry-sync.test.js +68 -0
- package/dist/test/telemetry.test.js +295 -0
- package/dist/test/terminal-zoom.test.js +102 -0
- package/dist/test/test/analytics.test.js +152 -0
- package/dist/test/test/auth.test.js +95 -0
- package/dist/test/test/branch-linker.test.js +231 -0
- package/dist/test/test/branch-rename.test.js +45 -0
- package/dist/test/test/clipboard.test.js +12 -0
- package/dist/test/test/config.test.js +281 -0
- package/dist/test/test/fs-browse.test.js +202 -0
- package/dist/test/test/git.test.js +67 -0
- package/dist/test/test/hooks.test.js +139 -0
- package/dist/test/test/integration-github.test.js +203 -0
- package/dist/test/test/integration-jira.test.js +294 -0
- package/dist/test/test/integration-linear.test.js +293 -0
- package/dist/test/test/mobile-input.test.js +193 -0
- package/dist/test/test/org-dashboard.test.js +240 -0
- package/dist/test/test/output-parser.test.js +95 -0
- package/dist/test/test/paths.test.js +32 -0
- package/dist/test/test/pr-state.test.js +220 -0
- package/dist/test/test/pull-requests.test.js +67 -0
- package/dist/test/test/review-poller.test.js +235 -0
- package/dist/test/test/service.test.js +43 -0
- package/dist/test/test/sessions.test.js +750 -0
- package/dist/test/test/ticket-transitions.test.js +130 -0
- package/dist/test/test/version.test.js +34 -0
- package/dist/test/test/worktrees.test.js +256 -0
- package/dist/test/ticket-transitions.test.js +312 -0
- package/dist/test/unread.test.js +23 -0
- package/dist/test/version.test.js +34 -0
- package/dist/test/webhook-manager.test.js +484 -0
- package/dist/test/webhooks.test.js +208 -0
- package/dist/test/workspace-groups.test.js +377 -0
- package/dist/test/worktrees.test.js +531 -0
- package/package.json +88 -0
|
@@ -0,0 +1,1549 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import readline from 'node:readline';
|
|
7
|
+
import { execFile } from 'node:child_process';
|
|
8
|
+
import { promisify } from 'node:util';
|
|
9
|
+
import express from 'express';
|
|
10
|
+
import cookieParser from 'cookie-parser';
|
|
11
|
+
import { loadConfig, saveConfig, DEFAULTS, readMeta, writeMeta, deleteMeta, ensureMetaDir, getConfigDir, resolveSessionSettings, } from './config.js';
|
|
12
|
+
import * as auth from './auth.js';
|
|
13
|
+
import * as sessions from './sessions.js';
|
|
14
|
+
import { AGENT_CONTINUE_ARGS, AGENT_YOLO_ARGS, serializeAll, restoreFromDisk, activeTmuxSessionNames, populateMetaCache, } from './sessions.js';
|
|
15
|
+
import { getTmuxPrefix } from './pty-handler.js';
|
|
16
|
+
import { setupWebSocket } from './ws.js';
|
|
17
|
+
import { WorktreeWatcher, BranchWatcher, RefWatcher, GitWatcher, parseAllWorktrees, } from './watcher.js';
|
|
18
|
+
import { isInstalled as serviceIsInstalled } from './service.js';
|
|
19
|
+
import { extensionForMime, setClipboardImage } from './clipboard.js';
|
|
20
|
+
import { createGitRouter } from './git-routes.js';
|
|
21
|
+
import { createGhRouter } from './gh-routes.js';
|
|
22
|
+
import * as push from './push.js';
|
|
23
|
+
import { initAnalytics, closeAnalytics, createAnalyticsRouter, createSessionAnalyticsRouter, flushEventBuffer, computeEngagementMetrics, upsertSessionRollup, getSessionRollup, startEventBatching, stopEventBatching, runRetentionCleanup, recoverOrphanedSessions, recordRateLimitSnapshot, } from './analytics.js';
|
|
24
|
+
import { createWorkspaceRouter, clearPrCache, clearFilesListCache, } from './workspaces.js';
|
|
25
|
+
import { createWorkspaceGroupsRouter } from './workspace-groups.js';
|
|
26
|
+
import { createOrgDashboardRouter } from './org-dashboard.js';
|
|
27
|
+
import { createIntegrationGitHubRouter } from './integration-github.js';
|
|
28
|
+
import { createBranchLinkerRouter, invalidateBranchLinkerCache, } from './branch-linker.js';
|
|
29
|
+
import { createHooksRouter } from './hooks.js';
|
|
30
|
+
import { createTicketTransitionsRouter } from './ticket-transitions.js';
|
|
31
|
+
import { createIntegrationJiraRouter } from './integration-jira.js';
|
|
32
|
+
import { startPolling, stopPolling } from './review-poller.js';
|
|
33
|
+
import { createGitHubAppRouter } from './github-app.js';
|
|
34
|
+
import { createWebhookRouter } from './webhooks.js';
|
|
35
|
+
import { createWebhookManagerRouter, reloadSmee, startSmartPolling, } from './webhook-manager.js';
|
|
36
|
+
import { fetchPrsGraphQL } from './github-graphql.js';
|
|
37
|
+
import { createTelemetryRouter, startTelemetry, stopTelemetry, getTelemetryForSession, getAccountTelemetry, } from './telemetry.js';
|
|
38
|
+
import { BUILTIN_FRAMEWORKS } from './types.js';
|
|
39
|
+
import { semverLessThan } from './utils.js';
|
|
40
|
+
import { createBrowserContentRouter, generateScopedToken, cleanExpiredTokens, } from './browser-content.js';
|
|
41
|
+
import { createLogger } from './logger.js';
|
|
42
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
43
|
+
const __dirname = path.dirname(__filename);
|
|
44
|
+
const execFileAsync = promisify(execFile);
|
|
45
|
+
const logger = createLogger('index');
|
|
46
|
+
// When run via CLI bin, config lives in ~/.config/relay-ide/
|
|
47
|
+
// When run directly (development), fall back to local config.json
|
|
48
|
+
const CONFIG_PATH = process.env.RELAY_IDE_CONFIG ||
|
|
49
|
+
path.join(__dirname, '..', '..', 'config.json');
|
|
50
|
+
const DEFAULT_GITHUB_CLIENT_ID = 'Ov23lilheF3LelYSo0bu';
|
|
51
|
+
const VERSION_CACHE_TTL = 5 * 60 * 1000;
|
|
52
|
+
const versionCache = new Map();
|
|
53
|
+
function getCurrentVersion() {
|
|
54
|
+
const pkgPath = path.join(__dirname, '..', '..', 'package.json');
|
|
55
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
56
|
+
return pkg.version;
|
|
57
|
+
}
|
|
58
|
+
async function getLatestVersion(channel = 'stable') {
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
const cached = versionCache.get(channel);
|
|
61
|
+
if (cached && now - cached.fetchedAt < VERSION_CACHE_TTL) {
|
|
62
|
+
return cached.latest;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const tag = channel === 'nightly' ? 'nightly' : 'latest';
|
|
66
|
+
const res = await fetch(`https://registry.npmjs.org/relay-ide/${tag}`);
|
|
67
|
+
if (!res.ok)
|
|
68
|
+
return null;
|
|
69
|
+
const data = (await res.json());
|
|
70
|
+
if (!data.version)
|
|
71
|
+
return null;
|
|
72
|
+
versionCache.set(channel, { latest: data.version, fetchedAt: now });
|
|
73
|
+
return data.version;
|
|
74
|
+
}
|
|
75
|
+
catch (_) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function execErrorMessage(err, fallback) {
|
|
80
|
+
const e = err;
|
|
81
|
+
return (e.stderr || e.message || fallback).trim();
|
|
82
|
+
}
|
|
83
|
+
function scanReposInRoot(rootDir) {
|
|
84
|
+
const repos = [];
|
|
85
|
+
let entries;
|
|
86
|
+
try {
|
|
87
|
+
entries = fs.readdirSync(rootDir, { withFileTypes: true });
|
|
88
|
+
}
|
|
89
|
+
catch (_) {
|
|
90
|
+
return repos;
|
|
91
|
+
}
|
|
92
|
+
for (const entry of entries) {
|
|
93
|
+
if (!entry.isDirectory() || entry.name.startsWith('.'))
|
|
94
|
+
continue;
|
|
95
|
+
const fullPath = path.join(rootDir, entry.name);
|
|
96
|
+
const dotGit = path.join(fullPath, '.git');
|
|
97
|
+
try {
|
|
98
|
+
if (fs.statSync(dotGit).isDirectory()) {
|
|
99
|
+
repos.push({ name: entry.name, path: fullPath, root: rootDir });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch (_) {
|
|
103
|
+
// .git doesn't exist — not a repo
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return repos;
|
|
107
|
+
}
|
|
108
|
+
function scanAllRepos(rootDirs) {
|
|
109
|
+
const repos = [];
|
|
110
|
+
for (const rootDir of rootDirs) {
|
|
111
|
+
repos.push(...scanReposInRoot(rootDir));
|
|
112
|
+
}
|
|
113
|
+
return repos;
|
|
114
|
+
}
|
|
115
|
+
function parseTTL(ttl) {
|
|
116
|
+
if (typeof ttl !== 'string')
|
|
117
|
+
return 24 * 60 * 60 * 1000;
|
|
118
|
+
const match = ttl.match(/^(\d+)([smhd])$/);
|
|
119
|
+
if (!match)
|
|
120
|
+
return 24 * 60 * 60 * 1000;
|
|
121
|
+
const value = parseInt(match[1], 10);
|
|
122
|
+
switch (match[2]) {
|
|
123
|
+
case 's':
|
|
124
|
+
return value * 1000;
|
|
125
|
+
case 'm':
|
|
126
|
+
return value * 60 * 1000;
|
|
127
|
+
case 'h':
|
|
128
|
+
return value * 60 * 60 * 1000;
|
|
129
|
+
case 'd':
|
|
130
|
+
return value * 24 * 60 * 60 * 1000;
|
|
131
|
+
default:
|
|
132
|
+
return 24 * 60 * 60 * 1000;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function promptPin(question) {
|
|
136
|
+
return new Promise((resolve) => {
|
|
137
|
+
const rl = readline.createInterface({
|
|
138
|
+
input: process.stdin,
|
|
139
|
+
output: process.stdout,
|
|
140
|
+
});
|
|
141
|
+
rl.question(question, (answer) => {
|
|
142
|
+
rl.close();
|
|
143
|
+
resolve(answer.trim());
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
async function main() {
|
|
148
|
+
// Ignore SIGPIPE: node-pty can propagate pipe breaks causing unexpected session exits
|
|
149
|
+
process.on('SIGPIPE', () => { });
|
|
150
|
+
// Ignore SIGHUP: keep server alive if controlling terminal disconnects
|
|
151
|
+
process.on('SIGHUP', () => { });
|
|
152
|
+
ensureMetaDir(CONFIG_PATH);
|
|
153
|
+
// Runtime config — always reads fresh from disk.
|
|
154
|
+
// Use this for ALL config access in route handlers, pollers, and event callbacks.
|
|
155
|
+
let lastGoodConfig = null;
|
|
156
|
+
function getConfig() {
|
|
157
|
+
try {
|
|
158
|
+
const fresh = loadConfig(CONFIG_PATH);
|
|
159
|
+
lastGoodConfig = fresh;
|
|
160
|
+
return structuredClone(fresh);
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
logger.warn('[config] Failed to load config, using last good config:', err);
|
|
164
|
+
const fallback = lastGoodConfig ?? { ...DEFAULTS };
|
|
165
|
+
return structuredClone(fallback);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// Startup-only config — captured once at boot.
|
|
169
|
+
// Use ONLY for values wired into the listening socket or long-lived connections
|
|
170
|
+
// (port, host, webhookSecret, smeeUrl, githubToken, forceOutputParser).
|
|
171
|
+
let startupConfig;
|
|
172
|
+
try {
|
|
173
|
+
startupConfig = loadConfig(CONFIG_PATH);
|
|
174
|
+
}
|
|
175
|
+
catch (_) {
|
|
176
|
+
startupConfig = { ...DEFAULTS };
|
|
177
|
+
saveConfig(CONFIG_PATH, startupConfig);
|
|
178
|
+
}
|
|
179
|
+
// CLI flag overrides
|
|
180
|
+
if (process.env.RELAY_IDE_PORT)
|
|
181
|
+
startupConfig.port = parseInt(process.env.RELAY_IDE_PORT, 10);
|
|
182
|
+
if (process.env.RELAY_IDE_HOST)
|
|
183
|
+
startupConfig.host = process.env.RELAY_IDE_HOST;
|
|
184
|
+
push.ensureVapidKeys(startupConfig, CONFIG_PATH, saveConfig);
|
|
185
|
+
const configDir = getConfigDir(CONFIG_PATH);
|
|
186
|
+
fs.mkdirSync(path.join(configDir, 'telemetry'), { recursive: true });
|
|
187
|
+
try {
|
|
188
|
+
initAnalytics(configDir);
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
logger.warn('Analytics disabled: failed to initialize:', err instanceof Error ? err.message : err);
|
|
192
|
+
}
|
|
193
|
+
if (startupConfig.pinHash && auth.isLegacyHash(startupConfig.pinHash)) {
|
|
194
|
+
logger.info('Migrating legacy PIN hash to scrypt. You will need to set a new PIN.');
|
|
195
|
+
delete startupConfig.pinHash;
|
|
196
|
+
saveConfig(CONFIG_PATH, startupConfig);
|
|
197
|
+
}
|
|
198
|
+
if (process.env.NO_PIN === '1') {
|
|
199
|
+
logger.info('PIN disabled (NO_PIN=1).');
|
|
200
|
+
startupConfig.pinHash = startupConfig.pinHash || 'disabled';
|
|
201
|
+
}
|
|
202
|
+
else if (!startupConfig.pinHash) {
|
|
203
|
+
if (process.stdin.isTTY) {
|
|
204
|
+
const pin = await promptPin('Set up a PIN for relay-ide:');
|
|
205
|
+
startupConfig.pinHash = await auth.hashPin(pin);
|
|
206
|
+
saveConfig(CONFIG_PATH, startupConfig);
|
|
207
|
+
logger.info('PIN set successfully.');
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
logger.info(`No PIN configured. Open http://localhost:${startupConfig.port} to set one.`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const authenticatedTokens = new Set();
|
|
214
|
+
// Build frontend if missing (e.g. fresh clone in development)
|
|
215
|
+
const frontendDir = path.join(__dirname, '..', 'frontend');
|
|
216
|
+
if (!fs.existsSync(path.join(frontendDir, 'index.html'))) {
|
|
217
|
+
const packageRoot = path.join(__dirname, '..', '..');
|
|
218
|
+
const viteConfig = path.join(packageRoot, 'frontend', 'vite.config.ts');
|
|
219
|
+
if (fs.existsSync(viteConfig)) {
|
|
220
|
+
logger.info('Frontend not built — building now...');
|
|
221
|
+
try {
|
|
222
|
+
await execFileAsync('npx', ['vite', 'build', '--config', 'frontend/vite.config.ts'], { cwd: packageRoot });
|
|
223
|
+
logger.info('Frontend build complete.');
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
logger.error('Frontend build failed:', err instanceof Error ? err.message : err);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
logger.warn('Frontend assets missing and source not available — UI will not be served.');
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const app = express();
|
|
234
|
+
// Mount webhooks BEFORE global express.json() — unconditionally.
|
|
235
|
+
// Secret is validated at request time (returns 401 if not configured).
|
|
236
|
+
let broadcastEventDelegate = null;
|
|
237
|
+
const webhookRouter = createWebhookRouter({
|
|
238
|
+
secret: () => loadConfig(CONFIG_PATH).github?.webhookSecret,
|
|
239
|
+
broadcastEvent: (type, data) => {
|
|
240
|
+
broadcastEventDelegate?.(type, data);
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
app.use('/webhooks', webhookRouter);
|
|
244
|
+
app.use(express.json({ limit: '15mb' }));
|
|
245
|
+
app.use(cookieParser());
|
|
246
|
+
app.use(express.static(frontendDir));
|
|
247
|
+
const requireAuth = (req, res, next) => {
|
|
248
|
+
const token = req.cookies && req.cookies.token;
|
|
249
|
+
if (!token || !authenticatedTokens.has(token)) {
|
|
250
|
+
res.status(401).json({ error: 'Unauthorized' });
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
next();
|
|
254
|
+
};
|
|
255
|
+
const webhookManagerRouter = createWebhookManagerRouter({
|
|
256
|
+
configPath: CONFIG_PATH,
|
|
257
|
+
broadcastEvent: (type, data) => {
|
|
258
|
+
broadcastEventDelegate?.(type, data);
|
|
259
|
+
},
|
|
260
|
+
requireAuth,
|
|
261
|
+
});
|
|
262
|
+
app.use('/webhooks/manage', webhookManagerRouter);
|
|
263
|
+
function boolConfigEndpoints(name, defaultValue, onEnable) {
|
|
264
|
+
app.get(`/config/${name}`, requireAuth, (_req, res) => {
|
|
265
|
+
res.json({
|
|
266
|
+
[name]: getConfig()[name] ??
|
|
267
|
+
defaultValue,
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
app.patch(`/config/${name}`, requireAuth, async (req, res) => {
|
|
271
|
+
const value = req.body[name];
|
|
272
|
+
if (typeof value !== 'boolean') {
|
|
273
|
+
res.status(400).json({ error: `${name} must be a boolean` });
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (value && onEnable) {
|
|
277
|
+
try {
|
|
278
|
+
await onEnable();
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
res.status(400).json({ error: `Validation failed for ${name}` });
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const c = getConfig();
|
|
286
|
+
c[name] = value;
|
|
287
|
+
saveConfig(CONFIG_PATH, c);
|
|
288
|
+
res.json({ [name]: value });
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
const watcher = new WorktreeWatcher();
|
|
292
|
+
watcher.rebuild(getConfig().repos || []);
|
|
293
|
+
const gitWatcher = new GitWatcher();
|
|
294
|
+
const server = http.createServer(app);
|
|
295
|
+
const { broadcastEvent, broadcastBranchChanged } = setupWebSocket(server, authenticatedTokens, watcher, CONFIG_PATH);
|
|
296
|
+
const browserScopedToken = generateScopedToken();
|
|
297
|
+
process.env['RELAY_IDE_BROWSER'] = '1';
|
|
298
|
+
process.env['RELAY_IDE_BROWSER_CMD'] = 'relay-ide browser';
|
|
299
|
+
process.env['RELAY_IDE_BROWSER_TOKEN'] = browserScopedToken;
|
|
300
|
+
if (!process.env['RELAY_IDE_PORT']) {
|
|
301
|
+
process.env['RELAY_IDE_PORT'] = String(startupConfig.port);
|
|
302
|
+
}
|
|
303
|
+
// Wire up the delegate used by the webhook router (mounted before broadcastEvent was available)
|
|
304
|
+
// Also clear the PR cache on real webhook events — these indicate actual PR state changes
|
|
305
|
+
broadcastEventDelegate = (type, data) => {
|
|
306
|
+
if (type === 'pr-updated')
|
|
307
|
+
clearPrCache();
|
|
308
|
+
broadcastEvent(type, data);
|
|
309
|
+
};
|
|
310
|
+
gitWatcher.on('files-changed', (data) => {
|
|
311
|
+
broadcastEvent('files-changed', {
|
|
312
|
+
workspacePath: data.workspacePath,
|
|
313
|
+
changedFiles: data.changedFiles,
|
|
314
|
+
});
|
|
315
|
+
clearFilesListCache(data.workspacePath);
|
|
316
|
+
});
|
|
317
|
+
// Watch .git/HEAD files for branch changes and update active sessions
|
|
318
|
+
const branchWatcher = new BranchWatcher((cwdPath, newBranch) => {
|
|
319
|
+
for (const session of sessions.list()) {
|
|
320
|
+
// Match by worktreePath or repoPath — session.cwd can drift to subdirectories
|
|
321
|
+
const groupPath = session.worktreePath ?? session.repoPath;
|
|
322
|
+
if (groupPath === cwdPath) {
|
|
323
|
+
const raw = sessions.get(session.id);
|
|
324
|
+
if (raw) {
|
|
325
|
+
raw.branchName = newBranch;
|
|
326
|
+
broadcastEvent('session-renamed', {
|
|
327
|
+
sessionId: session.id,
|
|
328
|
+
branchName: newBranch,
|
|
329
|
+
displayName: raw.displayName,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
broadcastBranchChanged(cwdPath, newBranch);
|
|
335
|
+
// Rebuild ref watchers when branches change (new upstream to watch)
|
|
336
|
+
rebuildRefWatcher();
|
|
337
|
+
});
|
|
338
|
+
branchWatcher.rebuild(getConfig().repos || []);
|
|
339
|
+
watcher.on('worktrees-changed', () => {
|
|
340
|
+
branchWatcher.rebuild(getConfig().repos || []);
|
|
341
|
+
});
|
|
342
|
+
// Watch upstream tracking refs for push/fetch and broadcast ref-changed events
|
|
343
|
+
const refWatcher = new RefWatcher((cwdPath, branch) => {
|
|
344
|
+
broadcastEvent('ref-changed', { cwdPath, branch });
|
|
345
|
+
// Clear all PR cache — cwdPath may be a worktree path that doesn't match workspace cache keys
|
|
346
|
+
clearPrCache();
|
|
347
|
+
});
|
|
348
|
+
let refWatcherRebuildPending = false;
|
|
349
|
+
let refWatcherNeedsRebuild = false;
|
|
350
|
+
function rebuildRefWatcher() {
|
|
351
|
+
if (refWatcherRebuildPending) {
|
|
352
|
+
refWatcherNeedsRebuild = true;
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
refWatcherRebuildPending = true;
|
|
356
|
+
refWatcherNeedsRebuild = false;
|
|
357
|
+
const entries = sessions
|
|
358
|
+
.list()
|
|
359
|
+
.filter((s) => s.branchName)
|
|
360
|
+
.map((s) => ({ cwdPath: s.cwd, branch: s.branchName }));
|
|
361
|
+
refWatcher.rebuild(entries).finally(() => {
|
|
362
|
+
refWatcherRebuildPending = false;
|
|
363
|
+
if (refWatcherNeedsRebuild)
|
|
364
|
+
rebuildRefWatcher();
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
rebuildRefWatcher();
|
|
368
|
+
sessions.onSessionCreate(() => rebuildRefWatcher());
|
|
369
|
+
sessions.onSessionEnd(() => rebuildRefWatcher());
|
|
370
|
+
// Configure session defaults for hooks injection (startup-only — changing these requires restart)
|
|
371
|
+
sessions.configure({
|
|
372
|
+
port: startupConfig.port,
|
|
373
|
+
forceOutputParser: startupConfig.forceOutputParser ?? false,
|
|
374
|
+
configDir,
|
|
375
|
+
});
|
|
376
|
+
// Mount hooks router BEFORE auth middleware — hook callbacks come from localhost Claude Code
|
|
377
|
+
const hooksRouter = createHooksRouter({
|
|
378
|
+
getSession: sessions.get,
|
|
379
|
+
broadcastEvent,
|
|
380
|
+
fireBackendStateIfChanged: sessions.fireBackendStateIfChanged,
|
|
381
|
+
notifySessionAttention: push.notifySessionAttention,
|
|
382
|
+
configPath: CONFIG_PATH,
|
|
383
|
+
});
|
|
384
|
+
app.use('/hooks', hooksRouter);
|
|
385
|
+
// Mount workspace router — rebuild watchers when workspaces are added or removed
|
|
386
|
+
const workspaceRouter = createWorkspaceRouter({
|
|
387
|
+
configPath: CONFIG_PATH,
|
|
388
|
+
onWorkspacesChanged: () => {
|
|
389
|
+
setImmediate(() => {
|
|
390
|
+
try {
|
|
391
|
+
const repoPaths = getConfig().repos || [];
|
|
392
|
+
watcher.rebuild(repoPaths);
|
|
393
|
+
branchWatcher.rebuild(repoPaths);
|
|
394
|
+
}
|
|
395
|
+
catch (err) {
|
|
396
|
+
logger.error('Failed to rebuild workspace watchers:', err);
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
app.use('/workspaces', requireAuth, workspaceRouter);
|
|
402
|
+
// Mount git (local/fast) and gh (network/slow) routers
|
|
403
|
+
app.use('/git', requireAuth, createGitRouter({
|
|
404
|
+
configPath: CONFIG_PATH,
|
|
405
|
+
getConfig,
|
|
406
|
+
getSessions: () => sessions.list().map((s) => ({
|
|
407
|
+
id: s.id,
|
|
408
|
+
worktreePath: s.worktreePath ?? s.repoPath,
|
|
409
|
+
})),
|
|
410
|
+
}));
|
|
411
|
+
app.use('/gh', requireAuth, createGhRouter());
|
|
412
|
+
// Mount workspace-groups CRUD router
|
|
413
|
+
app.use('/workspace-groups', createWorkspaceGroupsRouter(CONFIG_PATH, requireAuth, {
|
|
414
|
+
sessions,
|
|
415
|
+
gitWatcher,
|
|
416
|
+
configPath: CONFIG_PATH,
|
|
417
|
+
}));
|
|
418
|
+
// Mount GitHub integration router
|
|
419
|
+
const integrationGitHubRouter = createIntegrationGitHubRouter({
|
|
420
|
+
configPath: CONFIG_PATH,
|
|
421
|
+
});
|
|
422
|
+
app.use('/integration-github', requireAuth, integrationGitHubRouter);
|
|
423
|
+
// Mount Jira integration router
|
|
424
|
+
const integrationJiraRouter = createIntegrationJiraRouter({
|
|
425
|
+
configPath: CONFIG_PATH,
|
|
426
|
+
});
|
|
427
|
+
app.use('/integration-jira', requireAuth, integrationJiraRouter);
|
|
428
|
+
// Mount branch linker router
|
|
429
|
+
const branchLinkerRouter = createBranchLinkerRouter({
|
|
430
|
+
configPath: CONFIG_PATH,
|
|
431
|
+
getActiveBranchNames: () => {
|
|
432
|
+
const map = new Map();
|
|
433
|
+
for (const s of sessions.list()) {
|
|
434
|
+
if (!s.branchName)
|
|
435
|
+
continue;
|
|
436
|
+
// Use repoPath so all sessions (main worktree and sub-worktrees) group correctly
|
|
437
|
+
const wsRoot = s.repoPath || s.cwd;
|
|
438
|
+
const existing = map.get(wsRoot);
|
|
439
|
+
if (existing) {
|
|
440
|
+
existing.add(s.branchName);
|
|
441
|
+
}
|
|
442
|
+
else {
|
|
443
|
+
map.set(wsRoot, new Set([s.branchName]));
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return map;
|
|
447
|
+
},
|
|
448
|
+
});
|
|
449
|
+
app.use('/branch-linker', requireAuth, branchLinkerRouter);
|
|
450
|
+
// Mount ticket transitions router
|
|
451
|
+
const { router: ticketTransitionsRouter, transitionOnSessionCreate, checkPrTransitions, } = createTicketTransitionsRouter({ configPath: CONFIG_PATH });
|
|
452
|
+
app.use('/ticket-transitions', requireAuth, ticketTransitionsRouter);
|
|
453
|
+
// Mount GitHub device flow auth
|
|
454
|
+
// onConnected is called after token save; reload smee so it picks up any new config.
|
|
455
|
+
const githubAppRouter = createGitHubAppRouter({
|
|
456
|
+
configPath: CONFIG_PATH,
|
|
457
|
+
clientId: process.env.GITHUB_CLIENT_ID || DEFAULT_GITHUB_CLIENT_ID,
|
|
458
|
+
onConnected: () => {
|
|
459
|
+
reloadSmee(CONFIG_PATH, startupConfig.port);
|
|
460
|
+
},
|
|
461
|
+
});
|
|
462
|
+
app.use('/auth/github', requireAuth, githubAppRouter);
|
|
463
|
+
// Mount org dashboard router — use GraphQL when token available, fall back to gh CLI
|
|
464
|
+
const orgDashboardRouter = createOrgDashboardRouter({
|
|
465
|
+
configPath: CONFIG_PATH,
|
|
466
|
+
checkPrTransitions,
|
|
467
|
+
getBranchLinks: () => branchLinkerRouter.fetchLinks(),
|
|
468
|
+
fetchGraphQL: fetchPrsGraphQL,
|
|
469
|
+
});
|
|
470
|
+
app.use('/org-dashboard', requireAuth, orgDashboardRouter);
|
|
471
|
+
// Mount analytics router
|
|
472
|
+
app.use('/analytics', requireAuth, createAnalyticsRouter(configDir));
|
|
473
|
+
app.use('/api/analytics', requireAuth, createSessionAnalyticsRouter());
|
|
474
|
+
app.use('/telemetry', requireAuth, createTelemetryRouter());
|
|
475
|
+
// GET /api/frameworks — returns available agent frameworks with capabilities
|
|
476
|
+
app.get('/api/frameworks', requireAuth, (_req, res) => {
|
|
477
|
+
const frameworks = Object.values(BUILTIN_FRAMEWORKS).map((f) => ({
|
|
478
|
+
id: f.id,
|
|
479
|
+
displayName: f.displayName,
|
|
480
|
+
command: f.command,
|
|
481
|
+
capabilities: f.capabilities,
|
|
482
|
+
eventSource: f.eventSource,
|
|
483
|
+
}));
|
|
484
|
+
res.json({ frameworks });
|
|
485
|
+
});
|
|
486
|
+
// Restore sessions from a previous update restart
|
|
487
|
+
const restoredCount = await restoreFromDisk(configDir, getConfig().repos ?? [], getConfig().frameworks);
|
|
488
|
+
if (restoredCount > 0) {
|
|
489
|
+
logger.info(`Restored ${restoredCount} session(s) from previous update.`);
|
|
490
|
+
// Start git watching for restored sessions
|
|
491
|
+
for (const session of sessions.list()) {
|
|
492
|
+
gitWatcher.watch(session.cwd);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
startTelemetry({
|
|
496
|
+
getActiveSessions: sessions.list,
|
|
497
|
+
broadcastEvent,
|
|
498
|
+
configDir,
|
|
499
|
+
});
|
|
500
|
+
startEventBatching();
|
|
501
|
+
// Run retention cleanup and orphan recovery at startup
|
|
502
|
+
try {
|
|
503
|
+
const recovered = recoverOrphanedSessions();
|
|
504
|
+
if (recovered > 0)
|
|
505
|
+
logger.info(`[analytics] Recovered ${recovered} orphaned session(s).`);
|
|
506
|
+
runRetentionCleanup();
|
|
507
|
+
}
|
|
508
|
+
catch (err) {
|
|
509
|
+
logger.warn('[analytics] Retention/recovery error:', err);
|
|
510
|
+
}
|
|
511
|
+
// Periodic rate limit snapshot recording (every 5 minutes)
|
|
512
|
+
let lastRateLimitSnapshot = 0;
|
|
513
|
+
const RATE_LIMIT_SNAPSHOT_INTERVAL = 5 * 60 * 1000;
|
|
514
|
+
setInterval(() => {
|
|
515
|
+
const now = Date.now();
|
|
516
|
+
if (now - lastRateLimitSnapshot < RATE_LIMIT_SNAPSHOT_INTERVAL)
|
|
517
|
+
return;
|
|
518
|
+
const account = getAccountTelemetry();
|
|
519
|
+
if (!account || account.fiveHourUsedPercent < 0)
|
|
520
|
+
return;
|
|
521
|
+
lastRateLimitSnapshot = now;
|
|
522
|
+
recordRateLimitSnapshot({
|
|
523
|
+
fiveHourPercent: account.fiveHourUsedPercent,
|
|
524
|
+
fiveHourResetsAt: account.fiveHourResetsAt,
|
|
525
|
+
sevenDayPercent: account.sevenDayUsedPercent,
|
|
526
|
+
sevenDayResetsAt: account.sevenDayResetsAt,
|
|
527
|
+
timestamp: new Date().toISOString(),
|
|
528
|
+
});
|
|
529
|
+
}, 60_000);
|
|
530
|
+
// Schedule daily retention cleanup
|
|
531
|
+
setInterval(() => {
|
|
532
|
+
try {
|
|
533
|
+
runRetentionCleanup();
|
|
534
|
+
}
|
|
535
|
+
catch {
|
|
536
|
+
/* non-fatal */
|
|
537
|
+
}
|
|
538
|
+
}, 24 * 60 * 60 * 1000);
|
|
539
|
+
// Populate session metadata cache in background (non-blocking)
|
|
540
|
+
populateMetaCache().catch(() => { });
|
|
541
|
+
// Build shared deps for review poller
|
|
542
|
+
function buildPollerDeps() {
|
|
543
|
+
return {
|
|
544
|
+
configPath: CONFIG_PATH,
|
|
545
|
+
getWorkspacePaths: () => getConfig().repos ?? [],
|
|
546
|
+
getRepoSettings: (wsPath) => getConfig().repoSettings?.[wsPath],
|
|
547
|
+
createSession: async (opts) => {
|
|
548
|
+
const resolved = resolveSessionSettings(getConfig(), opts.repoPath, {});
|
|
549
|
+
const repoName = opts.repoPath.split('/').filter(Boolean).pop() || 'session';
|
|
550
|
+
const displayName = sessions.nextAgentName();
|
|
551
|
+
sessions.create({
|
|
552
|
+
type: 'agent',
|
|
553
|
+
agent: resolved.agent,
|
|
554
|
+
repoName,
|
|
555
|
+
repoPath: opts.repoPath,
|
|
556
|
+
worktreePath: opts.worktreePath,
|
|
557
|
+
cwd: opts.worktreePath,
|
|
558
|
+
branchName: opts.branchName,
|
|
559
|
+
displayName,
|
|
560
|
+
args: [
|
|
561
|
+
...resolved.claudeArgs,
|
|
562
|
+
...(resolved.yolo ? (AGENT_YOLO_ARGS[resolved.agent] ?? []) : []),
|
|
563
|
+
],
|
|
564
|
+
configPath: CONFIG_PATH,
|
|
565
|
+
useTmux: resolved.useTmux,
|
|
566
|
+
yolo: resolved.yolo,
|
|
567
|
+
claudeArgs: resolved.claudeArgs,
|
|
568
|
+
...(opts.initialPrompt != null && {
|
|
569
|
+
initialPrompt: opts.initialPrompt,
|
|
570
|
+
}),
|
|
571
|
+
});
|
|
572
|
+
},
|
|
573
|
+
broadcastEvent,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
// Start review request poller if enabled
|
|
577
|
+
if (getConfig().automations?.autoCheckoutReviewRequests) {
|
|
578
|
+
startPolling(buildPollerDeps());
|
|
579
|
+
}
|
|
580
|
+
// Start smee-client via webhook-manager
|
|
581
|
+
reloadSmee(CONFIG_PATH, startupConfig.port);
|
|
582
|
+
// Start smart polling — broadcasts pr-updated/ci-updated only for repos without webhooks
|
|
583
|
+
startSmartPolling(CONFIG_PATH, broadcastEvent);
|
|
584
|
+
// Invalidate branch linker cache on session lifecycle changes
|
|
585
|
+
sessions.onSessionCreate(() => {
|
|
586
|
+
invalidateBranchLinkerCache();
|
|
587
|
+
});
|
|
588
|
+
sessions.onSessionEnd((sessionId) => {
|
|
589
|
+
invalidateBranchLinkerCache();
|
|
590
|
+
lastPushState.delete(sessionId);
|
|
591
|
+
});
|
|
592
|
+
sessions.onSessionEnd((sessionId) => {
|
|
593
|
+
// 1-second grace period for in-flight hooks before computing final metrics
|
|
594
|
+
setTimeout(() => {
|
|
595
|
+
// Capture final telemetry snapshot
|
|
596
|
+
const telemetry = getTelemetryForSession(sessionId);
|
|
597
|
+
if (telemetry) {
|
|
598
|
+
upsertSessionRollup({
|
|
599
|
+
sessionId,
|
|
600
|
+
...(telemetry.model !== null ? { model: telemetry.model } : {}),
|
|
601
|
+
totalInputTokens: telemetry.totalInputTokens,
|
|
602
|
+
totalOutputTokens: telemetry.totalOutputTokens,
|
|
603
|
+
totalCacheRead: telemetry.totalCacheRead,
|
|
604
|
+
totalCacheWrite: telemetry.totalCacheWrite,
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
flushEventBuffer(sessionId);
|
|
608
|
+
const metrics = computeEngagementMetrics(sessionId);
|
|
609
|
+
const endedAt = new Date().toISOString();
|
|
610
|
+
const existingRollup = getSessionRollup(sessionId);
|
|
611
|
+
const durationSeconds = existingRollup?.startedAt
|
|
612
|
+
? Math.round((new Date(endedAt).getTime() -
|
|
613
|
+
new Date(existingRollup.startedAt).getTime()) /
|
|
614
|
+
1000)
|
|
615
|
+
: undefined;
|
|
616
|
+
upsertSessionRollup({
|
|
617
|
+
sessionId,
|
|
618
|
+
endedAt,
|
|
619
|
+
...(durationSeconds !== undefined ? { durationSeconds } : {}),
|
|
620
|
+
...(metrics
|
|
621
|
+
? {
|
|
622
|
+
...(metrics.humanResponseLatencyAvgMs !== null
|
|
623
|
+
? {
|
|
624
|
+
humanResponseLatencyAvgMs: metrics.humanResponseLatencyAvgMs,
|
|
625
|
+
}
|
|
626
|
+
: {}),
|
|
627
|
+
...(metrics.humanResponseLatencyP50Ms !== null
|
|
628
|
+
? {
|
|
629
|
+
humanResponseLatencyP50Ms: metrics.humanResponseLatencyP50Ms,
|
|
630
|
+
}
|
|
631
|
+
: {}),
|
|
632
|
+
...(metrics.humanResponseLatencyP95Ms !== null
|
|
633
|
+
? {
|
|
634
|
+
humanResponseLatencyP95Ms: metrics.humanResponseLatencyP95Ms,
|
|
635
|
+
}
|
|
636
|
+
: {}),
|
|
637
|
+
...(metrics.agentIdlePercent !== null
|
|
638
|
+
? { agentIdlePercent: metrics.agentIdlePercent }
|
|
639
|
+
: {}),
|
|
640
|
+
rateLimitEncounters: metrics.rateLimitEncounters,
|
|
641
|
+
toolUseCounts: metrics.toolUseCounts,
|
|
642
|
+
}
|
|
643
|
+
: {}),
|
|
644
|
+
});
|
|
645
|
+
}, 1000);
|
|
646
|
+
});
|
|
647
|
+
// Push notifications on meaningful state transitions (skip when hooks already sent attention notification)
|
|
648
|
+
const lastPushState = new Map();
|
|
649
|
+
sessions.onBackendStateChange((sessionId, state) => {
|
|
650
|
+
const prevState = lastPushState.get(sessionId);
|
|
651
|
+
lastPushState.set(sessionId, state);
|
|
652
|
+
// Only notify on meaningful transitions: running → idle or running → permission
|
|
653
|
+
if (prevState === 'running' &&
|
|
654
|
+
(state === 'idle' || state === 'permission')) {
|
|
655
|
+
const session = sessions.get(sessionId);
|
|
656
|
+
if (session && session.type !== 'terminal') {
|
|
657
|
+
// Dedup: if hooks fired an attention notification within last 10s, skip
|
|
658
|
+
if (session.hooksActive &&
|
|
659
|
+
session.lastAttentionNotifiedAt &&
|
|
660
|
+
Date.now() - session.lastAttentionNotifiedAt < 10000) {
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
push.notifySessionAttention(sessionId, session);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
// GET /auth/status — no auth required, tells frontend if PIN is configured
|
|
668
|
+
app.get('/auth/status', (_req, res) => {
|
|
669
|
+
const config = getConfig();
|
|
670
|
+
res.json({ hasPIN: !!config.pinHash });
|
|
671
|
+
});
|
|
672
|
+
// POST /auth/setup — set initial PIN (only works when no PIN is configured)
|
|
673
|
+
app.post('/auth/setup', async (req, res) => {
|
|
674
|
+
try {
|
|
675
|
+
const ip = (req.ip || req.connection.remoteAddress);
|
|
676
|
+
if (auth.isRateLimited(ip)) {
|
|
677
|
+
res.status(429).json({ error: 'Too many attempts. Try again later.' });
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
const { pin, confirm } = req.body;
|
|
681
|
+
if (!pin || !confirm) {
|
|
682
|
+
res.status(400).json({ error: 'PIN and confirmation required' });
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
if (pin !== confirm) {
|
|
686
|
+
auth.recordFailedAttempt(ip);
|
|
687
|
+
res.status(400).json({ error: 'PINs do not match' });
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
if (pin.length < 4) {
|
|
691
|
+
res.status(400).json({ error: 'PIN must be at least 4 characters' });
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
// Single read — check + write atomically to avoid TOCTOU race
|
|
695
|
+
const freshConfig = loadConfig(CONFIG_PATH);
|
|
696
|
+
if (freshConfig.pinHash) {
|
|
697
|
+
res
|
|
698
|
+
.status(403)
|
|
699
|
+
.json({ error: 'PIN is already configured. Use CLI to reset.' });
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
freshConfig.pinHash = await auth.hashPin(pin);
|
|
703
|
+
saveConfig(CONFIG_PATH, freshConfig);
|
|
704
|
+
// Auto-login: generate token and set cookie
|
|
705
|
+
auth.clearRateLimit(ip);
|
|
706
|
+
const token = auth.generateCookieToken();
|
|
707
|
+
authenticatedTokens.add(token);
|
|
708
|
+
const ttlMs = parseTTL(freshConfig.cookieTTL);
|
|
709
|
+
setTimeout(() => authenticatedTokens.delete(token), ttlMs);
|
|
710
|
+
res.cookie('token', token, {
|
|
711
|
+
httpOnly: true,
|
|
712
|
+
sameSite: 'strict',
|
|
713
|
+
maxAge: ttlMs,
|
|
714
|
+
});
|
|
715
|
+
res.json({ ok: true });
|
|
716
|
+
}
|
|
717
|
+
catch (err) {
|
|
718
|
+
logger.error('[auth] Unhandled error in POST /auth/setup:', err);
|
|
719
|
+
res.status(500).json({ error: 'Failed to set PIN' });
|
|
720
|
+
}
|
|
721
|
+
});
|
|
722
|
+
// POST /auth
|
|
723
|
+
app.post('/auth', async (req, res) => {
|
|
724
|
+
try {
|
|
725
|
+
const ip = (req.ip || req.connection.remoteAddress);
|
|
726
|
+
if (auth.isRateLimited(ip)) {
|
|
727
|
+
res.status(429).json({ error: 'Too many attempts. Try again later.' });
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
const { pin } = req.body;
|
|
731
|
+
if (!pin) {
|
|
732
|
+
res.status(400).json({ error: 'PIN required' });
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
const authConfig = getConfig();
|
|
736
|
+
if (!authConfig.pinHash) {
|
|
737
|
+
res.status(412).json({ error: 'No PIN configured', needsSetup: true });
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
const valid = process.env.NO_PIN === '1' ||
|
|
741
|
+
(await auth.verifyPin(pin, authConfig.pinHash));
|
|
742
|
+
if (!valid) {
|
|
743
|
+
auth.recordFailedAttempt(ip);
|
|
744
|
+
res.status(401).json({ error: 'Invalid PIN' });
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
auth.clearRateLimit(ip);
|
|
748
|
+
const token = auth.generateCookieToken();
|
|
749
|
+
authenticatedTokens.add(token);
|
|
750
|
+
const ttlMs = parseTTL(authConfig.cookieTTL);
|
|
751
|
+
setTimeout(() => authenticatedTokens.delete(token), ttlMs);
|
|
752
|
+
res.cookie('token', token, {
|
|
753
|
+
httpOnly: true,
|
|
754
|
+
sameSite: 'strict',
|
|
755
|
+
maxAge: ttlMs,
|
|
756
|
+
});
|
|
757
|
+
res.json({ ok: true });
|
|
758
|
+
}
|
|
759
|
+
catch (err) {
|
|
760
|
+
logger.error('[auth] Unhandled error in POST /auth:', err);
|
|
761
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
762
|
+
}
|
|
763
|
+
});
|
|
764
|
+
// GET /sessions — enrich with live branch from git (rate-limited to avoid spawning git on every poll)
|
|
765
|
+
const branchRefreshCache = new Map(); // sessionId -> last refresh timestamp
|
|
766
|
+
const BRANCH_REFRESH_INTERVAL_MS = 10_000;
|
|
767
|
+
app.get('/sessions', requireAuth, async (_req, res) => {
|
|
768
|
+
const allSessions = sessions.list();
|
|
769
|
+
const now = Date.now();
|
|
770
|
+
// Prune cache entries for sessions that no longer exist
|
|
771
|
+
const activeIds = new Set(allSessions.map((s) => s.id));
|
|
772
|
+
for (const sessionId of branchRefreshCache.keys()) {
|
|
773
|
+
if (!activeIds.has(sessionId))
|
|
774
|
+
branchRefreshCache.delete(sessionId);
|
|
775
|
+
}
|
|
776
|
+
await Promise.all(allSessions.map(async (s) => {
|
|
777
|
+
if (s.type !== 'agent')
|
|
778
|
+
return;
|
|
779
|
+
if (!s.cwd)
|
|
780
|
+
return;
|
|
781
|
+
const lastRefresh = branchRefreshCache.get(s.id) ?? 0;
|
|
782
|
+
if (now - lastRefresh < BRANCH_REFRESH_INTERVAL_MS)
|
|
783
|
+
return;
|
|
784
|
+
const cwd = s.cwd;
|
|
785
|
+
branchRefreshCache.set(s.id, now);
|
|
786
|
+
try {
|
|
787
|
+
const { stdout } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd });
|
|
788
|
+
const liveBranch = stdout.trim();
|
|
789
|
+
if (liveBranch && liveBranch !== s.branchName) {
|
|
790
|
+
s.branchName = liveBranch;
|
|
791
|
+
const raw = sessions.get(s.id);
|
|
792
|
+
if (raw)
|
|
793
|
+
raw.branchName = liveBranch;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
catch {
|
|
797
|
+
/* non-fatal */
|
|
798
|
+
}
|
|
799
|
+
}));
|
|
800
|
+
res.json(allSessions);
|
|
801
|
+
});
|
|
802
|
+
// GET /repos — scan root dirs for repos
|
|
803
|
+
app.get('/repos', requireAuth, async (_req, res) => {
|
|
804
|
+
const freshConfig = getConfig();
|
|
805
|
+
const repos = scanAllRepos(freshConfig.rootDirs || []);
|
|
806
|
+
// Also include legacy manually-added repos
|
|
807
|
+
if (freshConfig.repos) {
|
|
808
|
+
for (const repo of freshConfig.repos) {
|
|
809
|
+
if (!repos.some((r) => r.path === repo.path)) {
|
|
810
|
+
repos.push(repo);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
// Enrich with current branch (best-effort, parallel)
|
|
815
|
+
const enriched = await Promise.all(repos.map(async (repo) => {
|
|
816
|
+
try {
|
|
817
|
+
const { stdout } = await execFileAsync('git', ['symbolic-ref', '--short', 'HEAD'], { cwd: repo.path });
|
|
818
|
+
return { ...repo, defaultBranch: stdout.trim() };
|
|
819
|
+
}
|
|
820
|
+
catch {
|
|
821
|
+
return { ...repo, defaultBranch: null };
|
|
822
|
+
}
|
|
823
|
+
}));
|
|
824
|
+
res.json(enriched);
|
|
825
|
+
});
|
|
826
|
+
// GET /worktrees/status — pre-cleanup checks for a worktree
|
|
827
|
+
app.get('/worktrees/status', requireAuth, async (req, res) => {
|
|
828
|
+
const worktreePath = typeof req.query.path === 'string' ? req.query.path : undefined;
|
|
829
|
+
if (!worktreePath) {
|
|
830
|
+
res.status(400).json({ error: 'path query parameter is required' });
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
const resolved = path.resolve(worktreePath);
|
|
834
|
+
// Validate the path is a recognized git worktree via git worktree list (trust boundary first)
|
|
835
|
+
const allRoots = getConfig().rootDirs || [];
|
|
836
|
+
const allRepos = [...(getConfig().repos ?? [])];
|
|
837
|
+
for (const rootDir of allRoots) {
|
|
838
|
+
try {
|
|
839
|
+
for (const entry of fs.readdirSync(rootDir, { withFileTypes: true })) {
|
|
840
|
+
if (!entry.isDirectory() || entry.name.startsWith('.'))
|
|
841
|
+
continue;
|
|
842
|
+
const fullPath = path.join(rootDir, entry.name);
|
|
843
|
+
if (fs.existsSync(path.join(fullPath, '.git')))
|
|
844
|
+
allRepos.push(fullPath);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
catch {
|
|
848
|
+
/* skip unreadable rootDirs */
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
let isKnownWorktree = false;
|
|
852
|
+
for (const repoPath of [...new Set(allRepos)]) {
|
|
853
|
+
try {
|
|
854
|
+
const { stdout } = await execFileAsync('git', ['worktree', 'list', '--porcelain'], { cwd: repoPath, timeout: 5000 });
|
|
855
|
+
const allWt = parseAllWorktrees(stdout, repoPath);
|
|
856
|
+
if (allWt.some((wt) => wt.path === resolved && !wt.isMain)) {
|
|
857
|
+
isKnownWorktree = true;
|
|
858
|
+
break;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
catch {
|
|
862
|
+
/* skip repos where git fails */
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
if (!isKnownWorktree) {
|
|
866
|
+
res.status(400).json({ error: 'Path is not a recognized git worktree' });
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
if (!fs.existsSync(resolved)) {
|
|
870
|
+
res.status(404).json({ error: 'Worktree not found' });
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
// Check for active sessions in this worktree
|
|
874
|
+
const allSessions = sessions.list();
|
|
875
|
+
const activeSessions = allSessions
|
|
876
|
+
.filter((s) => s.worktreePath === resolved || s.cwd === resolved)
|
|
877
|
+
.map((s) => s.id);
|
|
878
|
+
// Check for uncommitted changes — default to true (safe: assume changes exist if check fails)
|
|
879
|
+
let hasUncommittedChanges = true;
|
|
880
|
+
try {
|
|
881
|
+
const { stdout } = await execFileAsync('git', ['status', '--porcelain'], {
|
|
882
|
+
cwd: resolved,
|
|
883
|
+
timeout: 5000,
|
|
884
|
+
});
|
|
885
|
+
hasUncommittedChanges = stdout.trim().length > 0;
|
|
886
|
+
}
|
|
887
|
+
catch (err) {
|
|
888
|
+
logger.warn('[worktrees/status] git status failed for', resolved, err instanceof Error ? err.message : err);
|
|
889
|
+
}
|
|
890
|
+
res.json({ activeSessions, hasUncommittedChanges });
|
|
891
|
+
});
|
|
892
|
+
// GET /config/defaultAgent — get default coding agent
|
|
893
|
+
app.get('/config/defaultAgent', requireAuth, (_req, res) => {
|
|
894
|
+
res.json({ defaultAgent: getConfig().defaultAgent || 'claude' });
|
|
895
|
+
});
|
|
896
|
+
// PATCH /config/defaultAgent — set default coding agent
|
|
897
|
+
app.patch('/config/defaultAgent', requireAuth, (req, res) => {
|
|
898
|
+
const { defaultAgent } = req.body;
|
|
899
|
+
if (!defaultAgent ||
|
|
900
|
+
(defaultAgent !== 'claude' && defaultAgent !== 'codex')) {
|
|
901
|
+
res
|
|
902
|
+
.status(400)
|
|
903
|
+
.json({ error: 'defaultAgent must be "claude" or "codex"' });
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
const c = getConfig();
|
|
907
|
+
c.defaultAgent = defaultAgent;
|
|
908
|
+
saveConfig(CONFIG_PATH, c);
|
|
909
|
+
res.json({ defaultAgent: c.defaultAgent });
|
|
910
|
+
});
|
|
911
|
+
boolConfigEndpoints('defaultContinue', true);
|
|
912
|
+
boolConfigEndpoints('defaultYolo', false);
|
|
913
|
+
boolConfigEndpoints('launchInTmux', false, async () => {
|
|
914
|
+
await execFileAsync('tmux', ['-V']);
|
|
915
|
+
});
|
|
916
|
+
boolConfigEndpoints('defaultNotifications', true);
|
|
917
|
+
boolConfigEndpoints('autoProvision', false);
|
|
918
|
+
// GET /config/automations — get automation settings
|
|
919
|
+
app.get('/config/automations', requireAuth, (_req, res) => {
|
|
920
|
+
res.json(getConfig().automations ?? {});
|
|
921
|
+
});
|
|
922
|
+
// PATCH /config/automations — update automation settings and start/stop poller
|
|
923
|
+
app.patch('/config/automations', requireAuth, (req, res) => {
|
|
924
|
+
const body = req.body;
|
|
925
|
+
const c = getConfig();
|
|
926
|
+
const prev = c.automations ?? {};
|
|
927
|
+
const next = { ...prev };
|
|
928
|
+
if (typeof body.autoCheckoutReviewRequests === 'boolean') {
|
|
929
|
+
next.autoCheckoutReviewRequests = body.autoCheckoutReviewRequests;
|
|
930
|
+
}
|
|
931
|
+
if (typeof body.autoReviewOnCheckout === 'boolean') {
|
|
932
|
+
next.autoReviewOnCheckout = body.autoReviewOnCheckout;
|
|
933
|
+
}
|
|
934
|
+
if (typeof body.pollIntervalMs === 'number' &&
|
|
935
|
+
body.pollIntervalMs >= 60000) {
|
|
936
|
+
next.pollIntervalMs = body.pollIntervalMs;
|
|
937
|
+
}
|
|
938
|
+
// Enforce: auto-review requires auto-checkout
|
|
939
|
+
if (!next.autoCheckoutReviewRequests) {
|
|
940
|
+
next.autoReviewOnCheckout = false;
|
|
941
|
+
}
|
|
942
|
+
c.automations = next;
|
|
943
|
+
try {
|
|
944
|
+
saveConfig(CONFIG_PATH, c);
|
|
945
|
+
}
|
|
946
|
+
catch (err) {
|
|
947
|
+
logger.error('[config] Failed to save automation settings:', err);
|
|
948
|
+
res.status(500).json({ error: 'Failed to save settings' });
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
// Start or stop poller based on new setting
|
|
952
|
+
void stopPolling().then(() => {
|
|
953
|
+
if (next.autoCheckoutReviewRequests) {
|
|
954
|
+
startPolling(buildPollerDeps());
|
|
955
|
+
}
|
|
956
|
+
});
|
|
957
|
+
res.json(next);
|
|
958
|
+
});
|
|
959
|
+
// GET /config/workspace-groups — return workspace group configuration
|
|
960
|
+
app.get('/config/workspace-groups', requireAuth, (_req, res) => {
|
|
961
|
+
res.json({ groups: getConfig().workspaceGroups ?? {} });
|
|
962
|
+
});
|
|
963
|
+
// GET /presets — return all filter presets (built-in merged with user presets)
|
|
964
|
+
app.get('/presets', requireAuth, (_req, res) => {
|
|
965
|
+
res.json(getConfig().filterPresets ?? []);
|
|
966
|
+
});
|
|
967
|
+
// POST /presets — add a new user filter preset
|
|
968
|
+
app.post('/presets', requireAuth, (req, res) => {
|
|
969
|
+
const { name, filters, sort } = req.body;
|
|
970
|
+
if (!name || typeof name !== 'string' || !name.trim()) {
|
|
971
|
+
res.status(400).json({ error: 'name is required' });
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
if (sort && typeof sort === 'object') {
|
|
975
|
+
const dir = sort.direction;
|
|
976
|
+
if (dir !== 'asc' && dir !== 'desc') {
|
|
977
|
+
res
|
|
978
|
+
.status(400)
|
|
979
|
+
.json({ error: 'sort.direction must be "asc" or "desc"' });
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
const col = sort.column;
|
|
983
|
+
if (!col || typeof col !== 'string' || !col.trim()) {
|
|
984
|
+
res
|
|
985
|
+
.status(400)
|
|
986
|
+
.json({ error: 'sort.column must be a non-empty string' });
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
const trimmedName = name.trim();
|
|
991
|
+
const c = getConfig();
|
|
992
|
+
const existingPresets = c.filterPresets ?? [];
|
|
993
|
+
const duplicate = existingPresets.some((p) => p.name.toLowerCase() === trimmedName.toLowerCase());
|
|
994
|
+
if (duplicate) {
|
|
995
|
+
res
|
|
996
|
+
.status(409)
|
|
997
|
+
.json({ error: `A preset named "${trimmedName}" already exists` });
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
const preset = {
|
|
1001
|
+
name: trimmedName,
|
|
1002
|
+
filters: filters ?? {},
|
|
1003
|
+
sort: sort ?? {
|
|
1004
|
+
column: 'role',
|
|
1005
|
+
direction: 'asc',
|
|
1006
|
+
},
|
|
1007
|
+
};
|
|
1008
|
+
if (!c.filterPresets)
|
|
1009
|
+
c.filterPresets = [];
|
|
1010
|
+
c.filterPresets.push(preset);
|
|
1011
|
+
saveConfig(CONFIG_PATH, c);
|
|
1012
|
+
res.json(preset);
|
|
1013
|
+
});
|
|
1014
|
+
// DELETE /presets/:name — remove a user preset (built-in presets cannot be deleted)
|
|
1015
|
+
app.delete('/presets/:name', requireAuth, (req, res) => {
|
|
1016
|
+
const name = decodeURIComponent(req.params['name'] ?? '');
|
|
1017
|
+
const c = getConfig();
|
|
1018
|
+
const presets = c.filterPresets ?? [];
|
|
1019
|
+
const target = presets.find((p) => p.name === name);
|
|
1020
|
+
if (!target) {
|
|
1021
|
+
res.status(404).json({ error: 'Preset not found' });
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
if (target.builtIn) {
|
|
1025
|
+
res.status(400).json({ error: 'Cannot delete a built-in preset' });
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
c.filterPresets = presets.filter((p) => p.name !== name);
|
|
1029
|
+
saveConfig(CONFIG_PATH, c);
|
|
1030
|
+
res.json({ ok: true });
|
|
1031
|
+
});
|
|
1032
|
+
// GET /push/vapid-key
|
|
1033
|
+
app.get('/push/vapid-key', requireAuth, (_req, res) => {
|
|
1034
|
+
const key = push.getVapidPublicKey();
|
|
1035
|
+
if (!key) {
|
|
1036
|
+
res.status(501).json({ error: 'Push not available' });
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
res.json({ vapidPublicKey: key });
|
|
1040
|
+
});
|
|
1041
|
+
// POST /push/subscribe
|
|
1042
|
+
app.post('/push/subscribe', requireAuth, (req, res) => {
|
|
1043
|
+
const { subscription, sessionIds } = req.body;
|
|
1044
|
+
if (!subscription?.endpoint) {
|
|
1045
|
+
res.status(400).json({ error: 'subscription required' });
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
push.subscribe(subscription, sessionIds || []);
|
|
1049
|
+
res.json({ ok: true });
|
|
1050
|
+
});
|
|
1051
|
+
// POST /push/unsubscribe
|
|
1052
|
+
app.post('/push/unsubscribe', requireAuth, (req, res) => {
|
|
1053
|
+
const { endpoint } = req.body;
|
|
1054
|
+
if (!endpoint) {
|
|
1055
|
+
res.status(400).json({ error: 'endpoint required' });
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
push.unsubscribe(endpoint);
|
|
1059
|
+
res.json({ ok: true });
|
|
1060
|
+
});
|
|
1061
|
+
// DELETE /worktrees — remove a worktree, prune, and delete its branch
|
|
1062
|
+
app.delete('/worktrees', requireAuth, async (req, res) => {
|
|
1063
|
+
const { worktreePath, repoPath, force } = req.body;
|
|
1064
|
+
if (!worktreePath || !repoPath) {
|
|
1065
|
+
res.status(400).json({ error: 'worktreePath and repoPath are required' });
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
// Validate the path is a real git worktree (not the main worktree)
|
|
1069
|
+
try {
|
|
1070
|
+
const { stdout: wtListOut } = await execFileAsync('git', ['worktree', 'list', '--porcelain'], { cwd: repoPath });
|
|
1071
|
+
const allWorktrees = parseAllWorktrees(wtListOut, repoPath);
|
|
1072
|
+
const isKnownWorktree = allWorktrees.some((wt) => wt.path === path.resolve(worktreePath) && !wt.isMain);
|
|
1073
|
+
if (!isKnownWorktree) {
|
|
1074
|
+
// Check if the path simply doesn't exist anymore (already cleaned up)
|
|
1075
|
+
if (!fs.existsSync(worktreePath)) {
|
|
1076
|
+
res.status(404).json({
|
|
1077
|
+
error: 'Worktree not found — may have been already cleaned up',
|
|
1078
|
+
});
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
res
|
|
1082
|
+
.status(400)
|
|
1083
|
+
.json({ error: 'Path is not a recognized git worktree' });
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
catch (err) {
|
|
1088
|
+
logger.warn('[worktrees/delete] git worktree list failed for', repoPath, err instanceof Error ? err.message : err);
|
|
1089
|
+
// Allow force-delete when git is broken (user explicitly wants cleanup)
|
|
1090
|
+
if (!force) {
|
|
1091
|
+
res.status(500).json({
|
|
1092
|
+
error: 'Cannot verify worktree — git worktree list failed. Use force: true to delete anyway.',
|
|
1093
|
+
});
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
// Check for active sessions in this worktree
|
|
1098
|
+
const allSessions = sessions.list();
|
|
1099
|
+
const resolvedPath = path.resolve(worktreePath);
|
|
1100
|
+
const worktreeSessions = allSessions.filter((s) => s.worktreePath === resolvedPath || s.cwd === resolvedPath);
|
|
1101
|
+
if (worktreeSessions.length > 0 && !force) {
|
|
1102
|
+
// Non-force delete with active sessions: reject to prevent killing PTYs unexpectedly
|
|
1103
|
+
res.status(409).json({
|
|
1104
|
+
error: 'active_sessions',
|
|
1105
|
+
sessionIds: worktreeSessions.map((s) => s.id),
|
|
1106
|
+
});
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
// Force: kill active sessions in this worktree first
|
|
1110
|
+
if (force) {
|
|
1111
|
+
for (const s of worktreeSessions) {
|
|
1112
|
+
try {
|
|
1113
|
+
sessions.kill(s.id);
|
|
1114
|
+
}
|
|
1115
|
+
catch (err) {
|
|
1116
|
+
logger.warn(`[worktrees] failed to kill session ${s.id}:`, err instanceof Error ? err.message : err);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
// Derive branch name from metadata or worktree directory name
|
|
1121
|
+
const meta = readMeta(CONFIG_PATH, worktreePath);
|
|
1122
|
+
const branchName = (meta && meta.branchName) || worktreePath.split('/').pop() || '';
|
|
1123
|
+
try {
|
|
1124
|
+
// Use --force when the user has confirmed via the cascade dialog
|
|
1125
|
+
const removeArgs = force
|
|
1126
|
+
? ['worktree', 'remove', '--force', worktreePath]
|
|
1127
|
+
: ['worktree', 'remove', worktreePath];
|
|
1128
|
+
await execFileAsync('git', removeArgs, { cwd: repoPath });
|
|
1129
|
+
}
|
|
1130
|
+
catch (err) {
|
|
1131
|
+
// If git worktree remove fails, the directory may be an orphaned worktree
|
|
1132
|
+
// that git no longer tracks. Try to remove the directory directly.
|
|
1133
|
+
if (fs.existsSync(worktreePath)) {
|
|
1134
|
+
try {
|
|
1135
|
+
fs.rmSync(worktreePath, { recursive: true });
|
|
1136
|
+
}
|
|
1137
|
+
catch (rmErr) {
|
|
1138
|
+
res.status(500).json({
|
|
1139
|
+
error: execErrorMessage(rmErr, 'Failed to remove worktree directory'),
|
|
1140
|
+
});
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
// If directory doesn't exist, the worktree is already gone — continue to cleanup
|
|
1145
|
+
}
|
|
1146
|
+
try {
|
|
1147
|
+
// Prune stale worktree refs
|
|
1148
|
+
await execFileAsync('git', ['worktree', 'prune'], { cwd: repoPath });
|
|
1149
|
+
}
|
|
1150
|
+
catch (_) {
|
|
1151
|
+
// Non-fatal: prune failure doesn't block success
|
|
1152
|
+
}
|
|
1153
|
+
if (branchName) {
|
|
1154
|
+
try {
|
|
1155
|
+
// Delete the branch
|
|
1156
|
+
await execFileAsync('git', ['branch', '-D', branchName], {
|
|
1157
|
+
cwd: repoPath,
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
catch (_) {
|
|
1161
|
+
// Non-fatal: branch may not exist or may be checked out elsewhere
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
// Clean up metadata file
|
|
1165
|
+
deleteMeta(CONFIG_PATH, worktreePath);
|
|
1166
|
+
// Broadcast worktrees-changed so all clients refresh
|
|
1167
|
+
broadcastEvent('worktrees-changed');
|
|
1168
|
+
res.json({ ok: true });
|
|
1169
|
+
});
|
|
1170
|
+
// POST /sessions — unified endpoint for agent and terminal sessions
|
|
1171
|
+
app.post('/sessions', requireAuth, async (req, res) => {
|
|
1172
|
+
const { repoPath, worktreePath, type = 'agent', agent, yolo, useTmux, claudeArgs, cols, rows, branchName: requestBranchName, needsBranchRename, branchRenamePrompt, initialPrompt, continue: explicitContinue, continuePolicy: explicitContinuePolicy, ticketContext, } = req.body;
|
|
1173
|
+
if (!repoPath) {
|
|
1174
|
+
res.status(400).json({ error: 'repoPath is required' });
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
// Read config once for the lifetime of this request
|
|
1178
|
+
const freshConfig = getConfig();
|
|
1179
|
+
// Validate repoPath is a configured workspace
|
|
1180
|
+
const configuredWorkspaces = freshConfig.repos ?? [];
|
|
1181
|
+
if (!configuredWorkspaces.includes(repoPath)) {
|
|
1182
|
+
res.status(400).json({ error: 'repoPath is not a configured workspace' });
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
const cwd = worktreePath ?? repoPath;
|
|
1186
|
+
// Validate cwd directory exists
|
|
1187
|
+
if (!fs.existsSync(cwd)) {
|
|
1188
|
+
res.status(400).json({ error: `Directory does not exist: ${cwd}` });
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
const safeCols = typeof cols === 'number' &&
|
|
1192
|
+
Number.isFinite(cols) &&
|
|
1193
|
+
cols >= 1 &&
|
|
1194
|
+
cols <= 500
|
|
1195
|
+
? Math.round(cols)
|
|
1196
|
+
: undefined;
|
|
1197
|
+
const safeRows = typeof rows === 'number' &&
|
|
1198
|
+
Number.isFinite(rows) &&
|
|
1199
|
+
rows >= 1 &&
|
|
1200
|
+
rows <= 200
|
|
1201
|
+
? Math.round(rows)
|
|
1202
|
+
: undefined;
|
|
1203
|
+
const name = repoPath.split('/').filter(Boolean).pop() || 'session';
|
|
1204
|
+
if (type === 'terminal') {
|
|
1205
|
+
// Terminal session — bare shell
|
|
1206
|
+
const shell = process.env.SHELL || '/bin/sh';
|
|
1207
|
+
const displayName = sessions.nextTerminalName();
|
|
1208
|
+
const session = sessions.create({
|
|
1209
|
+
type: 'terminal',
|
|
1210
|
+
agent: 'claude',
|
|
1211
|
+
repoName: name,
|
|
1212
|
+
repoPath,
|
|
1213
|
+
worktreePath: worktreePath ?? null,
|
|
1214
|
+
cwd,
|
|
1215
|
+
displayName,
|
|
1216
|
+
branchName: '',
|
|
1217
|
+
command: shell,
|
|
1218
|
+
args: [],
|
|
1219
|
+
...(safeCols != null && { cols: safeCols }),
|
|
1220
|
+
...(safeRows != null && { rows: safeRows }),
|
|
1221
|
+
});
|
|
1222
|
+
gitWatcher.watch(session.cwd);
|
|
1223
|
+
res.status(201).json(session);
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
// Agent session
|
|
1227
|
+
// Map legacy boolean continue → continuePolicy for backward compat
|
|
1228
|
+
const policyOverride = explicitContinuePolicy ??
|
|
1229
|
+
(explicitContinue !== undefined
|
|
1230
|
+
? explicitContinue
|
|
1231
|
+
? 'always'
|
|
1232
|
+
: 'never'
|
|
1233
|
+
: undefined);
|
|
1234
|
+
// For new worktrees, always use 'never' regardless of config
|
|
1235
|
+
const effectivePolicy = needsBranchRename
|
|
1236
|
+
? 'never'
|
|
1237
|
+
: policyOverride;
|
|
1238
|
+
const resolved = resolveSessionSettings(freshConfig, repoPath, {
|
|
1239
|
+
agent,
|
|
1240
|
+
yolo,
|
|
1241
|
+
useTmux,
|
|
1242
|
+
claudeArgs,
|
|
1243
|
+
continuePolicy: effectivePolicy,
|
|
1244
|
+
});
|
|
1245
|
+
const resolvedAgent = resolved.agent;
|
|
1246
|
+
const baseArgs = [
|
|
1247
|
+
...resolved.claudeArgs,
|
|
1248
|
+
...(resolved.yolo ? (AGENT_YOLO_ARGS[resolvedAgent] ?? []) : []),
|
|
1249
|
+
];
|
|
1250
|
+
// Determine --continue from policy (no .claude directory heuristic)
|
|
1251
|
+
const useContinue = resolved.continuePolicy === 'always';
|
|
1252
|
+
const args = useContinue
|
|
1253
|
+
? [...(AGENT_CONTINUE_ARGS[resolvedAgent] ?? []), ...baseArgs]
|
|
1254
|
+
: [...baseArgs];
|
|
1255
|
+
// Ticket context validation and initial prompt
|
|
1256
|
+
let computedInitialPrompt = initialPrompt;
|
|
1257
|
+
if (ticketContext) {
|
|
1258
|
+
if (typeof ticketContext.ticketId !== 'string' ||
|
|
1259
|
+
typeof ticketContext.title !== 'string' ||
|
|
1260
|
+
typeof ticketContext.url !== 'string') {
|
|
1261
|
+
res.status(400).json({
|
|
1262
|
+
error: 'ticketContext requires string ticketId, title, and url',
|
|
1263
|
+
});
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
if (ticketContext.source !== 'github' &&
|
|
1267
|
+
ticketContext.source !== 'jira') {
|
|
1268
|
+
res
|
|
1269
|
+
.status(400)
|
|
1270
|
+
.json({ error: "ticketContext.source must be 'github' or 'jira'" });
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
if (!configuredWorkspaces.includes(ticketContext.repoPath)) {
|
|
1274
|
+
res.status(400).json({
|
|
1275
|
+
error: 'ticketContext.repoPath is not a configured workspace',
|
|
1276
|
+
});
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
if (ticketContext.source === 'github' &&
|
|
1280
|
+
!/^GH-\d+$/.test(ticketContext.ticketId)) {
|
|
1281
|
+
res.status(400).json({
|
|
1282
|
+
error: 'ticketContext.ticketId for github must match GH-<number>',
|
|
1283
|
+
});
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
if (ticketContext.source === 'jira' &&
|
|
1287
|
+
!/^[A-Z][A-Z0-9]*-\d+$/.test(ticketContext.ticketId)) {
|
|
1288
|
+
res.status(400).json({
|
|
1289
|
+
error: 'ticketContext.ticketId must match <PROJECT>-<number>',
|
|
1290
|
+
});
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
const settings = freshConfig.repoSettings?.[ticketContext.repoPath];
|
|
1294
|
+
const template = settings?.promptStartWork ??
|
|
1295
|
+
'You are working on ticket {ticketId}: {title}\n\nTicket URL: {ticketUrl}\n\nPlease start by understanding the issue and proposing an approach.';
|
|
1296
|
+
computedInitialPrompt = template
|
|
1297
|
+
.replace(/\{ticketId\}/g, ticketContext.ticketId)
|
|
1298
|
+
.replace(/\{title\}/g, ticketContext.title)
|
|
1299
|
+
.replace(/\{ticketUrl\}/g, ticketContext.url)
|
|
1300
|
+
.replace(/\{description\}/g, ticketContext.description ?? '');
|
|
1301
|
+
}
|
|
1302
|
+
const displayName = sessions.nextAgentName();
|
|
1303
|
+
// Compute tmux-specific display name from repo + branch for identifiable tmux ls output
|
|
1304
|
+
// UI displayName stays as "Agent N" — tmux name and UI name are independent
|
|
1305
|
+
// generateTmuxSessionName handles all sanitization and truncation
|
|
1306
|
+
const tmuxDisplayName = requestBranchName
|
|
1307
|
+
? `${name}-${requestBranchName}`
|
|
1308
|
+
: name;
|
|
1309
|
+
const session = sessions.create({
|
|
1310
|
+
type: 'agent',
|
|
1311
|
+
agent: resolvedAgent,
|
|
1312
|
+
repoName: name,
|
|
1313
|
+
repoPath,
|
|
1314
|
+
worktreePath: worktreePath ?? null,
|
|
1315
|
+
cwd,
|
|
1316
|
+
branchName: requestBranchName || '', // caller may provide; branch watcher enriches later
|
|
1317
|
+
displayName,
|
|
1318
|
+
tmuxDisplayName,
|
|
1319
|
+
args,
|
|
1320
|
+
configPath: CONFIG_PATH,
|
|
1321
|
+
useTmux: resolved.useTmux,
|
|
1322
|
+
yolo: resolved.yolo,
|
|
1323
|
+
claudeArgs: resolved.claudeArgs,
|
|
1324
|
+
continuePolicy: resolved.continuePolicy,
|
|
1325
|
+
...(safeCols != null && { cols: safeCols }),
|
|
1326
|
+
...(safeRows != null && { rows: safeRows }),
|
|
1327
|
+
needsBranchRename: needsBranchRename ?? false,
|
|
1328
|
+
branchRenamePrompt: branchRenamePrompt ?? '',
|
|
1329
|
+
...(computedInitialPrompt != null && {
|
|
1330
|
+
initialPrompt: computedInitialPrompt,
|
|
1331
|
+
}),
|
|
1332
|
+
});
|
|
1333
|
+
// Write worktree metadata if in a worktree
|
|
1334
|
+
if (worktreePath) {
|
|
1335
|
+
writeMeta(CONFIG_PATH, {
|
|
1336
|
+
worktreePath: cwd,
|
|
1337
|
+
displayName,
|
|
1338
|
+
lastActivity: new Date().toISOString(),
|
|
1339
|
+
branchName: requestBranchName || '',
|
|
1340
|
+
});
|
|
1341
|
+
}
|
|
1342
|
+
gitWatcher.watch(session.cwd);
|
|
1343
|
+
if (ticketContext) {
|
|
1344
|
+
transitionOnSessionCreate(ticketContext).catch((err) => {
|
|
1345
|
+
logger.error('[index] transition on session create failed:', err);
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
res.status(201).json(session);
|
|
1349
|
+
});
|
|
1350
|
+
// DELETE /sessions/:id
|
|
1351
|
+
app.delete('/sessions/:id', requireAuth, (req, res) => {
|
|
1352
|
+
const id = req.params['id'];
|
|
1353
|
+
try {
|
|
1354
|
+
const sessionToDelete = sessions.get(id);
|
|
1355
|
+
sessions.kill(id);
|
|
1356
|
+
push.removeSession(id);
|
|
1357
|
+
if (sessionToDelete)
|
|
1358
|
+
gitWatcher.unwatch(sessionToDelete.cwd);
|
|
1359
|
+
res.json({ ok: true });
|
|
1360
|
+
}
|
|
1361
|
+
catch (_) {
|
|
1362
|
+
res.status(404).json({ error: 'Session not found' });
|
|
1363
|
+
}
|
|
1364
|
+
});
|
|
1365
|
+
// PATCH /sessions/:id — update displayName and persist to metadata
|
|
1366
|
+
app.patch('/sessions/:id', requireAuth, (req, res) => {
|
|
1367
|
+
const { displayName } = req.body;
|
|
1368
|
+
if (!displayName) {
|
|
1369
|
+
res.status(400).json({ error: 'displayName is required' });
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
try {
|
|
1373
|
+
const id = req.params['id'];
|
|
1374
|
+
const updated = sessions.updateDisplayName(id, displayName);
|
|
1375
|
+
const session = sessions.get(id);
|
|
1376
|
+
if (session) {
|
|
1377
|
+
writeMeta(CONFIG_PATH, {
|
|
1378
|
+
worktreePath: session.cwd,
|
|
1379
|
+
displayName,
|
|
1380
|
+
lastActivity: session.lastActivity,
|
|
1381
|
+
});
|
|
1382
|
+
}
|
|
1383
|
+
res.json(updated);
|
|
1384
|
+
}
|
|
1385
|
+
catch (_) {
|
|
1386
|
+
res.status(404).json({ error: 'Session not found' });
|
|
1387
|
+
}
|
|
1388
|
+
});
|
|
1389
|
+
// POST /sessions/:id/image — upload clipboard image, proxy to system clipboard
|
|
1390
|
+
const ALLOWED_IMAGE_TYPES = [
|
|
1391
|
+
'image/png',
|
|
1392
|
+
'image/jpeg',
|
|
1393
|
+
'image/gif',
|
|
1394
|
+
'image/webp',
|
|
1395
|
+
];
|
|
1396
|
+
app.post('/sessions/:id/image', requireAuth, async (req, res) => {
|
|
1397
|
+
const { data, mimeType } = req.body;
|
|
1398
|
+
if (!data || !mimeType) {
|
|
1399
|
+
res.status(400).json({ error: 'data and mimeType are required' });
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
if (!ALLOWED_IMAGE_TYPES.includes(mimeType)) {
|
|
1403
|
+
res.status(400).json({ error: 'Unsupported image type: ' + mimeType });
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
// base64 is ~33% larger than binary; 10MB binary ≈ 13.3MB base64
|
|
1407
|
+
if (data.length > 14 * 1024 * 1024) {
|
|
1408
|
+
res.status(413).json({ error: 'Image too large (max 10MB)' });
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
const sessionId = req.params['id'];
|
|
1412
|
+
if (!sessions.get(sessionId)) {
|
|
1413
|
+
res.status(404).json({ error: 'Session not found' });
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
1416
|
+
try {
|
|
1417
|
+
const ext = extensionForMime(mimeType);
|
|
1418
|
+
const dir = path.join(os.tmpdir(), 'relay-ide', sessionId);
|
|
1419
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1420
|
+
const filePath = path.join(dir, 'paste-' + Date.now() + ext);
|
|
1421
|
+
fs.writeFileSync(filePath, Buffer.from(data, 'base64'));
|
|
1422
|
+
let clipboardSet = false;
|
|
1423
|
+
try {
|
|
1424
|
+
clipboardSet = await setClipboardImage(filePath, mimeType);
|
|
1425
|
+
}
|
|
1426
|
+
catch {
|
|
1427
|
+
// Clipboard tools failed — fall back to path
|
|
1428
|
+
}
|
|
1429
|
+
if (clipboardSet) {
|
|
1430
|
+
sessions.write(sessionId, '\x16');
|
|
1431
|
+
}
|
|
1432
|
+
res.json({ path: filePath, clipboardSet });
|
|
1433
|
+
}
|
|
1434
|
+
catch (err) {
|
|
1435
|
+
const message = err instanceof Error ? err.message : 'Image upload failed';
|
|
1436
|
+
res.status(500).json({ error: message });
|
|
1437
|
+
}
|
|
1438
|
+
});
|
|
1439
|
+
// GET /version — check current vs latest
|
|
1440
|
+
app.get('/version', requireAuth, async (_req, res) => {
|
|
1441
|
+
const current = getCurrentVersion();
|
|
1442
|
+
const channel = startupConfig.updateChannel ?? 'stable';
|
|
1443
|
+
const latest = await getLatestVersion(channel);
|
|
1444
|
+
const updateAvailable = latest !== null && semverLessThan(current, latest);
|
|
1445
|
+
res.json({ current, latest, updateAvailable, channel });
|
|
1446
|
+
});
|
|
1447
|
+
// POST /update — install latest version from npm
|
|
1448
|
+
app.post('/update', requireAuth, async (_req, res) => {
|
|
1449
|
+
try {
|
|
1450
|
+
const channel = startupConfig.updateChannel ?? 'stable';
|
|
1451
|
+
const tag = channel === 'nightly' ? 'nightly' : 'latest';
|
|
1452
|
+
await execFileAsync('npm', ['install', '-g', `relay-ide@${tag}`]);
|
|
1453
|
+
const restarting = serviceIsInstalled();
|
|
1454
|
+
if (restarting) {
|
|
1455
|
+
stopEventBatching();
|
|
1456
|
+
stopTelemetry();
|
|
1457
|
+
serializeAll(configDir);
|
|
1458
|
+
}
|
|
1459
|
+
res.json({ ok: true, restarting });
|
|
1460
|
+
if (restarting) {
|
|
1461
|
+
setTimeout(() => process.exit(0), 1000);
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
catch (err) {
|
|
1465
|
+
const message = err instanceof Error ? err.message : 'Update failed';
|
|
1466
|
+
res.status(500).json({ ok: false, error: message });
|
|
1467
|
+
}
|
|
1468
|
+
});
|
|
1469
|
+
app.get('/update-channel', requireAuth, (_req, res) => {
|
|
1470
|
+
res.json({ channel: startupConfig.updateChannel ?? 'stable' });
|
|
1471
|
+
});
|
|
1472
|
+
app.put('/update-channel', requireAuth, (req, res) => {
|
|
1473
|
+
const { channel } = req.body;
|
|
1474
|
+
if (channel !== 'stable' && channel !== 'nightly') {
|
|
1475
|
+
res
|
|
1476
|
+
.status(400)
|
|
1477
|
+
.json({ error: 'Invalid channel. Must be "stable" or "nightly".' });
|
|
1478
|
+
return;
|
|
1479
|
+
}
|
|
1480
|
+
startupConfig.updateChannel = channel;
|
|
1481
|
+
saveConfig(CONFIG_PATH, startupConfig);
|
|
1482
|
+
versionCache.clear();
|
|
1483
|
+
res.json({ channel });
|
|
1484
|
+
});
|
|
1485
|
+
// Browser content viewer (token-based auth, not cookie auth)
|
|
1486
|
+
const browserContentRouter = createBrowserContentRouter(broadcastEvent);
|
|
1487
|
+
app.use(browserContentRouter);
|
|
1488
|
+
// Clean expired browser content tokens every hour
|
|
1489
|
+
const BROWSER_TOKEN_TTL = 24 * 60 * 60 * 1000;
|
|
1490
|
+
setInterval(() => cleanExpiredTokens(BROWSER_TOKEN_TTL), 60 * 60 * 1000);
|
|
1491
|
+
// Clean up orphaned tmux sessions from previous runs (skip any adopted by restore)
|
|
1492
|
+
// Skip in dev mode — another server instance owns these sessions
|
|
1493
|
+
if (process.env.NO_PIN === '1') {
|
|
1494
|
+
logger.info('Dev mode: skipping orphaned tmux session cleanup.');
|
|
1495
|
+
}
|
|
1496
|
+
else
|
|
1497
|
+
try {
|
|
1498
|
+
const adoptedNames = activeTmuxSessionNames();
|
|
1499
|
+
const { stdout } = await execFileAsync('tmux', [
|
|
1500
|
+
'list-sessions',
|
|
1501
|
+
'-F',
|
|
1502
|
+
'#{session_name}',
|
|
1503
|
+
]);
|
|
1504
|
+
const tmuxPrefix = getTmuxPrefix();
|
|
1505
|
+
const orphanedSessions = stdout
|
|
1506
|
+
.trim()
|
|
1507
|
+
.split('\n')
|
|
1508
|
+
.filter((name) => name.startsWith(tmuxPrefix) && !adoptedNames.has(name));
|
|
1509
|
+
for (const name of orphanedSessions) {
|
|
1510
|
+
execFileAsync('tmux', ['kill-session', '-t', name]).catch(() => { });
|
|
1511
|
+
}
|
|
1512
|
+
if (orphanedSessions.length > 0) {
|
|
1513
|
+
logger.info(`Cleaned up ${orphanedSessions.length} orphaned tmux session(s).`);
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
catch {
|
|
1517
|
+
// tmux not installed or no sessions — ignore
|
|
1518
|
+
}
|
|
1519
|
+
async function gracefulShutdown() {
|
|
1520
|
+
await stopPolling();
|
|
1521
|
+
stopEventBatching();
|
|
1522
|
+
stopTelemetry();
|
|
1523
|
+
closeAnalytics();
|
|
1524
|
+
branchWatcher.close();
|
|
1525
|
+
refWatcher.close();
|
|
1526
|
+
gitWatcher.close();
|
|
1527
|
+
server.close();
|
|
1528
|
+
// Serialize sessions to disk BEFORE killing them
|
|
1529
|
+
serializeAll(configDir);
|
|
1530
|
+
// Kill all active sessions (PTY + tmux)
|
|
1531
|
+
for (const s of sessions.list()) {
|
|
1532
|
+
try {
|
|
1533
|
+
sessions.kill(s.id);
|
|
1534
|
+
}
|
|
1535
|
+
catch {
|
|
1536
|
+
/* already exiting */
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
// Brief delay to let async tmux kill-session calls fire
|
|
1540
|
+
setTimeout(() => process.exit(0), 200);
|
|
1541
|
+
}
|
|
1542
|
+
process.on('SIGTERM', gracefulShutdown);
|
|
1543
|
+
process.on('SIGINT', gracefulShutdown);
|
|
1544
|
+
server.listen(startupConfig.port, startupConfig.host, () => {
|
|
1545
|
+
const addr = server.address();
|
|
1546
|
+
logger.info(`relay-ide listening on ${startupConfig.host}:${addr.port}`);
|
|
1547
|
+
});
|
|
1548
|
+
}
|
|
1549
|
+
main().catch((err) => logger.error('Unhandled fatal error:', err));
|