@made-by-moonlight/athene-web 0.9.2

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 (302) hide show
  1. package/.next/BUILD_ID +1 -0
  2. package/.next/app-build-manifest.json +448 -0
  3. package/.next/app-path-routes-manifest.json +47 -0
  4. package/.next/build-manifest.json +33 -0
  5. package/.next/export-marker.json +6 -0
  6. package/.next/images-manifest.json +58 -0
  7. package/.next/next-minimal-server.js.nft.json +1 -0
  8. package/.next/next-server.js.nft.json +1 -0
  9. package/.next/package.json +1 -0
  10. package/.next/prerender-manifest.json +172 -0
  11. package/.next/react-loadable-manifest.json +61 -0
  12. package/.next/required-server-files.json +335 -0
  13. package/.next/routes-manifest.json +234 -0
  14. package/.next/server/app/_not-found/page.js +2 -0
  15. package/.next/server/app/_not-found/page.js.nft.json +1 -0
  16. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -0
  17. package/.next/server/app/_not-found.html +1 -0
  18. package/.next/server/app/_not-found.meta +8 -0
  19. package/.next/server/app/_not-found.rsc +24 -0
  20. package/.next/server/app/api/backlog/route.js +1 -0
  21. package/.next/server/app/api/backlog/route.js.nft.json +1 -0
  22. package/.next/server/app/api/backlog/route_client-reference-manifest.js +1 -0
  23. package/.next/server/app/api/browse-directory/route.js +1 -0
  24. package/.next/server/app/api/browse-directory/route.js.nft.json +1 -0
  25. package/.next/server/app/api/browse-directory/route_client-reference-manifest.js +1 -0
  26. package/.next/server/app/api/filesystem/browse/route.js +1 -0
  27. package/.next/server/app/api/filesystem/browse/route.js.nft.json +1 -0
  28. package/.next/server/app/api/filesystem/browse/route_client-reference-manifest.js +1 -0
  29. package/.next/server/app/api/issues/route.js +1 -0
  30. package/.next/server/app/api/issues/route.js.nft.json +1 -0
  31. package/.next/server/app/api/issues/route_client-reference-manifest.js +1 -0
  32. package/.next/server/app/api/observability/route.js +1 -0
  33. package/.next/server/app/api/observability/route.js.nft.json +1 -0
  34. package/.next/server/app/api/observability/route_client-reference-manifest.js +1 -0
  35. package/.next/server/app/api/orchestrators/route.js +1 -0
  36. package/.next/server/app/api/orchestrators/route.js.nft.json +1 -0
  37. package/.next/server/app/api/orchestrators/route_client-reference-manifest.js +1 -0
  38. package/.next/server/app/api/projects/[id]/route.js +5 -0
  39. package/.next/server/app/api/projects/[id]/route.js.nft.json +1 -0
  40. package/.next/server/app/api/projects/[id]/route_client-reference-manifest.js +1 -0
  41. package/.next/server/app/api/projects/reload/route.js +1 -0
  42. package/.next/server/app/api/projects/reload/route.js.nft.json +1 -0
  43. package/.next/server/app/api/projects/reload/route_client-reference-manifest.js +1 -0
  44. package/.next/server/app/api/projects/route.js +1 -0
  45. package/.next/server/app/api/projects/route.js.nft.json +1 -0
  46. package/.next/server/app/api/projects/route_client-reference-manifest.js +1 -0
  47. package/.next/server/app/api/prs/[id]/merge/route.js +1 -0
  48. package/.next/server/app/api/prs/[id]/merge/route.js.nft.json +1 -0
  49. package/.next/server/app/api/prs/[id]/merge/route_client-reference-manifest.js +1 -0
  50. package/.next/server/app/api/reviews/execute/route.js +1 -0
  51. package/.next/server/app/api/reviews/execute/route.js.nft.json +1 -0
  52. package/.next/server/app/api/reviews/execute/route_client-reference-manifest.js +1 -0
  53. package/.next/server/app/api/reviews/findings/route.js +1 -0
  54. package/.next/server/app/api/reviews/findings/route.js.nft.json +1 -0
  55. package/.next/server/app/api/reviews/findings/route_client-reference-manifest.js +1 -0
  56. package/.next/server/app/api/reviews/route.js +1 -0
  57. package/.next/server/app/api/reviews/route.js.nft.json +1 -0
  58. package/.next/server/app/api/reviews/route_client-reference-manifest.js +1 -0
  59. package/.next/server/app/api/reviews/send/route.js +1 -0
  60. package/.next/server/app/api/reviews/send/route.js.nft.json +1 -0
  61. package/.next/server/app/api/reviews/send/route_client-reference-manifest.js +1 -0
  62. package/.next/server/app/api/runtime/terminal/route.js +1 -0
  63. package/.next/server/app/api/runtime/terminal/route.js.nft.json +1 -0
  64. package/.next/server/app/api/runtime/terminal/route_client-reference-manifest.js +1 -0
  65. package/.next/server/app/api/sessions/[id]/kill/route.js +1 -0
  66. package/.next/server/app/api/sessions/[id]/kill/route.js.nft.json +1 -0
  67. package/.next/server/app/api/sessions/[id]/kill/route_client-reference-manifest.js +1 -0
  68. package/.next/server/app/api/sessions/[id]/message/route.js +1 -0
  69. package/.next/server/app/api/sessions/[id]/message/route.js.nft.json +1 -0
  70. package/.next/server/app/api/sessions/[id]/message/route_client-reference-manifest.js +1 -0
  71. package/.next/server/app/api/sessions/[id]/remap/route.js +1 -0
  72. package/.next/server/app/api/sessions/[id]/remap/route.js.nft.json +1 -0
  73. package/.next/server/app/api/sessions/[id]/remap/route_client-reference-manifest.js +1 -0
  74. package/.next/server/app/api/sessions/[id]/restore/route.js +1 -0
  75. package/.next/server/app/api/sessions/[id]/restore/route.js.nft.json +1 -0
  76. package/.next/server/app/api/sessions/[id]/restore/route_client-reference-manifest.js +1 -0
  77. package/.next/server/app/api/sessions/[id]/route.js +1 -0
  78. package/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -0
  79. package/.next/server/app/api/sessions/[id]/route_client-reference-manifest.js +1 -0
  80. package/.next/server/app/api/sessions/[id]/send/route.js +1 -0
  81. package/.next/server/app/api/sessions/[id]/send/route.js.nft.json +1 -0
  82. package/.next/server/app/api/sessions/[id]/send/route_client-reference-manifest.js +1 -0
  83. package/.next/server/app/api/sessions/patches/route.js +1 -0
  84. package/.next/server/app/api/sessions/patches/route.js.nft.json +1 -0
  85. package/.next/server/app/api/sessions/patches/route_client-reference-manifest.js +1 -0
  86. package/.next/server/app/api/sessions/route.js +1 -0
  87. package/.next/server/app/api/sessions/route.js.nft.json +1 -0
  88. package/.next/server/app/api/sessions/route_client-reference-manifest.js +1 -0
  89. package/.next/server/app/api/setup-labels/route.js +1 -0
  90. package/.next/server/app/api/setup-labels/route.js.nft.json +1 -0
  91. package/.next/server/app/api/setup-labels/route_client-reference-manifest.js +1 -0
  92. package/.next/server/app/api/spawn/route.js +1 -0
  93. package/.next/server/app/api/spawn/route.js.nft.json +1 -0
  94. package/.next/server/app/api/spawn/route_client-reference-manifest.js +1 -0
  95. package/.next/server/app/api/update/route.js +1 -0
  96. package/.next/server/app/api/update/route.js.nft.json +1 -0
  97. package/.next/server/app/api/update/route_client-reference-manifest.js +1 -0
  98. package/.next/server/app/api/verify/route.js +1 -0
  99. package/.next/server/app/api/verify/route.js.nft.json +1 -0
  100. package/.next/server/app/api/verify/route_client-reference-manifest.js +1 -0
  101. package/.next/server/app/api/version/route.js +1 -0
  102. package/.next/server/app/api/version/route.js.nft.json +1 -0
  103. package/.next/server/app/api/version/route_client-reference-manifest.js +1 -0
  104. package/.next/server/app/api/webhooks/[...slug]/route.js +1 -0
  105. package/.next/server/app/api/webhooks/[...slug]/route.js.nft.json +1 -0
  106. package/.next/server/app/api/webhooks/[...slug]/route_client-reference-manifest.js +1 -0
  107. package/.next/server/app/apple-icon/route.js +1 -0
  108. package/.next/server/app/apple-icon/route.js.nft.json +1 -0
  109. package/.next/server/app/apple-icon/route_client-reference-manifest.js +1 -0
  110. package/.next/server/app/apple-icon.body +0 -0
  111. package/.next/server/app/apple-icon.meta +1 -0
  112. package/.next/server/app/dev/terminal-test/page.js +15 -0
  113. package/.next/server/app/dev/terminal-test/page.js.nft.json +1 -0
  114. package/.next/server/app/dev/terminal-test/page_client-reference-manifest.js +1 -0
  115. package/.next/server/app/dev/terminal-test.html +1 -0
  116. package/.next/server/app/dev/terminal-test.meta +7 -0
  117. package/.next/server/app/dev/terminal-test.rsc +28 -0
  118. package/.next/server/app/icon/route.js +1 -0
  119. package/.next/server/app/icon/route.js.nft.json +1 -0
  120. package/.next/server/app/icon/route_client-reference-manifest.js +1 -0
  121. package/.next/server/app/icon-192/route.js +1 -0
  122. package/.next/server/app/icon-192/route.js.nft.json +1 -0
  123. package/.next/server/app/icon-192/route_client-reference-manifest.js +1 -0
  124. package/.next/server/app/icon-512/route.js +1 -0
  125. package/.next/server/app/icon-512/route.js.nft.json +1 -0
  126. package/.next/server/app/icon-512/route_client-reference-manifest.js +1 -0
  127. package/.next/server/app/icon.body +0 -0
  128. package/.next/server/app/icon.meta +1 -0
  129. package/.next/server/app/manifest.webmanifest/route.js +16 -0
  130. package/.next/server/app/manifest.webmanifest/route.js.nft.json +1 -0
  131. package/.next/server/app/manifest.webmanifest/route_client-reference-manifest.js +1 -0
  132. package/.next/server/app/manifest.webmanifest.body +1 -0
  133. package/.next/server/app/manifest.webmanifest.meta +1 -0
  134. package/.next/server/app/page.js +2 -0
  135. package/.next/server/app/page.js.nft.json +1 -0
  136. package/.next/server/app/page_client-reference-manifest.js +1 -0
  137. package/.next/server/app/projects/[projectId]/page.js +2 -0
  138. package/.next/server/app/projects/[projectId]/page.js.nft.json +1 -0
  139. package/.next/server/app/projects/[projectId]/page_client-reference-manifest.js +1 -0
  140. package/.next/server/app/projects/[projectId]/sessions/[id]/page.js +2 -0
  141. package/.next/server/app/projects/[projectId]/sessions/[id]/page.js.nft.json +1 -0
  142. package/.next/server/app/projects/[projectId]/sessions/[id]/page_client-reference-manifest.js +1 -0
  143. package/.next/server/app/projects/[projectId]/settings/page.js +2 -0
  144. package/.next/server/app/projects/[projectId]/settings/page.js.nft.json +1 -0
  145. package/.next/server/app/projects/[projectId]/settings/page_client-reference-manifest.js +1 -0
  146. package/.next/server/app/prs/page.js +2 -0
  147. package/.next/server/app/prs/page.js.nft.json +1 -0
  148. package/.next/server/app/prs/page_client-reference-manifest.js +1 -0
  149. package/.next/server/app/review/page.js +2 -0
  150. package/.next/server/app/review/page.js.nft.json +1 -0
  151. package/.next/server/app/review/page_client-reference-manifest.js +1 -0
  152. package/.next/server/app/reviews/page.js +2 -0
  153. package/.next/server/app/reviews/page.js.nft.json +1 -0
  154. package/.next/server/app/reviews/page_client-reference-manifest.js +1 -0
  155. package/.next/server/app/sessions/[id]/page.js +2 -0
  156. package/.next/server/app/sessions/[id]/page.js.nft.json +1 -0
  157. package/.next/server/app/sessions/[id]/page_client-reference-manifest.js +1 -0
  158. package/.next/server/app/test-direct/page.js +2 -0
  159. package/.next/server/app/test-direct/page.js.nft.json +1 -0
  160. package/.next/server/app/test-direct/page_client-reference-manifest.js +1 -0
  161. package/.next/server/app/test-direct.html +1 -0
  162. package/.next/server/app/test-direct.meta +7 -0
  163. package/.next/server/app/test-direct.rsc +28 -0
  164. package/.next/server/app-paths-manifest.json +47 -0
  165. package/.next/server/chunks/1215.js +1 -0
  166. package/.next/server/chunks/1271.js +1 -0
  167. package/.next/server/chunks/2106.js +1 -0
  168. package/.next/server/chunks/2347.js +3 -0
  169. package/.next/server/chunks/2899.js +1 -0
  170. package/.next/server/chunks/2914.js +1 -0
  171. package/.next/server/chunks/4033.js +1 -0
  172. package/.next/server/chunks/4148.js +942 -0
  173. package/.next/server/chunks/4422.js +32 -0
  174. package/.next/server/chunks/5053.js +1 -0
  175. package/.next/server/chunks/5689.js +1 -0
  176. package/.next/server/chunks/6148.js +9 -0
  177. package/.next/server/chunks/6582.js +25 -0
  178. package/.next/server/chunks/7002.js +1 -0
  179. package/.next/server/chunks/7486.js +9 -0
  180. package/.next/server/chunks/8803.js +6 -0
  181. package/.next/server/chunks/9472.js +847 -0
  182. package/.next/server/chunks/9493.js +1 -0
  183. package/.next/server/chunks/9561.js +22 -0
  184. package/.next/server/chunks/966.js +1 -0
  185. package/.next/server/functions-config-manifest.json +4 -0
  186. package/.next/server/interception-route-rewrite-manifest.js +1 -0
  187. package/.next/server/middleware-build-manifest.js +1 -0
  188. package/.next/server/middleware-manifest.json +6 -0
  189. package/.next/server/middleware-react-loadable-manifest.js +1 -0
  190. package/.next/server/next-font-manifest.js +1 -0
  191. package/.next/server/next-font-manifest.json +1 -0
  192. package/.next/server/pages/404.html +1 -0
  193. package/.next/server/pages/500.html +1 -0
  194. package/.next/server/pages/_app.js +1 -0
  195. package/.next/server/pages/_app.js.nft.json +1 -0
  196. package/.next/server/pages/_document.js +1 -0
  197. package/.next/server/pages/_document.js.nft.json +1 -0
  198. package/.next/server/pages/_error.js +19 -0
  199. package/.next/server/pages/_error.js.nft.json +1 -0
  200. package/.next/server/pages-manifest.json +6 -0
  201. package/.next/server/server-reference-manifest.js +1 -0
  202. package/.next/server/server-reference-manifest.json +1 -0
  203. package/.next/server/webpack-runtime.js +1 -0
  204. package/.next/static/chunks/0f200859.359176a445d2791f.js +10 -0
  205. package/.next/static/chunks/1089-c6d7995c7c19039a.js +1 -0
  206. package/.next/static/chunks/1461-af7c54935f21d56d.js +1 -0
  207. package/.next/static/chunks/2073-4192de0bb00cc993.js +1 -0
  208. package/.next/static/chunks/3764.4c736d9a181489a4.js +1 -0
  209. package/.next/static/chunks/4115-1a4fa80ec67a29d3.js +1 -0
  210. package/.next/static/chunks/4751.6c71bfc0c4520627.js +1 -0
  211. package/.next/static/chunks/503.f7ff284457dd9c40.js +1 -0
  212. package/.next/static/chunks/5204.7de7e266895bced7.js +1 -0
  213. package/.next/static/chunks/6835.7a29fa4b665b7c2f.js +1 -0
  214. package/.next/static/chunks/7008-0d9bee1bf4bfcbea.js +1 -0
  215. package/.next/static/chunks/7317.685aa5231218e8d3.js +1 -0
  216. package/.next/static/chunks/8204-7c7837ed694da99c.js +1 -0
  217. package/.next/static/chunks/8713-d3d663f55dc00e48.js +1 -0
  218. package/.next/static/chunks/8759-490573536f93f85c.js +1 -0
  219. package/.next/static/chunks/88a6fc35-f836b4b72df5eafa.js +1 -0
  220. package/.next/static/chunks/9531-a5175e55fa0db48d.js +1 -0
  221. package/.next/static/chunks/9876-de0c5a1a319b4e8e.js +1 -0
  222. package/.next/static/chunks/app/_not-found/page-6add9dacf1870b4b.js +1 -0
  223. package/.next/static/chunks/app/api/backlog/route-6add9dacf1870b4b.js +1 -0
  224. package/.next/static/chunks/app/api/browse-directory/route-6add9dacf1870b4b.js +1 -0
  225. package/.next/static/chunks/app/api/filesystem/browse/route-6add9dacf1870b4b.js +1 -0
  226. package/.next/static/chunks/app/api/issues/route-6add9dacf1870b4b.js +1 -0
  227. package/.next/static/chunks/app/api/observability/route-6add9dacf1870b4b.js +1 -0
  228. package/.next/static/chunks/app/api/orchestrators/route-6add9dacf1870b4b.js +1 -0
  229. package/.next/static/chunks/app/api/projects/[id]/route-6add9dacf1870b4b.js +1 -0
  230. package/.next/static/chunks/app/api/projects/reload/route-6add9dacf1870b4b.js +1 -0
  231. package/.next/static/chunks/app/api/projects/route-6add9dacf1870b4b.js +1 -0
  232. package/.next/static/chunks/app/api/prs/[id]/merge/route-6add9dacf1870b4b.js +1 -0
  233. package/.next/static/chunks/app/api/reviews/execute/route-6add9dacf1870b4b.js +1 -0
  234. package/.next/static/chunks/app/api/reviews/findings/route-6add9dacf1870b4b.js +1 -0
  235. package/.next/static/chunks/app/api/reviews/route-6add9dacf1870b4b.js +1 -0
  236. package/.next/static/chunks/app/api/reviews/send/route-6add9dacf1870b4b.js +1 -0
  237. package/.next/static/chunks/app/api/runtime/terminal/route-6add9dacf1870b4b.js +1 -0
  238. package/.next/static/chunks/app/api/sessions/[id]/kill/route-6add9dacf1870b4b.js +1 -0
  239. package/.next/static/chunks/app/api/sessions/[id]/message/route-6add9dacf1870b4b.js +1 -0
  240. package/.next/static/chunks/app/api/sessions/[id]/remap/route-6add9dacf1870b4b.js +1 -0
  241. package/.next/static/chunks/app/api/sessions/[id]/restore/route-6add9dacf1870b4b.js +1 -0
  242. package/.next/static/chunks/app/api/sessions/[id]/route-6add9dacf1870b4b.js +1 -0
  243. package/.next/static/chunks/app/api/sessions/[id]/send/route-6add9dacf1870b4b.js +1 -0
  244. package/.next/static/chunks/app/api/sessions/patches/route-6add9dacf1870b4b.js +1 -0
  245. package/.next/static/chunks/app/api/sessions/route-6add9dacf1870b4b.js +1 -0
  246. package/.next/static/chunks/app/api/setup-labels/route-6add9dacf1870b4b.js +1 -0
  247. package/.next/static/chunks/app/api/spawn/route-6add9dacf1870b4b.js +1 -0
  248. package/.next/static/chunks/app/api/update/route-6add9dacf1870b4b.js +1 -0
  249. package/.next/static/chunks/app/api/verify/route-6add9dacf1870b4b.js +1 -0
  250. package/.next/static/chunks/app/api/version/route-6add9dacf1870b4b.js +1 -0
  251. package/.next/static/chunks/app/api/webhooks/[...slug]/route-6add9dacf1870b4b.js +1 -0
  252. package/.next/static/chunks/app/apple-icon/route-6add9dacf1870b4b.js +1 -0
  253. package/.next/static/chunks/app/dev/terminal-test/page-d0132109f9d8524e.js +1 -0
  254. package/.next/static/chunks/app/error-d632d0714b987864.js +1 -0
  255. package/.next/static/chunks/app/global-error-f6bef179169bcdae.js +1 -0
  256. package/.next/static/chunks/app/icon/route-6add9dacf1870b4b.js +1 -0
  257. package/.next/static/chunks/app/icon-192/route-6add9dacf1870b4b.js +1 -0
  258. package/.next/static/chunks/app/icon-512/route-6add9dacf1870b4b.js +1 -0
  259. package/.next/static/chunks/app/layout-5cac6fe817194d7a.js +1 -0
  260. package/.next/static/chunks/app/loading-6add9dacf1870b4b.js +1 -0
  261. package/.next/static/chunks/app/manifest.webmanifest/route-6add9dacf1870b4b.js +1 -0
  262. package/.next/static/chunks/app/not-found-cba3f587e1f98dcb.js +1 -0
  263. package/.next/static/chunks/app/page-cf7bccb75990950d.js +1 -0
  264. package/.next/static/chunks/app/projects/[projectId]/layout-6add9dacf1870b4b.js +1 -0
  265. package/.next/static/chunks/app/projects/[projectId]/loading-6add9dacf1870b4b.js +1 -0
  266. package/.next/static/chunks/app/projects/[projectId]/page-039a93b16089ed57.js +1 -0
  267. package/.next/static/chunks/app/projects/[projectId]/sessions/[id]/page-043b525bedb8f0f7.js +1 -0
  268. package/.next/static/chunks/app/projects/[projectId]/settings/page-63572555892d7a61.js +1 -0
  269. package/.next/static/chunks/app/projects/layout-d43b2e38d46221bd.js +1 -0
  270. package/.next/static/chunks/app/prs/page-e447852fbe0c6ee4.js +1 -0
  271. package/.next/static/chunks/app/review/page-022b2d2c326ff413.js +1 -0
  272. package/.next/static/chunks/app/reviews/page-6add9dacf1870b4b.js +1 -0
  273. package/.next/static/chunks/app/sessions/[id]/error-67c0d27f977a1cc1.js +1 -0
  274. package/.next/static/chunks/app/sessions/[id]/loading-6add9dacf1870b4b.js +1 -0
  275. package/.next/static/chunks/app/sessions/[id]/not-found-cba3f587e1f98dcb.js +1 -0
  276. package/.next/static/chunks/app/sessions/[id]/page-863cf8dd2c76d06d.js +1 -0
  277. package/.next/static/chunks/app/test-direct/page-8b80ed180c0f2f42.js +1 -0
  278. package/.next/static/chunks/e12b4bb0.8742134e1ac0263f.js +58 -0
  279. package/.next/static/chunks/framework-7060e2ac4971c604.js +1 -0
  280. package/.next/static/chunks/main-app-162601c3f1c01b19.js +1 -0
  281. package/.next/static/chunks/main-ed1610689fbd6f0d.js +1 -0
  282. package/.next/static/chunks/pages/_app-f4baf4dbe88f6f54.js +1 -0
  283. package/.next/static/chunks/pages/_error-a7f6723f42093f29.js +1 -0
  284. package/.next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  285. package/.next/static/chunks/webpack-f9566aaa604a1b07.js +1 -0
  286. package/.next/static/css/0c9b7451c2ce7c02.css +1 -0
  287. package/.next/static/css/7ca4e3753c1c7833.css +1 -0
  288. package/.next/static/css/b4a15f23f468892a.css +1 -0
  289. package/.next/static/css/d06ee45019116677.css +1 -0
  290. package/.next/static/media/91601dd83defba07-s.p.woff2 +0 -0
  291. package/.next/static/media/aa8291c31d4e9e54-s.p.woff2 +0 -0
  292. package/.next/static/pROr0laPuZIdA4NYNygMD/_buildManifest.js +1 -0
  293. package/.next/static/pROr0laPuZIdA4NYNygMD/_ssgManifest.js +1 -0
  294. package/LICENSE +22 -0
  295. package/dist-server/direct-terminal-ws.js +109 -0
  296. package/dist-server/mux-websocket.js +1162 -0
  297. package/dist-server/single-port-server.js +305 -0
  298. package/dist-server/start-all.js +150 -0
  299. package/dist-server/terminal-observability.js +16 -0
  300. package/dist-server/tmux-utils.js +301 -0
  301. package/next.config.js +71 -0
  302. package/package.json +97 -0
