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,1124 @@
|
|
|
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, 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 { setupWebSocket } from './ws.js';
|
|
16
|
+
import { WorktreeWatcher, WORKTREE_DIRS, isValidWorktreePath, parseWorktreeListPorcelain, parseAllWorktrees } from './watcher.js';
|
|
17
|
+
import { isInstalled as serviceIsInstalled } from './service.js';
|
|
18
|
+
import { extensionForMime, setClipboardImage } from './clipboard.js';
|
|
19
|
+
import { listBranches, isBranchStale } from './git.js';
|
|
20
|
+
import * as push from './push.js';
|
|
21
|
+
import { initAnalytics, closeAnalytics, createAnalyticsRouter } from './analytics.js';
|
|
22
|
+
import { createWorkspaceRouter } from './workspaces.js';
|
|
23
|
+
import { createOrgDashboardRouter } from './org-dashboard.js';
|
|
24
|
+
import { createIntegrationGitHubRouter } from './integration-github.js';
|
|
25
|
+
import { createBranchLinkerRouter, invalidateBranchLinkerCache } from './branch-linker.js';
|
|
26
|
+
import { createHooksRouter } from './hooks.js';
|
|
27
|
+
import { createTicketTransitionsRouter } from './ticket-transitions.js';
|
|
28
|
+
import { createIntegrationJiraRouter } from './integration-jira.js';
|
|
29
|
+
import { createIntegrationLinearRouter } from './integration-linear.js';
|
|
30
|
+
import { startPolling, stopPolling } from './review-poller.js';
|
|
31
|
+
import { MOUNTAIN_NAMES } from './types.js';
|
|
32
|
+
import { semverLessThan } from './utils.js';
|
|
33
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
34
|
+
const __dirname = path.dirname(__filename);
|
|
35
|
+
const execFileAsync = promisify(execFile);
|
|
36
|
+
// When run via CLI bin, config lives in ~/.config/claude-remote-cli/
|
|
37
|
+
// When run directly (development), fall back to local config.json
|
|
38
|
+
const CONFIG_PATH = process.env.CLAUDE_REMOTE_CONFIG || path.join(__dirname, '..', '..', 'config.json');
|
|
39
|
+
const VERSION_CACHE_TTL = 5 * 60 * 1000;
|
|
40
|
+
let versionCache = null;
|
|
41
|
+
function getCurrentVersion() {
|
|
42
|
+
const pkgPath = path.join(__dirname, '..', '..', 'package.json');
|
|
43
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
44
|
+
return pkg.version;
|
|
45
|
+
}
|
|
46
|
+
async function getLatestVersion() {
|
|
47
|
+
const now = Date.now();
|
|
48
|
+
if (versionCache && now - versionCache.fetchedAt < VERSION_CACHE_TTL) {
|
|
49
|
+
return versionCache.latest;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const res = await fetch('https://registry.npmjs.org/claude-remote-cli/latest');
|
|
53
|
+
if (!res.ok)
|
|
54
|
+
return null;
|
|
55
|
+
const data = await res.json();
|
|
56
|
+
if (!data.version)
|
|
57
|
+
return null;
|
|
58
|
+
versionCache = { latest: data.version, fetchedAt: now };
|
|
59
|
+
return data.version;
|
|
60
|
+
}
|
|
61
|
+
catch (_) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function execErrorMessage(err, fallback) {
|
|
66
|
+
const e = err;
|
|
67
|
+
return (e.stderr || e.message || fallback).trim();
|
|
68
|
+
}
|
|
69
|
+
function scanReposInRoot(rootDir) {
|
|
70
|
+
const repos = [];
|
|
71
|
+
let entries;
|
|
72
|
+
try {
|
|
73
|
+
entries = fs.readdirSync(rootDir, { withFileTypes: true });
|
|
74
|
+
}
|
|
75
|
+
catch (_) {
|
|
76
|
+
return repos;
|
|
77
|
+
}
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
if (!entry.isDirectory() || entry.name.startsWith('.'))
|
|
80
|
+
continue;
|
|
81
|
+
const fullPath = path.join(rootDir, entry.name);
|
|
82
|
+
const dotGit = path.join(fullPath, '.git');
|
|
83
|
+
try {
|
|
84
|
+
if (fs.statSync(dotGit).isDirectory()) {
|
|
85
|
+
repos.push({ name: entry.name, path: fullPath, root: rootDir });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch (_) {
|
|
89
|
+
// .git doesn't exist — not a repo
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return repos;
|
|
93
|
+
}
|
|
94
|
+
function scanAllRepos(rootDirs) {
|
|
95
|
+
const repos = [];
|
|
96
|
+
for (const rootDir of rootDirs) {
|
|
97
|
+
repos.push(...scanReposInRoot(rootDir));
|
|
98
|
+
}
|
|
99
|
+
return repos;
|
|
100
|
+
}
|
|
101
|
+
function parseTTL(ttl) {
|
|
102
|
+
if (typeof ttl !== 'string')
|
|
103
|
+
return 24 * 60 * 60 * 1000;
|
|
104
|
+
const match = ttl.match(/^(\d+)([smhd])$/);
|
|
105
|
+
if (!match)
|
|
106
|
+
return 24 * 60 * 60 * 1000;
|
|
107
|
+
const value = parseInt(match[1], 10);
|
|
108
|
+
switch (match[2]) {
|
|
109
|
+
case 's': return value * 1000;
|
|
110
|
+
case 'm': return value * 60 * 1000;
|
|
111
|
+
case 'h': return value * 60 * 60 * 1000;
|
|
112
|
+
case 'd': return value * 24 * 60 * 60 * 1000;
|
|
113
|
+
default: return 24 * 60 * 60 * 1000;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function promptPin(question) {
|
|
117
|
+
return new Promise((resolve) => {
|
|
118
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
119
|
+
rl.question(question, (answer) => {
|
|
120
|
+
rl.close();
|
|
121
|
+
resolve(answer.trim());
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
function ensureGitignore(repoPath, entry) {
|
|
126
|
+
const gitignorePath = path.join(repoPath, '.gitignore');
|
|
127
|
+
try {
|
|
128
|
+
if (fs.existsSync(gitignorePath)) {
|
|
129
|
+
const content = fs.readFileSync(gitignorePath, 'utf-8');
|
|
130
|
+
if (content.split('\n').some((line) => line.trim() === entry))
|
|
131
|
+
return;
|
|
132
|
+
const prefix = content.length > 0 && !content.endsWith('\n') ? '\n' : '';
|
|
133
|
+
fs.appendFileSync(gitignorePath, prefix + entry + '\n');
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
fs.writeFileSync(gitignorePath, entry + '\n');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch (_) {
|
|
140
|
+
// Non-fatal: gitignore update failure shouldn't block session creation
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async function main() {
|
|
144
|
+
// Ignore SIGPIPE: node-pty can propagate pipe breaks causing unexpected session exits
|
|
145
|
+
process.on('SIGPIPE', () => { });
|
|
146
|
+
// Ignore SIGHUP: keep server alive if controlling terminal disconnects
|
|
147
|
+
process.on('SIGHUP', () => { });
|
|
148
|
+
ensureMetaDir(CONFIG_PATH);
|
|
149
|
+
let config;
|
|
150
|
+
try {
|
|
151
|
+
config = loadConfig(CONFIG_PATH);
|
|
152
|
+
}
|
|
153
|
+
catch (_) {
|
|
154
|
+
config = { ...DEFAULTS };
|
|
155
|
+
saveConfig(CONFIG_PATH, config);
|
|
156
|
+
}
|
|
157
|
+
// CLI flag overrides
|
|
158
|
+
if (process.env.CLAUDE_REMOTE_PORT)
|
|
159
|
+
config.port = parseInt(process.env.CLAUDE_REMOTE_PORT, 10);
|
|
160
|
+
if (process.env.CLAUDE_REMOTE_HOST)
|
|
161
|
+
config.host = process.env.CLAUDE_REMOTE_HOST;
|
|
162
|
+
push.ensureVapidKeys(config, CONFIG_PATH, saveConfig);
|
|
163
|
+
const configDir = path.dirname(CONFIG_PATH);
|
|
164
|
+
try {
|
|
165
|
+
initAnalytics(configDir);
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
console.warn('Analytics disabled: failed to initialize:', err instanceof Error ? err.message : err);
|
|
169
|
+
}
|
|
170
|
+
if (config.pinHash && auth.isLegacyHash(config.pinHash)) {
|
|
171
|
+
console.log('Migrating legacy PIN hash to scrypt. You will need to set a new PIN.');
|
|
172
|
+
delete config.pinHash;
|
|
173
|
+
saveConfig(CONFIG_PATH, config);
|
|
174
|
+
}
|
|
175
|
+
if (!config.pinHash) {
|
|
176
|
+
if (!process.stdin.isTTY) {
|
|
177
|
+
console.error('No PIN configured. Run claude-remote-cli interactively first to set a PIN.');
|
|
178
|
+
process.exit(1);
|
|
179
|
+
}
|
|
180
|
+
const pin = await promptPin('Set up a PIN for claude-remote-cli:');
|
|
181
|
+
config.pinHash = await auth.hashPin(pin);
|
|
182
|
+
saveConfig(CONFIG_PATH, config);
|
|
183
|
+
console.log('PIN set successfully.');
|
|
184
|
+
}
|
|
185
|
+
const authenticatedTokens = new Set();
|
|
186
|
+
// Build frontend if missing (e.g. fresh clone in development)
|
|
187
|
+
const frontendDir = path.join(__dirname, '..', 'frontend');
|
|
188
|
+
if (!fs.existsSync(path.join(frontendDir, 'index.html'))) {
|
|
189
|
+
const packageRoot = path.join(__dirname, '..', '..');
|
|
190
|
+
const viteConfig = path.join(packageRoot, 'frontend', 'vite.config.ts');
|
|
191
|
+
if (fs.existsSync(viteConfig)) {
|
|
192
|
+
console.log('Frontend not built — building now...');
|
|
193
|
+
try {
|
|
194
|
+
await execFileAsync('npx', ['vite', 'build', '--config', 'frontend/vite.config.ts'], { cwd: packageRoot });
|
|
195
|
+
console.log('Frontend build complete.');
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
console.error('Frontend build failed:', err instanceof Error ? err.message : err);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
console.warn('Frontend assets missing and source not available — UI will not be served.');
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const app = express();
|
|
206
|
+
app.use(express.json({ limit: '15mb' }));
|
|
207
|
+
app.use(cookieParser());
|
|
208
|
+
app.use(express.static(frontendDir));
|
|
209
|
+
const requireAuth = (req, res, next) => {
|
|
210
|
+
const token = req.cookies && req.cookies.token;
|
|
211
|
+
if (!token || !authenticatedTokens.has(token)) {
|
|
212
|
+
res.status(401).json({ error: 'Unauthorized' });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
next();
|
|
216
|
+
};
|
|
217
|
+
function boolConfigEndpoints(name, defaultValue, onEnable) {
|
|
218
|
+
app.get(`/config/${name}`, requireAuth, (_req, res) => {
|
|
219
|
+
res.json({ [name]: config[name] ?? defaultValue });
|
|
220
|
+
});
|
|
221
|
+
app.patch(`/config/${name}`, requireAuth, async (req, res) => {
|
|
222
|
+
const value = req.body[name];
|
|
223
|
+
if (typeof value !== 'boolean') {
|
|
224
|
+
res.status(400).json({ error: `${name} must be a boolean` });
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (value && onEnable) {
|
|
228
|
+
try {
|
|
229
|
+
await onEnable();
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
res.status(400).json({ error: `Validation failed for ${name}` });
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
config[name] = value;
|
|
237
|
+
saveConfig(CONFIG_PATH, config);
|
|
238
|
+
res.json({ [name]: value });
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
const watcher = new WorktreeWatcher();
|
|
242
|
+
watcher.rebuild(config.workspaces || []);
|
|
243
|
+
const server = http.createServer(app);
|
|
244
|
+
const { broadcastEvent } = setupWebSocket(server, authenticatedTokens, watcher, CONFIG_PATH);
|
|
245
|
+
// Configure session defaults for hooks injection
|
|
246
|
+
sessions.configure({ port: config.port, forceOutputParser: config.forceOutputParser ?? false });
|
|
247
|
+
// Mount hooks router BEFORE auth middleware — hook callbacks come from localhost Claude Code
|
|
248
|
+
const hooksRouter = createHooksRouter({
|
|
249
|
+
getSession: sessions.get,
|
|
250
|
+
broadcastEvent,
|
|
251
|
+
fireStateChange: sessions.fireStateChange,
|
|
252
|
+
notifySessionAttention: push.notifySessionAttention,
|
|
253
|
+
configPath: CONFIG_PATH,
|
|
254
|
+
});
|
|
255
|
+
app.use('/hooks', hooksRouter);
|
|
256
|
+
// Mount workspace router
|
|
257
|
+
const workspaceRouter = createWorkspaceRouter({ configPath: CONFIG_PATH });
|
|
258
|
+
app.use('/workspaces', requireAuth, workspaceRouter);
|
|
259
|
+
// Mount GitHub integration router
|
|
260
|
+
const integrationGitHubRouter = createIntegrationGitHubRouter({ configPath: CONFIG_PATH });
|
|
261
|
+
app.use('/integration-github', requireAuth, integrationGitHubRouter);
|
|
262
|
+
// Mount Jira integration router
|
|
263
|
+
const integrationJiraRouter = createIntegrationJiraRouter({ configPath: CONFIG_PATH });
|
|
264
|
+
app.use('/integration-jira', requireAuth, integrationJiraRouter);
|
|
265
|
+
// Mount Linear integration router
|
|
266
|
+
const integrationLinearRouter = createIntegrationLinearRouter({ configPath: CONFIG_PATH });
|
|
267
|
+
app.use('/integration-linear', requireAuth, integrationLinearRouter);
|
|
268
|
+
// Mount branch linker router
|
|
269
|
+
const branchLinkerRouter = createBranchLinkerRouter({
|
|
270
|
+
configPath: CONFIG_PATH,
|
|
271
|
+
getActiveBranchNames: () => {
|
|
272
|
+
const map = new Map();
|
|
273
|
+
for (const s of sessions.list()) {
|
|
274
|
+
if (!s.branchName)
|
|
275
|
+
continue;
|
|
276
|
+
const existing = map.get(s.repoPath);
|
|
277
|
+
if (existing) {
|
|
278
|
+
existing.add(s.branchName);
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
map.set(s.repoPath, new Set([s.branchName]));
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return map;
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
app.use('/branch-linker', requireAuth, branchLinkerRouter);
|
|
288
|
+
// Mount ticket transitions router
|
|
289
|
+
const { router: ticketTransitionsRouter, transitionOnSessionCreate, checkPrTransitions } = createTicketTransitionsRouter({ configPath: CONFIG_PATH });
|
|
290
|
+
app.use('/ticket-transitions', requireAuth, ticketTransitionsRouter);
|
|
291
|
+
// Mount org dashboard router — use branchLinkerRouter.fetchLinks() directly (no loopback HTTP)
|
|
292
|
+
const orgDashboardRouter = createOrgDashboardRouter({ configPath: CONFIG_PATH, checkPrTransitions, getBranchLinks: () => branchLinkerRouter.fetchLinks() });
|
|
293
|
+
app.use('/org-dashboard', requireAuth, orgDashboardRouter);
|
|
294
|
+
// Mount analytics router
|
|
295
|
+
app.use('/analytics', requireAuth, createAnalyticsRouter(configDir));
|
|
296
|
+
// Restore sessions from a previous update restart
|
|
297
|
+
const restoredCount = await restoreFromDisk(configDir);
|
|
298
|
+
if (restoredCount > 0) {
|
|
299
|
+
console.log(`Restored ${restoredCount} session(s) from previous update.`);
|
|
300
|
+
}
|
|
301
|
+
// Populate session metadata cache in background (non-blocking)
|
|
302
|
+
populateMetaCache().catch(() => { });
|
|
303
|
+
// Build shared deps for review poller
|
|
304
|
+
function buildPollerDeps() {
|
|
305
|
+
return {
|
|
306
|
+
configPath: CONFIG_PATH,
|
|
307
|
+
getWorkspacePaths: () => config.workspaces ?? [],
|
|
308
|
+
getWorkspaceSettings: (wsPath) => config.workspaceSettings?.[wsPath],
|
|
309
|
+
createSession: async (opts) => {
|
|
310
|
+
const resolved = resolveSessionSettings(config, opts.repoPath, {});
|
|
311
|
+
const roots = config.rootDirs || [];
|
|
312
|
+
const root = roots.find((r) => opts.repoPath.startsWith(r)) || '';
|
|
313
|
+
const repoName = opts.repoPath.split('/').filter(Boolean).pop() || 'session';
|
|
314
|
+
const worktreeName = opts.worktreePath.split('/').pop() || '';
|
|
315
|
+
const displayName = sessions.nextAgentName();
|
|
316
|
+
sessions.create({
|
|
317
|
+
type: 'worktree',
|
|
318
|
+
agent: resolved.agent,
|
|
319
|
+
repoName,
|
|
320
|
+
repoPath: opts.worktreePath,
|
|
321
|
+
cwd: opts.worktreePath,
|
|
322
|
+
root,
|
|
323
|
+
worktreeName,
|
|
324
|
+
branchName: opts.branchName,
|
|
325
|
+
displayName,
|
|
326
|
+
args: [...resolved.claudeArgs, ...(resolved.yolo ? AGENT_YOLO_ARGS[resolved.agent] : [])],
|
|
327
|
+
configPath: CONFIG_PATH,
|
|
328
|
+
useTmux: resolved.useTmux,
|
|
329
|
+
...(opts.initialPrompt != null && { initialPrompt: opts.initialPrompt }),
|
|
330
|
+
});
|
|
331
|
+
},
|
|
332
|
+
broadcastEvent,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
// Start review request poller if enabled
|
|
336
|
+
if (config.automations?.autoCheckoutReviewRequests) {
|
|
337
|
+
startPolling(buildPollerDeps());
|
|
338
|
+
}
|
|
339
|
+
// Invalidate branch linker cache on session lifecycle changes
|
|
340
|
+
sessions.onSessionCreate(() => { invalidateBranchLinkerCache(); });
|
|
341
|
+
sessions.onSessionEnd(() => { invalidateBranchLinkerCache(); });
|
|
342
|
+
// Push notifications on session idle (skip when hooks already sent attention notification)
|
|
343
|
+
sessions.onIdleChange((sessionId, idle) => {
|
|
344
|
+
if (idle) {
|
|
345
|
+
const session = sessions.get(sessionId);
|
|
346
|
+
if (session && session.type !== 'terminal') {
|
|
347
|
+
// Dedup: if hooks fired an attention notification within last 10s, skip
|
|
348
|
+
if (session.hooksActive && session.lastAttentionNotifiedAt && Date.now() - session.lastAttentionNotifiedAt < 10000) {
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
push.notifySessionAttention(sessionId, session);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
// POST /auth
|
|
356
|
+
app.post('/auth', async (req, res) => {
|
|
357
|
+
const ip = (req.ip || req.connection.remoteAddress);
|
|
358
|
+
if (auth.isRateLimited(ip)) {
|
|
359
|
+
res.status(429).json({ error: 'Too many attempts. Try again later.' });
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
const { pin } = req.body;
|
|
363
|
+
if (!pin) {
|
|
364
|
+
res.status(400).json({ error: 'PIN required' });
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const valid = await auth.verifyPin(pin, config.pinHash);
|
|
368
|
+
if (!valid) {
|
|
369
|
+
auth.recordFailedAttempt(ip);
|
|
370
|
+
res.status(401).json({ error: 'Invalid PIN' });
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
auth.clearRateLimit(ip);
|
|
374
|
+
const token = auth.generateCookieToken();
|
|
375
|
+
authenticatedTokens.add(token);
|
|
376
|
+
const ttlMs = parseTTL(config.cookieTTL);
|
|
377
|
+
setTimeout(() => authenticatedTokens.delete(token), ttlMs);
|
|
378
|
+
res.cookie('token', token, {
|
|
379
|
+
httpOnly: true,
|
|
380
|
+
sameSite: 'strict',
|
|
381
|
+
maxAge: ttlMs,
|
|
382
|
+
});
|
|
383
|
+
res.json({ ok: true });
|
|
384
|
+
});
|
|
385
|
+
// GET /sessions
|
|
386
|
+
app.get('/sessions', requireAuth, (_req, res) => {
|
|
387
|
+
res.json(sessions.list());
|
|
388
|
+
});
|
|
389
|
+
// GET /repos — scan root dirs for repos
|
|
390
|
+
app.get('/repos', requireAuth, async (_req, res) => {
|
|
391
|
+
const repos = scanAllRepos(config.rootDirs || []);
|
|
392
|
+
// Also include legacy manually-added repos
|
|
393
|
+
if (config.repos) {
|
|
394
|
+
for (const repo of config.repos) {
|
|
395
|
+
if (!repos.some((r) => r.path === repo.path)) {
|
|
396
|
+
repos.push(repo);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
// Enrich with current branch (best-effort, parallel)
|
|
401
|
+
const enriched = await Promise.all(repos.map(async (repo) => {
|
|
402
|
+
try {
|
|
403
|
+
const { stdout } = await execFileAsync('git', ['symbolic-ref', '--short', 'HEAD'], { cwd: repo.path });
|
|
404
|
+
return { ...repo, defaultBranch: stdout.trim() };
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
return { ...repo, defaultBranch: null };
|
|
408
|
+
}
|
|
409
|
+
}));
|
|
410
|
+
res.json(enriched);
|
|
411
|
+
});
|
|
412
|
+
// GET /branches?repo=<path> — list local and remote branches for a repo
|
|
413
|
+
app.get('/branches', requireAuth, async (req, res) => {
|
|
414
|
+
const repoPath = typeof req.query.repo === 'string' ? req.query.repo : undefined;
|
|
415
|
+
const refresh = req.query.refresh === '1';
|
|
416
|
+
if (!repoPath) {
|
|
417
|
+
res.status(400).json({ error: 'repo query parameter is required' });
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
res.json(await listBranches(repoPath, { refresh }));
|
|
421
|
+
});
|
|
422
|
+
// GET /worktrees?repo=<path> — list worktrees; omit repo to scan all repos in all rootDirs
|
|
423
|
+
app.get('/worktrees', requireAuth, async (req, res) => {
|
|
424
|
+
const repoParam = typeof req.query.repo === 'string' ? req.query.repo : undefined;
|
|
425
|
+
const roots = config.rootDirs || [];
|
|
426
|
+
const worktrees = [];
|
|
427
|
+
let reposToScan;
|
|
428
|
+
if (repoParam) {
|
|
429
|
+
const root = roots.find(function (r) { return repoParam.startsWith(r); }) || '';
|
|
430
|
+
reposToScan = [{ path: repoParam, name: repoParam.split('/').filter(Boolean).pop() || '', root }];
|
|
431
|
+
}
|
|
432
|
+
else {
|
|
433
|
+
reposToScan = [];
|
|
434
|
+
for (const rootDir of roots) {
|
|
435
|
+
let entries;
|
|
436
|
+
try {
|
|
437
|
+
entries = fs.readdirSync(rootDir, { withFileTypes: true });
|
|
438
|
+
}
|
|
439
|
+
catch (_) {
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
for (const entry of entries) {
|
|
443
|
+
if (!entry.isDirectory() || entry.name.startsWith('.'))
|
|
444
|
+
continue;
|
|
445
|
+
const fullPath = path.join(rootDir, entry.name);
|
|
446
|
+
const dotGit = path.join(fullPath, '.git');
|
|
447
|
+
try {
|
|
448
|
+
if (fs.statSync(dotGit).isDirectory()) {
|
|
449
|
+
reposToScan.push({ name: entry.name, path: fullPath, root: rootDir });
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
catch (_) {
|
|
453
|
+
// .git doesn't exist — not a repo
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
for (const repo of reposToScan) {
|
|
459
|
+
// Use git worktree list to discover all worktrees (including those at arbitrary paths)
|
|
460
|
+
try {
|
|
461
|
+
const { stdout } = await execFileAsync('git', ['worktree', 'list', '--porcelain'], { cwd: repo.path });
|
|
462
|
+
const parsed = parseWorktreeListPorcelain(stdout, repo.path);
|
|
463
|
+
for (const wt of parsed) {
|
|
464
|
+
const dirName = wt.path.split('/').pop() || '';
|
|
465
|
+
const meta = readMeta(CONFIG_PATH, wt.path);
|
|
466
|
+
worktrees.push({
|
|
467
|
+
name: dirName,
|
|
468
|
+
path: wt.path,
|
|
469
|
+
repoName: repo.name,
|
|
470
|
+
repoPath: repo.path,
|
|
471
|
+
root: repo.root,
|
|
472
|
+
displayName: meta?.displayName || wt.branch || dirName,
|
|
473
|
+
lastActivity: meta?.lastActivity || '',
|
|
474
|
+
branchName: wt.branch || meta?.branchName || dirName,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
// git worktree list failed — fall back to directory scanning
|
|
480
|
+
for (const dir of WORKTREE_DIRS) {
|
|
481
|
+
const worktreeDir = path.join(repo.path, dir);
|
|
482
|
+
let entries;
|
|
483
|
+
try {
|
|
484
|
+
entries = fs.readdirSync(worktreeDir, { withFileTypes: true });
|
|
485
|
+
}
|
|
486
|
+
catch (_) {
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
for (const entry of entries) {
|
|
490
|
+
if (!entry.isDirectory())
|
|
491
|
+
continue;
|
|
492
|
+
const wtPath = path.join(worktreeDir, entry.name);
|
|
493
|
+
const meta = readMeta(CONFIG_PATH, wtPath);
|
|
494
|
+
worktrees.push({
|
|
495
|
+
name: entry.name,
|
|
496
|
+
path: wtPath,
|
|
497
|
+
repoName: repo.name,
|
|
498
|
+
repoPath: repo.path,
|
|
499
|
+
root: repo.root,
|
|
500
|
+
displayName: meta?.displayName || '',
|
|
501
|
+
lastActivity: meta?.lastActivity || '',
|
|
502
|
+
branchName: meta?.branchName || entry.name,
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
// Deduplicate by path (a worktree can appear via multiple repo scans)
|
|
509
|
+
const seen = new Set();
|
|
510
|
+
const unique = worktrees.filter(wt => {
|
|
511
|
+
if (seen.has(wt.path))
|
|
512
|
+
return false;
|
|
513
|
+
seen.add(wt.path);
|
|
514
|
+
return true;
|
|
515
|
+
});
|
|
516
|
+
res.json(unique);
|
|
517
|
+
});
|
|
518
|
+
// GET /config/defaultAgent — get default coding agent
|
|
519
|
+
app.get('/config/defaultAgent', requireAuth, (_req, res) => {
|
|
520
|
+
res.json({ defaultAgent: config.defaultAgent || 'claude' });
|
|
521
|
+
});
|
|
522
|
+
// PATCH /config/defaultAgent — set default coding agent
|
|
523
|
+
app.patch('/config/defaultAgent', requireAuth, (req, res) => {
|
|
524
|
+
const { defaultAgent } = req.body;
|
|
525
|
+
if (!defaultAgent || (defaultAgent !== 'claude' && defaultAgent !== 'codex')) {
|
|
526
|
+
res.status(400).json({ error: 'defaultAgent must be "claude" or "codex"' });
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
config.defaultAgent = defaultAgent;
|
|
530
|
+
saveConfig(CONFIG_PATH, config);
|
|
531
|
+
res.json({ defaultAgent: config.defaultAgent });
|
|
532
|
+
});
|
|
533
|
+
boolConfigEndpoints('defaultContinue', true);
|
|
534
|
+
boolConfigEndpoints('defaultYolo', false);
|
|
535
|
+
boolConfigEndpoints('launchInTmux', false, async () => {
|
|
536
|
+
await execFileAsync('tmux', ['-V']);
|
|
537
|
+
});
|
|
538
|
+
boolConfigEndpoints('defaultNotifications', true);
|
|
539
|
+
// GET /config/automations — get automation settings
|
|
540
|
+
app.get('/config/automations', requireAuth, (_req, res) => {
|
|
541
|
+
res.json(config.automations ?? {});
|
|
542
|
+
});
|
|
543
|
+
// PATCH /config/automations — update automation settings and start/stop poller
|
|
544
|
+
app.patch('/config/automations', requireAuth, (req, res) => {
|
|
545
|
+
const body = req.body;
|
|
546
|
+
const prev = config.automations ?? {};
|
|
547
|
+
const next = { ...prev };
|
|
548
|
+
if (typeof body.autoCheckoutReviewRequests === 'boolean') {
|
|
549
|
+
next.autoCheckoutReviewRequests = body.autoCheckoutReviewRequests;
|
|
550
|
+
}
|
|
551
|
+
if (typeof body.autoReviewOnCheckout === 'boolean') {
|
|
552
|
+
next.autoReviewOnCheckout = body.autoReviewOnCheckout;
|
|
553
|
+
}
|
|
554
|
+
if (typeof body.pollIntervalMs === 'number' && body.pollIntervalMs >= 60000) {
|
|
555
|
+
next.pollIntervalMs = body.pollIntervalMs;
|
|
556
|
+
}
|
|
557
|
+
// Enforce: auto-review requires auto-checkout
|
|
558
|
+
if (!next.autoCheckoutReviewRequests) {
|
|
559
|
+
next.autoReviewOnCheckout = false;
|
|
560
|
+
}
|
|
561
|
+
config.automations = next;
|
|
562
|
+
saveConfig(CONFIG_PATH, config);
|
|
563
|
+
// Start or stop poller based on new setting
|
|
564
|
+
if (next.autoCheckoutReviewRequests) {
|
|
565
|
+
stopPolling();
|
|
566
|
+
startPolling(buildPollerDeps());
|
|
567
|
+
}
|
|
568
|
+
else {
|
|
569
|
+
stopPolling();
|
|
570
|
+
}
|
|
571
|
+
res.json(next);
|
|
572
|
+
});
|
|
573
|
+
// GET /config/workspace-groups — return workspace group configuration
|
|
574
|
+
app.get('/config/workspace-groups', requireAuth, (_req, res) => {
|
|
575
|
+
res.json({ groups: config.workspaceGroups ?? {} });
|
|
576
|
+
});
|
|
577
|
+
// GET /push/vapid-key
|
|
578
|
+
app.get('/push/vapid-key', requireAuth, (_req, res) => {
|
|
579
|
+
const key = push.getVapidPublicKey();
|
|
580
|
+
if (!key) {
|
|
581
|
+
res.status(501).json({ error: 'Push not available' });
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
res.json({ vapidPublicKey: key });
|
|
585
|
+
});
|
|
586
|
+
// POST /push/subscribe
|
|
587
|
+
app.post('/push/subscribe', requireAuth, (req, res) => {
|
|
588
|
+
const { subscription, sessionIds } = req.body;
|
|
589
|
+
if (!subscription?.endpoint) {
|
|
590
|
+
res.status(400).json({ error: 'subscription required' });
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
push.subscribe(subscription, sessionIds || []);
|
|
594
|
+
res.json({ ok: true });
|
|
595
|
+
});
|
|
596
|
+
// POST /push/unsubscribe
|
|
597
|
+
app.post('/push/unsubscribe', requireAuth, (req, res) => {
|
|
598
|
+
const { endpoint } = req.body;
|
|
599
|
+
if (!endpoint) {
|
|
600
|
+
res.status(400).json({ error: 'endpoint required' });
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
push.unsubscribe(endpoint);
|
|
604
|
+
res.json({ ok: true });
|
|
605
|
+
});
|
|
606
|
+
// DELETE /worktrees — remove a worktree, prune, and delete its branch
|
|
607
|
+
app.delete('/worktrees', requireAuth, async (req, res) => {
|
|
608
|
+
const { worktreePath, repoPath } = req.body;
|
|
609
|
+
if (!worktreePath || !repoPath) {
|
|
610
|
+
res.status(400).json({ error: 'worktreePath and repoPath are required' });
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
// Validate the path is a real git worktree (not the main worktree)
|
|
614
|
+
try {
|
|
615
|
+
const { stdout: wtListOut } = await execFileAsync('git', ['worktree', 'list', '--porcelain'], { cwd: repoPath });
|
|
616
|
+
const allWorktrees = parseAllWorktrees(wtListOut, repoPath);
|
|
617
|
+
const isKnownWorktree = allWorktrees.some(wt => wt.path === path.resolve(worktreePath) && !wt.isMain);
|
|
618
|
+
if (!isKnownWorktree) {
|
|
619
|
+
res.status(400).json({ error: 'Path is not a recognized git worktree' });
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
catch {
|
|
624
|
+
// If git worktree list fails, fall back to the directory-name check
|
|
625
|
+
if (!isValidWorktreePath(worktreePath)) {
|
|
626
|
+
res.status(400).json({ error: 'Path is not inside a worktree directory' });
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
// Multiple sessions per worktree allowed (multi-tab support)
|
|
631
|
+
// Derive branch name from metadata or worktree directory name
|
|
632
|
+
const meta = readMeta(CONFIG_PATH, worktreePath);
|
|
633
|
+
const branchName = (meta && meta.branchName) || worktreePath.split('/').pop() || '';
|
|
634
|
+
try {
|
|
635
|
+
// Will fail if uncommitted changes -- no --force
|
|
636
|
+
await execFileAsync('git', ['worktree', 'remove', worktreePath], { cwd: repoPath });
|
|
637
|
+
}
|
|
638
|
+
catch (err) {
|
|
639
|
+
// If git worktree remove fails, the directory may be an orphaned worktree
|
|
640
|
+
// that git no longer tracks. Try to remove the directory directly.
|
|
641
|
+
if (fs.existsSync(worktreePath)) {
|
|
642
|
+
try {
|
|
643
|
+
fs.rmSync(worktreePath, { recursive: true });
|
|
644
|
+
}
|
|
645
|
+
catch (rmErr) {
|
|
646
|
+
res.status(500).json({ error: execErrorMessage(rmErr, 'Failed to remove worktree directory') });
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
// If directory doesn't exist, the worktree is already gone — continue to cleanup
|
|
651
|
+
}
|
|
652
|
+
try {
|
|
653
|
+
// Prune stale worktree refs
|
|
654
|
+
await execFileAsync('git', ['worktree', 'prune'], { cwd: repoPath });
|
|
655
|
+
}
|
|
656
|
+
catch (_) {
|
|
657
|
+
// Non-fatal: prune failure doesn't block success
|
|
658
|
+
}
|
|
659
|
+
if (branchName) {
|
|
660
|
+
try {
|
|
661
|
+
// Delete the branch
|
|
662
|
+
await execFileAsync('git', ['branch', '-D', branchName], { cwd: repoPath });
|
|
663
|
+
}
|
|
664
|
+
catch (_) {
|
|
665
|
+
// Non-fatal: branch may not exist or may be checked out elsewhere
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
// Clean up metadata file
|
|
669
|
+
deleteMeta(CONFIG_PATH, worktreePath);
|
|
670
|
+
res.json({ ok: true });
|
|
671
|
+
});
|
|
672
|
+
// POST /sessions
|
|
673
|
+
app.post('/sessions', requireAuth, async (req, res) => {
|
|
674
|
+
const { repoPath, repoName, worktreePath, branchName, claudeArgs, yolo, agent, useTmux, cols, rows, needsBranchRename, branchRenamePrompt, ticketContext } = req.body;
|
|
675
|
+
if (!repoPath) {
|
|
676
|
+
res.status(400).json({ error: 'repoPath is required' });
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
// Sanitize optional terminal dimensions
|
|
680
|
+
const safeCols = typeof cols === 'number' && Number.isFinite(cols) && cols >= 1 && cols <= 500 ? Math.round(cols) : undefined;
|
|
681
|
+
const safeRows = typeof rows === 'number' && Number.isFinite(rows) && rows >= 1 && rows <= 200 ? Math.round(rows) : undefined;
|
|
682
|
+
const resolved = resolveSessionSettings(config, repoPath, { agent, yolo, useTmux, claudeArgs });
|
|
683
|
+
const resolvedAgent = resolved.agent;
|
|
684
|
+
const name = repoName || repoPath.split('/').filter(Boolean).pop() || 'session';
|
|
685
|
+
let initialPrompt;
|
|
686
|
+
if (ticketContext && (typeof ticketContext.ticketId !== 'string' || typeof ticketContext.title !== 'string' || typeof ticketContext.url !== 'string')) {
|
|
687
|
+
res.status(400).json({ error: 'ticketContext requires string ticketId, title, and url' });
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
if (ticketContext) {
|
|
691
|
+
// Use ticketContext.repoPath (workspace root) for settings lookup
|
|
692
|
+
const settings = config.workspaceSettings?.[ticketContext.repoPath];
|
|
693
|
+
const template = settings?.promptStartWork ??
|
|
694
|
+
'You are working on ticket {ticketId}: {title}\n\nTicket URL: {ticketUrl}\n\nPlease start by understanding the issue and proposing an approach.';
|
|
695
|
+
initialPrompt = template
|
|
696
|
+
.replace(/\{ticketId\}/g, ticketContext.ticketId)
|
|
697
|
+
.replace(/\{title\}/g, ticketContext.title)
|
|
698
|
+
.replace(/\{ticketUrl\}/g, ticketContext.url)
|
|
699
|
+
.replace(/\{description\}/g, ticketContext.description ?? '');
|
|
700
|
+
}
|
|
701
|
+
const baseArgs = [
|
|
702
|
+
...(resolved.claudeArgs),
|
|
703
|
+
...(resolved.yolo ? AGENT_YOLO_ARGS[resolvedAgent] : []),
|
|
704
|
+
];
|
|
705
|
+
// Compute root by matching repoPath against configured rootDirs
|
|
706
|
+
const roots = config.rootDirs || [];
|
|
707
|
+
const root = roots.find(function (r) { return repoPath.startsWith(r); }) || '';
|
|
708
|
+
let args;
|
|
709
|
+
let cwd;
|
|
710
|
+
let worktreeName;
|
|
711
|
+
let sessionRepoPath;
|
|
712
|
+
let resolvedBranch = '';
|
|
713
|
+
let isMountainName = false;
|
|
714
|
+
if (worktreePath) {
|
|
715
|
+
// Check if the worktree's branch is stale (merged/at base) and needs a fresh name
|
|
716
|
+
const currentBranchResult = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: worktreePath }).catch(() => null);
|
|
717
|
+
const currentBranch = currentBranchResult?.stdout.trim();
|
|
718
|
+
if (currentBranch && !needsBranchRename) {
|
|
719
|
+
const stale = await isBranchStale(worktreePath, currentBranch);
|
|
720
|
+
if (stale) {
|
|
721
|
+
// Generate unique temp branch: <mountain>-<short-timestamp>
|
|
722
|
+
const mountainName = worktreePath.split('/').pop() || 'branch';
|
|
723
|
+
const suffix = Date.now().toString(36).slice(-4);
|
|
724
|
+
const tempBranch = `${mountainName}-${suffix}`;
|
|
725
|
+
try {
|
|
726
|
+
await execFileAsync('git', ['checkout', '-b', tempBranch], { cwd: worktreePath });
|
|
727
|
+
}
|
|
728
|
+
catch {
|
|
729
|
+
await execFileAsync('git', ['branch', '-m', tempBranch], { cwd: worktreePath }).catch(() => { });
|
|
730
|
+
}
|
|
731
|
+
isMountainName = true;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
// Only use --continue if:
|
|
735
|
+
// 1. Not a brand-new worktree (needsBranchRename flag)
|
|
736
|
+
// 2. A prior Claude session exists in this directory (.claude/ dir present)
|
|
737
|
+
// 3. Branch is not stale (isMountainName means we just created a fresh branch)
|
|
738
|
+
const hasPriorSession = !needsBranchRename && !isMountainName && fs.existsSync(path.join(worktreePath, '.claude'));
|
|
739
|
+
args = hasPriorSession ? [...AGENT_CONTINUE_ARGS[resolvedAgent], ...baseArgs] : [...baseArgs];
|
|
740
|
+
cwd = worktreePath;
|
|
741
|
+
sessionRepoPath = worktreePath;
|
|
742
|
+
worktreeName = worktreePath.split('/').pop() || '';
|
|
743
|
+
}
|
|
744
|
+
else {
|
|
745
|
+
// Create new worktree via git
|
|
746
|
+
let dirName;
|
|
747
|
+
if (branchName) {
|
|
748
|
+
dirName = branchName.replace(/\//g, '-');
|
|
749
|
+
resolvedBranch = branchName;
|
|
750
|
+
}
|
|
751
|
+
else {
|
|
752
|
+
// Pick the next mountain name from the cycling list
|
|
753
|
+
const idx = config.nextMountainIndex || 0;
|
|
754
|
+
const picked = MOUNTAIN_NAMES[idx % MOUNTAIN_NAMES.length];
|
|
755
|
+
dirName = picked;
|
|
756
|
+
resolvedBranch = picked;
|
|
757
|
+
isMountainName = true;
|
|
758
|
+
config.nextMountainIndex = (idx + 1) % MOUNTAIN_NAMES.length;
|
|
759
|
+
saveConfig(CONFIG_PATH, config);
|
|
760
|
+
}
|
|
761
|
+
const worktreeDir = path.join(repoPath, WORKTREE_DIRS[0]);
|
|
762
|
+
let targetDir = path.join(worktreeDir, dirName);
|
|
763
|
+
if (fs.existsSync(targetDir)) {
|
|
764
|
+
targetDir = targetDir + '-' + Date.now().toString(36);
|
|
765
|
+
dirName = path.basename(targetDir);
|
|
766
|
+
}
|
|
767
|
+
for (const dir of WORKTREE_DIRS) {
|
|
768
|
+
ensureGitignore(repoPath, dir + '/');
|
|
769
|
+
}
|
|
770
|
+
try {
|
|
771
|
+
// Check if branch exists locally or on a remote
|
|
772
|
+
let branchExists = false;
|
|
773
|
+
if (branchName) {
|
|
774
|
+
const localCheck = await execFileAsync('git', ['rev-parse', '--verify', branchName], { cwd: repoPath }).then(() => true, () => false);
|
|
775
|
+
if (localCheck) {
|
|
776
|
+
branchExists = true;
|
|
777
|
+
}
|
|
778
|
+
else {
|
|
779
|
+
const remoteCheck = await execFileAsync('git', ['rev-parse', '--verify', 'origin/' + branchName], { cwd: repoPath }).then(() => true, () => false);
|
|
780
|
+
if (remoteCheck) {
|
|
781
|
+
branchExists = true;
|
|
782
|
+
resolvedBranch = 'origin/' + branchName;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
if (branchName && branchExists) {
|
|
787
|
+
// Check if branch is already checked out in an existing worktree
|
|
788
|
+
const { stdout: wtListOut } = await execFileAsync('git', ['worktree', 'list', '--porcelain'], { cwd: repoPath });
|
|
789
|
+
const allWorktrees = parseAllWorktrees(wtListOut, repoPath);
|
|
790
|
+
const existingWt = allWorktrees.find(wt => wt.branch === branchName);
|
|
791
|
+
if (existingWt) {
|
|
792
|
+
// Branch already checked out — redirect to the existing worktree
|
|
793
|
+
if (existingWt.isMain) {
|
|
794
|
+
// Main worktree → create a repo session
|
|
795
|
+
const existingRepoSession = sessions.findRepoSession(repoPath);
|
|
796
|
+
if (existingRepoSession) {
|
|
797
|
+
res.status(409).json({ error: 'A session already exists for this repo', sessionId: existingRepoSession.id });
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
const repoSession = sessions.create({
|
|
801
|
+
type: 'repo',
|
|
802
|
+
agent: resolvedAgent,
|
|
803
|
+
repoName: name,
|
|
804
|
+
repoPath,
|
|
805
|
+
cwd: repoPath,
|
|
806
|
+
root,
|
|
807
|
+
displayName: sessions.nextAgentName(),
|
|
808
|
+
args: baseArgs,
|
|
809
|
+
useTmux: resolved.useTmux,
|
|
810
|
+
...(safeCols != null && { cols: safeCols }),
|
|
811
|
+
...(safeRows != null && { rows: safeRows }),
|
|
812
|
+
...(initialPrompt != null && { initialPrompt }),
|
|
813
|
+
});
|
|
814
|
+
if (ticketContext) {
|
|
815
|
+
transitionOnSessionCreate(ticketContext).catch((err) => {
|
|
816
|
+
console.error('[index] transition on session create failed:', err);
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
res.status(201).json(repoSession);
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
else {
|
|
823
|
+
// Another worktree → create a worktree session with --continue
|
|
824
|
+
cwd = existingWt.path;
|
|
825
|
+
sessionRepoPath = existingWt.path;
|
|
826
|
+
worktreeName = existingWt.path.split('/').pop() || '';
|
|
827
|
+
args = [...AGENT_CONTINUE_ARGS[resolvedAgent], ...baseArgs];
|
|
828
|
+
const displayNameVal = sessions.nextAgentName();
|
|
829
|
+
const session = sessions.create({
|
|
830
|
+
type: 'worktree',
|
|
831
|
+
agent: resolvedAgent,
|
|
832
|
+
repoName: name,
|
|
833
|
+
repoPath: sessionRepoPath,
|
|
834
|
+
cwd,
|
|
835
|
+
root,
|
|
836
|
+
worktreeName,
|
|
837
|
+
branchName: branchName || worktreeName,
|
|
838
|
+
displayName: displayNameVal,
|
|
839
|
+
args,
|
|
840
|
+
configPath: CONFIG_PATH,
|
|
841
|
+
useTmux: resolved.useTmux,
|
|
842
|
+
...(safeCols != null && { cols: safeCols }),
|
|
843
|
+
...(safeRows != null && { rows: safeRows }),
|
|
844
|
+
...(initialPrompt != null && { initialPrompt }),
|
|
845
|
+
});
|
|
846
|
+
writeMeta(CONFIG_PATH, {
|
|
847
|
+
worktreePath: sessionRepoPath,
|
|
848
|
+
displayName: displayNameVal,
|
|
849
|
+
lastActivity: new Date().toISOString(),
|
|
850
|
+
branchName: branchName || worktreeName,
|
|
851
|
+
});
|
|
852
|
+
if (ticketContext) {
|
|
853
|
+
transitionOnSessionCreate(ticketContext).catch((err) => {
|
|
854
|
+
console.error('[index] transition on session create failed:', err);
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
res.status(201).json(session);
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
await execFileAsync('git', ['worktree', 'add', targetDir, resolvedBranch], { cwd: repoPath });
|
|
862
|
+
}
|
|
863
|
+
else if (branchName) {
|
|
864
|
+
await execFileAsync('git', ['worktree', 'add', '-b', branchName, targetDir, 'HEAD'], { cwd: repoPath });
|
|
865
|
+
}
|
|
866
|
+
else {
|
|
867
|
+
await execFileAsync('git', ['worktree', 'add', '-b', dirName, targetDir, 'HEAD'], { cwd: repoPath });
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
catch (err) {
|
|
871
|
+
res.status(500).json({ error: execErrorMessage(err, 'Failed to create worktree') });
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
worktreeName = dirName;
|
|
875
|
+
sessionRepoPath = targetDir;
|
|
876
|
+
cwd = targetDir;
|
|
877
|
+
args = [...baseArgs];
|
|
878
|
+
}
|
|
879
|
+
const displayName = sessions.nextAgentName();
|
|
880
|
+
const session = sessions.create({
|
|
881
|
+
type: 'worktree',
|
|
882
|
+
agent: resolvedAgent,
|
|
883
|
+
repoName: name,
|
|
884
|
+
repoPath: sessionRepoPath,
|
|
885
|
+
cwd,
|
|
886
|
+
root,
|
|
887
|
+
worktreeName,
|
|
888
|
+
branchName: branchName || worktreeName,
|
|
889
|
+
displayName,
|
|
890
|
+
args,
|
|
891
|
+
configPath: CONFIG_PATH,
|
|
892
|
+
useTmux: resolved.useTmux,
|
|
893
|
+
...(safeCols != null && { cols: safeCols }),
|
|
894
|
+
...(safeRows != null && { rows: safeRows }),
|
|
895
|
+
needsBranchRename: isMountainName || (needsBranchRename ?? false),
|
|
896
|
+
branchRenamePrompt: branchRenamePrompt ?? '',
|
|
897
|
+
...(initialPrompt != null && { initialPrompt }),
|
|
898
|
+
});
|
|
899
|
+
if (!worktreePath) {
|
|
900
|
+
writeMeta(CONFIG_PATH, {
|
|
901
|
+
worktreePath: sessionRepoPath,
|
|
902
|
+
displayName,
|
|
903
|
+
lastActivity: new Date().toISOString(),
|
|
904
|
+
branchName: branchName || worktreeName,
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
if (ticketContext) {
|
|
908
|
+
transitionOnSessionCreate(ticketContext).catch((err) => {
|
|
909
|
+
console.error('[index] transition on session create failed:', err);
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
res.status(201).json(session);
|
|
913
|
+
});
|
|
914
|
+
// POST /sessions/repo — start a session in the repo root (no worktree)
|
|
915
|
+
app.post('/sessions/repo', requireAuth, async (req, res) => {
|
|
916
|
+
const { repoPath, repoName, continue: continueSession, claudeArgs, yolo, agent, useTmux, cols, rows } = req.body;
|
|
917
|
+
if (!repoPath) {
|
|
918
|
+
res.status(400).json({ error: 'repoPath is required' });
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
const resolved = resolveSessionSettings(config, repoPath, {
|
|
922
|
+
agent, yolo, continue: continueSession, useTmux, claudeArgs,
|
|
923
|
+
});
|
|
924
|
+
const resolvedAgent = resolved.agent;
|
|
925
|
+
// Sanitize optional terminal dimensions
|
|
926
|
+
const safeCols = typeof cols === 'number' && Number.isFinite(cols) && cols >= 1 && cols <= 500 ? Math.round(cols) : undefined;
|
|
927
|
+
const safeRows = typeof rows === 'number' && Number.isFinite(rows) && rows >= 1 && rows <= 200 ? Math.round(rows) : undefined;
|
|
928
|
+
// Multiple sessions per repo allowed (multi-tab support)
|
|
929
|
+
const name = repoName || repoPath.split('/').filter(Boolean).pop() || 'session';
|
|
930
|
+
const baseArgs = [
|
|
931
|
+
...(resolved.claudeArgs),
|
|
932
|
+
...(resolved.yolo ? AGENT_YOLO_ARGS[resolvedAgent] : []),
|
|
933
|
+
];
|
|
934
|
+
const args = resolved.continue ? [...AGENT_CONTINUE_ARGS[resolvedAgent], ...baseArgs] : [...baseArgs];
|
|
935
|
+
const roots = config.rootDirs || [];
|
|
936
|
+
const root = roots.find(function (r) { return repoPath.startsWith(r); }) || '';
|
|
937
|
+
let branchName = '';
|
|
938
|
+
try {
|
|
939
|
+
const { stdout } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: repoPath });
|
|
940
|
+
branchName = stdout.trim();
|
|
941
|
+
}
|
|
942
|
+
catch { /* non-fatal */ }
|
|
943
|
+
const session = sessions.create({
|
|
944
|
+
type: 'repo',
|
|
945
|
+
agent: resolvedAgent,
|
|
946
|
+
repoName: name,
|
|
947
|
+
repoPath,
|
|
948
|
+
cwd: repoPath,
|
|
949
|
+
root,
|
|
950
|
+
displayName: sessions.nextAgentName(),
|
|
951
|
+
args,
|
|
952
|
+
branchName,
|
|
953
|
+
useTmux: resolved.useTmux,
|
|
954
|
+
...(safeCols != null && { cols: safeCols }),
|
|
955
|
+
...(safeRows != null && { rows: safeRows }),
|
|
956
|
+
});
|
|
957
|
+
res.status(201).json(session);
|
|
958
|
+
});
|
|
959
|
+
// POST /sessions/terminal — start a bare shell session (no agent), optional cwd in body
|
|
960
|
+
app.post('/sessions/terminal', requireAuth, (req, res) => {
|
|
961
|
+
const shell = process.env.SHELL || '/bin/sh';
|
|
962
|
+
const displayName = sessions.nextTerminalName();
|
|
963
|
+
const rawCwd = req.body?.cwd;
|
|
964
|
+
const startDir = typeof rawCwd === 'string' && rawCwd.trim()
|
|
965
|
+
? rawCwd.trim()
|
|
966
|
+
: os.homedir();
|
|
967
|
+
if (!fs.existsSync(startDir) || !fs.statSync(startDir).isDirectory()) {
|
|
968
|
+
res.status(400).json({ error: `Directory does not exist: ${startDir}` });
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
const session = sessions.create({
|
|
972
|
+
type: 'terminal',
|
|
973
|
+
agent: 'claude', // required by CreateParams but unused for terminal sessions
|
|
974
|
+
repoPath: startDir,
|
|
975
|
+
cwd: startDir,
|
|
976
|
+
displayName,
|
|
977
|
+
command: shell,
|
|
978
|
+
args: [],
|
|
979
|
+
});
|
|
980
|
+
res.status(201).json(session);
|
|
981
|
+
});
|
|
982
|
+
// DELETE /sessions/:id
|
|
983
|
+
app.delete('/sessions/:id', requireAuth, (req, res) => {
|
|
984
|
+
const id = req.params['id'];
|
|
985
|
+
try {
|
|
986
|
+
sessions.kill(id);
|
|
987
|
+
push.removeSession(id);
|
|
988
|
+
res.json({ ok: true });
|
|
989
|
+
}
|
|
990
|
+
catch (_) {
|
|
991
|
+
res.status(404).json({ error: 'Session not found' });
|
|
992
|
+
}
|
|
993
|
+
});
|
|
994
|
+
// PATCH /sessions/:id — update displayName and persist to metadata
|
|
995
|
+
app.patch('/sessions/:id', requireAuth, (req, res) => {
|
|
996
|
+
const { displayName } = req.body;
|
|
997
|
+
if (!displayName) {
|
|
998
|
+
res.status(400).json({ error: 'displayName is required' });
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
try {
|
|
1002
|
+
const id = req.params['id'];
|
|
1003
|
+
const updated = sessions.updateDisplayName(id, displayName);
|
|
1004
|
+
const session = sessions.get(id);
|
|
1005
|
+
if (session) {
|
|
1006
|
+
writeMeta(CONFIG_PATH, { worktreePath: session.repoPath, displayName, lastActivity: session.lastActivity });
|
|
1007
|
+
}
|
|
1008
|
+
res.json(updated);
|
|
1009
|
+
}
|
|
1010
|
+
catch (_) {
|
|
1011
|
+
res.status(404).json({ error: 'Session not found' });
|
|
1012
|
+
}
|
|
1013
|
+
});
|
|
1014
|
+
// POST /sessions/:id/image — upload clipboard image, proxy to system clipboard
|
|
1015
|
+
const ALLOWED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
|
1016
|
+
app.post('/sessions/:id/image', requireAuth, async (req, res) => {
|
|
1017
|
+
const { data, mimeType } = req.body;
|
|
1018
|
+
if (!data || !mimeType) {
|
|
1019
|
+
res.status(400).json({ error: 'data and mimeType are required' });
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
if (!ALLOWED_IMAGE_TYPES.includes(mimeType)) {
|
|
1023
|
+
res.status(400).json({ error: 'Unsupported image type: ' + mimeType });
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
// base64 is ~33% larger than binary; 10MB binary ≈ 13.3MB base64
|
|
1027
|
+
if (data.length > 14 * 1024 * 1024) {
|
|
1028
|
+
res.status(413).json({ error: 'Image too large (max 10MB)' });
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
const sessionId = req.params['id'];
|
|
1032
|
+
if (!sessions.get(sessionId)) {
|
|
1033
|
+
res.status(404).json({ error: 'Session not found' });
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
try {
|
|
1037
|
+
const ext = extensionForMime(mimeType);
|
|
1038
|
+
const dir = path.join(os.tmpdir(), 'claude-remote-cli', sessionId);
|
|
1039
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1040
|
+
const filePath = path.join(dir, 'paste-' + Date.now() + ext);
|
|
1041
|
+
fs.writeFileSync(filePath, Buffer.from(data, 'base64'));
|
|
1042
|
+
let clipboardSet = false;
|
|
1043
|
+
try {
|
|
1044
|
+
clipboardSet = await setClipboardImage(filePath, mimeType);
|
|
1045
|
+
}
|
|
1046
|
+
catch {
|
|
1047
|
+
// Clipboard tools failed — fall back to path
|
|
1048
|
+
}
|
|
1049
|
+
if (clipboardSet) {
|
|
1050
|
+
sessions.write(sessionId, '\x16');
|
|
1051
|
+
}
|
|
1052
|
+
res.json({ path: filePath, clipboardSet });
|
|
1053
|
+
}
|
|
1054
|
+
catch (err) {
|
|
1055
|
+
const message = err instanceof Error ? err.message : 'Image upload failed';
|
|
1056
|
+
res.status(500).json({ error: message });
|
|
1057
|
+
}
|
|
1058
|
+
});
|
|
1059
|
+
// GET /version — check current vs latest
|
|
1060
|
+
app.get('/version', requireAuth, async (_req, res) => {
|
|
1061
|
+
const current = getCurrentVersion();
|
|
1062
|
+
const latest = await getLatestVersion();
|
|
1063
|
+
const updateAvailable = latest !== null && semverLessThan(current, latest);
|
|
1064
|
+
res.json({ current, latest, updateAvailable });
|
|
1065
|
+
});
|
|
1066
|
+
// POST /update — install latest version from npm
|
|
1067
|
+
app.post('/update', requireAuth, async (_req, res) => {
|
|
1068
|
+
try {
|
|
1069
|
+
await execFileAsync('npm', ['install', '-g', 'claude-remote-cli@latest']);
|
|
1070
|
+
const restarting = serviceIsInstalled();
|
|
1071
|
+
if (restarting) {
|
|
1072
|
+
// Persist sessions so they can be restored after restart
|
|
1073
|
+
const configDir = path.dirname(CONFIG_PATH);
|
|
1074
|
+
serializeAll(configDir);
|
|
1075
|
+
}
|
|
1076
|
+
res.json({ ok: true, restarting });
|
|
1077
|
+
if (restarting) {
|
|
1078
|
+
setTimeout(() => process.exit(0), 1000);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
catch (err) {
|
|
1082
|
+
const message = err instanceof Error ? err.message : 'Update failed';
|
|
1083
|
+
res.status(500).json({ ok: false, error: message });
|
|
1084
|
+
}
|
|
1085
|
+
});
|
|
1086
|
+
// Clean up orphaned tmux sessions from previous runs (skip any adopted by restore)
|
|
1087
|
+
try {
|
|
1088
|
+
const adoptedNames = activeTmuxSessionNames();
|
|
1089
|
+
const { stdout } = await execFileAsync('tmux', ['list-sessions', '-F', '#{session_name}']);
|
|
1090
|
+
const orphanedSessions = stdout.trim().split('\n').filter(name => name.startsWith('crc-') && !adoptedNames.has(name));
|
|
1091
|
+
for (const name of orphanedSessions) {
|
|
1092
|
+
execFileAsync('tmux', ['kill-session', '-t', name]).catch(() => { });
|
|
1093
|
+
}
|
|
1094
|
+
if (orphanedSessions.length > 0) {
|
|
1095
|
+
console.log(`Cleaned up ${orphanedSessions.length} orphaned tmux session(s).`);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
catch {
|
|
1099
|
+
// tmux not installed or no sessions — ignore
|
|
1100
|
+
}
|
|
1101
|
+
function gracefulShutdown() {
|
|
1102
|
+
stopPolling();
|
|
1103
|
+
closeAnalytics();
|
|
1104
|
+
server.close();
|
|
1105
|
+
// Serialize sessions to disk BEFORE killing them
|
|
1106
|
+
const configDir = path.dirname(CONFIG_PATH);
|
|
1107
|
+
serializeAll(configDir);
|
|
1108
|
+
// Kill all active sessions (PTY + tmux)
|
|
1109
|
+
for (const s of sessions.list()) {
|
|
1110
|
+
try {
|
|
1111
|
+
sessions.kill(s.id);
|
|
1112
|
+
}
|
|
1113
|
+
catch { /* already exiting */ }
|
|
1114
|
+
}
|
|
1115
|
+
// Brief delay to let async tmux kill-session calls fire
|
|
1116
|
+
setTimeout(() => process.exit(0), 200);
|
|
1117
|
+
}
|
|
1118
|
+
process.on('SIGTERM', gracefulShutdown);
|
|
1119
|
+
process.on('SIGINT', gracefulShutdown);
|
|
1120
|
+
server.listen(config.port, config.host, () => {
|
|
1121
|
+
console.log(`claude-remote-cli listening on ${config.host}:${config.port}`);
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
main().catch(console.error);
|