@pixelbyte-software/pixcode 1.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (336) hide show
  1. package/LICENSE +718 -0
  2. package/README.de.md +248 -0
  3. package/README.ja.md +240 -0
  4. package/README.ko.md +240 -0
  5. package/README.md +285 -0
  6. package/README.ru.md +248 -0
  7. package/README.tr.md +250 -0
  8. package/README.zh-CN.md +240 -0
  9. package/dist/api-docs.html +879 -0
  10. package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  11. package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  12. package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  13. package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  14. package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  15. package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  16. package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  17. package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  18. package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  19. package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  20. package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  21. package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  22. package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  23. package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  24. package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  25. package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  26. package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  27. package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  28. package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  29. package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  30. package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  31. package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  32. package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  33. package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  34. package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  35. package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  36. package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  37. package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  38. package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  39. package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  40. package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  41. package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  42. package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  43. package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  44. package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  45. package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  46. package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  47. package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  48. package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  49. package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  50. package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  51. package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  52. package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  53. package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  54. package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  55. package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  56. package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  57. package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  58. package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  59. package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  60. package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  61. package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  62. package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  63. package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  64. package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  65. package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  66. package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  67. package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  68. package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  69. package/dist/assets/index-C2c9QNwK.css +32 -0
  70. package/dist/assets/index-DyXDZED-.js +1277 -0
  71. package/dist/assets/vendor-codemirror-NA4v81it.js +41 -0
  72. package/dist/assets/vendor-react-D7WwDXvu.js +59 -0
  73. package/dist/assets/vendor-xterm-CJZjLICi.js +66 -0
  74. package/dist/clear-cache.html +85 -0
  75. package/dist/convert-icons.md +53 -0
  76. package/dist/favicon.png +0 -0
  77. package/dist/favicon.svg +9 -0
  78. package/dist/generate-icons.js +49 -0
  79. package/dist/icons/claude-ai-icon.svg +1 -0
  80. package/dist/icons/codex-white.svg +3 -0
  81. package/dist/icons/codex.svg +3 -0
  82. package/dist/icons/cursor-white.svg +12 -0
  83. package/dist/icons/cursor.svg +1 -0
  84. package/dist/icons/gemini-ai-icon.svg +1 -0
  85. package/dist/icons/icon-128x128.png +0 -0
  86. package/dist/icons/icon-128x128.svg +12 -0
  87. package/dist/icons/icon-144x144.png +0 -0
  88. package/dist/icons/icon-144x144.svg +12 -0
  89. package/dist/icons/icon-152x152.png +0 -0
  90. package/dist/icons/icon-152x152.svg +12 -0
  91. package/dist/icons/icon-192x192.png +0 -0
  92. package/dist/icons/icon-192x192.svg +12 -0
  93. package/dist/icons/icon-384x384.png +0 -0
  94. package/dist/icons/icon-384x384.svg +12 -0
  95. package/dist/icons/icon-512x512.png +0 -0
  96. package/dist/icons/icon-512x512.svg +12 -0
  97. package/dist/icons/icon-72x72.png +0 -0
  98. package/dist/icons/icon-72x72.svg +12 -0
  99. package/dist/icons/icon-96x96.png +0 -0
  100. package/dist/icons/icon-96x96.svg +12 -0
  101. package/dist/icons/icon-template.svg +12 -0
  102. package/dist/index.html +52 -0
  103. package/dist/logo-128.png +0 -0
  104. package/dist/logo-256.png +0 -0
  105. package/dist/logo-32.png +0 -0
  106. package/dist/logo-512.png +0 -0
  107. package/dist/logo-64.png +0 -0
  108. package/dist/logo.svg +17 -0
  109. package/dist/manifest.json +61 -0
  110. package/dist/screenshots/cli-selection.png +0 -0
  111. package/dist/screenshots/desktop-main.png +0 -0
  112. package/dist/screenshots/mobile-chat.png +0 -0
  113. package/dist/screenshots/tools-modal.png +0 -0
  114. package/dist/sw.js +124 -0
  115. package/dist-server/server/claude-sdk.js +709 -0
  116. package/dist-server/server/claude-sdk.js.map +1 -0
  117. package/dist-server/server/cli.js +854 -0
  118. package/dist-server/server/cli.js.map +1 -0
  119. package/dist-server/server/constants/config.js +6 -0
  120. package/dist-server/server/constants/config.js.map +1 -0
  121. package/dist-server/server/cursor-cli.js +277 -0
  122. package/dist-server/server/cursor-cli.js.map +1 -0
  123. package/dist-server/server/daemon/manager.js +486 -0
  124. package/dist-server/server/daemon/manager.js.map +1 -0
  125. package/dist-server/server/daemon-manager.js +823 -0
  126. package/dist-server/server/daemon-manager.js.map +1 -0
  127. package/dist-server/server/database/db.js +535 -0
  128. package/dist-server/server/database/db.js.map +1 -0
  129. package/dist-server/server/database/schema.js +97 -0
  130. package/dist-server/server/database/schema.js.map +1 -0
  131. package/dist-server/server/gemini-cli.js +408 -0
  132. package/dist-server/server/gemini-cli.js.map +1 -0
  133. package/dist-server/server/gemini-response-handler.js +72 -0
  134. package/dist-server/server/gemini-response-handler.js.map +1 -0
  135. package/dist-server/server/index.js +2325 -0
  136. package/dist-server/server/index.js.map +1 -0
  137. package/dist-server/server/load-env.js +32 -0
  138. package/dist-server/server/load-env.js.map +1 -0
  139. package/dist-server/server/middleware/auth.js +111 -0
  140. package/dist-server/server/middleware/auth.js.map +1 -0
  141. package/dist-server/server/modules/providers/list/claude/claude-auth.provider.js +103 -0
  142. package/dist-server/server/modules/providers/list/claude/claude-auth.provider.js.map +1 -0
  143. package/dist-server/server/modules/providers/list/claude/claude-mcp.provider.js +103 -0
  144. package/dist-server/server/modules/providers/list/claude/claude-mcp.provider.js.map +1 -0
  145. package/dist-server/server/modules/providers/list/claude/claude-sessions.provider.js +266 -0
  146. package/dist-server/server/modules/providers/list/claude/claude-sessions.provider.js.map +1 -0
  147. package/dist-server/server/modules/providers/list/claude/claude.provider.js +13 -0
  148. package/dist-server/server/modules/providers/list/claude/claude.provider.js.map +1 -0
  149. package/dist-server/server/modules/providers/list/codex/codex-auth.provider.js +84 -0
  150. package/dist-server/server/modules/providers/list/codex/codex-auth.provider.js.map +1 -0
  151. package/dist-server/server/modules/providers/list/codex/codex-mcp.provider.js +107 -0
  152. package/dist-server/server/modules/providers/list/codex/codex-mcp.provider.js.map +1 -0
  153. package/dist-server/server/modules/providers/list/codex/codex-sessions.provider.js +282 -0
  154. package/dist-server/server/modules/providers/list/codex/codex-sessions.provider.js.map +1 -0
  155. package/dist-server/server/modules/providers/list/codex/codex.provider.js +13 -0
  156. package/dist-server/server/modules/providers/list/codex/codex.provider.js.map +1 -0
  157. package/dist-server/server/modules/providers/list/cursor/cursor-auth.provider.js +118 -0
  158. package/dist-server/server/modules/providers/list/cursor/cursor-auth.provider.js.map +1 -0
  159. package/dist-server/server/modules/providers/list/cursor/cursor-mcp.provider.js +80 -0
  160. package/dist-server/server/modules/providers/list/cursor/cursor-mcp.provider.js.map +1 -0
  161. package/dist-server/server/modules/providers/list/cursor/cursor-sessions.provider.js +369 -0
  162. package/dist-server/server/modules/providers/list/cursor/cursor-sessions.provider.js.map +1 -0
  163. package/dist-server/server/modules/providers/list/cursor/cursor.provider.js +13 -0
  164. package/dist-server/server/modules/providers/list/cursor/cursor.provider.js.map +1 -0
  165. package/dist-server/server/modules/providers/list/gemini/gemini-auth.provider.js +131 -0
  166. package/dist-server/server/modules/providers/list/gemini/gemini-auth.provider.js.map +1 -0
  167. package/dist-server/server/modules/providers/list/gemini/gemini-mcp.provider.js +82 -0
  168. package/dist-server/server/modules/providers/list/gemini/gemini-mcp.provider.js.map +1 -0
  169. package/dist-server/server/modules/providers/list/gemini/gemini-sessions.provider.js +207 -0
  170. package/dist-server/server/modules/providers/list/gemini/gemini-sessions.provider.js.map +1 -0
  171. package/dist-server/server/modules/providers/list/gemini/gemini.provider.js +13 -0
  172. package/dist-server/server/modules/providers/list/gemini/gemini.provider.js.map +1 -0
  173. package/dist-server/server/modules/providers/provider.registry.js +31 -0
  174. package/dist-server/server/modules/providers/provider.registry.js.map +1 -0
  175. package/dist-server/server/modules/providers/provider.routes.js +159 -0
  176. package/dist-server/server/modules/providers/provider.routes.js.map +1 -0
  177. package/dist-server/server/modules/providers/services/mcp.service.js +69 -0
  178. package/dist-server/server/modules/providers/services/mcp.service.js.map +1 -0
  179. package/dist-server/server/modules/providers/services/provider-auth.service.js +25 -0
  180. package/dist-server/server/modules/providers/services/provider-auth.service.js.map +1 -0
  181. package/dist-server/server/modules/providers/services/sessions.service.js +29 -0
  182. package/dist-server/server/modules/providers/services/sessions.service.js.map +1 -0
  183. package/dist-server/server/modules/providers/shared/base/abstract.provider.js +14 -0
  184. package/dist-server/server/modules/providers/shared/base/abstract.provider.js.map +1 -0
  185. package/dist-server/server/modules/providers/shared/mcp/mcp.provider.js +102 -0
  186. package/dist-server/server/modules/providers/shared/mcp/mcp.provider.js.map +1 -0
  187. package/dist-server/server/modules/providers/tests/mcp.test.js +250 -0
  188. package/dist-server/server/modules/providers/tests/mcp.test.js.map +1 -0
  189. package/dist-server/server/openai-codex.js +373 -0
  190. package/dist-server/server/openai-codex.js.map +1 -0
  191. package/dist-server/server/projects.js +2492 -0
  192. package/dist-server/server/projects.js.map +1 -0
  193. package/dist-server/server/routes/agent.js +1147 -0
  194. package/dist-server/server/routes/agent.js.map +1 -0
  195. package/dist-server/server/routes/auth.js +117 -0
  196. package/dist-server/server/routes/auth.js.map +1 -0
  197. package/dist-server/server/routes/cli-auth.js +25 -0
  198. package/dist-server/server/routes/cli-auth.js.map +1 -0
  199. package/dist-server/server/routes/codex.js +18 -0
  200. package/dist-server/server/routes/codex.js.map +1 -0
  201. package/dist-server/server/routes/commands.js +487 -0
  202. package/dist-server/server/routes/commands.js.map +1 -0
  203. package/dist-server/server/routes/cursor.js +49 -0
  204. package/dist-server/server/routes/cursor.js.map +1 -0
  205. package/dist-server/server/routes/gemini.js +21 -0
  206. package/dist-server/server/routes/gemini.js.map +1 -0
  207. package/dist-server/server/routes/git.js +1259 -0
  208. package/dist-server/server/routes/git.js.map +1 -0
  209. package/dist-server/server/routes/mcp-utils.js +29 -0
  210. package/dist-server/server/routes/mcp-utils.js.map +1 -0
  211. package/dist-server/server/routes/messages.js +56 -0
  212. package/dist-server/server/routes/messages.js.map +1 -0
  213. package/dist-server/server/routes/plugins.js +266 -0
  214. package/dist-server/server/routes/plugins.js.map +1 -0
  215. package/dist-server/server/routes/projects.js +566 -0
  216. package/dist-server/server/routes/projects.js.map +1 -0
  217. package/dist-server/server/routes/settings.js +259 -0
  218. package/dist-server/server/routes/settings.js.map +1 -0
  219. package/dist-server/server/routes/taskmaster.js +1373 -0
  220. package/dist-server/server/routes/taskmaster.js.map +1 -0
  221. package/dist-server/server/routes/user.js +115 -0
  222. package/dist-server/server/routes/user.js.map +1 -0
  223. package/dist-server/server/services/notification-orchestrator.js +177 -0
  224. package/dist-server/server/services/notification-orchestrator.js.map +1 -0
  225. package/dist-server/server/services/vapid-keys.js +26 -0
  226. package/dist-server/server/services/vapid-keys.js.map +1 -0
  227. package/dist-server/server/sessionManager.js +194 -0
  228. package/dist-server/server/sessionManager.js.map +1 -0
  229. package/dist-server/server/shared/interfaces.js +2 -0
  230. package/dist-server/server/shared/interfaces.js.map +1 -0
  231. package/dist-server/server/shared/types.js +3 -0
  232. package/dist-server/server/shared/types.js.map +1 -0
  233. package/dist-server/server/shared/utils.js +150 -0
  234. package/dist-server/server/shared/utils.js.map +1 -0
  235. package/dist-server/server/utils/colors.js +20 -0
  236. package/dist-server/server/utils/colors.js.map +1 -0
  237. package/dist-server/server/utils/commandParser.js +255 -0
  238. package/dist-server/server/utils/commandParser.js.map +1 -0
  239. package/dist-server/server/utils/frontmatter.js +16 -0
  240. package/dist-server/server/utils/frontmatter.js.map +1 -0
  241. package/dist-server/server/utils/gitConfig.js +36 -0
  242. package/dist-server/server/utils/gitConfig.js.map +1 -0
  243. package/dist-server/server/utils/mcp-detector.js +134 -0
  244. package/dist-server/server/utils/mcp-detector.js.map +1 -0
  245. package/dist-server/server/utils/plugin-loader.js +413 -0
  246. package/dist-server/server/utils/plugin-loader.js.map +1 -0
  247. package/dist-server/server/utils/plugin-process-manager.js +163 -0
  248. package/dist-server/server/utils/plugin-process-manager.js.map +1 -0
  249. package/dist-server/server/utils/runtime-paths.js +30 -0
  250. package/dist-server/server/utils/runtime-paths.js.map +1 -0
  251. package/dist-server/server/utils/taskmaster-websocket.js +118 -0
  252. package/dist-server/server/utils/taskmaster-websocket.js.map +1 -0
  253. package/dist-server/server/utils/url-detection.js +58 -0
  254. package/dist-server/server/utils/url-detection.js.map +1 -0
  255. package/dist-server/server/vite-daemon.js +75 -0
  256. package/dist-server/server/vite-daemon.js.map +1 -0
  257. package/dist-server/shared/modelConstants.js +90 -0
  258. package/dist-server/shared/modelConstants.js.map +1 -0
  259. package/dist-server/shared/networkHosts.js +20 -0
  260. package/dist-server/shared/networkHosts.js.map +1 -0
  261. package/package.json +168 -0
  262. package/scripts/fix-node-pty.js +67 -0
  263. package/server/claude-sdk.js +834 -0
  264. package/server/cli.js +937 -0
  265. package/server/constants/config.js +5 -0
  266. package/server/cursor-cli.js +342 -0
  267. package/server/daemon/manager.js +564 -0
  268. package/server/daemon-manager.js +920 -0
  269. package/server/database/db.js +593 -0
  270. package/server/database/schema.js +102 -0
  271. package/server/gemini-cli.js +469 -0
  272. package/server/gemini-response-handler.js +79 -0
  273. package/server/index.js +2557 -0
  274. package/server/load-env.js +34 -0
  275. package/server/middleware/auth.js +132 -0
  276. package/server/modules/providers/list/claude/claude-auth.provider.ts +123 -0
  277. package/server/modules/providers/list/claude/claude-mcp.provider.ts +135 -0
  278. package/server/modules/providers/list/claude/claude-sessions.provider.ts +306 -0
  279. package/server/modules/providers/list/claude/claude.provider.ts +15 -0
  280. package/server/modules/providers/list/codex/codex-auth.provider.ts +100 -0
  281. package/server/modules/providers/list/codex/codex-mcp.provider.ts +135 -0
  282. package/server/modules/providers/list/codex/codex-sessions.provider.ts +319 -0
  283. package/server/modules/providers/list/codex/codex.provider.ts +15 -0
  284. package/server/modules/providers/list/cursor/cursor-auth.provider.ts +143 -0
  285. package/server/modules/providers/list/cursor/cursor-mcp.provider.ts +108 -0
  286. package/server/modules/providers/list/cursor/cursor-sessions.provider.ts +421 -0
  287. package/server/modules/providers/list/cursor/cursor.provider.ts +15 -0
  288. package/server/modules/providers/list/gemini/gemini-auth.provider.ts +151 -0
  289. package/server/modules/providers/list/gemini/gemini-mcp.provider.ts +110 -0
  290. package/server/modules/providers/list/gemini/gemini-sessions.provider.ts +227 -0
  291. package/server/modules/providers/list/gemini/gemini.provider.ts +15 -0
  292. package/server/modules/providers/provider.registry.ts +36 -0
  293. package/server/modules/providers/provider.routes.ts +217 -0
  294. package/server/modules/providers/services/mcp.service.ts +94 -0
  295. package/server/modules/providers/services/provider-auth.service.ts +26 -0
  296. package/server/modules/providers/services/sessions.service.ts +45 -0
  297. package/server/modules/providers/shared/base/abstract.provider.ts +20 -0
  298. package/server/modules/providers/shared/mcp/mcp.provider.ts +151 -0
  299. package/server/modules/providers/tests/mcp.test.ts +293 -0
  300. package/server/openai-codex.js +426 -0
  301. package/server/projects.js +2792 -0
  302. package/server/routes/agent.js +1245 -0
  303. package/server/routes/auth.js +135 -0
  304. package/server/routes/cli-auth.js +27 -0
  305. package/server/routes/codex.js +19 -0
  306. package/server/routes/commands.js +554 -0
  307. package/server/routes/cursor.js +52 -0
  308. package/server/routes/gemini.js +24 -0
  309. package/server/routes/git.js +1488 -0
  310. package/server/routes/mcp-utils.js +31 -0
  311. package/server/routes/messages.js +61 -0
  312. package/server/routes/plugins.js +307 -0
  313. package/server/routes/projects.js +627 -0
  314. package/server/routes/settings.js +286 -0
  315. package/server/routes/taskmaster.js +1471 -0
  316. package/server/routes/user.js +123 -0
  317. package/server/services/notification-orchestrator.js +227 -0
  318. package/server/services/vapid-keys.js +35 -0
  319. package/server/sessionManager.js +226 -0
  320. package/server/shared/interfaces.ts +54 -0
  321. package/server/shared/types.ts +172 -0
  322. package/server/shared/utils.ts +193 -0
  323. package/server/tsconfig.json +36 -0
  324. package/server/utils/colors.js +21 -0
  325. package/server/utils/commandParser.js +303 -0
  326. package/server/utils/frontmatter.js +18 -0
  327. package/server/utils/gitConfig.js +34 -0
  328. package/server/utils/mcp-detector.js +147 -0
  329. package/server/utils/plugin-loader.js +457 -0
  330. package/server/utils/plugin-process-manager.js +184 -0
  331. package/server/utils/runtime-paths.js +37 -0
  332. package/server/utils/taskmaster-websocket.js +129 -0
  333. package/server/utils/url-detection.js +71 -0
  334. package/server/vite-daemon.js +78 -0
  335. package/shared/modelConstants.js +97 -0
  336. package/shared/networkHosts.js +22 -0
