atoo-studio 0.0.1 → 0.0.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 (408) hide show
  1. package/LICENSE +21 -0
  2. package/README.github.md +322 -0
  3. package/README.md +112 -0
  4. package/README.npm.md +112 -0
  5. package/bin/atoo-studio.js +90 -0
  6. package/dist/src/agents/claude-code-terminal/adapter.d.ts +42 -0
  7. package/dist/src/agents/claude-code-terminal/adapter.js +166 -0
  8. package/dist/src/agents/claude-code-terminal/index.d.ts +13 -0
  9. package/dist/src/agents/claude-code-terminal/index.js +45 -0
  10. package/dist/src/agents/claude-code-terminal/spawner.d.ts +9 -0
  11. package/dist/src/agents/claude-code-terminal/spawner.js +37 -0
  12. package/dist/src/agents/claude-code-terminal-chatro/adapter.d.ts +51 -0
  13. package/dist/src/agents/claude-code-terminal-chatro/adapter.js +301 -0
  14. package/dist/src/agents/claude-code-terminal-chatro/index.d.ts +13 -0
  15. package/dist/src/agents/claude-code-terminal-chatro/index.js +45 -0
  16. package/dist/src/agents/claude-code-terminal-chatro/jsonl-watcher.d.ts +67 -0
  17. package/dist/src/agents/claude-code-terminal-chatro/jsonl-watcher.js +431 -0
  18. package/dist/src/agents/claude-code-terminal-chatro/spawner.d.ts +9 -0
  19. package/dist/src/agents/claude-code-terminal-chatro/spawner.js +37 -0
  20. package/dist/src/agents/codex-terminal/adapter.d.ts +40 -0
  21. package/dist/src/agents/codex-terminal/adapter.js +160 -0
  22. package/dist/src/agents/codex-terminal/index.d.ts +13 -0
  23. package/dist/src/agents/codex-terminal/index.js +47 -0
  24. package/dist/src/agents/codex-terminal/spawner.d.ts +9 -0
  25. package/dist/src/agents/codex-terminal/spawner.js +56 -0
  26. package/dist/src/agents/codex-terminal-chatro/adapter.d.ts +58 -0
  27. package/dist/src/agents/codex-terminal-chatro/adapter.js +266 -0
  28. package/dist/src/agents/codex-terminal-chatro/index.d.ts +13 -0
  29. package/dist/src/agents/codex-terminal-chatro/index.js +50 -0
  30. package/dist/src/agents/codex-terminal-chatro/jsonl-watcher.d.ts +36 -0
  31. package/dist/src/agents/codex-terminal-chatro/jsonl-watcher.js +205 -0
  32. package/dist/src/agents/codex-terminal-chatro/spawner.d.ts +9 -0
  33. package/dist/src/agents/codex-terminal-chatro/spawner.js +57 -0
  34. package/dist/src/agents/lib/chain-builder.d.ts +21 -0
  35. package/dist/src/agents/lib/chain-builder.js +139 -0
  36. package/dist/src/agents/lib/claude/fs-sessions.d.ts +31 -0
  37. package/dist/src/agents/lib/claude/fs-sessions.js +329 -0
  38. package/dist/src/agents/lib/claude/jsonl-writer.d.ts +32 -0
  39. package/dist/src/agents/lib/claude/jsonl-writer.js +342 -0
  40. package/dist/src/agents/lib/claude/workspace-trust.d.ts +1 -0
  41. package/dist/src/agents/lib/claude/workspace-trust.js +29 -0
  42. package/dist/src/agents/lib/codex/fs-sessions.d.ts +34 -0
  43. package/dist/src/agents/lib/codex/fs-sessions.js +255 -0
  44. package/dist/src/agents/lib/codex/jsonl-mapper.d.ts +11 -0
  45. package/dist/src/agents/lib/codex/jsonl-mapper.js +154 -0
  46. package/dist/src/agents/lib/codex/jsonl-writer.d.ts +8 -0
  47. package/dist/src/agents/lib/codex/jsonl-writer.js +440 -0
  48. package/dist/src/agents/lib/fs-tracking.d.ts +36 -0
  49. package/dist/src/agents/lib/fs-tracking.js +109 -0
  50. package/dist/src/agents/lib/pty-activity-tracker.d.ts +37 -0
  51. package/dist/src/agents/lib/pty-activity-tracker.js +105 -0
  52. package/dist/src/agents/lib/session-id-utils.d.ts +46 -0
  53. package/dist/src/agents/lib/session-id-utils.js +147 -0
  54. package/dist/src/agents/lib/session-precreate.d.ts +17 -0
  55. package/dist/src/agents/lib/session-precreate.js +177 -0
  56. package/dist/src/agents/registry.d.ts +72 -0
  57. package/dist/src/agents/registry.js +337 -0
  58. package/dist/src/agents/types.d.ts +135 -0
  59. package/dist/src/agents/types.js +1 -0
  60. package/dist/src/auth/crypto-key.d.ts +6 -0
  61. package/dist/src/auth/crypto-key.js +45 -0
  62. package/dist/src/auth/middleware.d.ts +18 -0
  63. package/dist/src/auth/middleware.js +54 -0
  64. package/dist/src/auth/password.d.ts +2 -0
  65. package/dist/src/auth/password.js +12 -0
  66. package/dist/src/auth/session.d.ts +10 -0
  67. package/dist/src/auth/session.js +33 -0
  68. package/dist/src/auth/totp.d.ts +12 -0
  69. package/dist/src/auth/totp.js +61 -0
  70. package/dist/src/auth/webauthn.d.ts +6 -0
  71. package/dist/src/auth/webauthn.js +117 -0
  72. package/dist/src/config.d.ts +10 -0
  73. package/dist/src/config.js +16 -0
  74. package/dist/src/database/connection-manager.d.ts +25 -0
  75. package/dist/src/database/connection-manager.js +211 -0
  76. package/dist/src/database/discovery/container.d.ts +6 -0
  77. package/dist/src/database/discovery/container.js +226 -0
  78. package/dist/src/database/discovery/env-parser.d.ts +9 -0
  79. package/dist/src/database/discovery/env-parser.js +525 -0
  80. package/dist/src/database/discovery/local-files.d.ts +6 -0
  81. package/dist/src/database/discovery/local-files.js +58 -0
  82. package/dist/src/database/discovery/port-scan.d.ts +7 -0
  83. package/dist/src/database/discovery/port-scan.js +61 -0
  84. package/dist/src/database/drivers/cassandra.d.ts +12 -0
  85. package/dist/src/database/drivers/cassandra.js +91 -0
  86. package/dist/src/database/drivers/clickhouse.d.ts +11 -0
  87. package/dist/src/database/drivers/clickhouse.js +127 -0
  88. package/dist/src/database/drivers/elasticsearch.d.ts +12 -0
  89. package/dist/src/database/drivers/elasticsearch.js +169 -0
  90. package/dist/src/database/drivers/influxdb.d.ts +14 -0
  91. package/dist/src/database/drivers/influxdb.js +194 -0
  92. package/dist/src/database/drivers/memcached.d.ts +11 -0
  93. package/dist/src/database/drivers/memcached.js +117 -0
  94. package/dist/src/database/drivers/mongodb.d.ts +12 -0
  95. package/dist/src/database/drivers/mongodb.js +128 -0
  96. package/dist/src/database/drivers/mysql.d.ts +11 -0
  97. package/dist/src/database/drivers/mysql.js +112 -0
  98. package/dist/src/database/drivers/neo4j.d.ts +11 -0
  99. package/dist/src/database/drivers/neo4j.js +158 -0
  100. package/dist/src/database/drivers/postgresql.d.ts +11 -0
  101. package/dist/src/database/drivers/postgresql.js +133 -0
  102. package/dist/src/database/drivers/redis.d.ts +11 -0
  103. package/dist/src/database/drivers/redis.js +91 -0
  104. package/dist/src/database/drivers/sqlite.d.ts +10 -0
  105. package/dist/src/database/drivers/sqlite.js +100 -0
  106. package/dist/src/database/query-stream.d.ts +5 -0
  107. package/dist/src/database/query-stream.js +75 -0
  108. package/dist/src/database/types.d.ts +71 -0
  109. package/dist/src/database/types.js +1 -0
  110. package/dist/src/events/index.d.ts +3 -0
  111. package/dist/src/events/index.js +3 -0
  112. package/dist/src/events/types.d.ts +214 -0
  113. package/dist/src/events/types.js +22 -0
  114. package/dist/src/events/wire.d.ts +114 -0
  115. package/dist/src/events/wire.js +296 -0
  116. package/dist/src/fs-monitor-types.d.ts +24 -0
  117. package/dist/src/fs-monitor-types.js +1 -0
  118. package/dist/src/fs-monitor.d.ts +80 -0
  119. package/dist/src/fs-monitor.js +637 -0
  120. package/dist/src/handlers/auth.d.ts +1 -0
  121. package/dist/src/handlers/auth.js +170 -0
  122. package/dist/src/handlers/changes.d.ts +1 -0
  123. package/dist/src/handlers/changes.js +203 -0
  124. package/dist/src/handlers/containers.d.ts +12 -0
  125. package/dist/src/handlers/containers.js +379 -0
  126. package/dist/src/handlers/databases.d.ts +3 -0
  127. package/dist/src/handlers/databases.js +327 -0
  128. package/dist/src/handlers/environments.d.ts +3 -0
  129. package/dist/src/handlers/environments.js +286 -0
  130. package/dist/src/handlers/github.d.ts +1 -0
  131. package/dist/src/handlers/github.js +153 -0
  132. package/dist/src/handlers/projects.d.ts +1 -0
  133. package/dist/src/handlers/projects.js +895 -0
  134. package/dist/src/handlers/ssh.d.ts +1 -0
  135. package/dist/src/handlers/ssh.js +162 -0
  136. package/dist/src/handlers/users.d.ts +1 -0
  137. package/dist/src/handlers/users.js +195 -0
  138. package/dist/src/index.d.ts +1 -0
  139. package/dist/src/index.js +228 -0
  140. package/dist/src/mcp/config.d.ts +32 -0
  141. package/dist/src/mcp/config.js +227 -0
  142. package/dist/src/mcp/server.d.ts +1 -0
  143. package/dist/src/mcp/server.js +574 -0
  144. package/dist/src/serial/cuse-device.d.ts +19 -0
  145. package/dist/src/serial/cuse-device.js +260 -0
  146. package/dist/src/serial/manager.d.ts +63 -0
  147. package/dist/src/serial/manager.js +206 -0
  148. package/dist/src/serial/pty-pair.d.ts +16 -0
  149. package/dist/src/serial/pty-pair.js +68 -0
  150. package/dist/src/services/fs-browser.d.ts +14 -0
  151. package/dist/src/services/fs-browser.js +98 -0
  152. package/dist/src/services/git-ops.d.ts +78 -0
  153. package/dist/src/services/git-ops.js +288 -0
  154. package/dist/src/services/github-ops.d.ts +104 -0
  155. package/dist/src/services/github-ops.js +192 -0
  156. package/dist/src/services/obfuscation.d.ts +2 -0
  157. package/dist/src/services/obfuscation.js +16 -0
  158. package/dist/src/services/preview/headless-backend.d.ts +62 -0
  159. package/dist/src/services/preview/headless-backend.js +698 -0
  160. package/dist/src/services/preview/injected-scripts.d.ts +9 -0
  161. package/dist/src/services/preview/injected-scripts.js +232 -0
  162. package/dist/src/services/preview/preview-backend.d.ts +92 -0
  163. package/dist/src/services/preview/preview-backend.js +15 -0
  164. package/dist/src/services/preview/universal-setter.d.ts +7 -0
  165. package/dist/src/services/preview/universal-setter.js +46 -0
  166. package/dist/src/services/preview-manager.d.ts +50 -0
  167. package/dist/src/services/preview-manager.js +216 -0
  168. package/dist/src/services/project-watcher.d.ts +6 -0
  169. package/dist/src/services/project-watcher.js +307 -0
  170. package/dist/src/services/remote-fs-browser.d.ts +11 -0
  171. package/dist/src/services/remote-fs-browser.js +50 -0
  172. package/dist/src/services/remote-git-ops.d.ts +71 -0
  173. package/dist/src/services/remote-git-ops.js +215 -0
  174. package/dist/src/services/session-search.d.ts +56 -0
  175. package/dist/src/services/session-search.js +303 -0
  176. package/dist/src/services/ssh-manager.d.ts +44 -0
  177. package/dist/src/services/ssh-manager.js +359 -0
  178. package/dist/src/session-writer.d.ts +9 -0
  179. package/dist/src/session-writer.js +66 -0
  180. package/dist/src/spawner.d.ts +56 -0
  181. package/dist/src/spawner.js +135 -0
  182. package/dist/src/state/db.d.ts +214 -0
  183. package/dist/src/state/db.js +897 -0
  184. package/dist/src/state/store.d.ts +37 -0
  185. package/dist/src/state/store.js +108 -0
  186. package/dist/src/state/types.d.ts +13 -0
  187. package/dist/src/state/types.js +1 -0
  188. package/dist/src/web/devtools-proxy.d.ts +7 -0
  189. package/dist/src/web/devtools-proxy.js +176 -0
  190. package/dist/src/web/port-proxy.d.ts +15 -0
  191. package/dist/src/web/port-proxy.js +124 -0
  192. package/dist/src/web/preview-ws.d.ts +5 -0
  193. package/dist/src/web/preview-ws.js +207 -0
  194. package/dist/src/web/server.d.ts +6 -0
  195. package/dist/src/web/server.js +1694 -0
  196. package/dist/src/ws/agent-ws.d.ts +5 -0
  197. package/dist/src/ws/agent-ws.js +93 -0
  198. package/frontend/dist/assets/_basePickBy-B-LibQ4-.js +1 -0
  199. package/frontend/dist/assets/_baseUniq-CprifHap.js +1 -0
  200. package/frontend/dist/assets/_createAssigner-ByDUqGii.js +1 -0
  201. package/frontend/dist/assets/abap-DuT-3z4x.js +1 -0
  202. package/frontend/dist/assets/addon-fit-CxQet2ja.js +1 -0
  203. package/frontend/dist/assets/addon-web-links-D_jRkPIl.js +1 -0
  204. package/frontend/dist/assets/apex-B-em86xX.js +1 -0
  205. package/frontend/dist/assets/api-SUPuHhSY.js +2 -0
  206. package/frontend/dist/assets/arc-Z0_eVteO.js +1 -0
  207. package/frontend/dist/assets/architecture-PBZL5I3N-hvVXGhqd.js +1 -0
  208. package/frontend/dist/assets/architectureDiagram-2XIMDMQ5-DiHPxX4j.js +36 -0
  209. package/frontend/dist/assets/array-CwG8vNfn.js +1 -0
  210. package/frontend/dist/assets/auth-store-R7eW5SVu.js +1 -0
  211. package/frontend/dist/assets/azcli-Bg9wQloi.js +1 -0
  212. package/frontend/dist/assets/bat-BM46z99L.js +1 -0
  213. package/frontend/dist/assets/bicep-DcBsJUfh.js +2 -0
  214. package/frontend/dist/assets/blockDiagram-WCTKOSBZ-C40u_hLo.js +132 -0
  215. package/frontend/dist/assets/c4Diagram-IC4MRINW-Ct7LjWFQ.js +10 -0
  216. package/frontend/dist/assets/cameligo-zw7JTtim.js +1 -0
  217. package/frontend/dist/assets/channel-ClCsE6HN.js +1 -0
  218. package/frontend/dist/assets/chunk-4BX2VUAB-zZ6P90VO.js +1 -0
  219. package/frontend/dist/assets/chunk-55IACEB6-DXllTDQl.js +1 -0
  220. package/frontend/dist/assets/chunk-7E7YKBS2-7zRaOLjj.js +1 -0
  221. package/frontend/dist/assets/chunk-7R4GIKGN-Csst1274.js +80 -0
  222. package/frontend/dist/assets/chunk-C72U2L5F-_JbQPbLN.js +1 -0
  223. package/frontend/dist/assets/chunk-CFjPhJqf.js +1 -0
  224. package/frontend/dist/assets/chunk-EGIJ26TM-B--aFyPw.js +1 -0
  225. package/frontend/dist/assets/chunk-FMBD7UC4-DVR34RNb.js +15 -0
  226. package/frontend/dist/assets/chunk-GEFDOKGD-CnmN6cC8.js +2 -0
  227. package/frontend/dist/assets/chunk-JSJVCQXG-CWxHBzeJ.js +1 -0
  228. package/frontend/dist/assets/chunk-KX2RTZJC-DkRk56s7.js +1 -0
  229. package/frontend/dist/assets/chunk-KYZI473N-DCCsG2dK.js +53 -0
  230. package/frontend/dist/assets/chunk-L3YUKLVL-C-DkZTMr.js +1 -0
  231. package/frontend/dist/assets/chunk-MX3YWQON-OUdzv5sZ.js +1 -0
  232. package/frontend/dist/assets/chunk-NQ4KR5QH-Bpu9FsM7.js +220 -0
  233. package/frontend/dist/assets/chunk-O4XLMI2P-BMLK6_ib.js +7 -0
  234. package/frontend/dist/assets/chunk-OZEHJAEY-CNNiJtG0.js +1 -0
  235. package/frontend/dist/assets/chunk-PQ6SQG4A-evVHD3KM.js +1 -0
  236. package/frontend/dist/assets/chunk-PU5JKC2W-DPFTYuvl.js +70 -0
  237. package/frontend/dist/assets/chunk-QZHKN3VN-JRdddPvu.js +1 -0
  238. package/frontend/dist/assets/chunk-R5LLSJPH-CHQzVVOV.js +1 -0
  239. package/frontend/dist/assets/chunk-WL4C6EOR-BNFU6IIi.js +189 -0
  240. package/frontend/dist/assets/chunk-XIRO2GV7-98T93G85.js +1 -0
  241. package/frontend/dist/assets/chunk-XZSTWKYB-BcW3cyNp.js +94 -0
  242. package/frontend/dist/assets/chunk-YBOYWFTD-BgKO1qAJ.js +1 -0
  243. package/frontend/dist/assets/classDiagram-VBA2DB6C-DikXzgcD.js +1 -0
  244. package/frontend/dist/assets/classDiagram-v2-RAHNMMFH-D7E3tQUK.js +1 -0
  245. package/frontend/dist/assets/clojure-FspFoNNQ.js +1 -0
  246. package/frontend/dist/assets/clone-mOXuZa7C.js +1 -0
  247. package/frontend/dist/assets/codicon-ngg6Pgfi.ttf +0 -0
  248. package/frontend/dist/assets/coffee-13n8Bk2W.js +1 -0
  249. package/frontend/dist/assets/cose-bilkent-S5V4N54A-zUOWQqLe.js +1 -0
  250. package/frontend/dist/assets/cpp-BVm2xGEs.js +1 -0
  251. package/frontend/dist/assets/csharp-D2kAWmUm.js +1 -0
  252. package/frontend/dist/assets/csp-Ezvgpf0e.js +1 -0
  253. package/frontend/dist/assets/css-CYxRwcFy.js +3 -0
  254. package/frontend/dist/assets/css.worker-Cd5h-ZOL.js +89 -0
  255. package/frontend/dist/assets/cssMode-CrXej49V.js +1 -0
  256. package/frontend/dist/assets/cypher-jg3SGErc.js +1 -0
  257. package/frontend/dist/assets/cytoscape.esm-kyyvzxNV.js +321 -0
  258. package/frontend/dist/assets/dagre-DH4bgZO7.js +1 -0
  259. package/frontend/dist/assets/dagre-KLK3FWXG-DNSqDkwT.js +4 -0
  260. package/frontend/dist/assets/dart-179jqhK4.js +1 -0
  261. package/frontend/dist/assets/defaultLocale-Dda4OpKy.js +1 -0
  262. package/frontend/dist/assets/diagram-E7M64L7V-RqPNT5Vs.js +24 -0
  263. package/frontend/dist/assets/diagram-IFDJBPK2-B-5NRyaE.js +43 -0
  264. package/frontend/dist/assets/diagram-P4PSJMXO-BrP69Hk0.js +24 -0
  265. package/frontend/dist/assets/dist-CU_Nb1G5.js +1 -0
  266. package/frontend/dist/assets/dockerfile-CIAtSGxS.js +1 -0
  267. package/frontend/dist/assets/ecl-CGVKfDxD.js +1 -0
  268. package/frontend/dist/assets/editor-Br_kD0ds.css +1 -0
  269. package/frontend/dist/assets/editor.api2-YXkDn0Gm.js +872 -0
  270. package/frontend/dist/assets/editor.main-fBaXZjJ0.js +6 -0
  271. package/frontend/dist/assets/elixir-BZ-6w0y3.js +1 -0
  272. package/frontend/dist/assets/erDiagram-INFDFZHY-BYiB9NYg.js +70 -0
  273. package/frontend/dist/assets/flow9-CVuOjTMv.js +1 -0
  274. package/frontend/dist/assets/flowDiagram-PKNHOUZH-Cwq47rsR.js +162 -0
  275. package/frontend/dist/assets/freemarker2-DM-pztJU.js +3 -0
  276. package/frontend/dist/assets/fsharp-q0pGJYr6.js +1 -0
  277. package/frontend/dist/assets/ganttDiagram-A5KZAMGK-Dnx3szD9.js +292 -0
  278. package/frontend/dist/assets/gitGraph-HDMCJU4V-COlTQ7bA.js +1 -0
  279. package/frontend/dist/assets/gitGraphDiagram-K3NZZRJ6-BaUxboNc.js +65 -0
  280. package/frontend/dist/assets/go-dzSPfdEO.js +1 -0
  281. package/frontend/dist/assets/graphlib-kEFlkt3U.js +1 -0
  282. package/frontend/dist/assets/graphql-CG4OUoEV.js +1 -0
  283. package/frontend/dist/assets/handlebars-BbK53Vec.js +1 -0
  284. package/frontend/dist/assets/hcl-Cy14JPk3.js +1 -0
  285. package/frontend/dist/assets/html-DYtTQNOG.js +1 -0
  286. package/frontend/dist/assets/html.worker-BjVEKLoU.js +502 -0
  287. package/frontend/dist/assets/htmlMode-C6GTouth.js +1 -0
  288. package/frontend/dist/assets/index-DMLxes_u.js +157 -0
  289. package/frontend/dist/assets/index-DmzeqkB1.css +1 -0
  290. package/frontend/dist/assets/info-3K5VOQVL-DBtHyA4C.js +1 -0
  291. package/frontend/dist/assets/infoDiagram-LFFYTUFH-yBXLgMPI.js +2 -0
  292. package/frontend/dist/assets/ini-Pbg8HGVD.js +1 -0
  293. package/frontend/dist/assets/init-D6KNwrax.js +1 -0
  294. package/frontend/dist/assets/ishikawaDiagram-PHBUUO56-Bld4two_.js +70 -0
  295. package/frontend/dist/assets/java-BmVu6Qrl.js +1 -0
  296. package/frontend/dist/assets/javascript-PbfQEdcJ.js +1 -0
  297. package/frontend/dist/assets/journeyDiagram-4ABVD52K-4HyMd4R2.js +139 -0
  298. package/frontend/dist/assets/json.worker-DqU5Wxnl.js +58 -0
  299. package/frontend/dist/assets/jsonMode-CASsGppE.js +7 -0
  300. package/frontend/dist/assets/julia-3cGnieBq.js +1 -0
  301. package/frontend/dist/assets/kanban-definition-K7BYSVSG-DpgsZmpG.js +89 -0
  302. package/frontend/dist/assets/katex-CEw3x5bf.js +261 -0
  303. package/frontend/dist/assets/kotlin-BuWkVcfV.js +1 -0
  304. package/frontend/dist/assets/less-CJ_VPy2C.js +2 -0
  305. package/frontend/dist/assets/lexon-BygAuZPu.js +1 -0
  306. package/frontend/dist/assets/line-CA_wh_TY.js +1 -0
  307. package/frontend/dist/assets/linear-BAcLW45z.js +1 -0
  308. package/frontend/dist/assets/liquid-kz84dle6.js +1 -0
  309. package/frontend/dist/assets/lspLanguageFeatures-C7hAHFn1.js +4 -0
  310. package/frontend/dist/assets/lua-C8Xs3dCx.js +1 -0
  311. package/frontend/dist/assets/m3-DTJeKBk4.js +1 -0
  312. package/frontend/dist/assets/markdown-QCgx8JqZ.js +1 -0
  313. package/frontend/dist/assets/math-D0YcMJAn.js +1 -0
  314. package/frontend/dist/assets/mdx-yRw0ap-E.js +1 -0
  315. package/frontend/dist/assets/mermaid-parser.core-DAeTodBQ.js +4 -0
  316. package/frontend/dist/assets/mindmap-definition-YRQLILUH-CoNlFyVl.js +68 -0
  317. package/frontend/dist/assets/mips-DopWaYgE.js +1 -0
  318. package/frontend/dist/assets/monaco.contribution-DeY0Qei-.js +2 -0
  319. package/frontend/dist/assets/msdax-BDis4ARV.js +1 -0
  320. package/frontend/dist/assets/mysql-BV6MLsOI.js +1 -0
  321. package/frontend/dist/assets/objective-c-B1UuzKs6.js +1 -0
  322. package/frontend/dist/assets/ordinal-jM7S0YHN.js +1 -0
  323. package/frontend/dist/assets/packet-RMMSAZCW-FF6-Tmai.js +1 -0
  324. package/frontend/dist/assets/pascal-BkvESCrc.js +1 -0
  325. package/frontend/dist/assets/pascaligo-lTy0kZYr.js +1 -0
  326. package/frontend/dist/assets/path-DNPd7Py7.js +1 -0
  327. package/frontend/dist/assets/perl-CrtUPXLV.js +1 -0
  328. package/frontend/dist/assets/pgsql-B9IbNWx2.js +1 -0
  329. package/frontend/dist/assets/php-CXvQBY2p.js +1 -0
  330. package/frontend/dist/assets/pie-UPGHQEXC-CFvXY2o-.js +1 -0
  331. package/frontend/dist/assets/pieDiagram-SKSYHLDU-CM_hbCcn.js +30 -0
  332. package/frontend/dist/assets/pla-DxBxuqWu.js +1 -0
  333. package/frontend/dist/assets/postiats-OkEuT5YF.js +1 -0
  334. package/frontend/dist/assets/powerquery-CMx5Tq4K.js +1 -0
  335. package/frontend/dist/assets/powershell-CstRxrEc.js +1 -0
  336. package/frontend/dist/assets/preload-helper-D4M6sveU.js +1 -0
  337. package/frontend/dist/assets/protobuf-Bx0Z-uRj.js +2 -0
  338. package/frontend/dist/assets/pug--W8vanWl.js +1 -0
  339. package/frontend/dist/assets/python-DA0rnlw3.js +1 -0
  340. package/frontend/dist/assets/qsharp-CRtr0YbN.js +1 -0
  341. package/frontend/dist/assets/quadrantDiagram-337W2JSQ-B3n3IUhC.js +7 -0
  342. package/frontend/dist/assets/r-C6E1d6iv.js +1 -0
  343. package/frontend/dist/assets/radar-KQ55EAFF-MPZu7SdX.js +1 -0
  344. package/frontend/dist/assets/razor-yd73uata.js +1 -0
  345. package/frontend/dist/assets/redis-Dx13voP3.js +1 -0
  346. package/frontend/dist/assets/redshift-D66HwlyV.js +1 -0
  347. package/frontend/dist/assets/requirementDiagram-Z7DCOOCP-CorP7L7F.js +73 -0
  348. package/frontend/dist/assets/restructuredtext-DQT2NKJ2.js +1 -0
  349. package/frontend/dist/assets/rough.esm-DxAX5Vpo.js +1 -0
  350. package/frontend/dist/assets/ruby-iFXI8hwH.js +1 -0
  351. package/frontend/dist/assets/rust-CSKiei34.js +1 -0
  352. package/frontend/dist/assets/sankeyDiagram-WA2Y5GQK-RDx6Bd-B.js +10 -0
  353. package/frontend/dist/assets/sb-Bo3ttdP2.js +1 -0
  354. package/frontend/dist/assets/scala-BC1D-Nxp.js +1 -0
  355. package/frontend/dist/assets/scheme-Z4OAo4Lv.js +1 -0
  356. package/frontend/dist/assets/scss-BvrdPs6B.js +3 -0
  357. package/frontend/dist/assets/sequenceDiagram-2WXFIKYE-JMqJSFq6.js +145 -0
  358. package/frontend/dist/assets/shell-Bh_aCyF-.js +1 -0
  359. package/frontend/dist/assets/solidity-CWHj6tSe.js +1 -0
  360. package/frontend/dist/assets/sophia-raoNtKtm.js +1 -0
  361. package/frontend/dist/assets/sparql-XzmoGnue.js +1 -0
  362. package/frontend/dist/assets/sql-BD0i9Gvg.js +1 -0
  363. package/frontend/dist/assets/src-Bn-kKzs7.js +1 -0
  364. package/frontend/dist/assets/st-DtVKyms6.js +1 -0
  365. package/frontend/dist/assets/stateDiagram-RAJIS63D-CgFfENdy.js +1 -0
  366. package/frontend/dist/assets/stateDiagram-v2-FVOUBMTO-C4Hh2P-U.js +1 -0
  367. package/frontend/dist/assets/swift--UZs77wT.js +1 -0
  368. package/frontend/dist/assets/systemverilog-CDnBSWUd.js +1 -0
  369. package/frontend/dist/assets/tcl-DdCEuTHZ.js +1 -0
  370. package/frontend/dist/assets/timeline-definition-YZTLITO2-BnatPBR5.js +61 -0
  371. package/frontend/dist/assets/treemap-KZPCXAKY-qb1Pl9la.js +1 -0
  372. package/frontend/dist/assets/ts.worker-DyPAEIuH.js +67719 -0
  373. package/frontend/dist/assets/tsMode-iuvyEpyO.js +11 -0
  374. package/frontend/dist/assets/twig-SSL-Altf.js +1 -0
  375. package/frontend/dist/assets/typescript-17918Hud.js +1 -0
  376. package/frontend/dist/assets/typespec-BT7S0ETg.js +1 -0
  377. package/frontend/dist/assets/vb-CrIgucua.js +1 -0
  378. package/frontend/dist/assets/vennDiagram-LZ73GAT5-DygS4Zzd.js +34 -0
  379. package/frontend/dist/assets/wgsl-BeKc3oEp.js +298 -0
  380. package/frontend/dist/assets/workers-DTfwKVoM.js +1 -0
  381. package/frontend/dist/assets/xml-CBMr_Wbw.js +1 -0
  382. package/frontend/dist/assets/xterm-BrP-ENHg.css +1 -0
  383. package/frontend/dist/assets/xterm-CBX2m0YM.js +36 -0
  384. package/frontend/dist/assets/xychartDiagram-JWTSCODW-D6wY1Jwd.js +7 -0
  385. package/frontend/dist/assets/yaml-CTjCH7Bv.js +1 -0
  386. package/frontend/dist/fonts/inter-300.ttf +0 -0
  387. package/frontend/dist/fonts/inter-400.ttf +0 -0
  388. package/frontend/dist/fonts/inter-500.ttf +0 -0
  389. package/frontend/dist/fonts/inter-600.ttf +0 -0
  390. package/frontend/dist/fonts/inter-700.ttf +0 -0
  391. package/frontend/dist/index.html +49 -0
  392. package/frontend/dist/logo_192x192.png +0 -0
  393. package/frontend/dist/logo_32x32.png +0 -0
  394. package/frontend/dist/logo_512x512.png +0 -0
  395. package/frontend/dist/logo_64x64.png +0 -0
  396. package/frontend/dist/logobg_192x192.png +0 -0
  397. package/frontend/dist/logobg_512x512.png +0 -0
  398. package/frontend/dist/logobg_64x64.png +0 -0
  399. package/frontend/dist/manifest.json +25 -0
  400. package/frontend/dist/sw.js +22 -0
  401. package/package.json +74 -7
  402. package/preload/Makefile +12 -0
  403. package/preload/atoo-studio-preload.c +647 -0
  404. package/preload/atoo-studio-preload.so +0 -0
  405. package/setup-cuse.sh +260 -0
  406. package/setup.sh +81 -0
  407. package/src/serial/native/binding.gyp +10 -0
  408. package/src/serial/native/pty_pair.c +222 -0