@@ -0,0 +1,1162 @@
1
+ /**
2
+ * Multiplexed WebSocket server for terminal multiplexing.
3
+ * Manages multiple terminal connections over a single persistent WebSocket.
4
+ *
5
+ * Session updates are delivered via polling of Next.js /api/sessions/patches
6
+ * every 3s, then broadcast to all subscribed clients via WebSocket.
7
+ */
8
+ import { WebSocketServer, WebSocket } from "ws";
9
+ import { spawn } from "node:child_process";
10
+ import * as fs from "node:fs";
11
+ import { createRequire } from "node:module";
12
+ import { connect as netConnect } from "node:net";
13
+ import { dirname, join } from "node:path";
14
+ import { DEFAULT_DASHBOARD_NOTIFICATION_LIMIT, getEnvDefaults, getDashboardNotificationStorePath, getNodePtyPrebuildsSubdir, isWindows, loadConfig, normalizeDashboardNotificationLimit, recordActivityEvent, readDashboardNotificationsFromFile, } from "@made-by-moonlight/athene-core";
15
+ import { findTmux, resolveTmuxSession, resolvePipePath, tmuxHasSession, validateSessionId, } from "./tmux-utils.js";
16
+ /**
17
+ * Manages polling of session patches from Next.js /api/sessions/patches.
18
+ * Broadcasts to all subscribed callbacks.
19
+ * Lazily starts polling on first subscriber, stops when the last one leaves.
20
+ */
21
+ export class SessionBroadcaster {
22
+ subscribers = new Set();
23
+ errorSubscribers = new Set();
24
+ intervalId = null;
25
+ polling = false;
26
+ // Tracks the last fetch outcome so we only emit ui.session_broadcast_failed on
27
+ // the healthy → failing transition (not every 3s during an outage).
28
+ lastFetchOk = true;
29
+ baseUrl;
30
+ constructor(nextPort) {
31
+ this.baseUrl = `http://localhost:${nextPort}`;
32
+ }
33
+ /**
34
+ * Subscribe to session patches and errors. Returns an unsubscribe function.
35
+ * Sends an immediate snapshot to the new subscriber, then polling updates.
36
+ */
37
+ subscribe(callback, onError) {
38
+ const wasEmpty = this.subscribers.size === 0;
39
+ this.subscribers.add(callback);
40
+ if (onError)
41
+ this.errorSubscribers.add(onError);
42
+ // Immediately send a one-off snapshot to just this new subscriber
43
+ void this.fetchSnapshot().then((result) => {
44
+ if (result.sessions && this.subscribers.has(callback)) {
45
+ try {
46
+ callback(result.sessions);
47
+ }
48
+ catch {
49
+ // Isolate subscriber errors so one bad subscriber doesn't break others
50
+ }
51
+ }
52
+ else if (result.error && onError && this.errorSubscribers.has(onError)) {
53
+ try {
54
+ onError(result.error);
55
+ }
56
+ catch {
57
+ // Isolate subscriber errors
58
+ }
59
+ }
60
+ });
61
+ // Start polling if this is the first subscriber
62
+ if (wasEmpty) {
63
+ this.intervalId = setInterval(() => {
64
+ if (this.polling)
65
+ return;
66
+ this.polling = true;
67
+ void this.fetchSnapshot()
68
+ .then((result) => {
69
+ if (result.sessions && this.intervalId !== null)
70
+ this.broadcast(result.sessions);
71
+ else if (result.error && this.intervalId !== null)
72
+ this.broadcastError(result.error);
73
+ })
74
+ .finally(() => {
75
+ this.polling = false;
76
+ });
77
+ }, 3000);
78
+ }
79
+ return () => {
80
+ this.subscribers.delete(callback);
81
+ if (onError)
82
+ this.errorSubscribers.delete(onError);
83
+ if (this.subscribers.size === 0) {
84
+ this.disconnect();
85
+ }
86
+ };
87
+ }
88
+ broadcast(sessions) {
89
+ for (const callback of this.subscribers) {
90
+ try {
91
+ callback(sessions);
92
+ }
93
+ catch (err) {
94
+ console.error("[MuxServer] Session broadcast subscriber threw:", err);
95
+ }
96
+ }
97
+ }
98
+ broadcastError(error) {
99
+ for (const callback of this.errorSubscribers) {
100
+ try {
101
+ callback(error);
102
+ }
103
+ catch (err) {
104
+ console.error("[MuxServer] Session error subscriber threw:", err);
105
+ }
106
+ }
107
+ }
108
+ /** One-shot HTTP fetch of the current session list. */
109
+ async fetchSnapshot() {
110
+ const controller = new AbortController();
111
+ const timeoutId = setTimeout(() => controller.abort(), 4000);
112
+ try {
113
+ const res = await fetch(`${this.baseUrl}/api/sessions/patches`, {
114
+ signal: controller.signal,
115
+ });
116
+ clearTimeout(timeoutId);
117
+ if (!res.ok) {
118
+ const msg = `Session fetch failed: HTTP ${res.status}`;
119
+ console.warn(`[SessionBroadcaster] ${msg}`);
120
+ this.recordFetchFailure(msg, { httpStatus: res.status });
121
+ return { sessions: null, error: msg };
122
+ }
123
+ const data = (await res.json());
124
+ this.lastFetchOk = true;
125
+ return { sessions: data.sessions ?? null, error: null };
126
+ }
127
+ catch (err) {
128
+ clearTimeout(timeoutId);
129
+ const msg = err instanceof Error ? err.message : String(err);
130
+ console.warn("[SessionBroadcaster] fetchSnapshot error:", msg);
131
+ this.recordFetchFailure(msg);
132
+ return { sessions: null, error: msg };
133
+ }
134
+ }
135
+ /**
136
+ * Emit ui.session_broadcast_failed once per healthy→failing transition.
137
+ * The broadcaster polls every 3s; emitting on every failure during a long
138
+ * outage would flood the events table (~20/min). Recovery resets the flag.
139
+ */
140
+ recordFetchFailure(message, extra) {
141
+ if (!this.lastFetchOk)
142
+ return;
143
+ this.lastFetchOk = false;
144
+ recordActivityEvent({
145
+ source: "ui",
146
+ kind: "ui.session_broadcast_failed",
147
+ level: "warn",
148
+ summary: `session broadcaster fetch failed: ${message}`,
149
+ data: {
150
+ url: `${this.baseUrl}/api/sessions/patches`,
151
+ errorMessage: message,
152
+ ...extra,
153
+ },
154
+ });
155
+ }
156
+ disconnect() {
157
+ if (this.intervalId !== null) {
158
+ clearInterval(this.intervalId);
159
+ this.intervalId = null;
160
+ }
161
+ }
162
+ }
163
+ function notificationKey(record) {
164
+ return `${record.id}:${record.receivedAt}`;
165
+ }
166
+ function readDashboardLimit(configPath) {
167
+ if (!configPath)
168
+ return DEFAULT_DASHBOARD_NOTIFICATION_LIMIT;
169
+ try {
170
+ const config = loadConfig(configPath);
171
+ const dashboardConfig = config.notifiers?.["dashboard"];
172
+ return normalizeDashboardNotificationLimit(dashboardConfig?.["limit"]);
173
+ }
174
+ catch (err) {
175
+ const message = err instanceof Error ? err.message : String(err);
176
+ console.warn("[NotificationBroadcaster] Could not read dashboard notifier limit:", message);
177
+ return DEFAULT_DASHBOARD_NOTIFICATION_LIMIT;
178
+ }
179
+ }
180
+ /**
181
+ * Polls the dashboard notification JSONL store and broadcasts changes to mux
182
+ * subscribers. The store is config-scoped and survives dashboard reloads.
183
+ */
184
+ export class NotificationBroadcaster {
185
+ subscribers = new Set();
186
+ errorSubscribers = new Set();
187
+ intervalId = null;
188
+ lastRecords = [];
189
+ configPath;
190
+ storePath;
191
+ constructor(configPath = process.env["AO_CONFIG_PATH"]) {
192
+ this.configPath = configPath;
193
+ this.storePath = configPath ? getDashboardNotificationStorePath(configPath) : null;
194
+ }
195
+ subscribe(callback, onError) {
196
+ const wasEmpty = this.subscribers.size === 0;
197
+ this.subscribers.add(callback);
198
+ if (onError)
199
+ this.errorSubscribers.add(onError);
200
+ const snapshot = this.fetchSnapshot();
201
+ if (wasEmpty) {
202
+ this.lastRecords = snapshot.notifications;
203
+ }
204
+ try {
205
+ callback(snapshot.notifications, "snapshot", snapshot.limit);
206
+ }
207
+ catch {
208
+ // Isolate subscriber errors so one bad socket does not break others.
209
+ }
210
+ if (snapshot.error && onError) {
211
+ try {
212
+ onError(snapshot.error);
213
+ }
214
+ catch {
215
+ // Isolate subscriber errors.
216
+ }
217
+ }
218
+ if (wasEmpty) {
219
+ this.intervalId = setInterval(() => {
220
+ const result = this.fetchSnapshot();
221
+ if (result.error) {
222
+ this.broadcastError(result.error);
223
+ return;
224
+ }
225
+ const previousKeys = new Set(this.lastRecords.map(notificationKey));
226
+ const appended = result.notifications.filter((record) => !previousKeys.has(notificationKey(record)));
227
+ const trimmed = result.notifications.length < this.lastRecords.length;
228
+ this.lastRecords = result.notifications;
229
+ if (appended.length > 0 && !trimmed) {
230
+ this.broadcast(appended, "append", result.limit);
231
+ }
232
+ else if (appended.length > 0 || trimmed) {
233
+ this.broadcast(result.notifications, "snapshot", result.limit);
234
+ }
235
+ }, 1000);
236
+ }
237
+ return () => {
238
+ this.subscribers.delete(callback);
239
+ if (onError)
240
+ this.errorSubscribers.delete(onError);
241
+ if (this.subscribers.size === 0) {
242
+ this.disconnect();
243
+ }
244
+ };
245
+ }
246
+ fetchSnapshot() {
247
+ const limit = readDashboardLimit(this.configPath);
248
+ if (!this.storePath)
249
+ return { notifications: [], error: null, limit };
250
+ try {
251
+ return {
252
+ notifications: readDashboardNotificationsFromFile(this.storePath, limit),
253
+ error: null,
254
+ limit,
255
+ };
256
+ }
257
+ catch (err) {
258
+ const message = err instanceof Error ? err.message : String(err);
259
+ console.warn("[NotificationBroadcaster] fetchSnapshot error:", message);
260
+ return { notifications: [], error: message, limit };
261
+ }
262
+ }
263
+ broadcast(notifications, type, limit) {
264
+ for (const callback of this.subscribers) {
265
+ try {
266
+ callback(notifications, type, limit);
267
+ }
268
+ catch (err) {
269
+ console.error("[MuxServer] Notification broadcast subscriber threw:", err);
270
+ }
271
+ }
272
+ }
273
+ broadcastError(error) {
274
+ for (const callback of this.errorSubscribers) {
275
+ try {
276
+ callback(error);
277
+ }
278
+ catch (err) {
279
+ console.error("[MuxServer] Notification error subscriber threw:", err);
280
+ }
281
+ }
282
+ }
283
+ disconnect() {
284
+ if (this.intervalId !== null) {
285
+ clearInterval(this.intervalId);
286
+ this.intervalId = null;
287
+ }
288
+ }
289
+ }
290
+ let ptySpawn;
291
+ /* eslint-enable @typescript-eslint/consistent-type-imports */
292
+ const nodePtyRequire = createRequire(import.meta.url);
293
+ export function resolveNodePtySpawnHelperPath() {
294
+ const override = process.env.AO_NODE_PTY_SPAWN_HELPER_PATH;
295
+ if (override)
296
+ return override;
297
+ try {
298
+ const packageJsonPath = nodePtyRequire.resolve("node-pty/package.json");
299
+ return join(dirname(packageJsonPath), "prebuilds", getNodePtyPrebuildsSubdir(), "spawn-helper");
300
+ }
301
+ catch {
302
+ return null;
303
+ }
304
+ }
305
+ function isPosixSpawnpFailure(err) {
306
+ return err instanceof Error && err.message.includes("posix_spawnp");
307
+ }
308
+ function repairNodePtySpawnHelper() {
309
+ if (isWindows())
310
+ return null;
311
+ const spawnHelperPath = resolveNodePtySpawnHelperPath();
312
+ if (!spawnHelperPath || !fs.existsSync(spawnHelperPath))
313
+ return null;
314
+ fs.chmodSync(spawnHelperPath, 0o755);
315
+ return spawnHelperPath;
316
+ }
317
+ try {
318
+ const nodePty = await import("node-pty");
319
+ ptySpawn = nodePty.spawn;
320
+ }
321
+ catch (err) {
322
+ console.warn("[MuxServer] node-pty not available — mux server will be disabled.", err);
323
+ }
324
+ const RING_BUFFER_MAX = 50 * 1024; // 50KB max per terminal
325
+ const WS_BUFFER_HIGH_WATERMARK = 64 * 1024; // 64KB
326
+ const MAX_REATTACH_ATTEMPTS = 3;
327
+ /**
328
+ * Grace period a freshly-attached PTY must survive before its successful
329
+ * attach is allowed to reset the re-attach counter. Prevents tight crash
330
+ * loops (e.g. attaching to a tmux session that no longer exists) from
331
+ * gaming the MAX_REATTACH_ATTEMPTS cap by resetting the counter to 0
332
+ * between every failed attempt.
333
+ *
334
+ * 5 s is comfortably longer than the ~40 ms a doomed `tmux attach-session`
335
+ * takes to exit, while still being short enough that a healthy PTY which
336
+ * crashes hours later gets a fresh retry budget.
337
+ */
338
+ const REATTACH_RESET_GRACE_MS = 5_000;
339
+ /**
340
+ * TerminalManager manages PTY processes independently of WebSocket connections.
341
+ * A single manager instance is shared across all mux connections.
342
+ */
343
+ export class TerminalManager {
344
+ terminals = new Map();
345
+ TMUX;
346
+ spawnHelperRepairAttempted = false;
347
+ constructor(tmuxPath) {
348
+ const resolved = tmuxPath ?? findTmux();
349
+ if (!resolved) {
350
+ throw new Error("tmux not available on this platform");
351
+ }
352
+ this.TMUX = resolved;
353
+ }
354
+ terminalKey(id, projectId) {
355
+ return projectId ? `${projectId}:${id}` : id;
356
+ }
357
+ spawnTmuxPty(args, options) {
358
+ if (!ptySpawn) {
359
+ throw new Error("node-pty not available");
360
+ }
361
+ try {
362
+ return ptySpawn(this.TMUX, args, options);
363
+ }
364
+ catch (err) {
365
+ if (this.spawnHelperRepairAttempted || !isPosixSpawnpFailure(err)) {
366
+ throw err;
367
+ }
368
+ this.spawnHelperRepairAttempted = true;
369
+ try {
370
+ const repairedPath = repairNodePtySpawnHelper();
371
+ if (repairedPath) {
372
+ console.warn(`[MuxServer] node-pty posix_spawnp failed; set executable bit on ${repairedPath} and retrying once.`);
373
+ }
374
+ else {
375
+ console.warn("[MuxServer] node-pty posix_spawnp failed; spawn-helper was not found, retrying once.");
376
+ }
377
+ }
378
+ catch (repairErr) {
379
+ const message = repairErr instanceof Error ? repairErr.message : String(repairErr);
380
+ console.warn(`[MuxServer] node-pty posix_spawnp failed; chmod spawn-helper failed (${message}), retrying once.`);
381
+ }
382
+ return ptySpawn(this.TMUX, args, options);
383
+ }
384
+ }
385
+ /**
386
+ * Open/attach to a terminal. If already open, just return.
387
+ * If has subscribers but PTY crashed, re-attach.
388
+ */
389
+ open(id, projectId, tmuxName) {
390
+ if (!validateSessionId(id)) {
391
+ throw new Error(`Invalid session ID: ${id}`);
392
+ }
393
+ const key = this.terminalKey(id, projectId);
394
+ const existing = this.terminals.get(key);
395
+ const tmuxSessionId = tmuxName ??
396
+ existing?.tmuxSessionId ??
397
+ resolveTmuxSession(id, this.TMUX, undefined, undefined, projectId);
398
+ if (!tmuxSessionId) {
399
+ throw new Error(`Session not found: ${id}`);
400
+ }
401
+ // Get or create terminal entry
402
+ let terminal = this.terminals.get(key);
403
+ if (!terminal) {
404
+ terminal = {
405
+ id,
406
+ tmuxSessionId,
407
+ pty: null,
408
+ subscribers: new Set(),
409
+ exitCallbacks: new Set(),
410
+ buffer: [],
411
+ bufferBytes: 0,
412
+ reattachAttempts: 0,
413
+ ptyLostEmitted: false,
414
+ };
415
+ this.terminals.set(key, terminal);
416
+ }
417
+ // If PTY is already attached, we're done
418
+ if (terminal.pty) {
419
+ return tmuxSessionId;
420
+ }
421
+ // tmux 3.4 only honours the `=` exact-match prefix on has-session and
422
+ // attach-session; set-option silently ignores it, so we use the bare id
423
+ // here. The `=`-prefixed form is built below for attach-session.
424
+ // Enable mouse mode
425
+ const mouseProc = spawn(this.TMUX, ["set-option", "-t", tmuxSessionId, "mouse", "on"]);
426
+ mouseProc.on("error", (err) => {
427
+ console.error(`[MuxServer] Failed to set mouse mode for ${tmuxSessionId}:`, err.message);
428
+ });
429
+ // Hide the status bar
430
+ const statusProc = spawn(this.TMUX, ["set-option", "-t", tmuxSessionId, "status", "off"]);
431
+ statusProc.on("error", (err) => {
432
+ console.error(`[MuxServer] Failed to hide status bar for ${tmuxSessionId}:`, err.message);
433
+ });
434
+ // Build environment
435
+ const platformDefaults = getEnvDefaults();
436
+ const homeDir = platformDefaults.HOME;
437
+ const env = {
438
+ HOME: platformDefaults.HOME,
439
+ SHELL: platformDefaults.SHELL,
440
+ USER: platformDefaults.USER,
441
+ PATH: process.env.PATH || platformDefaults.PATH,
442
+ TERM: "xterm-256color",
443
+ LANG: process.env.LANG || "en_US.UTF-8",
444
+ TMPDIR: platformDefaults.TMPDIR,
445
+ };
446
+ // Spawn PTY — use `=`-prefixed exact-match target so we never attach to
447
+ // a session whose name happens to be a prefix of the requested id.
448
+ const exactTmuxTarget = `=${tmuxSessionId}`;
449
+ const pty = this.spawnTmuxPty(["attach-session", "-t", exactTmuxTarget], {
450
+ name: "xterm-256color",
451
+ cols: 80,
452
+ rows: 24,
453
+ cwd: homeDir,
454
+ env,
455
+ });
456
+ terminal.pty = pty;
457
+ // Schedule a grace-period reset of the re-attach counter. We only
458
+ // consider an attach "really successful" if the PTY survives long
459
+ // enough to suggest the underlying tmux session is actually usable.
460
+ // The closure-captured `pty` reference is compared with terminal.pty
461
+ // so a stale timer cannot reset the counter for a PTY that has
462
+ // already exited or been replaced by re-attach. Any previously-
463
+ // scheduled timer (from a now-replaced PTY) is cleared so we don't
464
+ // keep its closure references reachable until the timer fires.
465
+ if (terminal.resetTimer) {
466
+ clearTimeout(terminal.resetTimer);
467
+ }
468
+ terminal.resetTimer = setTimeout(() => {
469
+ terminal.resetTimer = undefined;
470
+ if (terminal.pty === pty) {
471
+ terminal.reattachAttempts = 0;
472
+ terminal.ptyLostEmitted = false;
473
+ }
474
+ }, REATTACH_RESET_GRACE_MS);
475
+ terminal.resetTimer.unref();
476
+ // Wire up data events
477
+ pty.onData((data) => {
478
+ // Push to all subscribers — isolate each callback so a throw in one
479
+ // (e.g. a closed ws.send) doesn't abort the loop or skip the buffer.
480
+ for (const callback of terminal.subscribers) {
481
+ try {
482
+ callback(data);
483
+ }
484
+ catch (err) {
485
+ console.error("[MuxServer] Subscriber callback threw:", err);
486
+ }
487
+ }
488
+ // Append to ring buffer
489
+ terminal.buffer.push(data);
490
+ terminal.bufferBytes += Buffer.byteLength(data, "utf8");
491
+ // Trim buffer if over limit
492
+ while (terminal.bufferBytes > RING_BUFFER_MAX && terminal.buffer.length > 0) {
493
+ const removed = terminal.buffer.shift() ?? "";
494
+ terminal.bufferBytes -= Buffer.byteLength(removed, "utf8");
495
+ }
496
+ });
497
+ // Handle PTY exit
498
+ //
499
+ // Async: the has-session probe shells out via promisified execFile and
500
+ // must be awaited. node-pty fires onExit on the main thread; a sync
501
+ // probe would freeze the entire web server (every WebSocket, HTTP
502
+ // request, in-flight terminal) for up to the subprocess timeout when
503
+ // tmux is slow to respond.
504
+ pty.onExit(async ({ exitCode }) => {
505
+ console.log(`[MuxServer] PTY exited for ${id} with code ${exitCode}`);
506
+ terminal.pty = null;
507
+ let reattachError;
508
+ // Skip the re-attach loop entirely when the underlying tmux session is
509
+ // gone (e.g. user pressed Ctrl-C in the pane and the launch command
510
+ // exited, taking the only window with it). Without this guard we
511
+ // burn three doomed attach-session spawns and emit a noisy
512
+ // "Max re-attach attempts reached" log line for what is actually a
513
+ // clean user-initiated termination — see issue #1756. The
514
+ // MAX_REATTACH_ATTEMPTS bound from #1640 still covers tmux server
515
+ // hiccups where the session does still exist.
516
+ if (terminal.subscribers.size > 0 && !(await tmuxHasSession(this.TMUX, tmuxSessionId))) {
517
+ console.log(`[MuxServer] tmux session ${tmuxSessionId} is gone, not re-attaching`);
518
+ if (terminal.resetTimer) {
519
+ clearTimeout(terminal.resetTimer);
520
+ terminal.resetTimer = undefined;
521
+ }
522
+ if (!terminal.ptyLostEmitted) {
523
+ terminal.ptyLostEmitted = true;
524
+ recordActivityEvent({
525
+ projectId,
526
+ sessionId: id,
527
+ source: "ui",
528
+ kind: "ui.terminal_pty_lost",
529
+ level: "warn",
530
+ summary: `terminal PTY exited (code ${exitCode}) — tmux session gone`,
531
+ data: {
532
+ sessionId: id,
533
+ exitCode,
534
+ reattachAttempts: terminal.reattachAttempts,
535
+ maxReattachAttempts: MAX_REATTACH_ATTEMPTS,
536
+ reattachExhausted: false,
537
+ reattachSkipped: true,
538
+ tmuxSessionPresent: false,
539
+ subscriberCount: terminal.subscribers.size,
540
+ },
541
+ });
542
+ }
543
+ for (const cb of terminal.exitCallbacks) {
544
+ cb(exitCode);
545
+ }
546
+ return;
547
+ }
548
+ // Re-attach if subscribers are still present, up to MAX_REATTACH_ATTEMPTS.
549
+ // The cap prevents an unbounded respawn loop when the PTY crashes immediately
550
+ // after every attach (e.g. resource exhaustion or a broken tmux session).
551
+ // The counter is reset by a delayed timer in open() once the new PTY has
552
+ // survived REATTACH_RESET_GRACE_MS — see the comment on that constant.
553
+ // Resetting here would defeat the cap: when athene stop kills the tmux session
554
+ // out from under a still-subscribed dashboard, attach-session exits ~40 ms
555
+ // after spawn and the loop runs at ~80 spawns/sec, exhausting the system
556
+ // PTY pool in seconds (issue #1639).
557
+ if (terminal.subscribers.size > 0 && terminal.reattachAttempts < MAX_REATTACH_ATTEMPTS) {
558
+ terminal.reattachAttempts += 1;
559
+ console.log(`[MuxServer] Re-attaching to ${id} (attempt ${terminal.reattachAttempts}/${MAX_REATTACH_ATTEMPTS})`);
560
+ try {
561
+ this.open(id, projectId, tmuxSessionId);
562
+ if (!terminal.ptyLostEmitted) {
563
+ terminal.ptyLostEmitted = true;
564
+ recordActivityEvent({
565
+ projectId,
566
+ sessionId: id,
567
+ source: "ui",
568
+ kind: "ui.terminal_pty_lost",
569
+ level: "warn",
570
+ summary: `terminal PTY exited (code ${exitCode}) — reattached`,
571
+ data: {
572
+ sessionId: id,
573
+ exitCode,
574
+ reattachAttempts: terminal.reattachAttempts,
575
+ maxReattachAttempts: MAX_REATTACH_ATTEMPTS,
576
+ reattachExhausted: false,
577
+ reattachRecovered: true,
578
+ subscriberCount: terminal.subscribers.size,
579
+ },
580
+ });
581
+ }
582
+ return; // re-attached — don't notify exit
583
+ }
584
+ catch (err) {
585
+ reattachError = err instanceof Error ? err.message : String(err);
586
+ console.error(`[MuxServer] Failed to re-attach ${id}:`, err);
587
+ }
588
+ }
589
+ else if (terminal.reattachAttempts >= MAX_REATTACH_ATTEMPTS) {
590
+ console.error(`[MuxServer] Max re-attach attempts reached for ${id}, giving up`);
591
+ }
592
+ // PTY actually died (vs user closed browser): only emit when subscribers
593
+ // are still attached — otherwise the exit is just normal cleanup.
594
+ // Keep this event one-shot for the terminal entry. Clients may re-open
595
+ // the same terminal after a failed reattach; repeated PTY exits should
596
+ // not flood the activity log for the same loss condition.
597
+ if (terminal.subscribers.size > 0 && !terminal.ptyLostEmitted) {
598
+ terminal.ptyLostEmitted = true;
599
+ recordActivityEvent({
600
+ projectId,
601
+ sessionId: id,
602
+ source: "ui",
603
+ kind: "ui.terminal_pty_lost",
604
+ level: "warn",
605
+ summary: `terminal PTY exited (code ${exitCode})${terminal.reattachAttempts >= MAX_REATTACH_ATTEMPTS ? " — reattach exhausted" : ""}`,
606
+ data: {
607
+ sessionId: id,
608
+ exitCode,
609
+ reattachAttempts: terminal.reattachAttempts,
610
+ maxReattachAttempts: MAX_REATTACH_ATTEMPTS,
611
+ reattachExhausted: terminal.reattachAttempts >= MAX_REATTACH_ATTEMPTS,
612
+ subscriberCount: terminal.subscribers.size,
613
+ ...(reattachError ? { reattachError } : {}),
614
+ },
615
+ });
616
+ }
617
+ // Notify subscribers that the terminal has exited (re-attach failed or no subscribers)
618
+ for (const cb of terminal.exitCallbacks) {
619
+ cb(exitCode);
620
+ }
621
+ });
622
+ console.log(`[MuxServer] Opened terminal ${id} (tmux: ${tmuxSessionId})`);
623
+ return tmuxSessionId;
624
+ }
625
+ /**
626
+ * Write data to the PTY if attached
627
+ */
628
+ write(id, data, projectId) {
629
+ const terminal = this.terminals.get(this.terminalKey(id, projectId));
630
+ if (terminal?.pty) {
631
+ terminal.pty.write(data);
632
+ }
633
+ }
634
+ /**
635
+ * Resize the PTY if attached
636
+ */
637
+ resize(id, cols, rows, projectId) {
638
+ const terminal = this.terminals.get(this.terminalKey(id, projectId));
639
+ if (terminal?.pty) {
640
+ terminal.pty.resize(cols, rows);
641
+ }
642
+ }
643
+ /**
644
+ * Subscribe to terminal data. Returns unsubscribe function.
645
+ * Automatically opens the terminal if needed.
646
+ * @param onExit - called when the PTY exits and cannot be re-attached
647
+ */
648
+ subscribe(id, projectId, callback, onExit) {
649
+ // Ensure terminal is open
650
+ this.open(id, projectId);
651
+ const key = this.terminalKey(id, projectId);
652
+ const terminal = this.terminals.get(key);
653
+ if (!terminal) {
654
+ throw new Error(`Failed to open terminal: ${id}`);
655
+ }
656
+ // Add subscriber
657
+ terminal.subscribers.add(callback);
658
+ if (onExit)
659
+ terminal.exitCallbacks.add(onExit);
660
+ // Return unsubscribe function
661
+ return () => {
662
+ terminal.subscribers.delete(callback);
663
+ if (onExit)
664
+ terminal.exitCallbacks.delete(onExit);
665
+ // Kill PTY and clean up when the last subscriber leaves
666
+ if (terminal.subscribers.size === 0) {
667
+ if (terminal.resetTimer) {
668
+ clearTimeout(terminal.resetTimer);
669
+ terminal.resetTimer = undefined;
670
+ }
671
+ if (terminal.pty) {
672
+ terminal.pty.kill();
673
+ terminal.pty = null;
674
+ }
675
+ this.terminals.delete(key);
676
+ }
677
+ };
678
+ }
679
+ /**
680
+ * Get buffered data for a terminal
681
+ */
682
+ getBuffer(id, projectId) {
683
+ const terminal = this.terminals.get(this.terminalKey(id, projectId));
684
+ if (!terminal)
685
+ return "";
686
+ return terminal.buffer.join("");
687
+ }
688
+ }
689
+ // ── Windows Pipe Relay (extracted for testability) ──
690
+ const intentionalWinPipeCloses = new WeakSet();
691
+ /**
692
+ * Handle a Windows terminal message by relaying through named pipes.
693
+ * Extracted from the WebSocket connection handler for testability.
694
+ */
695
+ export function handleWindowsPipeMessage(msg, ws, winPipes, winPipeBuffers, deps) {
696
+ const WS_OPEN = 1; // WebSocket.OPEN
697
+ const { id, type, projectId } = msg;
698
+ // MuxProvider keys subscribers under `${projectId}:${id}` when projectId is
699
+ // provided, so every outbound terminal message must echo projectId back —
700
+ // otherwise the client routes by id alone and the subscriber bucket
701
+ // mismatches, leaving the xterm pane blank on /projects/[id]/sessions/[id].
702
+ const echo = projectId ? { projectId } : {};
703
+ // Project-scoped pipe-map key: matches the Unix `subscriptionKey` shape so
704
+ // two projects sharing a sessionId on the same mux connection don't collide
705
+ // on the same socket/buffer entry.
706
+ const pipeKey = projectId ? `${projectId}:${id}` : id;
707
+ // The Unix path validates inside TerminalManager.open(). The Windows pipe
708
+ // relay bypasses TerminalManager entirely, so validate here too — `id`
709
+ // becomes a map key and is constructed into a pipe path downstream.
710
+ if (!validateSessionId(id)) {
711
+ if (ws.readyState === WS_OPEN) {
712
+ ws.send(JSON.stringify({
713
+ ch: "terminal",
714
+ id,
715
+ type: "error",
716
+ message: "invalid session id",
717
+ ...echo,
718
+ }));
719
+ }
720
+ return;
721
+ }
722
+ if (type === "open") {
723
+ if (winPipes.has(pipeKey)) {
724
+ ws.send(JSON.stringify({ ch: "terminal", id, type: "opened", ...echo }));
725
+ }
726
+ else {
727
+ const pipePath = deps.resolvePipePath(id, projectId);
728
+ if (!pipePath) {
729
+ throw new Error(`No PTY host pipe found for session ${id}`);
730
+ }
731
+ const pipeSocket = deps.connect(pipePath);
732
+ winPipes.set(pipeKey, pipeSocket);
733
+ winPipeBuffers.set(pipeKey, Buffer.alloc(0));
734
+ let ptyLostEmitted = false;
735
+ const recordWindowsPtyLost = (reason, extra) => {
736
+ if (ptyLostEmitted || ws.readyState !== WS_OPEN)
737
+ return;
738
+ ptyLostEmitted = true;
739
+ recordActivityEvent({
740
+ projectId,
741
+ sessionId: id,
742
+ source: "ui",
743
+ kind: "ui.terminal_pty_lost",
744
+ level: "warn",
745
+ summary: reason === "host_not_alive"
746
+ ? `terminal PTY host reported not alive for ${id}`
747
+ : reason === "pipe_error"
748
+ ? `terminal PTY host pipe errored for ${id}`
749
+ : `terminal PTY host pipe closed for ${id}`,
750
+ data: {
751
+ sessionId: id,
752
+ transport: "windows_pipe",
753
+ reason,
754
+ ...extra,
755
+ },
756
+ });
757
+ };
758
+ pipeSocket.on("error", (err) => {
759
+ recordWindowsPtyLost("pipe_error", { errorMessage: err.message });
760
+ winPipes.delete(pipeKey);
761
+ winPipeBuffers.delete(pipeKey);
762
+ pipeSocket.destroy();
763
+ if (ws.readyState === WS_OPEN) {
764
+ ws.send(JSON.stringify({
765
+ ch: "terminal",
766
+ id,
767
+ type: "error",
768
+ message: `PTY host not available: ${err.message}`,
769
+ ...echo,
770
+ }));
771
+ }
772
+ });
773
+ pipeSocket.on("connect", () => {
774
+ if (ws.readyState === WS_OPEN) {
775
+ ws.send(JSON.stringify({ ch: "terminal", id, type: "opened", ...echo }));
776
+ }
777
+ pipeSocket.on("data", (chunk) => {
778
+ const existing = winPipeBuffers.get(pipeKey) ?? Buffer.alloc(0);
779
+ let buf = Buffer.concat([existing, chunk]);
780
+ winPipeBuffers.set(pipeKey, buf);
781
+ while (buf.length >= 5) {
782
+ const msgType = buf.readUInt8(0);
783
+ const length = buf.readUInt32BE(1);
784
+ if (buf.length < 5 + length)
785
+ break;
786
+ const payload = buf.subarray(5, 5 + length);
787
+ buf = buf.subarray(5 + length);
788
+ winPipeBuffers.set(pipeKey, buf);
789
+ if (msgType === 0x01 && ws.readyState === WS_OPEN) {
790
+ ws.send(JSON.stringify({
791
+ ch: "terminal",
792
+ id,
793
+ type: "data",
794
+ data: payload.toString("utf-8"),
795
+ ...echo,
796
+ }));
797
+ }
798
+ if (msgType === 0x07) {
799
+ try {
800
+ const status = JSON.parse(payload.toString("utf-8"));
801
+ if (!status.alive && ws.readyState === WS_OPEN) {
802
+ recordWindowsPtyLost("host_not_alive");
803
+ ws.send(JSON.stringify({ ch: "terminal", id, type: "exited", code: 0, ...echo }));
804
+ }
805
+ }
806
+ catch {
807
+ /* ignore parse errors */
808
+ }
809
+ }
810
+ }
811
+ });
812
+ pipeSocket.on("close", () => {
813
+ winPipes.delete(pipeKey);
814
+ winPipeBuffers.delete(pipeKey);
815
+ const intentionalClose = intentionalWinPipeCloses.delete(pipeSocket);
816
+ if (ws.readyState === WS_OPEN) {
817
+ if (!intentionalClose) {
818
+ recordWindowsPtyLost("pipe_closed");
819
+ }
820
+ ws.send(JSON.stringify({ ch: "terminal", id, type: "exited", code: 0, ...echo }));
821
+ }
822
+ });
823
+ });
824
+ }
825
+ }
826
+ else if (type === "data" && msg.data !== undefined) {
827
+ const pipeSocket = winPipes.get(pipeKey);
828
+ if (pipeSocket) {
829
+ const inputBuf = Buffer.from(msg.data, "utf-8");
830
+ const header = Buffer.alloc(5);
831
+ header.writeUInt8(0x02, 0);
832
+ header.writeUInt32BE(inputBuf.length, 1);
833
+ pipeSocket.write(Buffer.concat([header, inputBuf]));
834
+ }
835
+ }
836
+ else if (type === "resize" && msg.cols !== undefined && msg.rows !== undefined) {
837
+ const pipeSocket = winPipes.get(pipeKey);
838
+ if (pipeSocket) {
839
+ const resizePayload = Buffer.from(JSON.stringify({ cols: msg.cols, rows: msg.rows }));
840
+ const header = Buffer.alloc(5);
841
+ header.writeUInt8(0x03, 0);
842
+ header.writeUInt32BE(resizePayload.length, 1);
843
+ pipeSocket.write(Buffer.concat([header, resizePayload]));
844
+ }
845
+ }
846
+ else if (type === "close") {
847
+ const pipeSocket = winPipes.get(pipeKey);
848
+ if (pipeSocket) {
849
+ intentionalWinPipeCloses.add(pipeSocket);
850
+ pipeSocket.end();
851
+ winPipes.delete(pipeKey);
852
+ winPipeBuffers.delete(pipeKey);
853
+ }
854
+ }
855
+ }
856
+ /**
857
+ * Create a mux WebSocket server (noServer mode).
858
+ * Returns the WebSocketServer instance for manual upgrade routing.
859
+ */
860
+ export function createMuxWebSocket(tmuxPath) {
861
+ // On Windows, we use named pipe relay instead of node-pty/tmux.
862
+ // Allow the server to be created without ptySpawn on Windows.
863
+ if (!ptySpawn && !isWindows()) {
864
+ console.warn("[MuxServer] node-pty not available — mux WebSocket will be disabled");
865
+ return null;
866
+ }
867
+ // On Windows, terminal I/O goes through named pipe relay — no TerminalManager needed.
868
+ const terminalManager = ptySpawn && !isWindows() ? new TerminalManager(tmuxPath ?? undefined) : null;
869
+ const nextPort = process.env.PORT || "3000";
870
+ const broadcaster = new SessionBroadcaster(nextPort);
871
+ const notificationBroadcaster = new NotificationBroadcaster();
872
+ const wss = new WebSocketServer({ noServer: true });
873
+ wss.on("connection", (ws, request) => {
874
+ console.log("[MuxServer] New mux connection");
875
+ const connectedAt = Date.now();
876
+ // Best-effort remote addr — proxy headers if present, else socket peer.
877
+ const xff = request?.headers["x-forwarded-for"];
878
+ const xffStr = Array.isArray(xff) ? xff[0] : xff;
879
+ const remoteAddr = (typeof xffStr === "string" ? xffStr.split(",")[0]?.trim() : undefined) ??
880
+ request?.socket?.remoteAddress ??
881
+ undefined;
882
+ recordActivityEvent({
883
+ source: "ui",
884
+ kind: "ui.terminal_connected",
885
+ level: "info",
886
+ summary: "mux WebSocket connection opened",
887
+ data: { remoteAddr },
888
+ });
889
+ const subscriptions = new Map();
890
+ // Windows: named pipe sockets keyed by session ID
891
+ const winPipes = new Map();
892
+ // Windows: framing buffers keyed by session ID
893
+ const winPipeBuffers = new Map();
894
+ let sessionUnsubscribe = null;
895
+ let notificationUnsubscribe = null;
896
+ let missedPongs = 0;
897
+ let heartbeatLostEmitted = false;
898
+ const MAX_MISSED_PONGS = 3;
899
+ // Heartbeat: send native WebSocket ping every 15s.
900
+ // Browsers automatically respond to native pings with pong frames —
901
+ // no application-level code is needed on the client side.
902
+ const heartbeatInterval = setInterval(() => {
903
+ if (ws.readyState === WebSocket.OPEN) {
904
+ // Send the ping first so it counts as a sent-but-unanswered probe
905
+ ws.ping();
906
+ missedPongs += 1;
907
+ if (missedPongs >= MAX_MISSED_PONGS) {
908
+ console.log("[MuxServer] Too many missed pongs, terminating connection");
909
+ if (!heartbeatLostEmitted) {
910
+ heartbeatLostEmitted = true;
911
+ recordActivityEvent({
912
+ source: "ui",
913
+ kind: "ui.terminal_heartbeat_lost",
914
+ level: "warn",
915
+ summary: `mux WebSocket heartbeat lost (${missedPongs} missed pongs)`,
916
+ data: {
917
+ missedPongs,
918
+ maxMissedPongs: MAX_MISSED_PONGS,
919
+ connectionAgeMs: Date.now() - connectedAt,
920
+ remoteAddr,
921
+ subscriberCount: subscriptions.size,
922
+ },
923
+ });
924
+ }
925
+ ws.terminate();
926
+ }
927
+ }
928
+ }, 15_000);
929
+ // Native pong resets the missed counter
930
+ ws.on("pong", () => {
931
+ missedPongs = 0;
932
+ });
933
+ /**
934
+ * Handle incoming messages
935
+ */
936
+ ws.on("message", (data) => {
937
+ try {
938
+ const msg = JSON.parse(data.toString("utf8"));
939
+ if (msg.ch === "system") {
940
+ if (msg.type === "ping") {
941
+ const pong = { ch: "system", type: "pong" };
942
+ ws.send(JSON.stringify(pong));
943
+ }
944
+ }
945
+ else if (msg.ch === "terminal") {
946
+ const { id, type } = msg;
947
+ const projectId = "projectId" in msg ? msg.projectId : undefined;
948
+ const subscriptionKey = projectId ? `${projectId}:${id}` : id;
949
+ try {
950
+ if (type === "open") {
951
+ if (isWindows()) {
952
+ handleWindowsPipeMessage(msg, ws, winPipes, winPipeBuffers, { connect: netConnect, resolvePipePath });
953
+ }
954
+ else {
955
+ // --- Unix: tmux path with project scoping ---
956
+ if (!terminalManager)
957
+ throw new Error("Terminal manager not available");
958
+ terminalManager.open(id, projectId, "tmuxName" in msg ? msg.tmuxName : undefined);
959
+ // Send opened confirmation (idempotent — safe to send on re-open)
960
+ const openedMsg = {
961
+ ch: "terminal",
962
+ id,
963
+ type: "opened",
964
+ ...(projectId && { projectId }),
965
+ };
966
+ ws.send(JSON.stringify(openedMsg));
967
+ // Subscribe and send history buffer only for new subscribers.
968
+ // Skipping the buffer on re-open prevents duplicate output when
969
+ // MuxProvider re-sends open for all terminals on reconnect.
970
+ if (!subscriptions.has(subscriptionKey)) {
971
+ // Send buffered history to catch up the new subscriber
972
+ const buffer = terminalManager.getBuffer(id, projectId);
973
+ if (buffer) {
974
+ const bufferMsg = {
975
+ ch: "terminal",
976
+ id,
977
+ type: "data",
978
+ data: buffer,
979
+ ...(projectId && { projectId }),
980
+ };
981
+ ws.send(JSON.stringify(bufferMsg));
982
+ }
983
+ const unsub = terminalManager.subscribe(id, projectId, (data) => {
984
+ const dataMsg = {
985
+ ch: "terminal",
986
+ id,
987
+ type: "data",
988
+ data,
989
+ ...(projectId && { projectId }),
990
+ };
991
+ if (ws.readyState === WebSocket.OPEN) {
992
+ ws.send(JSON.stringify(dataMsg));
993
+ }
994
+ }, (exitCode) => {
995
+ const exitedMsg = {
996
+ ch: "terminal",
997
+ id,
998
+ type: "exited",
999
+ code: exitCode,
1000
+ ...(projectId && { projectId }),
1001
+ };
1002
+ if (ws.readyState === WebSocket.OPEN) {
1003
+ ws.send(JSON.stringify(exitedMsg));
1004
+ }
1005
+ });
1006
+ subscriptions.set(subscriptionKey, unsub);
1007
+ }
1008
+ }
1009
+ }
1010
+ else if (type === "data" && "data" in msg) {
1011
+ if (isWindows()) {
1012
+ handleWindowsPipeMessage(msg, ws, winPipes, winPipeBuffers, { connect: netConnect, resolvePipePath });
1013
+ }
1014
+ else {
1015
+ terminalManager?.write(id, msg.data, projectId);
1016
+ }
1017
+ }
1018
+ else if (type === "resize" && "cols" in msg && "rows" in msg) {
1019
+ if (isWindows()) {
1020
+ handleWindowsPipeMessage(msg, ws, winPipes, winPipeBuffers, { connect: netConnect, resolvePipePath });
1021
+ }
1022
+ else {
1023
+ terminalManager?.resize(id, msg.cols, msg.rows, projectId);
1024
+ }
1025
+ }
1026
+ else if (type === "close") {
1027
+ if (isWindows()) {
1028
+ handleWindowsPipeMessage(msg, ws, winPipes, winPipeBuffers, { connect: netConnect, resolvePipePath });
1029
+ }
1030
+ else {
1031
+ // Unsubscribe this client only — TerminalManager is shared across
1032
+ // all mux connections so we must not kill the PTY here.
1033
+ const unsub = subscriptions.get(subscriptionKey);
1034
+ if (unsub) {
1035
+ unsub();
1036
+ subscriptions.delete(subscriptionKey);
1037
+ }
1038
+ }
1039
+ }
1040
+ }
1041
+ catch (err) {
1042
+ if (ws.readyState === WebSocket.OPEN) {
1043
+ const errorMsg = {
1044
+ ch: "terminal",
1045
+ id,
1046
+ type: "error",
1047
+ message: err instanceof Error ? err.message : String(err),
1048
+ ...(projectId && { projectId }),
1049
+ };
1050
+ ws.send(JSON.stringify(errorMsg));
1051
+ }
1052
+ }
1053
+ }
1054
+ else if (msg.ch === "subscribe") {
1055
+ if (msg.topics.includes("sessions") && !sessionUnsubscribe) {
1056
+ sessionUnsubscribe = broadcaster.subscribe((sessions) => {
1057
+ if (ws.readyState !== WebSocket.OPEN)
1058
+ return;
1059
+ if (ws.bufferedAmount > WS_BUFFER_HIGH_WATERMARK) {
1060
+ console.warn("[MuxServer] Skipping session snapshot — socket backpressured");
1061
+ return;
1062
+ }
1063
+ const snapMsg = { ch: "sessions", type: "snapshot", sessions };
1064
+ ws.send(JSON.stringify(snapMsg));
1065
+ }, (error) => {
1066
+ if (ws.readyState !== WebSocket.OPEN)
1067
+ return;
1068
+ const errMsg = { ch: "sessions", type: "error", error };
1069
+ ws.send(JSON.stringify(errMsg));
1070
+ });
1071
+ }
1072
+ if (msg.topics.includes("notifications") && !notificationUnsubscribe) {
1073
+ notificationUnsubscribe = notificationBroadcaster.subscribe((notifications, type, limit) => {
1074
+ if (ws.readyState !== WebSocket.OPEN)
1075
+ return;
1076
+ if (ws.bufferedAmount > WS_BUFFER_HIGH_WATERMARK) {
1077
+ console.warn("[MuxServer] Skipping notification update — socket backpressured");
1078
+ return;
1079
+ }
1080
+ const msg = {
1081
+ ch: "notifications",
1082
+ type,
1083
+ notifications,
1084
+ limit,
1085
+ };
1086
+ ws.send(JSON.stringify(msg));
1087
+ }, (error) => {
1088
+ if (ws.readyState !== WebSocket.OPEN)
1089
+ return;
1090
+ const errMsg = { ch: "notifications", type: "error", error };
1091
+ ws.send(JSON.stringify(errMsg));
1092
+ });
1093
+ }
1094
+ }
1095
+ }
1096
+ catch (err) {
1097
+ console.error("[MuxServer] Failed to parse message:", err);
1098
+ recordActivityEvent({
1099
+ source: "ui",
1100
+ kind: "ui.terminal_protocol_error",
1101
+ level: "warn",
1102
+ summary: "invalid mux client message — parse failed",
1103
+ data: {
1104
+ errorMessage: err instanceof Error ? err.message : String(err),
1105
+ remoteAddr,
1106
+ subscriberCount: subscriptions.size,
1107
+ },
1108
+ });
1109
+ const errorMsg = {
1110
+ ch: "system",
1111
+ type: "error",
1112
+ message: "Invalid message format",
1113
+ };
1114
+ if (ws.readyState === WebSocket.OPEN) {
1115
+ ws.send(JSON.stringify(errorMsg));
1116
+ }
1117
+ }
1118
+ });
1119
+ /**
1120
+ * Handle connection close
1121
+ */
1122
+ ws.on("close", (code, reason) => {
1123
+ console.log("[MuxServer] Mux connection closed");
1124
+ recordActivityEvent({
1125
+ source: "ui",
1126
+ kind: "ui.terminal_disconnected",
1127
+ level: "info",
1128
+ summary: "mux WebSocket connection closed",
1129
+ data: {
1130
+ code,
1131
+ reason: reason?.toString("utf8") || undefined,
1132
+ connectionAgeMs: Date.now() - connectedAt,
1133
+ subscriberCount: subscriptions.size,
1134
+ heartbeatLost: heartbeatLostEmitted,
1135
+ remoteAddr,
1136
+ },
1137
+ });
1138
+ clearInterval(heartbeatInterval);
1139
+ sessionUnsubscribe?.();
1140
+ sessionUnsubscribe = null;
1141
+ notificationUnsubscribe?.();
1142
+ notificationUnsubscribe = null;
1143
+ for (const unsub of subscriptions.values()) {
1144
+ unsub();
1145
+ }
1146
+ subscriptions.clear();
1147
+ // Windows: close all open pipe sockets
1148
+ for (const pipeSocket of winPipes.values()) {
1149
+ pipeSocket.destroy();
1150
+ }
1151
+ winPipes.clear();
1152
+ winPipeBuffers.clear();
1153
+ });
1154
+ // In the ws library, "error" is always followed by "close", so the close
1155
+ // handler below handles all cleanup. Log the error here and nothing more.
1156
+ ws.on("error", (err) => {
1157
+ console.error("[MuxServer] WebSocket error:", err.message);
1158
+ });
1159
+ });
1160
+ console.log("[MuxServer] Mux WebSocket server created (noServer mode)");
1161
+ return wss;
1162
+ }