dr-claw 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (287) hide show
  1. package/LICENSE +1380 -0
  2. package/NOTICE +17 -0
  3. package/README.md +875 -0
  4. package/README.zh-CN.md +857 -0
  5. package/dist/api-docs.html +878 -0
  6. package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  7. package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  8. package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  9. package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  10. package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  11. package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  12. package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  13. package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  14. package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  15. package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  16. package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  17. package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  18. package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  19. package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  20. package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  21. package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  22. package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  23. package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  24. package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  25. package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  26. package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  27. package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  28. package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  29. package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  30. package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  31. package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  32. package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  33. package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  34. package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  35. package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  36. package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  37. package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  38. package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  39. package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  40. package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  41. package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  42. package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  43. package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  44. package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  45. package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  46. package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  47. package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  48. package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  49. package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  50. package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  51. package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  52. package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  53. package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  54. package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  55. package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  56. package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  57. package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  58. package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  59. package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  60. package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  61. package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  62. package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  63. package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  64. package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  65. package/dist/assets/_basePickBy-C0n1uo4u.js +1 -0
  66. package/dist/assets/_baseUniq-BRNmhFkc.js +1 -0
  67. package/dist/assets/arc-DOMBmC_A.js +1 -0
  68. package/dist/assets/architectureDiagram-VXUJARFQ-CtMWglbD.js +36 -0
  69. package/dist/assets/blockDiagram-VD42YOAC-DK_gTkJy.js +122 -0
  70. package/dist/assets/c4Diagram-YG6GDRKO-DrDAvsn7.js +10 -0
  71. package/dist/assets/channel-BFOE7OyC.js +1 -0
  72. package/dist/assets/chunk-4BX2VUAB-CGbhdZcN.js +1 -0
  73. package/dist/assets/chunk-55IACEB6-sGeFSxen.js +1 -0
  74. package/dist/assets/chunk-B4BG7PRW-RxgHAU_P.js +165 -0
  75. package/dist/assets/chunk-DI55MBZ5-CvHUXZrx.js +220 -0
  76. package/dist/assets/chunk-FMBD7UC4-Sn7cXVNU.js +15 -0
  77. package/dist/assets/chunk-QN33PNHL-bDKOHppN.js +1 -0
  78. package/dist/assets/chunk-QZHKN3VN-B9kN5LGm.js +1 -0
  79. package/dist/assets/chunk-TZMSLE5B-CCBe6len.js +1 -0
  80. package/dist/assets/classDiagram-2ON5EDUG-DQoMNYlY.js +1 -0
  81. package/dist/assets/classDiagram-v2-WZHVMYZB-DQoMNYlY.js +1 -0
  82. package/dist/assets/clone-BulQSblH.js +1 -0
  83. package/dist/assets/cose-bilkent-S5V4N54A-r6sLiHBW.js +1 -0
  84. package/dist/assets/cytoscape.esm-5J0xJHOV.js +321 -0
  85. package/dist/assets/dagre-6UL2VRFP-AbsaR1Oa.js +4 -0
  86. package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  87. package/dist/assets/diagram-PSM6KHXK-DjCvdUyy.js +24 -0
  88. package/dist/assets/diagram-QEK2KX5R-CqiocXkH.js +43 -0
  89. package/dist/assets/diagram-S2PKOQOG-D5ALeoII.js +24 -0
  90. package/dist/assets/erDiagram-Q2GNP2WA-C1AeOsiz.js +60 -0
  91. package/dist/assets/flowDiagram-NV44I4VS-C98ve_DO.js +162 -0
  92. package/dist/assets/ganttDiagram-JELNMOA3-3zdLvcxD.js +267 -0
  93. package/dist/assets/gitGraphDiagram-V2S2FVAM-nDuDMsHh.js +65 -0
  94. package/dist/assets/graph-Blm-emkR.js +1 -0
  95. package/dist/assets/index-B8Yutsnm.css +1 -0
  96. package/dist/assets/index-Cf6gyhc3.js +654 -0
  97. package/dist/assets/infoDiagram-HS3SLOUP-CmzE3q0R.js +2 -0
  98. package/dist/assets/init-Gi6I4Gst.js +1 -0
  99. package/dist/assets/journeyDiagram-XKPGCS4Q-CYaW7LrP.js +139 -0
  100. package/dist/assets/kanban-definition-3W4ZIXB7-mkmW7p0j.js +89 -0
  101. package/dist/assets/layout-Ct2kFIId.js +1 -0
  102. package/dist/assets/linear-CKG9EwIJ.js +1 -0
  103. package/dist/assets/mermaid.core-CaKcUnnd.js +250 -0
  104. package/dist/assets/mindmap-definition-VGOIOE7T-Bct-pYin.js +68 -0
  105. package/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  106. package/dist/assets/pieDiagram-ADFJNKIX-C8wFD4Jr.js +30 -0
  107. package/dist/assets/quadrantDiagram-AYHSOK5B-CuiE_f46.js +7 -0
  108. package/dist/assets/requirementDiagram-UZGBJVZJ-C4v1CqU8.js +64 -0
  109. package/dist/assets/sankeyDiagram-TZEHDZUN-keAqqs-Y.js +10 -0
  110. package/dist/assets/sequenceDiagram-WL72ISMW-ClNbvKTB.js +145 -0
  111. package/dist/assets/stateDiagram-FKZM4ZOC-D8We-2vq.js +1 -0
  112. package/dist/assets/stateDiagram-v2-4FDKWEC3-BUURfjrE.js +1 -0
  113. package/dist/assets/timeline-definition-IT6M3QCI-DsvEMT5T.js +61 -0
  114. package/dist/assets/treemap-GDKQZRPO-TJrzN7Bj.js +162 -0
  115. package/dist/assets/vendor-codemirror-ansM-eig.js +33 -0
  116. package/dist/assets/vendor-react-BWF6YQQ3.js +8 -0
  117. package/dist/assets/vendor-xterm-DfcmCpbH.js +66 -0
  118. package/dist/assets/xychartDiagram-PRI3JC2R-BdATmY5F.js +7 -0
  119. package/dist/clear-cache.html +85 -0
  120. package/dist/convert-icons.md +53 -0
  121. package/dist/dr-claw.png +0 -0
  122. package/dist/favicon.png +0 -0
  123. package/dist/generate-icons.js +49 -0
  124. package/dist/icons/claude-ai-icon.svg +1 -0
  125. package/dist/icons/codex-white.svg +3 -0
  126. package/dist/icons/codex.svg +3 -0
  127. package/dist/icons/cursor-white.svg +12 -0
  128. package/dist/icons/cursor.svg +1 -0
  129. package/dist/icons/file.svg +16997 -0
  130. package/dist/icons/generate-icons.md +19 -0
  131. package/dist/icons/icon-128x128.png +0 -0
  132. package/dist/icons/icon-144x144.png +0 -0
  133. package/dist/icons/icon-152x152.png +0 -0
  134. package/dist/icons/icon-192x192.png +0 -0
  135. package/dist/icons/icon-384x384.png +0 -0
  136. package/dist/icons/icon-512x512.png +0 -0
  137. package/dist/icons/icon-72x72.png +0 -0
  138. package/dist/icons/icon-96x96.png +0 -0
  139. package/dist/icons/news/arxiv.svg +1 -0
  140. package/dist/icons/news/huggingface.svg +8 -0
  141. package/dist/icons/news/x-black.png +0 -0
  142. package/dist/icons/news/x-white.png +0 -0
  143. package/dist/icons/news/xiaohongshu.png +0 -0
  144. package/dist/index.html +51 -0
  145. package/dist/leveraged-cognition.png +0 -0
  146. package/dist/manifest.json +61 -0
  147. package/dist/screenshots/chat.png +0 -0
  148. package/dist/screenshots/new_claw.png +0 -0
  149. package/dist/screenshots/news_dashboard.png +0 -0
  150. package/dist/screenshots/project_dashboard.png +0 -0
  151. package/dist/screenshots/research_lab.png +0 -0
  152. package/dist/screenshots/skill_library.png +0 -0
  153. package/dist/screenshots/task_list.png +0 -0
  154. package/dist/screenshots/terminal_example.png +0 -0
  155. package/dist/screenshots/terminal_example1.png +0 -0
  156. package/dist/sw.js +58 -0
  157. package/dist/wechat-group-qr.jpg +0 -0
  158. package/package.json +133 -0
  159. package/public/api-docs.html +878 -0
  160. package/public/clear-cache.html +85 -0
  161. package/public/convert-icons.md +53 -0
  162. package/public/dr-claw.png +0 -0
  163. package/public/favicon.png +0 -0
  164. package/public/generate-icons.js +49 -0
  165. package/public/icons/claude-ai-icon.svg +1 -0
  166. package/public/icons/codex-white.svg +3 -0
  167. package/public/icons/codex.svg +3 -0
  168. package/public/icons/cursor-white.svg +12 -0
  169. package/public/icons/cursor.svg +1 -0
  170. package/public/icons/file.svg +16997 -0
  171. package/public/icons/generate-icons.md +19 -0
  172. package/public/icons/icon-128x128.png +0 -0
  173. package/public/icons/icon-144x144.png +0 -0
  174. package/public/icons/icon-152x152.png +0 -0
  175. package/public/icons/icon-192x192.png +0 -0
  176. package/public/icons/icon-384x384.png +0 -0
  177. package/public/icons/icon-512x512.png +0 -0
  178. package/public/icons/icon-72x72.png +0 -0
  179. package/public/icons/icon-96x96.png +0 -0
  180. package/public/icons/news/arxiv.svg +1 -0
  181. package/public/icons/news/huggingface.svg +8 -0
  182. package/public/icons/news/x-black.png +0 -0
  183. package/public/icons/news/x-white.png +0 -0
  184. package/public/icons/news/xiaohongshu.png +0 -0
  185. package/public/leveraged-cognition.png +0 -0
  186. package/public/manifest.json +61 -0
  187. package/public/screenshots/chat.png +0 -0
  188. package/public/screenshots/new_claw.png +0 -0
  189. package/public/screenshots/news_dashboard.png +0 -0
  190. package/public/screenshots/project_dashboard.png +0 -0
  191. package/public/screenshots/research_lab.png +0 -0
  192. package/public/screenshots/skill_library.png +0 -0
  193. package/public/screenshots/task_list.png +0 -0
  194. package/public/screenshots/terminal_example.png +0 -0
  195. package/public/screenshots/terminal_example1.png +0 -0
  196. package/public/sw.js +58 -0
  197. package/public/wechat-group-qr.jpg +0 -0
  198. package/scripts/export-skills-catalog-v2.mjs +746 -0
  199. package/scripts/fix-node-pty.js +45 -0
  200. package/scripts/gemini_image_core.py +470 -0
  201. package/scripts/install-python-tools.js +64 -0
  202. package/server/AGENTS.md +73 -0
  203. package/server/__tests__/gemini-legacy-thought.test.mjs +88 -0
  204. package/server/__tests__/runtimePorts.test.mjs +115 -0
  205. package/server/__tests__/session-delete.test.mjs +166 -0
  206. package/server/bin/compute-node +70 -0
  207. package/server/claude-sdk.js +799 -0
  208. package/server/cli-chat.js +488 -0
  209. package/server/cli.js +354 -0
  210. package/server/compute-node.js +439 -0
  211. package/server/constants/config.js +5 -0
  212. package/server/cursor-cli.js +340 -0
  213. package/server/database/auth.db +0 -0
  214. package/server/database/db.js +1852 -0
  215. package/server/database/init.sql +194 -0
  216. package/server/gemini-cli.js +1352 -0
  217. package/server/index.js +3154 -0
  218. package/server/load-env.js +71 -0
  219. package/server/local-gpu.js +860 -0
  220. package/server/middleware/auth.js +100 -0
  221. package/server/openai-codex.js +724 -0
  222. package/server/openrouter.js +890 -0
  223. package/server/project-token-usage.js +407 -0
  224. package/server/projects.js +3973 -0
  225. package/server/routes/agent.js +1272 -0
  226. package/server/routes/auth.js +128 -0
  227. package/server/routes/auto-research.js +558 -0
  228. package/server/routes/cli-auth.js +958 -0
  229. package/server/routes/codex.js +344 -0
  230. package/server/routes/commands.js +601 -0
  231. package/server/routes/compute.js +358 -0
  232. package/server/routes/cursor.js +795 -0
  233. package/server/routes/git.js +1348 -0
  234. package/server/routes/mcp-utils.js +48 -0
  235. package/server/routes/mcp.js +618 -0
  236. package/server/routes/news.js +711 -0
  237. package/server/routes/projects.js +633 -0
  238. package/server/routes/references.js +338 -0
  239. package/server/routes/settings.js +285 -0
  240. package/server/routes/skills.js +914 -0
  241. package/server/routes/taskmaster.js +2566 -0
  242. package/server/routes/telemetry.js +89 -0
  243. package/server/routes/user.js +155 -0
  244. package/server/scripts/fix_session_names.js +57 -0
  245. package/server/scripts/research-news/common_words.py +19 -0
  246. package/server/scripts/research-news/link_keywords.py +298 -0
  247. package/server/scripts/research-news/scan_existing_notes.py +266 -0
  248. package/server/scripts/research-news/scoring_utils.py +227 -0
  249. package/server/scripts/research-news/search_arxiv.py +842 -0
  250. package/server/scripts/research-news/search_huggingface.py +438 -0
  251. package/server/scripts/research-news/search_x.py +490 -0
  252. package/server/scripts/research-news/search_xiaohongshu.py +610 -0
  253. package/server/taskmaster-templates/README.md +150 -0
  254. package/server/taskmaster-templates/README.zh-CN.md +150 -0
  255. package/server/taskmaster-templates/ai-research-dataset.json +230 -0
  256. package/server/taskmaster-templates/ai-research-method-model.json +230 -0
  257. package/server/taskmaster-templates/ai-research-position-paper.json +230 -0
  258. package/server/telemetry.js +121 -0
  259. package/server/templates/AGENTS.md +103 -0
  260. package/server/templates/CLAUDE.md +104 -0
  261. package/server/templates/GEMINI.md +103 -0
  262. package/server/templates/cursor-project.md +108 -0
  263. package/server/templates/index.js +37 -0
  264. package/server/utils/__tests__/sessionTokenUsage.test.js +82 -0
  265. package/server/utils/auto-research-mailer.js +57 -0
  266. package/server/utils/cliResolution.js +170 -0
  267. package/server/utils/commandParser.js +303 -0
  268. package/server/utils/cursorCommand.js +61 -0
  269. package/server/utils/geminiApiKey.js +28 -0
  270. package/server/utils/gitConfig.js +24 -0
  271. package/server/utils/imageAttachmentFiles.js +35 -0
  272. package/server/utils/mcp-detector.js +198 -0
  273. package/server/utils/parsers/bibtex-parser.js +207 -0
  274. package/server/utils/permissions.js +100 -0
  275. package/server/utils/runtimePorts.js +212 -0
  276. package/server/utils/sessionFormatting.js +68 -0
  277. package/server/utils/sessionIndex.js +100 -0
  278. package/server/utils/sessionMode.js +95 -0
  279. package/server/utils/sessionTokenUsage.js +105 -0
  280. package/server/utils/taskmaster-websocket.js +129 -0
  281. package/server/utils/zotero-client.js +136 -0
  282. package/shared/errorClassifier.js +102 -0
  283. package/shared/geminiThinkingSupport.d.ts +22 -0
  284. package/shared/geminiThinkingSupport.js +96 -0
  285. package/shared/geminiThoughtParser.d.ts +8 -0
  286. package/shared/geminiThoughtParser.js +30 -0
  287. package/shared/modelConstants.js +139 -0
