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.
Files changed (506) hide show
  1. package/README.md +259 -0
  2. package/dist/bin/claude-remote-cli.js +390 -0
  3. package/dist/bin/relay-ide.js +390 -0
  4. package/dist/frontend/assets/abap-BdImnpbu.js +1 -0
  5. package/dist/frontend/assets/actionscript-3-CoDkCxhg.js +1 -0
  6. package/dist/frontend/assets/ada-bCR0ucgS.js +1 -0
  7. package/dist/frontend/assets/andromeeda-C4gqWexZ.js +1 -0
  8. package/dist/frontend/assets/angular-html-DA-rfuFy.js +1 -0
  9. package/dist/frontend/assets/angular-ts-BrjP3tb8.js +1 -0
  10. package/dist/frontend/assets/apache-Pmp26Uib.js +1 -0
  11. package/dist/frontend/assets/apex-D8_7TLub.js +1 -0
  12. package/dist/frontend/assets/apl-CORt7UWP.js +1 -0
  13. package/dist/frontend/assets/applescript-Co6uUVPk.js +1 -0
  14. package/dist/frontend/assets/ara-BRHolxvo.js +1 -0
  15. package/dist/frontend/assets/asciidoc-Ve4PFQV2.js +1 -0
  16. package/dist/frontend/assets/asm-D_Q5rh1f.js +1 -0
  17. package/dist/frontend/assets/astro-HNnZUWAn.js +1 -0
  18. package/dist/frontend/assets/aurora-x-D-2ljcwZ.js +1 -0
  19. package/dist/frontend/assets/awk-DMzUqQB5.js +1 -0
  20. package/dist/frontend/assets/ayu-dark-DYE7WIF3.js +1 -0
  21. package/dist/frontend/assets/ayu-light-BA47KaF1.js +1 -0
  22. package/dist/frontend/assets/ayu-mirage-32ctXXKs.js +1 -0
  23. package/dist/frontend/assets/ballerina-BFfxhgS-.js +1 -0
  24. package/dist/frontend/assets/bat-BkioyH1T.js +1 -0
  25. package/dist/frontend/assets/beancount-k_qm7-4y.js +1 -0
  26. package/dist/frontend/assets/berry-uYugtg8r.js +1 -0
  27. package/dist/frontend/assets/bibtex-CHM0blh-.js +1 -0
  28. package/dist/frontend/assets/bicep-Bmn6On1c.js +1 -0
  29. package/dist/frontend/assets/bird2-BIv1doCn.js +1 -0
  30. package/dist/frontend/assets/blade-BjGOyj-B.js +1 -0
  31. package/dist/frontend/assets/bsl-BO_Y6i37.js +1 -0
  32. package/dist/frontend/assets/c-BIGW1oBm.js +1 -0
  33. package/dist/frontend/assets/c3-eo99z4R2.js +1 -0
  34. package/dist/frontend/assets/cadence-Bv_4Rxtq.js +1 -0
  35. package/dist/frontend/assets/cairo-KRGpt6FW.js +1 -0
  36. package/dist/frontend/assets/catppuccin-frappe-DFWUc33u.js +1 -0
  37. package/dist/frontend/assets/catppuccin-latte-C9dUb6Cb.js +1 -0
  38. package/dist/frontend/assets/catppuccin-macchiato-DQyhUUbL.js +1 -0
  39. package/dist/frontend/assets/catppuccin-mocha-D87Tk5Gz.js +1 -0
  40. package/dist/frontend/assets/clarity-D53aC0YG.js +1 -0
  41. package/dist/frontend/assets/clojure-P80f7IUj.js +1 -0
  42. package/dist/frontend/assets/cmake-D1j8_8rp.js +1 -0
  43. package/dist/frontend/assets/cobol-nBiQ_Alo.js +1 -0
  44. package/dist/frontend/assets/codeowners-Bp6g37R7.js +1 -0
  45. package/dist/frontend/assets/codeql-DsOJ9woJ.js +1 -0
  46. package/dist/frontend/assets/coffee-Ch7k5sss.js +1 -0
  47. package/dist/frontend/assets/common-lisp-Cg-RD9OK.js +1 -0
  48. package/dist/frontend/assets/coq-DkFqJrB1.js +1 -0
  49. package/dist/frontend/assets/cpp-CofmeUqb.js +1 -0
  50. package/dist/frontend/assets/crystal-DNxU26gB.js +1 -0
  51. package/dist/frontend/assets/csharp-COcwbKMJ.js +1 -0
  52. package/dist/frontend/assets/css-CLj8gQPS.js +1 -0
  53. package/dist/frontend/assets/csv-fuZLfV_i.js +1 -0
  54. package/dist/frontend/assets/cue-D82EKSYY.js +1 -0
  55. package/dist/frontend/assets/cypher-COkxafJQ.js +1 -0
  56. package/dist/frontend/assets/d-85-TOEBH.js +1 -0
  57. package/dist/frontend/assets/dark-plus-C3mMm8J8.js +1 -0
  58. package/dist/frontend/assets/dart-bE4Kk8sk.js +1 -0
  59. package/dist/frontend/assets/dax-CEL-wOlO.js +1 -0
  60. package/dist/frontend/assets/desktop-BmXAJ9_W.js +1 -0
  61. package/dist/frontend/assets/diff-D97Zzqfu.js +1 -0
  62. package/dist/frontend/assets/docker-BcOcwvcX.js +1 -0
  63. package/dist/frontend/assets/dotenv-Da5cRb03.js +1 -0
  64. package/dist/frontend/assets/dracula-BzJJZx-M.js +1 -0
  65. package/dist/frontend/assets/dracula-soft-BXkSAIEj.js +1 -0
  66. package/dist/frontend/assets/dream-maker-BtqSS_iP.js +1 -0
  67. package/dist/frontend/assets/edge-FbVlp4U3.js +1 -0
  68. package/dist/frontend/assets/elixir-CkH2-t6x.js +1 -0
  69. package/dist/frontend/assets/elm-DbKCFpqz.js +1 -0
  70. package/dist/frontend/assets/emacs-lisp-CXvaQtF9.js +1 -0
  71. package/dist/frontend/assets/erb-BYCe7drp.js +1 -0
  72. package/dist/frontend/assets/erlang-DsQrWhSR.js +1 -0
  73. package/dist/frontend/assets/everforest-dark-BgDCqdQA.js +1 -0
  74. package/dist/frontend/assets/everforest-light-C8M2exoo.js +1 -0
  75. package/dist/frontend/assets/fennel-BYunw83y.js +1 -0
  76. package/dist/frontend/assets/fish-BvzEVeQv.js +1 -0
  77. package/dist/frontend/assets/fluent-C4IJs8-o.js +1 -0
  78. package/dist/frontend/assets/fortran-fixed-form-CkoXwp7k.js +1 -0
  79. package/dist/frontend/assets/fortran-free-form-BxgE0vQu.js +1 -0
  80. package/dist/frontend/assets/fsharp-CXgrBDvD.js +1 -0
  81. package/dist/frontend/assets/gdresource-BOOCDP_w.js +1 -0
  82. package/dist/frontend/assets/gdscript-C5YyOfLZ.js +1 -0
  83. package/dist/frontend/assets/gdshader-DkwncUOv.js +1 -0
  84. package/dist/frontend/assets/genie-D0YGMca9.js +1 -0
  85. package/dist/frontend/assets/gherkin-DyxjwDmM.js +1 -0
  86. package/dist/frontend/assets/git-commit-F4YmCXRG.js +1 -0
  87. package/dist/frontend/assets/git-rebase-r7XF79zn.js +1 -0
  88. package/dist/frontend/assets/github-dark-DHJKELXO.js +1 -0
  89. package/dist/frontend/assets/github-dark-default-Cuk6v7N8.js +1 -0
  90. package/dist/frontend/assets/github-dark-dimmed-DH5Ifo-i.js +1 -0
  91. package/dist/frontend/assets/github-dark-high-contrast-E3gJ1_iC.js +1 -0
  92. package/dist/frontend/assets/github-light-DAi9KRSo.js +1 -0
  93. package/dist/frontend/assets/github-light-default-D7oLnXFd.js +1 -0
  94. package/dist/frontend/assets/github-light-high-contrast-BfjtVDDH.js +1 -0
  95. package/dist/frontend/assets/gleam-BspZqrRM.js +1 -0
  96. package/dist/frontend/assets/glimmer-js-ByusRIyA.js +1 -0
  97. package/dist/frontend/assets/glimmer-ts-BfAWNZQY.js +1 -0
  98. package/dist/frontend/assets/glsl-DplSGwfg.js +1 -0
  99. package/dist/frontend/assets/gn-n2N0HUVH.js +1 -0
  100. package/dist/frontend/assets/gnuplot-DdkO51Og.js +1 -0
  101. package/dist/frontend/assets/go-C27-OAKa.js +1 -0
  102. package/dist/frontend/assets/graphql-ChdNCCLP.js +1 -0
  103. package/dist/frontend/assets/groovy-gcz8RCvz.js +1 -0
  104. package/dist/frontend/assets/gruvbox-dark-hard-CFHQjOhq.js +1 -0
  105. package/dist/frontend/assets/gruvbox-dark-medium-GsRaNv29.js +1 -0
  106. package/dist/frontend/assets/gruvbox-dark-soft-CVdnzihN.js +1 -0
  107. package/dist/frontend/assets/gruvbox-light-hard-CH1njM8p.js +1 -0
  108. package/dist/frontend/assets/gruvbox-light-medium-DRw_LuNl.js +1 -0
  109. package/dist/frontend/assets/gruvbox-light-soft-hJgmCMqR.js +1 -0
  110. package/dist/frontend/assets/hack-i7_Ulhet.js +1 -0
  111. package/dist/frontend/assets/haml-D5jkg6IW.js +1 -0
  112. package/dist/frontend/assets/handlebars-BpdQsYii.js +1 -0
  113. package/dist/frontend/assets/haskell-Df6bDoY_.js +1 -0
  114. package/dist/frontend/assets/haxe-CzTSHFRz.js +1 -0
  115. package/dist/frontend/assets/hcl-BWvSN4gD.js +1 -0
  116. package/dist/frontend/assets/hjson-D5-asLiD.js +1 -0
  117. package/dist/frontend/assets/hlsl-D3lLCCz7.js +1 -0
  118. package/dist/frontend/assets/horizon-BUw7H-hv.js +1 -0
  119. package/dist/frontend/assets/horizon-bright-CUuTKBJd.js +1 -0
  120. package/dist/frontend/assets/houston-DnULxvSX.js +1 -0
  121. package/dist/frontend/assets/html-derivative-DlHx6ybY.js +1 -0
  122. package/dist/frontend/assets/html-pp8916En.js +1 -0
  123. package/dist/frontend/assets/http-jrhK8wxY.js +1 -0
  124. package/dist/frontend/assets/hurl-irOxFIW8.js +1 -0
  125. package/dist/frontend/assets/hxml-Bvhsp5Yf.js +1 -0
  126. package/dist/frontend/assets/hy-DFXneXwc.js +1 -0
  127. package/dist/frontend/assets/imba-DGztddWO.js +1 -0
  128. package/dist/frontend/assets/ini-BEwlwnbL.js +1 -0
  129. package/dist/frontend/assets/java-CylS5w8V.js +1 -0
  130. package/dist/frontend/assets/javascript-wDzz0qaB.js +1 -0
  131. package/dist/frontend/assets/jinja-f2NsQr07.js +1 -0
  132. package/dist/frontend/assets/jison-wvAkD_A8.js +1 -0
  133. package/dist/frontend/assets/json-Cp-IABpG.js +1 -0
  134. package/dist/frontend/assets/json5-C9tS-k6U.js +1 -0
  135. package/dist/frontend/assets/jsonc-Des-eS-w.js +1 -0
  136. package/dist/frontend/assets/jsonl-DcaNXYhu.js +1 -0
  137. package/dist/frontend/assets/jsonnet-DFQXde-d.js +1 -0
  138. package/dist/frontend/assets/jssm-C2t-YnRu.js +1 -0
  139. package/dist/frontend/assets/jsx-g9-lgVsj.js +1 -0
  140. package/dist/frontend/assets/julia-CxzCAyBv.js +1 -0
  141. package/dist/frontend/assets/just-VxiPbLrw.js +1 -0
  142. package/dist/frontend/assets/kanagawa-dragon-CkXjmgJE.js +1 -0
  143. package/dist/frontend/assets/kanagawa-lotus-CfQXZHmo.js +1 -0
  144. package/dist/frontend/assets/kanagawa-wave-DWedfzmr.js +1 -0
  145. package/dist/frontend/assets/kdl-DV7GczEv.js +1 -0
  146. package/dist/frontend/assets/kotlin-BdnUsdx6.js +1 -0
  147. package/dist/frontend/assets/kusto-wEQ09or8.js +1 -0
  148. package/dist/frontend/assets/laserwave-DUszq2jm.js +1 -0
  149. package/dist/frontend/assets/latex-CWtU0Tv5.js +1 -0
  150. package/dist/frontend/assets/lean-BZvkOJ9d.js +1 -0
  151. package/dist/frontend/assets/less-B1dDrJ26.js +1 -0
  152. package/dist/frontend/assets/light-plus-B7mTdjB0.js +1 -0
  153. package/dist/frontend/assets/liquid-C0sCDyMI.js +1 -0
  154. package/dist/frontend/assets/llvm-DjAJT7YJ.js +1 -0
  155. package/dist/frontend/assets/log-2UxHyX5q.js +1 -0
  156. package/dist/frontend/assets/logo-BtOb2qkB.js +1 -0
  157. package/dist/frontend/assets/lua-BaeVxFsk.js +1 -0
  158. package/dist/frontend/assets/luau-C-HG3fhB.js +1 -0
  159. package/dist/frontend/assets/main-CL5_Wlhv.css +32 -0
  160. package/dist/frontend/assets/main-Czet4Z1x.js +371 -0
  161. package/dist/frontend/assets/make-CHLpvVh8.js +1 -0
  162. package/dist/frontend/assets/markdown-Cvjx9yec.js +1 -0
  163. package/dist/frontend/assets/marko-DjSrsDqO.js +1 -0
  164. package/dist/frontend/assets/material-theme-D5KoaKCx.js +1 -0
  165. package/dist/frontend/assets/material-theme-darker-BfHTSMKl.js +1 -0
  166. package/dist/frontend/assets/material-theme-lighter-B0m2ddpp.js +1 -0
  167. package/dist/frontend/assets/material-theme-ocean-CyktbL80.js +1 -0
  168. package/dist/frontend/assets/material-theme-palenight-Csfq5Kiy.js +1 -0
  169. package/dist/frontend/assets/matlab-D7o27uSR.js +1 -0
  170. package/dist/frontend/assets/mdc-DTYItulj.js +1 -0
  171. package/dist/frontend/assets/mdx-Cmh6b_Ma.js +1 -0
  172. package/dist/frontend/assets/mermaid-mWjccvbQ.js +1 -0
  173. package/dist/frontend/assets/min-dark-CafNBF8u.js +1 -0
  174. package/dist/frontend/assets/min-light-CTRr51gU.js +1 -0
  175. package/dist/frontend/assets/mipsasm-CKIfxQSi.js +1 -0
  176. package/dist/frontend/assets/mojo-rZm6bMo-.js +1 -0
  177. package/dist/frontend/assets/monokai-D4h5O-jR.js +1 -0
  178. package/dist/frontend/assets/moonbit-_H4v1dQx.js +1 -0
  179. package/dist/frontend/assets/move-IF9eRakj.js +1 -0
  180. package/dist/frontend/assets/narrat-DRg8JJMk.js +1 -0
  181. package/dist/frontend/assets/nextflow-C-mBbutL.js +1 -0
  182. package/dist/frontend/assets/nextflow-groovy-vE_lwT2v.js +1 -0
  183. package/dist/frontend/assets/nginx-BpAMiNFr.js +1 -0
  184. package/dist/frontend/assets/night-owl-C39BiMTA.js +1 -0
  185. package/dist/frontend/assets/night-owl-light-CMTm3GFP.js +1 -0
  186. package/dist/frontend/assets/nim-BIad80T-.js +1 -0
  187. package/dist/frontend/assets/nix-CwoSXNpI.js +1 -0
  188. package/dist/frontend/assets/nord-Ddv68eIx.js +1 -0
  189. package/dist/frontend/assets/nushell-Cz2AlsmD.js +1 -0
  190. package/dist/frontend/assets/objective-c-DXmwc3jG.js +1 -0
  191. package/dist/frontend/assets/objective-cpp-CLxacb5B.js +1 -0
  192. package/dist/frontend/assets/ocaml-C0hk2d4L.js +1 -0
  193. package/dist/frontend/assets/odin-BBf5iR-q.js +1 -0
  194. package/dist/frontend/assets/one-dark-pro-DVMEJ2y_.js +1 -0
  195. package/dist/frontend/assets/one-light-C3Wv6jpd.js +1 -0
  196. package/dist/frontend/assets/openscad-C4EeE6gA.js +1 -0
  197. package/dist/frontend/assets/pascal-D93ZcfNL.js +1 -0
  198. package/dist/frontend/assets/perl-NvoQZIq0.js +1 -0
  199. package/dist/frontend/assets/php-R6g_5hLQ.js +1 -0
  200. package/dist/frontend/assets/pkl-u5AG7uiY.js +1 -0
  201. package/dist/frontend/assets/plastic-3e1v2bzS.js +1 -0
  202. package/dist/frontend/assets/plsql-ChMvpjG-.js +1 -0
  203. package/dist/frontend/assets/po-BTJTHyun.js +1 -0
  204. package/dist/frontend/assets/poimandres-CS3Unz2-.js +1 -0
  205. package/dist/frontend/assets/polar-C0HS_06l.js +1 -0
  206. package/dist/frontend/assets/postcss-CXtECtnM.js +1 -0
  207. package/dist/frontend/assets/powerquery-CEu0bR-o.js +1 -0
  208. package/dist/frontend/assets/powershell-Dpen1YoG.js +1 -0
  209. package/dist/frontend/assets/prisma-Dd19v3D-.js +1 -0
  210. package/dist/frontend/assets/prolog-CbFg5uaA.js +1 -0
  211. package/dist/frontend/assets/proto-C7zT0LnQ.js +1 -0
  212. package/dist/frontend/assets/pug-DKIMFp6K.js +1 -0
  213. package/dist/frontend/assets/puppet-BMWR74SV.js +1 -0
  214. package/dist/frontend/assets/purescript-CklMAg4u.js +1 -0
  215. package/dist/frontend/assets/python-B6aJPvgy.js +1 -0
  216. package/dist/frontend/assets/qml-3beO22l8.js +1 -0
  217. package/dist/frontend/assets/qmldir-C8lEn-DE.js +1 -0
  218. package/dist/frontend/assets/qss-IeuSbFQv.js +1 -0
  219. package/dist/frontend/assets/r-Dspwwk_N.js +1 -0
  220. package/dist/frontend/assets/racket-BqYA7rlc.js +1 -0
  221. package/dist/frontend/assets/raku-DXvB9xmW.js +1 -0
  222. package/dist/frontend/assets/razor-BDqjjVU7.js +1 -0
  223. package/dist/frontend/assets/red-bN70gL4F.js +1 -0
  224. package/dist/frontend/assets/reg-C-SQnVFl.js +1 -0
  225. package/dist/frontend/assets/regexp-CDVJQ6XC.js +1 -0
  226. package/dist/frontend/assets/rel-C3B-1QV4.js +1 -0
  227. package/dist/frontend/assets/riscv-BM1_JUlF.js +1 -0
  228. package/dist/frontend/assets/ron-D8l8udqQ.js +1 -0
  229. package/dist/frontend/assets/rose-pine-dawn-DHQR4-dF.js +1 -0
  230. package/dist/frontend/assets/rose-pine-moon-D4_iv3hh.js +1 -0
  231. package/dist/frontend/assets/rose-pine-qdsjHGoJ.js +1 -0
  232. package/dist/frontend/assets/rosmsg-BJDFO7_C.js +1 -0
  233. package/dist/frontend/assets/rst-CRjBmOyv.js +1 -0
  234. package/dist/frontend/assets/ruby-Wjq7vjNf.js +1 -0
  235. package/dist/frontend/assets/rust-B1yitclQ.js +1 -0
  236. package/dist/frontend/assets/sas-cz2c8ADy.js +1 -0
  237. package/dist/frontend/assets/sass-Cj5Yp3dK.js +1 -0
  238. package/dist/frontend/assets/scala-C151Ov-r.js +1 -0
  239. package/dist/frontend/assets/scheme-C98Dy4si.js +1 -0
  240. package/dist/frontend/assets/scss-D5BDwBP9.js +1 -0
  241. package/dist/frontend/assets/sdbl-DVxCFoDh.js +1 -0
  242. package/dist/frontend/assets/shaderlab-Dg9Lc6iA.js +1 -0
  243. package/dist/frontend/assets/shellscript-Yzrsuije.js +1 -0
  244. package/dist/frontend/assets/shellsession-BADoaaVG.js +1 -0
  245. package/dist/frontend/assets/slack-dark-BthQWCQV.js +1 -0
  246. package/dist/frontend/assets/slack-ochin-DqwNpetd.js +1 -0
  247. package/dist/frontend/assets/smalltalk-BERRCDM3.js +1 -0
  248. package/dist/frontend/assets/snazzy-light-Bw305WKR.js +1 -0
  249. package/dist/frontend/assets/solarized-dark-DXbdFlpD.js +1 -0
  250. package/dist/frontend/assets/solarized-light-L9t79GZl.js +1 -0
  251. package/dist/frontend/assets/solidity-rGO070M0.js +1 -0
  252. package/dist/frontend/assets/soy-8wufbnw4.js +1 -0
  253. package/dist/frontend/assets/sparql-rVzFXLq3.js +1 -0
  254. package/dist/frontend/assets/splunk-BtCnVYZw.js +1 -0
  255. package/dist/frontend/assets/sql-BLtJtn59.js +1 -0
  256. package/dist/frontend/assets/ssh-config-_ykCGR6B.js +1 -0
  257. package/dist/frontend/assets/stata-BH5u7GGu.js +1 -0
  258. package/dist/frontend/assets/stylus-BEDo0Tqx.js +1 -0
  259. package/dist/frontend/assets/surrealql-Bq5Q-fJD.js +1 -0
  260. package/dist/frontend/assets/svelte-Cy7k_4gC.js +1 -0
  261. package/dist/frontend/assets/swift-D82vCrfD.js +1 -0
  262. package/dist/frontend/assets/synthwave-84-CbfX1IO0.js +1 -0
  263. package/dist/frontend/assets/system-verilog-CnnmHF94.js +1 -0
  264. package/dist/frontend/assets/systemd-4A_iFExJ.js +1 -0
  265. package/dist/frontend/assets/talonscript-CkByrt1z.js +1 -0
  266. package/dist/frontend/assets/tasl-QIJgUcNo.js +1 -0
  267. package/dist/frontend/assets/tcl-dwOrl1Do.js +1 -0
  268. package/dist/frontend/assets/templ-DhtptRzy.js +1 -0
  269. package/dist/frontend/assets/terraform-BETggiCN.js +1 -0
  270. package/dist/frontend/assets/tex-idrVyKtj.js +1 -0
  271. package/dist/frontend/assets/tokyo-night-hegEt444.js +1 -0
  272. package/dist/frontend/assets/toml-vGWfd6FD.js +1 -0
  273. package/dist/frontend/assets/ts-tags-DQrlYJgV.js +1 -0
  274. package/dist/frontend/assets/tsv-B_m7g4N7.js +1 -0
  275. package/dist/frontend/assets/tsx-COt5Ahok.js +1 -0
  276. package/dist/frontend/assets/turtle-BsS91CYL.js +1 -0
  277. package/dist/frontend/assets/twig-xg9kU7Mw.js +1 -0
  278. package/dist/frontend/assets/typescript-BPQ3VLAy.js +1 -0
  279. package/dist/frontend/assets/typespec-CAFt9gP4.js +1 -0
  280. package/dist/frontend/assets/typst-DHCkPAjA.js +1 -0
  281. package/dist/frontend/assets/v-BcVCzyr7.js +1 -0
  282. package/dist/frontend/assets/vala-CsfeWuGM.js +1 -0
  283. package/dist/frontend/assets/vb-D17OF-Vu.js +1 -0
  284. package/dist/frontend/assets/verilog-BQ8w6xss.js +1 -0
  285. package/dist/frontend/assets/vesper-DU1UobuO.js +1 -0
  286. package/dist/frontend/assets/vhdl-CeAyd5Ju.js +1 -0
  287. package/dist/frontend/assets/viml-CJc9bBzg.js +1 -0
  288. package/dist/frontend/assets/vitesse-black-Bkuqu6BP.js +1 -0
  289. package/dist/frontend/assets/vitesse-dark-D0r3Knsf.js +1 -0
  290. package/dist/frontend/assets/vitesse-light-CVO1_9PV.js +1 -0
  291. package/dist/frontend/assets/vue-D2xRrEX4.js +1 -0
  292. package/dist/frontend/assets/vue-html-AaS7Mt5G.js +1 -0
  293. package/dist/frontend/assets/vue-vine-BoDAl6tE.js +1 -0
  294. package/dist/frontend/assets/vyper-CDx5xZoG.js +1 -0
  295. package/dist/frontend/assets/wasm-CG6Dc4jp.js +1 -0
  296. package/dist/frontend/assets/wasm-MzD3tlZU.js +1 -0
  297. package/dist/frontend/assets/wenyan-BV7otONQ.js +1 -0
  298. package/dist/frontend/assets/wgsl-Dx-B1_4e.js +1 -0
  299. package/dist/frontend/assets/wikitext-BhOHFoWU.js +1 -0
  300. package/dist/frontend/assets/wit-5i3qLPDT.js +1 -0
  301. package/dist/frontend/assets/wolfram-lXgVvXCa.js +1 -0
  302. package/dist/frontend/assets/xml-sdJ4AIDG.js +1 -0
  303. package/dist/frontend/assets/xsl-CtQFsRM5.js +1 -0
  304. package/dist/frontend/assets/yaml-Buea-lGh.js +1 -0
  305. package/dist/frontend/assets/zenscript-DVFEvuxE.js +1 -0
  306. package/dist/frontend/assets/zig-VOosw3JB.js +1 -0
  307. package/dist/frontend/icon-192.png +0 -0
  308. package/dist/frontend/icon-512.png +0 -0
  309. package/dist/frontend/icon.svg +8 -0
  310. package/dist/frontend/index.html +30 -0
  311. package/dist/frontend/manifest.json +25 -0
  312. package/dist/frontend/sw.js +66 -0
  313. package/dist/server/agent-events.js +39 -0
  314. package/dist/server/analytics.js +885 -0
  315. package/dist/server/auth.js +65 -0
  316. package/dist/server/belayer/executor.js +200 -0
  317. package/dist/server/belayer/intake.js +27 -0
  318. package/dist/server/belayer/pipeline.js +97 -0
  319. package/dist/server/belayer/pr-lifecycle.js +69 -0
  320. package/dist/server/belayer/prompts.js +154 -0
  321. package/dist/server/belayer/types.js +23 -0
  322. package/dist/server/branch-linker.js +137 -0
  323. package/dist/server/browser-content.js +145 -0
  324. package/dist/server/clipboard.js +63 -0
  325. package/dist/server/codex-hooks-adapter.js +93 -0
  326. package/dist/server/config.js +325 -0
  327. package/dist/server/gh-routes.js +163 -0
  328. package/dist/server/gh.js +276 -0
  329. package/dist/server/git-routes.js +154 -0
  330. package/dist/server/git.js +694 -0
  331. package/dist/server/github-app.js +218 -0
  332. package/dist/server/github-graphql.js +178 -0
  333. package/dist/server/hooks.js +373 -0
  334. package/dist/server/index.js +1549 -0
  335. package/dist/server/integration-github.js +137 -0
  336. package/dist/server/integration-jira.js +210 -0
  337. package/dist/server/integration-linear.js +176 -0
  338. package/dist/server/logger.js +18 -0
  339. package/dist/server/mobile-input-pipeline.js +129 -0
  340. package/dist/server/opencode-relay.js +53 -0
  341. package/dist/server/org-dashboard.js +241 -0
  342. package/dist/server/output-parsers/claude-parser.js +56 -0
  343. package/dist/server/output-parsers/codex-parser.js +13 -0
  344. package/dist/server/output-parsers/index.js +14 -0
  345. package/dist/server/output-parsers/null-parser.js +12 -0
  346. package/dist/server/output-parsers/opencode-parser.js +77 -0
  347. package/dist/server/pty-handler.js +586 -0
  348. package/dist/server/push.js +84 -0
  349. package/dist/server/review-poller.js +237 -0
  350. package/dist/server/sdk-handler.js +539 -0
  351. package/dist/server/service.js +189 -0
  352. package/dist/server/sessions.js +638 -0
  353. package/dist/server/telemetry.js +236 -0
  354. package/dist/server/ticket-transitions.js +166 -0
  355. package/dist/server/types.js +146 -0
  356. package/dist/server/utils.js +23 -0
  357. package/dist/server/watcher.js +661 -0
  358. package/dist/server/webhook-manager.js +547 -0
  359. package/dist/server/webhooks.js +73 -0
  360. package/dist/server/workspace-groups.js +363 -0
  361. package/dist/server/workspaces.js +1207 -0
  362. package/dist/server/ws.js +192 -0
  363. package/dist/test/EmptyState.spec.js +51 -0
  364. package/dist/test/action-coverage.test.js +139 -0
  365. package/dist/test/actions/registry.test.js +59 -0
  366. package/dist/test/actions/shortcuts.test.js +79 -0
  367. package/dist/test/agent-events.test.js +151 -0
  368. package/dist/test/analytics.test.js +158 -0
  369. package/dist/test/attention.test.js +91 -0
  370. package/dist/test/auth.test.js +105 -0
  371. package/dist/test/backend-state.test.js +47 -0
  372. package/dist/test/belayer-executor.test.js +33 -0
  373. package/dist/test/belayer-intake.test.js +44 -0
  374. package/dist/test/belayer-pipeline.test.js +113 -0
  375. package/dist/test/belayer-pr-lifecycle.test.js +26 -0
  376. package/dist/test/belayer-prompts.test.js +60 -0
  377. package/dist/test/belayer-types.test.js +69 -0
  378. package/dist/test/bin/claude-remote-cli.js +214 -0
  379. package/dist/test/boot-state.test.js +133 -0
  380. package/dist/test/branch-lifecycle.test.js +75 -0
  381. package/dist/test/branch-linker.test.js +236 -0
  382. package/dist/test/branch-rename.test.js +45 -0
  383. package/dist/test/branch-watcher.test.js +115 -0
  384. package/dist/test/browser-cli.test.js +91 -0
  385. package/dist/test/browser-content.test.js +93 -0
  386. package/dist/test/browser-tabs-ui.test.js +39 -0
  387. package/dist/test/changed-files-api.test.js +140 -0
  388. package/dist/test/clipboard.test.js +12 -0
  389. package/dist/test/codex-hooks-adapter.test.js +237 -0
  390. package/dist/test/components/EmptyState.spec.js +51 -0
  391. package/dist/test/components/ErrorToast.spec.js +65 -0
  392. package/dist/test/components/TuiCheckbox.spec.js +120 -0
  393. package/dist/test/components/TuiInput.spec.js +186 -0
  394. package/dist/test/components/leaf-component-migration.spec.js +104 -0
  395. package/dist/test/config-freshness.test.js +63 -0
  396. package/dist/test/config.test.js +813 -0
  397. package/dist/test/diff-summary.test.js +98 -0
  398. package/dist/test/display-state.test.js +179 -0
  399. package/dist/test/event-message-types.test.js +32 -0
  400. package/dist/test/file-tree-utils.test.js +167 -0
  401. package/dist/test/framework-types.test.js +183 -0
  402. package/dist/test/frameworks-api.test.js +93 -0
  403. package/dist/test/frontend/src/lib/pr-state.js +114 -0
  404. package/dist/test/fs-browse.test.js +246 -0
  405. package/dist/test/fuzzy-scorer.test.js +145 -0
  406. package/dist/test/gh-routes.test.js +156 -0
  407. package/dist/test/git-changed-files.test.js +152 -0
  408. package/dist/test/git-routes.test.js +146 -0
  409. package/dist/test/git-utils.test.js +68 -0
  410. package/dist/test/git-watcher.test.js +110 -0
  411. package/dist/test/git.test.js +140 -0
  412. package/dist/test/github-app.test.js +455 -0
  413. package/dist/test/github-graphql.test.js +301 -0
  414. package/dist/test/greetings.test.js +83 -0
  415. package/dist/test/hooks-agent-event.test.js +412 -0
  416. package/dist/test/hooks.test.js +149 -0
  417. package/dist/test/integration-github.test.js +220 -0
  418. package/dist/test/integration-jira.test.js +238 -0
  419. package/dist/test/integration-linear.test.js +293 -0
  420. package/dist/test/mobile-input.test.js +235 -0
  421. package/dist/test/opencode-relay.test.js +107 -0
  422. package/dist/test/org-dashboard.test.js +349 -0
  423. package/dist/test/output-parser.test.js +217 -0
  424. package/dist/test/paths.test.js +32 -0
  425. package/dist/test/pr-state.test.js +407 -0
  426. package/dist/test/pr-status.test.js +82 -0
  427. package/dist/test/presets.test.js +242 -0
  428. package/dist/test/pty-handler-multi-agent.test.js +149 -0
  429. package/dist/test/pty-handler.test.js +146 -0
  430. package/dist/test/pull-requests.test.js +78 -0
  431. package/dist/test/review-poller.test.js +349 -0
  432. package/dist/test/server/analytics.js +121 -0
  433. package/dist/test/server/auth.js +63 -0
  434. package/dist/test/server/branch-linker.js +124 -0
  435. package/dist/test/server/clipboard.js +56 -0
  436. package/dist/test/server/config.js +137 -0
  437. package/dist/test/server/git.js +308 -0
  438. package/dist/test/server/hooks.js +196 -0
  439. package/dist/test/server/index.js +1124 -0
  440. package/dist/test/server/integration-github.js +117 -0
  441. package/dist/test/server/integration-jira.js +164 -0
  442. package/dist/test/server/integration-linear.js +176 -0
  443. package/dist/test/server/mobile-input-pipeline.js +123 -0
  444. package/dist/test/server/org-dashboard.js +184 -0
  445. package/dist/test/server/output-parsers/claude-parser.js +54 -0
  446. package/dist/test/server/output-parsers/codex-parser.js +13 -0
  447. package/dist/test/server/output-parsers/index.js +7 -0
  448. package/dist/test/server/pty-handler.js +310 -0
  449. package/dist/test/server/push.js +80 -0
  450. package/dist/test/server/review-poller.js +218 -0
  451. package/dist/test/server/service.js +169 -0
  452. package/dist/test/server/sessions.js +434 -0
  453. package/dist/test/server/ticket-transitions.js +216 -0
  454. package/dist/test/server/types.js +20 -0
  455. package/dist/test/server/utils.js +22 -0
  456. package/dist/test/server/watcher.js +139 -0
  457. package/dist/test/server/workspaces.js +657 -0
  458. package/dist/test/server/ws.js +152 -0
  459. package/dist/test/server-startup.test.js +62 -0
  460. package/dist/test/service.test.js +43 -0
  461. package/dist/test/session-analytics-api.test.js +123 -0
  462. package/dist/test/session-analytics.test.js +425 -0
  463. package/dist/test/session-intent.test.js +249 -0
  464. package/dist/test/sessions.test.js +1152 -0
  465. package/dist/test/sidebar-items.test.js +164 -0
  466. package/dist/test/stores/boot-state-store.test.js +165 -0
  467. package/dist/test/stores/sessions-logic.test.js +191 -0
  468. package/dist/test/stores/toasts-store.test.js +66 -0
  469. package/dist/test/stores/ui-store.test.js +203 -0
  470. package/dist/test/stores/unread-store.test.js +97 -0
  471. package/dist/test/telemetry-api.test.js +54 -0
  472. package/dist/test/telemetry-sync.test.js +68 -0
  473. package/dist/test/telemetry.test.js +295 -0
  474. package/dist/test/terminal-zoom.test.js +102 -0
  475. package/dist/test/test/analytics.test.js +152 -0
  476. package/dist/test/test/auth.test.js +95 -0
  477. package/dist/test/test/branch-linker.test.js +231 -0
  478. package/dist/test/test/branch-rename.test.js +45 -0
  479. package/dist/test/test/clipboard.test.js +12 -0
  480. package/dist/test/test/config.test.js +281 -0
  481. package/dist/test/test/fs-browse.test.js +202 -0
  482. package/dist/test/test/git.test.js +67 -0
  483. package/dist/test/test/hooks.test.js +139 -0
  484. package/dist/test/test/integration-github.test.js +203 -0
  485. package/dist/test/test/integration-jira.test.js +294 -0
  486. package/dist/test/test/integration-linear.test.js +293 -0
  487. package/dist/test/test/mobile-input.test.js +193 -0
  488. package/dist/test/test/org-dashboard.test.js +240 -0
  489. package/dist/test/test/output-parser.test.js +95 -0
  490. package/dist/test/test/paths.test.js +32 -0
  491. package/dist/test/test/pr-state.test.js +220 -0
  492. package/dist/test/test/pull-requests.test.js +67 -0
  493. package/dist/test/test/review-poller.test.js +235 -0
  494. package/dist/test/test/service.test.js +43 -0
  495. package/dist/test/test/sessions.test.js +750 -0
  496. package/dist/test/test/ticket-transitions.test.js +130 -0
  497. package/dist/test/test/version.test.js +34 -0
  498. package/dist/test/test/worktrees.test.js +256 -0
  499. package/dist/test/ticket-transitions.test.js +312 -0
  500. package/dist/test/unread.test.js +23 -0
  501. package/dist/test/version.test.js +34 -0
  502. package/dist/test/webhook-manager.test.js +484 -0
  503. package/dist/test/webhooks.test.js +208 -0
  504. package/dist/test/workspace-groups.test.js +377 -0
  505. package/dist/test/worktrees.test.js +531 -0
  506. 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));