@@ -0,0 +1,1694 @@
1
+ import express from 'express';
2
+ import { WebSocketServer } from 'ws';
3
+ import http from 'http';
4
+ import https from 'https';
5
+ import path from 'path';
6
+ import fs from 'fs';
7
+ import os from 'os';
8
+ import { execSync } from 'child_process';
9
+ import { fileURLToPath } from 'url';
10
+ import cookieParser from 'cookie-parser';
11
+ import { store } from '../state/store.js';
12
+ import { v4 as uuidv4 } from 'uuid';
13
+ import * as pty from 'node-pty';
14
+ import { getPty, getEnvIdForSession, getScrollback } from '../spawner.js';
15
+ import { fsMonitor } from '../fs-monitor.js';
16
+ import { changesRouter } from '../handlers/changes.js';
17
+ import { projectsRouter } from '../handlers/projects.js';
18
+ import { environmentsRouter, setBroadcastSettingsChange } from '../handlers/environments.js';
19
+ import { sshRouter } from '../handlers/ssh.js';
20
+ import { githubRouter } from '../handlers/github.js';
21
+ import { authRouter } from '../handlers/auth.js';
22
+ import { usersRouter } from '../handlers/users.js';
23
+ import { isAgentWsUpgrade, handleAgentWsUpgrade } from '../ws/agent-ws.js';
24
+ import { agentRegistry } from '../agents/registry.js';
25
+ import { createPortProxy, portProxyMiddleware, isPortProxyUpgrade, handlePortProxyUpgrade } from './port-proxy.js';
26
+ import { isPreviewWsUpgrade, handlePreviewWsUpgrade } from './preview-ws.js';
27
+ import { previewManager } from '../services/preview-manager.js';
28
+ import { devtoolsProxyMiddleware, isDevtoolsWsUpgrade, handleDevtoolsWsUpgrade } from './devtools-proxy.js';
29
+ import forge from 'node-forge';
30
+ import { CA_CERT_PATH, CA_KEY_PATH, PROJECT_ROOT } from '../config.js';
31
+ import { serialManager } from '../serial/manager.js';
32
+ import { searchSessionHistory, fetchSessionRange } from '../services/session-search.js';
33
+ import { resolveChainHead, walkChain } from '../agents/lib/session-id-utils.js';
34
+ import { db } from '../state/db.js';
35
+ import { containersRouter, getContainerRuntimes } from '../handlers/containers.js';
36
+ import { databasesRouter, handleMcpConnectDatabase } from '../handlers/databases.js';
37
+ import { isDatabaseWsUpgrade, handleDatabaseWsUpgrade } from '../database/query-stream.js';
38
+ import { requireAuth, authenticateWsUpgrade, isAuthEnabled } from '../auth/middleware.js';
39
+ import { validateMcpToken } from '../mcp/config.js';
40
+ // Standalone shell terminals (not tied to Claude sessions)
41
+ const shellTerminals = new Map();
42
+ // Broadcast registries for multi-browser terminal/shell connections
43
+ // Each entry keeps a scrollback buffer so late-joining browsers get existing output
44
+ const MAX_SCROLLBACK = 200_000; // characters
45
+ const terminalClients = new Map();
46
+ const shellClients = new Map();
47
+ const pendingSessionSwitches = new Map();
48
+ const pendingOpenFiles = new Map();
49
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
50
+ export function createWebServer(tlsOptions) {
51
+ const app = express();
52
+ // Port-proxy: intercept before body parsing so proxied requests stream through
53
+ const portProxy = createPortProxy();
54
+ app.use(portProxyMiddleware(portProxy));
55
+ app.use(express.json({ limit: '50mb' }));
56
+ app.use(cookieParser());
57
+ // Auth routes (public — handles its own auth checks internally)
58
+ app.use(authRouter);
59
+ app.use(usersRouter);
60
+ // Serve CA certificate for trust installation (PEM for Linux/macOS, DER .crt for Windows)
61
+ app.get('/ca.pem', (_req, res) => {
62
+ try {
63
+ const caPem = fs.readFileSync(CA_CERT_PATH, 'utf-8');
64
+ res.setHeader('Content-Type', 'application/x-pem-file');
65
+ res.setHeader('Content-Disposition', 'attachment; filename="atoo-studio-ca.pem"');
66
+ res.send(caPem);
67
+ }
68
+ catch (err) {
69
+ res.status(500).json({ error: 'CA certificate not found' });
70
+ }
71
+ });
72
+ app.get('/ca.crt', (_req, res) => {
73
+ try {
74
+ const caPem = fs.readFileSync(CA_CERT_PATH, 'utf-8');
75
+ res.setHeader('Content-Type', 'application/x-x509-ca-cert');
76
+ res.setHeader('Content-Disposition', 'attachment; filename="atoo-studio-ca.crt"');
77
+ res.send(caPem);
78
+ }
79
+ catch (err) {
80
+ res.status(500).json({ error: 'CA certificate not found' });
81
+ }
82
+ });
83
+ // ═══════════════════════════════════════════════════════
84
+ // MCP middleware — localhost check + per-session token
85
+ // ═══════════════════════════════════════════════════════
86
+ app.use('/api/mcp', (req, res, next) => {
87
+ // Localhost check
88
+ const addr = req.socket.remoteAddress;
89
+ if (addr !== '127.0.0.1' && addr !== '::1' && addr !== '::ffff:127.0.0.1') {
90
+ res.status(403).json({ error: 'MCP callbacks are localhost-only' });
91
+ return;
92
+ }
93
+ // Token check
94
+ const authHeader = req.headers.authorization;
95
+ const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
96
+ if (!token || !validateMcpToken(token)) {
97
+ res.status(401).json({ error: 'Invalid or missing MCP token' });
98
+ return;
99
+ }
100
+ next();
101
+ });
102
+ // MCP callback: notify that a GitHub issue/PR was changed
103
+ app.post('/api/mcp/github-changed', (req, res) => {
104
+ const { repository, type, number, sessionUuid } = req.body;
105
+ if (!repository || typeof repository !== 'string') {
106
+ return res.status(400).json({ error: 'repository is required' });
107
+ }
108
+ if (type !== 'issue' && type !== 'pr') {
109
+ return res.status(400).json({ error: 'type must be "issue" or "pr"' });
110
+ }
111
+ if (!number || typeof number !== 'number') {
112
+ return res.status(400).json({ error: 'number is required' });
113
+ }
114
+ console.log(`[mcp] github-changed: ${type} #${number} in ${repository}${sessionUuid ? ` (session ${sessionUuid})` : ''}`);
115
+ const msg = JSON.stringify({ type: 'github_issue_pr_changed', repository, itemType: type, number, ...(sessionUuid ? { sessionUuid } : {}) });
116
+ for (const ws of store.statusClients) {
117
+ if (ws.readyState === 1)
118
+ ws.send(msg);
119
+ }
120
+ res.json({ success: true });
121
+ });
122
+ // MCP callback: report started TCP services
123
+ app.post('/api/mcp/report-services', (req, res) => {
124
+ const { services, cwd } = req.body;
125
+ if (!Array.isArray(services) || !services.length) {
126
+ return res.status(400).json({ error: 'services array is required' });
127
+ }
128
+ console.log(`[mcp] report-services: ${services.length} service(s) from ${cwd}`);
129
+ const msg = JSON.stringify({ type: 'service_started', services, cwd });
130
+ for (const ws of store.statusClients) {
131
+ if (ws.readyState === 1)
132
+ ws.send(msg);
133
+ }
134
+ res.json({ success: true });
135
+ });
136
+ // MCP callback: generate a certificate signed by the proxy CA and write to disk
137
+ app.post('/api/mcp/generate-cert', (req, res) => {
138
+ const { hostnames, output_dir } = req.body;
139
+ if (!Array.isArray(hostnames) || !hostnames.length) {
140
+ return res.status(400).json({ error: 'hostnames array is required' });
141
+ }
142
+ if (!output_dir || typeof output_dir !== 'string') {
143
+ return res.status(400).json({ error: 'output_dir is required' });
144
+ }
145
+ try {
146
+ const caCertPem = fs.readFileSync(CA_CERT_PATH, 'utf-8');
147
+ const caKeyPem = fs.readFileSync(CA_KEY_PATH, 'utf-8');
148
+ const caCert = forge.pki.certificateFromPem(caCertPem);
149
+ const caKey = forge.pki.privateKeyFromPem(caKeyPem);
150
+ const keys = forge.pki.rsa.generateKeyPair(2048);
151
+ const cert = forge.pki.createCertificate();
152
+ cert.publicKey = keys.publicKey;
153
+ cert.serialNumber = Date.now().toString(16);
154
+ cert.validity.notBefore = new Date();
155
+ cert.validity.notAfter = new Date();
156
+ cert.validity.notAfter.setFullYear(cert.validity.notAfter.getFullYear() + 1);
157
+ cert.setSubject([{ name: 'commonName', value: hostnames[0] }]);
158
+ cert.setIssuer(caCert.subject.attributes);
159
+ cert.setExtensions([
160
+ { name: 'basicConstraints', cA: false },
161
+ { name: 'subjectAltName', altNames: hostnames.map((h) => ({ type: 2, value: h })) },
162
+ { name: 'keyUsage', digitalSignature: true, keyEncipherment: true },
163
+ { name: 'extKeyUsage', serverAuth: true },
164
+ ]);
165
+ cert.sign(caKey, forge.md.sha256.create());
166
+ fs.mkdirSync(output_dir, { recursive: true });
167
+ const certPath = path.join(output_dir, 'cert.pem');
168
+ const keyPath = path.join(output_dir, 'key.pem');
169
+ const caPath = path.join(output_dir, 'ca.pem');
170
+ fs.writeFileSync(certPath, forge.pki.certificateToPem(cert));
171
+ fs.writeFileSync(keyPath, forge.pki.privateKeyToPem(keys.privateKey));
172
+ fs.writeFileSync(caPath, caCertPem);
173
+ console.log(`[mcp] Generated certificate for ${hostnames.join(', ')} → ${output_dir}`);
174
+ res.json({ cert_path: certPath, key_path: keyPath, ca_path: caPath });
175
+ }
176
+ catch (err) {
177
+ res.status(500).json({ error: err.message });
178
+ }
179
+ });
180
+ // MCP callback: request serial device passthrough
181
+ app.post('/api/mcp/request-serial', async (req, res) => {
182
+ const { baudRate = 115200, dataBits = 8, stopBits = 1, parity = 'none', description } = req.body;
183
+ const requestId = uuidv4();
184
+ try {
185
+ const { devicePath, controlSignalsSupported, readyPromise } = await serialManager.createRequest(requestId, {
186
+ baudRate, dataBits, stopBits, parity, description,
187
+ });
188
+ // Broadcast to all browsers so they can show the serial connect modal
189
+ const msg = JSON.stringify({
190
+ type: 'serial_request', requestId, baudRate, dataBits, stopBits, parity, description, controlSignalsSupported,
191
+ });
192
+ for (const ws of store.statusClients) {
193
+ if (ws.readyState === 1)
194
+ ws.send(msg);
195
+ }
196
+ // Wait for browser to connect (30s timeout)
197
+ const timeout = setTimeout(() => {
198
+ serialManager.rejectRequest(requestId, new Error('No browser connected within 30s'));
199
+ }, 30000);
200
+ const result = await readyPromise;
201
+ clearTimeout(timeout);
202
+ res.json({ success: true, ptyPath: result.devicePath, requestId, controlSignalsSupported: result.controlSignalsSupported });
203
+ }
204
+ catch (err) {
205
+ serialManager.closeRequest(requestId);
206
+ res.status(500).json({ error: err.message });
207
+ }
208
+ });
209
+ // Reject a serial request (user closed modal without connecting)
210
+ // MCP callback: suggest switching to another session
211
+ app.post('/api/mcp/suggest-session-switch', async (req, res) => {
212
+ const { session_uuid, refined_prompt, cwd, source_session_id } = req.body;
213
+ if (!session_uuid || !refined_prompt) {
214
+ return res.status(400).json({ error: 'session_uuid and refined_prompt are required' });
215
+ }
216
+ const requestId = uuidv4();
217
+ // Resolve chain head: find most recent session in the chain
218
+ let targetUuid = session_uuid;
219
+ try {
220
+ const allFiles = await agentRegistry.getSessionFilesForProject(cwd || process.cwd());
221
+ const allUuids = allFiles.map(f => {
222
+ const basename = path.basename(f, '.jsonl');
223
+ const m = basename.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/);
224
+ return m ? m[1] : '';
225
+ }).filter(Boolean);
226
+ targetUuid = resolveChainHead(session_uuid, allUuids);
227
+ }
228
+ catch (err) {
229
+ console.warn('[mcp] Failed to resolve chain head, using provided UUID:', err);
230
+ }
231
+ // Broadcast to all browsers
232
+ const msg = JSON.stringify({
233
+ type: 'session_switch_request',
234
+ requestId,
235
+ targetSessionUuid: targetUuid,
236
+ originalSessionUuid: session_uuid,
237
+ refinedPrompt: refined_prompt,
238
+ sourceSessionId: source_session_id || null,
239
+ });
240
+ for (const ws of store.statusClients) {
241
+ if (ws.readyState === 1)
242
+ ws.send(msg);
243
+ }
244
+ // Block until user responds (60s timeout)
245
+ try {
246
+ const result = await new Promise((resolve) => {
247
+ const timeout = setTimeout(() => {
248
+ pendingSessionSwitches.delete(requestId);
249
+ resolve({ action: 'rejected' });
250
+ }, 60000);
251
+ pendingSessionSwitches.set(requestId, { resolve, timeout });
252
+ });
253
+ res.json({ action: result.action, targetSessionUuid: targetUuid });
254
+ }
255
+ catch (err) {
256
+ res.status(500).json({ error: err.message });
257
+ }
258
+ });
259
+ // MCP callback: open a file in the user's browser (with confirmation)
260
+ app.post('/api/mcp/open-file', async (req, res) => {
261
+ const { file_path } = req.body;
262
+ if (!file_path || typeof file_path !== 'string') {
263
+ return res.status(400).json({ error: 'file_path is required' });
264
+ }
265
+ const requestId = uuidv4();
266
+ // Broadcast to all browsers
267
+ const msg = JSON.stringify({ type: 'open_file_request', requestId, filePath: file_path });
268
+ for (const ws of store.statusClients) {
269
+ if (ws.readyState === 1)
270
+ ws.send(msg);
271
+ }
272
+ // Block until user responds (30s timeout)
273
+ try {
274
+ const result = await new Promise((resolve) => {
275
+ const timeout = setTimeout(() => {
276
+ pendingOpenFiles.delete(requestId);
277
+ resolve({ action: 'rejected' });
278
+ }, 30000);
279
+ pendingOpenFiles.set(requestId, { resolve, timeout });
280
+ });
281
+ res.json({ action: result.action });
282
+ }
283
+ catch (err) {
284
+ res.status(500).json({ error: err.message });
285
+ }
286
+ });
287
+ // Helper: resolve chain UUIDs from a session UUID and project cwd
288
+ async function resolveChainUuids(sessionUuid, cwd) {
289
+ const allFiles = await agentRegistry.getSessionFilesForProject(cwd);
290
+ const allUuids = allFiles.map(f => {
291
+ const basename = path.basename(f, '.jsonl');
292
+ const m = basename.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/);
293
+ return m ? m[1] : '';
294
+ }).filter(Boolean);
295
+ return walkChain(sessionUuid, allUuids);
296
+ }
297
+ // Helper: resolve a session_uuid that might be an agent_xxx ID to its CLI UUID
298
+ function resolveToCliUuid(sessionUuid) {
299
+ // If it's an agent session ID, look up its CLI UUID
300
+ const agent = agentRegistry.getAgent(sessionUuid);
301
+ if (agent) {
302
+ const cliId = agent.getCliSessionId?.();
303
+ if (cliId)
304
+ return cliId;
305
+ }
306
+ return sessionUuid;
307
+ }
308
+ // Helper: find active agent session IDs whose CLI UUID is in the given set
309
+ function findAgentSessionIds(chainUuids) {
310
+ const uuidSet = new Set(chainUuids);
311
+ const agentIds = [];
312
+ for (const info of agentRegistry.listAgents()) {
313
+ const agent = agentRegistry.getAgent(info.sessionId);
314
+ if (agent) {
315
+ const cliId = agent.getCliSessionId?.();
316
+ if (cliId && uuidSet.has(cliId)) {
317
+ agentIds.push(info.sessionId);
318
+ }
319
+ }
320
+ }
321
+ return agentIds;
322
+ }
323
+ // Helper: merge metadata across a chain (name/description from latest, tags deduplicated)
324
+ function mergeChainMetadata(chainUuids) {
325
+ const allMeta = db.getMetadataForSessions(chainUuids);
326
+ const allTags = new Set();
327
+ let name;
328
+ let description;
329
+ // Walk from newest to oldest for name/description (first found wins)
330
+ for (let i = chainUuids.length - 1; i >= 0; i--) {
331
+ const meta = allMeta[chainUuids[i]];
332
+ if (!meta)
333
+ continue;
334
+ if (!name && meta.name)
335
+ name = meta.name;
336
+ if (!description && meta.description)
337
+ description = meta.description;
338
+ for (const t of meta.tags)
339
+ allTags.add(t);
340
+ }
341
+ return { name, description, tags: Array.from(allTags) };
342
+ }
343
+ // MCP callback: get metadata for a session chain
344
+ app.post('/api/mcp/get-metadata', async (req, res) => {
345
+ const { session_uuid, cwd } = req.body;
346
+ if (!session_uuid) {
347
+ return res.status(400).json({ error: 'session_uuid is required' });
348
+ }
349
+ try {
350
+ const cliUuid = resolveToCliUuid(session_uuid);
351
+ const chainUuids = await resolveChainUuids(cliUuid, cwd || process.cwd());
352
+ const agentIds = findAgentSessionIds(chainUuids);
353
+ const merged = mergeChainMetadata(chainUuids);
354
+ res.json({ ...merged, chainSessionIds: [...chainUuids, ...agentIds] });
355
+ }
356
+ catch (err) {
357
+ res.status(500).json({ error: err.message });
358
+ }
359
+ });
360
+ // MCP callback: set metadata on a session
361
+ app.post('/api/mcp/set-metadata', async (req, res) => {
362
+ const { session_uuid, name, description, tags, cwd } = req.body;
363
+ if (!session_uuid) {
364
+ return res.status(400).json({ error: 'session_uuid is required' });
365
+ }
366
+ if (name === undefined && description === undefined && tags === undefined) {
367
+ return res.status(400).json({ error: 'At least one of name, description, or tags is required' });
368
+ }
369
+ try {
370
+ db.setSessionMetadata(session_uuid, { name, description, tags });
371
+ const chainUuids = await resolveChainUuids(session_uuid, cwd || process.cwd());
372
+ const agentIds = findAgentSessionIds(chainUuids);
373
+ const merged = mergeChainMetadata(chainUuids);
374
+ // Broadcast to all browsers — include both CLI UUIDs and agent_xxx IDs
375
+ const msg = JSON.stringify({
376
+ type: 'session_metadata_updated',
377
+ sessionUuids: [...chainUuids, ...agentIds],
378
+ ...merged,
379
+ });
380
+ for (const ws of store.statusClients) {
381
+ if (ws.readyState === 1)
382
+ ws.send(msg);
383
+ }
384
+ res.json({ success: true, ...merged });
385
+ }
386
+ catch (err) {
387
+ res.status(500).json({ error: err.message });
388
+ }
389
+ });
390
+ // MCP callback: search session history files for the current project
391
+ app.post('/api/mcp/search-history', async (req, res) => {
392
+ const { query, max_results, max_results_per_query, cwd, type, session_uuid, sort } = req.body;
393
+ if (!query) {
394
+ return res.status(400).json({ error: 'query is required' });
395
+ }
396
+ if (!cwd || typeof cwd !== 'string') {
397
+ return res.status(400).json({ error: 'cwd is required' });
398
+ }
399
+ try {
400
+ // Support both old (max_results) and new (max_results_per_query) parameter names
401
+ const limit = max_results_per_query ?? max_results ?? 50;
402
+ const results = await searchSessionHistory(query, cwd, limit, {
403
+ type: type || 'FullProjectSearch',
404
+ sessionUuid: session_uuid,
405
+ sort: sort || 'newest_first',
406
+ });
407
+ res.json(results);
408
+ }
409
+ catch (err) {
410
+ res.status(500).json({ error: err.message });
411
+ }
412
+ });
413
+ // MCP callback: fetch full messages from a session by range
414
+ app.post('/api/mcp/fetch-history-range', async (req, res) => {
415
+ const { cwd, type, session_uuid, sort, session, target_session_uuid, from, to } = req.body;
416
+ if (!cwd || typeof cwd !== 'string') {
417
+ return res.status(400).json({ error: 'cwd is required' });
418
+ }
419
+ if (session == null && !target_session_uuid) {
420
+ return res.status(400).json({ error: 'session or target_session_uuid is required' });
421
+ }
422
+ if (from == null || to == null) {
423
+ return res.status(400).json({ error: 'from and to are required' });
424
+ }
425
+ try {
426
+ const result = await fetchSessionRange(cwd, {
427
+ type: type || 'FullProjectSearch',
428
+ sessionUuid: session_uuid,
429
+ sort: sort || 'newest_first',
430
+ session,
431
+ targetSessionUuid: target_session_uuid,
432
+ from,
433
+ to,
434
+ });
435
+ res.json(result);
436
+ }
437
+ catch (err) {
438
+ res.status(500).json({ error: err.message });
439
+ }
440
+ });
441
+ // MCP callback: database connect/query/schema (must be before auth wall)
442
+ app.post('/api/mcp/connect-database', handleMcpConnectDatabase);
443
+ // MCP callback: track project changes ("what has been done")
444
+ app.post('/api/mcp/track-changes', async (req, res) => {
445
+ const { mode, id, short_description, long_description, tags, approx_files_affected, session_uuid, cwd } = req.body;
446
+ if (!mode)
447
+ return res.status(400).json({ error: 'mode is required' });
448
+ // Resolve project from cwd
449
+ const projectPath = cwd || process.cwd();
450
+ const project = db.findProjectByPath(projectPath);
451
+ if (!project)
452
+ return res.status(404).json({ error: `No project found for path: ${projectPath}` });
453
+ try {
454
+ if (mode === 'get') {
455
+ const changes = db.listProjectChanges(project.id);
456
+ return res.json({ changes });
457
+ }
458
+ else if (mode === 'set') {
459
+ if (!short_description)
460
+ return res.status(400).json({ error: 'short_description is required for set mode' });
461
+ if (approx_files_affected === undefined)
462
+ return res.status(400).json({ error: 'approx_files_affected is required for set mode' });
463
+ let change;
464
+ if (id) {
465
+ // Update existing
466
+ const existing = db.getProjectChange(id);
467
+ if (!existing)
468
+ return res.status(404).json({ error: 'Change entry not found' });
469
+ db.updateProjectChange(id, short_description, long_description || '', tags || [], approx_files_affected);
470
+ change = db.getProjectChange(id);
471
+ }
472
+ else {
473
+ // Create new
474
+ change = db.createProjectChange(project.id, short_description, long_description || '', tags || [], approx_files_affected, session_uuid || null);
475
+ }
476
+ // Broadcast to UI
477
+ const msg = JSON.stringify({ type: 'project_changes_updated', projectId: project.id });
478
+ for (const ws of store.statusClients) {
479
+ if (ws.readyState === 1)
480
+ ws.send(msg);
481
+ }
482
+ return res.json({ change });
483
+ }
484
+ else if (mode === 'delete') {
485
+ if (!id)
486
+ return res.status(400).json({ error: 'id is required for delete mode' });
487
+ const deleted = db.deleteProjectChange(id);
488
+ if (!deleted)
489
+ return res.status(404).json({ error: 'Change entry not found' });
490
+ // Broadcast to UI
491
+ const msg = JSON.stringify({ type: 'project_changes_updated', projectId: project.id });
492
+ for (const ws of store.statusClients) {
493
+ if (ws.readyState === 1)
494
+ ws.send(msg);
495
+ }
496
+ return res.json({ message: 'Change deleted' });
497
+ }
498
+ else {
499
+ return res.status(400).json({ error: `Unknown mode: ${mode}` });
500
+ }
501
+ }
502
+ catch (err) {
503
+ res.status(500).json({ error: err.message });
504
+ }
505
+ });
506
+ // ═══════════════════════════════════════════════════════
507
+ // Auth middleware — protects all routes below this point
508
+ // Routes above (CA certs, auth, MCP callbacks) are exempt
509
+ // ═══════════════════════════════════════════════════════
510
+ app.use('/api', requireAuth);
511
+ // List CLI environments (runtime) — no longer applicable without MITM proxy
512
+ // ── Terminal file attachment ──────────────────────────────────────────
513
+ // Receives a file (base64) from the browser, writes it to a temp dir,
514
+ // returns the path so the frontend can inject it into the PTY.
515
+ const ATTACHMENT_BASE = path.join(os.tmpdir(), 'atoo');
516
+ app.post('/api/terminal/attachment', requireAuth, (req, res) => {
517
+ try {
518
+ const { filename, data } = req.body;
519
+ if (!filename || !data) {
520
+ res.status(400).json({ error: 'filename and data (base64) are required' });
521
+ return;
522
+ }
523
+ // Sanitize filename: strip path separators, limit length
524
+ const safeName = path.basename(filename).slice(0, 200) || 'attachment';
525
+ const dirId = `attachment_${uuidv4()}`;
526
+ const dir = path.join(ATTACHMENT_BASE, dirId);
527
+ fs.mkdirSync(dir, { recursive: true });
528
+ const filePath = path.join(dir, safeName);
529
+ fs.writeFileSync(filePath, Buffer.from(data, 'base64'));
530
+ res.json({ path: filePath });
531
+ }
532
+ catch (err) {
533
+ console.error('[attachment] Failed to write:', err);
534
+ res.status(500).json({ error: err.message });
535
+ }
536
+ });
537
+ // Cleanup old attachments on startup (older than 1 hour)
538
+ try {
539
+ if (fs.existsSync(ATTACHMENT_BASE)) {
540
+ const cutoff = Date.now() - 3600_000;
541
+ for (const entry of fs.readdirSync(ATTACHMENT_BASE)) {
542
+ const full = path.join(ATTACHMENT_BASE, entry);
543
+ try {
544
+ if (fs.statSync(full).mtimeMs < cutoff)
545
+ fs.rmSync(full, { recursive: true, force: true });
546
+ }
547
+ catch { }
548
+ }
549
+ }
550
+ }
551
+ catch { }
552
+ app.get('/api/cli-environments', (_req, res) => {
553
+ res.json([]);
554
+ });
555
+ // Browse directories for folder picker
556
+ app.get('/api/browse', (req, res) => {
557
+ const dir = req.query.path || os.homedir();
558
+ try {
559
+ const resolved = path.resolve(dir);
560
+ const entries = fs.readdirSync(resolved, { withFileTypes: true });
561
+ const dirs = entries
562
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.'))
563
+ .map((e) => ({
564
+ name: e.name,
565
+ path: path.join(resolved, e.name),
566
+ }))
567
+ .sort((a, b) => a.name.localeCompare(b.name));
568
+ res.json({ current: resolved, parent: path.dirname(resolved), dirs });
569
+ }
570
+ catch (err) {
571
+ res.status(400).json({ error: err.message });
572
+ }
573
+ });
574
+ app.post('/api/browse/mkdir', (req, res) => {
575
+ const { path: dirPath } = req.body;
576
+ if (!dirPath)
577
+ return res.status(400).json({ error: 'path is required' });
578
+ try {
579
+ const resolved = path.resolve(dirPath);
580
+ fs.mkdirSync(resolved, { recursive: true });
581
+ res.json({ success: true, path: resolved });
582
+ }
583
+ catch (err) {
584
+ res.status(400).json({ error: err.message });
585
+ }
586
+ });
587
+ // Extract text from office documents (docx, xlsx, pptx)
588
+ app.post('/api/extract-text', async (req, res) => {
589
+ const { data, name } = req.body;
590
+ if (!data || !name)
591
+ return res.status(400).json({ error: 'data and name are required' });
592
+ const ext = name.split('.').pop()?.toLowerCase();
593
+ const buf = Buffer.from(data, 'base64');
594
+ try {
595
+ let text = '';
596
+ if (ext === 'docx') {
597
+ const mammoth = await import('mammoth');
598
+ const result = await mammoth.default.extractRawText({ buffer: buf });
599
+ text = result.value;
600
+ }
601
+ else if (ext === 'xlsx' || ext === 'xls') {
602
+ const ExcelJS = await import('exceljs');
603
+ const workbook = new ExcelJS.default.Workbook();
604
+ await workbook.xlsx.load(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength));
605
+ const sheets = [];
606
+ workbook.eachSheet((sheet) => {
607
+ const rows = [];
608
+ sheet.eachRow((row) => {
609
+ const values = row.values.slice(1); // ExcelJS row.values is 1-indexed
610
+ rows.push(values.map(v => v ?? '').join(','));
611
+ });
612
+ sheets.push(`--- Sheet: ${sheet.name} ---\n${rows.join('\n')}`);
613
+ });
614
+ text = sheets.join('\n\n');
615
+ }
616
+ else if (ext === 'pptx') {
617
+ // pptx is a zip of XML slides — extract text from slide XML files
618
+ const { Readable } = await import('stream');
619
+ const unzipper = await import('unzipper');
620
+ const slides = [];
621
+ const stream = Readable.from(buf);
622
+ const directory = await stream.pipe(unzipper.default.Parse());
623
+ for await (const entry of directory) {
624
+ const entryPath = entry.path;
625
+ if (entryPath.match(/^ppt\/slides\/slide\d+\.xml$/)) {
626
+ const xml = (await entry.buffer()).toString('utf-8');
627
+ // Extract text from <a:t> tags
628
+ const texts = [];
629
+ const re = /<a:t[^>]*>([\s\S]*?)<\/a:t>/g;
630
+ let m;
631
+ while ((m = re.exec(xml)) !== null)
632
+ texts.push(m[1]);
633
+ const slideNum = parseInt(entryPath.match(/slide(\d+)/)?.[1] || '0');
634
+ if (texts.length)
635
+ slides.push({ num: slideNum, text: texts.join(' ') });
636
+ }
637
+ else {
638
+ entry.autodrain();
639
+ }
640
+ }
641
+ slides.sort((a, b) => a.num - b.num);
642
+ text = slides.map(s => `--- Slide ${s.num} ---\n${s.text}`).join('\n\n');
643
+ }
644
+ else {
645
+ return res.status(400).json({ error: `Unsupported file type: .${ext}` });
646
+ }
647
+ res.json({ text });
648
+ }
649
+ catch (err) {
650
+ console.error(`[web] extract-text error for ${name}:`, err.message);
651
+ res.status(500).json({ error: err.message });
652
+ }
653
+ });
654
+ // Broadcast dismiss_modal to all connected browsers so they close stale popups
655
+ function broadcastDismissModal(requestId) {
656
+ const msg = JSON.stringify({ type: 'dismiss_modal', requestId });
657
+ for (const ws of store.statusClients) {
658
+ if (ws.readyState === 1)
659
+ ws.send(msg);
660
+ }
661
+ }
662
+ // Frontend callbacks for MCP-initiated user prompts (serial, session switch, open file)
663
+ app.post('/api/reject-serial', (req, res) => {
664
+ const { requestId } = req.body;
665
+ if (!requestId)
666
+ return res.status(400).json({ error: 'requestId required' });
667
+ serialManager.rejectRequest(requestId, new Error('User rejected the serial device request'));
668
+ serialManager.closeRequest(requestId);
669
+ broadcastDismissModal(requestId);
670
+ res.json({ success: true });
671
+ });
672
+ app.post('/api/respond-session-switch', (req, res) => {
673
+ const { requestId, action } = req.body;
674
+ if (!requestId || !action) {
675
+ return res.status(400).json({ error: 'requestId and action are required' });
676
+ }
677
+ const pending = pendingSessionSwitches.get(requestId);
678
+ if (!pending) {
679
+ return res.status(404).json({ error: 'No pending request with this ID' });
680
+ }
681
+ clearTimeout(pending.timeout);
682
+ pendingSessionSwitches.delete(requestId);
683
+ pending.resolve({ action });
684
+ broadcastDismissModal(requestId);
685
+ res.json({ success: true });
686
+ });
687
+ app.post('/api/respond-open-file', (req, res) => {
688
+ const { requestId, action } = req.body;
689
+ if (!requestId || !action) {
690
+ return res.status(400).json({ error: 'requestId and action are required' });
691
+ }
692
+ const pending = pendingOpenFiles.get(requestId);
693
+ if (!pending) {
694
+ return res.status(404).json({ error: 'No pending request with this ID' });
695
+ }
696
+ clearTimeout(pending.timeout);
697
+ pendingOpenFiles.delete(requestId);
698
+ pending.resolve({ action });
699
+ broadcastDismissModal(requestId);
700
+ res.json({ success: true });
701
+ });
702
+ // Mount changes API routes
703
+ app.use(changesRouter);
704
+ // Project changes ("what has been done") — REST API for frontend
705
+ app.get('/api/projects/:id/changes', (req, res) => {
706
+ const changes = db.listProjectChanges(req.params.id);
707
+ res.json({ changes });
708
+ });
709
+ app.delete('/api/projects/:id/changes/:changeId', (req, res) => {
710
+ const deleted = db.deleteProjectChange(req.params.changeId);
711
+ if (!deleted)
712
+ return res.status(404).json({ error: 'Not found' });
713
+ res.json({ success: true });
714
+ });
715
+ app.delete('/api/projects/:id/changes', (req, res) => {
716
+ const count = db.deleteAllProjectChanges(req.params.id);
717
+ res.json({ deleted: count });
718
+ });
719
+ // Mount project/file/git API routes
720
+ app.use(projectsRouter);
721
+ // Mount environment API routes
722
+ app.use(environmentsRouter);
723
+ // Mount SSH API routes
724
+ app.use(sshRouter);
725
+ // Mount GitHub integration API routes
726
+ app.use(githubRouter);
727
+ // Mount container management API routes
728
+ app.use(containersRouter);
729
+ // Mount database explorer API routes
730
+ app.use(databasesRouter);
731
+ // Mount DevTools proxy
732
+ app.use(devtoolsProxyMiddleware());
733
+ // Preview: upload files for file chooser interception
734
+ app.post('/api/preview/:projectId/:tabId/upload', (req, res) => {
735
+ const { projectId, tabId } = req.params;
736
+ const { files, backendNodeId } = req.body;
737
+ // files = [{ name: string, data: string (base64) }]
738
+ if (!Array.isArray(files) || !backendNodeId) {
739
+ return res.status(400).json({ error: 'files array and backendNodeId required' });
740
+ }
741
+ const instance = previewManager.get(decodeURIComponent(projectId), decodeURIComponent(tabId));
742
+ if (!instance)
743
+ return res.status(404).json({ error: 'Preview instance not found' });
744
+ const tmpDir = path.join(os.tmpdir(), 'atoo-studio-uploads', `${projectId}_${tabId}`);
745
+ fs.mkdirSync(tmpDir, { recursive: true });
746
+ const filePaths = [];
747
+ for (const f of files) {
748
+ const filePath = path.join(tmpDir, f.name);
749
+ fs.writeFileSync(filePath, Buffer.from(f.data, 'base64'));
750
+ filePaths.push(filePath);
751
+ }
752
+ previewManager.handleFileChooserResponse(decodeURIComponent(projectId), decodeURIComponent(tabId), backendNodeId, filePaths).then(() => res.json({ success: true }))
753
+ .catch((err) => res.status(500).json({ error: err.message }));
754
+ });
755
+ // Preview: download intercepted file
756
+ app.get('/api/preview/:projectId/:tabId/download/:guid', (req, res) => {
757
+ const { projectId, tabId, guid } = req.params;
758
+ const filePath = previewManager.getDownloadPath(decodeURIComponent(projectId), decodeURIComponent(tabId), guid);
759
+ if (!filePath)
760
+ return res.status(404).json({ error: 'Download not found' });
761
+ res.download(filePath);
762
+ });
763
+ // List running shell terminals
764
+ app.get('/api/terminals', (_req, res) => {
765
+ const terminals = [];
766
+ for (const [id, entry] of shellTerminals) {
767
+ terminals.push({ id, cwd: entry.cwd, projectPath: entry.projectPath, pid: entry.pty.pid });
768
+ }
769
+ res.json(terminals);
770
+ });
771
+ // Spawn a standalone shell terminal
772
+ app.post('/api/terminals', (req, res) => {
773
+ const { cwd } = req.body || {};
774
+ const shell = process.env.SHELL || '/bin/bash';
775
+ const termCwd = cwd || os.homedir();
776
+ const id = uuidv4();
777
+ try {
778
+ const term = pty.spawn(shell, [], {
779
+ name: 'xterm-256color',
780
+ cols: 120,
781
+ rows: 30,
782
+ cwd: termCwd,
783
+ env: { ...process.env, TERM: 'xterm-256color' },
784
+ });
785
+ shellTerminals.set(id, { pty: term, cwd: termCwd, projectPath: termCwd });
786
+ // Broadcast terminal_created to all status clients (cross-browser sync)
787
+ const createdMsg = JSON.stringify({ type: 'terminal_created', terminal: { id, cwd: termCwd, projectPath: termCwd, pid: term.pid } });
788
+ for (const ws of store.statusClients) {
789
+ if (ws.readyState === 1)
790
+ ws.send(createdMsg);
791
+ }
792
+ term.onExit(() => {
793
+ shellTerminals.delete(id);
794
+ // Broadcast terminal_exited to all status clients
795
+ const exitMsg = JSON.stringify({ type: 'terminal_exited', terminal: { id } });
796
+ for (const ws of store.statusClients) {
797
+ if (ws.readyState === 1)
798
+ ws.send(exitMsg);
799
+ }
800
+ });
801
+ console.log(`[shell] Spawned standalone terminal ${id} (PID ${term.pid}) in ${termCwd}`);
802
+ res.json({ id, pid: term.pid });
803
+ }
804
+ catch (err) {
805
+ res.status(500).json({ error: err.message });
806
+ }
807
+ });
808
+ // Kill a shell terminal
809
+ app.delete('/api/terminals/:id', (req, res) => {
810
+ const entry = shellTerminals.get(req.params.id);
811
+ if (!entry)
812
+ return res.status(404).json({ error: 'Terminal not found' });
813
+ entry.pty.kill();
814
+ res.json({ success: true });
815
+ });
816
+ // Server LAN IP for nip.io reverse proxy URLs
817
+ app.get('/api/server-ip', (req, res) => {
818
+ // If the client connected via IP already, return that
819
+ const host = req.hostname;
820
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) {
821
+ return res.json({ ip: host });
822
+ }
823
+ // Otherwise find first non-internal IPv4 address
824
+ const nets = os.networkInterfaces();
825
+ for (const ifaces of Object.values(nets)) {
826
+ for (const iface of ifaces || []) {
827
+ if (iface.family === 'IPv4' && !iface.internal) {
828
+ return res.json({ ip: iface.address });
829
+ }
830
+ }
831
+ }
832
+ res.json({ ip: '127.0.0.1' });
833
+ });
834
+ // Status
835
+ app.get('/api/status', (_req, res) => {
836
+ res.json({
837
+ sessions: store.sessions.size,
838
+ });
839
+ });
840
+ // Dependencies check
841
+ app.get('/api/dependencies', (_req, res) => {
842
+ function getVersion(cmd) {
843
+ try {
844
+ return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }).trim();
845
+ }
846
+ catch {
847
+ return null;
848
+ }
849
+ }
850
+ function hasCommand(cmd) {
851
+ try {
852
+ execSync(`which ${cmd}`, { stdio: 'ignore', timeout: 3000 });
853
+ return true;
854
+ }
855
+ catch {
856
+ return false;
857
+ }
858
+ }
859
+ const platform = process.platform;
860
+ const deps = [];
861
+ // Required
862
+ deps.push({
863
+ name: 'git',
864
+ description: 'Version control, worktrees, file tracking',
865
+ installed: hasCommand('git'),
866
+ version: getVersion('git --version'),
867
+ required: true,
868
+ category: 'Core',
869
+ });
870
+ // Agent CLIs
871
+ deps.push({
872
+ name: 'claude',
873
+ description: 'Claude Code agent',
874
+ installed: hasCommand('claude'),
875
+ version: getVersion('claude --version'),
876
+ required: false,
877
+ category: 'Agents',
878
+ });
879
+ deps.push({
880
+ name: 'codex',
881
+ description: 'Codex agent',
882
+ installed: hasCommand('codex'),
883
+ version: getVersion('codex --version'),
884
+ required: false,
885
+ category: 'Agents',
886
+ });
887
+ // Integrations
888
+ deps.push({
889
+ name: 'gh',
890
+ description: 'GitHub integration (issues, PRs)',
891
+ installed: hasCommand('gh'),
892
+ version: getVersion('gh --version'),
893
+ required: false,
894
+ category: 'Integrations',
895
+ });
896
+ // Container runtimes
897
+ deps.push({
898
+ name: 'docker',
899
+ description: 'Docker container management',
900
+ installed: hasCommand('docker'),
901
+ version: getVersion('docker --version'),
902
+ required: false,
903
+ category: 'Containers',
904
+ });
905
+ deps.push({
906
+ name: 'podman',
907
+ description: 'Podman container management',
908
+ installed: hasCommand('podman'),
909
+ version: getVersion('podman --version'),
910
+ required: false,
911
+ category: 'Containers',
912
+ });
913
+ deps.push({
914
+ name: 'lxc',
915
+ description: 'LXC container management',
916
+ installed: hasCommand('lxc'),
917
+ version: getVersion('lxc --version'),
918
+ required: false,
919
+ category: 'Containers',
920
+ });
921
+ // Media
922
+ deps.push({
923
+ name: 'ffmpeg',
924
+ description: 'Screen recording',
925
+ installed: hasCommand('ffmpeg'),
926
+ version: getVersion('ffmpeg -version'),
927
+ required: false,
928
+ category: 'Media',
929
+ });
930
+ // Linux-specific
931
+ if (platform === 'linux') {
932
+ let chromeLibs = false;
933
+ try {
934
+ execSync('ldconfig -p | grep -q libatk-1.0', { stdio: 'ignore', timeout: 3000 });
935
+ chromeLibs = true;
936
+ }
937
+ catch { }
938
+ deps.push({
939
+ name: 'Chrome libs',
940
+ description: 'Browser preview (Puppeteer dependencies)',
941
+ installed: chromeLibs,
942
+ version: null,
943
+ required: false,
944
+ category: 'Preview',
945
+ });
946
+ const cusebin = path.join(process.env.HOME || '', '.atoo-studio', 'bin', 'cuse_serial');
947
+ const cuseReady = fs.existsSync('/dev/cuse') && fs.existsSync(cusebin);
948
+ deps.push({
949
+ name: 'CUSE',
950
+ description: 'Serial control signals (DTR/RTS)',
951
+ installed: cuseReady,
952
+ version: null,
953
+ required: false,
954
+ category: 'Serial',
955
+ });
956
+ }
957
+ res.json({ platform, deps });
958
+ });
959
+ // ═══════════════════════════════════════════════════════
960
+ // Update check & self-update
961
+ // ═══════════════════════════════════════════════════════
962
+ // Cache the update check so we don't hammer the registry
963
+ let updateCache = null;
964
+ const UPDATE_CACHE_TTL = 10 * 60 * 1000; // 10 minutes
965
+ app.get('/api/check-update', async (_req, res) => {
966
+ try {
967
+ // Return cached result if fresh
968
+ if (updateCache && Date.now() - updateCache.checkedAt < UPDATE_CACHE_TTL) {
969
+ return res.json(updateCache);
970
+ }
971
+ const pkgPath = path.join(PROJECT_ROOT, 'package.json');
972
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
973
+ const currentVersion = pkg.version;
974
+ // Fetch latest version from npm registry
975
+ const controller = new AbortController();
976
+ const timeout = setTimeout(() => controller.abort(), 8000);
977
+ const resp = await fetch('https://registry.npmjs.org/atoo-studio/latest', { signal: controller.signal });
978
+ clearTimeout(timeout);
979
+ if (!resp.ok)
980
+ throw new Error(`npm registry returned ${resp.status}`);
981
+ const data = await resp.json();
982
+ const latestVersion = data.version;
983
+ // Simple semver comparison: split into parts and compare numerically
984
+ const parseSemver = (v) => v.replace(/^v/, '').split('.').map(Number);
985
+ const cur = parseSemver(currentVersion);
986
+ const lat = parseSemver(latestVersion);
987
+ const updateAvailable = lat[0] > cur[0]
988
+ || (lat[0] === cur[0] && lat[1] > cur[1])
989
+ || (lat[0] === cur[0] && lat[1] === cur[1] && lat[2] > cur[2]);
990
+ updateCache = { currentVersion, latestVersion, updateAvailable, checkedAt: Date.now() };
991
+ res.json(updateCache);
992
+ }
993
+ catch (err) {
994
+ // Still return current version even if registry check fails
995
+ try {
996
+ const pkgPath = path.join(PROJECT_ROOT, 'package.json');
997
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
998
+ res.json({ currentVersion: pkg.version, latestVersion: null, updateAvailable: false, error: err.message });
999
+ }
1000
+ catch {
1001
+ res.status(500).json({ error: 'Failed to check for updates' });
1002
+ }
1003
+ }
1004
+ });
1005
+ app.post('/api/update', async (_req, res) => {
1006
+ try {
1007
+ // Determine how atoo-studio was installed
1008
+ const binPath = process.argv[1] || '';
1009
+ const isGlobal = binPath.includes('/node_modules/.global') || binPath.includes('/lib/node_modules/');
1010
+ const npmCmd = isGlobal ? 'npm install -g atoo-studio@latest' : 'npx atoo-studio@latest';
1011
+ // Run the update
1012
+ const { exec } = await import('child_process');
1013
+ const child = exec('npm install -g atoo-studio@latest', { timeout: 120000 });
1014
+ let stdout = '';
1015
+ let stderr = '';
1016
+ child.stdout?.on('data', (d) => { stdout += d; });
1017
+ child.stderr?.on('data', (d) => { stderr += d; });
1018
+ child.on('close', (code) => {
1019
+ // Invalidate cache
1020
+ updateCache = null;
1021
+ if (code === 0) {
1022
+ res.json({ success: true, output: stdout.trim(), restartRequired: true });
1023
+ }
1024
+ else {
1025
+ res.status(500).json({ success: false, error: stderr.trim() || `Exit code ${code}` });
1026
+ }
1027
+ });
1028
+ child.on('error', (err) => {
1029
+ res.status(500).json({ success: false, error: err.message });
1030
+ });
1031
+ }
1032
+ catch (err) {
1033
+ res.status(500).json({ success: false, error: err.message });
1034
+ }
1035
+ });
1036
+ // ═══════════════════════════════════════════════════════
1037
+ // Agent Session Endpoints (abstract layer)
1038
+ // ═══════════════════════════════════════════════════════
1039
+ // Create a new agent session
1040
+ app.post('/api/agent-sessions', async (req, res) => {
1041
+ const { agentType, cwd, skipPermissions, message, linkedIssue, resumeSessionUuid, forkParentSessionId, forkAfterEventUuid, forkFromEventUuid } = req.body;
1042
+ const type = agentType || 'claude-code-terminal-chatro';
1043
+ const sessionId = `agent_${uuidv4()}`;
1044
+ try {
1045
+ // Resolve fork: delegate to the parent agent to produce a resumable JSONL
1046
+ let resolvedResumeUuid = resumeSessionUuid;
1047
+ if (forkParentSessionId && forkAfterEventUuid) {
1048
+ const parentAgent = agentRegistry.getAgent(forkParentSessionId);
1049
+ if (!parentAgent) {
1050
+ return res.status(404).json({ error: `Parent agent not found: ${forkParentSessionId}` });
1051
+ }
1052
+ const resumeUuid = parentAgent.forkToResumable(forkAfterEventUuid, forkFromEventUuid, cwd || parentAgent.getInfo().cwd);
1053
+ if (resumeUuid) {
1054
+ // Fork always writes Claude JSONL — convert if target is a different family
1055
+ const parentAgentType = parentAgent.getInfo().agentType;
1056
+ resolvedResumeUuid = await agentRegistry.convertForkForAgent(resumeUuid, parentAgentType, type, cwd || parentAgent.getInfo().cwd || '.');
1057
+ }
1058
+ else {
1059
+ return res.status(400).json({ error: 'Agent does not support forking or fork point not found' });
1060
+ }
1061
+ }
1062
+ const agent = await agentRegistry.createAgent(type, sessionId, {
1063
+ cwd: cwd || undefined,
1064
+ skipPermissions: !!skipPermissions,
1065
+ resumeSessionUuid: resolvedResumeUuid,
1066
+ initialMessage: message || undefined,
1067
+ });
1068
+ if (linkedIssue) {
1069
+ agentRegistry.setBrowserState(sessionId, { linkedIssue });
1070
+ }
1071
+ const info = agent.getInfo();
1072
+ if (linkedIssue)
1073
+ info.linkedIssue = linkedIssue;
1074
+ res.json(info);
1075
+ }
1076
+ catch (err) {
1077
+ console.error(`[web] Failed to create agent session:`, err.message);
1078
+ res.status(500).json({ error: err.message });
1079
+ }
1080
+ });
1081
+ // List agent sessions
1082
+ app.get('/api/agent-sessions', (_req, res) => {
1083
+ res.json(agentRegistry.listAgents());
1084
+ });
1085
+ // Update browser-side state for a session (cached in memory, not DB)
1086
+ app.patch('/api/agent-sessions/:id/browser-state', (req, res) => {
1087
+ const { id } = req.params;
1088
+ const state = req.body;
1089
+ agentRegistry.setBrowserState(id, state);
1090
+ res.json({ ok: true });
1091
+ });
1092
+ // Available agent types (for agent picker UI)
1093
+ app.get('/api/available-agents', (_req, res) => {
1094
+ res.json(agentRegistry.getAvailableAgents());
1095
+ });
1096
+ // Historical sessions from all agent implementations
1097
+ // Optional ?cwd= param to filter by project path (includes worktree-related paths)
1098
+ app.get('/api/historical-sessions', async (req, res) => {
1099
+ try {
1100
+ const cwd = req.query.cwd;
1101
+ const sessions = await agentRegistry.getHistoricalSessions(cwd);
1102
+ // Enrich with metadata from DB
1103
+ if (sessions.length) {
1104
+ const allMeta = db.getMetadataForSessions(sessions.map(s => s.id));
1105
+ for (const s of sessions) {
1106
+ const meta = allMeta[s.id];
1107
+ if (meta?.name)
1108
+ s.metaName = meta.name;
1109
+ if (meta?.tags?.length)
1110
+ s.tags = meta.tags;
1111
+ }
1112
+ }
1113
+ res.json(sessions);
1114
+ }
1115
+ catch (err) {
1116
+ res.status(500).json({ error: err.message });
1117
+ }
1118
+ });
1119
+ // Create a chain continuation from an existing session.
1120
+ // Accepts either:
1121
+ // - agentSessionId: an active agent session (agent_xxx) — reads events from live agent memory
1122
+ // - sessionUuid: a historical JSONL session UUID — reads events from disk
1123
+ app.post('/api/agent-sessions/chain', async (req, res) => {
1124
+ const { agentSessionId, sessionUuid, cwd, skipPermissions, agentType } = req.body;
1125
+ try {
1126
+ let newAgent;
1127
+ if (agentSessionId) {
1128
+ // Active agent: try reading events from memory first, fall back to disk
1129
+ const activeAgent = agentRegistry.getAgent(agentSessionId);
1130
+ if (!activeAgent) {
1131
+ return res.status(404).json({ error: `Active agent not found: ${agentSessionId}` });
1132
+ }
1133
+ const events = activeAgent.getEvents();
1134
+ const cliUuid = activeAgent.getCliSessionId?.();
1135
+ const parentId = cliUuid || agentSessionId;
1136
+ console.log(`[web] Chain request for ${agentSessionId}: events=${events.length}, cliUuid=${cliUuid || 'null'}, agentType=${activeAgent.getInfo().agentType}`);
1137
+ if (events.length > 0) {
1138
+ // In-memory events available — build chain directly
1139
+ newAgent = await agentRegistry.chainFromEvents(events, parentId, {
1140
+ cwd: cwd || undefined,
1141
+ skipPermissions: !!skipPermissions,
1142
+ agentType: agentType || undefined,
1143
+ });
1144
+ }
1145
+ else if (cliUuid) {
1146
+ // No in-memory events (e.g. terminal-only adapter) — read from JSONL on disk
1147
+ newAgent = await agentRegistry.chainAgent(cliUuid, {
1148
+ cwd: cwd || undefined,
1149
+ skipPermissions: !!skipPermissions,
1150
+ agentType: agentType || undefined,
1151
+ });
1152
+ }
1153
+ else {
1154
+ return res.status(400).json({
1155
+ error: 'Could not determine CLI session UUID. The session hook may not have fired — try sending a message first, then chain again.',
1156
+ });
1157
+ }
1158
+ // Destroy the old active agent — the chain link replaces it
1159
+ agentRegistry.destroyAgent(agentSessionId).catch(err => {
1160
+ console.warn(`[web] Failed to destroy old agent after chain:`, err.message);
1161
+ });
1162
+ }
1163
+ else if (sessionUuid) {
1164
+ // Historical session: read events from disk via factory
1165
+ newAgent = await agentRegistry.chainAgent(sessionUuid, {
1166
+ cwd: cwd || undefined,
1167
+ skipPermissions: !!skipPermissions,
1168
+ agentType: agentType || undefined,
1169
+ });
1170
+ }
1171
+ else {
1172
+ return res.status(400).json({ error: 'agentSessionId or sessionUuid is required' });
1173
+ }
1174
+ res.json(newAgent.getInfo());
1175
+ }
1176
+ catch (err) {
1177
+ console.error(`[web] Failed to create chain session:`, err.message);
1178
+ res.status(500).json({ error: err.message });
1179
+ }
1180
+ });
1181
+ // Resume a historical session — optionally with a specific agent type
1182
+ app.post('/api/agent-sessions/resume', async (req, res) => {
1183
+ const { sessionUuid, cwd, skipPermissions, agentType } = req.body;
1184
+ if (!sessionUuid) {
1185
+ return res.status(400).json({ error: 'sessionUuid is required' });
1186
+ }
1187
+ try {
1188
+ const agent = await agentRegistry.resumeAgent(sessionUuid, {
1189
+ cwd: cwd || undefined,
1190
+ skipPermissions: !!skipPermissions,
1191
+ agentType: agentType || undefined,
1192
+ });
1193
+ res.json(agent.getInfo());
1194
+ }
1195
+ catch (err) {
1196
+ console.error(`[web] Failed to resume session:`, err.message);
1197
+ res.status(500).json({ error: err.message });
1198
+ }
1199
+ });
1200
+ // Destroy an agent session
1201
+ app.delete('/api/agent-sessions/:sessionId', async (req, res) => {
1202
+ const { sessionId } = req.params;
1203
+ try {
1204
+ await agentRegistry.destroyAgent(sessionId);
1205
+ res.json({ success: true });
1206
+ }
1207
+ catch (err) {
1208
+ res.status(500).json({ error: err.message });
1209
+ }
1210
+ });
1211
+ // Serve frontend static files (production)
1212
+ const projectRoot = PROJECT_ROOT;
1213
+ const frontendDist = path.join(projectRoot, 'frontend', 'dist');
1214
+ app.use(express.static(frontendDist));
1215
+ app.get('{*path}', (req, res, next) => {
1216
+ if (req.path.startsWith('/api/') || req.path.startsWith('/ws/'))
1217
+ return next();
1218
+ res.sendFile(path.join(frontendDist, 'index.html'), (err) => {
1219
+ if (err) {
1220
+ res.status(200).send(`
1221
+ <html>
1222
+ <body style="font-family:monospace;padding:2em;background:#1a1a2e;color:#e0e0e0">
1223
+ <h1>Atoo Studio</h1>
1224
+ <p>Frontend not built yet. Run <code>cd frontend && npm run build</code></p>
1225
+ <p>Or use the API directly:</p>
1226
+ <ul>
1227
+ <li>GET <a href="/api/status">/api/status</a></li>
1228
+ <li>GET <a href="/api/cli-environments">/api/cli-environments</a></li>
1229
+ <li>GET <a href="/api/agent-sessions">/api/agent-sessions</a></li>
1230
+ </ul>
1231
+ </body>
1232
+ </html>
1233
+ `);
1234
+ }
1235
+ });
1236
+ });
1237
+ const server = tlsOptions
1238
+ ? https.createServer(tlsOptions, app)
1239
+ : http.createServer(app);
1240
+ // Broadcast file change events to session subscribers
1241
+ fsMonitor.onChangeEvent((change) => {
1242
+ const event = {
1243
+ type: 'file_change',
1244
+ change: {
1245
+ change_id: change.changeId,
1246
+ session_id: change.sessionId,
1247
+ timestamp: change.timestamp,
1248
+ pid: change.pid,
1249
+ operation: change.operation,
1250
+ path: change.path,
1251
+ old_path: change.oldPath || null,
1252
+ before_hash: change.beforeHash,
1253
+ after_hash: change.afterHash,
1254
+ file_size: change.fileSize,
1255
+ is_binary: change.isBinary,
1256
+ },
1257
+ };
1258
+ // Legacy: store.broadcastToSubscribers(change.sessionId, event);
1259
+ });
1260
+ // Settings WS clients for real-time sync across browser tabs
1261
+ const settingsClients = new Set();
1262
+ setBroadcastSettingsChange((scope, key, settings, excludeWs) => {
1263
+ const msg = JSON.stringify({ type: 'settings_change', scope, key, settings });
1264
+ for (const ws of settingsClients) {
1265
+ if (ws !== excludeWs && ws.readyState === 1) {
1266
+ ws.send(msg);
1267
+ }
1268
+ }
1269
+ });
1270
+ // WebSocket for frontend live updates
1271
+ const wss = new WebSocketServer({ noServer: true });
1272
+ server.on('upgrade', (req, socket, head) => {
1273
+ // Port proxy WebSocket upgrades — check first
1274
+ if (isPortProxyUpgrade(req)) {
1275
+ handlePortProxyUpgrade(portProxy, req, socket, head);
1276
+ return;
1277
+ }
1278
+ // Authenticate WebSocket upgrades when auth is enabled
1279
+ if (isAuthEnabled()) {
1280
+ const user = authenticateWsUpgrade(req);
1281
+ if (!user) {
1282
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
1283
+ socket.destroy();
1284
+ return;
1285
+ }
1286
+ req.user = user;
1287
+ }
1288
+ const url = req.url || '';
1289
+ // /ws/agent/:sessionId — abstract agent WebSocket (new)
1290
+ if (isAgentWsUpgrade(url)) {
1291
+ handleAgentWsUpgrade(wss, req, socket, head);
1292
+ return;
1293
+ }
1294
+ // /ws/preview/:projectId/:tabId — remote browser streaming
1295
+ if (isPreviewWsUpgrade(url)) {
1296
+ handlePreviewWsUpgrade(wss, req, socket, head);
1297
+ return;
1298
+ }
1299
+ // /ws/devtools/:projectId/:tabId/* — DevTools WebSocket proxy
1300
+ if (isDevtoolsWsUpgrade(url)) {
1301
+ handleDevtoolsWsUpgrade(wss, req, socket, head);
1302
+ return;
1303
+ }
1304
+ if (url.startsWith('/ws/status')) {
1305
+ // Global agent status stream for sidebar
1306
+ wss.handleUpgrade(req, socket, head, (ws) => {
1307
+ store.statusClients.add(ws);
1308
+ // Send current statuses
1309
+ for (const [sid, status] of store.agentStatuses.entries()) {
1310
+ ws.send(JSON.stringify({ type: 'agent_status', status, session_id: sid }));
1311
+ }
1312
+ // Send current context usage
1313
+ for (const [sid, usage] of store.contextUsages.entries()) {
1314
+ ws.send(JSON.stringify({ type: 'context_usage', session_id: sid, ...usage }));
1315
+ }
1316
+ // Send current context-in-progress state
1317
+ for (const sid of store.contextInProgressSessions) {
1318
+ ws.send(JSON.stringify({ type: 'context_in_progress', session_id: sid, inProgress: true }));
1319
+ }
1320
+ ws.on('message', (data) => {
1321
+ try {
1322
+ const msg = JSON.parse(data.toString());
1323
+ if (msg.type === 'session_focus' && msg.session_id) {
1324
+ agentRegistry.setSessionFocused(msg.session_id);
1325
+ }
1326
+ else if (msg.type === 'session_blur' && msg.session_id) {
1327
+ agentRegistry.setSessionBlurred(msg.session_id);
1328
+ }
1329
+ }
1330
+ catch { }
1331
+ });
1332
+ ws.on('close', () => store.statusClients.delete(ws));
1333
+ });
1334
+ }
1335
+ else if (url.startsWith('/ws/settings')) {
1336
+ // Settings sync across browser tabs
1337
+ wss.handleUpgrade(req, socket, head, (ws) => {
1338
+ settingsClients.add(ws);
1339
+ ws.on('close', () => settingsClients.delete(ws));
1340
+ });
1341
+ }
1342
+ else {
1343
+ // /ws/terminal/:sessionId — pty I/O for browser terminal
1344
+ const termMatch = url.match(/^\/ws\/terminal\/([^/?]+)/);
1345
+ if (termMatch) {
1346
+ const sessionId = termMatch[1];
1347
+ // Resolve agent session IDs to the underlying CLI envId
1348
+ let envId = getEnvIdForSession(sessionId);
1349
+ if (!envId) {
1350
+ // Try agent registry — agent sessions use agent_* IDs
1351
+ const agent = agentRegistry.getAgent(sessionId);
1352
+ if (agent) {
1353
+ envId = agent.envId;
1354
+ }
1355
+ }
1356
+ if (!envId) {
1357
+ socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
1358
+ socket.destroy();
1359
+ return;
1360
+ }
1361
+ const ptyProcess = getPty(envId);
1362
+ if (!ptyProcess) {
1363
+ socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
1364
+ socket.destroy();
1365
+ return;
1366
+ }
1367
+ wss.handleUpgrade(req, socket, head, (ws) => {
1368
+ console.log(`[ws:terminal] Browser connected for session ${sessionId}`);
1369
+ // Keep-alive pings to prevent idle timeouts
1370
+ const pingInterval = setInterval(() => {
1371
+ if (ws.readyState === 1)
1372
+ ws.ping();
1373
+ }, 30000);
1374
+ // Get or create broadcast entry for this session's terminal
1375
+ if (!terminalClients.has(sessionId)) {
1376
+ // Seed scrollback from spawner's buffer (captures PTY output since spawn)
1377
+ const spawnerBuf = envId ? getScrollback(envId) : '';
1378
+ terminalClients.set(sessionId, { clients: new Set(), handler: null, scrollback: spawnerBuf });
1379
+ }
1380
+ const entry = terminalClients.get(sessionId);
1381
+ entry.clients.add(ws);
1382
+ // Replay scrollback buffer to this late-joining client
1383
+ if (entry.scrollback) {
1384
+ ws.send(JSON.stringify({ type: 'output', data: entry.scrollback }));
1385
+ }
1386
+ // Register single shared onData handler if first client.
1387
+ // PTY output is coalesced into ~16ms frames to avoid flicker:
1388
+ // TUI apps (ink, etc.) redraw by emitting clear + new content as
1389
+ // separate write() calls. Without buffering, each chunk becomes
1390
+ // its own WebSocket message and xterm may render an intermediate
1391
+ // "cleared" frame, causing visible scroll-up-then-down flicker.
1392
+ if (!entry.handler) {
1393
+ let pendingData = '';
1394
+ let flushTimer = null;
1395
+ const flushToClients = () => {
1396
+ flushTimer = null;
1397
+ if (!pendingData)
1398
+ return;
1399
+ const msg = JSON.stringify({ type: 'output', data: pendingData });
1400
+ pendingData = '';
1401
+ for (const client of entry.clients) {
1402
+ if (client.readyState === 1) {
1403
+ client.send(msg);
1404
+ }
1405
+ }
1406
+ };
1407
+ entry.handler = ptyProcess.onData((data) => {
1408
+ // Append to scrollback buffer (ring-trim if too large)
1409
+ entry.scrollback += data;
1410
+ if (entry.scrollback.length > MAX_SCROLLBACK) {
1411
+ entry.scrollback = entry.scrollback.slice(-MAX_SCROLLBACK);
1412
+ }
1413
+ pendingData += data;
1414
+ if (!flushTimer) {
1415
+ flushTimer = setTimeout(flushToClients, 16);
1416
+ }
1417
+ });
1418
+ }
1419
+ // Receive input/resize from browser
1420
+ ws.on('message', (raw) => {
1421
+ try {
1422
+ const msg = JSON.parse(raw.toString());
1423
+ if (msg.type === 'input' && typeof msg.data === 'string') {
1424
+ ptyProcess.write(msg.data);
1425
+ }
1426
+ else if (msg.type === 'resize' && msg.rows) {
1427
+ // Keep cols fixed at 120 for agent PTYs — only resize rows.
1428
+ // This ensures consistent output formatting regardless of
1429
+ // browser window size.
1430
+ ptyProcess.resize(120, msg.rows);
1431
+ }
1432
+ }
1433
+ catch { }
1434
+ });
1435
+ ws.on('close', () => {
1436
+ console.log(`[ws:terminal] Browser disconnected for session ${sessionId}`);
1437
+ clearInterval(pingInterval);
1438
+ entry.clients.delete(ws);
1439
+ // Keep handler alive so scrollback continues accumulating even with no browsers
1440
+ });
1441
+ });
1442
+ }
1443
+ else {
1444
+ // /ws/serial/:requestId — serial device passthrough
1445
+ const serialMatch = url.match(/^\/ws\/serial\/([^/?]+)/);
1446
+ if (serialMatch) {
1447
+ const requestId = serialMatch[1];
1448
+ const serialReq = serialManager.getRequest(requestId);
1449
+ if (!serialReq || serialReq.status !== 'pending') {
1450
+ socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
1451
+ socket.destroy();
1452
+ return;
1453
+ }
1454
+ wss.handleUpgrade(req, socket, head, (ws) => {
1455
+ console.log(`[ws:serial] Browser connected for request ${requestId}`);
1456
+ // Link browser WS and start data piping
1457
+ serialManager.connectBrowser(requestId, ws);
1458
+ // Browser → PTY: binary = serial data, text = control messages
1459
+ ws.on('message', (data, isBinary) => {
1460
+ if (isBinary) {
1461
+ serialManager.handleBrowserData(requestId, Buffer.from(data));
1462
+ }
1463
+ else {
1464
+ try {
1465
+ const msg = JSON.parse(data.toString());
1466
+ serialManager.handleBrowserControl(requestId, msg);
1467
+ }
1468
+ catch { }
1469
+ }
1470
+ });
1471
+ ws.on('close', () => {
1472
+ console.log(`[ws:serial] Browser disconnected for request ${requestId}`);
1473
+ serialManager.closeRequest(requestId);
1474
+ });
1475
+ });
1476
+ return;
1477
+ }
1478
+ // /ws/shell/:id — standalone shell terminal
1479
+ const shellMatch = url.match(/^\/ws\/shell\/([^/?]+)/);
1480
+ if (shellMatch) {
1481
+ const shellId = shellMatch[1];
1482
+ const shellEntry = shellTerminals.get(shellId);
1483
+ if (!shellEntry) {
1484
+ socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
1485
+ socket.destroy();
1486
+ return;
1487
+ }
1488
+ wss.handleUpgrade(req, socket, head, (ws) => {
1489
+ console.log(`[ws:shell] Browser connected for shell ${shellId}`);
1490
+ const ptyProcess = shellEntry.pty;
1491
+ // Keep-alive pings to prevent idle timeouts
1492
+ const pingInterval = setInterval(() => {
1493
+ if (ws.readyState === 1)
1494
+ ws.ping();
1495
+ }, 30000);
1496
+ // Get or create broadcast entry for this shell
1497
+ if (!shellClients.has(shellId)) {
1498
+ shellClients.set(shellId, { clients: new Set(), handler: null, scrollback: '' });
1499
+ }
1500
+ const entry = shellClients.get(shellId);
1501
+ entry.clients.add(ws);
1502
+ // Replay scrollback buffer to this late-joining client
1503
+ if (entry.scrollback) {
1504
+ ws.send(JSON.stringify({ type: 'output', data: entry.scrollback }));
1505
+ }
1506
+ // Register single shared onData handler if first client
1507
+ // (coalesced into ~16ms frames — same anti-flicker logic as terminal handler)
1508
+ if (!entry.handler) {
1509
+ let pendingData = '';
1510
+ let flushTimer = null;
1511
+ const flushToClients = () => {
1512
+ flushTimer = null;
1513
+ if (!pendingData)
1514
+ return;
1515
+ const msg = JSON.stringify({ type: 'output', data: pendingData });
1516
+ pendingData = '';
1517
+ for (const client of entry.clients) {
1518
+ if (client.readyState === 1) {
1519
+ client.send(msg);
1520
+ }
1521
+ }
1522
+ };
1523
+ entry.handler = ptyProcess.onData((data) => {
1524
+ entry.scrollback += data;
1525
+ if (entry.scrollback.length > MAX_SCROLLBACK) {
1526
+ entry.scrollback = entry.scrollback.slice(-MAX_SCROLLBACK);
1527
+ }
1528
+ pendingData += data;
1529
+ if (!flushTimer) {
1530
+ flushTimer = setTimeout(flushToClients, 16);
1531
+ }
1532
+ });
1533
+ }
1534
+ ws.on('message', (raw) => {
1535
+ try {
1536
+ const msg = JSON.parse(raw.toString());
1537
+ if (msg.type === 'input' && typeof msg.data === 'string') {
1538
+ ptyProcess.write(msg.data);
1539
+ }
1540
+ else if (msg.type === 'resize' && msg.cols && msg.rows) {
1541
+ ptyProcess.resize(msg.cols, msg.rows);
1542
+ }
1543
+ }
1544
+ catch { }
1545
+ });
1546
+ ws.on('close', () => {
1547
+ console.log(`[ws:shell] Browser disconnected for shell ${shellId}`);
1548
+ clearInterval(pingInterval);
1549
+ entry.clients.delete(ws);
1550
+ // Keep handler alive so scrollback continues accumulating even with no browsers
1551
+ });
1552
+ });
1553
+ }
1554
+ // /ws/database-query/:connectionId — stream query results
1555
+ if (isDatabaseWsUpgrade(url)) {
1556
+ handleDatabaseWsUpgrade(wss, req, socket, head);
1557
+ return;
1558
+ }
1559
+ // /ws/container-logs/:runtime/:containerId — stream container logs
1560
+ const logsMatch = url.match(/^\/ws\/container-logs\/([^/?]+)\/([^/?]+)/);
1561
+ if (logsMatch) {
1562
+ const [, runtime, containerId] = logsMatch;
1563
+ const runtimes = getContainerRuntimes();
1564
+ const validRuntimes = ['docker', 'podman', 'lxc'];
1565
+ if (!validRuntimes.includes(runtime) || !runtimes[runtime]?.accessible) {
1566
+ socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
1567
+ socket.destroy();
1568
+ return;
1569
+ }
1570
+ const idRegex = /^[a-zA-Z0-9][a-zA-Z0-9_.:/-]*$/;
1571
+ if (!idRegex.test(containerId) || containerId.length > 256) {
1572
+ socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
1573
+ socket.destroy();
1574
+ return;
1575
+ }
1576
+ wss.handleUpgrade(req, socket, head, (ws) => {
1577
+ console.log(`[ws:container-logs] Streaming logs for ${runtime}/${containerId}`);
1578
+ const pingInterval = setInterval(() => {
1579
+ if (ws.readyState === 1)
1580
+ ws.ping();
1581
+ }, 30000);
1582
+ let cmd;
1583
+ let args;
1584
+ if (runtime === 'lxc') {
1585
+ cmd = 'lxc';
1586
+ args = ['exec', containerId, '--', 'journalctl', '-f'];
1587
+ }
1588
+ else {
1589
+ cmd = runtime;
1590
+ args = ['logs', '-f', '--tail', '200', containerId];
1591
+ }
1592
+ const logPty = pty.spawn(cmd, args, {
1593
+ name: 'xterm-256color',
1594
+ cols: 200,
1595
+ rows: 50,
1596
+ });
1597
+ const handler = logPty.onData((data) => {
1598
+ if (ws.readyState === 1) {
1599
+ ws.send(JSON.stringify({ type: 'output', data }));
1600
+ }
1601
+ });
1602
+ logPty.onExit(() => {
1603
+ if (ws.readyState === 1) {
1604
+ ws.send(JSON.stringify({ type: 'exit' }));
1605
+ ws.close();
1606
+ }
1607
+ });
1608
+ ws.on('close', () => {
1609
+ console.log(`[ws:container-logs] Disconnected for ${runtime}/${containerId}`);
1610
+ clearInterval(pingInterval);
1611
+ handler.dispose();
1612
+ logPty.kill();
1613
+ });
1614
+ });
1615
+ return;
1616
+ }
1617
+ // /ws/container-shell/:runtime/:containerId — interactive shell
1618
+ const shellContainerMatch = url.match(/^\/ws\/container-shell\/([^/?]+)\/([^/?]+)/);
1619
+ if (shellContainerMatch) {
1620
+ const [, runtime, containerId] = shellContainerMatch;
1621
+ const runtimes = getContainerRuntimes();
1622
+ const validRuntimes = ['docker', 'podman', 'lxc'];
1623
+ if (!validRuntimes.includes(runtime) || !runtimes[runtime]?.accessible) {
1624
+ socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
1625
+ socket.destroy();
1626
+ return;
1627
+ }
1628
+ const idRegex = /^[a-zA-Z0-9][a-zA-Z0-9_.:/-]*$/;
1629
+ if (!idRegex.test(containerId) || containerId.length > 256) {
1630
+ socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
1631
+ socket.destroy();
1632
+ return;
1633
+ }
1634
+ wss.handleUpgrade(req, socket, head, (ws) => {
1635
+ console.log(`[ws:container-shell] Shell into ${runtime}/${containerId}`);
1636
+ const pingInterval = setInterval(() => {
1637
+ if (ws.readyState === 1)
1638
+ ws.ping();
1639
+ }, 30000);
1640
+ let cmd;
1641
+ let args;
1642
+ if (runtime === 'lxc') {
1643
+ cmd = 'lxc';
1644
+ args = ['exec', containerId, '--', '/bin/sh'];
1645
+ }
1646
+ else {
1647
+ cmd = runtime;
1648
+ args = ['exec', '-it', containerId, '/bin/sh'];
1649
+ }
1650
+ const shellPty = pty.spawn(cmd, args, {
1651
+ name: 'xterm-256color',
1652
+ cols: 80,
1653
+ rows: 24,
1654
+ });
1655
+ const handler = shellPty.onData((data) => {
1656
+ if (ws.readyState === 1) {
1657
+ ws.send(JSON.stringify({ type: 'output', data }));
1658
+ }
1659
+ });
1660
+ shellPty.onExit(() => {
1661
+ if (ws.readyState === 1) {
1662
+ ws.send(JSON.stringify({ type: 'exit' }));
1663
+ ws.close();
1664
+ }
1665
+ });
1666
+ ws.on('message', (raw) => {
1667
+ try {
1668
+ const msg = JSON.parse(raw.toString());
1669
+ if (msg.type === 'input' && typeof msg.data === 'string') {
1670
+ shellPty.write(msg.data);
1671
+ }
1672
+ else if (msg.type === 'resize' && msg.cols && msg.rows) {
1673
+ shellPty.resize(msg.cols, msg.rows);
1674
+ }
1675
+ }
1676
+ catch { }
1677
+ });
1678
+ ws.on('close', () => {
1679
+ console.log(`[ws:container-shell] Disconnected for ${runtime}/${containerId}`);
1680
+ clearInterval(pingInterval);
1681
+ handler.dispose();
1682
+ shellPty.kill();
1683
+ });
1684
+ });
1685
+ return;
1686
+ }
1687
+ if (!logsMatch && !shellContainerMatch && !shellMatch) {
1688
+ socket.destroy();
1689
+ }
1690
+ }
1691
+ }
1692
+ });
1693
+ return server;
1694
+ }