@@ -0,0 +1,3154 @@
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 { fileURLToPath } from 'url';
7
+ import { dirname } from 'path';
8
+
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = dirname(__filename);
11
+ const installMode = fs.existsSync(path.join(__dirname, '..', '.git')) ? 'git' : 'npm';
12
+ const npmPackageName = process.env.NPM_PACKAGE_NAME || 'dr-claw';
13
+
14
+ // ANSI color codes for terminal output
15
+ const colors = {
16
+ reset: '\x1b[0m',
17
+ bright: '\x1b[1m',
18
+ cyan: '\x1b[36m',
19
+ green: '\x1b[32m',
20
+ yellow: '\x1b[33m',
21
+ blue: '\x1b[34m',
22
+ dim: '\x1b[2m',
23
+ };
24
+
25
+ const c = {
26
+ info: (text) => `${colors.cyan}${text}${colors.reset}`,
27
+ ok: (text) => `${colors.green}${text}${colors.reset}`,
28
+ warn: (text) => `${colors.yellow}${text}${colors.reset}`,
29
+ tip: (text) => `${colors.blue}${text}${colors.reset}`,
30
+ bright: (text) => `${colors.bright}${text}${colors.reset}`,
31
+ dim: (text) => `${colors.dim}${text}${colors.reset}`,
32
+ };
33
+
34
+ console.log('Requested PORT from env:', process.env.PORT);
35
+
36
+ import express from 'express';
37
+ import { WebSocketServer, WebSocket } from 'ws';
38
+ import os from 'os';
39
+ import http from 'http';
40
+ import cors from 'cors';
41
+ import { promises as fsPromises } from 'fs';
42
+ import { spawn } from 'child_process';
43
+ import pty from 'node-pty';
44
+ import fetch from 'node-fetch';
45
+ import mime from 'mime-types';
46
+
47
+ import { getProjects, getTrashedProjects, getSessions, getSessionMessages, renameProject, renameSession, deleteSession, deleteProject, restoreProject, deleteTrashedProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js';
48
+ import { getProjectTokenUsageSummary } from './project-token-usage.js';
49
+ import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getClaudeSDKSessionStartTime, getActiveClaudeSDKSessions, resolveToolApproval } from './claude-sdk.js';
50
+ import { spawnCursor, abortCursorSession, isCursorSessionActive, getCursorSessionStartTime, getActiveCursorSessions } from './cursor-cli.js';
51
+ import { queryCodex, abortCodexSession, isCodexSessionActive, getCodexSessionStartTime, getActiveCodexSessions } from './openai-codex.js';
52
+ import { spawnGemini, abortGeminiSession, isGeminiSessionActive, getGeminiSessionStartTime, getActiveGeminiSessions } from './gemini-cli.js';
53
+ import { queryOpenRouter, abortOpenRouterSession, isOpenRouterSessionActive, getOpenRouterSessionStartTime, getActiveOpenRouterSessions } from './openrouter.js';
54
+ import { queryLocalGPU, abortLocalGPUSession, isLocalGPUSessionActive, getLocalGPUSessionStartTime, getActiveLocalGPUSessions } from './local-gpu.js';
55
+ import gitRoutes from './routes/git.js';
56
+ import authRoutes from './routes/auth.js';
57
+ import mcpRoutes from './routes/mcp.js';
58
+ import cursorRoutes from './routes/cursor.js';
59
+ import taskmasterRoutes from './routes/taskmaster.js';
60
+ import mcpUtilsRoutes from './routes/mcp-utils.js';
61
+ import commandsRoutes from './routes/commands.js';
62
+ import settingsRoutes from './routes/settings.js';
63
+ import agentRoutes from './routes/agent.js';
64
+ import projectsRoutes, { WORKSPACES_ROOT, getWorkspacesRoot, validateWorkspacePath } from './routes/projects.js';
65
+ import cliAuthRoutes from './routes/cli-auth.js';
66
+ import userRoutes from './routes/user.js';
67
+ import codexRoutes from './routes/codex.js';
68
+ import skillsRoutes from './routes/skills.js';
69
+ import telemetryRoutes from './routes/telemetry.js';
70
+ import computeRoutes from './routes/compute.js';
71
+ import newsRoutes from './routes/news.js';
72
+ import autoResearchRoutes from './routes/auto-research.js';
73
+ import referencesRoutes from './routes/references.js';
74
+ import { initializeDatabase, sessionDb, tagDb } from './database/db.js';
75
+ import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
76
+ import { IS_PLATFORM } from './constants/config.js';
77
+ import { enqueueTelemetryEvent } from './telemetry.js';
78
+ import { resolveCursorCliCommand, isCursorLoginCommand, isGeminiLoginCommand, normalizeCursorLoginCommand } from './utils/cursorCommand.js';
79
+ import { getGeminiApiKeyForUser, withGeminiApiKeyEnv } from './utils/geminiApiKey.js';
80
+ import {
81
+ DEFAULT_BACKEND_PORT,
82
+ DEFAULT_FRONTEND_PORT,
83
+ getFrontendPortSync,
84
+ listenOnAvailablePort,
85
+ parsePortNumber,
86
+ setRuntimePortSync,
87
+ } from './utils/runtimePorts.js';
88
+ import { buildCodexTokenUsageFromJsonl } from './utils/sessionTokenUsage.js';
89
+
90
+ // File system watchers for provider project/session folders
91
+ const PROVIDER_WATCH_PATHS = [
92
+ { provider: 'claude', rootPath: path.join(os.homedir(), '.claude', 'projects') },
93
+ { provider: 'cursor', rootPath: path.join(os.homedir(), '.cursor', 'chats') },
94
+ { provider: 'codex', rootPath: path.join(os.homedir(), '.codex', 'sessions') },
95
+ { provider: 'gemini', rootPath: path.join(os.homedir(), '.gemini', 'sessions') }
96
+ ];
97
+ const WATCHER_IGNORED_PATTERNS = [
98
+ '**/node_modules/**',
99
+ '**/.git/**',
100
+ '**/dist/**',
101
+ '**/build/**',
102
+ '**/*.tmp',
103
+ '**/*.swp',
104
+ '**/.DS_Store'
105
+ ];
106
+ const WATCHER_DEBOUNCE_MS = 1000;
107
+ let projectsWatchers = [];
108
+ let projectsWatcherDebounceTimer = null;
109
+ const connectedClients = new Set();
110
+ let isGetProjectsRunning = false; // Flag to prevent reentrant calls
111
+ let hasPendingProjectsUpdate = false;
112
+ let lastWatcherEvent = null;
113
+ let lastProjectsUpdateSignature = '';
114
+
115
+ function shouldProcessProjectsWatcherEvent(eventType, filePath, provider) {
116
+ if (eventType === 'addDir' || eventType === 'unlinkDir') {
117
+ return true;
118
+ }
119
+
120
+ const normalized = String(filePath || '').toLowerCase();
121
+ if (provider === 'claude' || provider === 'codex' || provider === 'gemini') {
122
+ return normalized.endsWith('.jsonl');
123
+ }
124
+
125
+ if (provider === 'cursor') {
126
+ return (
127
+ normalized.endsWith('.db') ||
128
+ normalized.endsWith('.db-wal') ||
129
+ normalized.endsWith('.db-shm') ||
130
+ normalized.endsWith('.sqlite') ||
131
+ normalized.endsWith('.json')
132
+ );
133
+ }
134
+
135
+ return true;
136
+ }
137
+
138
+ // Broadcast progress to all connected WebSocket clients
139
+ function broadcastProgress(progress) {
140
+ const message = JSON.stringify({
141
+ type: 'loading_progress',
142
+ ...progress
143
+ });
144
+ connectedClients.forEach(client => {
145
+ if (client.readyState === WebSocket.OPEN) {
146
+ client.send(message);
147
+ }
148
+ });
149
+ }
150
+
151
+ // Setup file system watchers for Claude, Cursor, and Codex project/session folders
152
+ async function setupProjectsWatcher() {
153
+ const chokidar = (await import('chokidar')).default;
154
+
155
+ if (projectsWatcherDebounceTimer) {
156
+ clearTimeout(projectsWatcherDebounceTimer);
157
+ projectsWatcherDebounceTimer = null;
158
+ }
159
+
160
+ await Promise.all(
161
+ projectsWatchers.map(async (watcher) => {
162
+ try {
163
+ await watcher.close();
164
+ } catch (error) {
165
+ console.error('[WARN] Failed to close watcher:', error);
166
+ }
167
+ })
168
+ );
169
+ projectsWatchers = [];
170
+
171
+ const debouncedUpdate = (eventType, filePath, provider, rootPath) => {
172
+ if (!shouldProcessProjectsWatcherEvent(eventType, filePath, provider)) {
173
+ return;
174
+ }
175
+
176
+ lastWatcherEvent = { eventType, filePath, provider, rootPath };
177
+
178
+ if (projectsWatcherDebounceTimer) {
179
+ clearTimeout(projectsWatcherDebounceTimer);
180
+ }
181
+
182
+ projectsWatcherDebounceTimer = setTimeout(async () => {
183
+ // Prevent reentrant calls
184
+ if (isGetProjectsRunning) {
185
+ hasPendingProjectsUpdate = true;
186
+ return;
187
+ }
188
+
189
+ try {
190
+ isGetProjectsRunning = true;
191
+ hasPendingProjectsUpdate = false;
192
+
193
+ // Clear project directory cache when files change
194
+ clearProjectDirectoryCache();
195
+
196
+ // Get updated projects list
197
+ const updatedProjects = await getProjects();
198
+ const updateSignature = JSON.stringify(updatedProjects);
199
+
200
+ // Skip broadcasting identical snapshots
201
+ if (updateSignature === lastProjectsUpdateSignature) {
202
+ return;
203
+ }
204
+ lastProjectsUpdateSignature = updateSignature;
205
+
206
+ // Notify all connected clients about the project changes
207
+ const updateMessage = JSON.stringify({
208
+ type: 'projects_updated',
209
+ projects: updatedProjects,
210
+ timestamp: new Date().toISOString(),
211
+ changeType: eventType,
212
+ changedFile: path.relative(rootPath, filePath),
213
+ watchProvider: provider
214
+ });
215
+
216
+ connectedClients.forEach(client => {
217
+ if (client.readyState === WebSocket.OPEN) {
218
+ client.send(updateMessage);
219
+ }
220
+ });
221
+
222
+ } catch (error) {
223
+ console.error('[ERROR] Error handling project changes:', error);
224
+ } finally {
225
+ isGetProjectsRunning = false;
226
+ if (hasPendingProjectsUpdate && lastWatcherEvent) {
227
+ hasPendingProjectsUpdate = false;
228
+ const { eventType, filePath, provider, rootPath } = lastWatcherEvent;
229
+ debouncedUpdate(eventType, filePath, provider, rootPath);
230
+ }
231
+ }
232
+ }, WATCHER_DEBOUNCE_MS);
233
+ };
234
+
235
+ for (const { provider, rootPath } of PROVIDER_WATCH_PATHS) {
236
+ try {
237
+ // chokidar v4 emits ENOENT via the "error" event for missing roots and will not auto-recover.
238
+ // Ensure provider folders exist before creating the watcher so watching stays active.
239
+ await fsPromises.mkdir(rootPath, { recursive: true });
240
+
241
+ // Initialize chokidar watcher with optimized settings
242
+ const watcher = chokidar.watch(rootPath, {
243
+ ignored: WATCHER_IGNORED_PATTERNS,
244
+ persistent: true,
245
+ ignoreInitial: true, // Don't fire events for existing files on startup
246
+ followSymlinks: false,
247
+ depth: 10, // Reasonable depth limit
248
+ awaitWriteFinish: {
249
+ stabilityThreshold: 100, // Wait 100ms for file to stabilize
250
+ pollInterval: 50
251
+ }
252
+ });
253
+
254
+ // Set up event listeners
255
+ watcher
256
+ .on('add', (filePath) => debouncedUpdate('add', filePath, provider, rootPath))
257
+ .on('change', (filePath) => debouncedUpdate('change', filePath, provider, rootPath))
258
+ .on('unlink', (filePath) => debouncedUpdate('unlink', filePath, provider, rootPath))
259
+ .on('addDir', (dirPath) => debouncedUpdate('addDir', dirPath, provider, rootPath))
260
+ .on('unlinkDir', (dirPath) => debouncedUpdate('unlinkDir', dirPath, provider, rootPath))
261
+ .on('error', (error) => {
262
+ console.error(`[ERROR] ${provider} watcher error:`, error);
263
+ })
264
+ .on('ready', () => {
265
+ });
266
+
267
+ projectsWatchers.push(watcher);
268
+ } catch (error) {
269
+ console.error(`[ERROR] Failed to setup ${provider} watcher for ${rootPath}:`, error);
270
+ }
271
+ }
272
+
273
+ if (projectsWatchers.length === 0) {
274
+ console.error('[ERROR] Failed to setup any provider watchers');
275
+ }
276
+ }
277
+
278
+
279
+ const app = express();
280
+ const server = http.createServer(app);
281
+
282
+ const ptySessionsMap = new Map();
283
+ const PTY_SESSION_TIMEOUT = 30 * 60 * 1000;
284
+ const SHELL_URL_PARSE_BUFFER_LIMIT = 32768;
285
+ const ANSI_ESCAPE_SEQUENCE_REGEX = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\))/g;
286
+ const TRAILING_URL_PUNCTUATION_REGEX = /[)\]}>.,;:!?]+$/;
287
+ const REPLAY_TERMINAL_QUERY_REGEXES = [
288
+ /\x1B\[[>?0-9;]*c/g,
289
+ /\x1B\[[0-9;?]*n/g,
290
+ /\x1B\[\?[0-9;]*\$p/g,
291
+ ];
292
+ const SHELL_EMBEDDED_ENV_KEYS_TO_REMOVE = [
293
+ 'TERM_PROGRAM',
294
+ 'TERM_PROGRAM_VERSION',
295
+ 'ITERM_SESSION_ID',
296
+ 'LC_TERMINAL',
297
+ 'LC_TERMINAL_VERSION',
298
+ 'WT_SESSION',
299
+ ];
300
+
301
+ function stripAnsiSequences(value = '') {
302
+ return value.replace(ANSI_ESCAPE_SEQUENCE_REGEX, '');
303
+ }
304
+
305
+ function stripReplayTerminalQueries(value = '') {
306
+ return REPLAY_TERMINAL_QUERY_REGEXES.reduce(
307
+ (currentValue, regex) => currentValue.replace(regex, ''),
308
+ value,
309
+ );
310
+ }
311
+
312
+ function buildEmbeddedShellEnv(baseEnv = process.env) {
313
+ const env = {
314
+ ...baseEnv,
315
+ TERM: 'xterm-256color',
316
+ COLORTERM: 'truecolor',
317
+ FORCE_COLOR: '3'
318
+ };
319
+
320
+ SHELL_EMBEDDED_ENV_KEYS_TO_REMOVE.forEach((key) => {
321
+ delete env[key];
322
+ });
323
+
324
+ Object.keys(env).forEach((key) => {
325
+ if (
326
+ key.startsWith('VSCODE_') ||
327
+ key.startsWith('FIG_') ||
328
+ key.startsWith('QTERM_')
329
+ ) {
330
+ delete env[key];
331
+ }
332
+ });
333
+
334
+ return env;
335
+ }
336
+
337
+ function normalizeDetectedUrl(url) {
338
+ if (!url || typeof url !== 'string') return null;
339
+
340
+ const cleaned = url.trim().replace(TRAILING_URL_PUNCTUATION_REGEX, '');
341
+ if (!cleaned) return null;
342
+
343
+ try {
344
+ const parsed = new URL(cleaned);
345
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
346
+ return null;
347
+ }
348
+ return parsed.toString();
349
+ } catch {
350
+ return null;
351
+ }
352
+ }
353
+
354
+ function extractUrlsFromText(value = '') {
355
+ const directMatches = value.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/gi) || [];
356
+
357
+ // Handle wrapped terminal URLs split across lines by terminal width.
358
+ const wrappedMatches = [];
359
+ const continuationRegex = /^[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$/;
360
+ const lines = value.split(/\r?\n/);
361
+ for (let i = 0; i < lines.length; i++) {
362
+ const line = lines[i].trim();
363
+ const startMatch = line.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/i);
364
+ if (!startMatch) continue;
365
+
366
+ let combined = startMatch[0];
367
+ let j = i + 1;
368
+ while (j < lines.length) {
369
+ const continuation = lines[j].trim();
370
+ if (!continuation) break;
371
+ if (!continuationRegex.test(continuation)) break;
372
+ combined += continuation;
373
+ j++;
374
+ }
375
+
376
+ wrappedMatches.push(combined.replace(/\r?\n\s*/g, ''));
377
+ }
378
+
379
+ return Array.from(new Set([...directMatches, ...wrappedMatches]));
380
+ }
381
+
382
+ function shouldAutoOpenUrlFromOutput(value = '') {
383
+ const normalized = value.toLowerCase();
384
+ return (
385
+ normalized.includes('browser didn\'t open') ||
386
+ normalized.includes('open this url') ||
387
+ normalized.includes('continue in your browser') ||
388
+ normalized.includes('press enter to open') ||
389
+ normalized.includes('open_url:') ||
390
+ normalized.includes('paste code here')
391
+ );
392
+ }
393
+
394
+ // Single WebSocket server that handles both paths
395
+ const wss = new WebSocketServer({
396
+ server,
397
+ verifyClient: (info) => {
398
+ const authHeader = info.req.headers.authorization || '';
399
+ const tokenFromHeader = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;
400
+ const requestUrl = new URL(info.req.url || '/', 'http://localhost');
401
+ const token = tokenFromHeader || requestUrl.searchParams.get('token');
402
+ const user = authenticateWebSocket(token);
403
+ if (!user) {
404
+ console.log('[WARN] WebSocket authentication failed');
405
+ return false;
406
+ }
407
+ info.req.user = user;
408
+ return true;
409
+ }
410
+ });
411
+
412
+ // Make WebSocket server available to routes
413
+ app.locals.wss = wss;
414
+
415
+ app.use(cors());
416
+ app.use(express.json({
417
+ limit: '50mb',
418
+ type: (req) => {
419
+ // Skip multipart/form-data requests (for file uploads like images)
420
+ const contentType = req.headers['content-type'] || '';
421
+ if (contentType.includes('multipart/form-data')) {
422
+ return false;
423
+ }
424
+ return contentType.includes('json');
425
+ }
426
+ }));
427
+ app.use(express.urlencoded({ limit: '50mb', extended: true }));
428
+
429
+ // Public health check endpoint (no authentication required)
430
+ app.get('/health', (req, res) => {
431
+ res.json({
432
+ status: 'ok',
433
+ timestamp: new Date().toISOString(),
434
+ installMode
435
+ });
436
+ });
437
+
438
+ // Optional API key validation (if configured)
439
+ app.use('/api', validateApiKey);
440
+
441
+ // Authentication routes (public)
442
+ app.use('/api/auth', authRoutes);
443
+
444
+ // Projects API Routes (protected)
445
+ app.use('/api/projects', authenticateToken, projectsRoutes);
446
+
447
+ // Git API Routes (protected)
448
+ app.use('/api/git', authenticateToken, gitRoutes);
449
+
450
+ // MCP API Routes (protected)
451
+ app.use('/api/mcp', authenticateToken, mcpRoutes);
452
+
453
+ // Cursor API Routes (protected)
454
+ app.use('/api/cursor', authenticateToken, cursorRoutes);
455
+
456
+ // TaskMaster API Routes (protected)
457
+ app.use('/api/taskmaster', authenticateToken, taskmasterRoutes);
458
+
459
+ // MCP utilities
460
+ app.use('/api/mcp-utils', authenticateToken, mcpUtilsRoutes);
461
+
462
+ // Commands API Routes (protected)
463
+ app.use('/api/commands', authenticateToken, commandsRoutes);
464
+
465
+ // Settings API Routes (protected)
466
+ app.use('/api/settings', authenticateToken, settingsRoutes);
467
+
468
+ // CLI Authentication API Routes (protected)
469
+ app.use('/api/cli', authenticateToken, cliAuthRoutes);
470
+
471
+ // User API Routes (protected)
472
+ app.use('/api/user', authenticateToken, userRoutes);
473
+
474
+ // Codex API Routes (protected)
475
+ app.use('/api/codex', authenticateToken, codexRoutes);
476
+
477
+ // Skills API Routes (protected)
478
+ app.use('/api/skills', authenticateToken, skillsRoutes);
479
+
480
+ // Telemetry API Routes (protected)
481
+ app.use('/api/telemetry', authenticateToken, telemetryRoutes);
482
+
483
+ // Compute API Routes (protected)
484
+ app.use('/api/compute', authenticateToken, computeRoutes);
485
+
486
+ // News API Routes (protected)
487
+ app.use('/api/news', authenticateToken, newsRoutes);
488
+
489
+ // Auto Research API Routes (protected)
490
+ app.use('/api/auto-research', authenticateToken, autoResearchRoutes);
491
+
492
+ // References (literature library) API Routes (protected)
493
+ app.use('/api/references', authenticateToken, referencesRoutes);
494
+
495
+ // Agent API Routes (uses API key authentication)
496
+ app.use('/api/agent', agentRoutes);
497
+
498
+ const expandWorkspacePath = async (inputPath) => {
499
+ if (!inputPath) return inputPath;
500
+ if (inputPath === '~') {
501
+ return os.homedir();
502
+ }
503
+ if (inputPath.startsWith('~/') || inputPath.startsWith('~\\')) {
504
+ return path.join(os.homedir(), inputPath.slice(2));
505
+ }
506
+ return inputPath;
507
+ };
508
+
509
+ // Browse filesystem endpoint for project suggestions - uses existing getFileTree
510
+ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
511
+ try {
512
+ const { path: dirPath, showHidden: showHiddenQuery } = req.query;
513
+ const showHidden = showHiddenQuery === 'true';
514
+
515
+ console.log('[API] Browse filesystem request for path:', dirPath, 'showHidden:', showHidden);
516
+ const homeDir = os.homedir();
517
+ // Default to home directory if no path provided
518
+ let targetPath = dirPath ? await expandWorkspacePath(dirPath) : homeDir;
519
+
520
+ // Resolve and normalize the path
521
+ targetPath = path.resolve(targetPath);
522
+
523
+ // Security check - ensure path is valid
524
+ const validation = await validateWorkspacePath(targetPath);
525
+ if (!validation.valid) {
526
+ return res.status(403).json({ error: validation.error });
527
+ }
528
+ const resolvedPath = validation.resolvedPath || targetPath;
529
+
530
+ // Security check - ensure path is accessible
531
+ try {
532
+ await fs.promises.access(resolvedPath);
533
+ const stats = await fs.promises.stat(resolvedPath);
534
+
535
+ if (!stats.isDirectory()) {
536
+ return res.status(400).json({ error: 'Path is not a directory' });
537
+ }
538
+ } catch (err) {
539
+ return res.status(404).json({ error: 'Directory not accessible' });
540
+ }
541
+
542
+ // Use existing getFileTree function with shallow depth (only direct children)
543
+ // For browsing, we use a more permissive version that doesn't skip node_modules etc.
544
+ const fileTree = await getFileTree(resolvedPath, 1, 0, showHidden, true); // maxDepth=1, showHidden, isBrowsing=true
545
+
546
+ // Filter only directories and format for suggestions
547
+ const directories = fileTree
548
+ .filter(item => item.type === 'directory')
549
+ .map(item => ({
550
+ path: item.path,
551
+ name: item.name,
552
+ type: 'directory'
553
+ }))
554
+ .sort((a, b) => {
555
+ const aHidden = a.name.startsWith('.');
556
+ const bHidden = b.name.startsWith('.');
557
+ if (aHidden && !bHidden) return 1;
558
+ if (!aHidden && bHidden) return -1;
559
+ return a.name.localeCompare(b.name);
560
+ });
561
+
562
+ // Add common directories if browsing home directory
563
+ const suggestions = [];
564
+ let resolvedHomeDir = homeDir;
565
+ try {
566
+ resolvedHomeDir = await fs.promises.realpath(homeDir);
567
+ } catch (error) {
568
+ // Use home dir as-is if realpath fails
569
+ }
570
+
571
+ if (resolvedPath === resolvedHomeDir) {
572
+ const commonDirs = ['Desktop', 'Documents', 'Downloads', 'Projects', 'Development', 'Dev', 'Code', 'workspace', 'vibelab'];
573
+ const existingCommon = directories.filter(dir => commonDirs.includes(dir.name));
574
+ const otherDirs = directories.filter(dir => !commonDirs.includes(dir.name));
575
+
576
+ suggestions.push(...existingCommon, ...otherDirs);
577
+ } else {
578
+ suggestions.push(...directories);
579
+ }
580
+
581
+ res.json({
582
+ path: resolvedPath,
583
+ suggestions: suggestions
584
+ });
585
+
586
+ } catch (error) {
587
+ console.error('Error browsing filesystem:', error);
588
+ res.status(500).json({ error: 'Failed to browse filesystem' });
589
+ }
590
+ });
591
+
592
+ app.post('/api/create-folder', authenticateToken, async (req, res) => {
593
+ try {
594
+ const { path: folderPath } = req.body;
595
+ if (!folderPath) {
596
+ return res.status(400).json({ error: 'Path is required' });
597
+ }
598
+ const expandedPath = await expandWorkspacePath(folderPath);
599
+ const resolvedInput = path.resolve(expandedPath);
600
+ const validation = await validateWorkspacePath(resolvedInput);
601
+ if (!validation.valid) {
602
+ return res.status(403).json({ error: validation.error });
603
+ }
604
+ const targetPath = validation.resolvedPath || resolvedInput;
605
+ const parentDir = path.dirname(targetPath);
606
+ try {
607
+ await fs.promises.access(parentDir);
608
+ } catch (err) {
609
+ return res.status(404).json({ error: 'Parent directory does not exist' });
610
+ }
611
+ try {
612
+ await fs.promises.access(targetPath);
613
+ return res.status(409).json({ error: 'Folder already exists' });
614
+ } catch (err) {
615
+ // Folder doesn't exist, which is what we want
616
+ }
617
+ try {
618
+ await fs.promises.mkdir(targetPath, { recursive: false });
619
+ res.json({ success: true, path: targetPath });
620
+ } catch (mkdirError) {
621
+ if (mkdirError.code === 'EEXIST') {
622
+ return res.status(409).json({ error: 'Folder already exists' });
623
+ }
624
+ throw mkdirError;
625
+ }
626
+ } catch (error) {
627
+ console.error('Error creating folder:', error);
628
+ res.status(500).json({ error: 'Failed to create folder' });
629
+ }
630
+ });
631
+
632
+ // Serve public files (like api-docs.html)
633
+ app.use(express.static(path.join(__dirname, '../public')));
634
+
635
+ // Static files served after API routes
636
+ // Add cache control: HTML files should not be cached, but assets can be cached
637
+ app.use(express.static(path.join(__dirname, '../dist'), {
638
+ setHeaders: (res, filePath) => {
639
+ if (filePath.endsWith('.html')) {
640
+ // Prevent HTML caching to avoid service worker issues after builds
641
+ res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
642
+ res.setHeader('Pragma', 'no-cache');
643
+ res.setHeader('Expires', '0');
644
+ } else if (filePath.match(/\.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico)$/)) {
645
+ // Cache static assets for 1 year (they have hashed names)
646
+ res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
647
+ }
648
+ }
649
+ }));
650
+
651
+ // API Routes (protected)
652
+ // /api/config endpoint removed - no longer needed
653
+ // Frontend now uses window.location for WebSocket URLs
654
+
655
+ // System update endpoint
656
+ app.post('/api/system/update', authenticateToken, async (req, res) => {
657
+ try {
658
+ // Get the project root directory (parent of server directory)
659
+ const projectRoot = path.join(__dirname, '..');
660
+
661
+ console.log('Starting system update from directory:', projectRoot);
662
+
663
+ // Run the update command based on installation mode
664
+ const updateCommand = installMode === 'git'
665
+ ? 'git checkout main && git pull && npm install'
666
+ : `npm install -g ${npmPackageName}@latest`;
667
+
668
+ const child = spawn('sh', ['-c', updateCommand], {
669
+ cwd: installMode === 'git' ? projectRoot : os.homedir(),
670
+ env: process.env
671
+ });
672
+
673
+ let output = '';
674
+ let errorOutput = '';
675
+
676
+ child.stdout.on('data', (data) => {
677
+ const text = data.toString();
678
+ output += text;
679
+ console.log('Update output:', text);
680
+ });
681
+
682
+ child.stderr.on('data', (data) => {
683
+ const text = data.toString();
684
+ errorOutput += text;
685
+ console.error('Update error:', text);
686
+ });
687
+
688
+ child.on('close', (code) => {
689
+ if (code === 0) {
690
+ res.json({
691
+ success: true,
692
+ output: output || 'Update completed successfully',
693
+ message: 'Update completed. Please restart the server to apply changes.'
694
+ });
695
+ } else {
696
+ res.status(500).json({
697
+ success: false,
698
+ error: 'Update command failed',
699
+ output: output,
700
+ errorOutput: errorOutput
701
+ });
702
+ }
703
+ });
704
+
705
+ child.on('error', (error) => {
706
+ console.error('Update process error:', error);
707
+ res.status(500).json({
708
+ success: false,
709
+ error: error.message
710
+ });
711
+ });
712
+
713
+ } catch (error) {
714
+ console.error('System update error:', error);
715
+ res.status(500).json({
716
+ success: false,
717
+ error: error.message
718
+ });
719
+ }
720
+ });
721
+
722
+ app.get('/api/projects', authenticateToken, async (req, res) => {
723
+ try {
724
+ const userId = req.user?.id;
725
+ const projects = await getProjects(userId, broadcastProgress);
726
+ res.json(projects);
727
+ } catch (error) {
728
+ res.status(500).json({ error: error.message });
729
+ }
730
+ });
731
+
732
+ app.get('/api/projects/trash', authenticateToken, async (req, res) => {
733
+ try {
734
+ const userId = req.user?.id;
735
+ const projects = await getTrashedProjects(userId);
736
+ res.json(projects);
737
+ } catch (error) {
738
+ res.status(500).json({ error: error.message });
739
+ }
740
+ });
741
+
742
+ app.post('/api/projects/token-usage-summary', authenticateToken, async (req, res) => {
743
+ try {
744
+ const projectRefs = req.body?.projects;
745
+ if (!Array.isArray(projectRefs)) {
746
+ return res.status(400).json({ error: 'projects array is required' });
747
+ }
748
+
749
+ const summary = await getProjectTokenUsageSummary(projectRefs);
750
+ res.json(summary);
751
+ } catch (error) {
752
+ console.error('Error building project token usage summary:', error);
753
+ res.status(500).json({ error: 'Failed to build project token usage summary' });
754
+ }
755
+ });
756
+
757
+ app.get('/api/projects/:projectName/sessions', authenticateToken, async (req, res) => {
758
+ try {
759
+ const userId = req.user?.id;
760
+ const { limit = 5, offset = 0 } = req.query;
761
+ const result = await getSessions(req.params.projectName, parseInt(limit), parseInt(offset), userId);
762
+ res.json(result);
763
+ } catch (error) {
764
+ res.status(500).json({ error: error.message });
765
+ }
766
+ });
767
+
768
+ // Get messages for a specific session
769
+ app.get('/api/projects/:projectName/sessions/:sessionId/messages', authenticateToken, async (req, res) => {
770
+ try {
771
+ const userId = req.user?.id;
772
+ const { projectName, sessionId } = req.params;
773
+ const { limit, offset, provider } = req.query;
774
+
775
+ // Parse limit and offset if provided
776
+ const parsedLimit = limit ? parseInt(limit, 10) : null;
777
+ const parsedOffset = offset ? parseInt(offset, 10) : 0;
778
+
779
+ const result = await getSessionMessages(projectName, sessionId, parsedLimit, parsedOffset, provider, userId);
780
+
781
+ // Handle both old and new response formats
782
+ if (Array.isArray(result)) {
783
+ // Backward compatibility: no pagination parameters were provided
784
+ res.json({ messages: result });
785
+ } else {
786
+ // New format with pagination info
787
+ res.json(result);
788
+ }
789
+ } catch (error) {
790
+ res.status(500).json({ error: error.message });
791
+ }
792
+ });
793
+
794
+ app.get('/api/projects/:projectName/tags', authenticateToken, async (req, res) => {
795
+ try {
796
+ const { projectName } = req.params;
797
+ // Lazy initialization: idempotent, uses INSERT OR IGNORE internally.
798
+ tagDb.ensureDefaultStageTags(projectName);
799
+ const { tagType } = req.query;
800
+ const tags = tagDb.listProjectTags(projectName, tagType || null);
801
+ res.json({ tags });
802
+ } catch (error) {
803
+ res.status(500).json({ error: error.message });
804
+ }
805
+ });
806
+
807
+ app.get('/api/projects/:projectName/sessions/:sessionId/tags', authenticateToken, async (req, res) => {
808
+ try {
809
+ const { projectName, sessionId } = req.params;
810
+ // Lazy initialization: idempotent, uses INSERT OR IGNORE internally.
811
+ tagDb.ensureDefaultStageTags(projectName);
812
+ const session = sessionDb.getSessionById(sessionId);
813
+ if (!session || session.project_name !== projectName) {
814
+ return res.status(404).json({ error: 'Session not found' });
815
+ }
816
+
817
+ const tags = tagDb.listTagsForSession(sessionId);
818
+ res.json({ tags });
819
+ } catch (error) {
820
+ res.status(500).json({ error: error.message });
821
+ }
822
+ });
823
+
824
+ app.put('/api/projects/:projectName/sessions/:sessionId/tags', authenticateToken, async (req, res) => {
825
+ try {
826
+ const { projectName, sessionId } = req.params;
827
+ const { tagIds } = req.body || {};
828
+
829
+ if (!Array.isArray(tagIds)) {
830
+ return res.status(400).json({ error: 'tagIds array is required' });
831
+ }
832
+
833
+ // Lazy initialization: idempotent, uses INSERT OR IGNORE internally.
834
+ tagDb.ensureDefaultStageTags(projectName);
835
+ const session = sessionDb.getSessionById(sessionId);
836
+ if (!session || session.project_name !== projectName) {
837
+ return res.status(404).json({ error: 'Session not found' });
838
+ }
839
+
840
+ const tags = tagDb.replaceSessionTags(sessionId, projectName, tagIds, {
841
+ linkedBy: req.user?.username || 'user',
842
+ source: 'manual',
843
+ });
844
+ res.json({ success: true, tags });
845
+ } catch (error) {
846
+ res.status(500).json({ error: error.message });
847
+ }
848
+ });
849
+
850
+ app.get('/api/projects/:projectName/sessions/:sessionId/context-review', authenticateToken, async (req, res) => {
851
+ try {
852
+ const { projectName, sessionId } = req.params;
853
+ const session = sessionDb.getSessionById(sessionId);
854
+
855
+ if (!session || session.project_name !== projectName) {
856
+ return res.status(404).json({ error: 'Session not found' });
857
+ }
858
+
859
+ res.json({
860
+ sessionId,
861
+ projectName,
862
+ reviews: sessionDb.getSessionContextReview(sessionId),
863
+ });
864
+ } catch (error) {
865
+ res.status(500).json({ error: error.message });
866
+ }
867
+ });
868
+
869
+ app.put('/api/projects/:projectName/sessions/:sessionId/context-review', authenticateToken, async (req, res) => {
870
+ try {
871
+ const { projectName, sessionId } = req.params;
872
+ const { reviews } = req.body || {};
873
+ const session = sessionDb.getSessionById(sessionId);
874
+
875
+ if (!session || session.project_name !== projectName) {
876
+ return res.status(404).json({ error: 'Session not found' });
877
+ }
878
+
879
+ res.json({
880
+ sessionId,
881
+ projectName,
882
+ reviews: sessionDb.updateSessionContextReview(sessionId, reviews),
883
+ });
884
+ } catch (error) {
885
+ res.status(500).json({ error: error.message });
886
+ }
887
+ });
888
+
889
+ // Rename project endpoint
890
+ app.put('/api/projects/:projectName/rename', authenticateToken, async (req, res) => {
891
+ try {
892
+ const userId = req.user?.id;
893
+ const { displayName } = req.body;
894
+ await renameProject(req.params.projectName, displayName, userId);
895
+ res.json({ success: true });
896
+ } catch (error) {
897
+ res.status(500).json({ error: error.message });
898
+ }
899
+ });
900
+
901
+ // Rename session endpoint
902
+ app.put('/api/projects/:projectName/sessions/:sessionId/rename', authenticateToken, async (req, res) => {
903
+ try {
904
+ const userId = req.user?.id;
905
+ const { projectName, sessionId } = req.params;
906
+ const { summary, provider } = req.body;
907
+ await renameSession(projectName, sessionId, summary, provider, userId);
908
+ res.json({ success: true });
909
+ } catch (error) {
910
+ res.status(500).json({ error: error.message });
911
+ }
912
+ });
913
+
914
+ // Delete session endpoint
915
+ app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken, async (req, res) => {
916
+ try {
917
+ const { projectName, sessionId } = req.params;
918
+ const { provider } = req.query;
919
+ console.log(`[API] Deleting session: ${sessionId} from project: ${projectName}, provider: ${provider || 'claude'}`);
920
+ await deleteSession(projectName, sessionId, provider);
921
+ console.log(`[API] Session ${sessionId} deleted successfully`);
922
+ res.json({ success: true });
923
+ } catch (error) {
924
+ console.error(`[API] Error deleting session ${req.params.sessionId}:`, error);
925
+ res.status(500).json({ error: error.message });
926
+ }
927
+ });
928
+
929
+ // Delete project endpoint (force=true to delete with sessions)
930
+ app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => {
931
+ try {
932
+ const userId = req.user?.id;
933
+ const { projectName } = req.params;
934
+ const force = req.query.force === 'true';
935
+ await deleteProject(projectName, force, userId);
936
+ res.json({ success: true });
937
+ } catch (error) {
938
+ res.status(500).json({ error: error.message });
939
+ }
940
+ });
941
+
942
+ app.post('/api/projects/trash/:projectName/restore', authenticateToken, async (req, res) => {
943
+ try {
944
+ const userId = req.user?.id;
945
+ await restoreProject(req.params.projectName, userId);
946
+ res.json({ success: true });
947
+ } catch (error) {
948
+ res.status(500).json({ error: error.message });
949
+ }
950
+ });
951
+
952
+ app.delete('/api/projects/trash/:projectName', authenticateToken, async (req, res) => {
953
+ try {
954
+ const userId = req.user?.id;
955
+ const mode = req.query.mode === 'physical' ? 'physical' : 'logical';
956
+ await deleteTrashedProject(req.params.projectName, mode, userId);
957
+ res.json({ success: true });
958
+ } catch (error) {
959
+ res.status(500).json({ error: error.message });
960
+ }
961
+ });
962
+
963
+ // Create project endpoint
964
+ async function handleCreateProject(req, res) {
965
+ try {
966
+ const { path: projectPath, displayName = null } = req.body;
967
+
968
+ if (!projectPath || !projectPath.trim()) {
969
+ return res.status(400).json({ error: 'Project path is required' });
970
+ }
971
+
972
+ const project = await addProjectManually(projectPath.trim(), displayName, req.user?.id);
973
+ res.json({ success: true, project });
974
+ } catch (error) {
975
+ console.error('Error creating project:', error);
976
+ res.status(500).json({ error: error.message });
977
+ }
978
+ }
979
+
980
+ app.post('/api/projects/create', authenticateToken, handleCreateProject);
981
+ app.post('/api/projects', authenticateToken, handleCreateProject);
982
+
983
+ // Read file content endpoint
984
+ app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
985
+ try {
986
+ const { projectName } = req.params;
987
+ const { filePath } = req.query;
988
+
989
+
990
+ // Security: ensure the requested path is inside the project root
991
+ if (!filePath) {
992
+ return res.status(400).json({ error: 'Invalid file path' });
993
+ }
994
+
995
+ const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
996
+ if (!projectRoot) {
997
+ return res.status(404).json({ error: 'Project not found' });
998
+ }
999
+
1000
+ // Handle both absolute and relative paths with pipeline file fallback + bare name search
1001
+ const result = await resolveProjectFilePath(projectRoot, filePath);
1002
+ if (result.candidates) {
1003
+ return res.json({ ambiguous: true, candidates: result.candidates });
1004
+ }
1005
+ const resolved = result.resolved;
1006
+ const normalizedRoot = path.resolve(projectRoot) + path.sep;
1007
+ if (!resolved.startsWith(normalizedRoot)) {
1008
+ return res.status(403).json({ error: 'Path must be under project root' });
1009
+ }
1010
+
1011
+ const content = await fsPromises.readFile(resolved, 'utf8');
1012
+ res.json({ content, path: resolved });
1013
+ } catch (error) {
1014
+ if (error.code === 'ENOENT') {
1015
+ // File not found is a normal condition (e.g. optional config files) — no noisy log
1016
+ res.status(404).json({ error: 'File not found' });
1017
+ } else if (error.code === 'EACCES') {
1018
+ console.error('Permission denied reading file:', resolved);
1019
+ res.status(403).json({ error: 'Permission denied' });
1020
+ } else {
1021
+ console.error('Error reading file:', error);
1022
+ res.status(500).json({ error: error.message });
1023
+ }
1024
+ }
1025
+ });
1026
+
1027
+ // Serve binary file content endpoint (for images, etc.)
1028
+ app.get('/api/projects/:projectName/files/content', authenticateToken, async (req, res) => {
1029
+ try {
1030
+ const { projectName } = req.params;
1031
+ const { path: filePath } = req.query;
1032
+
1033
+
1034
+ // Security: ensure the requested path is inside the project root
1035
+ if (!filePath) {
1036
+ return res.status(400).json({ error: 'Invalid file path' });
1037
+ }
1038
+
1039
+ const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
1040
+ if (!projectRoot) {
1041
+ return res.status(404).json({ error: 'Project not found' });
1042
+ }
1043
+
1044
+ const resolved = path.isAbsolute(filePath)
1045
+ ? path.resolve(filePath)
1046
+ : path.resolve(projectRoot, filePath);
1047
+ const normalizedRoot = path.resolve(projectRoot) + path.sep;
1048
+ if (!resolved.startsWith(normalizedRoot)) {
1049
+ return res.status(403).json({ error: 'Path must be under project root' });
1050
+ }
1051
+
1052
+ // Check if file exists
1053
+ try {
1054
+ await fsPromises.access(resolved);
1055
+ } catch (error) {
1056
+ return res.status(404).json({ error: 'File not found' });
1057
+ }
1058
+
1059
+ // Get file extension and set appropriate content type
1060
+ const mimeType = mime.lookup(resolved) || 'application/octet-stream';
1061
+ res.setHeader('Content-Type', mimeType);
1062
+
1063
+ // Stream the file
1064
+ const fileStream = fs.createReadStream(resolved);
1065
+ fileStream.pipe(res);
1066
+
1067
+ fileStream.on('error', (error) => {
1068
+ console.error('Error streaming file:', error);
1069
+ if (!res.headersSent) {
1070
+ res.status(500).json({ error: 'Error reading file' });
1071
+ }
1072
+ });
1073
+
1074
+ } catch (error) {
1075
+ console.error('Error serving binary file:', error);
1076
+ if (!res.headersSent) {
1077
+ res.status(500).json({ error: error.message });
1078
+ }
1079
+ }
1080
+ });
1081
+
1082
+ // Save file content endpoint
1083
+ app.put('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
1084
+ try {
1085
+ const { projectName } = req.params;
1086
+ const { filePath, content } = req.body;
1087
+
1088
+
1089
+ // Security: ensure the requested path is inside the project root
1090
+ if (!filePath) {
1091
+ return res.status(400).json({ error: 'Invalid file path' });
1092
+ }
1093
+
1094
+ if (content === undefined) {
1095
+ return res.status(400).json({ error: 'Content is required' });
1096
+ }
1097
+
1098
+ const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
1099
+ if (!projectRoot) {
1100
+ return res.status(404).json({ error: 'Project not found' });
1101
+ }
1102
+
1103
+ // Handle both absolute and relative paths with pipeline file fallback + bare name search
1104
+ const result = await resolveProjectFilePath(projectRoot, filePath);
1105
+ if (result.candidates) {
1106
+ return res.status(400).json({ error: 'Ambiguous filename', candidates: result.candidates });
1107
+ }
1108
+ const resolved = result.resolved;
1109
+ const normalizedRoot = path.resolve(projectRoot) + path.sep;
1110
+ if (!resolved.startsWith(normalizedRoot)) {
1111
+ return res.status(403).json({ error: 'Path must be under project root' });
1112
+ }
1113
+
1114
+ // Write the new content
1115
+ await fsPromises.writeFile(resolved, content, 'utf8');
1116
+
1117
+ res.json({
1118
+ success: true,
1119
+ path: resolved,
1120
+ message: 'File saved successfully'
1121
+ });
1122
+ } catch (error) {
1123
+ console.error('Error saving file:', error);
1124
+ if (error.code === 'ENOENT') {
1125
+ res.status(404).json({ error: 'File or directory not found' });
1126
+ } else if (error.code === 'EACCES') {
1127
+ res.status(403).json({ error: 'Permission denied' });
1128
+ } else {
1129
+ res.status(500).json({ error: error.message });
1130
+ }
1131
+ }
1132
+ });
1133
+
1134
+ // Delete a file or directory from the project filesystem
1135
+ app.delete('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
1136
+ try {
1137
+ const projectName = req.params.projectName;
1138
+ const { filePath } = req.body || {};
1139
+
1140
+ if (!filePath || typeof filePath !== 'string') {
1141
+ return res.status(400).json({ error: 'Invalid file path' });
1142
+ }
1143
+
1144
+ const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
1145
+ if (!projectRoot) {
1146
+ return res.status(404).json({ error: 'Project not found' });
1147
+ }
1148
+
1149
+ const resolved = path.isAbsolute(filePath)
1150
+ ? path.resolve(filePath)
1151
+ : path.resolve(projectRoot, filePath);
1152
+ const normalizedRoot = path.resolve(projectRoot) + path.sep;
1153
+ if (!resolved.startsWith(normalizedRoot)) {
1154
+ return res.status(403).json({ error: 'Path must be under project root' });
1155
+ }
1156
+
1157
+ await fsPromises.rm(resolved, { recursive: true });
1158
+
1159
+ res.json({ success: true });
1160
+ } catch (error) {
1161
+ console.error('Error deleting file:', error);
1162
+ if (error.code === 'ENOENT') {
1163
+ res.status(404).json({ error: 'File or directory not found' });
1164
+ } else if (error.code === 'EACCES') {
1165
+ res.status(403).json({ error: 'Permission denied' });
1166
+ } else {
1167
+ res.status(500).json({ error: error.message });
1168
+ }
1169
+ }
1170
+ });
1171
+
1172
+ // Upload files to project filesystem
1173
+ app.post('/api/projects/:projectName/upload-files', authenticateToken, async (req, res) => {
1174
+ try {
1175
+ const multer = (await import('multer')).default;
1176
+ const projectName = req.params.projectName;
1177
+ const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
1178
+ if (!projectRoot) {
1179
+ return res.status(404).json({ error: 'Project not found' });
1180
+ }
1181
+
1182
+ const storage = multer.diskStorage({
1183
+ destination: async (req, file, cb) => {
1184
+ const targetDir = (req.body && req.body.targetDir) || '';
1185
+ const resolved = path.resolve(projectRoot, targetDir);
1186
+ const normalizedRoot = path.resolve(projectRoot) + path.sep;
1187
+ if (!resolved.startsWith(normalizedRoot) && resolved !== path.resolve(projectRoot)) {
1188
+ return cb(new Error('Path must be under project root'));
1189
+ }
1190
+ await fsPromises.mkdir(resolved, { recursive: true });
1191
+ cb(null, resolved);
1192
+ },
1193
+ filename: (req, file, cb) => {
1194
+ const sanitized = file.originalname.replace(/[/\\]/g, '_').replace(/\.\./g, '_');
1195
+ cb(null, sanitized);
1196
+ }
1197
+ });
1198
+
1199
+ const upload = multer({
1200
+ storage,
1201
+ limits: {
1202
+ fileSize: 50 * 1024 * 1024, // 50MB
1203
+ files: 20
1204
+ }
1205
+ });
1206
+
1207
+ upload.array('files', 20)(req, res, async (err) => {
1208
+ if (err) {
1209
+ const status = err.message === 'Path must be under project root' ? 403 : 400;
1210
+ return res.status(status).json({ error: err.message });
1211
+ }
1212
+
1213
+ if (!req.files || req.files.length === 0) {
1214
+ return res.status(400).json({ error: 'No files provided' });
1215
+ }
1216
+
1217
+ res.json({
1218
+ files: req.files.map(f => ({
1219
+ name: f.filename,
1220
+ size: f.size,
1221
+ path: f.path
1222
+ }))
1223
+ });
1224
+ });
1225
+ } catch (error) {
1226
+ console.error('Error in file upload endpoint:', error);
1227
+ res.status(500).json({ error: 'Internal server error' });
1228
+ }
1229
+ });
1230
+
1231
+ // Global skills endpoints (GET /api/skills, GET /api/skills/file) are handled
1232
+ // by the skillsRoutes router mounted above at /api/skills.
1233
+
1234
+ app.get('/api/projects/:projectName/files', authenticateToken, async (req, res) => {
1235
+ try {
1236
+
1237
+ // Using fsPromises from import
1238
+
1239
+ // Use extractProjectDirectory to get the actual project path
1240
+ let actualPath;
1241
+ try {
1242
+ actualPath = await extractProjectDirectory(req.params.projectName);
1243
+ } catch (error) {
1244
+ console.error('Error extracting project directory:', error);
1245
+ // Fallback to simple dash replacement
1246
+ actualPath = req.params.projectName.replace(/-/g, '/');
1247
+ }
1248
+
1249
+ const projectRoot = path.resolve(actualPath);
1250
+ const { path: requestedPath, maxDepth: maxDepthQuery, showHidden: showHiddenQuery } = req.query;
1251
+
1252
+ let targetPath = projectRoot;
1253
+ if (typeof requestedPath === 'string' && requestedPath.trim()) {
1254
+ targetPath = path.isAbsolute(requestedPath)
1255
+ ? path.resolve(requestedPath)
1256
+ : path.resolve(projectRoot, requestedPath);
1257
+
1258
+ const normalizedRoot = projectRoot + path.sep;
1259
+ if (targetPath !== projectRoot && !targetPath.startsWith(normalizedRoot)) {
1260
+ return res.status(403).json({ error: 'Path must be under project root' });
1261
+ }
1262
+ }
1263
+
1264
+ // Check if path exists
1265
+ try {
1266
+ await fsPromises.access(targetPath);
1267
+ } catch (e) {
1268
+ return res.status(404).json({ error: `Project path not found: ${targetPath}` });
1269
+ }
1270
+
1271
+ let maxDepth = 10;
1272
+ if (maxDepthQuery !== undefined) {
1273
+ const parsedDepth = Number.parseInt(String(maxDepthQuery), 10);
1274
+ if (!Number.isNaN(parsedDepth)) {
1275
+ maxDepth = Math.min(10, Math.max(0, parsedDepth));
1276
+ }
1277
+ }
1278
+
1279
+ const showHidden = showHiddenQuery === undefined
1280
+ ? true
1281
+ : ['1', 'true', 'yes', 'on'].includes(String(showHiddenQuery).toLowerCase());
1282
+
1283
+ const stats = await fsPromises.stat(targetPath);
1284
+ if (!stats.isDirectory()) {
1285
+ return res.status(400).json({ error: 'Path must be a directory' });
1286
+ }
1287
+
1288
+ const files = await getFileTree(targetPath, maxDepth, 0, showHidden);
1289
+ res.json(files);
1290
+ } catch (error) {
1291
+ console.error('[ERROR] File tree error:', error.message);
1292
+ res.status(500).json({ error: error.message });
1293
+ }
1294
+ });
1295
+
1296
+ // WebSocket connection handler that routes based on URL path
1297
+ wss.on('connection', (ws, request) => {
1298
+ const url = request.url;
1299
+ console.log('[INFO] Client connected to:', url);
1300
+
1301
+ // Parse URL to get pathname without query parameters
1302
+ const urlObj = new URL(url, 'http://localhost');
1303
+ const pathname = urlObj.pathname;
1304
+
1305
+ if (pathname === '/shell') {
1306
+ handleShellConnection(ws);
1307
+ } else if (pathname === '/ws') {
1308
+ handleChatConnection(ws, request);
1309
+ } else if (pathname === '/compute-shell') {
1310
+ const nodeId = urlObj.searchParams.get('nodeId') || null;
1311
+ handleComputeShellConnection(ws, nodeId);
1312
+ } else {
1313
+ console.log('[WARN] Unknown WebSocket path:', pathname);
1314
+ ws.close();
1315
+ }
1316
+ });
1317
+
1318
+ /**
1319
+ * WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface
1320
+ */
1321
+ class WebSocketWriter {
1322
+ constructor(ws, telemetryContext = null) {
1323
+ this.ws = ws;
1324
+ this.sessionId = null;
1325
+ this.isWebSocketWriter = true; // Marker for transport detection
1326
+ this.telemetryContext = telemetryContext;
1327
+ this.projectPath = null;
1328
+ }
1329
+
1330
+ send(data) {
1331
+ if (this.ws.readyState === 1) { // WebSocket.OPEN
1332
+ // Providers send raw objects, we stringify for WebSocket
1333
+ this.ws.send(JSON.stringify(data));
1334
+ trackAgentResponseTelemetry(data, this.telemetryContext);
1335
+ }
1336
+ }
1337
+
1338
+ setSessionId(sessionId) {
1339
+ this.sessionId = sessionId;
1340
+ }
1341
+
1342
+ setProjectPath(projectPath) {
1343
+ this.projectPath = projectPath;
1344
+ }
1345
+
1346
+ getSessionId() {
1347
+ return this.sessionId;
1348
+ }
1349
+
1350
+ getProjectPath() {
1351
+ return this.projectPath;
1352
+ }
1353
+ }
1354
+
1355
+ function enqueueConversationTelemetry(event, context = {}) {
1356
+ if (context.telemetryEnabled === false) {
1357
+ return;
1358
+ }
1359
+ enqueueTelemetryEvent({
1360
+ source: 'chat-websocket',
1361
+ ...context,
1362
+ ...event,
1363
+ receivedAt: new Date().toISOString(),
1364
+ });
1365
+ }
1366
+
1367
+ function hasAgentResponseContent(payload) {
1368
+ if (!payload || typeof payload !== 'object') {
1369
+ return false;
1370
+ }
1371
+
1372
+ if (payload.type === 'claude-response') {
1373
+ const data = payload.data;
1374
+ if (!data || typeof data !== 'object') {
1375
+ return false;
1376
+ }
1377
+
1378
+ if (typeof data.content === 'string' && data.content.trim()) {
1379
+ return true;
1380
+ }
1381
+
1382
+ if (Array.isArray(data.content)) {
1383
+ return data.content.some((part) => part?.type === 'text' && typeof part?.text === 'string' && part.text.trim());
1384
+ }
1385
+
1386
+ return false;
1387
+ }
1388
+
1389
+ if (payload.type === 'cursor-result') {
1390
+ const result = payload.data?.result;
1391
+ return typeof result === 'string' && Boolean(result.trim());
1392
+ }
1393
+
1394
+ if (payload.type === 'codex-response') {
1395
+ const codexData = payload.data;
1396
+ if (!codexData || typeof codexData !== 'object') {
1397
+ return false;
1398
+ }
1399
+ if (codexData.type === 'item' && codexData.itemType === 'agent_message') {
1400
+ const content = codexData.message?.content;
1401
+ return typeof content === 'string' && Boolean(content.trim());
1402
+ }
1403
+ }
1404
+
1405
+ return false;
1406
+ }
1407
+
1408
+ function trackAgentResponseTelemetry(payload, context = {}) {
1409
+ if (context.telemetryEnabled === false) {
1410
+ return;
1411
+ }
1412
+ if (context.provider === 'claude' && payload?.type === 'claude-response') {
1413
+ const streamData = payload.data;
1414
+ const sessionKey = `${context.provider}:${payload.sessionId || 'pending'}`;
1415
+
1416
+ if (streamData?.type === 'content_block_delta' && typeof streamData?.delta?.text === 'string') {
1417
+ trackAgentResponseTelemetry.streamBuffers.set(sessionKey, true);
1418
+ return;
1419
+ }
1420
+
1421
+ if (streamData?.type === 'content_block_stop') {
1422
+ const hasContent = trackAgentResponseTelemetry.streamBuffers.get(sessionKey);
1423
+ if (hasContent) {
1424
+ enqueueConversationTelemetry(
1425
+ {
1426
+ name: 'agent_dialogue_meta',
1427
+ direction: 'agent_to_user',
1428
+ provider: context.provider || 'unknown',
1429
+ sessionId: payload.sessionId || context.sessionId || null,
1430
+ transportType: payload.type || 'unknown',
1431
+ },
1432
+ context,
1433
+ );
1434
+ }
1435
+ trackAgentResponseTelemetry.streamBuffers.delete(sessionKey);
1436
+ return;
1437
+ }
1438
+ }
1439
+
1440
+ if (!hasAgentResponseContent(payload)) {
1441
+ return;
1442
+ }
1443
+
1444
+ enqueueConversationTelemetry(
1445
+ {
1446
+ name: 'agent_dialogue_meta',
1447
+ direction: 'agent_to_user',
1448
+ provider: context.provider || 'unknown',
1449
+ sessionId: payload.sessionId || context.sessionId || null,
1450
+ transportType: payload.type || 'unknown',
1451
+ },
1452
+ context,
1453
+ );
1454
+ }
1455
+ trackAgentResponseTelemetry.streamBuffers = new Map();
1456
+
1457
+ // Handle chat WebSocket connections
1458
+ function handleChatConnection(ws, request) {
1459
+ console.log('[INFO] Chat WebSocket connected');
1460
+
1461
+ // Add to connected clients for project updates
1462
+ connectedClients.add(ws);
1463
+
1464
+ const user = request?.user || {};
1465
+ const userId = user.userId || user.id || null;
1466
+ const telemetryContext = {
1467
+ userId: userId,
1468
+ username: user.username || null,
1469
+ clientType: 'websocket',
1470
+ telemetryEnabled: true,
1471
+ };
1472
+
1473
+ const geminiApiKey = getGeminiApiKeyForUser(userId);
1474
+ const sessionEnv = withGeminiApiKeyEnv(process.env, geminiApiKey);
1475
+
1476
+ // Wrap WebSocket with writer for consistent interface with SSEStreamWriter
1477
+ const writer = new WebSocketWriter(ws, telemetryContext);
1478
+
1479
+ ws.on('message', async (message) => {
1480
+ try {
1481
+ const data = JSON.parse(message);
1482
+ console.log(`[DEBUG] Received WebSocket message: ${data.type}`);
1483
+
1484
+ if (data.type === 'telemetry-settings') {
1485
+ const enabled = data.enabled !== false;
1486
+ writer.telemetryContext = {
1487
+ ...(writer.telemetryContext || telemetryContext),
1488
+ telemetryEnabled: enabled,
1489
+ };
1490
+ } else if (data.type === 'claude-command') {
1491
+ console.log('[DEBUG] User message:', data.command || '[Continue/Resume]');
1492
+ console.log('📁 Project:', data.options?.projectPath || 'Unknown');
1493
+ console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
1494
+ const commandTelemetryEnabled = data.options?.telemetryEnabled !== false;
1495
+ enqueueConversationTelemetry(
1496
+ {
1497
+ name: 'agent_dialogue_meta',
1498
+ direction: 'user_to_agent',
1499
+ provider: 'claude',
1500
+ sessionId: data.options?.sessionId || data.sessionId || null,
1501
+ projectPath: data.options?.projectPath || data.options?.cwd || null,
1502
+ transportType: data.type,
1503
+ },
1504
+ { ...telemetryContext, telemetryEnabled: commandTelemetryEnabled },
1505
+ );
1506
+ writer.telemetryContext = { ...telemetryContext, provider: 'claude', telemetryEnabled: commandTelemetryEnabled };
1507
+ writer.setProjectPath(data.options?.projectPath || data.options?.cwd || null);
1508
+
1509
+ // Use Claude Agents SDK
1510
+ const sessionId = data.options?.sessionId || data.sessionId;
1511
+ if (sessionId && isClaudeSDKSessionActive(sessionId)) {
1512
+ console.log(`[WARN] Session ${sessionId} is already active. Ignoring concurrent request.`);
1513
+ return;
1514
+ }
1515
+
1516
+ queryClaudeSDK(data.command, { ...data.options, env: sessionEnv }, writer).catch(error => {
1517
+ console.error('[ERROR] Claude query error:', error);
1518
+ });
1519
+ } else if (data.type === 'cursor-command') {
1520
+ console.log('[DEBUG] Cursor message:', data.command || '[Continue/Resume]');
1521
+ console.log('📁 Project:', data.options?.cwd || 'Unknown');
1522
+ console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
1523
+ console.log('🤖 Model:', data.options?.model || 'default');
1524
+ const commandTelemetryEnabled = data.options?.telemetryEnabled !== false;
1525
+ const sessionId = data.options?.sessionId || data.sessionId;
1526
+
1527
+ if (sessionId && isCursorSessionActive(sessionId)) {
1528
+ console.log(`[WARN] Cursor session ${sessionId} is already active. Ignoring concurrent request.`);
1529
+ return;
1530
+ }
1531
+
1532
+ enqueueConversationTelemetry(
1533
+ {
1534
+ name: 'agent_dialogue_meta',
1535
+ direction: 'user_to_agent',
1536
+ provider: 'cursor',
1537
+ sessionId: sessionId || null,
1538
+ projectPath: data.options?.projectPath || data.options?.cwd || null,
1539
+ transportType: data.type,
1540
+ },
1541
+ { ...telemetryContext, telemetryEnabled: commandTelemetryEnabled },
1542
+ );
1543
+ writer.telemetryContext = { ...telemetryContext, provider: 'cursor', telemetryEnabled: commandTelemetryEnabled };
1544
+ writer.setProjectPath(data.options?.projectPath || data.options?.cwd || null);
1545
+ spawnCursor(data.command, { ...data.options, env: sessionEnv }, writer).catch(error => {
1546
+ console.error('[ERROR] Cursor spawn error:', error);
1547
+ });
1548
+ } else if (data.type === 'codex-command') {
1549
+ console.log('[DEBUG] Codex message:', data.command || '[Continue/Resume]');
1550
+ console.log('📁 Project:', data.options?.projectPath || data.options?.cwd || 'Unknown');
1551
+ console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
1552
+ console.log('🤖 Model:', data.options?.model || 'default');
1553
+ const commandTelemetryEnabled = data.options?.telemetryEnabled !== false;
1554
+ const sessionId = data.options?.sessionId || data.sessionId;
1555
+
1556
+ if (sessionId && isCodexSessionActive(sessionId)) {
1557
+ console.log(`[WARN] Codex session ${sessionId} is already active. Ignoring concurrent request.`);
1558
+ return;
1559
+ }
1560
+
1561
+ enqueueConversationTelemetry(
1562
+ {
1563
+ name: 'agent_dialogue_meta',
1564
+ direction: 'user_to_agent',
1565
+ provider: 'codex',
1566
+ sessionId: sessionId || null,
1567
+ projectPath: data.options?.projectPath || data.options?.cwd || null,
1568
+ transportType: data.type,
1569
+ },
1570
+ { ...telemetryContext, telemetryEnabled: commandTelemetryEnabled },
1571
+ );
1572
+ writer.telemetryContext = { ...telemetryContext, provider: 'codex', telemetryEnabled: commandTelemetryEnabled };
1573
+ writer.setProjectPath(data.options?.projectPath || data.options?.cwd || null);
1574
+ queryCodex(data.command, { ...data.options, env: sessionEnv }, writer).catch(error => {
1575
+ console.error('[ERROR] Codex query error:', error);
1576
+ });
1577
+ } else if (data.type === 'gemini-command') {
1578
+ console.log('[DEBUG] Gemini message:', data.command || '[Continue/Resume]');
1579
+ console.log('📁 Project:', data.options?.projectPath || data.options?.cwd || 'Unknown');
1580
+ console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
1581
+ console.log('🤖 Model:', data.options?.model || 'default');
1582
+ const commandTelemetryEnabled = data.options?.telemetryEnabled !== false;
1583
+ const sessionId = data.options?.sessionId || data.sessionId;
1584
+
1585
+ if (sessionId && isGeminiSessionActive(sessionId)) {
1586
+ console.log(`[WARN] Gemini session ${sessionId} is already active. Ignoring concurrent request.`);
1587
+ return;
1588
+ }
1589
+
1590
+ enqueueConversationTelemetry(
1591
+ {
1592
+ name: 'agent_dialogue_meta',
1593
+ direction: 'user_to_agent',
1594
+ provider: 'gemini',
1595
+ sessionId: sessionId || null,
1596
+ projectPath: data.options?.projectPath || data.options?.cwd || null,
1597
+ transportType: data.type,
1598
+ },
1599
+ { ...telemetryContext, telemetryEnabled: commandTelemetryEnabled },
1600
+ );
1601
+ writer.telemetryContext = { ...telemetryContext, provider: 'gemini', telemetryEnabled: commandTelemetryEnabled };
1602
+ writer.setProjectPath(data.options?.projectPath || data.options?.cwd || null);
1603
+ spawnGemini(data.command, { ...data.options, env: sessionEnv }, writer).catch(error => {
1604
+ console.error('[ERROR] Gemini spawn error:', error);
1605
+ });
1606
+ } else if (data.type === 'openrouter-command') {
1607
+ console.log('[DEBUG] OpenRouter message:', data.command || '[Continue/Resume]');
1608
+ console.log('📁 Project:', data.options?.projectPath || data.options?.cwd || 'Unknown');
1609
+ console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
1610
+ console.log('🤖 Model:', data.options?.model || 'default');
1611
+ const commandTelemetryEnabled = data.options?.telemetryEnabled !== false;
1612
+ const sessionId = data.options?.sessionId || data.sessionId;
1613
+
1614
+ if (sessionId && isOpenRouterSessionActive(sessionId)) {
1615
+ console.log(`[WARN] OpenRouter session ${sessionId} is already active. Ignoring concurrent request.`);
1616
+ return;
1617
+ }
1618
+
1619
+ enqueueConversationTelemetry(
1620
+ {
1621
+ name: 'agent_dialogue_meta',
1622
+ direction: 'user_to_agent',
1623
+ provider: 'openrouter',
1624
+ sessionId: sessionId || null,
1625
+ projectPath: data.options?.projectPath || data.options?.cwd || null,
1626
+ transportType: data.type,
1627
+ },
1628
+ { ...telemetryContext, telemetryEnabled: commandTelemetryEnabled },
1629
+ );
1630
+ writer.telemetryContext = { ...telemetryContext, provider: 'openrouter', telemetryEnabled: commandTelemetryEnabled };
1631
+ writer.setProjectPath(data.options?.projectPath || data.options?.cwd || null);
1632
+ queryOpenRouter(data.command, { ...data.options, env: sessionEnv }, writer).catch(error => {
1633
+ console.error('[ERROR] OpenRouter query error:', error);
1634
+ });
1635
+ } else if (data.type === 'local-command') {
1636
+ console.log('[DEBUG] Local GPU message:', data.command || '[Continue/Resume]');
1637
+ console.log('📁 Project:', data.options?.projectPath || data.options?.cwd || 'Unknown');
1638
+ console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
1639
+ console.log('🤖 Model:', data.options?.model || 'default');
1640
+ console.log('🖥️ Server:', data.options?.serverUrl || 'default');
1641
+ const commandTelemetryEnabled = data.options?.telemetryEnabled !== false;
1642
+ const sessionId = data.options?.sessionId || data.sessionId;
1643
+
1644
+ if (sessionId && isLocalGPUSessionActive(sessionId)) {
1645
+ console.log(`[WARN] Local GPU session ${sessionId} is already active. Ignoring concurrent request.`);
1646
+ return;
1647
+ }
1648
+
1649
+ enqueueConversationTelemetry(
1650
+ {
1651
+ name: 'agent_dialogue_meta',
1652
+ direction: 'user_to_agent',
1653
+ provider: 'local',
1654
+ sessionId: sessionId || null,
1655
+ projectPath: data.options?.projectPath || data.options?.cwd || null,
1656
+ transportType: data.type,
1657
+ },
1658
+ { ...telemetryContext, telemetryEnabled: commandTelemetryEnabled },
1659
+ );
1660
+ writer.telemetryContext = { ...telemetryContext, provider: 'local', telemetryEnabled: commandTelemetryEnabled };
1661
+ writer.setProjectPath(data.options?.projectPath || data.options?.cwd || null);
1662
+ queryLocalGPU(data.command, { ...data.options, env: sessionEnv }, writer).catch(error => {
1663
+ console.error('[ERROR] Local GPU query error:', error);
1664
+ });
1665
+ } else if (data.type === 'cursor-resume') {
1666
+ // Backward compatibility: treat as cursor-command with resume and no prompt
1667
+ console.log('[DEBUG] Cursor resume session (compat):', data.sessionId);
1668
+ const sessionId = data.sessionId;
1669
+
1670
+ if (sessionId && isCursorSessionActive(sessionId)) {
1671
+ console.log(`[WARN] Cursor session ${sessionId} is already active. Ignoring concurrent request.`);
1672
+ return;
1673
+ }
1674
+
1675
+ spawnCursor('', {
1676
+ sessionId: data.sessionId,
1677
+ resume: true,
1678
+ cwd: data.options?.cwd,
1679
+ env: sessionEnv
1680
+ }, writer).catch(error => {
1681
+ console.error('[ERROR] Cursor resume error:', error);
1682
+ });
1683
+ } else if (data.type === 'abort-session') {
1684
+ console.log('[DEBUG] Abort session request:', data.sessionId);
1685
+ const provider = data.provider || 'claude';
1686
+ let success;
1687
+
1688
+ if (provider === 'cursor') {
1689
+ success = abortCursorSession(data.sessionId);
1690
+ } else if (provider === 'codex') {
1691
+ success = abortCodexSession(data.sessionId);
1692
+ } else if (provider === 'gemini') {
1693
+ success = abortGeminiSession(data.sessionId);
1694
+ } else if (provider === 'openrouter') {
1695
+ success = abortOpenRouterSession(data.sessionId);
1696
+ } else if (provider === 'local') {
1697
+ success = abortLocalGPUSession(data.sessionId);
1698
+ } else {
1699
+ // Use Claude Agents SDK
1700
+ success = await abortClaudeSDKSession(data.sessionId);
1701
+ }
1702
+
1703
+ writer.send({
1704
+ type: 'session-aborted',
1705
+ sessionId: data.sessionId,
1706
+ provider,
1707
+ success
1708
+ });
1709
+ } else if (data.type === 'claude-permission-response') {
1710
+ // Relay UI approval decisions back into the SDK control flow.
1711
+ // This does not persist permissions; it only resolves the in-flight request,
1712
+ // introduced so the SDK can resume once the user clicks Allow/Deny.
1713
+ if (data.requestId) {
1714
+ resolveToolApproval(data.requestId, {
1715
+ allow: Boolean(data.allow),
1716
+ updatedInput: data.updatedInput,
1717
+ message: data.message,
1718
+ rememberEntry: data.rememberEntry
1719
+ });
1720
+ }
1721
+ } else if (data.type === 'cursor-abort') {
1722
+ console.log('[DEBUG] Abort Cursor session:', data.sessionId);
1723
+ const success = abortCursorSession(data.sessionId);
1724
+ writer.send({
1725
+ type: 'session-aborted',
1726
+ sessionId: data.sessionId,
1727
+ provider: 'cursor',
1728
+ success
1729
+ });
1730
+ } else if (data.type === 'check-session-status') {
1731
+ // Check if a specific session is currently processing
1732
+ const provider = data.provider || 'claude';
1733
+ const sessionId = data.sessionId;
1734
+ let isActive;
1735
+ let startTime = null;
1736
+
1737
+ if (provider === 'cursor') {
1738
+ isActive = isCursorSessionActive(sessionId);
1739
+ startTime = getCursorSessionStartTime(sessionId);
1740
+ } else if (provider === 'codex') {
1741
+ isActive = isCodexSessionActive(sessionId);
1742
+ startTime = getCodexSessionStartTime(sessionId);
1743
+ } else if (provider === 'gemini') {
1744
+ isActive = isGeminiSessionActive(sessionId);
1745
+ startTime = getGeminiSessionStartTime(sessionId);
1746
+ } else if (provider === 'openrouter') {
1747
+ isActive = isOpenRouterSessionActive(sessionId);
1748
+ startTime = getOpenRouterSessionStartTime(sessionId);
1749
+ } else if (provider === 'local') {
1750
+ isActive = isLocalGPUSessionActive(sessionId);
1751
+ startTime = getLocalGPUSessionStartTime(sessionId);
1752
+ } else {
1753
+ // Use Claude Agents SDK
1754
+ isActive = isClaudeSDKSessionActive(sessionId);
1755
+ startTime = getClaudeSDKSessionStartTime(sessionId);
1756
+ }
1757
+
1758
+ writer.send({
1759
+ type: 'session-status',
1760
+ sessionId,
1761
+ provider,
1762
+ isProcessing: isActive,
1763
+ startTime
1764
+ });
1765
+ } else if (data.type === 'get-active-sessions') {
1766
+ // Get all currently active sessions
1767
+ const activeSessions = {
1768
+ claude: getActiveClaudeSDKSessions(),
1769
+ cursor: getActiveCursorSessions(),
1770
+ codex: getActiveCodexSessions(),
1771
+ gemini: getActiveGeminiSessions(),
1772
+ local: getActiveLocalGPUSessions()
1773
+ };
1774
+
1775
+ writer.send({
1776
+ type: 'active-sessions',
1777
+ sessions: activeSessions
1778
+ });
1779
+ }
1780
+ } catch (error) {
1781
+ console.error('[ERROR] Chat WebSocket error:', error.message);
1782
+ writer.send({
1783
+ type: 'error',
1784
+ error: error.message
1785
+ });
1786
+ }
1787
+ });
1788
+
1789
+ ws.on('close', () => {
1790
+ console.log('🔌 Chat client disconnected');
1791
+ // Remove from connected clients
1792
+ connectedClients.delete(ws);
1793
+ });
1794
+ }
1795
+
1796
+ // Handle shell WebSocket connections
1797
+ function handleShellConnection(ws) {
1798
+ console.log('🐚 Shell client connected');
1799
+ let shellProcess = null;
1800
+ let ptySessionKey = null;
1801
+ let urlDetectionBuffer = '';
1802
+ const announcedAuthUrls = new Set();
1803
+
1804
+ ws.on('message', async (message) => {
1805
+ try {
1806
+ const data = JSON.parse(message);
1807
+ console.log('📨 Shell message received:', data.type);
1808
+
1809
+ if (data.type === 'init') {
1810
+ const projectPath = data.projectPath || process.cwd();
1811
+ const sessionId = data.sessionId;
1812
+ const hasSession = data.hasSession;
1813
+ const provider = ['claude', 'cursor', 'codex', 'gemini', 'plain-shell'].includes(data.provider)
1814
+ ? data.provider
1815
+ : 'claude';
1816
+ const initialCommand = data.initialCommand;
1817
+ const normalizedInitialCommand = normalizeCursorLoginCommand(initialCommand || '');
1818
+ const isPlainShell = data.isPlainShell || (!!initialCommand && !hasSession) || provider === 'plain-shell';
1819
+ urlDetectionBuffer = '';
1820
+ announcedAuthUrls.clear();
1821
+
1822
+ // Login commands (Claude/Cursor auth) should never reuse cached sessions
1823
+ const isLoginCommand = initialCommand && (
1824
+ initialCommand.includes('setup-token') ||
1825
+ initialCommand.includes('/login') ||
1826
+ isCursorLoginCommand(initialCommand) ||
1827
+ isGeminiLoginCommand(initialCommand) ||
1828
+ initialCommand.includes('auth login')
1829
+ );
1830
+ // Include command hash in session key so different commands get separate sessions
1831
+ const commandSuffix = isPlainShell && initialCommand
1832
+ ? `_cmd_${Buffer.from(initialCommand).toString('base64').slice(0, 16)}`
1833
+ : '';
1834
+ const providerSuffix = isPlainShell ? '_provider_plain-shell' : `_provider_${provider}`;
1835
+ ptySessionKey = `${projectPath}_${sessionId || 'default'}${providerSuffix}${commandSuffix}`;
1836
+
1837
+ // Kill any existing login session before starting fresh
1838
+ if (isLoginCommand) {
1839
+ const oldSession = ptySessionsMap.get(ptySessionKey);
1840
+ if (oldSession) {
1841
+ console.log('🧹 Cleaning up existing login session:', ptySessionKey);
1842
+ if (oldSession.timeoutId) clearTimeout(oldSession.timeoutId);
1843
+ if (oldSession.pty && oldSession.pty.kill) oldSession.pty.kill();
1844
+ ptySessionsMap.delete(ptySessionKey);
1845
+ }
1846
+ }
1847
+
1848
+ const existingSession = isLoginCommand ? null : ptySessionsMap.get(ptySessionKey);
1849
+ if (existingSession) {
1850
+ console.log('♻️ Reconnecting to existing PTY session:', ptySessionKey);
1851
+ shellProcess = existingSession.pty;
1852
+
1853
+ clearTimeout(existingSession.timeoutId);
1854
+
1855
+ ws.send(JSON.stringify({
1856
+ type: 'output',
1857
+ data: `\x1b[36m[Reconnected to existing session]\x1b[0m\r\n`
1858
+ }));
1859
+
1860
+ if (existingSession.buffer && existingSession.buffer.length > 0) {
1861
+ console.log(`📜 Sending ${existingSession.buffer.length} buffered messages`);
1862
+ existingSession.buffer.forEach(bufferedData => {
1863
+ const replayData = stripReplayTerminalQueries(bufferedData);
1864
+ if (!replayData) {
1865
+ return;
1866
+ }
1867
+ ws.send(JSON.stringify({
1868
+ type: 'output',
1869
+ data: replayData
1870
+ }));
1871
+ });
1872
+ }
1873
+
1874
+ existingSession.ws = ws;
1875
+
1876
+ return;
1877
+ }
1878
+
1879
+ console.log('[INFO] Starting shell in:', projectPath);
1880
+ console.log('📋 Session info:', hasSession ? `Resume session ${sessionId}` : (isPlainShell ? 'Plain shell mode' : 'New session'));
1881
+ console.log('🤖 Provider:', isPlainShell ? 'plain-shell' : provider);
1882
+ if (initialCommand) {
1883
+ console.log('⚡ Initial command:', initialCommand);
1884
+ }
1885
+
1886
+ // First send a welcome message
1887
+ let welcomeMsg;
1888
+ if (isPlainShell) {
1889
+ welcomeMsg = `\x1b[36mStarting terminal in: ${projectPath}\x1b[0m\r\n`;
1890
+ } else {
1891
+ const providerName = provider === 'cursor' ? 'Cursor' :
1892
+ provider === 'codex' ? 'Codex' :
1893
+ provider === 'gemini' ? 'Gemini' :
1894
+ 'Claude';
1895
+ welcomeMsg = hasSession ?
1896
+ `\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n` :
1897
+ `\x1b[36mStarting new ${providerName} session in: ${projectPath}\x1b[0m\r\n`;
1898
+ }
1899
+ ws.send(JSON.stringify({
1900
+ type: 'output',
1901
+ data: welcomeMsg
1902
+ }));
1903
+
1904
+ try {
1905
+ // Prepare the shell command adapted to the platform and provider
1906
+ let shellCommand;
1907
+ if (isPlainShell) {
1908
+ // Plain shell mode - run the initial command or launch interactive shell
1909
+ const shellInitialCommand = normalizedInitialCommand || initialCommand;
1910
+ if (shellInitialCommand) {
1911
+ // Has a command to run - wrap it in bash -c / powershell
1912
+ if (os.platform() === 'win32') {
1913
+ shellCommand = `Set-Location -Path "${projectPath}"; ${shellInitialCommand}`;
1914
+ } else {
1915
+ shellCommand = `cd "${projectPath}" && ${shellInitialCommand}`;
1916
+ }
1917
+ } else {
1918
+ // No command - launch interactive shell directly (handled in spawn below)
1919
+ shellCommand = null;
1920
+ }
1921
+ } else if (provider === 'cursor') {
1922
+ const cursorCommand = resolveCursorCliCommand();
1923
+ if (!cursorCommand) {
1924
+ ws.send(JSON.stringify({
1925
+ type: 'output',
1926
+ data: '\x1b[31mCursor CLI not found. Install Cursor CLI or set CURSOR_CLI_PATH to `agent` or `cursor-agent`.\x1b[0m\r\n'
1927
+ }));
1928
+ return;
1929
+ }
1930
+
1931
+ const cursorCommandForShell = cursorCommand.includes(' ')
1932
+ ? `"${cursorCommand.replace(/"/g, '\\"')}"`
1933
+ : cursorCommand;
1934
+
1935
+ // Use resolved Cursor CLI command (`cursor-agent` or `agent`)
1936
+ if (os.platform() === 'win32') {
1937
+ if (hasSession && sessionId) {
1938
+ shellCommand = `Set-Location -Path "${projectPath}"; ${cursorCommandForShell} --resume="${sessionId}"`;
1939
+ } else {
1940
+ shellCommand = `Set-Location -Path "${projectPath}"; ${cursorCommandForShell}`;
1941
+ }
1942
+ } else {
1943
+ if (hasSession && sessionId) {
1944
+ shellCommand = `cd "${projectPath}" && ${cursorCommandForShell} --resume="${sessionId}"`;
1945
+ } else {
1946
+ shellCommand = `cd "${projectPath}" && ${cursorCommandForShell}`;
1947
+ }
1948
+ }
1949
+ } else if (provider === 'codex') {
1950
+ // Use codex command
1951
+ if (os.platform() === 'win32') {
1952
+ if (hasSession && sessionId) {
1953
+ shellCommand = `Set-Location -Path "${projectPath}"; codex resume ${sessionId}; if ($LASTEXITCODE -ne 0) { codex }`;
1954
+ } else {
1955
+ shellCommand = `Set-Location -Path "${projectPath}"; codex`;
1956
+ }
1957
+ } else {
1958
+ if (hasSession && sessionId) {
1959
+ shellCommand = `cd "${projectPath}" && codex resume ${sessionId} || codex`;
1960
+ } else {
1961
+ shellCommand = `cd "${projectPath}" && codex`;
1962
+ }
1963
+ }
1964
+ } else if (provider === 'gemini') {
1965
+ // Use gemini command
1966
+ const command = initialCommand || 'gemini';
1967
+ if (os.platform() === 'win32') {
1968
+ if (hasSession && sessionId) {
1969
+ shellCommand = `Set-Location -Path "${projectPath}"; gemini --resume ${sessionId}; if ($LASTEXITCODE -ne 0) { gemini }`;
1970
+ } else {
1971
+ shellCommand = `Set-Location -Path "${projectPath}"; ${command}`;
1972
+ }
1973
+ } else {
1974
+ if (hasSession && sessionId) {
1975
+ shellCommand = `cd "${projectPath}" && gemini --resume ${sessionId} || gemini`;
1976
+ } else {
1977
+ shellCommand = `cd "${projectPath}" && ${command}`;
1978
+ }
1979
+ }
1980
+ } else {
1981
+ // Use claude command (default) or initialCommand if provided
1982
+
1983
+ const command = initialCommand || 'claude';
1984
+ if (os.platform() === 'win32') {
1985
+ if (hasSession && sessionId) {
1986
+ // Try to resume session, but with fallback to new session if it fails
1987
+ shellCommand = `Set-Location -Path "${projectPath}"; claude --resume ${sessionId}; if ($LASTEXITCODE -ne 0) { claude }`;
1988
+ } else {
1989
+ shellCommand = `Set-Location -Path "${projectPath}"; ${command}`;
1990
+ }
1991
+ } else {
1992
+ if (hasSession && sessionId) {
1993
+ shellCommand = `cd "${projectPath}" && claude --resume ${sessionId} || claude`;
1994
+ } else {
1995
+ shellCommand = `cd "${projectPath}" && ${command}`;
1996
+ }
1997
+ }
1998
+ }
1999
+
2000
+ console.log('🔧 Executing shell command:', shellCommand);
2001
+
2002
+ // Determine shell, args, and cwd based on command mode
2003
+ let spawnShell, spawnArgs, spawnCwd;
2004
+ if (shellCommand === null) {
2005
+ // Interactive shell mode - launch user's default shell directly
2006
+ if (os.platform() === 'win32') {
2007
+ spawnShell = 'powershell.exe';
2008
+ spawnArgs = [];
2009
+ } else {
2010
+ spawnShell = process.env.SHELL || '/bin/bash';
2011
+ spawnArgs = ['--login'];
2012
+ }
2013
+ spawnCwd = projectPath;
2014
+ } else {
2015
+ // Command mode - run via shell -c
2016
+ spawnShell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
2017
+ spawnArgs = os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand];
2018
+ spawnCwd = os.homedir();
2019
+ }
2020
+
2021
+ // Use terminal dimensions from client if provided, otherwise use defaults
2022
+ const termCols = data.cols || 80;
2023
+ const termRows = data.rows || 24;
2024
+ console.log('📐 Using terminal dimensions:', termCols, 'x', termRows);
2025
+
2026
+ shellProcess = pty.spawn(spawnShell, spawnArgs, {
2027
+ name: 'xterm-256color',
2028
+ cols: termCols,
2029
+ rows: termRows,
2030
+ cwd: spawnCwd,
2031
+ env: buildEmbeddedShellEnv(process.env)
2032
+ });
2033
+
2034
+ console.log('🟢 Shell process started with PTY, PID:', shellProcess.pid);
2035
+
2036
+ ptySessionsMap.set(ptySessionKey, {
2037
+ pty: shellProcess,
2038
+ ws: ws,
2039
+ buffer: [],
2040
+ timeoutId: null,
2041
+ projectPath,
2042
+ sessionId
2043
+ });
2044
+
2045
+ // Handle data output
2046
+ shellProcess.onData((data) => {
2047
+ const session = ptySessionsMap.get(ptySessionKey);
2048
+ if (!session) return;
2049
+
2050
+ if (session.buffer.length < 5000) {
2051
+ session.buffer.push(data);
2052
+ } else {
2053
+ session.buffer.shift();
2054
+ session.buffer.push(data);
2055
+ }
2056
+
2057
+ if (session.ws && session.ws.readyState === WebSocket.OPEN) {
2058
+ let outputData = data;
2059
+
2060
+ const cleanChunk = stripAnsiSequences(data);
2061
+ urlDetectionBuffer = `${urlDetectionBuffer}${cleanChunk}`.slice(-SHELL_URL_PARSE_BUFFER_LIMIT);
2062
+
2063
+ outputData = outputData.replace(
2064
+ /OPEN_URL:\s*(https?:\/\/[^\s\x1b\x07]+)/g,
2065
+ '[INFO] Opening in browser: $1'
2066
+ );
2067
+
2068
+ const emitAuthUrl = (detectedUrl, autoOpen = false) => {
2069
+ const normalizedUrl = normalizeDetectedUrl(detectedUrl);
2070
+ if (!normalizedUrl) return;
2071
+
2072
+ const isNewUrl = !announcedAuthUrls.has(normalizedUrl);
2073
+ if (isNewUrl) {
2074
+ announcedAuthUrls.add(normalizedUrl);
2075
+ session.ws.send(JSON.stringify({
2076
+ type: 'auth_url',
2077
+ url: normalizedUrl,
2078
+ autoOpen
2079
+ }));
2080
+ }
2081
+
2082
+ };
2083
+
2084
+ const normalizedDetectedUrls = extractUrlsFromText(urlDetectionBuffer)
2085
+ .map((url) => normalizeDetectedUrl(url))
2086
+ .filter(Boolean);
2087
+
2088
+ // Prefer the most complete URL if shorter prefix variants are also present.
2089
+ const dedupedDetectedUrls = Array.from(new Set(normalizedDetectedUrls)).filter((url, _, urls) =>
2090
+ !urls.some((otherUrl) => otherUrl !== url && otherUrl.startsWith(url))
2091
+ );
2092
+
2093
+ dedupedDetectedUrls.forEach((url) => emitAuthUrl(url, false));
2094
+
2095
+ if (shouldAutoOpenUrlFromOutput(cleanChunk) && dedupedDetectedUrls.length > 0) {
2096
+ const bestUrl = dedupedDetectedUrls.reduce((longest, current) =>
2097
+ current.length > longest.length ? current : longest
2098
+ );
2099
+ emitAuthUrl(bestUrl, true);
2100
+ }
2101
+
2102
+ // Send regular output
2103
+ session.ws.send(JSON.stringify({
2104
+ type: 'output',
2105
+ data: outputData
2106
+ }));
2107
+ }
2108
+ });
2109
+
2110
+ // Handle process exit
2111
+ shellProcess.onExit((exitCode) => {
2112
+ console.log('🔚 Shell process exited with code:', exitCode.exitCode, 'signal:', exitCode.signal);
2113
+ const session = ptySessionsMap.get(ptySessionKey);
2114
+ if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {
2115
+ session.ws.send(JSON.stringify({
2116
+ type: 'output',
2117
+ data: `\r\n\x1b[33mProcess exited with code ${exitCode.exitCode}${exitCode.signal ? ` (${exitCode.signal})` : ''}\x1b[0m\r\n`
2118
+ }));
2119
+ }
2120
+ if (session && session.timeoutId) {
2121
+ clearTimeout(session.timeoutId);
2122
+ }
2123
+ ptySessionsMap.delete(ptySessionKey);
2124
+ shellProcess = null;
2125
+ });
2126
+
2127
+ } catch (spawnError) {
2128
+ console.error('[ERROR] Error spawning process:', spawnError);
2129
+ ws.send(JSON.stringify({
2130
+ type: 'output',
2131
+ data: `\r\n\x1b[31mError: ${spawnError.message}\x1b[0m\r\n`
2132
+ }));
2133
+ }
2134
+
2135
+ } else if (data.type === 'input') {
2136
+ // Send input to shell process
2137
+ if (shellProcess && shellProcess.write) {
2138
+ try {
2139
+ shellProcess.write(data.data);
2140
+ } catch (error) {
2141
+ console.error('Error writing to shell:', error);
2142
+ }
2143
+ } else {
2144
+ console.warn('No active shell process to send input to');
2145
+ }
2146
+ } else if (data.type === 'resize') {
2147
+ // Handle terminal resize
2148
+ if (shellProcess && shellProcess.resize) {
2149
+ console.log('Terminal resize requested:', data.cols, 'x', data.rows);
2150
+ shellProcess.resize(data.cols, data.rows);
2151
+ }
2152
+ }
2153
+ } catch (error) {
2154
+ console.error('[ERROR] Shell WebSocket error:', error.message);
2155
+ if (ws.readyState === WebSocket.OPEN) {
2156
+ ws.send(JSON.stringify({
2157
+ type: 'output',
2158
+ data: `\r\n\x1b[31mError: ${error.message}\x1b[0m\r\n`
2159
+ }));
2160
+ }
2161
+ }
2162
+ });
2163
+
2164
+ ws.on('close', () => {
2165
+ console.log('🔌 Shell client disconnected');
2166
+
2167
+ if (ptySessionKey) {
2168
+ const session = ptySessionsMap.get(ptySessionKey);
2169
+ if (session) {
2170
+ console.log('⏳ PTY session kept alive, will timeout in 30 minutes:', ptySessionKey);
2171
+ session.ws = null;
2172
+
2173
+ session.timeoutId = setTimeout(() => {
2174
+ console.log('⏰ PTY session timeout, killing process:', ptySessionKey);
2175
+ if (session.pty && session.pty.kill) {
2176
+ session.pty.kill();
2177
+ }
2178
+ ptySessionsMap.delete(ptySessionKey);
2179
+ }, PTY_SESSION_TIMEOUT);
2180
+ }
2181
+ }
2182
+ });
2183
+
2184
+ ws.on('error', (error) => {
2185
+ console.error('[ERROR] Shell WebSocket error:', error);
2186
+ });
2187
+ }
2188
+
2189
+ function handleComputeShellConnection(ws, urlNodeId) {
2190
+ console.log('🖥️ Compute shell client connected');
2191
+ let shellProcess = null;
2192
+ let ptySessionKey = null;
2193
+
2194
+ const getComputeNodeConfig = async (nodeId) => {
2195
+ const { loadNodeConfig, getActiveNode } = await import('./compute-node.js');
2196
+ const effectiveNodeId = nodeId || urlNodeId;
2197
+ if (effectiveNodeId) {
2198
+ return await loadNodeConfig(effectiveNodeId);
2199
+ }
2200
+ return await getActiveNode();
2201
+ };
2202
+
2203
+ ws.on('message', async (message) => {
2204
+ try {
2205
+ const data = JSON.parse(message);
2206
+ console.log('📨 Compute shell message received:', data.type);
2207
+
2208
+ if (data.type === 'init') {
2209
+ const requestedNodeId = data.nodeId;
2210
+ let config;
2211
+ try {
2212
+ config = await getComputeNodeConfig(requestedNodeId);
2213
+ } catch (e) {
2214
+ ws.send(JSON.stringify({
2215
+ type: 'output',
2216
+ data: '\x1b[31mCompute node not configured. Please save configuration first.\x1b[0m\r\n'
2217
+ }));
2218
+ return;
2219
+ }
2220
+
2221
+ if (!config || !config.host || !config.user) {
2222
+ ws.send(JSON.stringify({
2223
+ type: 'output',
2224
+ data: '\x1b[31mCompute node configuration incomplete (missing host/user).\x1b[0m\r\n'
2225
+ }));
2226
+ return;
2227
+ }
2228
+
2229
+ const termCols = data.cols || 80;
2230
+ const termRows = data.rows || 24;
2231
+
2232
+ ptySessionKey = `compute_${config.id || 'default'}_${config.host}_${config.user}`;
2233
+
2234
+ const existingSession = ptySessionsMap.get(ptySessionKey);
2235
+ if (existingSession) {
2236
+ console.log('♻️ Reconnecting to existing compute PTY session:', ptySessionKey);
2237
+ shellProcess = existingSession.pty;
2238
+ clearTimeout(existingSession.timeoutId);
2239
+
2240
+ ws.send(JSON.stringify({
2241
+ type: 'output',
2242
+ data: '\x1b[36m[Reconnected to compute node]\x1b[0m\r\n'
2243
+ }));
2244
+
2245
+ if (existingSession.buffer && existingSession.buffer.length > 0) {
2246
+ existingSession.buffer.forEach(bufferedData => {
2247
+ ws.send(JSON.stringify({ type: 'output', data: bufferedData }));
2248
+ });
2249
+ }
2250
+
2251
+ existingSession.ws = ws;
2252
+ return;
2253
+ }
2254
+
2255
+ const spawnCmd = 'ssh';
2256
+ const sshArgs = ['-o', 'StrictHostKeyChecking=no', '-tt'];
2257
+
2258
+ const sshPort = config.port || 22;
2259
+ if (sshPort !== 22) {
2260
+ sshArgs.push('-p', String(sshPort));
2261
+ }
2262
+
2263
+ if (config.keyPath) {
2264
+ sshArgs.push('-i', config.keyPath);
2265
+ }
2266
+
2267
+ sshArgs.push(`${config.user}@${config.host}`);
2268
+
2269
+ if (config.workDir && config.workDir !== '~') {
2270
+ sshArgs.push(`cd ${config.workDir} && exec $SHELL -l`);
2271
+ }
2272
+
2273
+ const usePasswordAuth = config.password && !config.keyPath;
2274
+
2275
+ ws.send(JSON.stringify({
2276
+ type: 'output',
2277
+ data: `\x1b[36mConnecting to ${config.user}@${config.host}${config.workDir && config.workDir !== '~' ? ` (workDir: ${config.workDir})` : ''}...\x1b[0m\r\n`
2278
+ }));
2279
+
2280
+ try {
2281
+ shellProcess = pty.spawn(spawnCmd, sshArgs, {
2282
+ name: 'xterm-256color',
2283
+ cols: termCols,
2284
+ rows: termRows,
2285
+ cwd: os.homedir(),
2286
+ env: {
2287
+ ...process.env,
2288
+ TERM: 'xterm-256color',
2289
+ COLORTERM: 'truecolor',
2290
+ FORCE_COLOR: '3'
2291
+ }
2292
+ });
2293
+
2294
+ console.log('🟢 Compute shell process started, PID:', shellProcess.pid);
2295
+
2296
+ let passwordAutoFilled = false;
2297
+ let earlyOutput = '';
2298
+
2299
+ ptySessionsMap.set(ptySessionKey, {
2300
+ pty: shellProcess,
2301
+ ws: ws,
2302
+ buffer: [],
2303
+ timeoutId: null,
2304
+ host: config.host
2305
+ });
2306
+
2307
+ shellProcess.onData((outputData) => {
2308
+ const session = ptySessionsMap.get(ptySessionKey);
2309
+ if (!session) return;
2310
+
2311
+ if (usePasswordAuth && !passwordAutoFilled) {
2312
+ earlyOutput += outputData;
2313
+ const cleanText = earlyOutput.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '');
2314
+ if (/[Pp]assword[:\s]*$/.test(cleanText)) {
2315
+ passwordAutoFilled = true;
2316
+ shellProcess.write(config.password + '\n');
2317
+ return;
2318
+ }
2319
+ }
2320
+
2321
+ if (session.buffer.length < 5000) {
2322
+ session.buffer.push(outputData);
2323
+ } else {
2324
+ session.buffer.shift();
2325
+ session.buffer.push(outputData);
2326
+ }
2327
+
2328
+ if (session.ws && session.ws.readyState === WebSocket.OPEN) {
2329
+ session.ws.send(JSON.stringify({
2330
+ type: 'output',
2331
+ data: outputData
2332
+ }));
2333
+ }
2334
+ });
2335
+
2336
+ shellProcess.onExit((exitCode) => {
2337
+ console.log('🔚 Compute shell exited with code:', exitCode.exitCode);
2338
+ const session = ptySessionsMap.get(ptySessionKey);
2339
+ if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {
2340
+ session.ws.send(JSON.stringify({
2341
+ type: 'output',
2342
+ data: `\r\n\x1b[33mSSH session ended (code ${exitCode.exitCode})\x1b[0m\r\n`
2343
+ }));
2344
+ }
2345
+ if (session && session.timeoutId) {
2346
+ clearTimeout(session.timeoutId);
2347
+ }
2348
+ ptySessionsMap.delete(ptySessionKey);
2349
+ shellProcess = null;
2350
+ });
2351
+
2352
+ } catch (spawnError) {
2353
+ console.error('[ERROR] Error spawning SSH process:', spawnError);
2354
+ ws.send(JSON.stringify({
2355
+ type: 'output',
2356
+ data: `\r\n\x1b[31mError: ${spawnError.message}\x1b[0m\r\n`
2357
+ }));
2358
+ }
2359
+
2360
+ } else if (data.type === 'input') {
2361
+ if (shellProcess && shellProcess.write) {
2362
+ try {
2363
+ shellProcess.write(data.data);
2364
+ } catch (error) {
2365
+ console.error('Error writing to compute shell:', error);
2366
+ }
2367
+ }
2368
+ } else if (data.type === 'resize') {
2369
+ if (shellProcess && shellProcess.resize) {
2370
+ shellProcess.resize(data.cols, data.rows);
2371
+ }
2372
+ }
2373
+ } catch (error) {
2374
+ console.error('[ERROR] Compute shell WebSocket error:', error.message);
2375
+ if (ws.readyState === WebSocket.OPEN) {
2376
+ ws.send(JSON.stringify({
2377
+ type: 'output',
2378
+ data: `\r\n\x1b[31mError: ${error.message}\x1b[0m\r\n`
2379
+ }));
2380
+ }
2381
+ }
2382
+ });
2383
+
2384
+ ws.on('close', () => {
2385
+ console.log('🔌 Compute shell client disconnected');
2386
+ if (ptySessionKey) {
2387
+ const session = ptySessionsMap.get(ptySessionKey);
2388
+ if (session) {
2389
+ console.log('⏳ Compute PTY session kept alive for 30 minutes:', ptySessionKey);
2390
+ session.ws = null;
2391
+ session.timeoutId = setTimeout(() => {
2392
+ console.log('⏰ Compute PTY session timeout, killing:', ptySessionKey);
2393
+ if (session.pty && session.pty.kill) {
2394
+ session.pty.kill();
2395
+ }
2396
+ ptySessionsMap.delete(ptySessionKey);
2397
+ }, PTY_SESSION_TIMEOUT);
2398
+ }
2399
+ }
2400
+ });
2401
+
2402
+ ws.on('error', (error) => {
2403
+ console.error('[ERROR] Compute shell WebSocket error:', error);
2404
+ });
2405
+ }
2406
+
2407
+ // Audio transcription endpoint
2408
+ app.post('/api/transcribe', authenticateToken, async (req, res) => {
2409
+ try {
2410
+ const multer = (await import('multer')).default;
2411
+ const upload = multer({ storage: multer.memoryStorage() });
2412
+
2413
+ // Handle multipart form data
2414
+ upload.single('audio')(req, res, async (err) => {
2415
+ if (err) {
2416
+ return res.status(400).json({ error: 'Failed to process audio file' });
2417
+ }
2418
+
2419
+ if (!req.file) {
2420
+ return res.status(400).json({ error: 'No audio file provided' });
2421
+ }
2422
+
2423
+ const apiKey = process.env.OPENAI_API_KEY;
2424
+ if (!apiKey) {
2425
+ return res.status(500).json({ error: 'OpenAI API key not configured. Please set OPENAI_API_KEY in server environment.' });
2426
+ }
2427
+
2428
+ try {
2429
+ // Create form data for OpenAI
2430
+ const FormData = (await import('form-data')).default;
2431
+ const formData = new FormData();
2432
+ formData.append('file', req.file.buffer, {
2433
+ filename: req.file.originalname,
2434
+ contentType: req.file.mimetype
2435
+ });
2436
+ formData.append('model', 'whisper-1');
2437
+ formData.append('response_format', 'json');
2438
+ formData.append('language', 'en');
2439
+
2440
+ // Make request to OpenAI
2441
+ const response = await fetch('https://api.openai.com/v1/audio/transcriptions', {
2442
+ method: 'POST',
2443
+ headers: {
2444
+ 'Authorization': `Bearer ${apiKey}`,
2445
+ ...formData.getHeaders()
2446
+ },
2447
+ body: formData
2448
+ });
2449
+
2450
+ if (!response.ok) {
2451
+ const errorData = await response.json().catch(() => ({}));
2452
+ throw new Error(errorData.error?.message || `Whisper API error: ${response.status}`);
2453
+ }
2454
+
2455
+ const data = await response.json();
2456
+ let transcribedText = data.text || '';
2457
+
2458
+ // Check if enhancement mode is enabled
2459
+ const mode = req.body.mode || 'default';
2460
+
2461
+ // If no transcribed text, return empty
2462
+ if (!transcribedText) {
2463
+ return res.json({ text: '' });
2464
+ }
2465
+
2466
+ // If default mode, return transcribed text without enhancement
2467
+ if (mode === 'default') {
2468
+ return res.json({ text: transcribedText });
2469
+ }
2470
+
2471
+ // Handle different enhancement modes
2472
+ try {
2473
+ const OpenAI = (await import('openai')).default;
2474
+ const openai = new OpenAI({ apiKey });
2475
+
2476
+ let prompt, systemMessage, temperature = 0.7, maxTokens = 800;
2477
+
2478
+ switch (mode) {
2479
+ case 'prompt':
2480
+ systemMessage = 'You are an expert prompt engineer who creates clear, detailed, and effective prompts.';
2481
+ prompt = `You are an expert prompt engineer. Transform the following rough instruction into a clear, detailed, and context-aware AI prompt.
2482
+
2483
+ Your enhanced prompt should:
2484
+ 1. Be specific and unambiguous
2485
+ 2. Include relevant context and constraints
2486
+ 3. Specify the desired output format
2487
+ 4. Use clear, actionable language
2488
+ 5. Include examples where helpful
2489
+ 6. Consider edge cases and potential ambiguities
2490
+
2491
+ Transform this rough instruction into a well-crafted prompt:
2492
+ "${transcribedText}"
2493
+
2494
+ Enhanced prompt:`;
2495
+ break;
2496
+
2497
+ case 'vibe':
2498
+ case 'instructions':
2499
+ case 'architect':
2500
+ systemMessage = 'You are a helpful assistant that formats ideas into clear, actionable instructions for AI agents.';
2501
+ temperature = 0.5; // Lower temperature for more controlled output
2502
+ prompt = `Transform the following idea into clear, well-structured instructions that an AI agent can easily understand and execute.
2503
+
2504
+ IMPORTANT RULES:
2505
+ - Format as clear, step-by-step instructions
2506
+ - Add reasonable implementation details based on common patterns
2507
+ - Only include details directly related to what was asked
2508
+ - Do NOT add features or functionality not mentioned
2509
+ - Keep the original intent and scope intact
2510
+ - Use clear, actionable language an agent can follow
2511
+
2512
+ Transform this idea into agent-friendly instructions:
2513
+ "${transcribedText}"
2514
+
2515
+ Agent instructions:`;
2516
+ break;
2517
+
2518
+ default:
2519
+ // No enhancement needed
2520
+ break;
2521
+ }
2522
+
2523
+ // Only make GPT call if we have a prompt
2524
+ if (prompt) {
2525
+ const completion = await openai.chat.completions.create({
2526
+ model: 'gpt-4o-mini',
2527
+ messages: [
2528
+ { role: 'system', content: systemMessage },
2529
+ { role: 'user', content: prompt }
2530
+ ],
2531
+ temperature: temperature,
2532
+ max_tokens: maxTokens
2533
+ });
2534
+
2535
+ transcribedText = completion.choices[0].message.content || transcribedText;
2536
+ }
2537
+
2538
+ } catch (gptError) {
2539
+ console.error('GPT processing error:', gptError);
2540
+ // Fall back to original transcription if GPT fails
2541
+ }
2542
+
2543
+ res.json({ text: transcribedText });
2544
+
2545
+ } catch (error) {
2546
+ console.error('Transcription error:', error);
2547
+ res.status(500).json({ error: error.message });
2548
+ }
2549
+ });
2550
+ } catch (error) {
2551
+ console.error('Endpoint error:', error);
2552
+ res.status(500).json({ error: 'Internal server error' });
2553
+ }
2554
+ });
2555
+
2556
+ // Image upload endpoint
2557
+ app.post('/api/projects/:projectName/upload-images', authenticateToken, async (req, res) => {
2558
+ try {
2559
+ const multer = (await import('multer')).default;
2560
+ const path = (await import('path')).default;
2561
+ const fs = (await import('fs')).promises;
2562
+ const os = (await import('os')).default;
2563
+
2564
+ // Configure multer for image uploads
2565
+ const storage = multer.diskStorage({
2566
+ destination: async (req, file, cb) => {
2567
+ const uploadDir = path.join(os.tmpdir(), 'claude-ui-uploads', String(req.user.id));
2568
+ await fs.mkdir(uploadDir, { recursive: true });
2569
+ cb(null, uploadDir);
2570
+ },
2571
+ filename: (req, file, cb) => {
2572
+ const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
2573
+ const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_');
2574
+ cb(null, uniqueSuffix + '-' + sanitizedName);
2575
+ }
2576
+ });
2577
+
2578
+ const upload = multer({
2579
+ storage,
2580
+ limits: {
2581
+ fileSize: 10 * 1024 * 1024, // 10MB
2582
+ files: 5
2583
+ }
2584
+ });
2585
+
2586
+ // Handle multipart form data
2587
+ upload.array('images', 5)(req, res, async (err) => {
2588
+ if (err) {
2589
+ return res.status(400).json({ error: err.message });
2590
+ }
2591
+
2592
+ if (!req.files || req.files.length === 0) {
2593
+ return res.status(400).json({ error: 'No files provided' });
2594
+ }
2595
+
2596
+ try {
2597
+ // Process uploaded images
2598
+ const processedImages = await Promise.all(
2599
+ req.files.map(async (file) => {
2600
+ // Read file and convert to base64
2601
+ const buffer = await fs.readFile(file.path);
2602
+ const base64 = buffer.toString('base64');
2603
+ const mimeType = file.mimetype;
2604
+
2605
+ // Clean up temp file immediately
2606
+ await fs.unlink(file.path);
2607
+
2608
+ return {
2609
+ name: file.originalname,
2610
+ data: `data:${mimeType};base64,${base64}`,
2611
+ size: file.size,
2612
+ mimeType: mimeType
2613
+ };
2614
+ })
2615
+ );
2616
+
2617
+ res.json({ images: processedImages });
2618
+ } catch (error) {
2619
+ console.error('Error processing images:', error);
2620
+ // Clean up any remaining files
2621
+ await Promise.all(req.files.map(f => fs.unlink(f.path).catch(() => { })));
2622
+ res.status(500).json({ error: 'Failed to process images' });
2623
+ }
2624
+ });
2625
+ } catch (error) {
2626
+ console.error('Error in image upload endpoint:', error);
2627
+ res.status(500).json({ error: 'Internal server error' });
2628
+ }
2629
+ });
2630
+
2631
+ // Get token usage for a specific session
2632
+ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => {
2633
+ try {
2634
+ const { projectName, sessionId } = req.params;
2635
+ const { provider = 'claude' } = req.query;
2636
+ const homeDir = os.homedir();
2637
+
2638
+ // Allow only safe characters in sessionId
2639
+ const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
2640
+ if (!safeSessionId) {
2641
+ return res.status(400).json({ error: 'Invalid sessionId' });
2642
+ }
2643
+
2644
+ // Handle Cursor sessions - they use SQLite and don't have token usage info
2645
+ if (provider === 'cursor') {
2646
+ return res.json({
2647
+ used: 0,
2648
+ total: 0,
2649
+ breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
2650
+ unsupported: true,
2651
+ message: 'Token usage tracking not available for Cursor sessions'
2652
+ });
2653
+ }
2654
+
2655
+ // Handle Codex sessions
2656
+ if (provider === 'codex') {
2657
+ const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
2658
+
2659
+ // Find the session file by searching for the session ID
2660
+ const findSessionFile = async (dir) => {
2661
+ try {
2662
+ const entries = await fsPromises.readdir(dir, { withFileTypes: true });
2663
+ for (const entry of entries) {
2664
+ const fullPath = path.join(dir, entry.name);
2665
+ if (entry.isDirectory()) {
2666
+ const found = await findSessionFile(fullPath);
2667
+ if (found) return found;
2668
+ } else if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) {
2669
+ return fullPath;
2670
+ }
2671
+ }
2672
+ } catch (error) {
2673
+ // Skip directories we can't read
2674
+ }
2675
+ return null;
2676
+ };
2677
+
2678
+ const sessionFilePath = await findSessionFile(codexSessionsDir);
2679
+
2680
+ if (!sessionFilePath) {
2681
+ return res.status(404).json({ error: 'Codex session file not found', sessionId: safeSessionId });
2682
+ }
2683
+
2684
+ // Read and parse the Codex JSONL file
2685
+ let fileContent;
2686
+ try {
2687
+ fileContent = await fsPromises.readFile(sessionFilePath, 'utf8');
2688
+ } catch (error) {
2689
+ if (error.code === 'ENOENT') {
2690
+ return res.status(404).json({ error: 'Session file not found', path: sessionFilePath });
2691
+ }
2692
+ throw error;
2693
+ }
2694
+ return res.json(buildCodexTokenUsageFromJsonl(fileContent));
2695
+ }
2696
+
2697
+ // Handle Gemini sessions
2698
+ if (provider === 'gemini') {
2699
+ const geminiSessionPath = path.join(homeDir, '.gemini', 'sessions', `${safeSessionId}.jsonl`);
2700
+ let fileContent;
2701
+ try {
2702
+ fileContent = await fsPromises.readFile(geminiSessionPath, 'utf8');
2703
+ } catch (error) {
2704
+ if (error.code === 'ENOENT') {
2705
+ return res.status(404).json({ error: 'Session file not found', path: geminiSessionPath });
2706
+ }
2707
+ throw error;
2708
+ }
2709
+
2710
+ const lines = fileContent.trim().split('\n');
2711
+ let latestStats = null;
2712
+ for (let i = lines.length - 1; i >= 0; i--) {
2713
+ try {
2714
+ const entry = JSON.parse(lines[i]);
2715
+ if (entry.type === 'status' && entry.stats) {
2716
+ latestStats = entry.stats;
2717
+ break;
2718
+ }
2719
+ } catch {
2720
+ // Ignore malformed line
2721
+ }
2722
+ }
2723
+
2724
+ const used = latestStats
2725
+ ? (latestStats.total_tokens || ((latestStats.input_tokens || 0) + (latestStats.output_tokens || 0)))
2726
+ : 0;
2727
+ const total = parseInt(process.env.GEMINI_CONTEXT_WINDOW || process.env.CONTEXT_WINDOW || '2000000', 10);
2728
+ const cacheCreation = latestStats?.cache_creation_input_tokens || 0;
2729
+ const cacheRead = latestStats?.cache_read_input_tokens || 0;
2730
+ const input = latestStats?.input_tokens || 0;
2731
+ const output = latestStats?.output_tokens || 0;
2732
+
2733
+ return res.json({
2734
+ used,
2735
+ total,
2736
+ breakdown: {
2737
+ input,
2738
+ output,
2739
+ cacheCreation,
2740
+ cacheRead
2741
+ }
2742
+ });
2743
+ }
2744
+
2745
+ // Handle Claude sessions (default)
2746
+ // Extract actual project path
2747
+ let projectPath;
2748
+ try {
2749
+ projectPath = await extractProjectDirectory(projectName);
2750
+ } catch (error) {
2751
+ console.error('Error extracting project directory:', error);
2752
+ return res.status(500).json({ error: 'Failed to determine project path' });
2753
+ }
2754
+
2755
+ // Construct the JSONL file path
2756
+ // Claude stores session files in ~/.claude/projects/[encoded-project-path]/[session-id].jsonl
2757
+ // The encoding replaces /, spaces, ~, and _ with -
2758
+ const encodedPath = projectPath.replace(/[\\/:\s~_]/g, '-');
2759
+ const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
2760
+
2761
+ const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
2762
+
2763
+ // Constrain to projectDir
2764
+ const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath));
2765
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
2766
+ return res.status(400).json({ error: 'Invalid path' });
2767
+ }
2768
+
2769
+ // Read and parse the JSONL file
2770
+ let fileContent;
2771
+ try {
2772
+ fileContent = await fsPromises.readFile(jsonlPath, 'utf8');
2773
+ } catch (error) {
2774
+ if (error.code === 'ENOENT') {
2775
+ return res.status(404).json({ error: 'Session file not found', path: jsonlPath });
2776
+ }
2777
+ throw error; // Re-throw other errors to be caught by outer try-catch
2778
+ }
2779
+ const lines = fileContent.trim().split('\n');
2780
+
2781
+ let inputTokens = 0;
2782
+ let cacheCreationTokens = 0;
2783
+ let cacheReadTokens = 0;
2784
+ let modelName = null;
2785
+
2786
+ // Find the latest assistant message with usage data (scan from end)
2787
+ for (let i = lines.length - 1; i >= 0; i--) {
2788
+ try {
2789
+ const entry = JSON.parse(lines[i]);
2790
+
2791
+ // Only count assistant messages which have usage data
2792
+ if (entry.type === 'assistant' && entry.message?.usage) {
2793
+ const usage = entry.message.usage;
2794
+
2795
+ // Use token counts from latest assistant message only
2796
+ inputTokens = usage.input_tokens || 0;
2797
+ cacheCreationTokens = usage.cache_creation_input_tokens || 0;
2798
+ cacheReadTokens = usage.cache_read_input_tokens || 0;
2799
+ modelName = entry.message.model || null;
2800
+
2801
+ break; // Stop after finding the latest assistant message
2802
+ }
2803
+ } catch (parseError) {
2804
+ // Skip lines that can't be parsed
2805
+ continue;
2806
+ }
2807
+ }
2808
+
2809
+ // Determine context window from model name
2810
+ const MODEL_CONTEXT_WINDOWS = {
2811
+ 'claude-opus-4-6': 200000,
2812
+ 'claude-opus-4-20250918': 200000,
2813
+ 'claude-sonnet-4-6': 200000,
2814
+ 'claude-sonnet-4-20250514': 200000,
2815
+ 'claude-haiku-4-5': 200000,
2816
+ 'claude-haiku-4-5-20251001': 200000,
2817
+ 'claude-3-5-sonnet': 200000,
2818
+ 'claude-3-5-sonnet-20241022': 200000,
2819
+ 'claude-3-5-haiku': 200000,
2820
+ 'claude-3-5-haiku-20241022': 200000,
2821
+ 'claude-3-opus': 200000,
2822
+ 'claude-3-opus-20240229': 200000,
2823
+ 'claude-3-sonnet': 200000,
2824
+ 'claude-3-haiku': 200000,
2825
+ };
2826
+
2827
+ // Priority: env var override > model-based lookup > default
2828
+ const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10);
2829
+ let contextWindow;
2830
+ if (Number.isFinite(parsedContextWindow)) {
2831
+ contextWindow = parsedContextWindow;
2832
+ } else if (modelName) {
2833
+ // Try exact match first, then prefix match
2834
+ contextWindow = MODEL_CONTEXT_WINDOWS[modelName];
2835
+ if (!contextWindow) {
2836
+ const prefix = Object.keys(MODEL_CONTEXT_WINDOWS).find(k => modelName.startsWith(k));
2837
+ contextWindow = prefix ? MODEL_CONTEXT_WINDOWS[prefix] : 200000;
2838
+ }
2839
+ } else {
2840
+ contextWindow = 200000;
2841
+ }
2842
+
2843
+ // Calculate total context usage (excluding output_tokens, as per ccusage)
2844
+ const totalUsed = inputTokens + cacheCreationTokens + cacheReadTokens;
2845
+
2846
+ res.json({
2847
+ used: totalUsed,
2848
+ total: contextWindow,
2849
+ model: modelName,
2850
+ breakdown: {
2851
+ input: inputTokens,
2852
+ cacheCreation: cacheCreationTokens,
2853
+ cacheRead: cacheReadTokens
2854
+ }
2855
+ });
2856
+ } catch (error) {
2857
+ console.error('Error reading session token usage:', error);
2858
+ res.status(500).json({ error: 'Failed to read session token usage' });
2859
+ }
2860
+ });
2861
+
2862
+ // Serve React app for all other routes (excluding static files)
2863
+ app.get('*', (req, res) => {
2864
+ // Skip requests for static assets (files with extensions)
2865
+ if (path.extname(req.path)) {
2866
+ return res.status(404).send('Not found');
2867
+ }
2868
+
2869
+ // Only serve index.html for HTML routes, not for static assets
2870
+ // Static assets should already be handled by express.static middleware above
2871
+ const indexPath = path.join(__dirname, '../dist/index.html');
2872
+
2873
+ // Check if dist/index.html exists (production build available)
2874
+ if (fs.existsSync(indexPath)) {
2875
+ // Set no-cache headers for HTML to prevent service worker issues
2876
+ res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
2877
+ res.setHeader('Pragma', 'no-cache');
2878
+ res.setHeader('Expires', '0');
2879
+ res.sendFile(indexPath);
2880
+ } else {
2881
+ // In development, redirect to Vite dev server only if dist doesn't exist
2882
+ res.redirect(`http://${DISPLAY_HOST}:${getFrontendPortSync(REQUESTED_VITE_PORT)}`);
2883
+ }
2884
+ });
2885
+
2886
+ // Helper function to convert permissions to rwx format
2887
+ function permToRwx(perm) {
2888
+ const r = perm & 4 ? 'r' : '-';
2889
+ const w = perm & 2 ? 'w' : '-';
2890
+ const x = perm & 1 ? 'x' : '-';
2891
+ return r + w + x;
2892
+ }
2893
+
2894
+ async function findAllFilesInProject(projectRoot, fileName, maxDepth = 10) {
2895
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build']);
2896
+ const results = [];
2897
+ const queue = [[projectRoot, 0]];
2898
+
2899
+ while (queue.length > 0) {
2900
+ const [dirPath, depth] = queue.shift();
2901
+ let entries;
2902
+ try {
2903
+ entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
2904
+ } catch { continue; }
2905
+
2906
+ for (const entry of entries) {
2907
+ const entryPath = path.join(dirPath, entry.name);
2908
+ let isDir = entry.isDirectory();
2909
+
2910
+ // Resolve symlinks (e.g. .claude/skills/* are symlinked directories)
2911
+ if (!isDir && entry.isSymbolicLink()) {
2912
+ try {
2913
+ const stats = await fsPromises.stat(entryPath);
2914
+ isDir = stats.isDirectory();
2915
+ } catch { continue; }
2916
+ }
2917
+
2918
+ if (entry.name === fileName && !isDir) {
2919
+ results.push(entryPath);
2920
+ if (results.length >= 20) return results;
2921
+ }
2922
+ if (isDir && !SKIP_DIRS.has(entry.name)
2923
+ && depth < maxDepth) {
2924
+ queue.push([entryPath, depth + 1]);
2925
+ }
2926
+ }
2927
+ }
2928
+ return results;
2929
+ }
2930
+
2931
+ async function resolveProjectFilePath(projectRoot, inputPath) {
2932
+ if (!inputPath || typeof inputPath !== 'string') return { resolved: path.resolve(projectRoot, '') };
2933
+ if (path.isAbsolute(inputPath)) return { resolved: path.resolve(inputPath) };
2934
+
2935
+ const direct = path.resolve(projectRoot, inputPath);
2936
+ const isSimpleName = !inputPath.includes('/') && !inputPath.includes('\\');
2937
+
2938
+ // For paths with separators (e.g. "src/main.tsx"), check direct first, then search
2939
+ if (!isSimpleName) {
2940
+ try {
2941
+ await fsPromises.access(direct);
2942
+ return { resolved: direct };
2943
+ } catch { /* not found at direct path */ }
2944
+
2945
+ // Search for the filename, then filter matches ending with the partial path
2946
+ const fileName = path.basename(inputPath);
2947
+ const normalizedInput = inputPath.split(path.sep).join('/');
2948
+ const matches = await findAllFilesInProject(projectRoot, fileName);
2949
+ const filtered = matches.filter(m => {
2950
+ const rel = path.relative(projectRoot, m).split(path.sep).join('/');
2951
+ return rel === normalizedInput || rel.endsWith('/' + normalizedInput);
2952
+ });
2953
+
2954
+ if (filtered.length === 1) {
2955
+ return { resolved: filtered[0] };
2956
+ }
2957
+ if (filtered.length > 1) {
2958
+ return {
2959
+ resolved: null,
2960
+ candidates: filtered.map(m => path.relative(projectRoot, m))
2961
+ };
2962
+ }
2963
+
2964
+ return { resolved: direct };
2965
+ }
2966
+
2967
+ // 1. Hardcoded pipeline fallbacks
2968
+ const fallbackMap = {
2969
+ 'research_brief.json': '.pipeline/docs/research_brief.json',
2970
+ 'tasks.json': '.pipeline/tasks/tasks.json',
2971
+ 'pipeline_config.json': '.pipeline/config.json'
2972
+ };
2973
+ const mapped = fallbackMap[inputPath];
2974
+ if (mapped) return { resolved: path.resolve(projectRoot, mapped) };
2975
+
2976
+ // 2. If the file exists at project root, use it
2977
+ try {
2978
+ await fsPromises.access(direct);
2979
+ return { resolved: direct };
2980
+ } catch { /* not at root */ }
2981
+
2982
+ // 3. Search project tree
2983
+ const matches = await findAllFilesInProject(projectRoot, inputPath);
2984
+ if (matches.length === 1) {
2985
+ return { resolved: matches[0] };
2986
+ }
2987
+ if (matches.length > 1) {
2988
+ return {
2989
+ resolved: null,
2990
+ candidates: matches.map(m => path.relative(projectRoot, m))
2991
+ };
2992
+ }
2993
+
2994
+ // 4. No match — fall back to direct path (will 404)
2995
+ return { resolved: direct };
2996
+ }
2997
+
2998
+ async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden = true, isBrowsing = false) {
2999
+ // Using fsPromises from import
3000
+ const items = [];
3001
+
3002
+ try {
3003
+ const entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
3004
+
3005
+ for (const entry of entries) {
3006
+ // Debug: log all entries including hidden files
3007
+ if (!showHidden && entry.name.startsWith('.')) continue;
3008
+
3009
+ // Skip heavy build directories and VCS directories unless we are browsing
3010
+ if (!isBrowsing && (
3011
+ entry.name === 'node_modules' ||
3012
+ entry.name === 'dist' ||
3013
+ entry.name === 'build' ||
3014
+ entry.name === '.git' ||
3015
+ entry.name === '.svn' ||
3016
+ entry.name === '.hg'
3017
+ )) continue;
3018
+
3019
+ const itemPath = path.join(dirPath, entry.name);
3020
+ let isDirectory = entry.isDirectory();
3021
+
3022
+ // Resolve symlinked directories (e.g. .claude/skills/*) as directories.
3023
+ if (!isDirectory && entry.isSymbolicLink()) {
3024
+ try {
3025
+ const targetStats = await fsPromises.stat(itemPath);
3026
+ isDirectory = targetStats.isDirectory();
3027
+ } catch (_) {
3028
+ // Broken symlink or inaccessible target; keep as file.
3029
+ }
3030
+ }
3031
+
3032
+ const item = {
3033
+ name: entry.name,
3034
+ path: itemPath,
3035
+ type: isDirectory ? 'directory' : 'file'
3036
+ };
3037
+
3038
+ // Get file stats for additional metadata
3039
+ try {
3040
+ const stats = await fsPromises.stat(itemPath);
3041
+ item.size = stats.size;
3042
+ item.modified = stats.mtime.toISOString();
3043
+
3044
+ // Convert permissions to rwx format
3045
+ const mode = stats.mode;
3046
+ const ownerPerm = (mode >> 6) & 7;
3047
+ const groupPerm = (mode >> 3) & 7;
3048
+ const otherPerm = mode & 7;
3049
+ item.permissions = ((mode >> 6) & 7).toString() + ((mode >> 3) & 7).toString() + (mode & 7).toString();
3050
+ item.permissionsRwx = permToRwx(ownerPerm) + permToRwx(groupPerm) + permToRwx(otherPerm);
3051
+ } catch (statError) {
3052
+ // If stat fails, provide default values
3053
+ item.size = 0;
3054
+ item.modified = null;
3055
+ item.permissions = '000';
3056
+ item.permissionsRwx = '---------';
3057
+ }
3058
+
3059
+ if (isDirectory && currentDepth < maxDepth) {
3060
+ // Recursively get subdirectories but limit depth
3061
+ try {
3062
+ // Check if we can access the directory before trying to read it
3063
+ await fsPromises.access(item.path, fs.constants.R_OK);
3064
+ item.children = await getFileTree(item.path, maxDepth, currentDepth + 1, showHidden);
3065
+ } catch (e) {
3066
+ // Silently skip directories we can't access (permission denied, etc.)
3067
+ item.children = [];
3068
+ }
3069
+ }
3070
+
3071
+ items.push(item);
3072
+ }
3073
+ } catch (error) {
3074
+ // Only log non-permission errors to avoid spam
3075
+ if (error.code !== 'EACCES' && error.code !== 'EPERM') {
3076
+ console.error('Error reading directory:', error);
3077
+ }
3078
+ }
3079
+
3080
+ return items.sort((a, b) => {
3081
+ if (a.type !== b.type) {
3082
+ return a.type === 'directory' ? -1 : 1;
3083
+ }
3084
+ return a.name.localeCompare(b.name);
3085
+ });
3086
+ }
3087
+
3088
+ const REQUESTED_PORT = parsePortNumber(process.env.PORT, DEFAULT_BACKEND_PORT);
3089
+ const REQUESTED_VITE_PORT = parsePortNumber(process.env.VITE_PORT, DEFAULT_FRONTEND_PORT);
3090
+ const HOST = process.env.HOST || '0.0.0.0';
3091
+ // Show localhost when binding to all interfaces; 0.0.0.0 is not directly connectable.
3092
+ const DISPLAY_HOST = HOST === '0.0.0.0' ? 'localhost' : HOST;
3093
+
3094
+ // Initialize database and start server
3095
+ async function startServer() {
3096
+ try {
3097
+ // Initialize authentication database
3098
+ await initializeDatabase();
3099
+
3100
+ // Check if running in production mode (dist folder exists)
3101
+ const distIndexPath = path.join(__dirname, '../dist/index.html');
3102
+ const isProduction = fs.existsSync(distIndexPath);
3103
+
3104
+ // Log Claude implementation mode
3105
+ console.log(`${c.info('[INFO]')} Using Claude Agents SDK for Claude integration`);
3106
+ console.log(`${c.info('[INFO]')} Running in ${c.bright(isProduction ? 'PRODUCTION' : 'DEVELOPMENT')} mode`);
3107
+
3108
+ if (!isProduction) {
3109
+ console.log(`${c.warn('[WARN]')} Note: Requests will be proxied to Vite dev server at ${c.dim('http://' + DISPLAY_HOST + ':' + getFrontendPortSync(REQUESTED_VITE_PORT))}`);
3110
+ }
3111
+
3112
+ const activePort = await listenOnAvailablePort(server, {
3113
+ startPort: REQUESTED_PORT,
3114
+ host: HOST,
3115
+ });
3116
+ setRuntimePortSync('backend', activePort);
3117
+
3118
+ const appInstallPath = path.join(__dirname, '..');
3119
+ const vitePort = getFrontendPortSync(REQUESTED_VITE_PORT);
3120
+
3121
+ if (activePort !== REQUESTED_PORT) {
3122
+ console.log(`${c.warn('[WARN]')} Port ${REQUESTED_PORT} is busy, switched backend to ${activePort}`);
3123
+ }
3124
+
3125
+ console.log('');
3126
+ console.log(c.dim('═'.repeat(63)));
3127
+ console.log(` ${c.bright('Dr. Claw Server - Ready')}`);
3128
+ console.log(c.dim('═'.repeat(63)));
3129
+ console.log('');
3130
+
3131
+ if (isProduction) {
3132
+ console.log(`${c.info('[INFO]')} Server URL: ${c.bright('http://' + DISPLAY_HOST + ':' + activePort)}`);
3133
+ } else {
3134
+ console.log(`${c.info('[INFO]')} Backend URL: ${c.dim('http://' + DISPLAY_HOST + ':' + activePort)}`);
3135
+ console.log(`${c.ok('[OK]')} Frontend URL: ${c.bright('http://' + DISPLAY_HOST + ':' + vitePort)} (Use this for development)`);
3136
+ }
3137
+
3138
+ console.log(`${c.info('[INFO]')} Installed at: ${c.dim(appInstallPath)}`);
3139
+ console.log(`${c.tip('[TIP]')} Run "dr-claw status" for full configuration details`);
3140
+ console.log('');
3141
+
3142
+ // Ensure the workspaces root directory exists
3143
+ const startupWorkspaceRoot = await getWorkspacesRoot();
3144
+ await fsPromises.mkdir(startupWorkspaceRoot, { recursive: true });
3145
+
3146
+ // Start watching the projects folder for changes
3147
+ await setupProjectsWatcher();
3148
+ } catch (error) {
3149
+ console.error('[ERROR] Failed to start server:', error);
3150
+ process.exit(1);
3151
+ }
3152
+ }
3153
+
3154
+ startServer();