@juspay/shooter 1.0.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 (327) hide show
  1. package/.claude/hooks/notifier.cjs +1431 -0
  2. package/.claude/settings.json +162 -0
  3. package/README.md +515 -0
  4. package/bin/shooter.cjs +141 -0
  5. package/build/client/_app/immutable/assets/0.CM9Hl6d-.css +1 -0
  6. package/build/client/_app/immutable/assets/0.CM9Hl6d-.css.br +0 -0
  7. package/build/client/_app/immutable/assets/0.CM9Hl6d-.css.gz +0 -0
  8. package/build/client/_app/immutable/assets/2.CAShZ7lQ.css +1 -0
  9. package/build/client/_app/immutable/assets/2.CAShZ7lQ.css.br +1 -0
  10. package/build/client/_app/immutable/assets/2.CAShZ7lQ.css.gz +0 -0
  11. package/build/client/_app/immutable/assets/3.C0uFg0IS.css +1 -0
  12. package/build/client/_app/immutable/assets/3.C0uFg0IS.css.br +0 -0
  13. package/build/client/_app/immutable/assets/3.C0uFg0IS.css.gz +0 -0
  14. package/build/client/_app/immutable/assets/4.cJuCkJKZ.css +1 -0
  15. package/build/client/_app/immutable/assets/4.cJuCkJKZ.css.br +0 -0
  16. package/build/client/_app/immutable/assets/4.cJuCkJKZ.css.gz +0 -0
  17. package/build/client/_app/immutable/assets/5.DRjApZQW.css +1 -0
  18. package/build/client/_app/immutable/assets/5.DRjApZQW.css.br +0 -0
  19. package/build/client/_app/immutable/assets/5.DRjApZQW.css.gz +0 -0
  20. package/build/client/_app/immutable/assets/6.AraZrY8I.css +1 -0
  21. package/build/client/_app/immutable/assets/6.AraZrY8I.css.br +0 -0
  22. package/build/client/_app/immutable/assets/6.AraZrY8I.css.gz +0 -0
  23. package/build/client/_app/immutable/assets/7.BCJ1IuMx.css +1 -0
  24. package/build/client/_app/immutable/assets/7.BCJ1IuMx.css.br +0 -0
  25. package/build/client/_app/immutable/assets/7.BCJ1IuMx.css.gz +0 -0
  26. package/build/client/_app/immutable/assets/ChatView.CsdBAOKx.css +1 -0
  27. package/build/client/_app/immutable/assets/ChatView.CsdBAOKx.css.br +0 -0
  28. package/build/client/_app/immutable/assets/ChatView.CsdBAOKx.css.gz +0 -0
  29. package/build/client/_app/immutable/assets/markdown.B0b5w2tq.css +1 -0
  30. package/build/client/_app/immutable/assets/markdown.B0b5w2tq.css.br +0 -0
  31. package/build/client/_app/immutable/assets/markdown.B0b5w2tq.css.gz +0 -0
  32. package/build/client/_app/immutable/assets/xterm.DFuMZ0ql.css +1 -0
  33. package/build/client/_app/immutable/assets/xterm.DFuMZ0ql.css.br +0 -0
  34. package/build/client/_app/immutable/assets/xterm.DFuMZ0ql.css.gz +0 -0
  35. package/build/client/_app/immutable/chunks/BNJphC1q.js +56 -0
  36. package/build/client/_app/immutable/chunks/BNJphC1q.js.br +0 -0
  37. package/build/client/_app/immutable/chunks/BNJphC1q.js.gz +0 -0
  38. package/build/client/_app/immutable/chunks/BTGVxaYV.js +9 -0
  39. package/build/client/_app/immutable/chunks/BTGVxaYV.js.br +0 -0
  40. package/build/client/_app/immutable/chunks/BTGVxaYV.js.gz +0 -0
  41. package/build/client/_app/immutable/chunks/BlxrFPDK.js +1 -0
  42. package/build/client/_app/immutable/chunks/BlxrFPDK.js.br +0 -0
  43. package/build/client/_app/immutable/chunks/BlxrFPDK.js.gz +0 -0
  44. package/build/client/_app/immutable/chunks/Bvk7mfPM.js +1 -0
  45. package/build/client/_app/immutable/chunks/Bvk7mfPM.js.br +0 -0
  46. package/build/client/_app/immutable/chunks/Bvk7mfPM.js.gz +0 -0
  47. package/build/client/_app/immutable/chunks/CAokzuPQ.js +1 -0
  48. package/build/client/_app/immutable/chunks/CAokzuPQ.js.br +0 -0
  49. package/build/client/_app/immutable/chunks/CAokzuPQ.js.gz +0 -0
  50. package/build/client/_app/immutable/chunks/CGLrx-H5.js +1 -0
  51. package/build/client/_app/immutable/chunks/CGLrx-H5.js.br +0 -0
  52. package/build/client/_app/immutable/chunks/CGLrx-H5.js.gz +0 -0
  53. package/build/client/_app/immutable/chunks/CgCpWzEA.js +1 -0
  54. package/build/client/_app/immutable/chunks/CgCpWzEA.js.br +0 -0
  55. package/build/client/_app/immutable/chunks/CgCpWzEA.js.gz +0 -0
  56. package/build/client/_app/immutable/chunks/Cjwk_cGO.js +6 -0
  57. package/build/client/_app/immutable/chunks/Cjwk_cGO.js.br +0 -0
  58. package/build/client/_app/immutable/chunks/Cjwk_cGO.js.gz +0 -0
  59. package/build/client/_app/immutable/chunks/CtQ8EED1.js +11 -0
  60. package/build/client/_app/immutable/chunks/CtQ8EED1.js.br +0 -0
  61. package/build/client/_app/immutable/chunks/CtQ8EED1.js.gz +0 -0
  62. package/build/client/_app/immutable/chunks/DERQCisl.js +1 -0
  63. package/build/client/_app/immutable/chunks/DERQCisl.js.br +0 -0
  64. package/build/client/_app/immutable/chunks/DERQCisl.js.gz +0 -0
  65. package/build/client/_app/immutable/chunks/DKrg8TQs.js +1 -0
  66. package/build/client/_app/immutable/chunks/DKrg8TQs.js.br +0 -0
  67. package/build/client/_app/immutable/chunks/DKrg8TQs.js.gz +0 -0
  68. package/build/client/_app/immutable/chunks/DLu6yJIZ.js +1 -0
  69. package/build/client/_app/immutable/chunks/DLu6yJIZ.js.br +0 -0
  70. package/build/client/_app/immutable/chunks/DLu6yJIZ.js.gz +0 -0
  71. package/build/client/_app/immutable/chunks/Dkkpz_4D.js +126 -0
  72. package/build/client/_app/immutable/chunks/Dkkpz_4D.js.br +0 -0
  73. package/build/client/_app/immutable/chunks/Dkkpz_4D.js.gz +0 -0
  74. package/build/client/_app/immutable/chunks/DoczjQhA.js +1 -0
  75. package/build/client/_app/immutable/chunks/DoczjQhA.js.br +0 -0
  76. package/build/client/_app/immutable/chunks/DoczjQhA.js.gz +0 -0
  77. package/build/client/_app/immutable/chunks/PPVm8Dsz.js +1 -0
  78. package/build/client/_app/immutable/chunks/PPVm8Dsz.js.br +0 -0
  79. package/build/client/_app/immutable/chunks/PPVm8Dsz.js.gz +0 -0
  80. package/build/client/_app/immutable/chunks/RpcNruLP.js +2 -0
  81. package/build/client/_app/immutable/chunks/RpcNruLP.js.br +0 -0
  82. package/build/client/_app/immutable/chunks/RpcNruLP.js.gz +0 -0
  83. package/build/client/_app/immutable/chunks/a-St0Zwo.js +1 -0
  84. package/build/client/_app/immutable/chunks/a-St0Zwo.js.br +0 -0
  85. package/build/client/_app/immutable/chunks/a-St0Zwo.js.gz +0 -0
  86. package/build/client/_app/immutable/chunks/bo70OQUZ.js +1 -0
  87. package/build/client/_app/immutable/chunks/bo70OQUZ.js.br +0 -0
  88. package/build/client/_app/immutable/chunks/bo70OQUZ.js.gz +0 -0
  89. package/build/client/_app/immutable/entry/app.QvGgdvTI.js +2 -0
  90. package/build/client/_app/immutable/entry/app.QvGgdvTI.js.br +0 -0
  91. package/build/client/_app/immutable/entry/app.QvGgdvTI.js.gz +0 -0
  92. package/build/client/_app/immutable/entry/start.BntDNRMC.js +1 -0
  93. package/build/client/_app/immutable/entry/start.BntDNRMC.js.br +0 -0
  94. package/build/client/_app/immutable/entry/start.BntDNRMC.js.gz +0 -0
  95. package/build/client/_app/immutable/nodes/0.CzkdvJ7j.js +1 -0
  96. package/build/client/_app/immutable/nodes/0.CzkdvJ7j.js.br +0 -0
  97. package/build/client/_app/immutable/nodes/0.CzkdvJ7j.js.gz +0 -0
  98. package/build/client/_app/immutable/nodes/1.MG1QhfrI.js +1 -0
  99. package/build/client/_app/immutable/nodes/1.MG1QhfrI.js.br +0 -0
  100. package/build/client/_app/immutable/nodes/1.MG1QhfrI.js.gz +0 -0
  101. package/build/client/_app/immutable/nodes/2.B4MlOSh6.js +1 -0
  102. package/build/client/_app/immutable/nodes/2.B4MlOSh6.js.br +0 -0
  103. package/build/client/_app/immutable/nodes/2.B4MlOSh6.js.gz +0 -0
  104. package/build/client/_app/immutable/nodes/3.DIwYkjDn.js +3 -0
  105. package/build/client/_app/immutable/nodes/3.DIwYkjDn.js.br +0 -0
  106. package/build/client/_app/immutable/nodes/3.DIwYkjDn.js.gz +0 -0
  107. package/build/client/_app/immutable/nodes/4.D-cIe70D.js +1 -0
  108. package/build/client/_app/immutable/nodes/4.D-cIe70D.js.br +0 -0
  109. package/build/client/_app/immutable/nodes/4.D-cIe70D.js.gz +0 -0
  110. package/build/client/_app/immutable/nodes/5.D7zPRe3L.js +1 -0
  111. package/build/client/_app/immutable/nodes/5.D7zPRe3L.js.br +0 -0
  112. package/build/client/_app/immutable/nodes/5.D7zPRe3L.js.gz +0 -0
  113. package/build/client/_app/immutable/nodes/6.BB7QE48r.js +2 -0
  114. package/build/client/_app/immutable/nodes/6.BB7QE48r.js.br +0 -0
  115. package/build/client/_app/immutable/nodes/6.BB7QE48r.js.gz +0 -0
  116. package/build/client/_app/immutable/nodes/7.D8mqsrZG.js +2 -0
  117. package/build/client/_app/immutable/nodes/7.D8mqsrZG.js.br +0 -0
  118. package/build/client/_app/immutable/nodes/7.D8mqsrZG.js.gz +0 -0
  119. package/build/client/_app/version.json +1 -0
  120. package/build/client/_app/version.json.br +0 -0
  121. package/build/client/_app/version.json.gz +0 -0
  122. package/build/client/app-icon.png +0 -0
  123. package/build/client/apple-touch-icon.png +0 -0
  124. package/build/client/favicon.png +0 -0
  125. package/build/client/favicon.svg +10 -0
  126. package/build/client/favicon.svg.br +0 -0
  127. package/build/client/favicon.svg.gz +0 -0
  128. package/build/client/manifest.webmanifest +1 -0
  129. package/build/client/pwa-192x192.png +0 -0
  130. package/build/client/pwa-512x512.png +0 -0
  131. package/build/client/registerSW.js +1 -0
  132. package/build/client/registerSW.js.br +0 -0
  133. package/build/client/registerSW.js.gz +0 -0
  134. package/build/client/sw.js +222 -0
  135. package/build/client/sw.js.br +0 -0
  136. package/build/client/sw.js.gz +0 -0
  137. package/build/client/workbox-5119daf5.js +3395 -0
  138. package/build/client/workbox-5119daf5.js.br +0 -0
  139. package/build/client/workbox-5119daf5.js.gz +0 -0
  140. package/build/env.js +94 -0
  141. package/build/handler.js +1494 -0
  142. package/build/index.js +345 -0
  143. package/build/pty-holder.cjs +510 -0
  144. package/build/server/chunks/0-q2IUp76Y.js +9 -0
  145. package/build/server/chunks/0-q2IUp76Y.js.map +1 -0
  146. package/build/server/chunks/1-CU50G5wZ.js +9 -0
  147. package/build/server/chunks/1-CU50G5wZ.js.map +1 -0
  148. package/build/server/chunks/2-D01t9s8T.js +9 -0
  149. package/build/server/chunks/2-D01t9s8T.js.map +1 -0
  150. package/build/server/chunks/3-5PUQ04wC.js +9 -0
  151. package/build/server/chunks/3-5PUQ04wC.js.map +1 -0
  152. package/build/server/chunks/4-e7gywnSG.js +9 -0
  153. package/build/server/chunks/4-e7gywnSG.js.map +1 -0
  154. package/build/server/chunks/5-CA1SA6KZ.js +9 -0
  155. package/build/server/chunks/5-CA1SA6KZ.js.map +1 -0
  156. package/build/server/chunks/6-71H221sV.js +9 -0
  157. package/build/server/chunks/6-71H221sV.js.map +1 -0
  158. package/build/server/chunks/7-Bo-vmdyz.js +9 -0
  159. package/build/server/chunks/7-Bo-vmdyz.js.map +1 -0
  160. package/build/server/chunks/_layout.svelte-SFHOxs74.js +132 -0
  161. package/build/server/chunks/_layout.svelte-SFHOxs74.js.map +1 -0
  162. package/build/server/chunks/_page.svelte-B4w-2wD-.js +120 -0
  163. package/build/server/chunks/_page.svelte-B4w-2wD-.js.map +1 -0
  164. package/build/server/chunks/_page.svelte-B_qAXjkh.js +213 -0
  165. package/build/server/chunks/_page.svelte-B_qAXjkh.js.map +1 -0
  166. package/build/server/chunks/_page.svelte-CsF1_TRG.js +50 -0
  167. package/build/server/chunks/_page.svelte-CsF1_TRG.js.map +1 -0
  168. package/build/server/chunks/_page.svelte-DJC6U-P0.js +68 -0
  169. package/build/server/chunks/_page.svelte-DJC6U-P0.js.map +1 -0
  170. package/build/server/chunks/_page.svelte-DQ6HBtsz.js +407 -0
  171. package/build/server/chunks/_page.svelte-DQ6HBtsz.js.map +1 -0
  172. package/build/server/chunks/_page.svelte-LbhhjP21.js +148 -0
  173. package/build/server/chunks/_page.svelte-LbhhjP21.js.map +1 -0
  174. package/build/server/chunks/_server.ts-BL2FGb5Z.js +387 -0
  175. package/build/server/chunks/_server.ts-BL2FGb5Z.js.map +1 -0
  176. package/build/server/chunks/_server.ts-BgdjBZco.js +47 -0
  177. package/build/server/chunks/_server.ts-BgdjBZco.js.map +1 -0
  178. package/build/server/chunks/_server.ts-BihKSdj_.js +59 -0
  179. package/build/server/chunks/_server.ts-BihKSdj_.js.map +1 -0
  180. package/build/server/chunks/_server.ts-BjOJsoy4.js +63 -0
  181. package/build/server/chunks/_server.ts-BjOJsoy4.js.map +1 -0
  182. package/build/server/chunks/_server.ts-C29xzfaw.js +77 -0
  183. package/build/server/chunks/_server.ts-C29xzfaw.js.map +1 -0
  184. package/build/server/chunks/_server.ts-CPa6DgIt.js +71 -0
  185. package/build/server/chunks/_server.ts-CPa6DgIt.js.map +1 -0
  186. package/build/server/chunks/_server.ts-CbDRDIoP.js +36 -0
  187. package/build/server/chunks/_server.ts-CbDRDIoP.js.map +1 -0
  188. package/build/server/chunks/_server.ts-Cl1OEWL4.js +54 -0
  189. package/build/server/chunks/_server.ts-Cl1OEWL4.js.map +1 -0
  190. package/build/server/chunks/_server.ts-ColfDHW8.js +60 -0
  191. package/build/server/chunks/_server.ts-ColfDHW8.js.map +1 -0
  192. package/build/server/chunks/_server.ts-Cv_OrRuL.js +494 -0
  193. package/build/server/chunks/_server.ts-Cv_OrRuL.js.map +1 -0
  194. package/build/server/chunks/_server.ts-D4MNi4cD.js +25 -0
  195. package/build/server/chunks/_server.ts-D4MNi4cD.js.map +1 -0
  196. package/build/server/chunks/_server.ts-DRVbgm6k.js +125 -0
  197. package/build/server/chunks/_server.ts-DRVbgm6k.js.map +1 -0
  198. package/build/server/chunks/_server.ts-DfajWaqh.js +39 -0
  199. package/build/server/chunks/_server.ts-DfajWaqh.js.map +1 -0
  200. package/build/server/chunks/_server.ts-y9-WYDMa.js +35 -0
  201. package/build/server/chunks/_server.ts-y9-WYDMa.js.map +1 -0
  202. package/build/server/chunks/auth-CEgFis71.js +32 -0
  203. package/build/server/chunks/auth-CEgFis71.js.map +1 -0
  204. package/build/server/chunks/client-CxCatAKr.js +255 -0
  205. package/build/server/chunks/client-CxCatAKr.js.map +1 -0
  206. package/build/server/chunks/error.svelte-BqdwMWdK.js +26 -0
  207. package/build/server/chunks/error.svelte-BqdwMWdK.js.map +1 -0
  208. package/build/server/chunks/exports-CJ0Q5XmL.js +4081 -0
  209. package/build/server/chunks/exports-CJ0Q5XmL.js.map +1 -0
  210. package/build/server/chunks/index2-DAxIoAO-.js +36 -0
  211. package/build/server/chunks/index2-DAxIoAO-.js.map +1 -0
  212. package/build/server/chunks/jsonl-parser-dmZU_Hyu.js +137 -0
  213. package/build/server/chunks/jsonl-parser-dmZU_Hyu.js.map +1 -0
  214. package/build/server/chunks/library-apns-BHxLmuIx.js +104 -0
  215. package/build/server/chunks/library-apns-BHxLmuIx.js.map +1 -0
  216. package/build/server/chunks/markdown-Bxrl3cCF.js +1241 -0
  217. package/build/server/chunks/markdown-Bxrl3cCF.js.map +1 -0
  218. package/build/server/chunks/pending-requests-D8UiTw7L.js +44 -0
  219. package/build/server/chunks/pending-requests-D8UiTw7L.js.map +1 -0
  220. package/build/server/chunks/pty-manager-C0FhBiVq.js +1697 -0
  221. package/build/server/chunks/pty-manager-C0FhBiVq.js.map +1 -0
  222. package/build/server/chunks/shared-server-BDY8jh20.js +200 -0
  223. package/build/server/chunks/shared-server-BDY8jh20.js.map +1 -0
  224. package/build/server/chunks/stores-D0HorpgL.js +36 -0
  225. package/build/server/chunks/stores-D0HorpgL.js.map +1 -0
  226. package/build/server/index.js +6466 -0
  227. package/build/server/index.js.map +1 -0
  228. package/build/server/manifest.js +184 -0
  229. package/build/server/manifest.js.map +1 -0
  230. package/build/shims.js +32 -0
  231. package/package.json +94 -0
  232. package/scripts/clipboard-shims/wl-paste +48 -0
  233. package/scripts/clipboard-shims/xclip +31 -0
  234. package/scripts/install.sh +477 -0
  235. package/scripts/setup-node-pty.sh +63 -0
  236. package/scripts/setup.cjs +571 -0
  237. package/scripts/test-runner.ts +243 -0
  238. package/scripts/vercel-env-commands.sh +60 -0
  239. package/server.ts +139 -0
  240. package/src/app.css +1835 -0
  241. package/src/app.d.ts +31 -0
  242. package/src/app.html +24 -0
  243. package/src/generated/types/APN.ts +305 -0
  244. package/src/generated/types/CLI.ts +52 -0
  245. package/src/generated/types/JWT.ts +92 -0
  246. package/src/generated/types/Terminal.ts +2736 -0
  247. package/src/generated/types/index.ts +6 -0
  248. package/src/lib/assets/icons/alert-triangle.svg +5 -0
  249. package/src/lib/assets/icons/bell.svg +4 -0
  250. package/src/lib/assets/icons/check-circle.svg +4 -0
  251. package/src/lib/assets/icons/file.svg +4 -0
  252. package/src/lib/assets/icons/folder.svg +3 -0
  253. package/src/lib/assets/icons/play.svg +3 -0
  254. package/src/lib/assets/icons/refresh.svg +4 -0
  255. package/src/lib/assets/icons/settings.svg +4 -0
  256. package/src/lib/assets/icons/terminal.svg +1 -0
  257. package/src/lib/assets/icons/tool.svg +3 -0
  258. package/src/lib/assets/icons/x-circle.svg +5 -0
  259. package/src/lib/modules/client/common/Card.svelte +26 -0
  260. package/src/lib/modules/client/common/EmptyState.svelte +36 -0
  261. package/src/lib/modules/client/common/Icon.svelte +61 -0
  262. package/src/lib/modules/client/common/StatusBadge.svelte +38 -0
  263. package/src/lib/modules/client/common/cache.ts +31 -0
  264. package/src/lib/modules/client/common/config-guard.ts +18 -0
  265. package/src/lib/modules/client/common/index.ts +12 -0
  266. package/src/lib/modules/client/common/markdown.ts +23 -0
  267. package/src/lib/modules/client/common/native-bridge.ts +50 -0
  268. package/src/lib/modules/client/common/time.ts +22 -0
  269. package/src/lib/modules/client/common/tool-title.ts +28 -0
  270. package/src/lib/modules/client/terminal/ChatView.svelte +400 -0
  271. package/src/lib/modules/client/terminal/CommandPalette.svelte +60 -0
  272. package/src/lib/modules/client/terminal/ConnectionStatus.svelte +99 -0
  273. package/src/lib/modules/client/terminal/LaunchSheet.svelte +294 -0
  274. package/src/lib/modules/client/terminal/QuickKeys.svelte +71 -0
  275. package/src/lib/modules/client/terminal/ShortcutsHelp.svelte +79 -0
  276. package/src/lib/modules/client/terminal/keyboard-shortcuts.ts +70 -0
  277. package/src/lib/modules/client/terminal/xterm-wrapper.ts +243 -0
  278. package/src/lib/modules/server/apn/library-apns.ts +137 -0
  279. package/src/lib/modules/server/apn/notification-history.ts +35 -0
  280. package/src/lib/modules/server/apn/notification-sessions.ts +117 -0
  281. package/src/lib/modules/server/apn/pending-requests.ts +65 -0
  282. package/src/lib/modules/server/apn/types.ts +51 -0
  283. package/src/lib/modules/server/auth.ts +34 -0
  284. package/src/lib/modules/server/cli/index.ts +79 -0
  285. package/src/lib/modules/server/cli/runner.ts +162 -0
  286. package/src/lib/modules/server/fcm/fcm-service.ts +72 -0
  287. package/src/lib/modules/server/sessions/jsonl-parser.ts +197 -0
  288. package/src/lib/modules/server/sessions/jsonl-reader.ts +301 -0
  289. package/src/lib/modules/server/sessions/opencode-reader.ts +264 -0
  290. package/src/lib/modules/server/sessions/types.ts +53 -0
  291. package/src/lib/modules/server/terminal/holder-client.ts +273 -0
  292. package/src/lib/modules/server/terminal/opencode-watcher.ts +661 -0
  293. package/src/lib/modules/server/terminal/pty-holder.cjs +510 -0
  294. package/src/lib/modules/server/terminal/pty-manager.ts +1012 -0
  295. package/src/lib/modules/server/terminal/session-watcher.ts +320 -0
  296. package/src/lib/modules/server/terminal/terminal-store.ts +198 -0
  297. package/src/lib/modules/server/ws/events-handler.ts +73 -0
  298. package/src/lib/modules/server/ws/keepalive.ts +108 -0
  299. package/src/lib/modules/server/ws/server.ts +93 -0
  300. package/src/lib/modules/server/ws/session-handler.ts +462 -0
  301. package/src/lib/modules/server/ws/terminal-handler.ts +197 -0
  302. package/src/lib/modules/server/ws/ticket-store.ts +58 -0
  303. package/src/lib/theme.css +529 -0
  304. package/src/lib/types/config.ts +6 -0
  305. package/src/routes/+layout.svelte +218 -0
  306. package/src/routes/+page.svelte +261 -0
  307. package/src/routes/api/debug/+server.ts +33 -0
  308. package/src/routes/api/device-token/+server.ts +85 -0
  309. package/src/routes/api/health/+server.ts +100 -0
  310. package/src/routes/api/notify/+server.ts +418 -0
  311. package/src/routes/api/qr-config/+server.ts +45 -0
  312. package/src/routes/api/response/+server.ts +73 -0
  313. package/src/routes/api/sessions/+server.ts +120 -0
  314. package/src/routes/api/terminals/+server.ts +141 -0
  315. package/src/routes/api/terminals/[id]/+server.ts +75 -0
  316. package/src/routes/api/terminals/[id]/paste-image/+server.ts +61 -0
  317. package/src/routes/api/terminals/[id]/resize/+server.ts +60 -0
  318. package/src/routes/api/webhook/+server.ts +42 -0
  319. package/src/routes/api/ws-status/+server.ts +23 -0
  320. package/src/routes/api/ws-ticket/+server.ts +86 -0
  321. package/src/routes/config/+page.svelte +600 -0
  322. package/src/routes/project/+page.svelte +274 -0
  323. package/src/routes/session/[id]/+page.svelte +434 -0
  324. package/src/routes/terminals/+page.svelte +618 -0
  325. package/src/routes/terminals/[id]/+page.svelte +968 -0
  326. package/svelte.config.js +18 -0
  327. package/tsconfig.json +14 -0