@@ -0,0 +1,2557 @@
1
+ #!/usr/bin/env node
2
+ // Load environment variables before other imports execute
3
+ import './load-env.js';
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+ import { findAppRoot, getModuleDir } from './utils/runtime-paths.js';
7
+
8
+ import { AppError, createNormalizedMessage } from '@/shared/utils.js';
9
+
10
+
11
+ const __dirname = getModuleDir(import.meta.url);
12
+ // The server source runs from /server, while the compiled output runs from /dist-server/server.
13
+ // Resolving the app root once keeps every repo-level lookup below aligned across both layouts.
14
+ const APP_ROOT = findAppRoot(__dirname);
15
+ const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm';
16
+ const DAEMON_COMMAND_CONTEXT = {
17
+ appRoot: APP_ROOT,
18
+ cliEntry: path.join(APP_ROOT, 'server', 'cli.js'),
19
+ nodeExecPath: process.execPath,
20
+ };
21
+
22
+ import { c } from './utils/colors.js';
23
+
24
+ console.log('SERVER_PORT from env:', process.env.SERVER_PORT);
25
+
26
+ import express from 'express';
27
+ import { WebSocketServer, WebSocket } from 'ws';
28
+ import os from 'os';
29
+ import http from 'http';
30
+ import net from 'node:net';
31
+ import cors from 'cors';
32
+ import { promises as fsPromises } from 'fs';
33
+ import { spawn } from 'child_process';
34
+ import pty from 'node-pty';
35
+ import mime from 'mime-types';
36
+
37
+ import { getProjects, getSessions, renameProject, deleteSession, deleteProject, extractProjectDirectory, clearProjectDirectoryCache, searchConversations } from './projects.js';
38
+ import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval, getPendingApprovalsForSession, reconnectSessionWriter } from './claude-sdk.js';
39
+ import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
40
+ import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js';
41
+ import { spawnGemini, abortGeminiSession, isGeminiSessionActive, getActiveGeminiSessions } from './gemini-cli.js';
42
+ import sessionManager from './sessionManager.js';
43
+ import gitRoutes from './routes/git.js';
44
+ import authRoutes from './routes/auth.js';
45
+ import cursorRoutes from './routes/cursor.js';
46
+ import taskmasterRoutes from './routes/taskmaster.js';
47
+ import mcpUtilsRoutes from './routes/mcp-utils.js';
48
+ import commandsRoutes from './routes/commands.js';
49
+ import settingsRoutes from './routes/settings.js';
50
+ import agentRoutes from './routes/agent.js';
51
+ import projectsRoutes, {
52
+ WORKSPACES_ROOT,
53
+ WORKSPACES_BASE,
54
+ validateWorkspacePath,
55
+ normalizeWorkspacePath,
56
+ } from './routes/projects.js';
57
+ import cliAuthRoutes from './routes/cli-auth.js';
58
+ import userRoutes from './routes/user.js';
59
+ import codexRoutes from './routes/codex.js';
60
+ import geminiRoutes from './routes/gemini.js';
61
+ import pluginsRoutes from './routes/plugins.js';
62
+ import messagesRoutes from './routes/messages.js';
63
+ import providerRoutes from './modules/providers/provider.routes.js';
64
+ import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js';
65
+ import { initializeDatabase, sessionNamesDb, applyCustomSessionNames } from './database/db.js';
66
+ import { configureWebPush } from './services/vapid-keys.js';
67
+ import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
68
+ import { IS_PLATFORM } from './constants/config.js';
69
+ import { getConnectableHost } from '../shared/networkHosts.js';
70
+ import { buildDaemonCliCommand, handleDaemonCommand } from './daemon-manager.js';
71
+
72
+ const VALID_PROVIDERS = ['claude', 'codex', 'cursor', 'gemini'];
73
+
74
+ // File system watchers for provider project/session folders
75
+ const PROVIDER_WATCH_PATHS = [
76
+ { provider: 'claude', rootPath: path.join(os.homedir(), '.claude', 'projects') },
77
+ { provider: 'cursor', rootPath: path.join(os.homedir(), '.cursor', 'chats') },
78
+ { provider: 'codex', rootPath: path.join(os.homedir(), '.codex', 'sessions') },
79
+ { provider: 'gemini', rootPath: path.join(os.homedir(), '.gemini', 'projects') },
80
+ { provider: 'gemini_sessions', rootPath: path.join(os.homedir(), '.gemini', 'sessions') },
81
+ { provider: 'gemini_cli', rootPath: path.join(os.homedir(), '.gemini', 'tmp') },
82
+ ];
83
+ const WATCHER_IGNORED_PATTERNS = [
84
+ '**/node_modules/**',
85
+ '**/.git/**',
86
+ '**/dist/**',
87
+ '**/build/**',
88
+ '**/*.tmp',
89
+ '**/*.swp',
90
+ '**/.DS_Store'
91
+ ];
92
+ const WATCHER_DEBOUNCE_MS = 300;
93
+ let projectsWatchers = [];
94
+ let projectsWatcherDebounceTimer = null;
95
+ const connectedClients = new Set();
96
+ let isGetProjectsRunning = false; // Flag to prevent reentrant calls
97
+
98
+ // Broadcast progress to all connected WebSocket clients
99
+ function broadcastProgress(progress) {
100
+ const message = JSON.stringify({
101
+ type: 'loading_progress',
102
+ ...progress
103
+ });
104
+ connectedClients.forEach(client => {
105
+ if (client.readyState === WebSocket.OPEN) {
106
+ client.send(message);
107
+ }
108
+ });
109
+ }
110
+
111
+ // Setup file system watchers for Claude, Cursor, and Codex project/session folders
112
+ async function setupProjectsWatcher() {
113
+ const chokidar = (await import('chokidar')).default;
114
+
115
+ if (projectsWatcherDebounceTimer) {
116
+ clearTimeout(projectsWatcherDebounceTimer);
117
+ projectsWatcherDebounceTimer = null;
118
+ }
119
+
120
+ await Promise.all(
121
+ projectsWatchers.map(async (watcher) => {
122
+ try {
123
+ await watcher.close();
124
+ } catch (error) {
125
+ console.error('[WARN] Failed to close watcher:', error);
126
+ }
127
+ })
128
+ );
129
+ projectsWatchers = [];
130
+
131
+ const debouncedUpdate = (eventType, filePath, provider, rootPath) => {
132
+ if (projectsWatcherDebounceTimer) {
133
+ clearTimeout(projectsWatcherDebounceTimer);
134
+ }
135
+
136
+ projectsWatcherDebounceTimer = setTimeout(async () => {
137
+ // Prevent reentrant calls
138
+ if (isGetProjectsRunning) {
139
+ return;
140
+ }
141
+
142
+ try {
143
+ isGetProjectsRunning = true;
144
+
145
+ // Clear project directory cache when files change
146
+ clearProjectDirectoryCache();
147
+
148
+ // Get updated projects list
149
+ const updatedProjects = await getProjects(broadcastProgress);
150
+
151
+ // Notify all connected clients about the project changes
152
+ const updateMessage = JSON.stringify({
153
+ type: 'projects_updated',
154
+ projects: updatedProjects,
155
+ timestamp: new Date().toISOString(),
156
+ changeType: eventType,
157
+ changedFile: path.relative(rootPath, filePath),
158
+ watchProvider: provider
159
+ });
160
+
161
+ connectedClients.forEach(client => {
162
+ if (client.readyState === WebSocket.OPEN) {
163
+ client.send(updateMessage);
164
+ }
165
+ });
166
+
167
+ } catch (error) {
168
+ console.error('[ERROR] Error handling project changes:', error);
169
+ } finally {
170
+ isGetProjectsRunning = false;
171
+ }
172
+ }, WATCHER_DEBOUNCE_MS);
173
+ };
174
+
175
+ for (const { provider, rootPath } of PROVIDER_WATCH_PATHS) {
176
+ try {
177
+ // chokidar v4 emits ENOENT via the "error" event for missing roots and will not auto-recover.
178
+ // Ensure provider folders exist before creating the watcher so watching stays active.
179
+ await fsPromises.mkdir(rootPath, { recursive: true });
180
+
181
+ // Initialize chokidar watcher with optimized settings
182
+ const watcher = chokidar.watch(rootPath, {
183
+ ignored: WATCHER_IGNORED_PATTERNS,
184
+ persistent: true,
185
+ ignoreInitial: true, // Don't fire events for existing files on startup
186
+ followSymlinks: false,
187
+ depth: 10, // Reasonable depth limit
188
+ awaitWriteFinish: {
189
+ stabilityThreshold: 100, // Wait 100ms for file to stabilize
190
+ pollInterval: 50
191
+ }
192
+ });
193
+
194
+ // Set up event listeners
195
+ watcher
196
+ .on('add', (filePath) => debouncedUpdate('add', filePath, provider, rootPath))
197
+ .on('change', (filePath) => debouncedUpdate('change', filePath, provider, rootPath))
198
+ .on('unlink', (filePath) => debouncedUpdate('unlink', filePath, provider, rootPath))
199
+ .on('addDir', (dirPath) => debouncedUpdate('addDir', dirPath, provider, rootPath))
200
+ .on('unlinkDir', (dirPath) => debouncedUpdate('unlinkDir', dirPath, provider, rootPath))
201
+ .on('error', (error) => {
202
+ console.error(`[ERROR] ${provider} watcher error:`, error);
203
+ })
204
+ .on('ready', () => {
205
+ });
206
+
207
+ projectsWatchers.push(watcher);
208
+ } catch (error) {
209
+ console.error(`[ERROR] Failed to setup ${provider} watcher for ${rootPath}:`, error);
210
+ }
211
+ }
212
+
213
+ if (projectsWatchers.length === 0) {
214
+ console.error('[ERROR] Failed to setup any provider watchers');
215
+ }
216
+ }
217
+
218
+
219
+ const app = express();
220
+ const server = http.createServer(app);
221
+
222
+ const ptySessionsMap = new Map();
223
+ const PTY_SESSION_TIMEOUT = 30 * 60 * 1000;
224
+ const SHELL_URL_PARSE_BUFFER_LIMIT = 32768;
225
+ import { stripAnsiSequences, normalizeDetectedUrl, extractUrlsFromText, shouldAutoOpenUrlFromOutput } from './utils/url-detection.js';
226
+
227
+ // Single WebSocket server that handles both paths
228
+ const wss = new WebSocketServer({
229
+ server,
230
+ verifyClient: (info) => {
231
+ console.log('WebSocket connection attempt to:', info.req.url);
232
+
233
+ // Platform mode: always allow connection
234
+ if (IS_PLATFORM) {
235
+ const user = authenticateWebSocket(null); // Will return first user
236
+ if (!user) {
237
+ console.log('[WARN] Platform mode: No user found in database');
238
+ return false;
239
+ }
240
+ info.req.user = user;
241
+ console.log('[OK] Platform mode WebSocket authenticated for user:', user.username);
242
+ return true;
243
+ }
244
+
245
+ // Normal mode: verify token
246
+ // Extract token from query parameters or headers
247
+ const url = new URL(info.req.url, 'http://localhost');
248
+ const token = url.searchParams.get('token') ||
249
+ info.req.headers.authorization?.split(' ')[1];
250
+
251
+ // Verify token
252
+ const user = authenticateWebSocket(token);
253
+ if (!user) {
254
+ console.log('[WARN] WebSocket authentication failed');
255
+ return false;
256
+ }
257
+
258
+ // Store user info in the request for later use
259
+ info.req.user = user;
260
+ console.log('[OK] WebSocket authenticated for user:', user.username);
261
+ return true;
262
+ }
263
+ });
264
+
265
+ // Make WebSocket server available to routes
266
+ app.locals.wss = wss;
267
+
268
+ app.use(cors({ exposedHeaders: ['X-Refreshed-Token'] }));
269
+ app.use(express.json({
270
+ limit: '50mb',
271
+ type: (req) => {
272
+ // Skip multipart/form-data requests (for file uploads like images)
273
+ const contentType = req.headers['content-type'] || '';
274
+ if (contentType.includes('multipart/form-data')) {
275
+ return false;
276
+ }
277
+ return contentType.includes('json');
278
+ }
279
+ }));
280
+ app.use(express.urlencoded({ limit: '50mb', extended: true }));
281
+
282
+ // Public health check endpoint (no authentication required)
283
+ app.get('/health', (req, res) => {
284
+ res.json({
285
+ status: 'ok',
286
+ timestamp: new Date().toISOString(),
287
+ installMode
288
+ });
289
+ });
290
+
291
+ // Optional API key validation (if configured)
292
+ app.use('/api', validateApiKey);
293
+
294
+ // Authentication routes (public)
295
+ app.use('/api/auth', authRoutes);
296
+
297
+ // Projects API Routes (protected)
298
+ app.use('/api/projects', authenticateToken, projectsRoutes);
299
+
300
+ // Git API Routes (protected)
301
+ app.use('/api/git', authenticateToken, gitRoutes);
302
+
303
+ // Cursor API Routes (protected)
304
+ app.use('/api/cursor', authenticateToken, cursorRoutes);
305
+
306
+ // TaskMaster API Routes (protected)
307
+ app.use('/api/taskmaster', authenticateToken, taskmasterRoutes);
308
+
309
+ // MCP utilities
310
+ app.use('/api/mcp-utils', authenticateToken, mcpUtilsRoutes);
311
+
312
+ // Commands API Routes (protected)
313
+ app.use('/api/commands', authenticateToken, commandsRoutes);
314
+
315
+ // Settings API Routes (protected)
316
+ app.use('/api/settings', authenticateToken, settingsRoutes);
317
+
318
+ // User API Routes (protected)
319
+ app.use('/api/user', authenticateToken, userRoutes);
320
+
321
+ // Codex API Routes (protected)
322
+ app.use('/api/codex', authenticateToken, codexRoutes);
323
+
324
+ // Gemini API Routes (protected)
325
+ app.use('/api/gemini', authenticateToken, geminiRoutes);
326
+
327
+ // Plugins API Routes (protected)
328
+ app.use('/api/plugins', authenticateToken, pluginsRoutes);
329
+
330
+ // Unified session messages route (protected)
331
+ app.use('/api/sessions', authenticateToken, messagesRoutes);
332
+
333
+ // Unified provider MCP routes (protected)
334
+ app.use('/api/providers', authenticateToken, providerRoutes);
335
+
336
+ // Agent API Routes (uses API key authentication)
337
+ app.use('/api/agent', agentRoutes);
338
+
339
+ // Serve public files (like api-docs.html)
340
+ app.use(express.static(path.join(APP_ROOT, 'public')));
341
+
342
+ // Static files served after API routes
343
+ // Add cache control: HTML files should not be cached, but assets can be cached
344
+ app.use(express.static(path.join(APP_ROOT, 'dist'), {
345
+ setHeaders: (res, filePath) => {
346
+ if (filePath.endsWith('.html')) {
347
+ // Prevent HTML caching to avoid service worker issues after builds
348
+ res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
349
+ res.setHeader('Pragma', 'no-cache');
350
+ res.setHeader('Expires', '0');
351
+ } else if (filePath.match(/\.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico)$/)) {
352
+ // Cache static assets for 1 year (they have hashed names)
353
+ res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
354
+ }
355
+ }
356
+ }));
357
+
358
+ // API Routes (protected)
359
+ // /api/config endpoint removed - no longer needed
360
+ // Frontend now uses window.location for WebSocket URLs
361
+
362
+ // System update endpoint
363
+ app.post('/api/system/update', authenticateToken, async (req, res) => {
364
+ try {
365
+ // Get the project root directory (parent of server directory)
366
+ const projectRoot = APP_ROOT;
367
+
368
+ console.log('Starting system update from directory:', projectRoot);
369
+
370
+ // Platform deployments use their own update workflow from the project root.
371
+ const updateCommand = IS_PLATFORM
372
+ // In platform, husky and dev dependencies are not needed
373
+ ? 'npm run update:platform'
374
+ : installMode === 'git'
375
+ ? 'git checkout main && git pull && npm install'
376
+ : 'npm install -g @pixelbyte-software/pixcode@latest';
377
+
378
+ const updateCwd = IS_PLATFORM || installMode === 'git'
379
+ ? projectRoot
380
+ : os.homedir();
381
+
382
+ const child = spawn('sh', ['-c', updateCommand], {
383
+ cwd: updateCwd,
384
+ env: process.env
385
+ });
386
+
387
+ let output = '';
388
+ let errorOutput = '';
389
+
390
+ child.stdout.on('data', (data) => {
391
+ const text = data.toString();
392
+ output += text;
393
+ console.log('Update output:', text);
394
+ });
395
+
396
+ child.stderr.on('data', (data) => {
397
+ const text = data.toString();
398
+ errorOutput += text;
399
+ console.error('Update error:', text);
400
+ });
401
+
402
+ child.on('close', (code) => {
403
+ if (code === 0) {
404
+ res.json({
405
+ success: true,
406
+ output: output || 'Update completed successfully',
407
+ message: 'Update completed. Please restart the server to apply changes.'
408
+ });
409
+ } else {
410
+ res.status(500).json({
411
+ success: false,
412
+ error: 'Update command failed',
413
+ output: output,
414
+ errorOutput: errorOutput
415
+ });
416
+ }
417
+ });
418
+
419
+ child.on('error', (error) => {
420
+ console.error('Update process error:', error);
421
+ res.status(500).json({
422
+ success: false,
423
+ error: error.message
424
+ });
425
+ });
426
+
427
+ } catch (error) {
428
+ console.error('System update error:', error);
429
+ res.status(500).json({
430
+ success: false,
431
+ error: error.message
432
+ });
433
+ }
434
+ });
435
+
436
+ app.get('/api/projects', authenticateToken, async (req, res) => {
437
+ try {
438
+ const projects = await getProjects(broadcastProgress);
439
+ res.json(projects);
440
+ } catch (error) {
441
+ res.status(500).json({ error: error.message });
442
+ }
443
+ });
444
+
445
+ app.get('/api/projects/:projectName/sessions', authenticateToken, async (req, res) => {
446
+ try {
447
+ const { limit = 5, offset = 0 } = req.query;
448
+ const result = await getSessions(req.params.projectName, parseInt(limit), parseInt(offset));
449
+ applyCustomSessionNames(result.sessions, 'claude');
450
+ res.json(result);
451
+ } catch (error) {
452
+ res.status(500).json({ error: error.message });
453
+ }
454
+ });
455
+
456
+ // Rename project endpoint
457
+ app.put('/api/projects/:projectName/rename', authenticateToken, async (req, res) => {
458
+ try {
459
+ const { displayName } = req.body;
460
+ await renameProject(req.params.projectName, displayName);
461
+ res.json({ success: true });
462
+ } catch (error) {
463
+ res.status(500).json({ error: error.message });
464
+ }
465
+ });
466
+
467
+ // Delete session endpoint
468
+ app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken, async (req, res) => {
469
+ try {
470
+ const { projectName, sessionId } = req.params;
471
+ console.log(`[API] Deleting session: ${sessionId} from project: ${projectName}`);
472
+ await deleteSession(projectName, sessionId);
473
+ sessionNamesDb.deleteName(sessionId, 'claude');
474
+ console.log(`[API] Session ${sessionId} deleted successfully`);
475
+ res.json({ success: true });
476
+ } catch (error) {
477
+ console.error(`[API] Error deleting session ${req.params.sessionId}:`, error);
478
+ res.status(500).json({ error: error.message });
479
+ }
480
+ });
481
+
482
+ // Rename session endpoint
483
+ app.put('/api/sessions/:sessionId/rename', authenticateToken, async (req, res) => {
484
+ try {
485
+ const { sessionId } = req.params;
486
+ const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
487
+ if (!safeSessionId || safeSessionId !== String(sessionId)) {
488
+ return res.status(400).json({ error: 'Invalid sessionId' });
489
+ }
490
+ const { summary, provider } = req.body;
491
+ if (!summary || typeof summary !== 'string' || summary.trim() === '') {
492
+ return res.status(400).json({ error: 'Summary is required' });
493
+ }
494
+ if (summary.trim().length > 500) {
495
+ return res.status(400).json({ error: 'Summary must not exceed 500 characters' });
496
+ }
497
+ if (!provider || !VALID_PROVIDERS.includes(provider)) {
498
+ return res.status(400).json({ error: `Provider must be one of: ${VALID_PROVIDERS.join(', ')}` });
499
+ }
500
+ sessionNamesDb.setName(safeSessionId, provider, summary.trim());
501
+ res.json({ success: true });
502
+ } catch (error) {
503
+ console.error(`[API] Error renaming session ${req.params.sessionId}:`, error);
504
+ res.status(500).json({ error: error.message });
505
+ }
506
+ });
507
+
508
+ // Delete project endpoint
509
+ // force=true to allow removal even when sessions exist
510
+ // deleteData=true to also delete session/memory files on disk (destructive)
511
+ app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => {
512
+ try {
513
+ const { projectName } = req.params;
514
+ const force = req.query.force === 'true';
515
+ const deleteData = req.query.deleteData === 'true';
516
+ await deleteProject(projectName, force, deleteData);
517
+ res.json({ success: true });
518
+ } catch (error) {
519
+ res.status(500).json({ error: error.message });
520
+ }
521
+ });
522
+
523
+ // Search conversations content (SSE streaming)
524
+ app.get('/api/search/conversations', authenticateToken, async (req, res) => {
525
+ const query = typeof req.query.q === 'string' ? req.query.q.trim() : '';
526
+ const parsedLimit = Number.parseInt(String(req.query.limit), 10);
527
+ const limit = Number.isNaN(parsedLimit) ? 50 : Math.max(1, Math.min(parsedLimit, 100));
528
+
529
+ if (query.length < 2) {
530
+ return res.status(400).json({ error: 'Query must be at least 2 characters' });
531
+ }
532
+
533
+ res.writeHead(200, {
534
+ 'Content-Type': 'text/event-stream',
535
+ 'Cache-Control': 'no-cache',
536
+ 'Connection': 'keep-alive',
537
+ 'X-Accel-Buffering': 'no',
538
+ });
539
+
540
+ let closed = false;
541
+ const abortController = new AbortController();
542
+ req.on('close', () => { closed = true; abortController.abort(); });
543
+
544
+ try {
545
+ await searchConversations(query, limit, ({ projectResult, totalMatches, scannedProjects, totalProjects }) => {
546
+ if (closed) return;
547
+ if (projectResult) {
548
+ res.write(`event: result\ndata: ${JSON.stringify({ projectResult, totalMatches, scannedProjects, totalProjects })}\n\n`);
549
+ } else {
550
+ res.write(`event: progress\ndata: ${JSON.stringify({ totalMatches, scannedProjects, totalProjects })}\n\n`);
551
+ }
552
+ }, abortController.signal);
553
+ if (!closed) {
554
+ res.write(`event: done\ndata: {}\n\n`);
555
+ }
556
+ } catch (error) {
557
+ console.error('Error searching conversations:', error);
558
+ if (!closed) {
559
+ res.write(`event: error\ndata: ${JSON.stringify({ error: 'Search failed' })}\n\n`);
560
+ }
561
+ } finally {
562
+ if (!closed) {
563
+ res.end();
564
+ }
565
+ }
566
+ });
567
+
568
+ const expandWorkspacePath = (inputPath) => {
569
+ if (!inputPath) return WORKSPACES_BASE;
570
+ return normalizeWorkspacePath(inputPath);
571
+ };
572
+
573
+ // Browse filesystem endpoint for project suggestions - uses existing getFileTree
574
+ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
575
+ try {
576
+ const { path: dirPath } = req.query;
577
+
578
+ console.log('[API] Browse filesystem request for path:', dirPath);
579
+ console.log('[API] WORKSPACES_ROOT is:', WORKSPACES_ROOT);
580
+ console.log('[API] WORKSPACES_BASE is:', WORKSPACES_BASE);
581
+ // Default to home directory if no path provided
582
+ const defaultRoot = WORKSPACES_BASE;
583
+ let targetPath = dirPath ? expandWorkspacePath(dirPath) : defaultRoot;
584
+
585
+ // Security check - ensure path is within allowed workspace root
586
+ let validation = await validateWorkspacePath(targetPath);
587
+ if (!validation.valid) {
588
+ // Keep the browser functional by returning to the safe base on invalid navigation.
589
+ const fallbackValidation = await validateWorkspacePath(defaultRoot);
590
+ if (!fallbackValidation.valid) {
591
+ return res.status(403).json({ error: validation.error });
592
+ }
593
+ validation = fallbackValidation;
594
+ }
595
+ const resolvedPath = validation.resolvedPath || targetPath;
596
+
597
+ // Security check - ensure path is accessible
598
+ try {
599
+ if (resolvedPath === defaultRoot) {
600
+ await fs.promises.mkdir(resolvedPath, { recursive: true });
601
+ }
602
+ await fs.promises.access(resolvedPath);
603
+ const stats = await fs.promises.stat(resolvedPath);
604
+
605
+ if (!stats.isDirectory()) {
606
+ return res.status(400).json({ error: 'Path is not a directory' });
607
+ }
608
+ } catch (err) {
609
+ return res.status(404).json({ error: 'Directory not accessible' });
610
+ }
611
+
612
+ // Use existing getFileTree function with shallow depth (only direct children)
613
+ const fileTree = await getFileTree(resolvedPath, 1, 0, false); // maxDepth=1, showHidden=false
614
+
615
+ // Filter only directories and format for suggestions
616
+ const directories = fileTree
617
+ .filter(item => item.type === 'directory')
618
+ .map(item => ({
619
+ path: item.path,
620
+ name: item.name,
621
+ type: 'directory'
622
+ }))
623
+ .sort((a, b) => {
624
+ const aHidden = a.name.startsWith('.');
625
+ const bHidden = b.name.startsWith('.');
626
+ if (aHidden && !bHidden) return 1;
627
+ if (!aHidden && bHidden) return -1;
628
+ return a.name.localeCompare(b.name);
629
+ });
630
+
631
+ // Add common directories if browsing home directory
632
+ const suggestions = [];
633
+ let resolvedWorkspaceBase = defaultRoot;
634
+ try {
635
+ resolvedWorkspaceBase = await fsPromises.realpath(defaultRoot);
636
+ } catch (error) {
637
+ // Use default root as-is if realpath fails
638
+ }
639
+ if (resolvedPath === resolvedWorkspaceBase) {
640
+ const commonDirs = ['Desktop', 'Documents', 'Projects', 'Development', 'Dev', 'Code', 'workspace'];
641
+ const existingCommon = directories.filter(dir => commonDirs.includes(dir.name));
642
+ const otherDirs = directories.filter(dir => !commonDirs.includes(dir.name));
643
+
644
+ suggestions.push(...existingCommon, ...otherDirs);
645
+ } else {
646
+ suggestions.push(...directories);
647
+ }
648
+
649
+ res.json({
650
+ path: resolvedPath,
651
+ rootPath: resolvedWorkspaceBase,
652
+ suggestions: suggestions
653
+ });
654
+
655
+ } catch (error) {
656
+ console.error('Error browsing filesystem:', error);
657
+ res.status(500).json({ error: 'Failed to browse filesystem' });
658
+ }
659
+ });
660
+
661
+ app.post('/api/create-folder', authenticateToken, async (req, res) => {
662
+ try {
663
+ const { path: folderPath } = req.body;
664
+ if (!folderPath) {
665
+ return res.status(400).json({ error: 'Path is required' });
666
+ }
667
+ const expandedPath = expandWorkspacePath(folderPath);
668
+ const resolvedInput = path.resolve(expandedPath);
669
+ const validation = await validateWorkspacePath(resolvedInput);
670
+ if (!validation.valid) {
671
+ return res.status(403).json({ error: validation.error });
672
+ }
673
+ const targetPath = validation.resolvedPath || resolvedInput;
674
+ const parentDir = path.dirname(targetPath);
675
+ try {
676
+ await fs.promises.access(parentDir);
677
+ } catch (err) {
678
+ return res.status(404).json({ error: 'Parent directory does not exist' });
679
+ }
680
+ try {
681
+ await fs.promises.access(targetPath);
682
+ return res.status(409).json({ error: 'Folder already exists' });
683
+ } catch (err) {
684
+ // Folder doesn't exist, which is what we want
685
+ }
686
+ try {
687
+ await fs.promises.mkdir(targetPath, { recursive: false });
688
+ res.json({ success: true, path: targetPath });
689
+ } catch (mkdirError) {
690
+ if (mkdirError.code === 'EEXIST') {
691
+ return res.status(409).json({ error: 'Folder already exists' });
692
+ }
693
+ throw mkdirError;
694
+ }
695
+ } catch (error) {
696
+ console.error('Error creating folder:', error);
697
+ res.status(500).json({ error: 'Failed to create folder' });
698
+ }
699
+ });
700
+
701
+ // Read file content endpoint
702
+ app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
703
+ try {
704
+ const { projectName } = req.params;
705
+ const { filePath } = req.query;
706
+
707
+
708
+ // Security: ensure the requested path is inside the project root
709
+ if (!filePath) {
710
+ return res.status(400).json({ error: 'Invalid file path' });
711
+ }
712
+
713
+ const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
714
+ if (!projectRoot) {
715
+ return res.status(404).json({ error: 'Project not found' });
716
+ }
717
+
718
+ // Handle both absolute and relative paths
719
+ const resolved = path.isAbsolute(filePath)
720
+ ? path.resolve(filePath)
721
+ : path.resolve(projectRoot, filePath);
722
+ const normalizedRoot = path.resolve(projectRoot) + path.sep;
723
+ if (!resolved.startsWith(normalizedRoot)) {
724
+ return res.status(403).json({ error: 'Path must be under project root' });
725
+ }
726
+
727
+ const content = await fsPromises.readFile(resolved, 'utf8');
728
+ res.json({ content, path: resolved });
729
+ } catch (error) {
730
+ console.error('Error reading file:', error);
731
+ if (error.code === 'ENOENT') {
732
+ res.status(404).json({ error: 'File not found' });
733
+ } else if (error.code === 'EACCES') {
734
+ res.status(403).json({ error: 'Permission denied' });
735
+ } else {
736
+ res.status(500).json({ error: error.message });
737
+ }
738
+ }
739
+ });
740
+
741
+ // Serve raw file bytes for previews and downloads.
742
+ app.get('/api/projects/:projectName/files/content', authenticateToken, async (req, res) => {
743
+ try {
744
+ const { projectName } = req.params;
745
+ const { path: filePath } = req.query;
746
+
747
+
748
+ // Security: ensure the requested path is inside the project root
749
+ if (!filePath) {
750
+ return res.status(400).json({ error: 'Invalid file path' });
751
+ }
752
+
753
+ const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
754
+ if (!projectRoot) {
755
+ return res.status(404).json({ error: 'Project not found' });
756
+ }
757
+
758
+ // Match the text reader endpoint so callers can pass either project-relative
759
+ // or absolute paths without changing how the bytes are served.
760
+ const resolved = path.isAbsolute(filePath)
761
+ ? path.resolve(filePath)
762
+ : path.resolve(projectRoot, filePath);
763
+ const normalizedRoot = path.resolve(projectRoot) + path.sep;
764
+ if (!resolved.startsWith(normalizedRoot)) {
765
+ return res.status(403).json({ error: 'Path must be under project root' });
766
+ }
767
+
768
+ // Check if file exists
769
+ try {
770
+ await fsPromises.access(resolved);
771
+ } catch (error) {
772
+ return res.status(404).json({ error: 'File not found' });
773
+ }
774
+
775
+ // Get file extension and set appropriate content type
776
+ const mimeType = mime.lookup(resolved) || 'application/octet-stream';
777
+ res.setHeader('Content-Type', mimeType);
778
+
779
+ // Stream the file
780
+ const fileStream = fs.createReadStream(resolved);
781
+ fileStream.pipe(res);
782
+
783
+ fileStream.on('error', (error) => {
784
+ console.error('Error streaming file:', error);
785
+ if (!res.headersSent) {
786
+ res.status(500).json({ error: 'Error reading file' });
787
+ }
788
+ });
789
+
790
+ } catch (error) {
791
+ console.error('Error serving binary file:', error);
792
+ if (!res.headersSent) {
793
+ res.status(500).json({ error: error.message });
794
+ }
795
+ }
796
+ });
797
+
798
+ // Save file content endpoint
799
+ app.put('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
800
+ try {
801
+ const { projectName } = req.params;
802
+ const { filePath, content } = req.body;
803
+
804
+
805
+ // Security: ensure the requested path is inside the project root
806
+ if (!filePath) {
807
+ return res.status(400).json({ error: 'Invalid file path' });
808
+ }
809
+
810
+ if (content === undefined) {
811
+ return res.status(400).json({ error: 'Content is required' });
812
+ }
813
+
814
+ const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
815
+ if (!projectRoot) {
816
+ return res.status(404).json({ error: 'Project not found' });
817
+ }
818
+
819
+ // Handle both absolute and relative paths
820
+ const resolved = path.isAbsolute(filePath)
821
+ ? path.resolve(filePath)
822
+ : path.resolve(projectRoot, filePath);
823
+ const normalizedRoot = path.resolve(projectRoot) + path.sep;
824
+ if (!resolved.startsWith(normalizedRoot)) {
825
+ return res.status(403).json({ error: 'Path must be under project root' });
826
+ }
827
+
828
+ // Write the new content
829
+ await fsPromises.writeFile(resolved, content, 'utf8');
830
+
831
+ res.json({
832
+ success: true,
833
+ path: resolved,
834
+ message: 'File saved successfully'
835
+ });
836
+ } catch (error) {
837
+ console.error('Error saving file:', error);
838
+ if (error.code === 'ENOENT') {
839
+ res.status(404).json({ error: 'File or directory not found' });
840
+ } else if (error.code === 'EACCES') {
841
+ res.status(403).json({ error: 'Permission denied' });
842
+ } else {
843
+ res.status(500).json({ error: error.message });
844
+ }
845
+ }
846
+ });
847
+
848
+ app.get('/api/projects/:projectName/files', authenticateToken, async (req, res) => {
849
+ try {
850
+
851
+ // Using fsPromises from import
852
+
853
+ // Use extractProjectDirectory to get the actual project path
854
+ let actualPath;
855
+ try {
856
+ actualPath = await extractProjectDirectory(req.params.projectName);
857
+ } catch (error) {
858
+ console.error('Error extracting project directory:', error);
859
+ // Fallback to simple dash replacement
860
+ actualPath = req.params.projectName.replace(/-/g, '/');
861
+ }
862
+
863
+ // Check if path exists
864
+ try {
865
+ await fsPromises.access(actualPath);
866
+ } catch (e) {
867
+ return res.status(404).json({ error: `Project path not found: ${actualPath}` });
868
+ }
869
+
870
+ const files = await getFileTree(actualPath, 10, 0, true);
871
+ res.json(files);
872
+ } catch (error) {
873
+ console.error('[ERROR] File tree error:', error.message);
874
+ res.status(500).json({ error: error.message });
875
+ }
876
+ });
877
+
878
+ // ============================================================================
879
+ // FILE OPERATIONS API ENDPOINTS
880
+ // ============================================================================
881
+
882
+ /**
883
+ * Validate that a path is within the project root
884
+ * @param {string} projectRoot - The project root path
885
+ * @param {string} targetPath - The path to validate
886
+ * @returns {{ valid: boolean, resolved?: string, error?: string }}
887
+ */
888
+ function validatePathInProject(projectRoot, targetPath) {
889
+ const resolved = path.isAbsolute(targetPath)
890
+ ? path.resolve(targetPath)
891
+ : path.resolve(projectRoot, targetPath);
892
+ const normalizedRoot = path.resolve(projectRoot) + path.sep;
893
+ if (!resolved.startsWith(normalizedRoot)) {
894
+ return { valid: false, error: 'Path must be under project root' };
895
+ }
896
+ return { valid: true, resolved };
897
+ }
898
+
899
+ /**
900
+ * Validate filename - check for invalid characters
901
+ * @param {string} name - The filename to validate
902
+ * @returns {{ valid: boolean, error?: string }}
903
+ */
904
+ function validateFilename(name) {
905
+ if (!name || !name.trim()) {
906
+ return { valid: false, error: 'Filename cannot be empty' };
907
+ }
908
+ // Check for invalid characters (Windows + Unix)
909
+ const invalidChars = /[<>:"/\\|?*\x00-\x1f]/;
910
+ if (invalidChars.test(name)) {
911
+ return { valid: false, error: 'Filename contains invalid characters' };
912
+ }
913
+ // Check for reserved names (Windows)
914
+ const reserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i;
915
+ if (reserved.test(name)) {
916
+ return { valid: false, error: 'Filename is a reserved name' };
917
+ }
918
+ // Check for dots only
919
+ if (/^\.+$/.test(name)) {
920
+ return { valid: false, error: 'Filename cannot be only dots' };
921
+ }
922
+ return { valid: true };
923
+ }
924
+
925
+ // POST /api/projects/:projectName/files/create - Create new file or directory
926
+ app.post('/api/projects/:projectName/files/create', authenticateToken, async (req, res) => {
927
+ try {
928
+ const { projectName } = req.params;
929
+ const { path: parentPath, type, name } = req.body;
930
+
931
+ // Validate input
932
+ if (!name || !type) {
933
+ return res.status(400).json({ error: 'Name and type are required' });
934
+ }
935
+
936
+ if (!['file', 'directory'].includes(type)) {
937
+ return res.status(400).json({ error: 'Type must be "file" or "directory"' });
938
+ }
939
+
940
+ const nameValidation = validateFilename(name);
941
+ if (!nameValidation.valid) {
942
+ return res.status(400).json({ error: nameValidation.error });
943
+ }
944
+
945
+ // Get project root
946
+ const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
947
+ if (!projectRoot) {
948
+ return res.status(404).json({ error: 'Project not found' });
949
+ }
950
+
951
+ // Build and validate target path
952
+ const targetDir = parentPath || '';
953
+ const targetPath = targetDir ? path.join(targetDir, name) : name;
954
+ const validation = validatePathInProject(projectRoot, targetPath);
955
+ if (!validation.valid) {
956
+ return res.status(403).json({ error: validation.error });
957
+ }
958
+
959
+ const resolvedPath = validation.resolved;
960
+
961
+ // Check if already exists
962
+ try {
963
+ await fsPromises.access(resolvedPath);
964
+ return res.status(409).json({ error: `${type === 'file' ? 'File' : 'Directory'} already exists` });
965
+ } catch {
966
+ // Doesn't exist, which is what we want
967
+ }
968
+
969
+ // Create file or directory
970
+ if (type === 'directory') {
971
+ await fsPromises.mkdir(resolvedPath, { recursive: false });
972
+ } else {
973
+ // Ensure parent directory exists
974
+ const parentDir = path.dirname(resolvedPath);
975
+ try {
976
+ await fsPromises.access(parentDir);
977
+ } catch {
978
+ await fsPromises.mkdir(parentDir, { recursive: true });
979
+ }
980
+ await fsPromises.writeFile(resolvedPath, '', 'utf8');
981
+ }
982
+
983
+ res.json({
984
+ success: true,
985
+ path: resolvedPath,
986
+ name,
987
+ type,
988
+ message: `${type === 'file' ? 'File' : 'Directory'} created successfully`
989
+ });
990
+ } catch (error) {
991
+ console.error('Error creating file/directory:', error);
992
+ if (error.code === 'EACCES') {
993
+ res.status(403).json({ error: 'Permission denied' });
994
+ } else if (error.code === 'ENOENT') {
995
+ res.status(404).json({ error: 'Parent directory not found' });
996
+ } else {
997
+ res.status(500).json({ error: error.message });
998
+ }
999
+ }
1000
+ });
1001
+
1002
+ // PUT /api/projects/:projectName/files/rename - Rename file or directory
1003
+ app.put('/api/projects/:projectName/files/rename', authenticateToken, async (req, res) => {
1004
+ try {
1005
+ const { projectName } = req.params;
1006
+ const { oldPath, newName } = req.body;
1007
+
1008
+ // Validate input
1009
+ if (!oldPath || !newName) {
1010
+ return res.status(400).json({ error: 'oldPath and newName are required' });
1011
+ }
1012
+
1013
+ const nameValidation = validateFilename(newName);
1014
+ if (!nameValidation.valid) {
1015
+ return res.status(400).json({ error: nameValidation.error });
1016
+ }
1017
+
1018
+ // Get project root
1019
+ const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
1020
+ if (!projectRoot) {
1021
+ return res.status(404).json({ error: 'Project not found' });
1022
+ }
1023
+
1024
+ // Validate old path
1025
+ const oldValidation = validatePathInProject(projectRoot, oldPath);
1026
+ if (!oldValidation.valid) {
1027
+ return res.status(403).json({ error: oldValidation.error });
1028
+ }
1029
+
1030
+ const resolvedOldPath = oldValidation.resolved;
1031
+
1032
+ // Check if old path exists
1033
+ try {
1034
+ await fsPromises.access(resolvedOldPath);
1035
+ } catch {
1036
+ return res.status(404).json({ error: 'File or directory not found' });
1037
+ }
1038
+
1039
+ // Build and validate new path
1040
+ const parentDir = path.dirname(resolvedOldPath);
1041
+ const resolvedNewPath = path.join(parentDir, newName);
1042
+ const newValidation = validatePathInProject(projectRoot, resolvedNewPath);
1043
+ if (!newValidation.valid) {
1044
+ return res.status(403).json({ error: newValidation.error });
1045
+ }
1046
+
1047
+ // Check if new path already exists
1048
+ try {
1049
+ await fsPromises.access(resolvedNewPath);
1050
+ return res.status(409).json({ error: 'A file or directory with this name already exists' });
1051
+ } catch {
1052
+ // Doesn't exist, which is what we want
1053
+ }
1054
+
1055
+ // Rename
1056
+ await fsPromises.rename(resolvedOldPath, resolvedNewPath);
1057
+
1058
+ res.json({
1059
+ success: true,
1060
+ oldPath: resolvedOldPath,
1061
+ newPath: resolvedNewPath,
1062
+ newName,
1063
+ message: 'Renamed successfully'
1064
+ });
1065
+ } catch (error) {
1066
+ console.error('Error renaming file/directory:', error);
1067
+ if (error.code === 'EACCES') {
1068
+ res.status(403).json({ error: 'Permission denied' });
1069
+ } else if (error.code === 'ENOENT') {
1070
+ res.status(404).json({ error: 'File or directory not found' });
1071
+ } else if (error.code === 'EXDEV') {
1072
+ res.status(400).json({ error: 'Cannot move across different filesystems' });
1073
+ } else {
1074
+ res.status(500).json({ error: error.message });
1075
+ }
1076
+ }
1077
+ });
1078
+
1079
+ // DELETE /api/projects/:projectName/files - Delete file or directory
1080
+ app.delete('/api/projects/:projectName/files', authenticateToken, async (req, res) => {
1081
+ try {
1082
+ const { projectName } = req.params;
1083
+ const { path: targetPath, type } = req.body;
1084
+
1085
+ // Validate input
1086
+ if (!targetPath) {
1087
+ return res.status(400).json({ error: 'Path is required' });
1088
+ }
1089
+
1090
+ // Get project root
1091
+ const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
1092
+ if (!projectRoot) {
1093
+ return res.status(404).json({ error: 'Project not found' });
1094
+ }
1095
+
1096
+ // Validate path
1097
+ const validation = validatePathInProject(projectRoot, targetPath);
1098
+ if (!validation.valid) {
1099
+ return res.status(403).json({ error: validation.error });
1100
+ }
1101
+
1102
+ const resolvedPath = validation.resolved;
1103
+
1104
+ // Check if path exists and get stats
1105
+ let stats;
1106
+ try {
1107
+ stats = await fsPromises.stat(resolvedPath);
1108
+ } catch {
1109
+ return res.status(404).json({ error: 'File or directory not found' });
1110
+ }
1111
+
1112
+ // Prevent deleting the project root itself
1113
+ if (resolvedPath === path.resolve(projectRoot)) {
1114
+ return res.status(403).json({ error: 'Cannot delete project root directory' });
1115
+ }
1116
+
1117
+ // Delete based on type
1118
+ if (stats.isDirectory()) {
1119
+ await fsPromises.rm(resolvedPath, { recursive: true, force: true });
1120
+ } else {
1121
+ await fsPromises.unlink(resolvedPath);
1122
+ }
1123
+
1124
+ res.json({
1125
+ success: true,
1126
+ path: resolvedPath,
1127
+ type: stats.isDirectory() ? 'directory' : 'file',
1128
+ message: 'Deleted successfully'
1129
+ });
1130
+ } catch (error) {
1131
+ console.error('Error deleting file/directory:', error);
1132
+ if (error.code === 'EACCES') {
1133
+ res.status(403).json({ error: 'Permission denied' });
1134
+ } else if (error.code === 'ENOENT') {
1135
+ res.status(404).json({ error: 'File or directory not found' });
1136
+ } else if (error.code === 'ENOTEMPTY') {
1137
+ res.status(400).json({ error: 'Directory is not empty' });
1138
+ } else {
1139
+ res.status(500).json({ error: error.message });
1140
+ }
1141
+ }
1142
+ });
1143
+
1144
+ // POST /api/projects/:projectName/files/upload - Upload files
1145
+ // Dynamic import of multer for file uploads
1146
+ const uploadFilesHandler = async (req, res) => {
1147
+ // Dynamic import of multer
1148
+ const multer = (await import('multer')).default;
1149
+
1150
+ const uploadMiddleware = multer({
1151
+ storage: multer.diskStorage({
1152
+ destination: (req, file, cb) => {
1153
+ cb(null, os.tmpdir());
1154
+ },
1155
+ filename: (req, file, cb) => {
1156
+ // Use a unique temp name, but preserve original name in file.originalname
1157
+ // Note: file.originalname may contain path separators for folder uploads
1158
+ const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
1159
+ // For temp file, just use a safe unique name without the path
1160
+ cb(null, `upload-${uniqueSuffix}`);
1161
+ }
1162
+ }),
1163
+ limits: {
1164
+ fileSize: 50 * 1024 * 1024, // 50MB limit
1165
+ files: 20 // Max 20 files at once
1166
+ }
1167
+ });
1168
+
1169
+ // Use multer middleware
1170
+ uploadMiddleware.array('files', 20)(req, res, async (err) => {
1171
+ if (err) {
1172
+ console.error('Multer error:', err);
1173
+ if (err.code === 'LIMIT_FILE_SIZE') {
1174
+ return res.status(400).json({ error: 'File too large. Maximum size is 50MB.' });
1175
+ }
1176
+ if (err.code === 'LIMIT_FILE_COUNT') {
1177
+ return res.status(400).json({ error: 'Too many files. Maximum is 20 files.' });
1178
+ }
1179
+ return res.status(500).json({ error: err.message });
1180
+ }
1181
+
1182
+ try {
1183
+ const { projectName } = req.params;
1184
+ const { targetPath, relativePaths } = req.body;
1185
+
1186
+ // Parse relative paths if provided (for folder uploads)
1187
+ let filePaths = [];
1188
+ if (relativePaths) {
1189
+ try {
1190
+ filePaths = JSON.parse(relativePaths);
1191
+ } catch (e) {
1192
+ console.log('[DEBUG] Failed to parse relativePaths:', relativePaths);
1193
+ }
1194
+ }
1195
+
1196
+ console.log('[DEBUG] File upload request:', {
1197
+ projectName,
1198
+ targetPath: JSON.stringify(targetPath),
1199
+ targetPathType: typeof targetPath,
1200
+ filesCount: req.files?.length,
1201
+ relativePaths: filePaths
1202
+ });
1203
+
1204
+ if (!req.files || req.files.length === 0) {
1205
+ return res.status(400).json({ error: 'No files provided' });
1206
+ }
1207
+
1208
+ // Get project root
1209
+ const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
1210
+ if (!projectRoot) {
1211
+ return res.status(404).json({ error: 'Project not found' });
1212
+ }
1213
+
1214
+ console.log('[DEBUG] Project root:', projectRoot);
1215
+
1216
+ // Validate and resolve target path
1217
+ // If targetPath is empty or '.', use project root directly
1218
+ const targetDir = targetPath || '';
1219
+ let resolvedTargetDir;
1220
+
1221
+ console.log('[DEBUG] Target dir:', JSON.stringify(targetDir));
1222
+
1223
+ if (!targetDir || targetDir === '.' || targetDir === './') {
1224
+ // Empty path means upload to project root
1225
+ resolvedTargetDir = path.resolve(projectRoot);
1226
+ console.log('[DEBUG] Using project root as target:', resolvedTargetDir);
1227
+ } else {
1228
+ const validation = validatePathInProject(projectRoot, targetDir);
1229
+ if (!validation.valid) {
1230
+ console.log('[DEBUG] Path validation failed:', validation.error);
1231
+ return res.status(403).json({ error: validation.error });
1232
+ }
1233
+ resolvedTargetDir = validation.resolved;
1234
+ console.log('[DEBUG] Resolved target dir:', resolvedTargetDir);
1235
+ }
1236
+
1237
+ // Ensure target directory exists
1238
+ try {
1239
+ await fsPromises.access(resolvedTargetDir);
1240
+ } catch {
1241
+ await fsPromises.mkdir(resolvedTargetDir, { recursive: true });
1242
+ }
1243
+
1244
+ // Move uploaded files from temp to target directory
1245
+ const uploadedFiles = [];
1246
+ console.log('[DEBUG] Processing files:', req.files.map(f => ({ originalname: f.originalname, path: f.path })));
1247
+ for (let i = 0; i < req.files.length; i++) {
1248
+ const file = req.files[i];
1249
+ // Use relative path if provided (for folder uploads), otherwise use originalname
1250
+ const fileName = (filePaths && filePaths[i]) ? filePaths[i] : file.originalname;
1251
+ console.log('[DEBUG] Processing file:', fileName, '(originalname:', file.originalname + ')');
1252
+ const destPath = path.join(resolvedTargetDir, fileName);
1253
+
1254
+ // Validate destination path
1255
+ const destValidation = validatePathInProject(projectRoot, destPath);
1256
+ if (!destValidation.valid) {
1257
+ console.log('[DEBUG] Destination validation failed for:', destPath);
1258
+ // Clean up temp file
1259
+ await fsPromises.unlink(file.path).catch(() => {});
1260
+ continue;
1261
+ }
1262
+
1263
+ // Ensure parent directory exists (for nested files from folder upload)
1264
+ const parentDir = path.dirname(destPath);
1265
+ try {
1266
+ await fsPromises.access(parentDir);
1267
+ } catch {
1268
+ await fsPromises.mkdir(parentDir, { recursive: true });
1269
+ }
1270
+
1271
+ // Move file (copy + unlink to handle cross-device scenarios)
1272
+ await fsPromises.copyFile(file.path, destPath);
1273
+ await fsPromises.unlink(file.path);
1274
+
1275
+ uploadedFiles.push({
1276
+ name: fileName,
1277
+ path: destPath,
1278
+ size: file.size,
1279
+ mimeType: file.mimetype
1280
+ });
1281
+ }
1282
+
1283
+ res.json({
1284
+ success: true,
1285
+ files: uploadedFiles,
1286
+ targetPath: resolvedTargetDir,
1287
+ message: `Uploaded ${uploadedFiles.length} file(s) successfully`
1288
+ });
1289
+ } catch (error) {
1290
+ console.error('Error uploading files:', error);
1291
+ // Clean up any remaining temp files
1292
+ if (req.files) {
1293
+ for (const file of req.files) {
1294
+ await fsPromises.unlink(file.path).catch(() => {});
1295
+ }
1296
+ }
1297
+ if (error.code === 'EACCES') {
1298
+ res.status(403).json({ error: 'Permission denied' });
1299
+ } else {
1300
+ res.status(500).json({ error: error.message });
1301
+ }
1302
+ }
1303
+ });
1304
+ };
1305
+
1306
+ app.post('/api/projects/:projectName/files/upload', authenticateToken, uploadFilesHandler);
1307
+
1308
+ /**
1309
+ * Proxy an authenticated client WebSocket to a plugin's internal WS server.
1310
+ * Auth is enforced by verifyClient before this function is reached.
1311
+ */
1312
+ function handlePluginWsProxy(clientWs, pathname) {
1313
+ const pluginName = pathname.replace('/plugin-ws/', '');
1314
+ if (!pluginName || /[^a-zA-Z0-9_-]/.test(pluginName)) {
1315
+ clientWs.close(4400, 'Invalid plugin name');
1316
+ return;
1317
+ }
1318
+
1319
+ const port = getPluginPort(pluginName);
1320
+ if (!port) {
1321
+ clientWs.close(4404, 'Plugin not running');
1322
+ return;
1323
+ }
1324
+
1325
+ const upstream = new WebSocket(`ws://127.0.0.1:${port}/ws`);
1326
+
1327
+ upstream.on('open', () => {
1328
+ console.log(`[Plugins] WS proxy connected to "${pluginName}" on port ${port}`);
1329
+ });
1330
+
1331
+ // Relay messages bidirectionally
1332
+ upstream.on('message', (data) => {
1333
+ if (clientWs.readyState === WebSocket.OPEN) clientWs.send(data);
1334
+ });
1335
+ clientWs.on('message', (data) => {
1336
+ if (upstream.readyState === WebSocket.OPEN) upstream.send(data);
1337
+ });
1338
+
1339
+ // Propagate close in both directions
1340
+ upstream.on('close', () => { if (clientWs.readyState === WebSocket.OPEN) clientWs.close(); });
1341
+ clientWs.on('close', () => { if (upstream.readyState === WebSocket.OPEN) upstream.close(); });
1342
+
1343
+ upstream.on('error', (err) => {
1344
+ console.error(`[Plugins] WS proxy error for "${pluginName}":`, err.message);
1345
+ if (clientWs.readyState === WebSocket.OPEN) clientWs.close(4502, 'Upstream error');
1346
+ });
1347
+ clientWs.on('error', () => {
1348
+ if (upstream.readyState === WebSocket.OPEN) upstream.close();
1349
+ });
1350
+ }
1351
+
1352
+ // WebSocket connection handler that routes based on URL path
1353
+ wss.on('connection', (ws, request) => {
1354
+ const url = request.url;
1355
+ console.log('[INFO] Client connected to:', url);
1356
+
1357
+ // Parse URL to get pathname without query parameters
1358
+ const urlObj = new URL(url, 'http://localhost');
1359
+ const pathname = urlObj.pathname;
1360
+
1361
+ if (pathname === '/shell') {
1362
+ handleShellConnection(ws);
1363
+ } else if (pathname === '/ws') {
1364
+ handleChatConnection(ws, request);
1365
+ } else if (pathname.startsWith('/plugin-ws/')) {
1366
+ handlePluginWsProxy(ws, pathname);
1367
+ } else {
1368
+ console.log('[WARN] Unknown WebSocket path:', pathname);
1369
+ ws.close();
1370
+ }
1371
+ });
1372
+
1373
+ /**
1374
+ * WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface
1375
+ *
1376
+ * Provider files use `createNormalizedMessage()` from `shared/utils.js` and
1377
+ * adapter `normalizeMessage()` to produce unified NormalizedMessage events.
1378
+ * The writer simply serialises and sends.
1379
+ */
1380
+ class WebSocketWriter {
1381
+ constructor(ws, userId = null) {
1382
+ this.ws = ws;
1383
+ this.sessionId = null;
1384
+ this.userId = userId;
1385
+ this.isWebSocketWriter = true; // Marker for transport detection
1386
+ }
1387
+
1388
+ send(data) {
1389
+ if (this.ws.readyState === 1) { // WebSocket.OPEN
1390
+ this.ws.send(JSON.stringify(data));
1391
+ }
1392
+ }
1393
+
1394
+ updateWebSocket(newRawWs) {
1395
+ this.ws = newRawWs;
1396
+ }
1397
+
1398
+ setSessionId(sessionId) {
1399
+ this.sessionId = sessionId;
1400
+ }
1401
+
1402
+ getSessionId() {
1403
+ return this.sessionId;
1404
+ }
1405
+ }
1406
+
1407
+ // Handle chat WebSocket connections
1408
+ function handleChatConnection(ws, request) {
1409
+ console.log('[INFO] Chat WebSocket connected');
1410
+
1411
+ // Add to connected clients for project updates
1412
+ connectedClients.add(ws);
1413
+
1414
+ // Wrap WebSocket with writer for consistent interface with SSEStreamWriter
1415
+ const writer = new WebSocketWriter(ws, request?.user?.id ?? request?.user?.userId ?? null);
1416
+
1417
+ ws.on('message', async (message) => {
1418
+ try {
1419
+ const data = JSON.parse(message);
1420
+
1421
+ if (data.type === 'claude-command') {
1422
+ console.log('[DEBUG] User message:', data.command || '[Continue/Resume]');
1423
+ console.log('📁 Project:', data.options?.projectPath || 'Unknown');
1424
+ console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
1425
+
1426
+ // Use Claude Agents SDK
1427
+ await queryClaudeSDK(data.command, data.options, writer);
1428
+ } else if (data.type === 'cursor-command') {
1429
+ console.log('[DEBUG] Cursor message:', data.command || '[Continue/Resume]');
1430
+ console.log('📁 Project:', data.options?.cwd || 'Unknown');
1431
+ console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
1432
+ console.log('🤖 Model:', data.options?.model || 'default');
1433
+ await spawnCursor(data.command, data.options, writer);
1434
+ } else if (data.type === 'codex-command') {
1435
+ console.log('[DEBUG] Codex message:', data.command || '[Continue/Resume]');
1436
+ console.log('📁 Project:', data.options?.projectPath || data.options?.cwd || 'Unknown');
1437
+ console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
1438
+ console.log('🤖 Model:', data.options?.model || 'default');
1439
+ await queryCodex(data.command, data.options, writer);
1440
+ } else if (data.type === 'gemini-command') {
1441
+ console.log('[DEBUG] Gemini message:', data.command || '[Continue/Resume]');
1442
+ console.log('📁 Project:', data.options?.projectPath || data.options?.cwd || 'Unknown');
1443
+ console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
1444
+ console.log('🤖 Model:', data.options?.model || 'default');
1445
+ await spawnGemini(data.command, data.options, writer);
1446
+ } else if (data.type === 'cursor-resume') {
1447
+ // Backward compatibility: treat as cursor-command with resume and no prompt
1448
+ console.log('[DEBUG] Cursor resume session (compat):', data.sessionId);
1449
+ await spawnCursor('', {
1450
+ sessionId: data.sessionId,
1451
+ resume: true,
1452
+ cwd: data.options?.cwd
1453
+ }, writer);
1454
+ } else if (data.type === 'abort-session') {
1455
+ console.log('[DEBUG] Abort session request:', data.sessionId);
1456
+ const provider = data.provider || 'claude';
1457
+ let success;
1458
+
1459
+ if (provider === 'cursor') {
1460
+ success = abortCursorSession(data.sessionId);
1461
+ } else if (provider === 'codex') {
1462
+ success = abortCodexSession(data.sessionId);
1463
+ } else if (provider === 'gemini') {
1464
+ success = abortGeminiSession(data.sessionId);
1465
+ } else {
1466
+ // Use Claude Agents SDK
1467
+ success = await abortClaudeSDKSession(data.sessionId);
1468
+ }
1469
+
1470
+ writer.send(createNormalizedMessage({ kind: 'complete', exitCode: success ? 0 : 1, aborted: true, success, sessionId: data.sessionId, provider }));
1471
+ } else if (data.type === 'claude-permission-response') {
1472
+ // Relay UI approval decisions back into the SDK control flow.
1473
+ // This does not persist permissions; it only resolves the in-flight request,
1474
+ // introduced so the SDK can resume once the user clicks Allow/Deny.
1475
+ if (data.requestId) {
1476
+ resolveToolApproval(data.requestId, {
1477
+ allow: Boolean(data.allow),
1478
+ updatedInput: data.updatedInput,
1479
+ message: data.message,
1480
+ rememberEntry: data.rememberEntry
1481
+ });
1482
+ }
1483
+ } else if (data.type === 'cursor-abort') {
1484
+ console.log('[DEBUG] Abort Cursor session:', data.sessionId);
1485
+ const success = abortCursorSession(data.sessionId);
1486
+ writer.send(createNormalizedMessage({ kind: 'complete', exitCode: success ? 0 : 1, aborted: true, success, sessionId: data.sessionId, provider: 'cursor' }));
1487
+ } else if (data.type === 'check-session-status') {
1488
+ // Check if a specific session is currently processing
1489
+ const provider = data.provider || 'claude';
1490
+ const sessionId = data.sessionId;
1491
+ let isActive;
1492
+
1493
+ if (provider === 'cursor') {
1494
+ isActive = isCursorSessionActive(sessionId);
1495
+ } else if (provider === 'codex') {
1496
+ isActive = isCodexSessionActive(sessionId);
1497
+ } else if (provider === 'gemini') {
1498
+ isActive = isGeminiSessionActive(sessionId);
1499
+ } else {
1500
+ // Use Claude Agents SDK
1501
+ isActive = isClaudeSDKSessionActive(sessionId);
1502
+ if (isActive) {
1503
+ // Reconnect the session's writer to the new WebSocket so
1504
+ // subsequent SDK output flows to the refreshed client.
1505
+ reconnectSessionWriter(sessionId, ws);
1506
+ }
1507
+ }
1508
+
1509
+ writer.send({
1510
+ type: 'session-status',
1511
+ sessionId,
1512
+ provider,
1513
+ isProcessing: isActive
1514
+ });
1515
+ } else if (data.type === 'get-pending-permissions') {
1516
+ // Return pending permission requests for a session
1517
+ const sessionId = data.sessionId;
1518
+ if (sessionId && isClaudeSDKSessionActive(sessionId)) {
1519
+ const pending = getPendingApprovalsForSession(sessionId);
1520
+ writer.send({
1521
+ type: 'pending-permissions-response',
1522
+ sessionId,
1523
+ data: pending
1524
+ });
1525
+ }
1526
+ } else if (data.type === 'get-active-sessions') {
1527
+ // Get all currently active sessions
1528
+ const activeSessions = {
1529
+ claude: getActiveClaudeSDKSessions(),
1530
+ cursor: getActiveCursorSessions(),
1531
+ codex: getActiveCodexSessions(),
1532
+ gemini: getActiveGeminiSessions()
1533
+ };
1534
+ writer.send({
1535
+ type: 'active-sessions',
1536
+ sessions: activeSessions
1537
+ });
1538
+ }
1539
+ } catch (error) {
1540
+ console.error('[ERROR] Chat WebSocket error:', error.message);
1541
+ writer.send({
1542
+ type: 'error',
1543
+ error: error.message
1544
+ });
1545
+ }
1546
+ });
1547
+
1548
+ ws.on('close', () => {
1549
+ console.log('🔌 Chat client disconnected');
1550
+ // Remove from connected clients
1551
+ connectedClients.delete(ws);
1552
+ });
1553
+ }
1554
+
1555
+ // Handle shell WebSocket connections
1556
+ function handleShellConnection(ws) {
1557
+ console.log('🐚 Shell client connected');
1558
+ let shellProcess = null;
1559
+ let ptySessionKey = null;
1560
+ let urlDetectionBuffer = '';
1561
+ const announcedAuthUrls = new Set();
1562
+
1563
+ ws.on('message', async (message) => {
1564
+ try {
1565
+ const data = JSON.parse(message);
1566
+ console.log('📨 Shell message received:', data.type);
1567
+
1568
+ if (data.type === 'init') {
1569
+ const projectPath = data.projectPath || process.cwd();
1570
+ const sessionId = data.sessionId;
1571
+ const hasSession = data.hasSession;
1572
+ const provider = data.provider || 'claude';
1573
+ const initialCommand = data.initialCommand;
1574
+ const isPlainShell = data.isPlainShell || (!!initialCommand && !hasSession) || provider === 'plain-shell';
1575
+ urlDetectionBuffer = '';
1576
+ announcedAuthUrls.clear();
1577
+
1578
+ // Login commands (Claude/Cursor auth) should never reuse cached sessions
1579
+ const isLoginCommand = initialCommand && (
1580
+ initialCommand.includes('setup-token') ||
1581
+ initialCommand.includes('cursor-agent login') ||
1582
+ initialCommand.includes('auth login')
1583
+ );
1584
+
1585
+ // Include command hash in session key so different commands get separate sessions
1586
+ const commandSuffix = isPlainShell && initialCommand
1587
+ ? `_cmd_${Buffer.from(initialCommand).toString('base64').slice(0, 16)}`
1588
+ : '';
1589
+ ptySessionKey = `${projectPath}_${sessionId || 'default'}${commandSuffix}`;
1590
+
1591
+ // Kill any existing login session before starting fresh
1592
+ if (isLoginCommand) {
1593
+ const oldSession = ptySessionsMap.get(ptySessionKey);
1594
+ if (oldSession) {
1595
+ console.log('🧹 Cleaning up existing login session:', ptySessionKey);
1596
+ if (oldSession.timeoutId) clearTimeout(oldSession.timeoutId);
1597
+ if (oldSession.pty && oldSession.pty.kill) oldSession.pty.kill();
1598
+ ptySessionsMap.delete(ptySessionKey);
1599
+ }
1600
+ }
1601
+
1602
+ const existingSession = isLoginCommand ? null : ptySessionsMap.get(ptySessionKey);
1603
+ if (existingSession) {
1604
+ console.log('♻️ Reconnecting to existing PTY session:', ptySessionKey);
1605
+ shellProcess = existingSession.pty;
1606
+
1607
+ clearTimeout(existingSession.timeoutId);
1608
+
1609
+ ws.send(JSON.stringify({
1610
+ type: 'output',
1611
+ data: `\x1b[36m[Reconnected to existing session]\x1b[0m\r\n`
1612
+ }));
1613
+
1614
+ if (existingSession.buffer && existingSession.buffer.length > 0) {
1615
+ console.log(`📜 Sending ${existingSession.buffer.length} buffered messages`);
1616
+ existingSession.buffer.forEach(bufferedData => {
1617
+ ws.send(JSON.stringify({
1618
+ type: 'output',
1619
+ data: bufferedData
1620
+ }));
1621
+ });
1622
+ }
1623
+
1624
+ existingSession.ws = ws;
1625
+
1626
+ return;
1627
+ }
1628
+
1629
+ console.log('[INFO] Starting shell in:', projectPath);
1630
+ console.log('📋 Session info:', hasSession ? `Resume session ${sessionId}` : (isPlainShell ? 'Plain shell mode' : 'New session'));
1631
+ console.log('🤖 Provider:', isPlainShell ? 'plain-shell' : provider);
1632
+ if (initialCommand) {
1633
+ console.log('⚡ Initial command:', initialCommand);
1634
+ }
1635
+
1636
+ // First send a welcome message
1637
+ let welcomeMsg;
1638
+ if (isPlainShell) {
1639
+ welcomeMsg = `\x1b[36mStarting terminal in: ${projectPath}\x1b[0m\r\n`;
1640
+ } else {
1641
+ const providerName = provider === 'cursor' ? 'Cursor' : (provider === 'codex' ? 'Codex' : (provider === 'gemini' ? 'Gemini' : 'Claude'));
1642
+ welcomeMsg = hasSession ?
1643
+ `\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n` :
1644
+ `\x1b[36mStarting new ${providerName} session in: ${projectPath}\x1b[0m\r\n`;
1645
+ }
1646
+
1647
+ ws.send(JSON.stringify({
1648
+ type: 'output',
1649
+ data: welcomeMsg
1650
+ }));
1651
+
1652
+ try {
1653
+ // Validate projectPath — resolve to absolute and verify it exists
1654
+ const resolvedProjectPath = path.resolve(projectPath);
1655
+ try {
1656
+ const stats = fs.statSync(resolvedProjectPath);
1657
+ if (!stats.isDirectory()) {
1658
+ throw new Error('Not a directory');
1659
+ }
1660
+ } catch (pathErr) {
1661
+ ws.send(JSON.stringify({ type: 'error', message: 'Invalid project path' }));
1662
+ return;
1663
+ }
1664
+
1665
+ // Validate sessionId — only allow safe characters
1666
+ const safeSessionIdPattern = /^[a-zA-Z0-9_.\-:]+$/;
1667
+ if (sessionId && !safeSessionIdPattern.test(sessionId)) {
1668
+ ws.send(JSON.stringify({ type: 'error', message: 'Invalid session ID' }));
1669
+ return;
1670
+ }
1671
+
1672
+ // Build shell command — use cwd for project path (never interpolate into shell string)
1673
+ let shellCommand;
1674
+ if (isPlainShell) {
1675
+ // Plain shell mode - run the initial command in the project directory
1676
+ shellCommand = initialCommand;
1677
+ } else if (provider === 'cursor') {
1678
+ if (hasSession && sessionId) {
1679
+ shellCommand = `cursor-agent --resume="${sessionId}"`;
1680
+ } else {
1681
+ shellCommand = 'cursor-agent';
1682
+ }
1683
+ } else if (provider === 'codex') {
1684
+ // Use codex command; attempt to resume and fall back to a new session when the resume fails.
1685
+ if (hasSession && sessionId) {
1686
+ if (os.platform() === 'win32') {
1687
+ // PowerShell syntax for fallback
1688
+ shellCommand = `codex resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { codex }`;
1689
+ } else {
1690
+ shellCommand = `codex resume "${sessionId}" || codex`;
1691
+ }
1692
+ } else {
1693
+ shellCommand = 'codex';
1694
+ }
1695
+ } else if (provider === 'gemini') {
1696
+ const command = initialCommand || 'gemini';
1697
+ let resumeId = sessionId;
1698
+ if (hasSession && sessionId) {
1699
+ try {
1700
+ // Gemini CLI enforces its own native session IDs, unlike other agents that accept arbitrary string names.
1701
+ // The UI only knows about its internal generated `sessionId` (e.g. gemini_1234).
1702
+ // We must fetch the mapping from the backend session manager to pass the native `cliSessionId` to the shell.
1703
+ const sess = sessionManager.getSession(sessionId);
1704
+ if (sess && sess.cliSessionId) {
1705
+ resumeId = sess.cliSessionId;
1706
+ // Validate the looked-up CLI session ID too
1707
+ if (!safeSessionIdPattern.test(resumeId)) {
1708
+ resumeId = null;
1709
+ }
1710
+ }
1711
+ } catch (err) {
1712
+ console.error('Failed to get Gemini CLI session ID:', err);
1713
+ }
1714
+ }
1715
+
1716
+ if (hasSession && resumeId) {
1717
+ shellCommand = `${command} --resume "${resumeId}"`;
1718
+ } else {
1719
+ shellCommand = command;
1720
+ }
1721
+ } else {
1722
+ // Claude (default provider)
1723
+ const command = initialCommand || 'claude';
1724
+ if (hasSession && sessionId) {
1725
+ if (os.platform() === 'win32') {
1726
+ shellCommand = `claude --resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { claude }`;
1727
+ } else {
1728
+ shellCommand = `claude --resume "${sessionId}" || claude`;
1729
+ }
1730
+ } else {
1731
+ shellCommand = command;
1732
+ }
1733
+ }
1734
+
1735
+ console.log('🔧 Executing shell command:', shellCommand);
1736
+
1737
+ // Use appropriate shell based on platform
1738
+ const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
1739
+ const shellArgs = os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand];
1740
+
1741
+ // Use terminal dimensions from client if provided, otherwise use defaults
1742
+ const termCols = data.cols || 80;
1743
+ const termRows = data.rows || 24;
1744
+ console.log('📐 Using terminal dimensions:', termCols, 'x', termRows);
1745
+
1746
+ shellProcess = pty.spawn(shell, shellArgs, {
1747
+ name: 'xterm-256color',
1748
+ cols: termCols,
1749
+ rows: termRows,
1750
+ cwd: resolvedProjectPath,
1751
+ env: {
1752
+ ...process.env,
1753
+ TERM: 'xterm-256color',
1754
+ COLORTERM: 'truecolor',
1755
+ FORCE_COLOR: '3'
1756
+ }
1757
+ });
1758
+
1759
+ console.log('🟢 Shell process started with PTY, PID:', shellProcess.pid);
1760
+
1761
+ ptySessionsMap.set(ptySessionKey, {
1762
+ pty: shellProcess,
1763
+ ws: ws,
1764
+ buffer: [],
1765
+ timeoutId: null,
1766
+ projectPath,
1767
+ sessionId
1768
+ });
1769
+
1770
+ // Handle data output
1771
+ shellProcess.onData((data) => {
1772
+ const session = ptySessionsMap.get(ptySessionKey);
1773
+ if (!session) return;
1774
+
1775
+ if (session.buffer.length < 5000) {
1776
+ session.buffer.push(data);
1777
+ } else {
1778
+ session.buffer.shift();
1779
+ session.buffer.push(data);
1780
+ }
1781
+
1782
+ if (session.ws && session.ws.readyState === WebSocket.OPEN) {
1783
+ let outputData = data;
1784
+
1785
+ const cleanChunk = stripAnsiSequences(data);
1786
+ urlDetectionBuffer = `${urlDetectionBuffer}${cleanChunk}`.slice(-SHELL_URL_PARSE_BUFFER_LIMIT);
1787
+
1788
+ outputData = outputData.replace(
1789
+ /OPEN_URL:\s*(https?:\/\/[^\s\x1b\x07]+)/g,
1790
+ '[INFO] Opening in browser: $1'
1791
+ );
1792
+
1793
+ const emitAuthUrl = (detectedUrl, autoOpen = false) => {
1794
+ const normalizedUrl = normalizeDetectedUrl(detectedUrl);
1795
+ if (!normalizedUrl) return;
1796
+
1797
+ const isNewUrl = !announcedAuthUrls.has(normalizedUrl);
1798
+ if (isNewUrl) {
1799
+ announcedAuthUrls.add(normalizedUrl);
1800
+ session.ws.send(JSON.stringify({
1801
+ type: 'auth_url',
1802
+ url: normalizedUrl,
1803
+ autoOpen
1804
+ }));
1805
+ }
1806
+
1807
+ };
1808
+
1809
+ const normalizedDetectedUrls = extractUrlsFromText(urlDetectionBuffer)
1810
+ .map((url) => normalizeDetectedUrl(url))
1811
+ .filter(Boolean);
1812
+
1813
+ // Prefer the most complete URL if shorter prefix variants are also present.
1814
+ const dedupedDetectedUrls = Array.from(new Set(normalizedDetectedUrls)).filter((url, _, urls) =>
1815
+ !urls.some((otherUrl) => otherUrl !== url && otherUrl.startsWith(url))
1816
+ );
1817
+
1818
+ dedupedDetectedUrls.forEach((url) => emitAuthUrl(url, false));
1819
+
1820
+ if (shouldAutoOpenUrlFromOutput(cleanChunk) && dedupedDetectedUrls.length > 0) {
1821
+ const bestUrl = dedupedDetectedUrls.reduce((longest, current) =>
1822
+ current.length > longest.length ? current : longest
1823
+ );
1824
+ emitAuthUrl(bestUrl, true);
1825
+ }
1826
+
1827
+ // Send regular output
1828
+ session.ws.send(JSON.stringify({
1829
+ type: 'output',
1830
+ data: outputData
1831
+ }));
1832
+ }
1833
+ });
1834
+
1835
+ // Handle process exit
1836
+ shellProcess.onExit((exitCode) => {
1837
+ console.log('🔚 Shell process exited with code:', exitCode.exitCode, 'signal:', exitCode.signal);
1838
+ const session = ptySessionsMap.get(ptySessionKey);
1839
+ if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {
1840
+ session.ws.send(JSON.stringify({
1841
+ type: 'output',
1842
+ data: `\r\n\x1b[33mProcess exited with code ${exitCode.exitCode}${exitCode.signal ? ` (${exitCode.signal})` : ''}\x1b[0m\r\n`
1843
+ }));
1844
+ }
1845
+ if (session && session.timeoutId) {
1846
+ clearTimeout(session.timeoutId);
1847
+ }
1848
+ ptySessionsMap.delete(ptySessionKey);
1849
+ shellProcess = null;
1850
+ });
1851
+
1852
+ } catch (spawnError) {
1853
+ console.error('[ERROR] Error spawning process:', spawnError);
1854
+ ws.send(JSON.stringify({
1855
+ type: 'output',
1856
+ data: `\r\n\x1b[31mError: ${spawnError.message}\x1b[0m\r\n`
1857
+ }));
1858
+ }
1859
+
1860
+ } else if (data.type === 'input') {
1861
+ // Send input to shell process
1862
+ if (shellProcess && shellProcess.write) {
1863
+ try {
1864
+ shellProcess.write(data.data);
1865
+ } catch (error) {
1866
+ console.error('Error writing to shell:', error);
1867
+ }
1868
+ } else {
1869
+ console.warn('No active shell process to send input to');
1870
+ }
1871
+ } else if (data.type === 'resize') {
1872
+ // Handle terminal resize
1873
+ if (shellProcess && shellProcess.resize) {
1874
+ console.log('Terminal resize requested:', data.cols, 'x', data.rows);
1875
+ shellProcess.resize(data.cols, data.rows);
1876
+ }
1877
+ }
1878
+ } catch (error) {
1879
+ console.error('[ERROR] Shell WebSocket error:', error.message);
1880
+ if (ws.readyState === WebSocket.OPEN) {
1881
+ ws.send(JSON.stringify({
1882
+ type: 'output',
1883
+ data: `\r\n\x1b[31mError: ${error.message}\x1b[0m\r\n`
1884
+ }));
1885
+ }
1886
+ }
1887
+ });
1888
+
1889
+ ws.on('close', () => {
1890
+ console.log('🔌 Shell client disconnected');
1891
+
1892
+ if (ptySessionKey) {
1893
+ const session = ptySessionsMap.get(ptySessionKey);
1894
+ if (session) {
1895
+ console.log('⏳ PTY session kept alive, will timeout in 30 minutes:', ptySessionKey);
1896
+ session.ws = null;
1897
+
1898
+ session.timeoutId = setTimeout(() => {
1899
+ console.log('⏰ PTY session timeout, killing process:', ptySessionKey);
1900
+ if (session.pty && session.pty.kill) {
1901
+ session.pty.kill();
1902
+ }
1903
+ ptySessionsMap.delete(ptySessionKey);
1904
+ }, PTY_SESSION_TIMEOUT);
1905
+ }
1906
+ }
1907
+ });
1908
+
1909
+ ws.on('error', (error) => {
1910
+ console.error('[ERROR] Shell WebSocket error:', error);
1911
+ });
1912
+ }
1913
+ // Image upload endpoint
1914
+ app.post('/api/projects/:projectName/upload-images', authenticateToken, async (req, res) => {
1915
+ try {
1916
+ const multer = (await import('multer')).default;
1917
+ const path = (await import('path')).default;
1918
+ const fs = (await import('fs')).promises;
1919
+ const os = (await import('os')).default;
1920
+
1921
+ // Configure multer for image uploads
1922
+ const storage = multer.diskStorage({
1923
+ destination: async (req, file, cb) => {
1924
+ const uploadDir = path.join(os.tmpdir(), 'claude-ui-uploads', String(req.user.id));
1925
+ await fs.mkdir(uploadDir, { recursive: true });
1926
+ cb(null, uploadDir);
1927
+ },
1928
+ filename: (req, file, cb) => {
1929
+ const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
1930
+ const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_');
1931
+ cb(null, uniqueSuffix + '-' + sanitizedName);
1932
+ }
1933
+ });
1934
+
1935
+ const fileFilter = (req, file, cb) => {
1936
+ const allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
1937
+ if (allowedMimes.includes(file.mimetype)) {
1938
+ cb(null, true);
1939
+ } else {
1940
+ cb(new Error('Invalid file type. Only JPEG, PNG, GIF, WebP, and SVG are allowed.'));
1941
+ }
1942
+ };
1943
+
1944
+ const upload = multer({
1945
+ storage,
1946
+ fileFilter,
1947
+ limits: {
1948
+ fileSize: 5 * 1024 * 1024, // 5MB
1949
+ files: 5
1950
+ }
1951
+ });
1952
+
1953
+ // Handle multipart form data
1954
+ upload.array('images', 5)(req, res, async (err) => {
1955
+ if (err) {
1956
+ return res.status(400).json({ error: err.message });
1957
+ }
1958
+
1959
+ if (!req.files || req.files.length === 0) {
1960
+ return res.status(400).json({ error: 'No image files provided' });
1961
+ }
1962
+
1963
+ try {
1964
+ // Process uploaded images
1965
+ const processedImages = await Promise.all(
1966
+ req.files.map(async (file) => {
1967
+ // Read file and convert to base64
1968
+ const buffer = await fs.readFile(file.path);
1969
+ const base64 = buffer.toString('base64');
1970
+ const mimeType = file.mimetype;
1971
+
1972
+ // Clean up temp file immediately
1973
+ await fs.unlink(file.path);
1974
+
1975
+ return {
1976
+ name: file.originalname,
1977
+ data: `data:${mimeType};base64,${base64}`,
1978
+ size: file.size,
1979
+ mimeType: mimeType
1980
+ };
1981
+ })
1982
+ );
1983
+
1984
+ res.json({ images: processedImages });
1985
+ } catch (error) {
1986
+ console.error('Error processing images:', error);
1987
+ // Clean up any remaining files
1988
+ await Promise.all(req.files.map(f => fs.unlink(f.path).catch(() => { })));
1989
+ res.status(500).json({ error: 'Failed to process images' });
1990
+ }
1991
+ });
1992
+ } catch (error) {
1993
+ console.error('Error in image upload endpoint:', error);
1994
+ res.status(500).json({ error: 'Internal server error' });
1995
+ }
1996
+ });
1997
+
1998
+ // Get token usage for a specific session
1999
+ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => {
2000
+ try {
2001
+ const { projectName, sessionId } = req.params;
2002
+ const { provider = 'claude' } = req.query;
2003
+ const homeDir = os.homedir();
2004
+
2005
+ // Allow only safe characters in sessionId
2006
+ const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
2007
+ if (!safeSessionId || safeSessionId !== String(sessionId)) {
2008
+ return res.status(400).json({ error: 'Invalid sessionId' });
2009
+ }
2010
+
2011
+ // Handle Cursor sessions - they use SQLite and don't have token usage info
2012
+ if (provider === 'cursor') {
2013
+ return res.json({
2014
+ used: 0,
2015
+ total: 0,
2016
+ breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
2017
+ unsupported: true,
2018
+ message: 'Token usage tracking not available for Cursor sessions'
2019
+ });
2020
+ }
2021
+
2022
+ // Handle Gemini sessions - they are raw logs in our current setup
2023
+ if (provider === 'gemini') {
2024
+ return res.json({
2025
+ used: 0,
2026
+ total: 0,
2027
+ breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
2028
+ unsupported: true,
2029
+ message: 'Token usage tracking not available for Gemini sessions'
2030
+ });
2031
+ }
2032
+
2033
+ // Handle Codex sessions
2034
+ if (provider === 'codex') {
2035
+ const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
2036
+
2037
+ // Find the session file by searching for the session ID
2038
+ const findSessionFile = async (dir) => {
2039
+ try {
2040
+ const entries = await fsPromises.readdir(dir, { withFileTypes: true });
2041
+ for (const entry of entries) {
2042
+ const fullPath = path.join(dir, entry.name);
2043
+ if (entry.isDirectory()) {
2044
+ const found = await findSessionFile(fullPath);
2045
+ if (found) return found;
2046
+ } else if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) {
2047
+ return fullPath;
2048
+ }
2049
+ }
2050
+ } catch (error) {
2051
+ // Skip directories we can't read
2052
+ }
2053
+ return null;
2054
+ };
2055
+
2056
+ const sessionFilePath = await findSessionFile(codexSessionsDir);
2057
+
2058
+ if (!sessionFilePath) {
2059
+ return res.status(404).json({ error: 'Codex session file not found', sessionId: safeSessionId });
2060
+ }
2061
+
2062
+ // Read and parse the Codex JSONL file
2063
+ let fileContent;
2064
+ try {
2065
+ fileContent = await fsPromises.readFile(sessionFilePath, 'utf8');
2066
+ } catch (error) {
2067
+ if (error.code === 'ENOENT') {
2068
+ return res.status(404).json({ error: 'Session file not found', path: sessionFilePath });
2069
+ }
2070
+ throw error;
2071
+ }
2072
+ const lines = fileContent.trim().split('\n');
2073
+ let totalTokens = 0;
2074
+ let contextWindow = 200000; // Default for Codex/OpenAI
2075
+
2076
+ // Find the latest token_count event with info (scan from end)
2077
+ for (let i = lines.length - 1; i >= 0; i--) {
2078
+ try {
2079
+ const entry = JSON.parse(lines[i]);
2080
+
2081
+ // Codex stores token info in event_msg with type: "token_count"
2082
+ if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
2083
+ const tokenInfo = entry.payload.info;
2084
+ if (tokenInfo.total_token_usage) {
2085
+ totalTokens = tokenInfo.total_token_usage.total_tokens || 0;
2086
+ }
2087
+ if (tokenInfo.model_context_window) {
2088
+ contextWindow = tokenInfo.model_context_window;
2089
+ }
2090
+ break; // Stop after finding the latest token count
2091
+ }
2092
+ } catch (parseError) {
2093
+ // Skip lines that can't be parsed
2094
+ continue;
2095
+ }
2096
+ }
2097
+
2098
+ return res.json({
2099
+ used: totalTokens,
2100
+ total: contextWindow
2101
+ });
2102
+ }
2103
+
2104
+ // Handle Claude sessions (default)
2105
+ // Extract actual project path
2106
+ let projectPath;
2107
+ try {
2108
+ projectPath = await extractProjectDirectory(projectName);
2109
+ } catch (error) {
2110
+ console.error('Error extracting project directory:', error);
2111
+ return res.status(500).json({ error: 'Failed to determine project path' });
2112
+ }
2113
+
2114
+ // Construct the JSONL file path
2115
+ // Claude stores session files in ~/.claude/projects/[encoded-project-path]/[session-id].jsonl
2116
+ // The encoding replaces any non-alphanumeric character (except -) with -
2117
+ const encodedPath = projectPath.replace(/[^a-zA-Z0-9-]/g, '-');
2118
+ const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
2119
+
2120
+ const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
2121
+
2122
+ // Constrain to projectDir
2123
+ const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath));
2124
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
2125
+ return res.status(400).json({ error: 'Invalid path' });
2126
+ }
2127
+
2128
+ // Read and parse the JSONL file
2129
+ let fileContent;
2130
+ try {
2131
+ fileContent = await fsPromises.readFile(jsonlPath, 'utf8');
2132
+ } catch (error) {
2133
+ if (error.code === 'ENOENT') {
2134
+ return res.status(404).json({ error: 'Session file not found', path: jsonlPath });
2135
+ }
2136
+ throw error; // Re-throw other errors to be caught by outer try-catch
2137
+ }
2138
+ const lines = fileContent.trim().split('\n');
2139
+
2140
+ const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10);
2141
+ const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000;
2142
+ let inputTokens = 0;
2143
+ let cacheCreationTokens = 0;
2144
+ let cacheReadTokens = 0;
2145
+
2146
+ // Find the latest assistant message with usage data (scan from end)
2147
+ for (let i = lines.length - 1; i >= 0; i--) {
2148
+ try {
2149
+ const entry = JSON.parse(lines[i]);
2150
+
2151
+ // Only count assistant messages which have usage data
2152
+ if (entry.type === 'assistant' && entry.message?.usage) {
2153
+ const usage = entry.message.usage;
2154
+
2155
+ // Use token counts from latest assistant message only
2156
+ inputTokens = usage.input_tokens || 0;
2157
+ cacheCreationTokens = usage.cache_creation_input_tokens || 0;
2158
+ cacheReadTokens = usage.cache_read_input_tokens || 0;
2159
+
2160
+ break; // Stop after finding the latest assistant message
2161
+ }
2162
+ } catch (parseError) {
2163
+ // Skip lines that can't be parsed
2164
+ continue;
2165
+ }
2166
+ }
2167
+
2168
+ // Calculate total context usage (excluding output_tokens, as per ccusage)
2169
+ const totalUsed = inputTokens + cacheCreationTokens + cacheReadTokens;
2170
+
2171
+ res.json({
2172
+ used: totalUsed,
2173
+ total: contextWindow,
2174
+ breakdown: {
2175
+ input: inputTokens,
2176
+ cacheCreation: cacheCreationTokens,
2177
+ cacheRead: cacheReadTokens
2178
+ }
2179
+ });
2180
+ } catch (error) {
2181
+ console.error('Error reading session token usage:', error);
2182
+ res.status(500).json({ error: 'Failed to read session token usage' });
2183
+ }
2184
+ });
2185
+
2186
+ // Serve React app for all other routes (excluding static files)
2187
+ app.get('*', (req, res) => {
2188
+ // Skip requests for static assets (files with extensions)
2189
+ if (path.extname(req.path)) {
2190
+ return res.status(404).send('Not found');
2191
+ }
2192
+
2193
+ // Only serve index.html for HTML routes, not for static assets
2194
+ // Static assets should already be handled by express.static middleware above
2195
+ const indexPath = path.join(APP_ROOT, 'dist', 'index.html');
2196
+
2197
+ // Check if dist/index.html exists (production build available)
2198
+ if (fs.existsSync(indexPath)) {
2199
+ // Set no-cache headers for HTML to prevent service worker issues
2200
+ res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
2201
+ res.setHeader('Pragma', 'no-cache');
2202
+ res.setHeader('Expires', '0');
2203
+ res.sendFile(indexPath);
2204
+ } else {
2205
+ // In development, redirect to Vite dev server only if dist doesn't exist
2206
+ const redirectHost = getConnectableHost(req.hostname);
2207
+ res.redirect(`${req.protocol}://${redirectHost}:${VITE_PORT}`);
2208
+ }
2209
+ });
2210
+
2211
+ // global error middleware must be last
2212
+ app.use((err, req, res, next) => {
2213
+ if (err instanceof AppError) {
2214
+ return res.status(err.statusCode).json({
2215
+ success: false,
2216
+ error: {
2217
+ code: err.code,
2218
+ message: err.message,
2219
+ details: err.details,
2220
+ },
2221
+ });
2222
+ }
2223
+
2224
+ console.error(err);
2225
+
2226
+ return res.status(500).json({
2227
+ success: false,
2228
+ error: {
2229
+ code: 'INTERNAL_ERROR',
2230
+ message: 'Internal server error',
2231
+ },
2232
+ });
2233
+ });
2234
+
2235
+ // Helper function to convert permissions to rwx format
2236
+ function permToRwx(perm) {
2237
+ const r = perm & 4 ? 'r' : '-';
2238
+ const w = perm & 2 ? 'w' : '-';
2239
+ const x = perm & 1 ? 'x' : '-';
2240
+ return r + w + x;
2241
+ }
2242
+
2243
+ async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden = true) {
2244
+ // Using fsPromises from import
2245
+ const items = [];
2246
+
2247
+ try {
2248
+ const entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
2249
+
2250
+ for (const entry of entries) {
2251
+ // Debug: log all entries including hidden files
2252
+
2253
+
2254
+ // Skip heavy build directories and VCS directories
2255
+ if (entry.name === 'node_modules' ||
2256
+ entry.name === 'dist' ||
2257
+ entry.name === 'build' ||
2258
+ entry.name === '.git' ||
2259
+ entry.name === '.svn' ||
2260
+ entry.name === '.hg') continue;
2261
+
2262
+ const itemPath = path.join(dirPath, entry.name);
2263
+ const item = {
2264
+ name: entry.name,
2265
+ path: itemPath,
2266
+ type: entry.isDirectory() ? 'directory' : 'file'
2267
+ };
2268
+
2269
+ // Get file stats for additional metadata
2270
+ try {
2271
+ const stats = await fsPromises.stat(itemPath);
2272
+ item.size = stats.size;
2273
+ item.modified = stats.mtime.toISOString();
2274
+
2275
+ // Convert permissions to rwx format
2276
+ const mode = stats.mode;
2277
+ const ownerPerm = (mode >> 6) & 7;
2278
+ const groupPerm = (mode >> 3) & 7;
2279
+ const otherPerm = mode & 7;
2280
+ item.permissions = ((mode >> 6) & 7).toString() + ((mode >> 3) & 7).toString() + (mode & 7).toString();
2281
+ item.permissionsRwx = permToRwx(ownerPerm) + permToRwx(groupPerm) + permToRwx(otherPerm);
2282
+ } catch (statError) {
2283
+ // If stat fails, provide default values
2284
+ item.size = 0;
2285
+ item.modified = null;
2286
+ item.permissions = '000';
2287
+ item.permissionsRwx = '---------';
2288
+ }
2289
+
2290
+ if (entry.isDirectory() && currentDepth < maxDepth) {
2291
+ // Recursively get subdirectories but limit depth
2292
+ try {
2293
+ // Check if we can access the directory before trying to read it
2294
+ await fsPromises.access(item.path, fs.constants.R_OK);
2295
+ item.children = await getFileTree(item.path, maxDepth, currentDepth + 1, showHidden);
2296
+ } catch (e) {
2297
+ // Silently skip directories we can't access (permission denied, etc.)
2298
+ item.children = [];
2299
+ }
2300
+ }
2301
+
2302
+ items.push(item);
2303
+ }
2304
+ } catch (error) {
2305
+ // Only log non-permission errors to avoid spam
2306
+ if (error.code !== 'EACCES' && error.code !== 'EPERM') {
2307
+ console.error('Error reading directory:', error);
2308
+ }
2309
+ }
2310
+
2311
+ return items.sort((a, b) => {
2312
+ if (a.type !== b.type) {
2313
+ return a.type === 'directory' ? -1 : 1;
2314
+ }
2315
+ return a.name.localeCompare(b.name);
2316
+ });
2317
+ }
2318
+
2319
+ const SERVER_PORT = process.env.SERVER_PORT || 3001;
2320
+ const HOST = process.env.HOST || '0.0.0.0';
2321
+ const DISPLAY_HOST = getConnectableHost(HOST);
2322
+ const VITE_PORT = process.env.VITE_PORT || 5173;
2323
+
2324
+ async function isPortOpen(port, timeoutMs = 800) {
2325
+ return await new Promise((resolve) => {
2326
+ const socket = net.createConnection({ host: '127.0.0.1', port: Number(port) });
2327
+ let settled = false;
2328
+ const done = (value) => {
2329
+ if (settled) return;
2330
+ settled = true;
2331
+ socket.destroy();
2332
+ resolve(value);
2333
+ };
2334
+
2335
+ socket.setTimeout(timeoutMs);
2336
+ socket.once('connect', () => done(true));
2337
+ socket.once('timeout', () => done(false));
2338
+ socket.once('error', () => done(false));
2339
+ });
2340
+ }
2341
+
2342
+ async function waitForPortOpen(port, timeoutMs = 25000) {
2343
+ const deadline = Date.now() + timeoutMs;
2344
+ while (Date.now() < deadline) {
2345
+ if (await isPortOpen(port)) {
2346
+ return true;
2347
+ }
2348
+ await new Promise(resolve => setTimeout(resolve, 1000));
2349
+ }
2350
+ return false;
2351
+ }
2352
+
2353
+ function printSystemDaemonActiveNotice(port) {
2354
+ const effectivePort = Number(port) || 3001;
2355
+ const statusCommand = buildDaemonCliCommand(
2356
+ { subcommand: 'status', mode: 'system' },
2357
+ DAEMON_COMMAND_CONTEXT
2358
+ );
2359
+ const stopCommand = buildDaemonCliCommand(
2360
+ { subcommand: 'stop', mode: 'system' },
2361
+ DAEMON_COMMAND_CONTEXT
2362
+ );
2363
+ const logsCommand = buildDaemonCliCommand(
2364
+ { subcommand: 'logs', mode: 'system' },
2365
+ DAEMON_COMMAND_CONTEXT
2366
+ );
2367
+ console.log(`${c.ok('[OK]')} System daemon is active and managing Pixcode.`);
2368
+ console.log(`${c.info('[INFO]')} Health URL: ${c.bright(`http://localhost:${effectivePort}/health`)}`);
2369
+ console.log(`${c.info('[INFO]')} Status: ${c.bright(statusCommand)}`);
2370
+ console.log(`${c.info('[INFO]')} Stop: ${c.bright(stopCommand)}`);
2371
+ console.log(`${c.info('[INFO]')} Logs: ${c.bright(logsCommand)}`);
2372
+ }
2373
+
2374
+ function printUserDaemonActiveNotice(port, frontendPort) {
2375
+ const effectivePort = Number(port) || 3001;
2376
+ const effectiveFrontendPort = Number(frontendPort) || 5173;
2377
+ const statusCommand = buildDaemonCliCommand(
2378
+ { subcommand: 'status', mode: 'user' },
2379
+ DAEMON_COMMAND_CONTEXT
2380
+ );
2381
+ const stopCommand = buildDaemonCliCommand(
2382
+ { subcommand: 'stop', mode: 'user' },
2383
+ DAEMON_COMMAND_CONTEXT
2384
+ );
2385
+ const logsCommand = buildDaemonCliCommand(
2386
+ { subcommand: 'logs', mode: 'user' },
2387
+ DAEMON_COMMAND_CONTEXT
2388
+ );
2389
+ console.log(`${c.ok('[OK]')} User daemon is active for this account.`);
2390
+ console.log(`${c.info('[INFO]')} Backend: ${c.bright(`http://localhost:${effectivePort}`)}`);
2391
+ console.log(`${c.info('[INFO]')} Frontend: ${c.bright(`http://localhost:${effectiveFrontendPort}`)}`);
2392
+ console.log(`${c.info('[INFO]')} Status: ${c.bright(statusCommand)}`);
2393
+ console.log(`${c.info('[INFO]')} Stop: ${c.bright(stopCommand)}`);
2394
+ console.log(`${c.info('[INFO]')} Logs: ${c.bright(logsCommand)}`);
2395
+ console.log(`${c.tip('[TIP]')} For login/reboot persistence, enable linger once: ${c.bright(`sudo loginctl enable-linger ${os.userInfo().username}`)}`);
2396
+ }
2397
+
2398
+ function isSystemPermissionError(error) {
2399
+ const message = String(error?.message || error || '');
2400
+ return /(access denied|permission denied|must be root|interactive authentication required|not permitted|failed to connect to bus|operation not permitted|authentication is required|polkit)/i.test(message);
2401
+ }
2402
+
2403
+ async function maybeAutoDaemonBootstrapFromIndex() {
2404
+ if (process.platform !== 'linux') return false;
2405
+ if (process.env.PIXCODE_DAEMON_MANAGED === '1') return false;
2406
+ if (process.env.PIXCODE_NO_DAEMON === '1') return false;
2407
+ if (process.env.PIXCODE_DAEMON_ATTEMPTED === '1') return false;
2408
+
2409
+ process.env.PIXCODE_DAEMON_ATTEMPTED = '1';
2410
+
2411
+ const systemArgs = ['install', '--mode=system', '--port', String(SERVER_PORT), '--frontend-port', String(VITE_PORT)];
2412
+ const userArgs = ['install', '--mode=user', '--port', String(SERVER_PORT), '--frontend-port', String(VITE_PORT)];
2413
+
2414
+ try {
2415
+ console.log(`${c.info('[INFO]')} Linux detected. Enforcing system daemon mode for Pixcode...`);
2416
+ await handleDaemonCommand(systemArgs, {
2417
+ appRoot: APP_ROOT,
2418
+ defaultPort: String(SERVER_PORT),
2419
+ color: c,
2420
+ cliEntry: path.join(APP_ROOT, 'server', 'cli.js'),
2421
+ });
2422
+ return true;
2423
+ } catch (systemError) {
2424
+ const healthySoon = await waitForPortOpen(SERVER_PORT);
2425
+ if (healthySoon) {
2426
+ console.log(`${c.warn('[WARN]')} System daemon health check was delayed, but port ${SERVER_PORT} is now reachable.`);
2427
+ printSystemDaemonActiveNotice(SERVER_PORT);
2428
+ return true;
2429
+ }
2430
+
2431
+ if (!isSystemPermissionError(systemError)) {
2432
+ const installSystemCommand = buildDaemonCliCommand(
2433
+ {
2434
+ subcommand: 'install',
2435
+ mode: 'system',
2436
+ extraArgs: ['--port', String(SERVER_PORT), '--frontend-port', String(VITE_PORT)],
2437
+ },
2438
+ DAEMON_COMMAND_CONTEXT
2439
+ );
2440
+ throw new Error(
2441
+ `System daemon bootstrap failed.\n` +
2442
+ `${systemError.message}\n` +
2443
+ `Run with privileges: ${installSystemCommand}`
2444
+ );
2445
+ }
2446
+
2447
+ console.log(`${c.warn('[WARN]')} System daemon setup requires elevated privileges for this user.`);
2448
+ console.log(`${c.info('[INFO]')} Falling back to user daemon mode for account "${os.userInfo().username}"...`);
2449
+
2450
+ try {
2451
+ await handleDaemonCommand(userArgs, {
2452
+ appRoot: APP_ROOT,
2453
+ defaultPort: String(SERVER_PORT),
2454
+ color: c,
2455
+ cliEntry: path.join(APP_ROOT, 'server', 'cli.js'),
2456
+ });
2457
+ printUserDaemonActiveNotice(SERVER_PORT, VITE_PORT);
2458
+ return true;
2459
+ } catch (userError) {
2460
+ const userHealthySoon = await waitForPortOpen(SERVER_PORT);
2461
+ if (userHealthySoon) {
2462
+ console.log(`${c.warn('[WARN]')} User daemon health check was delayed, but port ${SERVER_PORT} is now reachable.`);
2463
+ printUserDaemonActiveNotice(SERVER_PORT, VITE_PORT);
2464
+ return true;
2465
+ }
2466
+ const installSystemCommand = buildDaemonCliCommand(
2467
+ {
2468
+ subcommand: 'install',
2469
+ mode: 'system',
2470
+ extraArgs: ['--port', String(SERVER_PORT), '--frontend-port', String(VITE_PORT)],
2471
+ },
2472
+ DAEMON_COMMAND_CONTEXT
2473
+ );
2474
+ const installUserCommand = buildDaemonCliCommand(
2475
+ {
2476
+ subcommand: 'install',
2477
+ mode: 'user',
2478
+ extraArgs: ['--port', String(SERVER_PORT), '--frontend-port', String(VITE_PORT)],
2479
+ },
2480
+ DAEMON_COMMAND_CONTEXT
2481
+ );
2482
+ throw new Error(
2483
+ `System daemon bootstrap failed.\n` +
2484
+ `${systemError.message}\n\n` +
2485
+ `User daemon fallback also failed.\n` +
2486
+ `${userError.message}\n` +
2487
+ `Try one of:\n` +
2488
+ `1) ${installSystemCommand}\n` +
2489
+ `2) ${installUserCommand}`
2490
+ );
2491
+ }
2492
+ }
2493
+ }
2494
+
2495
+ // Initialize database and start server
2496
+ async function startServer() {
2497
+ try {
2498
+ if (await maybeAutoDaemonBootstrapFromIndex()) {
2499
+ return;
2500
+ }
2501
+
2502
+ // Initialize authentication database
2503
+ await initializeDatabase();
2504
+
2505
+ // Configure Web Push (VAPID keys)
2506
+ configureWebPush();
2507
+
2508
+ // Check if running in production mode (dist folder exists)
2509
+ const distIndexPath = path.join(APP_ROOT, 'dist', 'index.html');
2510
+ const isProduction = fs.existsSync(distIndexPath);
2511
+
2512
+ // Log Claude implementation mode
2513
+ console.log(`${c.info('[INFO]')} Using Claude Agents SDK for Claude integration`);
2514
+ console.log('');
2515
+
2516
+ if (isProduction) {
2517
+ console.log(`${c.info('[INFO]')} To run in production mode, go to http://${DISPLAY_HOST}:${SERVER_PORT}`);
2518
+ }
2519
+
2520
+ console.log(`${c.info('[INFO]')} To run in development mode with hot-module replacement, go to http://${DISPLAY_HOST}:${VITE_PORT}`);
2521
+
2522
+ server.listen(SERVER_PORT, HOST, async () => {
2523
+ const appInstallPath = APP_ROOT;
2524
+
2525
+ console.log('');
2526
+ console.log(c.dim('═'.repeat(63)));
2527
+ console.log(` ${c.bright('Pixcode Server - Ready')}`);
2528
+ console.log(c.dim('═'.repeat(63)));
2529
+ console.log('');
2530
+ console.log(`${c.info('[INFO]')} Server URL: ${c.bright('http://' + DISPLAY_HOST + ':' + SERVER_PORT)}`);
2531
+ console.log(`${c.info('[INFO]')} Installed at: ${c.dim(appInstallPath)}`);
2532
+ console.log(`${c.tip('[TIP]')} Run "pixcode status" for full configuration details`);
2533
+ console.log('');
2534
+
2535
+ // Start watching the projects folder for changes
2536
+ await setupProjectsWatcher();
2537
+
2538
+ // Start server-side plugin processes for enabled plugins
2539
+ startEnabledPluginServers().catch(err => {
2540
+ console.error('[Plugins] Error during startup:', err.message);
2541
+ });
2542
+ });
2543
+
2544
+ // Clean up plugin processes on shutdown
2545
+ const shutdownPlugins = async () => {
2546
+ await stopAllPlugins();
2547
+ process.exit(0);
2548
+ };
2549
+ process.on('SIGTERM', () => void shutdownPlugins());
2550
+ process.on('SIGINT', () => void shutdownPlugins());
2551
+ } catch (error) {
2552
+ console.error('[ERROR] Failed to start server:', error);
2553
+ process.exit(1);
2554
+ }
2555
+ }
2556
+
2557
+ startServer();