@@ -0,0 +1,1012 @@
1
+ import type WebSocket from 'ws';
2
+
3
+ import { type ChildProcess, fork } from 'child_process';
4
+ import { randomBytes } from 'crypto';
5
+ import { existsSync, readdirSync, readFileSync, statSync, unlinkSync } from 'fs';
6
+ import path from 'path';
7
+ import { fileURLToPath } from 'url';
8
+
9
+ import { HolderClient } from './holder-client';
10
+ import { openCodeWatcher } from './opencode-watcher';
11
+ import { sessionWatcher } from './session-watcher';
12
+ import { terminalStore } from './terminal-store';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Types
16
+ // ---------------------------------------------------------------------------
17
+
18
+ interface ManagedTerminal {
19
+ args: string[];
20
+ clients: Set<WebSocket>;
21
+ cols: number;
22
+ command: string;
23
+ createdAt: Date;
24
+ currentCwd: null | string;
25
+ cwd: string;
26
+ exitCode: null | number;
27
+ exitedAt: Date | null;
28
+ holderPid: number;
29
+ id: string;
30
+ isActive: boolean;
31
+ openCodeNoopCb: ((messages: import('../sessions/types').ConversationMessage[]) => void) | null;
32
+ openCodeSessionId: null | string;
33
+ outputBuffers: Map<WebSocket, OutputBuffer>;
34
+ pid: number;
35
+ pollTimer: null | ReturnType<typeof setInterval>;
36
+ pty: HolderClient;
37
+ rows: number;
38
+ scrollback: string;
39
+ sessionFile: null | string;
40
+ socketPath: string;
41
+ status: 'exited' | 'running';
42
+ watcherOffset: number;
43
+ }
44
+
45
+ interface OutputBuffer {
46
+ data: string[];
47
+ size: number;
48
+ }
49
+
50
+ export type { ManagedTerminal };
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Constants
54
+ // ---------------------------------------------------------------------------
55
+
56
+ const MAX_SCROLLBACK_BYTES = 512 * 1024; // 512 KB cached scrollback cap
57
+ const MAX_OUTPUT_BUFFER_BYTES = 1024 * 1024; // 1 MB per client
58
+ const SCROLLBACK_CHUNK_SIZE = 50 * 1024; // 50 KB per chunk
59
+ const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
60
+ const EXITED_TTL_MS = 60 * 60 * 1000; // 1 hour
61
+ const MAX_EXITED_TERMINALS = 10;
62
+ const SIGKILL_DELAY_MS = 5000;
63
+ const HOLDER_READY_TIMEOUT_MS = 5000;
64
+ const DB_CLEANUP_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours for SQLite records
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Resolve holder script path (ESM — no __dirname)
68
+ // ---------------------------------------------------------------------------
69
+
70
+ const __filename = fileURLToPath(import.meta.url);
71
+ const __dirname = path.dirname(__filename);
72
+
73
+ class PtyManager {
74
+ private cleanupTimer: null | ReturnType<typeof setInterval> = null;
75
+ private terminals = new Map<string, ManagedTerminal>();
76
+
77
+ constructor() {
78
+ this.cleanupTimer = setInterval(() => { this.cleanup(); }, CLEANUP_INTERVAL_MS);
79
+ }
80
+
81
+ // -----------------------------------------------------------------------
82
+ // create — now async: forks a holder process, connects via HolderClient,
83
+ // persists to SQLite
84
+ // -----------------------------------------------------------------------
85
+
86
+ attach(id: string, ws: WebSocket): boolean {
87
+ const terminal = this.terminals.get(id);
88
+ if (!terminal) {return false;}
89
+
90
+ terminal.clients.add(ws);
91
+ terminal.outputBuffers.set(ws, { data: [], size: 0 });
92
+
93
+ // Send cached scrollback in chunks
94
+ this.sendScrollback(terminal, ws);
95
+
96
+ return true;
97
+ }
98
+
99
+ // -----------------------------------------------------------------------
100
+ // reconnectAll — recover persisted terminals on server startup
101
+ // -----------------------------------------------------------------------
102
+
103
+ cleanup(): void {
104
+ const now = Date.now();
105
+ const exited: { exitedAt: number; id: string; }[] = [];
106
+
107
+ for (const [id, terminal] of this.terminals) {
108
+ if (terminal.status !== 'exited') {continue;}
109
+
110
+ const exitTime = terminal.exitedAt?.getTime() ?? terminal.createdAt.getTime();
111
+
112
+ // Evict if older than TTL
113
+ if (now - exitTime > EXITED_TTL_MS) {
114
+ this.evict(id);
115
+ continue;
116
+ }
117
+
118
+ exited.push({ exitedAt: exitTime, id });
119
+ }
120
+
121
+ // If more than MAX_EXITED_TERMINALS remain, evict the oldest
122
+ if (exited.length > MAX_EXITED_TERMINALS) {
123
+ exited.sort((a, b) => a.exitedAt - b.exitedAt);
124
+ const toEvict = exited.slice(0, exited.length - MAX_EXITED_TERMINALS);
125
+ for (const { id } of toEvict) {
126
+ this.evict(id);
127
+ }
128
+ }
129
+
130
+ // Clean up old SQLite records (exited/orphaned older than 24 hours)
131
+ try {
132
+ const deleted = terminalStore.deleteOlderThan(DB_CLEANUP_TTL_MS);
133
+ if (deleted > 0) {
134
+ console.log(`[pty-manager] Cleaned up ${deleted} old terminal record(s) from SQLite`);
135
+ }
136
+ } catch {
137
+ // Best effort — don't crash the cleanup cycle
138
+ }
139
+ }
140
+
141
+ // -----------------------------------------------------------------------
142
+ // disconnectAll — graceful shutdown: disconnect clients, keep holders alive
143
+ // -----------------------------------------------------------------------
144
+
145
+ async create(
146
+ command: string,
147
+ args: string[],
148
+ cwd: string,
149
+ cols: number,
150
+ rows: number
151
+ ): Promise<ManagedTerminal> {
152
+ const id = randomBytes(4).toString('hex'); // 8 hex chars
153
+ const socketPath = `/tmp/shooter-term-${id}.sock`;
154
+ const holderScript = resolveHolderPath();
155
+
156
+ // Fork the holder process as detached so it survives server restarts
157
+ const holderArgs = [id, socketPath, cwd, String(cols), String(rows), command, ...args];
158
+ const holder: ChildProcess = fork(holderScript, holderArgs, {
159
+ detached: true,
160
+ stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
161
+ });
162
+ holder.unref();
163
+
164
+ // Wait for the holder to signal ready (socket listening)
165
+ await new Promise<void>((resolve, reject) => {
166
+ const timeout = setTimeout(() => {
167
+ holder.kill();
168
+ reject(new Error('Holder ready timeout'));
169
+ }, HOLDER_READY_TIMEOUT_MS);
170
+
171
+ holder.on('message', (msg: { type: string }) => {
172
+ if (msg.type === 'ready') {
173
+ clearTimeout(timeout);
174
+ holder.disconnect(); // Release IPC — holder is now fully detached
175
+ resolve();
176
+ }
177
+ });
178
+
179
+ holder.on('error', (err) => {
180
+ clearTimeout(timeout);
181
+ reject(new Error(`Holder process error: ${err.message}`));
182
+ });
183
+
184
+ holder.on('exit', (code) => {
185
+ clearTimeout(timeout);
186
+ reject(new Error(`Holder process exited with code ${code} before ready`));
187
+ });
188
+ });
189
+
190
+ const holderPid = holder.pid!;
191
+
192
+ // Connect to the holder via Unix socket
193
+ const client = new HolderClient();
194
+ const connectResult = await client.connect(socketPath);
195
+
196
+ const now = new Date();
197
+ const terminal: ManagedTerminal = {
198
+ args,
199
+ clients: new Set(),
200
+ cols,
201
+ command,
202
+ createdAt: now,
203
+ cwd,
204
+ currentCwd: null,
205
+ exitCode: connectResult.exitCode,
206
+ exitedAt: null,
207
+ holderPid,
208
+ id,
209
+ isActive: false,
210
+ openCodeNoopCb: null,
211
+ openCodeSessionId: null,
212
+ outputBuffers: new Map(),
213
+ pid: connectResult.pid,
214
+ pollTimer: null,
215
+ pty: client,
216
+ rows,
217
+ scrollback: connectResult.scrollback,
218
+ sessionFile: null,
219
+ socketPath,
220
+ status: connectResult.exited ? 'exited' : 'running',
221
+ watcherOffset: 0,
222
+ };
223
+
224
+ // Wire up all HolderClient callbacks
225
+ this.wireHolderCallbacks(client, terminal);
226
+
227
+ // Persist to SQLite
228
+ terminalStore.insert({
229
+ args: JSON.stringify(args),
230
+ cols,
231
+ command,
232
+ createdAt: now.toISOString(),
233
+ cwd,
234
+ exitCode: null,
235
+ exitedAt: null,
236
+ holderPid,
237
+ id,
238
+ opencodeSessionId: null,
239
+ pid: connectResult.pid,
240
+ rows,
241
+ sessionFile: null,
242
+ socketPath,
243
+ status: 'running',
244
+ });
245
+
246
+ // Start session file discovery (same polling logic as before)
247
+ this.startSessionDiscovery(terminal);
248
+
249
+ this.terminals.set(id, terminal);
250
+ return terminal;
251
+ }
252
+
253
+ // -----------------------------------------------------------------------
254
+ // get
255
+ // -----------------------------------------------------------------------
256
+
257
+ destroy(): void {
258
+ if (this.cleanupTimer) {
259
+ clearInterval(this.cleanupTimer);
260
+ this.cleanupTimer = null;
261
+ }
262
+
263
+ for (const [id, terminal] of this.terminals) {
264
+ // Clear session-file poll timer if still running
265
+ if (terminal.pollTimer) {
266
+ clearInterval(terminal.pollTimer);
267
+ terminal.pollTimer = null;
268
+ }
269
+
270
+ if (terminal.status === 'running') {
271
+ try {
272
+ terminal.pty.kill('SIGTERM');
273
+ } catch {
274
+ // Best effort
275
+ }
276
+ // Also kill the holder process directly
277
+ try {
278
+ process.kill(terminal.holderPid, 'SIGKILL');
279
+ } catch {
280
+ // Best effort — holder may already be gone
281
+ }
282
+ }
283
+
284
+ // Disconnect from holder socket
285
+ terminal.pty.disconnect();
286
+
287
+ // Close all client connections
288
+ for (const ws of terminal.clients) {
289
+ try {
290
+ ws.close();
291
+ } catch {
292
+ // Best effort
293
+ }
294
+ }
295
+ this.terminals.delete(id);
296
+ }
297
+ }
298
+
299
+ // -----------------------------------------------------------------------
300
+ // list — running first, then recently exited, each group sorted by
301
+ // createdAt descending
302
+ // -----------------------------------------------------------------------
303
+
304
+ detach(id: string, ws: WebSocket): boolean {
305
+ const terminal = this.terminals.get(id);
306
+ if (!terminal) {return false;}
307
+
308
+ terminal.clients.delete(ws);
309
+ terminal.outputBuffers.delete(ws);
310
+ return true;
311
+ }
312
+
313
+ // -----------------------------------------------------------------------
314
+ // kill — route through holder: SIGTERM, then SIGKILL after 5 s
315
+ // -----------------------------------------------------------------------
316
+
317
+ disconnectAll(): void {
318
+ if (this.cleanupTimer) {
319
+ clearInterval(this.cleanupTimer);
320
+ this.cleanupTimer = null;
321
+ }
322
+
323
+ for (const [, terminal] of this.terminals) {
324
+ // Clear session-file poll timer
325
+ if (terminal.pollTimer) {
326
+ clearInterval(terminal.pollTimer);
327
+ terminal.pollTimer = null;
328
+ }
329
+
330
+ // Disconnect from holder (does NOT kill the holder process)
331
+ terminal.pty.disconnect();
332
+
333
+ // Close all WS client connections
334
+ for (const ws of terminal.clients) {
335
+ try {
336
+ ws.close();
337
+ } catch {
338
+ // Best effort
339
+ }
340
+ }
341
+ terminal.clients.clear();
342
+ terminal.outputBuffers.clear();
343
+ }
344
+
345
+ this.terminals.clear();
346
+ }
347
+
348
+ // -----------------------------------------------------------------------
349
+ // remove — remove an exited terminal from the map
350
+ // -----------------------------------------------------------------------
351
+
352
+ get(id: string): ManagedTerminal | null {
353
+ return this.terminals.get(id) ?? null;
354
+ }
355
+
356
+ // -----------------------------------------------------------------------
357
+ // resize
358
+ // -----------------------------------------------------------------------
359
+
360
+ getScrollback(id: string): null | string {
361
+ const terminal = this.terminals.get(id);
362
+ if (!terminal) {return null;}
363
+
364
+ return terminal.scrollback;
365
+ }
366
+
367
+ // -----------------------------------------------------------------------
368
+ // attach — register a WebSocket client and replay scrollback
369
+ // -----------------------------------------------------------------------
370
+
371
+ kill(id: string): boolean {
372
+ const terminal = this.terminals.get(id);
373
+ if (!terminal) {return false;}
374
+ if (terminal.status === 'exited') {return true;} // already dead
375
+
376
+ try {
377
+ // Send SIGTERM through the holder protocol
378
+ terminal.pty.kill('SIGTERM');
379
+ } catch {
380
+ // Holder may already be gone — mark as exited
381
+ terminal.status = 'exited';
382
+ terminal.exitedAt = new Date();
383
+ terminalStore.markExited(id, null);
384
+ return true;
385
+ }
386
+
387
+ // Schedule forceful kill if still running after delay
388
+ setTimeout(() => {
389
+ if (terminal.status === 'running') {
390
+ try {
391
+ terminal.pty.kill('SIGKILL');
392
+ } catch {
393
+ // Already gone
394
+ }
395
+ terminal.status = 'exited';
396
+ terminal.exitedAt = terminal.exitedAt ?? new Date();
397
+ terminalStore.markExited(id, null);
398
+ }
399
+ }, SIGKILL_DELAY_MS);
400
+
401
+ return true;
402
+ }
403
+
404
+ // -----------------------------------------------------------------------
405
+ // detach — remove a WebSocket client
406
+ // -----------------------------------------------------------------------
407
+
408
+ list(): ManagedTerminal[] {
409
+ const all = Array.from(this.terminals.values());
410
+
411
+ const running = all
412
+ .filter((t) => t.status === 'running')
413
+ .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
414
+
415
+ const exited = all
416
+ .filter((t) => t.status === 'exited')
417
+ .sort((a, b) => {
418
+ const aTime = a.exitedAt?.getTime() ?? a.createdAt.getTime();
419
+ const bTime = b.exitedAt?.getTime() ?? b.createdAt.getTime();
420
+ return bTime - aTime;
421
+ });
422
+
423
+ return [...running, ...exited];
424
+ }
425
+
426
+ // -----------------------------------------------------------------------
427
+ // getScrollback — return raw scrollback data for replay
428
+ // -----------------------------------------------------------------------
429
+
430
+ async reconnectAll(): Promise<void> {
431
+ const running = terminalStore.listRunning();
432
+ if (running.length === 0) {
433
+ console.log('[pty-manager] No persisted terminals to reconnect');
434
+ return;
435
+ }
436
+
437
+ console.log(`[pty-manager] Reconnecting to ${running.length} persisted terminal(s)...`);
438
+
439
+ for (const record of running) {
440
+ try {
441
+ await this.reconnectOne(record);
442
+ } catch (err) {
443
+ const errMsg = err instanceof Error ? err.message : String(err);
444
+ console.warn(`[pty-manager] Failed to reconnect terminal ${record.id}: ${errMsg}`);
445
+ this.handleReconnectFailure(record);
446
+ }
447
+ }
448
+ }
449
+
450
+ // -----------------------------------------------------------------------
451
+ // cleanup — evict exited terminals older than 1 hour, cap at 10 exited;
452
+ // also clean up old SQLite records
453
+ // -----------------------------------------------------------------------
454
+
455
+ remove(id: string): boolean {
456
+ const terminal = this.terminals.get(id);
457
+ if (!terminal) {return false;}
458
+ if (terminal.status === 'running') {return false;} // cannot remove running terminals
459
+
460
+ this.evict(id);
461
+ return true;
462
+ }
463
+
464
+ // -----------------------------------------------------------------------
465
+ // destroy — emergency forced kill (kills holder processes too)
466
+ // -----------------------------------------------------------------------
467
+
468
+ resize(id: string, cols: number, rows: number): boolean {
469
+ const terminal = this.terminals.get(id);
470
+ if (!terminal || terminal.status === 'exited') {return false;}
471
+
472
+ try {
473
+ terminal.pty.resize(cols, rows);
474
+ terminal.cols = cols;
475
+ terminal.rows = rows;
476
+ return true;
477
+ } catch {
478
+ return false;
479
+ }
480
+ }
481
+
482
+ // -----------------------------------------------------------------------
483
+ // Private: reconnectOne — reconnect to a single persisted terminal
484
+ // -----------------------------------------------------------------------
485
+
486
+ private appendScrollback(terminal: ManagedTerminal, data: string): void {
487
+ terminal.scrollback += data;
488
+
489
+ // Trim at newline boundary when we exceed the byte cap
490
+ // (avoids corrupting multi-byte UTF-8 chars or VT escape sequences)
491
+ if (Buffer.byteLength(terminal.scrollback, 'utf8') > MAX_SCROLLBACK_BYTES) {
492
+ const mid = Math.floor(terminal.scrollback.length / 2);
493
+ const newlineIdx = terminal.scrollback.indexOf('\n', mid);
494
+ if (newlineIdx !== -1) {
495
+ terminal.scrollback = terminal.scrollback.slice(newlineIdx + 1);
496
+ } else {
497
+ // No newline found after midpoint — discard the first half entirely
498
+ terminal.scrollback = terminal.scrollback.slice(mid);
499
+ }
500
+ }
501
+ }
502
+
503
+ // -----------------------------------------------------------------------
504
+ // Private: handleReconnectFailure — handle failed reconnection
505
+ // -----------------------------------------------------------------------
506
+
507
+ private broadcastOutput(terminal: ManagedTerminal, data: string): void {
508
+ const msg = JSON.stringify({ data, type: 'output' });
509
+
510
+ for (const ws of terminal.clients) {
511
+ // Skip if WebSocket has too much queued already
512
+ if (ws.bufferedAmount > MAX_OUTPUT_BUFFER_BYTES) {
513
+ this.safeSend(ws, JSON.stringify({ bytes: data.length, type: 'output-dropped' }));
514
+ continue;
515
+ }
516
+
517
+ const buffer = terminal.outputBuffers.get(ws);
518
+ if (!buffer) {continue;}
519
+
520
+ const msgSize = Buffer.byteLength(msg, 'utf8');
521
+
522
+ // Check backpressure: if buffer exceeds limit, drop oldest data
523
+ if (buffer.size + msgSize > MAX_OUTPUT_BUFFER_BYTES) {
524
+ let droppedBytes = 0;
525
+ while (buffer.size + msgSize > MAX_OUTPUT_BUFFER_BYTES && buffer.data.length > 0) {
526
+ const dropped = buffer.data.shift();
527
+ if (dropped) {
528
+ const droppedSize = Buffer.byteLength(dropped, 'utf8');
529
+ buffer.size -= droppedSize;
530
+ droppedBytes += droppedSize;
531
+ }
532
+ }
533
+
534
+ // Notify client of dropped output
535
+ if (droppedBytes > 0) {
536
+ const dropMsg = JSON.stringify({
537
+ bytes: droppedBytes,
538
+ type: 'output-dropped',
539
+ });
540
+ this.safeSend(ws, dropMsg);
541
+ }
542
+ }
543
+
544
+ // Buffer the message and attempt to send
545
+ buffer.data.push(msg);
546
+ buffer.size += msgSize;
547
+
548
+ this.flushOutputBuffer(ws, buffer);
549
+ }
550
+ }
551
+
552
+ // -----------------------------------------------------------------------
553
+ // Private: startSessionDiscovery — polling for session files
554
+ // -----------------------------------------------------------------------
555
+
556
+ /** Evict a terminal, freeing all resources. */
557
+ private evict(id: string): void {
558
+ const terminal = this.terminals.get(id);
559
+ if (!terminal) {return;}
560
+
561
+ // Clear session-file poll timer if still running
562
+ if (terminal.pollTimer) {
563
+ clearInterval(terminal.pollTimer);
564
+ terminal.pollTimer = null;
565
+ }
566
+
567
+ // Unsubscribe the no-op OpenCode watcher callback if present
568
+ if (terminal.openCodeSessionId && terminal.openCodeNoopCb) {
569
+ openCodeWatcher.stop(terminal.openCodeSessionId, terminal.openCodeNoopCb);
570
+ terminal.openCodeNoopCb = null;
571
+ }
572
+
573
+ // Disconnect from holder (but don't kill it — it may already be gone)
574
+ terminal.pty.disconnect();
575
+
576
+ // Close remaining client connections
577
+ for (const ws of terminal.clients) {
578
+ try {
579
+ ws.close();
580
+ } catch {
581
+ // Best effort
582
+ }
583
+ }
584
+ terminal.clients.clear();
585
+ terminal.outputBuffers.clear();
586
+ terminal.scrollback = '';
587
+
588
+ this.terminals.delete(id);
589
+ }
590
+
591
+ // -----------------------------------------------------------------------
592
+ // Private: appendScrollback — append to cached scrollback string,
593
+ // trim from midpoint when cap exceeded
594
+ // -----------------------------------------------------------------------
595
+
596
+ /** Attempt to flush buffered messages to a WebSocket client. */
597
+ private flushOutputBuffer(ws: WebSocket, buffer: OutputBuffer): void {
598
+ while (buffer.data.length > 0) {
599
+ const msg = buffer.data[0];
600
+ if (!this.safeSend(ws, msg)) {
601
+ // Send failed — leave remaining messages in the buffer
602
+ break;
603
+ }
604
+ buffer.data.shift();
605
+ buffer.size -= Buffer.byteLength(msg, 'utf8');
606
+ }
607
+ }
608
+
609
+ // -----------------------------------------------------------------------
610
+ // Private: broadcastOutput — send output to all connected WS clients
611
+ // with backpressure management
612
+ // -----------------------------------------------------------------------
613
+
614
+ private handleReconnectFailure(record: {
615
+ holderPid: null | number;
616
+ id: string;
617
+ socketPath: null | string;
618
+ }): void {
619
+ // Check if holder PID is still alive
620
+ if (record.holderPid) {
621
+ try {
622
+ process.kill(record.holderPid, 0); // Signal 0 = check alive
623
+ // PID is alive but socket failed — unusual state, still mark orphaned
624
+ console.warn(
625
+ `[pty-manager] Holder PID ${record.holderPid} alive but socket dead for ${record.id}`
626
+ );
627
+ } catch {
628
+ // PID is dead — expected case
629
+ }
630
+ }
631
+
632
+ // Check for .exit sidecar
633
+ if (record.socketPath) {
634
+ const exitFilePath = `${record.socketPath }.exit`;
635
+ if (existsSync(exitFilePath)) {
636
+ try {
637
+ const exitData = JSON.parse(readFileSync(exitFilePath, 'utf8')) as {
638
+ code: null | number;
639
+ timestamp: number;
640
+ };
641
+ unlinkSync(exitFilePath);
642
+ terminalStore.markExited(record.id, exitData.code);
643
+ console.log(
644
+ `[pty-manager] Terminal ${record.id} exited while disconnected (code=${exitData.code})`
645
+ );
646
+ return;
647
+ } catch {
648
+ // Malformed sidecar — fall through to orphan
649
+ }
650
+ }
651
+ }
652
+
653
+ // Mark as orphaned in SQLite (not added to in-memory Map)
654
+ terminalStore.markOrphaned(record.id);
655
+ console.log(`[pty-manager] Marked terminal ${record.id} as orphaned`);
656
+ }
657
+
658
+ private async reconnectOne(record: {
659
+ args: string;
660
+ cols: number;
661
+ command: string;
662
+ createdAt: string;
663
+ cwd: string;
664
+ exitCode: null | number;
665
+ exitedAt: null | string;
666
+ holderPid: null | number;
667
+ id: string;
668
+ opencodeSessionId: null | string;
669
+ pid: null | number;
670
+ rows: number;
671
+ sessionFile: null | string;
672
+ socketPath: null | string;
673
+ status: string;
674
+ }): Promise<void> {
675
+ if (!record.socketPath) {
676
+ throw new Error('No socket path stored');
677
+ }
678
+
679
+ // Check for .exit sidecar file — the PTY may have exited while
680
+ // the server was down
681
+ const exitFilePath = `${record.socketPath }.exit`;
682
+ if (existsSync(exitFilePath)) {
683
+ try {
684
+ const exitData = JSON.parse(readFileSync(exitFilePath, 'utf8')) as {
685
+ code: null | number;
686
+ timestamp: number;
687
+ };
688
+ unlinkSync(exitFilePath); // Clean up sidecar immediately
689
+ terminalStore.markExited(record.id, exitData.code);
690
+ console.log(
691
+ `[pty-manager] Terminal ${record.id} exited while disconnected (code=${exitData.code})`
692
+ );
693
+ return; // Do not add to in-memory Map
694
+ } catch {
695
+ // Sidecar file may be malformed — continue with socket connect attempt
696
+ }
697
+ }
698
+
699
+ // Try connecting to the holder via its Unix socket
700
+ const client = new HolderClient();
701
+ const connectResult = await client.connect(record.socketPath);
702
+
703
+ // Parse stored args
704
+ let parsedArgs: string[] = [];
705
+ try {
706
+ parsedArgs = JSON.parse(record.args) as string[];
707
+ } catch {
708
+ // Fallback to empty
709
+ }
710
+
711
+ const terminal: ManagedTerminal = {
712
+ args: parsedArgs,
713
+ clients: new Set(),
714
+ cols: record.cols,
715
+ command: record.command,
716
+ createdAt: new Date(record.createdAt),
717
+ currentCwd: null,
718
+ cwd: record.cwd,
719
+ exitCode: connectResult.exitCode,
720
+ exitedAt: record.exitedAt ? new Date(record.exitedAt) : null,
721
+ holderPid: record.holderPid ?? 0,
722
+ id: record.id,
723
+ isActive: false,
724
+ openCodeNoopCb: null,
725
+ openCodeSessionId: record.opencodeSessionId ?? null,
726
+ outputBuffers: new Map(),
727
+ pid: connectResult.pid,
728
+ pollTimer: null,
729
+ pty: client,
730
+ rows: record.rows,
731
+ scrollback: connectResult.scrollback,
732
+ sessionFile: record.sessionFile ?? null,
733
+ socketPath: record.socketPath,
734
+ status: connectResult.exited ? 'exited' : 'running',
735
+ watcherOffset: 0,
736
+ };
737
+
738
+ // If the PTY already exited, update SQLite and add to Map for visibility
739
+ if (connectResult.exited) {
740
+ terminal.exitedAt = terminal.exitedAt ?? new Date();
741
+ terminalStore.markExited(record.id, connectResult.exitCode);
742
+ }
743
+
744
+ // Wire up all HolderClient callbacks
745
+ this.wireHolderCallbacks(client, terminal);
746
+
747
+ // Re-attach session watchers
748
+ if (terminal.sessionFile) {
749
+ // No-op: session-handler.ts subscribes when a client connects.
750
+ // Previously called sessionWatcher.watch() with an empty callback,
751
+ // which blocked real subscribers due to the single-callback guard.
752
+ }
753
+ if (terminal.openCodeSessionId) {
754
+ const noopCb: (messages: import('../sessions/types').ConversationMessage[]) => void = () => {};
755
+ terminal.openCodeNoopCb = noopCb;
756
+ openCodeWatcher.watch(terminal.openCodeSessionId, noopCb);
757
+ }
758
+
759
+ // Restart session discovery if session hasn't been found yet
760
+ if (!terminal.sessionFile && !terminal.openCodeSessionId && terminal.status === 'running') {
761
+ this.startSessionDiscovery(terminal);
762
+ }
763
+
764
+ // Update PID in SQLite if it changed (e.g., holder restarted PTY — unlikely but defensive)
765
+ if (record.pid !== connectResult.pid) {
766
+ terminalStore.update(record.id, { pid: connectResult.pid });
767
+ }
768
+
769
+ this.terminals.set(record.id, terminal);
770
+ console.log(
771
+ `[pty-manager] Reconnected terminal ${record.id} (pid=${connectResult.pid}, ` +
772
+ `holder=${record.holderPid}, status=${terminal.status})`
773
+ );
774
+ }
775
+
776
+ /** Wire up all HolderClient callbacks (activity, CWD, output, exit, disconnect). */
777
+ private wireHolderCallbacks(client: HolderClient, terminal: ManagedTerminal): void {
778
+ client.onActivity((active: boolean) => {
779
+ terminal.isActive = active;
780
+ const msg = JSON.stringify({ active, type: 'activity' });
781
+ for (const ws of terminal.clients) {
782
+ this.safeSend(ws, msg);
783
+ }
784
+ });
785
+
786
+ client.onCwd((path: string) => {
787
+ terminal.currentCwd = path;
788
+ const msg = JSON.stringify({ path, type: 'cwd' });
789
+ for (const ws of terminal.clients) {
790
+ this.safeSend(ws, msg);
791
+ }
792
+ });
793
+
794
+ client.onOutput((data: string) => {
795
+ this.appendScrollback(terminal, data);
796
+ this.broadcastOutput(terminal, data);
797
+ });
798
+
799
+ client.onExit((exitCode: null | number) => {
800
+ terminal.status = 'exited';
801
+ terminal.exitCode = exitCode;
802
+ terminal.exitedAt = new Date();
803
+ terminalStore.markExited(terminal.id, exitCode);
804
+
805
+ const exitMsg = JSON.stringify({
806
+ code: exitCode,
807
+ signal: null,
808
+ type: 'exit',
809
+ });
810
+ for (const ws of terminal.clients) {
811
+ this.safeSend(ws, exitMsg);
812
+ }
813
+ });
814
+
815
+ client.onDisconnect(() => {
816
+ if (terminal.status === 'running') {
817
+ console.warn(`[pty-manager] Holder disconnected unexpectedly for terminal ${terminal.id}`);
818
+ terminal.status = 'exited';
819
+ terminal.exitedAt = new Date();
820
+ terminalStore.markOrphaned(terminal.id);
821
+ }
822
+ });
823
+ }
824
+
825
+ /** Safely send a message to a WebSocket, returning false on failure. */
826
+ private safeSend(ws: WebSocket, msg: string): boolean {
827
+ try {
828
+ // readyState 1 === OPEN
829
+ if (ws.readyState !== 1) {return false;}
830
+ ws.send(msg);
831
+ return true;
832
+ } catch {
833
+ return false;
834
+ }
835
+ }
836
+
837
+ /** Send cached scrollback data to a newly connected client in 50 KB chunks. */
838
+ private async sendScrollback(terminal: ManagedTerminal, ws: WebSocket): Promise<void> {
839
+ const fullData = terminal.scrollback;
840
+ if (fullData.length === 0) {return;}
841
+
842
+ const totalBytes = Buffer.byteLength(fullData, 'utf8');
843
+ const totalChunks = Math.ceil(totalBytes / SCROLLBACK_CHUNK_SIZE);
844
+
845
+ if (totalChunks <= 1) {
846
+ // Single chunk — send directly
847
+ const msg = JSON.stringify({
848
+ chunk: 1,
849
+ data: fullData,
850
+ total: 1,
851
+ type: 'scrollback',
852
+ });
853
+ this.safeSend(ws, msg);
854
+ return;
855
+ }
856
+
857
+ // Multi-chunk: split the data into byte-safe segments
858
+ const buf = Buffer.from(fullData, 'utf8');
859
+ let offset = 0;
860
+ let chunkIndex = 1;
861
+
862
+ while (offset < buf.length) {
863
+ // Gate scrollback sending on actual socket backpressure
864
+ if (ws.bufferedAmount > SCROLLBACK_CHUNK_SIZE * 2) {
865
+ await new Promise(resolve => setTimeout(resolve, 50));
866
+ }
867
+
868
+ const end = Math.min(offset + SCROLLBACK_CHUNK_SIZE, buf.length);
869
+ const chunkData = buf.subarray(offset, end).toString('utf8');
870
+ const msg = JSON.stringify({
871
+ chunk: chunkIndex,
872
+ data: chunkData,
873
+ total: totalChunks,
874
+ type: 'scrollback',
875
+ });
876
+ this.safeSend(ws, msg);
877
+ offset = end;
878
+ chunkIndex++;
879
+ }
880
+ }
881
+
882
+ private startSessionDiscovery(terminal: ManagedTerminal): void {
883
+ const { command, cwd, id } = terminal;
884
+
885
+ // For Claude Code: detect the session file by watching the project
886
+ // directory for new JSONL files created after launch.
887
+ if (command === 'claude') {
888
+ const projectDir = path.join(
889
+ process.env.HOME || '', '.claude', 'projects',
890
+ cwd.replace(/\//g, '-')
891
+ );
892
+ const launchTime = terminal.createdAt.getTime();
893
+
894
+ terminal.pollTimer = setInterval(() => {
895
+ if (terminal.status === 'exited' || terminal.sessionFile) {
896
+ if (terminal.pollTimer) {
897
+ clearInterval(terminal.pollTimer);
898
+ terminal.pollTimer = null;
899
+ }
900
+ if (terminal.sessionFile) {
901
+ // No-op: session-handler.ts subscribes when a client connects.
902
+ // Previously called sessionWatcher.watch() with an empty callback,
903
+ // which blocked real subscribers due to the single-callback guard.
904
+ }
905
+ return;
906
+ }
907
+ try {
908
+ if (!existsSync(projectDir)) {return;}
909
+ const files = readdirSync(projectDir)
910
+ .filter((f) => f.endsWith('.jsonl'))
911
+ .map((f) => {
912
+ const stat = statSync(path.join(projectDir, f));
913
+ return {
914
+ birthtime: stat.birthtimeMs,
915
+ fullPath: path.join(projectDir, f),
916
+ mtime: stat.mtimeMs,
917
+ name: f,
918
+ };
919
+ })
920
+ // Filter by CREATION time, not modification time.
921
+ // Using mtime would match existing active sessions in the same
922
+ // project directory (their mtime keeps updating as they write).
923
+ .filter((f) => f.birthtime > launchTime)
924
+ .sort((a, b) => b.birthtime - a.birthtime);
925
+
926
+ if (files.length > 0) {
927
+ terminal.sessionFile = files[0].fullPath;
928
+ if (terminal.pollTimer) {
929
+ clearInterval(terminal.pollTimer);
930
+ terminal.pollTimer = null;
931
+ }
932
+ // No-op: session-handler.ts subscribes when a client connects.
933
+ // Previously called sessionWatcher.watch() with an empty callback,
934
+ // which blocked real subscribers due to the single-callback guard.
935
+ // Persist session file to SQLite
936
+ terminalStore.update(id, { sessionFile: terminal.sessionFile });
937
+ }
938
+ } catch {
939
+ // ignore filesystem errors
940
+ }
941
+ }, 1500);
942
+
943
+ setTimeout(() => {
944
+ if (terminal.pollTimer) {
945
+ clearInterval(terminal.pollTimer);
946
+ terminal.pollTimer = null;
947
+ }
948
+ }, 5 * 60 * 1000);
949
+ }
950
+
951
+ // For OpenCode: detect the session via SQLite database lookup.
952
+ // Only match sessions created AFTER this terminal launched (prevents
953
+ // latching onto old sessions in the same directory).
954
+ if (command === 'opencode') {
955
+ const launchTime = terminal.createdAt.getTime();
956
+ const pollInterval = setInterval(() => {
957
+ if (terminal.status === 'exited' || terminal.openCodeSessionId) {
958
+ clearInterval(pollInterval);
959
+ if (terminal.openCodeSessionId) {
960
+ const noopCb: (messages: import('../sessions/types').ConversationMessage[]) => void = () => {};
961
+ terminal.openCodeNoopCb = noopCb;
962
+ openCodeWatcher.watch(terminal.openCodeSessionId, noopCb);
963
+ }
964
+ return;
965
+ }
966
+ const sessionId = openCodeWatcher.findSessionId(cwd, launchTime);
967
+ if (sessionId) {
968
+ terminal.openCodeSessionId = sessionId;
969
+ clearInterval(pollInterval);
970
+ const noopCb: (messages: import('../sessions/types').ConversationMessage[]) => void = () => {};
971
+ terminal.openCodeNoopCb = noopCb;
972
+ openCodeWatcher.watch(sessionId, noopCb);
973
+ // Persist session ID to SQLite
974
+ terminalStore.update(id, { opencodeSessionId: sessionId });
975
+ }
976
+ }, 2000);
977
+
978
+ terminal.pollTimer = pollInterval;
979
+ setTimeout(() => { clearInterval(pollInterval); terminal.pollTimer = null; }, 5 * 60 * 1000);
980
+ }
981
+ }
982
+ }
983
+
984
+ // ---------------------------------------------------------------------------
985
+ // PtyManager
986
+ // ---------------------------------------------------------------------------
987
+
988
+ function resolveHolderPath(): string {
989
+ if (process.env.SHOOTER_HOLDER_PATH) {
990
+ return process.env.SHOOTER_HOLDER_PATH;
991
+ }
992
+ // In dev: __dirname is src/lib/modules/server/terminal/ → pty-holder.cjs is co-located
993
+ // In prod: __dirname is build/server/chunks/ → pty-holder.cjs is at build/ (copied by postbuild)
994
+ const colocated = path.join(__dirname, 'pty-holder.cjs');
995
+ if (existsSync(colocated)) {
996
+ return colocated;
997
+ }
998
+ // Walk up from build/server/chunks/ to build/
999
+ return path.resolve(__dirname, '..', '..', 'pty-holder.cjs');
1000
+ }
1001
+
1002
+ // ---------------------------------------------------------------------------
1003
+ // Singleton export
1004
+ // ---------------------------------------------------------------------------
1005
+
1006
+ // Use globalThis to ensure a single shared instance across module loaders.
1007
+ // server.ts (tsx) and SvelteKit's build handler load this module separately.
1008
+ const PTY_GLOBAL_KEY = '__shooter_pty_manager';
1009
+ export const ptyManager: PtyManager =
1010
+ ((globalThis as Record<string, unknown>)[PTY_GLOBAL_KEY] as PtyManager) ||
1011
+ new PtyManager();
1012
+ (globalThis as Record<string, unknown>)[PTY_GLOBAL_KEY] = ptyManager;