upfynai-code 2.9.1 → 2.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (229) hide show
  1. package/README.md +91 -66
  2. package/client/dist/api-docs.html +838 -0
  3. package/client/dist/assets/AppContent-BXZDeSIC.js +545 -0
  4. package/client/dist/assets/CanvasFullScreen-mnpCnLZ9.js +1 -0
  5. package/client/dist/assets/CanvasWorkspace-4CqmjAVQ.js +163 -0
  6. package/client/dist/assets/DashboardPanel-zFIFlw56.js +1 -0
  7. package/client/dist/assets/FileTree-B0c_GaB3.js +1 -0
  8. package/client/dist/assets/GitPanel-DUP4zVU4.js +2 -0
  9. package/client/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  10. package/client/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  11. package/client/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  12. package/client/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  13. package/client/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  14. package/client/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  15. package/client/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  16. package/client/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  17. package/client/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  18. package/client/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  19. package/client/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  20. package/client/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  21. package/client/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  22. package/client/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  23. package/client/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  24. package/client/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  25. package/client/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  26. package/client/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  27. package/client/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  28. package/client/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  29. package/client/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  30. package/client/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  31. package/client/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  32. package/client/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  33. package/client/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  34. package/client/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  35. package/client/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  36. package/client/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  37. package/client/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  38. package/client/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  39. package/client/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  40. package/client/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  41. package/client/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  42. package/client/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  43. package/client/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  44. package/client/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  45. package/client/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  46. package/client/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  47. package/client/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  48. package/client/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  49. package/client/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  50. package/client/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  51. package/client/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  52. package/client/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  53. package/client/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  54. package/client/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  55. package/client/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  56. package/client/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  57. package/client/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  58. package/client/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  59. package/client/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  60. package/client/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  61. package/client/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  62. package/client/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  63. package/client/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  64. package/client/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  65. package/client/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  66. package/client/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  67. package/client/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  68. package/client/dist/assets/LoginModal-BRycfsyD.js +13 -0
  69. package/client/dist/assets/MarkdownPreview-DHmk3qzu.js +1 -0
  70. package/client/dist/assets/MermaidBlock-BuBc_G-F.js +2 -0
  71. package/client/dist/assets/Onboarding-BcnaZZ0o.js +1 -0
  72. package/client/dist/assets/PreviewPanel-CqCa92Tf.js +32 -0
  73. package/client/dist/assets/SetupForm-S0g6u5yT.js +1 -0
  74. package/client/dist/assets/WorkflowsPanel-CouH9JDO.js +1 -0
  75. package/client/dist/assets/index-BFuqS0tY.css +1 -0
  76. package/client/dist/assets/index-CNDcVl2g.js +68 -0
  77. package/client/dist/assets/pdf-CE_K4jFx.js +12 -0
  78. package/client/dist/assets/vendor-canvas-BZV40eAE.css +1 -0
  79. package/client/dist/assets/vendor-canvas-D39yWul6.js +49 -0
  80. package/client/dist/assets/vendor-codemirror-CbtmxxaB.js +35 -0
  81. package/client/dist/assets/vendor-diff-DNQpbhrT.js +69 -0
  82. package/client/dist/assets/vendor-i18n-DCFGyhQR.js +1 -0
  83. package/client/dist/assets/vendor-icons-BaD0x9SL.js +711 -0
  84. package/client/dist/assets/vendor-markdown-CimbIo6Y.js +296 -0
  85. package/client/dist/assets/vendor-mermaid-CH7SGc99.js +2556 -0
  86. package/client/dist/assets/vendor-react-96lCPsRK.js +67 -0
  87. package/client/dist/assets/vendor-syntax-DuHI9Ok6.js +16 -0
  88. package/client/dist/assets/vendor-xterm-CZq1hqo1.js +66 -0
  89. package/client/dist/assets/vendor-xterm-qxJ8_QYu.css +32 -0
  90. package/client/dist/clear-cache.html +85 -0
  91. package/client/dist/convert-icons.md +53 -0
  92. package/client/dist/favicon.png +0 -0
  93. package/client/dist/favicon.svg +5 -0
  94. package/client/dist/generate-icons.js +49 -0
  95. package/client/dist/icons/claude-ai-icon.svg +1 -0
  96. package/client/dist/icons/codex-white.svg +3 -0
  97. package/client/dist/icons/codex.svg +3 -0
  98. package/client/dist/icons/cursor-white.svg +12 -0
  99. package/client/dist/icons/cursor.svg +1 -0
  100. package/client/dist/icons/icon-128x128.png +0 -0
  101. package/client/dist/icons/icon-128x128.svg +5 -0
  102. package/client/dist/icons/icon-144x144.png +0 -0
  103. package/client/dist/icons/icon-144x144.svg +5 -0
  104. package/client/dist/icons/icon-152x152.png +0 -0
  105. package/client/dist/icons/icon-152x152.svg +5 -0
  106. package/client/dist/icons/icon-192x192.png +0 -0
  107. package/client/dist/icons/icon-192x192.svg +5 -0
  108. package/client/dist/icons/icon-384x384.png +0 -0
  109. package/client/dist/icons/icon-384x384.svg +5 -0
  110. package/client/dist/icons/icon-512x512.png +0 -0
  111. package/client/dist/icons/icon-512x512.svg +5 -0
  112. package/client/dist/icons/icon-72x72.png +0 -0
  113. package/client/dist/icons/icon-72x72.svg +5 -0
  114. package/client/dist/icons/icon-96x96.png +0 -0
  115. package/client/dist/icons/icon-96x96.svg +5 -0
  116. package/client/dist/icons/icon-template.svg +5 -0
  117. package/client/dist/index.html +119 -0
  118. package/client/dist/logo-128.png +0 -0
  119. package/client/dist/logo-256.png +0 -0
  120. package/client/dist/logo-32.png +0 -0
  121. package/client/dist/logo-512.png +0 -0
  122. package/client/dist/logo-64.png +0 -0
  123. package/client/dist/logo.svg +14 -0
  124. package/client/dist/manifest.json +61 -0
  125. package/client/dist/mcp-docs.html +108 -0
  126. package/client/dist/offline.html +84 -0
  127. package/client/dist/screenshots/cli-selection.png +0 -0
  128. package/client/dist/screenshots/desktop-main.png +0 -0
  129. package/client/dist/screenshots/mobile-chat.png +0 -0
  130. package/client/dist/screenshots/tools-modal.png +0 -0
  131. package/client/dist/sw.js +82 -0
  132. package/commands/upfynai-connect.md +59 -0
  133. package/commands/upfynai-disconnect.md +31 -0
  134. package/commands/upfynai-doctor.md +99 -0
  135. package/commands/upfynai-export.md +49 -0
  136. package/commands/upfynai-local.md +82 -0
  137. package/commands/upfynai-status.md +75 -0
  138. package/commands/upfynai-stop.md +49 -0
  139. package/commands/upfynai-uninstall.md +58 -0
  140. package/commands/upfynai.md +69 -0
  141. package/package.json +143 -82
  142. package/scripts/build-client.js +17 -0
  143. package/scripts/fix-node-pty.js +67 -0
  144. package/scripts/install-commands.js +78 -0
  145. package/server/agent-loop.js +242 -0
  146. package/server/auto-compact.js +99 -0
  147. package/server/claude-sdk.js +797 -0
  148. package/server/cli-ui.js +785 -0
  149. package/server/cli.js +596 -0
  150. package/server/constants/config.js +31 -0
  151. package/server/cursor-cli.js +270 -0
  152. package/server/database/auth.db +0 -0
  153. package/server/database/db.js +1391 -0
  154. package/server/database/init.sql +70 -0
  155. package/server/index.js +3799 -0
  156. package/server/load-env.js +26 -0
  157. package/server/mcp-server.js +621 -0
  158. package/server/middleware/auth.js +176 -0
  159. package/server/middleware/relayHelpers.js +44 -0
  160. package/server/middleware/sandboxRouter.js +174 -0
  161. package/server/openai-codex.js +403 -0
  162. package/server/openrouter.js +137 -0
  163. package/server/projects.js +1807 -0
  164. package/server/provider-factory.js +174 -0
  165. package/server/relay-client.js +379 -0
  166. package/server/routes/agent.js +1226 -0
  167. package/server/routes/auth.js +554 -0
  168. package/server/routes/canvas.js +53 -0
  169. package/server/routes/cli-auth.js +263 -0
  170. package/server/routes/codex.js +396 -0
  171. package/server/routes/commands.js +707 -0
  172. package/server/routes/composio.js +176 -0
  173. package/server/routes/cursor.js +770 -0
  174. package/server/routes/dashboard.js +295 -0
  175. package/server/routes/git.js +1208 -0
  176. package/server/routes/keys.js +34 -0
  177. package/server/routes/mcp-utils.js +48 -0
  178. package/server/routes/mcp.js +661 -0
  179. package/server/routes/payments.js +227 -0
  180. package/server/routes/projects.js +655 -0
  181. package/server/routes/sessions.js +146 -0
  182. package/server/routes/settings.js +261 -0
  183. package/server/routes/taskmaster.js +1928 -0
  184. package/server/routes/user.js +106 -0
  185. package/server/routes/vapi-chat.js +624 -0
  186. package/server/routes/voice.js +235 -0
  187. package/server/routes/webhooks.js +166 -0
  188. package/server/routes/workflows.js +312 -0
  189. package/server/sandbox.js +120 -0
  190. package/server/services/composio.js +204 -0
  191. package/server/services/sessionRegistry.js +139 -0
  192. package/server/services/whisperService.js +84 -0
  193. package/server/services/workflowScheduler.js +206 -0
  194. package/server/tests/relay-flow.test.js +570 -0
  195. package/server/tests/sessions.test.js +259 -0
  196. package/server/utils/commandParser.js +303 -0
  197. package/server/utils/email.js +61 -0
  198. package/server/utils/gitConfig.js +24 -0
  199. package/server/utils/mcp-detector.js +198 -0
  200. package/server/utils/taskmaster-websocket.js +129 -0
  201. package/shared/integrationCatalog.d.ts +12 -0
  202. package/shared/integrationCatalog.js +172 -0
  203. package/shared/modelConstants.js +96 -0
  204. package/bin/cli.js +0 -97
  205. package/dist/agents/claude.js +0 -229
  206. package/dist/agents/codex.js +0 -48
  207. package/dist/agents/cursor.js +0 -48
  208. package/dist/agents/detect.js +0 -51
  209. package/dist/agents/exec.js +0 -31
  210. package/dist/agents/files.js +0 -105
  211. package/dist/agents/git.js +0 -18
  212. package/dist/agents/gitagent.js +0 -67
  213. package/dist/agents/index.js +0 -88
  214. package/dist/agents/shell.js +0 -38
  215. package/dist/agents/utils.js +0 -136
  216. package/scripts/postinstall.js +0 -9
  217. package/scripts/prepublish.js +0 -58
  218. package/src/animation.js +0 -228
  219. package/src/auth.js +0 -122
  220. package/src/config.js +0 -40
  221. package/src/connect.js +0 -416
  222. package/src/launch.js +0 -78
  223. package/src/mcp.js +0 -57
  224. package/src/permissions.js +0 -140
  225. package/src/persistent-shell.js +0 -261
  226. package/src/server.js +0 -54
  227. /package/{dist → shared}/gitagent/index.js +0 -0
  228. /package/{dist → shared}/gitagent/parser.js +0 -0
  229. /package/{dist → shared}/gitagent/prompt-builder.js +0 -0
@@ -0,0 +1,3799 @@
1
+ #!/usr/bin/env node
2
+ // Load environment variables before other imports execute
3
+ import './load-env.js';
4
+
5
+ // Strip Claude Code session markers so spawned CLI processes don't fail with
6
+ // "cannot be launched inside another Claude Code session" errors.
7
+ delete process.env.CLAUDECODE;
8
+ delete process.env.CLAUDE_CODE;
9
+ import crypto from 'crypto';
10
+ import fs from 'fs';
11
+ import path from 'path';
12
+ import jwt from 'jsonwebtoken';
13
+ import { fileURLToPath } from 'url';
14
+ import { dirname } from 'path';
15
+
16
+ const __filename = fileURLToPath(import.meta.url);
17
+ const __dirname = dirname(__filename);
18
+
19
+ // Read version from package.json at startup
20
+ const SERVER_VERSION = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version;
21
+
22
+ // ANSI color codes for terminal output
23
+ const colors = {
24
+ reset: '\x1b[0m',
25
+ bright: '\x1b[1m',
26
+ cyan: '\x1b[36m',
27
+ green: '\x1b[32m',
28
+ yellow: '\x1b[33m',
29
+ blue: '\x1b[34m',
30
+ dim: '\x1b[2m',
31
+ };
32
+
33
+ const c = {
34
+ info: (text) => `${colors.cyan}${text}${colors.reset}`,
35
+ ok: (text) => `${colors.green}${text}${colors.reset}`,
36
+ warn: (text) => `${colors.yellow}${text}${colors.reset}`,
37
+ tip: (text) => `${colors.blue}${text}${colors.reset}`,
38
+ bright: (text) => `${colors.bright}${text}${colors.reset}`,
39
+ dim: (text) => `${colors.dim}${text}${colors.reset}`,
40
+ };
41
+
42
+ // PORT read from env (no log)
43
+
44
+ import express from 'express';
45
+ import { WebSocketServer, WebSocket } from 'ws';
46
+ import os from 'os';
47
+ import http from 'http';
48
+ import cors from 'cors';
49
+ import cookieParser from 'cookie-parser';
50
+ import { promises as fsPromises } from 'fs';
51
+ import { spawn } from 'child_process';
52
+ // node-pty: conditionally imported (not available on Vercel serverless)
53
+ let pty = null;
54
+ try {
55
+ pty = (await import('node-pty')).default;
56
+ } catch (e) {
57
+ console.warn('[WARN] node-pty not available. Shell tab requires relay connection.');
58
+ }
59
+ // Node 22+ has built-in fetch — no need for node-fetch
60
+ import mime from 'mime-types';
61
+
62
+ import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache, setCloudUserId } from './projects.js';
63
+ import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval, loadGitagentContext, clearGitagentCache, extractTokenBudget } from './claude-sdk.js';
64
+ import { buildSystemPromptAppendix } from '../shared/gitagent/prompt-builder.js';
65
+ import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
66
+ import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js';
67
+ import { queryOpenRouter, OPENROUTER_MODELS } from './openrouter.js';
68
+ import { createMcpServer, mountMcpServer } from './mcp-server.js';
69
+ import gitRoutes from './routes/git.js';
70
+ import authRoutes from './routes/auth.js';
71
+ import mcpRoutes from './routes/mcp.js';
72
+ import cursorRoutes from './routes/cursor.js';
73
+ import taskmasterRoutes from './routes/taskmaster.js';
74
+ import mcpUtilsRoutes from './routes/mcp-utils.js';
75
+ import commandsRoutes from './routes/commands.js';
76
+ import settingsRoutes from './routes/settings.js';
77
+ import agentRoutes from './routes/agent.js';
78
+ import projectsRoutes, { WORKSPACES_ROOT, validateWorkspacePath } from './routes/projects.js';
79
+ import cliAuthRoutes from './routes/cli-auth.js';
80
+ import userRoutes from './routes/user.js';
81
+ import codexRoutes from './routes/codex.js';
82
+ import paymentRoutes from './routes/payments.js';
83
+ import webhookRoutes from './routes/webhooks.js';
84
+ import workflowRoutes from './routes/workflows.js';
85
+ import voiceRoutes from './routes/voice.js';
86
+ import dashboardRoutes from './routes/dashboard.js';
87
+ import keysRoutes from './routes/keys.js';
88
+ import assistantRoutes from './routes/vapi-chat.js';
89
+ import canvasRoutes from './routes/canvas.js';
90
+ import composioRoutes from './routes/composio.js';
91
+ import sessionRoutes from './routes/sessions.js';
92
+ import { handleSandboxWebSocketCommand } from './middleware/sandboxRouter.js';
93
+ import { createRelayMiddleware } from './middleware/relayHelpers.js';
94
+ import { initScheduler } from './services/workflowScheduler.js';
95
+ import { initializeDatabase, relayTokensDb, subscriptionDb, credentialsDb, userDb, fileVersionDb, sessionUsageDb, connectionDb, projectDb, voiceCallDb } from './database/db.js';
96
+ import { validateApiKey, authenticateToken, authenticateWebSocket, JWT_SECRET } from './middleware/auth.js';
97
+ import { IS_PLATFORM, IS_LOCAL } from './constants/config.js';
98
+ import { sandboxClient } from './sandbox.js';
99
+ import { execSync } from 'child_process';
100
+
101
+ // ─── Project ownership middleware (user isolation) ──────────────────────────────
102
+ // Ensures the requesting user owns the project before allowing access.
103
+ // In cloud mode: checks user_projects table. In local mode: always passes (single user).
104
+ const IS_CLOUD_ENV = !!(process.env.TURSO_DATABASE_URL && !process.env.IS_LOCAL_SERVER);
105
+
106
+ function authorizeProject(req, res, next) {
107
+ const projectName = req.params.projectName;
108
+ if (!projectName) return next();
109
+
110
+ // Local mode — single user, no isolation needed
111
+ if (!IS_CLOUD_ENV) return next();
112
+
113
+ const userId = req.user?.id || req.user?.userId;
114
+ if (!userId) return res.status(401).json({ error: 'Authentication required' });
115
+
116
+ // Check user_projects table for ownership
117
+ projectDb.getAll(userId).then(projects => {
118
+ const owns = projects.some(p => p.project_name === projectName || p.original_path === projectName);
119
+ if (!owns) {
120
+ return res.status(403).json({ error: 'Access denied — you do not own this project' });
121
+ }
122
+ next();
123
+ }).catch(() => {
124
+ res.status(500).json({ error: 'Internal server error' });
125
+ });
126
+ }
127
+
128
+ // File system watchers for provider project/session folders
129
+ const PROVIDER_WATCH_PATHS = [
130
+ { provider: 'claude', rootPath: path.join(os.homedir(), '.claude', 'projects') },
131
+ { provider: 'cursor', rootPath: path.join(os.homedir(), '.cursor', 'chats') },
132
+ { provider: 'codex', rootPath: path.join(os.homedir(), '.codex', 'sessions') }
133
+ ];
134
+ const WATCHER_IGNORED_PATTERNS = [
135
+ '**/node_modules/**',
136
+ '**/.git/**',
137
+ '**/dist/**',
138
+ '**/build/**',
139
+ '**/*.tmp',
140
+ '**/*.swp',
141
+ '**/.DS_Store'
142
+ ];
143
+ const WATCHER_DEBOUNCE_MS = 300;
144
+ let projectsWatchers = [];
145
+ let projectsWatcherDebounceTimer = null;
146
+ const connectedClients = new Set();
147
+ let isGetProjectsRunning = false; // Flag to prevent reentrant calls
148
+
149
+ // Relay connections: Maps userId → { ws, capabilities, user }
150
+ // Connects user's local machine to the hosted server
151
+ const relayConnections = new Map();
152
+ // Pending relay requests: Maps requestId → { resolve, reject, timeout }
153
+ const pendingRelayRequests = new Map();
154
+ // Relay session tracking: Maps sessionId → { userId, requestId } (for abort forwarding)
155
+ const relaySessionRequests = new Map();
156
+ // Sandbox cleanup timers: Maps userId → timeout (grace period before sandbox destroy)
157
+ const sandboxCleanupTimers = new Map();
158
+ const SANDBOX_GRACE_PERIOD_MS = 5 * 60 * 1000; // 5 minutes
159
+
160
+ // ─── Relay Security ──────────────────────────────────────────────────────────
161
+
162
+ // Allowed relay actions — only these can be sent to user's machine.
163
+ // Anything not on this list is rejected to prevent arbitrary command execution.
164
+ const ALLOWED_RELAY_ACTIONS = new Set([
165
+ 'claude-query', 'codex-query', 'cursor-query',
166
+ 'claude-task-query', // Sub-agent: read-only research (opencode pattern)
167
+ 'exec', 'shell-command', 'shell-session-start', // Shell execution & interactive terminal
168
+ 'file-read', 'file-write', 'file-tree',
169
+ 'browse-dirs', 'validate-path', 'create-folder',
170
+ 'git-operation', 'detect-agents',
171
+ 'gitagent-detect', 'gitagent-parse', // Gitagent directory structure detection
172
+ ]);
173
+
174
+ // Dangerous shell patterns — block injection attempts in shell-command payloads.
175
+ // These patterns catch common shell injection techniques: pipes, chaining,
176
+ // backtick execution, subshells, redirection to sensitive files, etc.
177
+ const DANGEROUS_SHELL_PATTERNS = [
178
+ /;\s*(rm|del|format|mkfs|dd)\b/i, // destructive chained commands
179
+ /\|\s*(bash|sh|cmd|powershell|nc|ncat|curl\s.*\|)/i, // pipe to shell/reverse-shell
180
+ /`[^`]*`/, // backtick command substitution
181
+ /\$\([^)]*\)/, // $() command substitution
182
+ />\s*\/etc\//, // redirect to system files
183
+ />\s*C:\\Windows\\/i, // redirect to Windows system
184
+ /&&\s*(rm|del|format|shutdown|reboot)\b/i, // chain destructive commands
185
+ /\|\|\s*(rm|del|format)\b/i, // fallback destructive
186
+ /;\s*(curl|wget|nc)\s.*\s*\|/i, // download-and-execute
187
+ /eval\s*\(/, // eval injection
188
+ /\bsudo\b/, // privilege escalation
189
+ /\bchmod\s+[0-7]*[67][0-7]*\s/, // making files world-writable
190
+ ];
191
+
192
+ /**
193
+ * Extract real client IP from request (respects proxy headers on Railway/Vercel).
194
+ * Railway sets x-forwarded-for; we trust the first IP in the chain.
195
+ */
196
+ function extractClientIp(request) {
197
+ const forwarded = request?.headers?.['x-forwarded-for'];
198
+ if (forwarded) {
199
+ // x-forwarded-for may be comma-separated; first is the real client
200
+ return forwarded.split(',')[0].trim();
201
+ }
202
+ return request?.socket?.remoteAddress || request?.connection?.remoteAddress || 'unknown';
203
+ }
204
+
205
+ /**
206
+ * Validate a relay command payload for safety.
207
+ * Returns { valid: true } or { valid: false, reason: string }.
208
+ */
209
+ function validateRelayPayload(action, payload) {
210
+ if (!ALLOWED_RELAY_ACTIONS.has(action)) {
211
+ return { valid: false, reason: `Unknown relay action: ${action}` };
212
+ }
213
+
214
+ // For shell-command, validate the command string
215
+ if (action === 'shell-command' && payload?.command) {
216
+ const cmd = payload.command;
217
+
218
+ // Must be a string
219
+ if (typeof cmd !== 'string') {
220
+ return { valid: false, reason: 'Command must be a string' };
221
+ }
222
+
223
+ // Max command length (prevent buffer overflow attempts)
224
+ if (cmd.length > 10000) {
225
+ return { valid: false, reason: 'Command too long' };
226
+ }
227
+
228
+ // Only allow git commands and known safe commands via shell-command
229
+ // This is the key security gate: shell-command is only used for git operations
230
+ // and system commands like mkdir, rm (for discard). Block everything else.
231
+ const allowedPrefixes = ['git ', 'mkdir ', 'rm ', 'wmic ', 'dir ', 'claude ', 'codex ', 'npm '];
232
+ const isAllowedCommand = allowedPrefixes.some(prefix => cmd.trimStart().startsWith(prefix));
233
+ if (!isAllowedCommand) {
234
+ return { valid: false, reason: `Shell command not allowed: ${cmd.substring(0, 50)}` };
235
+ }
236
+
237
+ // Check for dangerous patterns even in allowed commands
238
+ for (const pattern of DANGEROUS_SHELL_PATTERNS) {
239
+ if (pattern.test(cmd)) {
240
+ return { valid: false, reason: 'Dangerous command pattern detected' };
241
+ }
242
+ }
243
+ }
244
+
245
+ // For file operations, validate paths
246
+ if (['file-read', 'file-write', 'file-tree', 'create-folder'].includes(action)) {
247
+ const filePath = payload?.filePath || payload?.dirPath || payload?.folderPath;
248
+ if (filePath && typeof filePath === 'string') {
249
+ // Block path traversal attempts
250
+ if (filePath.includes('..') && (filePath.includes('/etc/') || filePath.includes('/proc/') || filePath.includes('\\Windows\\System32'))) {
251
+ return { valid: false, reason: 'Path traversal blocked' };
252
+ }
253
+ }
254
+ }
255
+
256
+ return { valid: true };
257
+ }
258
+
259
+ // Session-tab locking: Maps sessionId → WebSocket connection
260
+ // Prevents the same session from being active in multiple tabs
261
+ const sessionLocks = new Map();
262
+
263
+ // Session-to-user ownership map — tracks which userId owns each sessionId
264
+ // Used by session REST routes to enforce user isolation
265
+ const sessionOwners = new Map();
266
+
267
+ // Session busy detection (opencode pattern: IsSessionBusy)
268
+ // Maps sessionId → true when a query is being processed
269
+ const busySessions = new Map();
270
+
271
+ function markSessionBusy(sessionId) {
272
+ if (!sessionId) return true;
273
+ if (busySessions.has(sessionId)) return false; // already busy
274
+ busySessions.set(sessionId, true);
275
+ return true;
276
+ }
277
+
278
+ function markSessionFree(sessionId) {
279
+ if (sessionId) busySessions.delete(sessionId);
280
+ }
281
+
282
+ function acquireSessionLock(sessionId, ws) {
283
+ if (!sessionId) return true; // New sessions don't need locks yet
284
+ const existingWs = sessionLocks.get(sessionId);
285
+ if (existingWs && existingWs !== ws && existingWs.readyState === 1) {
286
+ return false; // Another tab has this session locked
287
+ }
288
+ sessionLocks.set(sessionId, ws);
289
+ return true;
290
+ }
291
+
292
+ function releaseSessionLock(sessionId, ws) {
293
+ const lockedWs = sessionLocks.get(sessionId);
294
+ if (lockedWs === ws) {
295
+ sessionLocks.delete(sessionId);
296
+ }
297
+ }
298
+
299
+ function releaseAllLocksForWs(ws) {
300
+ for (const [sessionId, lockedWs] of sessionLocks.entries()) {
301
+ if (lockedWs === ws) {
302
+ sessionLocks.delete(sessionId);
303
+ }
304
+ }
305
+ }
306
+
307
+ // Session subscribers: Maps sessionId → Set<WebSocket>
308
+ // Allows multiple tabs to READ output from an active session
309
+ const sessionSubscribers = new Map();
310
+
311
+ function subscribeToSession(sessionId, ws) {
312
+ if (!sessionId) return;
313
+ if (!sessionSubscribers.has(sessionId)) {
314
+ sessionSubscribers.set(sessionId, new Set());
315
+ }
316
+ sessionSubscribers.get(sessionId).add(ws);
317
+ }
318
+
319
+ function unsubscribeFromSession(sessionId, ws) {
320
+ const subs = sessionSubscribers.get(sessionId);
321
+ if (subs) {
322
+ subs.delete(ws);
323
+ if (subs.size === 0) sessionSubscribers.delete(sessionId);
324
+ }
325
+ }
326
+
327
+ function unsubscribeAllForWs(ws) {
328
+ for (const [sessionId, subs] of sessionSubscribers.entries()) {
329
+ subs.delete(ws);
330
+ if (subs.size === 0) sessionSubscribers.delete(sessionId);
331
+ }
332
+ }
333
+
334
+ function broadcastToSessionSubscribers(sessionId, message) {
335
+ const subs = sessionSubscribers.get(sessionId);
336
+ if (!subs) return;
337
+ const payload = typeof message === 'string' ? message : JSON.stringify(message);
338
+ for (const ws of subs) {
339
+ if (ws.readyState === 1) {
340
+ ws.send(payload);
341
+ }
342
+ }
343
+ }
344
+
345
+ // Broadcast progress to all connected WebSocket clients
346
+ function broadcastProgress(progress) {
347
+ const message = JSON.stringify({
348
+ type: 'loading_progress',
349
+ ...progress
350
+ });
351
+ connectedClients.forEach(client => {
352
+ if (client.readyState === WebSocket.OPEN) {
353
+ client.send(message);
354
+ }
355
+ });
356
+ }
357
+
358
+ // Setup file system watchers for Claude, Cursor, and Codex project/session folders
359
+ async function setupProjectsWatcher() {
360
+ const chokidar = (await import('chokidar')).default;
361
+
362
+ if (projectsWatcherDebounceTimer) {
363
+ clearTimeout(projectsWatcherDebounceTimer);
364
+ projectsWatcherDebounceTimer = null;
365
+ }
366
+
367
+ await Promise.all(
368
+ projectsWatchers.map(async (watcher) => {
369
+ try {
370
+ await watcher.close();
371
+ } catch (error) {
372
+ // watcher close error handled silently
373
+ }
374
+ })
375
+ );
376
+ projectsWatchers = [];
377
+
378
+ const debouncedUpdate = (eventType, filePath, provider, rootPath) => {
379
+ if (projectsWatcherDebounceTimer) {
380
+ clearTimeout(projectsWatcherDebounceTimer);
381
+ }
382
+
383
+ projectsWatcherDebounceTimer = setTimeout(async () => {
384
+ // Prevent reentrant calls
385
+ if (isGetProjectsRunning) {
386
+ return;
387
+ }
388
+
389
+ try {
390
+ isGetProjectsRunning = true;
391
+
392
+ // Clear project directory cache when files change
393
+ clearProjectDirectoryCache();
394
+
395
+ // Get updated projects list
396
+ const updatedProjects = await getProjects(broadcastProgress);
397
+
398
+ // Notify connected clients about project changes (per-user filtered)
399
+ const basePayload = {
400
+ type: 'projects_updated',
401
+ timestamp: new Date().toISOString(),
402
+ changeType: eventType,
403
+ changedFile: path.relative(rootPath, filePath),
404
+ watchProvider: provider
405
+ };
406
+
407
+ // Build per-user project lists, then send to all their clients
408
+ const userProjectsCache = new Map();
409
+ for (const client of connectedClients) {
410
+ if (client.readyState !== WebSocket.OPEN) continue;
411
+ const uid = client._wsUser?.userId;
412
+ if (!uid) {
413
+ // No auth — local mode, send all projects
414
+ client.send(JSON.stringify({ ...basePayload, projects: updatedProjects }));
415
+ continue;
416
+ }
417
+ try {
418
+ if (!userProjectsCache.has(uid)) {
419
+ userProjectsCache.set(uid, await getProjects(null, uid));
420
+ }
421
+ client.send(JSON.stringify({ ...basePayload, projects: userProjectsCache.get(uid) }));
422
+ } catch { /* ignore per-user fetch errors */ }
423
+ }
424
+
425
+ } catch (error) {
426
+ // project change error handled silently
427
+ } finally {
428
+ isGetProjectsRunning = false;
429
+ }
430
+ }, WATCHER_DEBOUNCE_MS);
431
+ };
432
+
433
+ for (const { provider, rootPath } of PROVIDER_WATCH_PATHS) {
434
+ try {
435
+ // chokidar v4 emits ENOENT via the "error" event for missing roots and will not auto-recover.
436
+ // Ensure provider folders exist before creating the watcher so watching stays active.
437
+ await fsPromises.mkdir(rootPath, { recursive: true });
438
+
439
+ // Initialize chokidar watcher with optimized settings
440
+ const watcher = chokidar.watch(rootPath, {
441
+ ignored: WATCHER_IGNORED_PATTERNS,
442
+ persistent: true,
443
+ ignoreInitial: true, // Don't fire events for existing files on startup
444
+ followSymlinks: false,
445
+ depth: 10, // Reasonable depth limit
446
+ awaitWriteFinish: {
447
+ stabilityThreshold: 100, // Wait 100ms for file to stabilize
448
+ pollInterval: 50
449
+ }
450
+ });
451
+
452
+ // Set up event listeners
453
+ watcher
454
+ .on('add', (filePath) => debouncedUpdate('add', filePath, provider, rootPath))
455
+ .on('change', (filePath) => debouncedUpdate('change', filePath, provider, rootPath))
456
+ .on('unlink', (filePath) => debouncedUpdate('unlink', filePath, provider, rootPath))
457
+ .on('addDir', (dirPath) => debouncedUpdate('addDir', dirPath, provider, rootPath))
458
+ .on('unlinkDir', (dirPath) => debouncedUpdate('unlinkDir', dirPath, provider, rootPath))
459
+ .on('error', (error) => {
460
+ // provider watcher error handled silently
461
+ })
462
+ .on('ready', () => {
463
+ });
464
+
465
+ projectsWatchers.push(watcher);
466
+ } catch (error) {
467
+ // provider watcher setup error handled silently
468
+ }
469
+ }
470
+
471
+ if (projectsWatchers.length === 0) {
472
+ // no provider watchers available
473
+ }
474
+ }
475
+
476
+
477
+ const app = express();
478
+
479
+ // On Vercel serverless, we don't need an HTTP server or WebSocket server
480
+ const server = process.env.VERCEL ? null : http.createServer(app);
481
+
482
+ const ptySessionsMap = new Map();
483
+ const PTY_SESSION_TIMEOUT = 30 * 60 * 1000;
484
+ const SHELL_URL_PARSE_BUFFER_LIMIT = 32768;
485
+ const ANSI_ESCAPE_SEQUENCE_REGEX = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\))/g;
486
+ const TRAILING_URL_PUNCTUATION_REGEX = /[)\]}>.,;:!?]+$/;
487
+
488
+ function stripAnsiSequences(value = '') {
489
+ return value.replace(ANSI_ESCAPE_SEQUENCE_REGEX, '');
490
+ }
491
+
492
+ function normalizeDetectedUrl(url) {
493
+ if (!url || typeof url !== 'string') return null;
494
+
495
+ const cleaned = url.trim().replace(TRAILING_URL_PUNCTUATION_REGEX, '');
496
+ if (!cleaned) return null;
497
+
498
+ try {
499
+ const parsed = new URL(cleaned);
500
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
501
+ return null;
502
+ }
503
+ return parsed.toString();
504
+ } catch {
505
+ return null;
506
+ }
507
+ }
508
+
509
+ function extractUrlsFromText(value = '') {
510
+ const directMatches = value.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/gi) || [];
511
+
512
+ // Handle wrapped terminal URLs split across lines by terminal width.
513
+ const wrappedMatches = [];
514
+ const continuationRegex = /^[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$/;
515
+ const lines = value.split(/\r?\n/);
516
+ for (let i = 0; i < lines.length; i++) {
517
+ const line = lines[i].trim();
518
+ const startMatch = line.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/i);
519
+ if (!startMatch) continue;
520
+
521
+ let combined = startMatch[0];
522
+ let j = i + 1;
523
+ while (j < lines.length) {
524
+ const continuation = lines[j].trim();
525
+ if (!continuation) break;
526
+ if (!continuationRegex.test(continuation)) break;
527
+ combined += continuation;
528
+ j++;
529
+ }
530
+
531
+ wrappedMatches.push(combined.replace(/\r?\n\s*/g, ''));
532
+ }
533
+
534
+ return Array.from(new Set([...directMatches, ...wrappedMatches]));
535
+ }
536
+
537
+ function shouldAutoOpenUrlFromOutput(value = '') {
538
+ const normalized = value.toLowerCase();
539
+ return (
540
+ normalized.includes('browser didn\'t open') ||
541
+ normalized.includes('open this url') ||
542
+ normalized.includes('continue in your browser') ||
543
+ normalized.includes('press enter to open') ||
544
+ normalized.includes('open_url:')
545
+ );
546
+ }
547
+
548
+ // Single WebSocket server that handles both paths (skip on Vercel serverless)
549
+ let wss = null;
550
+ if (server) {
551
+ wss = new WebSocketServer({
552
+ server,
553
+ verifyClient: (info, done) => {
554
+ const reqUrl = info.req.url || '';
555
+ const origin = info.req.headers?.origin || 'no-origin';
556
+ authenticateWebSocket(info.req).then(user => {
557
+ if (!user) {
558
+ done(false, 401, 'Unauthorized');
559
+ return;
560
+ }
561
+ info.req.user = user;
562
+ done(true);
563
+ }).catch(err => {
564
+ console.error(`[WS] Auth ERROR for ${reqUrl}:`, err.message);
565
+ done(false, 500, 'Auth error');
566
+ });
567
+ }
568
+ });
569
+ }
570
+
571
+ // Make WebSocket server available to routes
572
+ app.locals.wss = wss;
573
+
574
+ // Security headers — protect against common web attacks
575
+ app.use((req, res, next) => {
576
+ res.setHeader('X-Content-Type-Options', 'nosniff');
577
+ // Allow framing from our own frontend domains (Vercel embeds Railway in an iframe)
578
+ const allowedFrameOrigins = (process.env.CORS_ORIGINS || '').split(',').map(s => s.trim()).filter(Boolean);
579
+ if (allowedFrameOrigins.length > 0) {
580
+ res.setHeader('Content-Security-Policy', `frame-ancestors 'self' ${allowedFrameOrigins.join(' ')}`);
581
+ } else {
582
+ res.setHeader('X-Frame-Options', 'SAMEORIGIN');
583
+ }
584
+ res.setHeader('X-XSS-Protection', '1; mode=block');
585
+ res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
586
+ if (process.env.NODE_ENV === 'production') {
587
+ res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
588
+ }
589
+ next();
590
+ });
591
+
592
+ // CORS: require explicit CORS_ORIGINS in production, restrict to same-origin otherwise
593
+ const CORS_ORIGINS = process.env.CORS_ORIGINS
594
+ ? process.env.CORS_ORIGINS.split(',').map(o => o.trim())
595
+ : (process.env.NODE_ENV === 'production' ? ['https://cli.upfyn.com'] : true);
596
+ app.use(cors({ origin: CORS_ORIGINS, credentials: true }));
597
+ app.use(cookieParser());
598
+ app.use(express.json({
599
+ limit: '50mb',
600
+ type: (req) => {
601
+ // Skip multipart/form-data requests (for file uploads like images)
602
+ const contentType = req.headers['content-type'] || '';
603
+ if (contentType.includes('multipart/form-data')) {
604
+ return false;
605
+ }
606
+ return contentType.includes('json');
607
+ }
608
+ }));
609
+ app.use(express.urlencoded({ limit: '50mb', extended: true }));
610
+
611
+ // Rate limiting for auth endpoints — prevent brute force attacks
612
+ const authRateLimitMap = new Map();
613
+ const AUTH_RATE_WINDOW = 15 * 60 * 1000; // 15 minutes
614
+ const AUTH_RATE_MAX = 10; // max attempts per window
615
+
616
+ function authRateLimit(req, res, next) {
617
+ const key = req.ip || req.connection.remoteAddress || 'unknown';
618
+ const now = Date.now();
619
+ const entry = authRateLimitMap.get(key);
620
+
621
+ if (entry) {
622
+ // Clean expired entries
623
+ if (now - entry.windowStart > AUTH_RATE_WINDOW) {
624
+ authRateLimitMap.set(key, { windowStart: now, count: 1 });
625
+ return next();
626
+ }
627
+ entry.count++;
628
+ if (entry.count > AUTH_RATE_MAX) {
629
+ return res.status(429).json({ error: 'Too many attempts. Please try again later.' });
630
+ }
631
+ } else {
632
+ authRateLimitMap.set(key, { windowStart: now, count: 1 });
633
+ }
634
+ next();
635
+ }
636
+
637
+ // Clean up stale rate limit entries every 30 minutes
638
+ setInterval(() => {
639
+ const now = Date.now();
640
+ for (const [key, entry] of authRateLimitMap) {
641
+ if (now - entry.windowStart > AUTH_RATE_WINDOW) {
642
+ authRateLimitMap.delete(key);
643
+ }
644
+ }
645
+ }, 30 * 60 * 1000);
646
+
647
+ app.locals.authRateLimit = authRateLimit;
648
+
649
+ // Vercel serverless: lazy DB initialization on first request
650
+ let dbInitialized = false;
651
+ if (process.env.VERCEL) {
652
+ app.use(async (req, res, next) => {
653
+ if (!dbInitialized) {
654
+ try {
655
+ await initializeDatabase();
656
+ dbInitialized = true;
657
+ } catch (err) {
658
+ // DB init error handled silently
659
+ return res.status(500).json({ error: 'Service temporarily unavailable' });
660
+ }
661
+ }
662
+ next();
663
+ });
664
+ }
665
+
666
+ // Public health check endpoint (no authentication required)
667
+ app.get('/health', (req, res) => {
668
+ res.json({
669
+ status: 'ok',
670
+ version: SERVER_VERSION,
671
+ timestamp: new Date().toISOString()
672
+ });
673
+ });
674
+
675
+ // Optional API key validation (if configured)
676
+ app.use('/api', validateApiKey);
677
+
678
+ // Authentication routes (public)
679
+ app.use('/api/auth', authRoutes);
680
+
681
+ // Relay middleware — injects req.isCloud, req.hasRelay(), req.sendRelay(), req.requireRelay()
682
+ // Must be created after hasActiveRelay/sendRelayCommand are defined (they're hoisted functions)
683
+ const relayMiddleware = createRelayMiddleware(hasActiveRelay, sendRelayCommand, withRetry);
684
+
685
+ // Projects API Routes (protected + relay-aware)
686
+ app.use('/api/projects', authenticateToken, relayMiddleware, projectsRoutes);
687
+
688
+ // Git API Routes (protected + relay-aware)
689
+ app.use('/api/git', authenticateToken, relayMiddleware, gitRoutes);
690
+
691
+ // MCP API Routes (protected + relay-aware)
692
+ app.use('/api/mcp', authenticateToken, relayMiddleware, mcpRoutes);
693
+
694
+ // Cursor API Routes (protected + relay-aware)
695
+ app.use('/api/cursor', authenticateToken, relayMiddleware, cursorRoutes);
696
+
697
+ // TaskMaster API Routes (protected)
698
+ app.use('/api/taskmaster', authenticateToken, taskmasterRoutes);
699
+
700
+ // MCP utilities
701
+ app.use('/api/mcp-utils', authenticateToken, relayMiddleware, mcpUtilsRoutes);
702
+
703
+ // Upfyn-Code MCP Server — exposes app capabilities to any MCP client (ChatGPT, Claude Desktop, Cursor, etc.)
704
+ const mcpDeps = {
705
+ getProjects,
706
+ getSessions,
707
+ getSessionMessages,
708
+ queryClaudeSDK,
709
+ abortClaudeSDKSession,
710
+ getActiveClaudeSDKSessions,
711
+ connectedClients,
712
+ };
713
+ const mcpAppServer = createMcpServer(mcpDeps);
714
+ const mcpServerFactory = () => createMcpServer(mcpDeps);
715
+ mountMcpServer(app, mcpAppServer, mcpServerFactory).catch(err => console.error('[MCP] Failed to mount:', err.message));
716
+
717
+ // Commands API Routes (protected + relay-aware)
718
+ app.use('/api/commands', authenticateToken, relayMiddleware, commandsRoutes);
719
+
720
+ // Settings API Routes (protected)
721
+ app.use('/api/settings', authenticateToken, settingsRoutes);
722
+
723
+ // CLI Authentication API Routes (protected)
724
+ app.use('/api/cli', authenticateToken, cliAuthRoutes);
725
+
726
+ // User API Routes (protected)
727
+ app.use('/api/user', authenticateToken, userRoutes);
728
+
729
+ // Codex API Routes (protected + relay-aware)
730
+ app.use('/api/codex', authenticateToken, relayMiddleware, codexRoutes);
731
+
732
+ // Payment & Subscription Routes (protected)
733
+ app.use('/api/payments', authenticateToken, paymentRoutes);
734
+ app.use('/api/webhooks', authenticateToken, webhookRoutes);
735
+ app.use('/api/workflows', authenticateToken, workflowRoutes);
736
+ app.use('/api/voice', authenticateToken, voiceRoutes);
737
+ app.use('/api/dashboard', authenticateToken, dashboardRoutes);
738
+ // Inject sessionOwners map so session routes can filter by userId
739
+ app.use('/api/sessions', authenticateToken, (req, _res, next) => {
740
+ req.sessionOwners = sessionOwners;
741
+ next();
742
+ }, sessionRoutes);
743
+ app.use('/api/keys', authenticateToken, keysRoutes);
744
+ app.use('/api/assistant', assistantRoutes);
745
+ app.use('/api/canvas', authenticateToken, canvasRoutes);
746
+ app.use('/api/composio', authenticateToken, composioRoutes);
747
+ app.use('/api/vapi', assistantRoutes); // Alias: VAPI dashboard webhook points to /api/vapi/webhook
748
+
749
+ // Voice call history endpoints (per-user isolated)
750
+ app.get('/api/voice/calls', authenticateToken, async (req, res) => {
751
+ try {
752
+ const userId = req.user.id || req.user.userId;
753
+ const limit = Math.min(parseInt(req.query.limit) || 20, 100);
754
+ const offset = parseInt(req.query.offset) || 0;
755
+ const calls = await voiceCallDb.getByUser(userId, limit, offset);
756
+ const stats = await voiceCallDb.getUserStats(userId);
757
+ res.json({ calls, stats });
758
+ } catch {
759
+ res.status(500).json({ error: 'Failed to fetch voice call history' });
760
+ }
761
+ });
762
+
763
+ // Agent API Routes (uses API key authentication)
764
+ app.use('/api/agent', agentRoutes);
765
+
766
+ // Relay token management routes
767
+ app.get('/api/relay/tokens', authenticateToken, async (req, res) => {
768
+ try {
769
+ const tokens = await relayTokensDb.getTokens(req.user.id);
770
+ res.json(tokens.map(t => ({ ...t, token: t.token.slice(0, 10) + '...' }))); // mask tokens
771
+ } catch (err) {
772
+ res.status(500).json({ error: 'Failed to fetch relay tokens' });
773
+ }
774
+ });
775
+
776
+ app.post('/api/relay/tokens', authenticateToken, async (req, res) => {
777
+ try {
778
+ const name = req.body.name || 'default';
779
+ const result = await relayTokensDb.createToken(req.user.id, name);
780
+ res.json(result); // returns full token only on creation
781
+ } catch (err) {
782
+ res.status(500).json({ error: 'Failed to create relay token' });
783
+ }
784
+ });
785
+
786
+ app.delete('/api/relay/tokens/:id', authenticateToken, async (req, res) => {
787
+ try {
788
+ await relayTokensDb.deleteToken(req.user.id, req.params.id);
789
+ res.json({ success: true });
790
+ } catch (err) {
791
+ res.status(500).json({ error: 'Failed to delete relay token' });
792
+ }
793
+ });
794
+
795
+ app.get('/api/relay/status', authenticateToken, async (req, res) => {
796
+ // In local mode, always connected — SDK runs directly on this machine
797
+ if (IS_LOCAL) {
798
+ return res.json({ connected: true, local: true, connectedAt: Date.now() });
799
+ }
800
+ const relay = relayConnections.get(Number(req.user.id));
801
+ const connected = !!(relay && relay.ws.readyState === 1);
802
+
803
+ // Ensure relay CWD is registered as a project (handles post-deploy filesystem wipe)
804
+ if (connected && relay.cwd) {
805
+ try { await addProjectManually(relay.cwd, null, req.user.id); } catch { /* already exists */ }
806
+ }
807
+
808
+ // Check if sandbox is alive (even if relay is disconnected — grace period)
809
+ let sandboxActive = false;
810
+ try {
811
+ const sbStatus = await sandboxClient.getStatus(req.user.id).catch(() => null);
812
+ sandboxActive = !!(sbStatus?.running);
813
+ } catch { /* ignore */ }
814
+
815
+ // Get last connection info from DB for reconnection context
816
+ let lastConnection = null;
817
+ if (!connected) {
818
+ try {
819
+ const active = await connectionDb.getActive(String(req.user.id));
820
+ if (active.length === 0) {
821
+ // Check recent disconnections
822
+ const all = await connectionDb.getAllActive();
823
+ lastConnection = all.find(c => c.user_id === String(req.user.id)) || null;
824
+ }
825
+ } catch { /* ignore */ }
826
+ }
827
+
828
+ res.json({
829
+ connected,
830
+ connectedAt: relay?.connectedAt || null,
831
+ cwd: connected ? relay.cwd : null,
832
+ machine: connected ? relay.machine : null,
833
+ platform: connected ? relay.platform : null,
834
+ version: connected ? relay.version : null,
835
+ sandboxActive,
836
+ lastConnection: lastConnection ? {
837
+ cwd: lastConnection.last_cwd,
838
+ machine: lastConnection.last_machine,
839
+ platform: lastConnection.last_platform,
840
+ disconnectedAt: lastConnection.disconnected_at,
841
+ } : null,
842
+ // Mask the IP — only show last octet for user verification
843
+ clientIp: connected && relay.clientIp ? relay.clientIp.replace(/(\d+\.\d+\.\d+\.)(\d+)/, '$1***') : null,
844
+ });
845
+ });
846
+
847
+ // POST /api/relay/disconnect — user-initiated disconnect (saves state, destroys sandbox)
848
+ app.post('/api/relay/disconnect', authenticateToken, async (req, res) => {
849
+ const userId = Number(req.user.id);
850
+ const relay = relayConnections.get(userId);
851
+ if (!relay || relay.ws.readyState !== 1) {
852
+ return res.json({ success: false, error: 'No active relay connection' });
853
+ }
854
+
855
+ // Cancel any pending sandbox grace period
856
+ const pending = sandboxCleanupTimers.get(userId);
857
+ if (pending) { clearTimeout(pending); sandboxCleanupTimers.delete(userId); }
858
+
859
+ // Save sandbox state to connection record before destroying
860
+ try {
861
+ const sbStatus = await sandboxClient.getStatus(userId).catch(() => null);
862
+ await connectionDb.disconnect(String(userId), 'relay');
863
+ } catch { /* non-critical */ }
864
+
865
+ // Destroy sandbox immediately (user-initiated = no grace period)
866
+ try { await sandboxClient.destroySandbox(userId); } catch { /* best-effort */ }
867
+
868
+ // Close the relay WebSocket
869
+ try {
870
+ relay.ws.send(JSON.stringify({ type: 'server-disconnect', reason: 'User disconnected from web UI' }));
871
+ relay.ws.close();
872
+ } catch { /* may already be closing */ }
873
+
874
+ res.json({ success: true, message: 'Relay disconnected' });
875
+ });
876
+
877
+ // ─── Sandbox API endpoints ──────────────────────────────────────────────────
878
+
879
+ // Initialize sandbox for user
880
+ app.post('/api/sandbox/init', authenticateToken, async (req, res) => {
881
+ try {
882
+ const result = await sandboxClient.initSandbox(req.user.id);
883
+ res.json(result);
884
+ } catch (err) {
885
+ res.status(503).json({ error: err.message });
886
+ }
887
+ });
888
+
889
+ // Get sandbox status
890
+ app.get('/api/sandbox/status', authenticateToken, async (req, res) => {
891
+ try {
892
+ const result = await sandboxClient.getStatus(req.user.id);
893
+ res.json(result);
894
+ } catch (err) {
895
+ res.status(503).json({ error: err.message });
896
+ }
897
+ });
898
+
899
+ // ─── System update endpoint ─────────────────────────────────────────────────
900
+ // Runs npm update on the user's machine. Only available in self-hosted mode.
901
+ // Platform users (cli.upfyn.com) don't see update notifications — the server auto-deploys.
902
+ app.post('/api/system/update', authenticateToken, async (req, res) => {
903
+ try {
904
+ if (IS_LOCAL) {
905
+ // Self-hosted: run locally
906
+ const { execSync } = await import('child_process');
907
+ const output = execSync('npm install -g upfynai-code@latest', { encoding: 'utf8', timeout: 120000 });
908
+ res.json({ output, exitCode: 0 });
909
+ } else {
910
+ // Platform mode: server is managed by Railway, no user action needed
911
+ res.status(400).json({ error: 'Server updates are automatic on the platform. No action needed.' });
912
+ }
913
+ } catch (err) {
914
+ res.status(500).json({ error: err.message || 'Update failed' });
915
+ }
916
+ });
917
+
918
+ // Execute command in sandbox
919
+ app.post('/api/sandbox/exec', authenticateToken, async (req, res) => {
920
+ try {
921
+ const { command, cwd, timeout } = req.body;
922
+ // Get user's BYOK keys for sandbox env
923
+ const userKeys = {};
924
+ try {
925
+ const anthropicKey = await credentialsDb.getCredentialByType(req.user.id, 'anthropic_key');
926
+ if (anthropicKey) userKeys.anthropic_key = anthropicKey.credential_value;
927
+ const openaiKey = await credentialsDb.getCredentialByType(req.user.id, 'openai_key');
928
+ if (openaiKey) userKeys.openai_key = openaiKey.credential_value;
929
+ } catch { /* no keys */ }
930
+ const result = await sandboxClient.exec(req.user.id, command, { cwd, timeout, userKeys });
931
+ res.json(result);
932
+ } catch (err) {
933
+ res.status(500).json({ error: err.message });
934
+ }
935
+ });
936
+
937
+ // Read file from sandbox
938
+ app.post('/api/sandbox/file/read', authenticateToken, async (req, res) => {
939
+ try {
940
+ const result = await sandboxClient.readFile(req.user.id, req.body.filePath);
941
+ res.json(result);
942
+ } catch (err) {
943
+ const status = err.message?.includes('Access denied') ? 403
944
+ : err.message?.includes('ENOENT') || err.message?.includes('404') ? 404 : 500;
945
+ res.status(status).json({ error: err.message });
946
+ }
947
+ });
948
+
949
+ // Write file to sandbox
950
+ app.post('/api/sandbox/file/write', authenticateToken, async (req, res) => {
951
+ try {
952
+ const result = await sandboxClient.writeFile(req.user.id, req.body.filePath, req.body.content);
953
+ res.json(result);
954
+ } catch (err) {
955
+ res.status(500).json({ error: err.message });
956
+ }
957
+ });
958
+
959
+ // File tree from sandbox
960
+ app.post('/api/sandbox/file/tree', authenticateToken, async (req, res) => {
961
+ try {
962
+ const result = await sandboxClient.getFileTree(req.user.id, req.body.dirPath, req.body.depth);
963
+ res.json(result);
964
+ } catch (err) {
965
+ res.status(500).json({ error: err.message });
966
+ }
967
+ });
968
+
969
+ // Git operation in sandbox
970
+ app.post('/api/sandbox/git', authenticateToken, async (req, res) => {
971
+ try {
972
+ const result = await sandboxClient.gitOperation(req.user.id, req.body.gitCommand, req.body.cwd);
973
+ res.json(result);
974
+ } catch (err) {
975
+ res.status(500).json({ error: err.message });
976
+ }
977
+ });
978
+
979
+ // Destroy sandbox
980
+ app.delete('/api/sandbox', authenticateToken, async (req, res) => {
981
+ try {
982
+ const result = await sandboxClient.destroySandbox(req.user.id);
983
+ res.json(result);
984
+ } catch (err) {
985
+ res.status(500).json({ error: err.message });
986
+ }
987
+ });
988
+
989
+ /**
990
+ * Detect installed AI CLI agents on the local machine (server-side).
991
+ * Used in self-hosted/local mode where no relay is needed.
992
+ */
993
+ let cachedLocalAgents = null;
994
+ let localAgentsCacheTime = 0;
995
+ function detectLocalAgents() {
996
+ // Cache for 60 seconds
997
+ if (cachedLocalAgents && Date.now() - localAgentsCacheTime < 60000) {
998
+ return cachedLocalAgents;
999
+ }
1000
+ const isWindows = process.platform === 'win32';
1001
+ const whichCmd = isWindows ? 'where' : 'which';
1002
+ const agents = [
1003
+ { name: 'claude', binary: 'claude', label: 'Claude Code' },
1004
+ { name: 'codex', binary: 'codex', label: 'OpenAI Codex' },
1005
+ { name: 'cursor', binary: 'cursor-agent', label: 'Cursor Agent' },
1006
+ ];
1007
+ const detected = {};
1008
+ for (const agent of agents) {
1009
+ try {
1010
+ const result = execSync(`${whichCmd} ${agent.binary}`, { stdio: 'pipe', timeout: 5000 }).toString().trim();
1011
+ detected[agent.name] = { installed: true, path: result.split('\n')[0].trim(), label: agent.label };
1012
+ } catch {
1013
+ detected[agent.name] = { installed: false, label: agent.label };
1014
+ }
1015
+ }
1016
+ cachedLocalAgents = detected;
1017
+ localAgentsCacheTime = Date.now();
1018
+ return detected;
1019
+ }
1020
+
1021
+ // Connection status — alias at path the frontend expects
1022
+ app.get('/api/auth/connection-status', authenticateToken, async (req, res) => {
1023
+ const relay = relayConnections.get(Number(req.user.id));
1024
+ const connected = !!(relay && relay.ws.readyState === 1);
1025
+
1026
+ // In local mode, always "connected" — SDK runs directly on this machine
1027
+ if (IS_LOCAL) {
1028
+ const agents = detectLocalAgents();
1029
+ return res.json({
1030
+ connected: true,
1031
+ local: true,
1032
+ connectedAt: Date.now(),
1033
+ agents,
1034
+ machine: {
1035
+ hostname: os.hostname(),
1036
+ platform: process.platform,
1037
+ cwd: process.cwd(),
1038
+ }
1039
+ });
1040
+ }
1041
+
1042
+ // Check sandbox service availability when no relay
1043
+ let sandboxAvailable = false;
1044
+ let sandboxInfo = null;
1045
+ if (!connected) {
1046
+ try {
1047
+ sandboxAvailable = await sandboxClient.isAvailable();
1048
+ if (sandboxAvailable) {
1049
+ sandboxInfo = await sandboxClient.getStatus(req.user.id);
1050
+ }
1051
+ } catch { /* sandbox service unreachable */ }
1052
+ }
1053
+
1054
+ res.json({
1055
+ connected,
1056
+ local: false,
1057
+ connectedAt: relay?.connectedAt || null,
1058
+ agents: connected ? (relay.agents || null) : null,
1059
+ machine: connected ? {
1060
+ hostname: relay.machine,
1061
+ platform: relay.platform,
1062
+ cwd: relay.cwd,
1063
+ version: relay.version,
1064
+ } : null,
1065
+ sandbox: {
1066
+ available: sandboxAvailable,
1067
+ active: sandboxInfo?.exists || false,
1068
+ diskUsage: sandboxInfo?.exists ? {
1069
+ usedMB: sandboxInfo.usedMB,
1070
+ maxMB: sandboxInfo.maxMB,
1071
+ } : null,
1072
+ },
1073
+ });
1074
+ });
1075
+
1076
+ // Serve public files (like api-docs.html)
1077
+ app.use(express.static(path.join(__dirname, '../client/public')));
1078
+
1079
+ // Static files served after API routes
1080
+ // Add cache control: HTML files should not be cached, but assets can be cached
1081
+ app.use(express.static(path.join(__dirname, '../client/dist'), {
1082
+ setHeaders: (res, filePath) => {
1083
+ if (filePath.endsWith('.html')) {
1084
+ // Prevent HTML caching to avoid service worker issues after builds
1085
+ res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
1086
+ res.setHeader('Pragma', 'no-cache');
1087
+ res.setHeader('Expires', '0');
1088
+ } else if (filePath.match(/\.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico)$/)) {
1089
+ // Cache static assets for 1 year (they have hashed names)
1090
+ res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
1091
+ }
1092
+ }
1093
+ }));
1094
+
1095
+ // API Routes (protected)
1096
+ // /api/config endpoint removed - no longer needed
1097
+ // Frontend now uses window.location for WebSocket URLs
1098
+
1099
+ // System update endpoint — REMOVED for security (shell command execution risk)
1100
+ // Use `uc update` from CLI instead
1101
+
1102
+ app.get('/api/projects', authenticateToken, async (req, res) => {
1103
+ try {
1104
+ const projects = await getProjects(broadcastProgress, req.user.id);
1105
+ res.json(projects);
1106
+ } catch (error) {
1107
+ res.status(500).json({ error: 'Internal server error' });
1108
+ }
1109
+ });
1110
+
1111
+ app.get('/api/projects/:projectName/sessions', authenticateToken, authorizeProject, async (req, res) => {
1112
+ try {
1113
+ const { limit = 5, offset = 0 } = req.query;
1114
+ const result = await getSessions(req.params.projectName, parseInt(limit), parseInt(offset));
1115
+ res.json(result);
1116
+ } catch (error) {
1117
+ res.status(500).json({ error: 'Internal server error' });
1118
+ }
1119
+ });
1120
+
1121
+ // Get messages for a specific session
1122
+ app.get('/api/projects/:projectName/sessions/:sessionId/messages', authenticateToken, authorizeProject, async (req, res) => {
1123
+ try {
1124
+ const { projectName, sessionId } = req.params;
1125
+ const { limit, offset } = req.query;
1126
+
1127
+ const parsedLimit = limit ? parseInt(limit, 10) : null;
1128
+ const parsedOffset = offset ? parseInt(offset, 10) : 0;
1129
+
1130
+ const result = await getSessionMessages(projectName, sessionId, parsedLimit, parsedOffset);
1131
+
1132
+ if (Array.isArray(result)) {
1133
+ res.json({ messages: result });
1134
+ } else {
1135
+ res.json(result);
1136
+ }
1137
+ } catch (error) {
1138
+ res.status(500).json({ error: 'Internal server error' });
1139
+ }
1140
+ });
1141
+
1142
+ // Rename project endpoint
1143
+ app.put('/api/projects/:projectName/rename', authenticateToken, authorizeProject, async (req, res) => {
1144
+ try {
1145
+ const { displayName } = req.body;
1146
+ await renameProject(req.params.projectName, displayName, req.user.id);
1147
+ res.json({ success: true });
1148
+ } catch (error) {
1149
+ res.status(500).json({ error: 'Internal server error' });
1150
+ }
1151
+ });
1152
+
1153
+ // Delete session endpoint
1154
+ app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken, authorizeProject, async (req, res) => {
1155
+ try {
1156
+ const { projectName, sessionId } = req.params;
1157
+ await deleteSession(projectName, sessionId);
1158
+ res.json({ success: true });
1159
+ } catch (error) {
1160
+ res.status(500).json({ error: 'Internal server error' });
1161
+ }
1162
+ });
1163
+
1164
+ // Delete project endpoint (force=true to delete with sessions)
1165
+ app.delete('/api/projects/:projectName', authenticateToken, authorizeProject, async (req, res) => {
1166
+ try {
1167
+ const { projectName } = req.params;
1168
+ const force = req.query.force === 'true';
1169
+ await deleteProject(projectName, force, req.user.id);
1170
+ res.json({ success: true });
1171
+ } catch (error) {
1172
+ res.status(500).json({ error: 'Internal server error' });
1173
+ }
1174
+ });
1175
+
1176
+ // Read CLAUDE.md for a project
1177
+ app.get('/api/projects/:projectName/claude-md', authenticateToken, authorizeProject, async (req, res) => {
1178
+ try {
1179
+ const projectDir = await extractProjectDirectory(req.params.projectName);
1180
+ if (!projectDir) {
1181
+ return res.json({ content: null, path: null, error: 'Could not resolve project path' });
1182
+ }
1183
+
1184
+ const claudeMdPath = path.join(projectDir, 'CLAUDE.md');
1185
+
1186
+ if (IS_CLOUD_ENV) {
1187
+ const userId = req.user?.id || req.user?.userId;
1188
+ if (!hasActiveRelay(Number(userId))) {
1189
+ return res.json({ content: null, path: claudeMdPath, error: 'No machine connected' });
1190
+ }
1191
+ try {
1192
+ const result = await sendRelayCommand(Number(userId), 'file-read', { filePath: claudeMdPath }, null, 10000);
1193
+ return res.json({ content: result?.data?.content || null, path: claudeMdPath });
1194
+ } catch {
1195
+ return res.json({ content: null, path: claudeMdPath });
1196
+ }
1197
+ }
1198
+
1199
+ // Local mode
1200
+ try {
1201
+ const content = await fsPromises.readFile(claudeMdPath, 'utf8');
1202
+ res.json({ content, path: claudeMdPath });
1203
+ } catch {
1204
+ res.json({ content: null, path: claudeMdPath });
1205
+ }
1206
+ } catch {
1207
+ res.json({ content: null, path: null });
1208
+ }
1209
+ });
1210
+
1211
+ // Create project endpoint
1212
+ app.post('/api/projects/create', authenticateToken, async (req, res) => {
1213
+ try {
1214
+ const { path: projectPath } = req.body;
1215
+
1216
+ if (!projectPath || !projectPath.trim()) {
1217
+ return res.status(400).json({ error: 'Project path is required' });
1218
+ }
1219
+
1220
+ const project = await addProjectManually(projectPath.trim(), null, req.user.id);
1221
+ res.json({ success: true, project });
1222
+ } catch (error) {
1223
+ // project creation error handled silently
1224
+ res.status(500).json({ error: 'Internal server error' });
1225
+ }
1226
+ });
1227
+
1228
+ // ── Gitagent API endpoints ──────────────────────────────────────────
1229
+
1230
+ // Get parsed gitagent definition + system prompt preview for a project
1231
+ app.get('/api/projects/:projectName/gitagent', authenticateToken, authorizeProject, async (req, res) => {
1232
+ try {
1233
+ const projectDir = await extractProjectDirectory(req.params.projectName);
1234
+ if (!projectDir) return res.status(404).json({ error: 'Project not found' });
1235
+
1236
+ const ctx = await loadGitagentContext(projectDir);
1237
+ if (!ctx) return res.json({ detected: false });
1238
+
1239
+ res.json({
1240
+ detected: true,
1241
+ definition: ctx.definition,
1242
+ systemPromptPreview: ctx.systemPromptAppendix,
1243
+ model: ctx.model,
1244
+ allowedTools: ctx.allowedTools,
1245
+ runtime: ctx.runtime,
1246
+ });
1247
+ } catch {
1248
+ res.status(500).json({ error: 'Internal server error' });
1249
+ }
1250
+ });
1251
+
1252
+ // Clear cache and re-parse gitagent for a project
1253
+ app.post('/api/projects/:projectName/gitagent/refresh', authenticateToken, authorizeProject, async (req, res) => {
1254
+ try {
1255
+ const projectDir = await extractProjectDirectory(req.params.projectName);
1256
+ if (!projectDir) return res.status(404).json({ error: 'Project not found' });
1257
+
1258
+ clearGitagentCache(projectDir);
1259
+ const ctx = await loadGitagentContext(projectDir);
1260
+ if (!ctx) return res.json({ detected: false });
1261
+
1262
+ res.json({
1263
+ detected: true,
1264
+ definition: ctx.definition,
1265
+ systemPromptPreview: ctx.systemPromptAppendix,
1266
+ model: ctx.model,
1267
+ allowedTools: ctx.allowedTools,
1268
+ runtime: ctx.runtime,
1269
+ });
1270
+ } catch {
1271
+ res.status(500).json({ error: 'Internal server error' });
1272
+ }
1273
+ });
1274
+
1275
+ const expandWorkspacePath = (inputPath) => {
1276
+ if (!inputPath) return inputPath;
1277
+ if (inputPath === '~') {
1278
+ return WORKSPACES_ROOT;
1279
+ }
1280
+ if (inputPath.startsWith('~/') || inputPath.startsWith('~\\')) {
1281
+ return path.join(WORKSPACES_ROOT, inputPath.slice(2));
1282
+ }
1283
+ return inputPath;
1284
+ };
1285
+
1286
+ // Browse filesystem endpoint for project suggestions
1287
+ // When relay is connected, proxies to user's local machine; otherwise uses server filesystem
1288
+ app.get('/api/browse-filesystem', authenticateToken, relayMiddleware, async (req, res) => {
1289
+ try {
1290
+ const { path: dirPath } = req.query;
1291
+
1292
+ // Cloud mode: always use relay
1293
+ if (req.isCloud) {
1294
+ if (!req.hasRelay()) {
1295
+ return res.status(503).json({
1296
+ error: 'Machine not connected',
1297
+ message: 'Run "uc connect" on your local machine to use this feature.',
1298
+ code: 'RELAY_NOT_CONNECTED'
1299
+ });
1300
+ }
1301
+ try {
1302
+ const result = await req.sendRelay('browse-dirs', { dirPath: dirPath || '~' }, 15000);
1303
+ return res.json(result);
1304
+ } catch (err) {
1305
+ return res.status(500).json({ error: err.message || 'Failed to browse filesystem via relay' });
1306
+ }
1307
+ }
1308
+
1309
+ // Local mode: if relay is connected, prefer it
1310
+ if (req.hasRelay()) {
1311
+ try {
1312
+ const result = await req.sendRelay('browse-dirs', { dirPath: dirPath || '~' }, 15000);
1313
+ return res.json(result);
1314
+ } catch (err) {
1315
+ return res.status(500).json({ error: err.message || 'Failed to browse filesystem via relay' });
1316
+ }
1317
+ }
1318
+
1319
+ // Fallback: browse server filesystem (local/self-hosted mode)
1320
+ const defaultRoot = WORKSPACES_ROOT || os.homedir();
1321
+ let targetPath = dirPath ? expandWorkspacePath(dirPath) : defaultRoot;
1322
+
1323
+ // Resolve and normalize the path
1324
+ targetPath = path.resolve(targetPath);
1325
+
1326
+ // Security check - ensure path is within allowed workspace root
1327
+ const validation = await validateWorkspacePath(targetPath);
1328
+ if (!validation.valid) {
1329
+ return res.status(403).json({ error: validation.error });
1330
+ }
1331
+ const resolvedPath = validation.resolvedPath || targetPath;
1332
+
1333
+ // Security check - ensure path is accessible
1334
+ try {
1335
+ await fs.promises.access(resolvedPath);
1336
+ const stats = await fs.promises.stat(resolvedPath);
1337
+
1338
+ if (!stats.isDirectory()) {
1339
+ return res.status(400).json({ error: 'Path is not a directory' });
1340
+ }
1341
+ } catch (err) {
1342
+ return res.status(404).json({ error: 'Directory not accessible' });
1343
+ }
1344
+
1345
+ // Use existing getFileTree function with shallow depth (only direct children)
1346
+ const fileTree = await getFileTree(resolvedPath, 1, 0, false); // maxDepth=1, showHidden=false
1347
+
1348
+ // Filter only directories and format for suggestions
1349
+ const directories = fileTree
1350
+ .filter(item => item.type === 'directory')
1351
+ .map(item => ({
1352
+ path: item.path,
1353
+ name: item.name,
1354
+ type: 'directory'
1355
+ }))
1356
+ .sort((a, b) => {
1357
+ const aHidden = a.name.startsWith('.');
1358
+ const bHidden = b.name.startsWith('.');
1359
+ if (aHidden && !bHidden) return 1;
1360
+ if (!aHidden && bHidden) return -1;
1361
+ return a.name.localeCompare(b.name);
1362
+ });
1363
+
1364
+ // Add common directories if browsing home directory
1365
+ const suggestions = [];
1366
+ let resolvedWorkspaceRoot = defaultRoot;
1367
+ try {
1368
+ resolvedWorkspaceRoot = await fsPromises.realpath(defaultRoot);
1369
+ } catch (error) {
1370
+ // Use default root as-is if realpath fails
1371
+ }
1372
+ if (resolvedPath === resolvedWorkspaceRoot) {
1373
+ const commonDirs = ['Desktop', 'Documents', 'Projects', 'Development', 'Dev', 'Code', 'workspace'];
1374
+ const existingCommon = directories.filter(dir => commonDirs.includes(dir.name));
1375
+ const otherDirs = directories.filter(dir => !commonDirs.includes(dir.name));
1376
+
1377
+ suggestions.push(...existingCommon, ...otherDirs);
1378
+ } else {
1379
+ suggestions.push(...directories);
1380
+ }
1381
+
1382
+ res.json({
1383
+ path: resolvedPath,
1384
+ suggestions: suggestions
1385
+ });
1386
+
1387
+ } catch (error) {
1388
+ // filesystem browse error handled silently
1389
+ res.status(500).json({ error: 'Failed to browse filesystem' });
1390
+ }
1391
+ });
1392
+
1393
+ app.post('/api/create-folder', authenticateToken, relayMiddleware, async (req, res) => {
1394
+ try {
1395
+ const { path: folderPath } = req.body;
1396
+ if (!folderPath) {
1397
+ return res.status(400).json({ error: 'Path is required' });
1398
+ }
1399
+
1400
+ // Cloud mode: create folder via relay on user's machine
1401
+ if (req.isCloud) {
1402
+ if (!req.requireRelay()) return;
1403
+ try {
1404
+ const result = await req.sendRelay('create-folder', { folderPath }, 15000);
1405
+ return res.json(result);
1406
+ } catch (err) {
1407
+ return res.status(500).json({ error: err.message || 'Failed to create folder via relay' });
1408
+ }
1409
+ }
1410
+
1411
+ // Local mode
1412
+ const expandedPath = expandWorkspacePath(folderPath);
1413
+ const resolvedInput = path.resolve(expandedPath);
1414
+ const validation = await validateWorkspacePath(resolvedInput);
1415
+ if (!validation.valid) {
1416
+ return res.status(403).json({ error: validation.error });
1417
+ }
1418
+ const targetPath = validation.resolvedPath || resolvedInput;
1419
+ const parentDir = path.dirname(targetPath);
1420
+ try {
1421
+ await fs.promises.access(parentDir);
1422
+ } catch (err) {
1423
+ return res.status(404).json({ error: 'Parent directory does not exist' });
1424
+ }
1425
+ try {
1426
+ await fs.promises.access(targetPath);
1427
+ return res.status(409).json({ error: 'Folder already exists' });
1428
+ } catch (err) {
1429
+ // Folder doesn't exist, which is what we want
1430
+ }
1431
+ try {
1432
+ await fs.promises.mkdir(targetPath, { recursive: false });
1433
+ res.json({ success: true, path: targetPath });
1434
+ } catch (mkdirError) {
1435
+ if (mkdirError.code === 'EEXIST') {
1436
+ return res.status(409).json({ error: 'Folder already exists' });
1437
+ }
1438
+ throw mkdirError;
1439
+ }
1440
+ } catch (error) {
1441
+ // folder creation error handled silently
1442
+ res.status(500).json({ error: 'Failed to create folder' });
1443
+ }
1444
+ });
1445
+
1446
+ // Read file content endpoint
1447
+ app.get('/api/projects/:projectName/file', authenticateToken, authorizeProject, relayMiddleware, async (req, res) => {
1448
+ try {
1449
+ const { projectName } = req.params;
1450
+ const { filePath } = req.query;
1451
+
1452
+ if (!filePath) {
1453
+ return res.status(400).json({ error: 'Invalid file path' });
1454
+ }
1455
+
1456
+ const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
1457
+ if (!projectRoot) {
1458
+ return res.status(404).json({ error: 'Project not found' });
1459
+ }
1460
+
1461
+ // Cloud mode: read file via relay on user's machine
1462
+ if (req.isCloud) {
1463
+ if (!req.requireRelay()) return;
1464
+ try {
1465
+ // Construct full path: project root + relative file path
1466
+ const fullPath = filePath.startsWith('/') || /^[A-Za-z]:/.test(filePath)
1467
+ ? filePath
1468
+ : `${projectRoot}/${filePath}`;
1469
+ const result = await req.sendRelay('file-read', { filePath: fullPath }, 15000);
1470
+ return res.json({ content: result.content, path: fullPath });
1471
+ } catch (err) {
1472
+ if (err.message?.includes('ENOENT') || err.message?.includes('not found')) {
1473
+ return res.status(404).json({ error: 'File not found' });
1474
+ }
1475
+ return res.status(500).json({ error: err.message || 'Failed to read file via relay' });
1476
+ }
1477
+ }
1478
+
1479
+ // Local mode
1480
+ const resolved = path.resolve(projectRoot, filePath);
1481
+ const normalizedRoot = path.resolve(projectRoot) + path.sep;
1482
+ if (!resolved.startsWith(normalizedRoot) && resolved !== path.resolve(projectRoot)) {
1483
+ return res.status(403).json({ error: 'Access denied' });
1484
+ }
1485
+
1486
+ const content = await fsPromises.readFile(resolved, 'utf8');
1487
+ res.json({ content, path: resolved });
1488
+ } catch (error) {
1489
+ if (error.code === 'ENOENT') {
1490
+ res.status(404).json({ error: 'File not found' });
1491
+ } else if (error.code === 'EACCES') {
1492
+ res.status(403).json({ error: 'Permission denied' });
1493
+ } else {
1494
+ res.status(500).json({ error: 'Internal server error' });
1495
+ }
1496
+ }
1497
+ });
1498
+
1499
+ // Serve binary file content endpoint (for images, etc.)
1500
+ app.get('/api/projects/:projectName/files/content', authenticateToken, authorizeProject, relayMiddleware, async (req, res) => {
1501
+ try {
1502
+ const { projectName } = req.params;
1503
+ const { path: filePath } = req.query;
1504
+
1505
+ if (!filePath) {
1506
+ return res.status(400).json({ error: 'Invalid file path' });
1507
+ }
1508
+
1509
+ const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
1510
+ if (!projectRoot) {
1511
+ return res.status(404).json({ error: 'Project not found' });
1512
+ }
1513
+
1514
+ // Cloud mode: read binary file via relay (base64 encoded)
1515
+ if (req.isCloud) {
1516
+ if (!req.requireRelay()) return;
1517
+ try {
1518
+ const fullPath = filePath.startsWith('/') || /^[A-Za-z]:/.test(filePath)
1519
+ ? filePath
1520
+ : `${projectRoot}/${filePath}`;
1521
+ const result = await req.sendRelay('file-read', { filePath: fullPath, encoding: 'base64' }, 30000);
1522
+ const mimeType = mime.lookup(fullPath) || 'application/octet-stream';
1523
+ res.setHeader('Content-Type', mimeType);
1524
+ return res.send(Buffer.from(result.content, 'base64'));
1525
+ } catch (err) {
1526
+ if (err.message?.includes('ENOENT') || err.message?.includes('not found')) {
1527
+ return res.status(404).json({ error: 'File not found' });
1528
+ }
1529
+ return res.status(500).json({ error: err.message || 'Failed to read file via relay' });
1530
+ }
1531
+ }
1532
+
1533
+ // Local mode
1534
+ const resolved = path.resolve(projectRoot, filePath);
1535
+ const normalizedRoot = path.resolve(projectRoot) + path.sep;
1536
+ if (!resolved.startsWith(normalizedRoot) && resolved !== path.resolve(projectRoot)) {
1537
+ return res.status(403).json({ error: 'Access denied' });
1538
+ }
1539
+
1540
+ try {
1541
+ await fsPromises.access(resolved);
1542
+ } catch (error) {
1543
+ return res.status(404).json({ error: 'File not found' });
1544
+ }
1545
+
1546
+ const mimeType = mime.lookup(resolved) || 'application/octet-stream';
1547
+ res.setHeader('Content-Type', mimeType);
1548
+
1549
+ const fileStream = fs.createReadStream(resolved);
1550
+ fileStream.pipe(res);
1551
+
1552
+ fileStream.on('error', (error) => {
1553
+ if (!res.headersSent) {
1554
+ res.status(500).json({ error: 'Error reading file' });
1555
+ }
1556
+ });
1557
+
1558
+ } catch (error) {
1559
+ if (!res.headersSent) {
1560
+ res.status(500).json({ error: 'Internal server error' });
1561
+ }
1562
+ }
1563
+ });
1564
+
1565
+ // Save file content endpoint
1566
+ app.put('/api/projects/:projectName/file', authenticateToken, authorizeProject, relayMiddleware, async (req, res) => {
1567
+ try {
1568
+ const { projectName } = req.params;
1569
+ const { filePath, content } = req.body;
1570
+
1571
+ if (!filePath) {
1572
+ return res.status(400).json({ error: 'Invalid file path' });
1573
+ }
1574
+
1575
+ if (content === undefined) {
1576
+ return res.status(400).json({ error: 'Content is required' });
1577
+ }
1578
+
1579
+ const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
1580
+ if (!projectRoot) {
1581
+ return res.status(404).json({ error: 'Project not found' });
1582
+ }
1583
+
1584
+ // Cloud mode: write file via relay on user's machine
1585
+ if (req.isCloud) {
1586
+ if (!req.requireRelay()) return;
1587
+ try {
1588
+ const fullPath = filePath.startsWith('/') || /^[A-Za-z]:/.test(filePath)
1589
+ ? filePath
1590
+ : `${projectRoot}/${filePath}`;
1591
+ const result = await req.sendRelay('file-write', { filePath: fullPath, content }, 15000);
1592
+ // Track file version in Turso
1593
+ try { await fileVersionDb.save(req.user.userId || req.user.id, projectName, filePath, content, 'edit'); } catch { /* non-critical */ }
1594
+ return res.json({ success: true, path: fullPath, message: 'File saved successfully' });
1595
+ } catch (err) {
1596
+ return res.status(500).json({ error: err.message || 'Failed to write file via relay' });
1597
+ }
1598
+ }
1599
+
1600
+ // Local mode
1601
+ const resolved = path.resolve(projectRoot, filePath);
1602
+ const normalizedRoot = path.resolve(projectRoot) + path.sep;
1603
+ if (!resolved.startsWith(normalizedRoot) && resolved !== path.resolve(projectRoot)) {
1604
+ return res.status(403).json({ error: 'Access denied' });
1605
+ }
1606
+
1607
+ await fsPromises.writeFile(resolved, content, 'utf8');
1608
+ // Track file version
1609
+ try { await fileVersionDb.save(req.user?.userId || req.user?.id || 'local', projectName, filePath, content, 'edit'); } catch { /* non-critical */ }
1610
+
1611
+ res.json({
1612
+ success: true,
1613
+ path: resolved,
1614
+ message: 'File saved successfully'
1615
+ });
1616
+ } catch (error) {
1617
+ if (error.code === 'ENOENT') {
1618
+ res.status(404).json({ error: 'File or directory not found' });
1619
+ } else if (error.code === 'EACCES') {
1620
+ res.status(403).json({ error: 'Permission denied' });
1621
+ } else {
1622
+ res.status(500).json({ error: 'Internal server error' });
1623
+ }
1624
+ }
1625
+ });
1626
+
1627
+ // File version history endpoint
1628
+ app.get('/api/projects/:projectName/files/versions', authenticateToken, authorizeProject, async (req, res) => {
1629
+ try {
1630
+ const { projectName } = req.params;
1631
+ const { path: filePath, limit } = req.query;
1632
+ if (filePath) {
1633
+ const versions = await fileVersionDb.getVersions(projectName, filePath, parseInt(limit) || 20);
1634
+ return res.json({ versions });
1635
+ }
1636
+ // No path — return all files with versions in this project
1637
+ const files = await fileVersionDb.getSessionFiles(projectName);
1638
+ res.json({ files });
1639
+ } catch (error) {
1640
+ res.status(500).json({ error: 'Internal server error' });
1641
+ }
1642
+ });
1643
+
1644
+ // Usage stats endpoint
1645
+ app.get('/api/usage', authenticateToken, async (req, res) => {
1646
+ try {
1647
+ const userId = req.user.userId || req.user.id;
1648
+ const days = parseInt(req.query.days) || 30;
1649
+ const usage = await sessionUsageDb.getUserUsage(userId, days);
1650
+ const sessions = await sessionUsageDb.getUserSessions(userId, parseInt(req.query.limit) || 20);
1651
+ res.json({ usage, sessions });
1652
+ } catch (error) {
1653
+ res.status(500).json({ error: 'Internal server error' });
1654
+ }
1655
+ });
1656
+
1657
+ app.get('/api/projects/:projectName/files', authenticateToken, authorizeProject, relayMiddleware, async (req, res) => {
1658
+ try {
1659
+ let actualPath;
1660
+ try {
1661
+ actualPath = await extractProjectDirectory(req.params.projectName);
1662
+ } catch (error) {
1663
+ actualPath = req.params.projectName.replace(/-/g, '/');
1664
+ }
1665
+
1666
+ // Cloud mode: get file tree via relay on user's machine
1667
+ if (req.isCloud) {
1668
+ if (!req.requireRelay()) return;
1669
+ try {
1670
+ // In cloud mode, actualPath comes from extractProjectDirectory (which stores the original
1671
+ // Windows/Unix path from when the project was added via relay CWD). If that failed and
1672
+ // fell back to dash-replacement, use the relay connection's CWD as a better fallback.
1673
+ const relay = relayConnections.get(Number(req.user?.id));
1674
+ const relayCwd = relay?.cwd;
1675
+ const dirPath = actualPath && actualPath !== req.params.projectName.replace(/-/g, '/')
1676
+ ? actualPath
1677
+ : (relayCwd || actualPath);
1678
+ const result = await req.sendRelay('file-tree', { dirPath, maxDepth: 10 }, 30000);
1679
+ return res.json(result.files || result);
1680
+ } catch (err) {
1681
+ return res.status(500).json({ error: err.message || 'Failed to get file tree via relay' });
1682
+ }
1683
+ }
1684
+
1685
+ // Local mode
1686
+ try {
1687
+ await fsPromises.access(actualPath);
1688
+ } catch (e) {
1689
+ return res.status(404).json({ error: `Project path not found: ${actualPath}` });
1690
+ }
1691
+
1692
+ const files = await getFileTree(actualPath, 10, 0, true);
1693
+ res.json(files);
1694
+ } catch (error) {
1695
+ res.status(500).json({ error: 'Internal server error' });
1696
+ }
1697
+ });
1698
+
1699
+ // WebSocket connection handler that routes based on URL path (skip on Vercel)
1700
+ if (wss) wss.on('connection', (ws, request) => {
1701
+ const url = request.url;
1702
+ // Client connected
1703
+
1704
+ // Parse URL to get pathname without query parameters
1705
+ const urlObj = new URL(url, 'http://localhost');
1706
+ const pathname = urlObj.pathname;
1707
+
1708
+ if (pathname === '/shell') {
1709
+ handleShellConnection(ws, request);
1710
+ } else if (pathname === '/ws') {
1711
+ handleChatConnection(ws, request);
1712
+ } else if (pathname === '/relay') {
1713
+ handleRelayConnection(ws, urlObj.searchParams.get('token'), request);
1714
+ } else {
1715
+ // unknown WebSocket path
1716
+ ws.close();
1717
+ }
1718
+ });
1719
+
1720
+ /**
1721
+ * WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface
1722
+ */
1723
+ class WebSocketWriter {
1724
+ constructor(ws) {
1725
+ this.ws = ws;
1726
+ this.sessionId = null;
1727
+ this.isWebSocketWriter = true; // Marker for transport detection
1728
+ }
1729
+
1730
+ send(data) {
1731
+ if (this.ws.readyState === 1) { // WebSocket.OPEN
1732
+ // Providers send raw objects, we stringify for WebSocket
1733
+ this.ws.send(JSON.stringify(data));
1734
+ }
1735
+ }
1736
+
1737
+ setSessionId(sessionId) {
1738
+ this.sessionId = sessionId;
1739
+ }
1740
+
1741
+ getSessionId() {
1742
+ return this.sessionId;
1743
+ }
1744
+ }
1745
+
1746
+ /**
1747
+ * Look up a user's stored API key for a given provider.
1748
+ * Falls back to server env vars if user has none stored.
1749
+ * @param {number} userId
1750
+ * @param {string} providerType - e.g. 'anthropic_key', 'openai_key', 'openrouter_key', 'google_key'
1751
+ * @returns {Promise<string|null>}
1752
+ */
1753
+ async function getUserProviderKey(userId, providerType) {
1754
+ if (!userId) return null;
1755
+ try {
1756
+ const creds = await credentialsDb.getCredentials(userId, providerType);
1757
+ const active = creds.find(c => c.is_active);
1758
+ return active?.credential_value || null;
1759
+ } catch { return null; }
1760
+ }
1761
+
1762
+ /**
1763
+ * Temporarily set environment variable for an AI SDK call, then restore.
1764
+ * @param {string} envKey - e.g. 'ANTHROPIC_API_KEY'
1765
+ * @param {string|null} userKey - user's BYOK key, null to skip
1766
+ * @param {Function} fn - async function to execute with the key set
1767
+ */
1768
+ async function withUserApiKey(envKey, userKey, fn) {
1769
+ if (!userKey) return fn();
1770
+ const prev = process.env[envKey];
1771
+ process.env[envKey] = userKey;
1772
+ try {
1773
+ return await fn();
1774
+ } finally {
1775
+ if (prev !== undefined) process.env[envKey] = prev;
1776
+ else delete process.env[envKey];
1777
+ }
1778
+ }
1779
+
1780
+ // Handle chat WebSocket connections
1781
+ function handleChatConnection(ws, request) {
1782
+ // chat WebSocket connected
1783
+ const wsUser = request?.user || null;
1784
+
1785
+ // Add to connected clients for project updates + tag with user for permission routing
1786
+ ws._wsUser = wsUser;
1787
+ connectedClients.add(ws);
1788
+
1789
+ // Wrap WebSocket with writer for consistent interface with SSEStreamWriter
1790
+ const writer = new WebSocketWriter(ws);
1791
+
1792
+ // Track which sessions this WebSocket has locked
1793
+ const lockedSessionsForThisWs = new Set();
1794
+
1795
+ // Track usage per-query for sessionUsageDb
1796
+ let lastTokenBudget = null;
1797
+ let lastCompletedSessionId = null;
1798
+
1799
+ // Wrap the original writer.send to capture session-created events for auto-locking
1800
+ const originalSend = writer.send.bind(writer);
1801
+ writer.send = (data) => {
1802
+ // When a new session is created, auto-lock it to this WebSocket + track ownership
1803
+ if (data.type === 'session-created' && data.sessionId) {
1804
+ sessionLocks.set(data.sessionId, ws);
1805
+ lockedSessionsForThisWs.add(data.sessionId);
1806
+ if (wsUser?.userId) sessionOwners.set(data.sessionId, wsUser.userId);
1807
+ }
1808
+ // Capture token usage for tracking
1809
+ if (data.type === 'token-budget' && data.data) {
1810
+ lastTokenBudget = data.data;
1811
+ if (data.sessionId) lastCompletedSessionId = data.sessionId;
1812
+ }
1813
+ originalSend(data);
1814
+ };
1815
+
1816
+ ws.on('message', async (message) => {
1817
+ try {
1818
+ const data = JSON.parse(message);
1819
+
1820
+ // Handle session lock request (tab claiming a session)
1821
+ if (data.type === 'lock-session') {
1822
+ const sid = data.sessionId;
1823
+ if (!sid) return;
1824
+ if (acquireSessionLock(sid, ws)) {
1825
+ lockedSessionsForThisWs.add(sid);
1826
+ writer.send({ type: 'session-locked', sessionId: sid, success: true });
1827
+ // session locked to tab
1828
+ } else {
1829
+ writer.send({ type: 'session-locked', sessionId: sid, success: false,
1830
+ error: 'Session is already open in another tab' });
1831
+ // session lock denied
1832
+ }
1833
+ return;
1834
+ }
1835
+
1836
+ // Handle session unlock request
1837
+ if (data.type === 'unlock-session') {
1838
+ const sid = data.sessionId;
1839
+ if (sid) {
1840
+ releaseSessionLock(sid, ws);
1841
+ lockedSessionsForThisWs.delete(sid);
1842
+ writer.send({ type: 'session-unlocked', sessionId: sid });
1843
+ // session unlocked
1844
+ }
1845
+ return;
1846
+ }
1847
+
1848
+ if (data.type === 'shell-exec') {
1849
+ const { command, cwd } = data;
1850
+ if (!command) {
1851
+ writer.send({ type: 'shell-exec-output', output: 'No command provided.', exitCode: 1, status: 'error' });
1852
+ return;
1853
+ }
1854
+
1855
+ if (hasActiveRelay(wsUser?.userId)) {
1856
+ try {
1857
+ const result = await sendRelayCommand(Number(wsUser.userId), 'exec', { command, cwd }, null, 30000);
1858
+ const output = result?.data?.stdout || result?.data?.output || result?.data?.stderr || '';
1859
+ writer.send({ type: 'shell-exec-output', output, exitCode: result?.data?.exitCode ?? 0, status: 'complete' });
1860
+ } catch (err) {
1861
+ writer.send({ type: 'shell-exec-output', output: err.message || 'Command execution failed.', exitCode: 1, status: 'error' });
1862
+ }
1863
+ } else {
1864
+ writer.send({ type: 'shell-exec-output', output: 'No machine connected. Use `uc connect` to bridge your local machine.', exitCode: 1, status: 'error' });
1865
+ }
1866
+ return;
1867
+ }
1868
+
1869
+ if (data.type === 'claude-command') {
1870
+ const sid = data.options?.sessionId;
1871
+
1872
+ // Session-tab locking: check if this session is locked by another tab
1873
+ if (sid && !acquireSessionLock(sid, ws)) {
1874
+ writer.send({
1875
+ type: 'session-lock-denied',
1876
+ sessionId: sid,
1877
+ error: 'This session is already active in another tab. Close it there first.'
1878
+ });
1879
+ return;
1880
+ }
1881
+ if (sid) lockedSessionsForThisWs.add(sid);
1882
+
1883
+ // Session busy detection (opencode pattern: prevent duplicate requests)
1884
+ if (sid && !markSessionBusy(sid)) {
1885
+ writer.send({
1886
+ type: 'session-busy',
1887
+ sessionId: sid,
1888
+ error: 'This session is already processing a request. Wait for it to finish or abort it first.'
1889
+ });
1890
+ return;
1891
+ }
1892
+
1893
+ try {
1894
+ // Check if user has active relay → route to local machine
1895
+ if (hasActiveRelay(wsUser?.userId)) {
1896
+ // Lightweight gitagent check — 1.5s timeout, non-blocking on failure
1897
+ if (data.options?.cwd) {
1898
+ try {
1899
+ const gaResult = await sendRelayCommand(Number(wsUser.userId), 'gitagent-parse', { projectPath: data.options.cwd }, null, 1500);
1900
+ if (gaResult?.data?.definition) {
1901
+ const appendix = buildSystemPromptAppendix(gaResult.data.definition);
1902
+ data.command = `<gitagent-context>\n${appendix}\n</gitagent-context>\n\n${data.command}`;
1903
+ }
1904
+ } catch { /* not gitagent or timed out — proceed without delay */ }
1905
+ }
1906
+ await routeViaRelay(wsUser.userId, 'claude-query', data, writer, {
1907
+ response: 'claude-response',
1908
+ complete: 'claude-complete',
1909
+ error: 'claude-error'
1910
+ });
1911
+ } else {
1912
+ // No relay — try sandbox fallback, then server-side SDK
1913
+ try {
1914
+ const sandboxHandled = wsUser?.userId
1915
+ ? await handleSandboxWebSocketCommand(wsUser.userId, 'claude-command', { ...data, sessionId: data.options?.sessionId }, writer)
1916
+ : false;
1917
+
1918
+ if (!sandboxHandled) {
1919
+ // Fall back to server-side SDK
1920
+ const userAnthropicKey = wsUser?.userId
1921
+ ? await getUserProviderKey(wsUser.userId, 'anthropic_key')
1922
+ : null;
1923
+
1924
+ await withUserApiKey('ANTHROPIC_API_KEY', userAnthropicKey, () =>
1925
+ queryClaudeSDK(data.command, data.options, writer)
1926
+ );
1927
+ }
1928
+ } catch {
1929
+ // SDK fallback failed — send claude-error (not generic error)
1930
+ // so the frontend stops the spinner instead of hanging forever
1931
+ writer.send({
1932
+ type: 'claude-error',
1933
+ error: 'No machine connected and server-side AI is unavailable. Please connect with "uc connect" and try again.',
1934
+ sessionId: sid
1935
+ });
1936
+ }
1937
+ }
1938
+ } finally {
1939
+ markSessionFree(sid);
1940
+ // Track session usage in Turso
1941
+ if (wsUser?.userId && lastTokenBudget) {
1942
+ const usageSessionId = lastCompletedSessionId || sid || 'unknown';
1943
+ try {
1944
+ await sessionUsageDb.upsert(wsUser.userId, usageSessionId, {
1945
+ provider: 'claude',
1946
+ promptTokens: lastTokenBudget.used || 0,
1947
+ completionTokens: 0,
1948
+ costCents: 0,
1949
+ model: data.options?.model || null,
1950
+ });
1951
+ } catch { /* non-critical */ }
1952
+ lastTokenBudget = null;
1953
+ lastCompletedSessionId = null;
1954
+ }
1955
+ }
1956
+ } else if (data.type === 'cursor-command') {
1957
+ // Check if user has active relay → route to local machine
1958
+ if (hasActiveRelay(wsUser?.userId)) {
1959
+ await routeViaRelay(wsUser.userId, 'cursor-query', data, writer, {
1960
+ response: 'cursor-response',
1961
+ complete: 'cursor-complete',
1962
+ error: 'cursor-error'
1963
+ });
1964
+ } else {
1965
+ await spawnCursor(data.command, data.options, writer);
1966
+ }
1967
+ } else if (data.type === 'codex-command') {
1968
+ // Check if user has active relay → route to local machine
1969
+ if (hasActiveRelay(wsUser?.userId)) {
1970
+ await routeViaRelay(wsUser.userId, 'codex-query', data, writer, {
1971
+ response: 'codex-response',
1972
+ complete: 'codex-complete',
1973
+ error: 'codex-error'
1974
+ });
1975
+ } else {
1976
+ const userOpenaiKey = wsUser?.userId
1977
+ ? await getUserProviderKey(wsUser.userId, 'openai_key')
1978
+ : null;
1979
+
1980
+ await withUserApiKey('OPENAI_API_KEY', userOpenaiKey, () =>
1981
+ queryCodex(data.command, data.options, writer)
1982
+ );
1983
+ }
1984
+ } else if (data.type === 'openrouter-command') {
1985
+ // BYOK: OpenRouter requires user's own API key
1986
+ const userOrKey = wsUser?.userId
1987
+ ? await getUserProviderKey(wsUser.userId, 'openrouter_key')
1988
+ : null;
1989
+
1990
+ await queryOpenRouter(data.command, {
1991
+ ...data.options,
1992
+ apiKey: userOrKey,
1993
+ }, writer);
1994
+ } else if (data.type === 'cursor-resume') {
1995
+ // Backward compatibility: treat as cursor-command with resume and no prompt
1996
+ // cursor resume session
1997
+ await spawnCursor('', {
1998
+ sessionId: data.sessionId,
1999
+ resume: true,
2000
+ cwd: data.options?.cwd
2001
+ }, writer);
2002
+ } else if (data.type === 'abort-session') {
2003
+ // abort session request
2004
+ const provider = data.provider || 'claude';
2005
+ let success;
2006
+
2007
+ // Check if this is a relay session first (opencode pattern: cancel propagation)
2008
+ const relayInfo = relaySessionRequests.get(data.sessionId);
2009
+ if (relayInfo) {
2010
+ const relay = relayConnections.get(relayInfo.userId);
2011
+ if (relay && relay.ws.readyState === 1) {
2012
+ // Forward abort to CLI via relay WebSocket
2013
+ relay.ws.send(JSON.stringify({
2014
+ type: 'relay-abort',
2015
+ requestId: relayInfo.requestId,
2016
+ }));
2017
+ // Clean up pending relay request
2018
+ const pending = pendingRelayRequests.get(relayInfo.requestId);
2019
+ if (pending) {
2020
+ clearTimeout(pending.timeout);
2021
+ pending.resolve({ exitCode: -1, aborted: true });
2022
+ pendingRelayRequests.delete(relayInfo.requestId);
2023
+ }
2024
+ relaySessionRequests.delete(data.sessionId);
2025
+ success = true;
2026
+ } else {
2027
+ success = false;
2028
+ }
2029
+ } else if (provider === 'cursor') {
2030
+ success = abortCursorSession(data.sessionId);
2031
+ } else if (provider === 'codex') {
2032
+ success = abortCodexSession(data.sessionId);
2033
+ } else {
2034
+ // Use Claude Agents SDK
2035
+ success = await abortClaudeSDKSession(data.sessionId);
2036
+ }
2037
+
2038
+ writer.send({
2039
+ type: 'session-aborted',
2040
+ sessionId: data.sessionId,
2041
+ provider,
2042
+ success
2043
+ });
2044
+ } else if (data.type === 'claude-permission-response') {
2045
+ // Relay UI approval decisions back into the SDK control flow.
2046
+ // This does not persist permissions; it only resolves the in-flight request,
2047
+ // introduced so the SDK can resume once the user clicks Allow/Deny.
2048
+ if (data.requestId) {
2049
+ resolveToolApproval(data.requestId, {
2050
+ allow: Boolean(data.allow),
2051
+ updatedInput: data.updatedInput,
2052
+ message: data.message,
2053
+ rememberEntry: data.rememberEntry
2054
+ });
2055
+ }
2056
+ } else if (data.type === 'relay-permission-response') {
2057
+ // Forward permission response from browser → CLI relay (opencode pattern)
2058
+ if (wsUser?.userId && hasActiveRelay(wsUser.userId)) {
2059
+ const relay = relayConnections.get(Number(wsUser.userId));
2060
+ if (relay?.ws?.readyState === 1) {
2061
+ relay.ws.send(JSON.stringify({
2062
+ type: 'relay-permission-response',
2063
+ permissionId: data.permissionId,
2064
+ approved: data.approved,
2065
+ }));
2066
+ }
2067
+ }
2068
+ } else if (data.type === 'cursor-abort') {
2069
+ // abort cursor session
2070
+ const success = abortCursorSession(data.sessionId);
2071
+ writer.send({
2072
+ type: 'session-aborted',
2073
+ sessionId: data.sessionId,
2074
+ provider: 'cursor',
2075
+ success
2076
+ });
2077
+ } else if (data.type === 'check-session-status') {
2078
+ // Check if a specific session is currently processing
2079
+ const provider = data.provider || 'claude';
2080
+ const sessionId = data.sessionId;
2081
+ let isActive;
2082
+
2083
+ if (provider === 'cursor') {
2084
+ isActive = isCursorSessionActive(sessionId);
2085
+ } else if (provider === 'codex') {
2086
+ isActive = isCodexSessionActive(sessionId);
2087
+ } else {
2088
+ // Use Claude Agents SDK
2089
+ isActive = isClaudeSDKSessionActive(sessionId);
2090
+ }
2091
+
2092
+ writer.send({
2093
+ type: 'session-status',
2094
+ sessionId,
2095
+ provider,
2096
+ isProcessing: isActive
2097
+ });
2098
+ } else if (data.type === 'get-active-sessions') {
2099
+ // Get all currently active sessions
2100
+ const activeSessions = {
2101
+ claude: getActiveClaudeSDKSessions(),
2102
+ cursor: getActiveCursorSessions(),
2103
+ codex: getActiveCodexSessions()
2104
+ };
2105
+ writer.send({
2106
+ type: 'active-sessions',
2107
+ sessions: activeSessions
2108
+ });
2109
+ } else if (data.type === 'subscribe-session') {
2110
+ // Multi-tab read access: subscribe to session output without locking
2111
+ const sid = data.sessionId;
2112
+ if (sid) {
2113
+ subscribeToSession(sid, ws);
2114
+ writer.send({ type: 'session-subscribed', sessionId: sid });
2115
+ }
2116
+ } else if (data.type === 'unsubscribe-session') {
2117
+ const sid = data.sessionId;
2118
+ if (sid) {
2119
+ unsubscribeFromSession(sid, ws);
2120
+ writer.send({ type: 'session-unsubscribed', sessionId: sid });
2121
+ }
2122
+ }
2123
+ } catch (error) {
2124
+ // chat WebSocket error
2125
+ writer.send({
2126
+ type: 'error',
2127
+ error: 'An unexpected error occurred'
2128
+ });
2129
+ }
2130
+ });
2131
+
2132
+ ws.on('close', () => {
2133
+ // Chat client disconnected
2134
+ // Release all session locks held by this WebSocket
2135
+ releaseAllLocksForWs(ws);
2136
+ unsubscribeAllForWs(ws);
2137
+ // Remove from connected clients
2138
+ connectedClients.delete(ws);
2139
+ });
2140
+ }
2141
+
2142
+ // Handle relay WebSocket connections (local machine ↔ server bridge)
2143
+ async function handleRelayConnection(ws, token, request) {
2144
+ if (!token) {
2145
+ ws.send(JSON.stringify({ type: 'error', error: 'Relay token required. Use ?token=upfyn_xxx' }));
2146
+ ws.close();
2147
+ return;
2148
+ }
2149
+
2150
+ // Extract and pin client IP for this relay session
2151
+ const clientIp = extractClientIp(request);
2152
+
2153
+ const tokenData = await relayTokensDb.validateToken(token, clientIp);
2154
+ if (!tokenData) {
2155
+ ws.send(JSON.stringify({ type: 'error', error: 'Invalid or expired relay token' }));
2156
+ ws.close();
2157
+ return;
2158
+ }
2159
+
2160
+ const userId = Number(tokenData.user_id);
2161
+ const username = tokenData.username;
2162
+
2163
+ // Reject if another relay is already connected for this user from a DIFFERENT IP
2164
+ // This prevents session hijacking — only the original machine can hold the relay
2165
+ const existingRelay = relayConnections.get(userId);
2166
+ if (existingRelay && existingRelay.ws.readyState === 1 && existingRelay.clientIp !== clientIp) {
2167
+ ws.send(JSON.stringify({
2168
+ type: 'error',
2169
+ error: 'Another machine is already connected for this account. Disconnect it first or use a different token.'
2170
+ }));
2171
+ ws.close();
2172
+ return;
2173
+ }
2174
+
2175
+ // Extract optional headers from relay handshake
2176
+ const anthropicApiKey = request?.headers?.['x-anthropic-api-key'] || null;
2177
+ const relayVersion = request?.headers?.['x-upfyn-version'] || null;
2178
+ const relayMachine = request?.headers?.['x-upfyn-machine'] || null;
2179
+ const relayPlatform = request?.headers?.['x-upfyn-platform'] || null;
2180
+ const relayCwd = request?.headers?.['x-upfyn-cwd'] || null;
2181
+
2182
+ // Store relay connection with IP pinning — all commands must originate from authenticated user
2183
+ // API key is held per-user in the relay connection, NOT in process.env
2184
+ relayConnections.set(userId, {
2185
+ ws, user: tokenData, connectedAt: Date.now(), anthropicApiKey,
2186
+ version: relayVersion, machine: relayMachine, platform: relayPlatform, cwd: relayCwd,
2187
+ agents: null, // populated when client sends agent-capabilities
2188
+ lastPong: Date.now(),
2189
+ clientIp, // Pinned IP — used for security validation
2190
+ tokenId: tokenData.id, // Token ID used for this connection
2191
+ });
2192
+
2193
+ ws.send(JSON.stringify({
2194
+ type: 'relay-connected',
2195
+ message: `Connected as ${username}. Your local machine is now bridged to the server.`
2196
+ }));
2197
+
2198
+ // Auto-add relay CWD as a project so the user lands with a ready workspace
2199
+ if (relayCwd) {
2200
+ try {
2201
+ await addProjectManually(relayCwd, null, userId);
2202
+ } catch { /* project may already exist, ignore */ }
2203
+ }
2204
+
2205
+ // Cancel any pending sandbox cleanup (user reconnected within grace period)
2206
+ const pendingCleanup = sandboxCleanupTimers.get(userId);
2207
+ if (pendingCleanup) {
2208
+ clearTimeout(pendingCleanup);
2209
+ sandboxCleanupTimers.delete(userId);
2210
+ }
2211
+
2212
+ // Initialize sandbox for user isolation (reuses existing if still alive)
2213
+ let sandboxActive = false;
2214
+ try {
2215
+ // Check if sandbox is still running from previous session
2216
+ const status = await sandboxClient.getStatus(userId).catch(() => null);
2217
+ if (status?.running) {
2218
+ sandboxActive = true;
2219
+ } else {
2220
+ await sandboxClient.initSandbox(userId, { projectName: relayCwd });
2221
+ sandboxActive = true;
2222
+ }
2223
+ } catch { /* sandbox is optional — don't block relay */ }
2224
+
2225
+ // Track active connection in Turso (with last cwd/machine/platform for smooth reconnection)
2226
+ try { await connectionDb.connect(String(userId), relayCwd, 'relay', sandboxActive ? String(userId) : null, { cwd: relayCwd, machine: relayMachine, platform: relayPlatform }); } catch { /* non-critical */ }
2227
+
2228
+ // Broadcast relay status to this user's browser clients only
2229
+ for (const client of connectedClients) {
2230
+ try {
2231
+ if (client.readyState === 1 && client._wsUser?.userId === userId) {
2232
+ client.send(JSON.stringify({
2233
+ type: 'relay-status',
2234
+ userId,
2235
+ connected: true,
2236
+ cwd: relayCwd,
2237
+ machine: relayMachine,
2238
+ platform: relayPlatform,
2239
+ sandboxActive,
2240
+ }));
2241
+ }
2242
+ } catch (e) { /* ignore */ }
2243
+ }
2244
+
2245
+ // Native WebSocket pong handler — Railway proxy recognizes protocol-level
2246
+ // ping/pong as keepalive, preventing idle connection termination
2247
+ ws.on('pong', () => {
2248
+ const relay = relayConnections.get(userId);
2249
+ if (relay && relay.ws === ws) relay.lastPong = Date.now();
2250
+ });
2251
+
2252
+ ws.on('message', async (message) => {
2253
+ try {
2254
+ const data = JSON.parse(message);
2255
+
2256
+ // Relay response from local machine → resolve pending request
2257
+ if (data.type === 'relay-response' && data.requestId) {
2258
+ const pending = pendingRelayRequests.get(data.requestId);
2259
+ if (pending) {
2260
+ clearTimeout(pending.timeout);
2261
+ pendingRelayRequests.delete(data.requestId);
2262
+ // If CLI sent an error, reject; otherwise unwrap data.data
2263
+ if (data.error) {
2264
+ pending.reject(new Error(data.error));
2265
+ } else {
2266
+ pending.resolve(data.data || data);
2267
+ }
2268
+ }
2269
+ return;
2270
+ }
2271
+
2272
+ // Relay stream chunk from local machine → forward to browser WebSocket
2273
+ if (data.type === 'relay-stream' && data.requestId) {
2274
+ const pending = pendingRelayRequests.get(data.requestId);
2275
+ if (pending && pending.onStream) {
2276
+ pending.onStream(data.data);
2277
+ }
2278
+ return;
2279
+ }
2280
+
2281
+ // Relay complete signal
2282
+ if (data.type === 'relay-complete' && data.requestId) {
2283
+ const pending = pendingRelayRequests.get(data.requestId);
2284
+ if (pending) {
2285
+ clearTimeout(pending.timeout);
2286
+ pendingRelayRequests.delete(data.requestId);
2287
+ pending.resolve(data);
2288
+ }
2289
+ return;
2290
+ }
2291
+
2292
+ // Permission request from CLI → forward to browser (opencode pattern)
2293
+ if (data.type === 'relay-permission-request') {
2294
+ for (const client of connectedClients) {
2295
+ try {
2296
+ if (client.readyState === 1 && client._wsUser?.userId === userId) {
2297
+ client.send(JSON.stringify({
2298
+ type: 'relay-permission-request',
2299
+ ...data,
2300
+ }));
2301
+ }
2302
+ } catch { /* ignore */ }
2303
+ }
2304
+ return;
2305
+ }
2306
+
2307
+ // Agent capabilities report from relay client
2308
+ if (data.type === 'agent-capabilities') {
2309
+ const relay = relayConnections.get(userId);
2310
+ if (relay) {
2311
+ relay.agents = data.agents || {};
2312
+ relay.machine = data.machine || relay.machine;
2313
+ }
2314
+ // Broadcast agent info to this user's browser clients only
2315
+ for (const client of connectedClients) {
2316
+ try {
2317
+ if (client.readyState === 1 && client._wsUser?.userId === userId) {
2318
+ client.send(JSON.stringify({
2319
+ type: 'relay-agents',
2320
+ userId,
2321
+ agents: data.agents || {},
2322
+ machine: data.machine || {}
2323
+ }));
2324
+ }
2325
+ } catch (e) { /* ignore */ }
2326
+ }
2327
+ return;
2328
+ }
2329
+
2330
+ // Relay shell output from CLI → forward to browser shell WebSocket
2331
+ if (data.type === 'relay-shell-output' && data.shellSessionId) {
2332
+ const browserWs = relayShellBrowserSockets.get(data.shellSessionId);
2333
+ if (browserWs?.readyState === 1) {
2334
+ browserWs.send(JSON.stringify({ type: 'output', data: data.data }));
2335
+ }
2336
+ return;
2337
+ }
2338
+
2339
+ // Relay shell exited on CLI
2340
+ if (data.type === 'relay-shell-exited' && data.shellSessionId) {
2341
+ const browserWs = relayShellBrowserSockets.get(data.shellSessionId);
2342
+ if (browserWs?.readyState === 1) {
2343
+ browserWs.send(JSON.stringify({
2344
+ type: 'output',
2345
+ data: `\r\n\x1b[33mProcess exited with code ${data.exitCode ?? 0}\x1b[0m\r\n`
2346
+ }));
2347
+ }
2348
+ relayShellBrowserSockets.delete(data.shellSessionId);
2349
+ return;
2350
+ }
2351
+
2352
+ // Relay shell auth URL detected
2353
+ if (data.type === 'relay-shell-auth-url' && data.shellSessionId) {
2354
+ const browserWs = relayShellBrowserSockets.get(data.shellSessionId);
2355
+ if (browserWs?.readyState === 1) {
2356
+ browserWs.send(JSON.stringify({ type: 'auth_url', url: data.url, autoOpen: data.autoOpen }));
2357
+ }
2358
+ return;
2359
+ }
2360
+
2361
+ // Relay init: CLI sends working directory after connect
2362
+ // Auto-register CWD as default project so user lands with a ready workspace
2363
+ if (data.type === 'relay-init') {
2364
+ const relay = relayConnections.get(userId);
2365
+ if (relay) {
2366
+ relay.cwd = data.cwd || relay.cwd;
2367
+ relay.platform = data.platform || relay.platform;
2368
+ relay.machine = data.hostname || relay.machine;
2369
+ }
2370
+ // Auto-add CWD as project (pass userId for cloud DB storage)
2371
+ if (data.cwd) {
2372
+ try {
2373
+ await addProjectManually(data.cwd, null, userId);
2374
+ } catch { /* already exists */ }
2375
+ }
2376
+ // Broadcast to browser so it auto-selects this project
2377
+ for (const client of connectedClients) {
2378
+ try {
2379
+ if (client.readyState === 1) {
2380
+ client.send(JSON.stringify({
2381
+ type: 'relay-init',
2382
+ userId,
2383
+ cwd: data.cwd,
2384
+ dirName: data.dirName,
2385
+ platform: data.platform,
2386
+ }));
2387
+ }
2388
+ } catch { /* ignore */ }
2389
+ }
2390
+ return;
2391
+ }
2392
+
2393
+ // Heartbeat
2394
+ if (data.type === 'ping') {
2395
+ const relay = relayConnections.get(userId);
2396
+ if (relay) relay.lastPong = Date.now();
2397
+ ws.send(JSON.stringify({ type: 'pong' }));
2398
+ return;
2399
+ }
2400
+ } catch (e) {
2401
+ // relay message processing error
2402
+ }
2403
+ });
2404
+
2405
+ // Server-side heartbeat: ping relay client every 25s, terminate if no pong in 60s
2406
+ const relayHeartbeat = setInterval(() => {
2407
+ const relay = relayConnections.get(userId);
2408
+ if (!relay || relay.ws !== ws) {
2409
+ clearInterval(relayHeartbeat);
2410
+ return;
2411
+ }
2412
+ // If no ping received from client in 60s, consider connection stale
2413
+ if (Date.now() - relay.lastPong > 60000) {
2414
+ clearInterval(relayHeartbeat);
2415
+ ws.terminate();
2416
+ return;
2417
+ }
2418
+ // Send native WebSocket ping frame — recognized by Railway proxy as keepalive
2419
+ try { ws.ping(); } catch { /* ignore */ }
2420
+ // Also send JSON-level ping to keep application-layer alive
2421
+ try {
2422
+ if (ws.readyState === 1) {
2423
+ ws.send(JSON.stringify({ type: 'server-ping' }));
2424
+ }
2425
+ } catch { /* ignore */ }
2426
+ }, 25000);
2427
+
2428
+ ws.on('close', async () => {
2429
+ clearInterval(relayHeartbeat);
2430
+
2431
+ // Guard: only delete if THIS ws is still the active relay for this user.
2432
+ // Prevents race condition where CLI reconnects quickly and the old close
2433
+ // handler fires after the new connection is stored, wiping it out.
2434
+ const currentRelay = relayConnections.get(userId);
2435
+ if (!currentRelay || currentRelay.ws !== ws) {
2436
+ // New connection already replaced this one — skip all cleanup
2437
+ return;
2438
+ }
2439
+
2440
+ relayConnections.delete(userId);
2441
+
2442
+ // Clean up pending requests for this user (relay is truly gone)
2443
+ for (const [reqId, pending] of pendingRelayRequests) {
2444
+ if (pending.userId === userId) {
2445
+ clearTimeout(pending.timeout);
2446
+ pending.reject(new Error('Relay disconnected'));
2447
+ pendingRelayRequests.delete(reqId);
2448
+ }
2449
+ }
2450
+
2451
+ // Track disconnection in Turso (but keep sandbox alive for grace period)
2452
+ try { await connectionDb.disconnect(String(userId), 'relay'); } catch { /* non-critical */ }
2453
+
2454
+ // Schedule sandbox cleanup after grace period (5 min)
2455
+ // If user reconnects within this window, the timer is cancelled and sandbox reused
2456
+ const cleanupTimer = setTimeout(async () => {
2457
+ sandboxCleanupTimers.delete(userId);
2458
+ // Only destroy if user hasn't reconnected
2459
+ if (!relayConnections.has(userId)) {
2460
+ try { await sandboxClient.destroySandbox(userId); } catch { /* best-effort */ }
2461
+ }
2462
+ }, SANDBOX_GRACE_PERIOD_MS);
2463
+ sandboxCleanupTimers.set(userId, cleanupTimer);
2464
+
2465
+ // Broadcast relay disconnect to this user's browser clients only
2466
+ // Note: sandboxActive stays true during grace period (sandbox is still alive)
2467
+ for (const client of connectedClients) {
2468
+ try {
2469
+ if (client.readyState === 1 && client._wsUser?.userId === userId) {
2470
+ client.send(JSON.stringify({ type: 'relay-status', userId, connected: false, sandboxActive: true }));
2471
+ }
2472
+ } catch (e) { /* ignore */ }
2473
+ }
2474
+ });
2475
+
2476
+ ws.on('error', () => {
2477
+ clearInterval(relayHeartbeat);
2478
+ });
2479
+ }
2480
+
2481
+ /**
2482
+ * Retry helper with exponential backoff (opencode pattern)
2483
+ * Only retries on transient errors (timeout, disconnection). Not for validation/security.
2484
+ */
2485
+ async function withRetry(fn, { maxRetries = 3, baseDelayMs = 2000, jitter = 0.2 } = {}) {
2486
+ let lastError;
2487
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
2488
+ try {
2489
+ return await fn();
2490
+ } catch (err) {
2491
+ lastError = err;
2492
+ const msg = err.message || '';
2493
+ const isTransient = msg.includes('timed out') || msg.includes('disconnected');
2494
+ if (!isTransient || attempt === maxRetries) throw err;
2495
+ const delay = baseDelayMs * Math.pow(2, attempt) * (1 + (Math.random() * jitter * 2 - jitter));
2496
+ await new Promise(r => setTimeout(r, delay));
2497
+ }
2498
+ }
2499
+ throw lastError;
2500
+ }
2501
+
2502
+ /**
2503
+ * Send a command to a user's relay and wait for response
2504
+ * @param {number} userId - User ID
2505
+ * @param {string} action - Action type (claude-query, shell-command, file-read, etc.)
2506
+ * @param {object} payload - Action payload
2507
+ * @param {function} onStream - Optional callback for streaming chunks
2508
+ * @param {number} timeoutMs - Timeout in milliseconds (default 5 min)
2509
+ * @returns {Promise<object>} Relay response
2510
+ */
2511
+ function sendRelayCommand(userId, action, payload, onStream = null, timeoutMs = 300000, externalRequestId = null) {
2512
+ return new Promise((resolve, reject) => {
2513
+ const relay = relayConnections.get(userId);
2514
+ if (!relay || relay.ws.readyState !== 1) {
2515
+ reject(new Error('No relay connection. Run "uc connect" on your local machine.'));
2516
+ return;
2517
+ }
2518
+
2519
+ // Security: validate action is in allowlist and payload is safe
2520
+ const validation = validateRelayPayload(action, payload);
2521
+ if (!validation.valid) {
2522
+ reject(new Error(`Relay command blocked: ${validation.reason}`));
2523
+ return;
2524
+ }
2525
+
2526
+ // Use external requestId if provided (for abort tracking), else generate
2527
+ const requestId = externalRequestId || crypto.randomUUID();
2528
+ const timeout = setTimeout(() => {
2529
+ pendingRelayRequests.delete(requestId);
2530
+ reject(new Error('Relay request timed out'));
2531
+ }, timeoutMs);
2532
+
2533
+ pendingRelayRequests.set(requestId, { resolve, reject, timeout, userId, onStream });
2534
+
2535
+ relay.ws.send(JSON.stringify({
2536
+ type: 'relay-command',
2537
+ requestId,
2538
+ action,
2539
+ ...payload
2540
+ }));
2541
+ });
2542
+ }
2543
+
2544
+ /**
2545
+ * Check if a user has an active relay connection
2546
+ */
2547
+ function hasActiveRelay(userId) {
2548
+ if (!userId) return false;
2549
+ const relay = relayConnections.get(Number(userId));
2550
+ return relay && relay.ws.readyState === 1;
2551
+ }
2552
+
2553
+ /**
2554
+ * Route a chat command through the user's relay connection to their local machine.
2555
+ * Translates relay-stream/relay-complete events into the format the frontend expects.
2556
+ *
2557
+ * @param {number} userId - User ID
2558
+ * @param {string} action - Relay action (claude-query, codex-query, cursor-query)
2559
+ * @param {object} data - Original command data from the browser
2560
+ * @param {object} writer - WebSocket writer to send events to browser
2561
+ * @param {object} eventMap - Maps relay stream data types to chat event types
2562
+ */
2563
+ async function routeViaRelay(userId, action, data, writer, eventMap = {}) {
2564
+ const sessionId = data.options?.sessionId || `relay-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
2565
+
2566
+ // Generate requestId upfront so we can track it for abort (opencode pattern)
2567
+ const requestId = crypto.randomUUID();
2568
+
2569
+ // Track relay session → requestId mapping for abort forwarding
2570
+ relaySessionRequests.set(sessionId, { userId: Number(userId), requestId });
2571
+
2572
+ // Send session-created so the frontend can track this query
2573
+ writer.send({ type: 'session-created', sessionId });
2574
+
2575
+ // Determine event types from the provider
2576
+ const responseType = eventMap.response || 'claude-response';
2577
+ const completeType = eventMap.complete || 'claude-complete';
2578
+ const errorType = eventMap.error || 'claude-error';
2579
+
2580
+ let fullContent = '';
2581
+ let capturedCliSessionId = null;
2582
+ let usedSDKFormat = false;
2583
+
2584
+ try {
2585
+ const result = await sendRelayCommand(
2586
+ Number(userId),
2587
+ action,
2588
+ {
2589
+ command: data.command,
2590
+ options: data.options || {}
2591
+ },
2592
+ // onStream callback — translates relay events to chat events
2593
+ (streamData) => {
2594
+ // ── NEW: Handle SDK messages from upgraded CLI agent ──
2595
+ // The CLI now uses @anthropic-ai/claude-agent-sdk query() which
2596
+ // sends rich typed messages (tool_use, tool_result, text, system, result).
2597
+ // Forward them directly — same format as queryClaudeSDK() in claude-sdk.js.
2598
+ if (streamData.type === 'claude-sdk-message') {
2599
+ const sdkMessage = streamData.data;
2600
+ if (!sdkMessage) return;
2601
+
2602
+ // Capture session ID from SDK messages
2603
+ if (sdkMessage.session_id && !capturedCliSessionId) {
2604
+ capturedCliSessionId = sdkMessage.session_id;
2605
+
2606
+ // Send session-created with the real CLI session ID
2607
+ writer.send({
2608
+ type: 'relay-session-id',
2609
+ sessionId,
2610
+ cliSessionId: capturedCliSessionId,
2611
+ model: sdkMessage.model,
2612
+ cwd: sdkMessage.cwd,
2613
+ });
2614
+ }
2615
+
2616
+ // Transform parent_tool_use_id for subagent grouping (match queryClaudeSDK format)
2617
+ const transformedMessage = sdkMessage.parent_tool_use_id
2618
+ ? { ...sdkMessage, parentToolUseId: sdkMessage.parent_tool_use_id }
2619
+ : sdkMessage;
2620
+
2621
+ // Forward SDK message — SAME format as queryClaudeSDK()
2622
+ writer.send({
2623
+ type: responseType,
2624
+ data: transformedMessage,
2625
+ sessionId: capturedCliSessionId || sessionId || null,
2626
+ });
2627
+
2628
+ // Extract and send token budget from result messages
2629
+ if (sdkMessage.type === 'result' && sdkMessage.modelUsage) {
2630
+ const tokenBudget = extractTokenBudget(sdkMessage);
2631
+ if (tokenBudget) {
2632
+ writer.send({
2633
+ type: 'token-budget',
2634
+ data: tokenBudget,
2635
+ sessionId: capturedCliSessionId || sessionId || null,
2636
+ });
2637
+ }
2638
+ }
2639
+
2640
+ // Track that SDK format was used (skip content_block_stop on completion)
2641
+ usedSDKFormat = true;
2642
+ if (sdkMessage.type === 'assistant' || sdkMessage.type === 'result') {
2643
+ fullContent += '1'; // Mark that we got content
2644
+ }
2645
+
2646
+ return;
2647
+ }
2648
+
2649
+ // ── LEGACY: Handle old CLI format (backward compat with pre-SDK CLI versions) ──
2650
+ // Capture CLI-reported session ID for resume support
2651
+ // Forward immediately so frontend can use it for --continue
2652
+ if (streamData.type === 'claude-system' && streamData.sessionId) {
2653
+ capturedCliSessionId = streamData.sessionId;
2654
+ writer.send({
2655
+ type: 'relay-session-id',
2656
+ sessionId,
2657
+ cliSessionId: capturedCliSessionId,
2658
+ model: streamData.model,
2659
+ cwd: streamData.cwd,
2660
+ });
2661
+ }
2662
+
2663
+ if (streamData.type === 'claude-response' || streamData.type === 'codex-response' || streamData.type === 'cursor-response') {
2664
+ const chunk = streamData.content || '';
2665
+ if (chunk) {
2666
+ fullContent += chunk;
2667
+ // Send in content_block_delta format — matches what useChatRealtimeHandlers expects
2668
+ writer.send({
2669
+ type: responseType,
2670
+ data: { type: 'content_block_delta', delta: { text: chunk } },
2671
+ sessionId
2672
+ });
2673
+ }
2674
+ } else if (streamData.type === 'claude-error' || streamData.type === 'codex-error' || streamData.type === 'cursor-error') {
2675
+ const errChunk = streamData.content || '';
2676
+ if (errChunk) {
2677
+ writer.send({
2678
+ type: responseType,
2679
+ data: { type: 'content_block_delta', delta: { text: errChunk } },
2680
+ sessionId
2681
+ });
2682
+ }
2683
+ } else if (streamData.type === 'claude-result') {
2684
+ // Result event from stream-json — capture session ID
2685
+ if (streamData.sessionId) capturedCliSessionId = streamData.sessionId;
2686
+ }
2687
+ },
2688
+ 600000, // 10 minute timeout for AI queries
2689
+ requestId // Pass pre-generated requestId for abort tracking
2690
+ );
2691
+
2692
+ // Clean up relay session tracking
2693
+ relaySessionRequests.delete(sessionId);
2694
+
2695
+ // Finalize any open streaming message before sending complete
2696
+ // (only for legacy text-streaming mode — SDK messages are self-contained)
2697
+ if (fullContent && !usedSDKFormat) {
2698
+ writer.send({ type: responseType, data: { type: 'content_block_stop' }, sessionId });
2699
+ }
2700
+
2701
+ // Send completion event
2702
+ writer.send({
2703
+ type: completeType,
2704
+ sessionId,
2705
+ cliSessionId: capturedCliSessionId || null,
2706
+ exitCode: result?.exitCode ?? 0,
2707
+ isNewSession: !data.options?.sessionId,
2708
+ viaRelay: true
2709
+ });
2710
+ } catch (error) {
2711
+ relaySessionRequests.delete(sessionId);
2712
+ const isRelayLost = error.message?.includes('Relay disconnected') || error.message?.includes('No relay connection') || error.message?.includes('Relay request timed out');
2713
+ writer.send({
2714
+ type: errorType,
2715
+ error: isRelayLost
2716
+ ? 'Your machine disconnected. Please reconnect with "uc connect" and try again.'
2717
+ : 'An error occurred while processing your request',
2718
+ sessionId,
2719
+ relayDisconnected: isRelayLost
2720
+ });
2721
+ }
2722
+ }
2723
+
2724
+ // Handle shell WebSocket connections
2725
+ /**
2726
+ * Bridge a browser shell WebSocket to the user's local machine via relay.
2727
+ * Creates a relay shell session on the CLI and forwards I/O bidirectionally.
2728
+ */
2729
+ function handleRelayShell(browserWs, userId) {
2730
+ const relay = relayConnections.get(userId);
2731
+ if (!relay || relay.ws.readyState !== 1) {
2732
+ browserWs.send(JSON.stringify({ type: 'output', data: '\r\n\x1b[31m[Error] Relay connection lost. Reconnect with "uc connect".\x1b[0m\r\n' }));
2733
+ browserWs.close();
2734
+ return;
2735
+ }
2736
+
2737
+ const shellSessionId = `relay-shell-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
2738
+
2739
+ browserWs.send(JSON.stringify({ type: 'output', data: '\x1b[36m[Connected to local machine via relay]\x1b[0m\r\n' }));
2740
+
2741
+ // Forward browser shell messages to relay CLI
2742
+ browserWs.on('message', async (message) => {
2743
+ try {
2744
+ const data = JSON.parse(message);
2745
+
2746
+ if (data.type === 'init') {
2747
+ // Auto-register project path so it appears in sidebar
2748
+ if (data.projectPath) {
2749
+ try { await addProjectManually(data.projectPath, null, userId); } catch { /* exists */ }
2750
+ // Notify browser clients — if this is a Claude/Cursor/Codex session,
2751
+ // tell the frontend to auto-select this project and open a new chat
2752
+ const isAgentSession = data.provider && data.provider !== 'plain-shell';
2753
+ for (const client of connectedClients) {
2754
+ try {
2755
+ if (client.readyState === 1 && client._wsUser?.userId === userId) {
2756
+ if (isAgentSession) {
2757
+ client.send(JSON.stringify({
2758
+ type: 'shell-project-selected',
2759
+ projectPath: data.projectPath,
2760
+ provider: data.provider,
2761
+ }));
2762
+ } else {
2763
+ client.send(JSON.stringify({ type: 'projects_updated' }));
2764
+ }
2765
+ }
2766
+ } catch { /* ignore */ }
2767
+ }
2768
+ }
2769
+ // Start relay shell session on CLI
2770
+ const relayConn = relayConnections.get(userId);
2771
+ if (relayConn?.ws?.readyState === 1) {
2772
+ relayConn.ws.send(JSON.stringify({
2773
+ type: 'relay-command',
2774
+ requestId: shellSessionId,
2775
+ action: 'shell-session-start',
2776
+ projectPath: data.projectPath,
2777
+ cols: data.cols || 80,
2778
+ rows: data.rows || 24,
2779
+ shellType: data.shellType,
2780
+ initialCommand: data.initialCommand,
2781
+ provider: data.provider,
2782
+ sessionId: data.sessionId,
2783
+ hasSession: data.hasSession,
2784
+ isPlainShell: data.isPlainShell,
2785
+ }));
2786
+ }
2787
+ } else if (data.type === 'input') {
2788
+ // Forward keystroke to CLI shell
2789
+ const relayConn = relayConnections.get(userId);
2790
+ if (relayConn?.ws?.readyState === 1) {
2791
+ relayConn.ws.send(JSON.stringify({
2792
+ type: 'relay-shell-input',
2793
+ shellSessionId,
2794
+ data: data.data,
2795
+ }));
2796
+ }
2797
+ } else if (data.type === 'resize') {
2798
+ // Forward resize to CLI shell
2799
+ const relayConn = relayConnections.get(userId);
2800
+ if (relayConn?.ws?.readyState === 1) {
2801
+ relayConn.ws.send(JSON.stringify({
2802
+ type: 'relay-shell-resize',
2803
+ shellSessionId,
2804
+ cols: data.cols,
2805
+ rows: data.rows,
2806
+ }));
2807
+ }
2808
+ }
2809
+ } catch { /* ignore parse errors */ }
2810
+ });
2811
+
2812
+ // Listen for relay shell output → forward to browser
2813
+ // We tag this browser WS so the relay message handler can find it
2814
+ browserWs._relayShellSessionId = shellSessionId;
2815
+ browserWs._relayShellUserId = userId;
2816
+
2817
+ // Register this shell WS for output routing
2818
+ if (!relayShellBrowserSockets) {
2819
+ // Will be initialized below
2820
+ }
2821
+ relayShellBrowserSockets.set(shellSessionId, browserWs);
2822
+
2823
+ browserWs.on('close', () => {
2824
+ relayShellBrowserSockets.delete(shellSessionId);
2825
+ // Tell CLI to kill the shell session
2826
+ const relayConn = relayConnections.get(userId);
2827
+ if (relayConn?.ws?.readyState === 1) {
2828
+ relayConn.ws.send(JSON.stringify({
2829
+ type: 'relay-shell-kill',
2830
+ shellSessionId,
2831
+ }));
2832
+ }
2833
+ });
2834
+ }
2835
+
2836
+ // Maps shellSessionId → browser WebSocket (for routing relay shell output)
2837
+ const relayShellBrowserSockets = new Map();
2838
+
2839
+ function handleShellConnection(ws, request) {
2840
+ const shellUserId = request?.user?.id || request?.user?.userId || null;
2841
+
2842
+ // ── Relay mode: bridge shell WebSocket to user's local machine ─────────
2843
+ if (shellUserId && hasActiveRelay(Number(shellUserId))) {
2844
+ handleRelayShell(ws, Number(shellUserId));
2845
+ return;
2846
+ }
2847
+
2848
+ if (!pty) {
2849
+ ws.send(JSON.stringify({ type: 'output', data: '\r\n[Shell unavailable] node-pty not installed. Use relay connection for shell access.\r\n' }));
2850
+ ws.close();
2851
+ return;
2852
+ }
2853
+ // Shell client connected
2854
+ let shellProcess = null;
2855
+ let ptySessionKey = null;
2856
+ let urlDetectionBuffer = '';
2857
+ const announcedAuthUrls = new Set();
2858
+
2859
+ ws.on('message', async (message) => {
2860
+ try {
2861
+ const data = JSON.parse(message);
2862
+ // Shell message received
2863
+
2864
+ if (data.type === 'init') {
2865
+ const projectPath = data.projectPath || process.cwd();
2866
+ const sessionId = data.sessionId;
2867
+ const hasSession = data.hasSession;
2868
+ const provider = data.provider || 'claude';
2869
+ const initialCommand = data.initialCommand;
2870
+ const isPlainShell = data.isPlainShell || (!!initialCommand && !hasSession) || provider === 'plain-shell';
2871
+ const shellType = data.shellType || null;
2872
+ urlDetectionBuffer = '';
2873
+ announcedAuthUrls.clear();
2874
+
2875
+ // Login commands (Claude/Cursor auth) should never reuse cached sessions
2876
+ const isLoginCommand = initialCommand && (
2877
+ initialCommand.includes('setup-token') ||
2878
+ initialCommand.includes('cursor-agent login') ||
2879
+ initialCommand.includes('auth login')
2880
+ );
2881
+
2882
+ // Include command hash in session key so different commands get separate sessions
2883
+ const commandSuffix = isPlainShell && initialCommand
2884
+ ? `_cmd_${Buffer.from(initialCommand).toString('base64').slice(0, 16)}`
2885
+ : '';
2886
+ ptySessionKey = `${shellUserId || 'anon'}_${projectPath}_${sessionId || 'default'}${commandSuffix}`;
2887
+
2888
+ // Kill any existing login session before starting fresh
2889
+ if (isLoginCommand) {
2890
+ const oldSession = ptySessionsMap.get(ptySessionKey);
2891
+ if (oldSession) {
2892
+ // cleaning up existing session
2893
+ if (oldSession.timeoutId) clearTimeout(oldSession.timeoutId);
2894
+ if (oldSession.pty && oldSession.pty.kill) oldSession.pty.kill();
2895
+ ptySessionsMap.delete(ptySessionKey);
2896
+ }
2897
+ }
2898
+
2899
+ const existingSession = isLoginCommand ? null : ptySessionsMap.get(ptySessionKey);
2900
+ if (existingSession) {
2901
+ // reconnecting to existing PTY session
2902
+ shellProcess = existingSession.pty;
2903
+
2904
+ clearTimeout(existingSession.timeoutId);
2905
+
2906
+ ws.send(JSON.stringify({
2907
+ type: 'output',
2908
+ data: `\x1b[36m[Reconnected to existing session]\x1b[0m\r\n`
2909
+ }));
2910
+
2911
+ if (existingSession.buffer && existingSession.buffer.length > 0) {
2912
+ // sending buffered messages
2913
+ existingSession.buffer.forEach(bufferedData => {
2914
+ ws.send(JSON.stringify({
2915
+ type: 'output',
2916
+ data: bufferedData
2917
+ }));
2918
+ });
2919
+ }
2920
+
2921
+ existingSession.ws = ws;
2922
+
2923
+ return;
2924
+ }
2925
+
2926
+ // shell start path logged silently
2927
+ // shell session started
2928
+ // provider info logged silently
2929
+ if (initialCommand) {
2930
+ // initial command logged silently
2931
+ }
2932
+
2933
+ // First send a welcome message
2934
+ let welcomeMsg;
2935
+ if (isPlainShell) {
2936
+ welcomeMsg = `\x1b[36mStarting terminal in: ${projectPath}\x1b[0m\r\n`;
2937
+ } else {
2938
+ const providerName = provider === 'cursor' ? 'Cursor' : 'Claude';
2939
+ welcomeMsg = hasSession ?
2940
+ `\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n` :
2941
+ `\x1b[36mStarting new ${providerName} session in: ${projectPath}\x1b[0m\r\n`;
2942
+ }
2943
+
2944
+ ws.send(JSON.stringify({
2945
+ type: 'output',
2946
+ data: welcomeMsg
2947
+ }));
2948
+
2949
+ try {
2950
+ // Prepare the shell command adapted to the platform and provider
2951
+ let shellCommand;
2952
+ if (isPlainShell) {
2953
+ // Plain shell mode - run initial command or open interactive shell
2954
+ const usesPowerShell = !shellType || shellType === 'powershell';
2955
+ if (initialCommand) {
2956
+ if (usesPowerShell) {
2957
+ shellCommand = `Set-Location -Path "${projectPath}"; ${initialCommand}`;
2958
+ } else {
2959
+ shellCommand = `cd "${projectPath}" && ${initialCommand}`;
2960
+ }
2961
+ } else {
2962
+ // Interactive shell tab — spawn shell directly (no command wrapper)
2963
+ shellCommand = null;
2964
+ }
2965
+ } else if (provider === 'cursor') {
2966
+ // Use cursor-agent command
2967
+ if (os.platform() === 'win32') {
2968
+ if (hasSession && sessionId) {
2969
+ shellCommand = `Set-Location -Path "${projectPath}"; cursor-agent --resume="${sessionId}"`;
2970
+ } else {
2971
+ shellCommand = `Set-Location -Path "${projectPath}"; cursor-agent`;
2972
+ }
2973
+ } else {
2974
+ if (hasSession && sessionId) {
2975
+ shellCommand = `cd "${projectPath}" && cursor-agent --resume="${sessionId}"`;
2976
+ } else {
2977
+ shellCommand = `cd "${projectPath}" && cursor-agent`;
2978
+ }
2979
+ }
2980
+ } else {
2981
+ // Use claude command (default) or initialCommand if provided
2982
+ const command = initialCommand || 'claude';
2983
+ if (os.platform() === 'win32') {
2984
+ if (hasSession && sessionId) {
2985
+ // Try to resume session, but with fallback to new session if it fails
2986
+ shellCommand = `Set-Location -Path "${projectPath}"; claude --resume ${sessionId}; if ($LASTEXITCODE -ne 0) { claude }`;
2987
+ } else {
2988
+ shellCommand = `Set-Location -Path "${projectPath}"; ${command}`;
2989
+ }
2990
+ } else {
2991
+ if (hasSession && sessionId) {
2992
+ shellCommand = `cd "${projectPath}" && claude --resume ${sessionId} || claude`;
2993
+ } else {
2994
+ shellCommand = `cd "${projectPath}" && ${command}`;
2995
+ }
2996
+ }
2997
+ }
2998
+
2999
+ // shell command logged silently
3000
+
3001
+ // Use appropriate shell based on platform and requested shellType
3002
+ const shellMap = {
3003
+ 'powershell': { cmd: 'powershell.exe', args: ['-Command'] },
3004
+ 'cmd': { cmd: 'cmd.exe', args: ['/c'] },
3005
+ 'bash': { cmd: 'bash', args: ['-c'] },
3006
+ };
3007
+ const defaultShell = os.platform() === 'win32'
3008
+ ? { cmd: 'powershell.exe', args: ['-Command'] }
3009
+ : { cmd: 'bash', args: ['-c'] };
3010
+ const selectedShell = (shellType && shellMap[shellType]) || defaultShell;
3011
+ const shell = selectedShell.cmd;
3012
+ // If shellCommand is null, spawn an interactive shell with no args
3013
+ const shellArgs = shellCommand ? [...selectedShell.args, shellCommand] : [];
3014
+
3015
+ // Use terminal dimensions from client if provided, otherwise use defaults
3016
+ const termCols = data.cols || 80;
3017
+ const termRows = data.rows || 24;
3018
+ // terminal dimensions logged silently
3019
+
3020
+ shellProcess = pty.spawn(shell, shellArgs, {
3021
+ name: 'xterm-256color',
3022
+ cols: termCols,
3023
+ rows: termRows,
3024
+ cwd: shellCommand ? os.homedir() : projectPath,
3025
+ env: {
3026
+ ...process.env,
3027
+ TERM: 'xterm-256color',
3028
+ COLORTERM: 'truecolor',
3029
+ FORCE_COLOR: '3'
3030
+ }
3031
+ });
3032
+
3033
+ // shell process started
3034
+
3035
+ ptySessionsMap.set(ptySessionKey, {
3036
+ pty: shellProcess,
3037
+ ws: ws,
3038
+ buffer: [],
3039
+ timeoutId: null,
3040
+ projectPath,
3041
+ sessionId
3042
+ });
3043
+
3044
+ // Handle data output
3045
+ shellProcess.onData((data) => {
3046
+ const session = ptySessionsMap.get(ptySessionKey);
3047
+ if (!session) return;
3048
+
3049
+ if (session.buffer.length < 5000) {
3050
+ session.buffer.push(data);
3051
+ } else {
3052
+ session.buffer.shift();
3053
+ session.buffer.push(data);
3054
+ }
3055
+
3056
+ if (session.ws && session.ws.readyState === WebSocket.OPEN) {
3057
+ let outputData = data;
3058
+
3059
+ const cleanChunk = stripAnsiSequences(data);
3060
+ urlDetectionBuffer = `${urlDetectionBuffer}${cleanChunk}`.slice(-SHELL_URL_PARSE_BUFFER_LIMIT);
3061
+
3062
+ outputData = outputData.replace(
3063
+ /OPEN_URL:\s*(https?:\/\/[^\s\x1b\x07]+)/g,
3064
+ '[INFO] Opening in browser: $1'
3065
+ );
3066
+
3067
+ const emitAuthUrl = (detectedUrl, autoOpen = false) => {
3068
+ const normalizedUrl = normalizeDetectedUrl(detectedUrl);
3069
+ if (!normalizedUrl) return;
3070
+
3071
+ const isNewUrl = !announcedAuthUrls.has(normalizedUrl);
3072
+ if (isNewUrl) {
3073
+ announcedAuthUrls.add(normalizedUrl);
3074
+ session.ws.send(JSON.stringify({
3075
+ type: 'auth_url',
3076
+ url: normalizedUrl,
3077
+ autoOpen
3078
+ }));
3079
+ }
3080
+
3081
+ };
3082
+
3083
+ const normalizedDetectedUrls = extractUrlsFromText(urlDetectionBuffer)
3084
+ .map((url) => normalizeDetectedUrl(url))
3085
+ .filter(Boolean);
3086
+
3087
+ // Prefer the most complete URL if shorter prefix variants are also present.
3088
+ const dedupedDetectedUrls = Array.from(new Set(normalizedDetectedUrls)).filter((url, _, urls) =>
3089
+ !urls.some((otherUrl) => otherUrl !== url && otherUrl.startsWith(url))
3090
+ );
3091
+
3092
+ dedupedDetectedUrls.forEach((url) => emitAuthUrl(url, false));
3093
+
3094
+ if (shouldAutoOpenUrlFromOutput(cleanChunk) && dedupedDetectedUrls.length > 0) {
3095
+ const bestUrl = dedupedDetectedUrls.reduce((longest, current) =>
3096
+ current.length > longest.length ? current : longest
3097
+ );
3098
+ emitAuthUrl(bestUrl, true);
3099
+ }
3100
+
3101
+ // Send regular output
3102
+ session.ws.send(JSON.stringify({
3103
+ type: 'output',
3104
+ data: outputData
3105
+ }));
3106
+ }
3107
+ });
3108
+
3109
+ // Handle process exit
3110
+ shellProcess.onExit((exitCode) => {
3111
+ // shell process exited
3112
+ const session = ptySessionsMap.get(ptySessionKey);
3113
+ if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {
3114
+ session.ws.send(JSON.stringify({
3115
+ type: 'output',
3116
+ data: `\r\n\x1b[33mProcess exited with code ${exitCode.exitCode}${exitCode.signal ? ` (${exitCode.signal})` : ''}\x1b[0m\r\n`
3117
+ }));
3118
+ }
3119
+ if (session && session.timeoutId) {
3120
+ clearTimeout(session.timeoutId);
3121
+ }
3122
+ ptySessionsMap.delete(ptySessionKey);
3123
+ shellProcess = null;
3124
+ });
3125
+
3126
+ } catch (spawnError) {
3127
+ // process spawn error handled silently
3128
+ ws.send(JSON.stringify({
3129
+ type: 'output',
3130
+ data: `\r\n\x1b[31mError: ${spawnError.message}\x1b[0m\r\n`
3131
+ }));
3132
+ }
3133
+
3134
+ } else if (data.type === 'input') {
3135
+ // Send input to shell process
3136
+ if (shellProcess && shellProcess.write) {
3137
+ try {
3138
+ shellProcess.write(data.data);
3139
+ } catch (error) {
3140
+ // shell write error handled silently
3141
+ }
3142
+ } else {
3143
+ // no active shell process
3144
+ }
3145
+ } else if (data.type === 'resize') {
3146
+ // Handle terminal resize
3147
+ if (shellProcess && shellProcess.resize) {
3148
+ // terminal resize handled
3149
+ shellProcess.resize(data.cols, data.rows);
3150
+ }
3151
+ }
3152
+ } catch (error) {
3153
+ // shell WebSocket error
3154
+ if (ws.readyState === WebSocket.OPEN) {
3155
+ ws.send(JSON.stringify({
3156
+ type: 'output',
3157
+ data: `\r\n\x1b[31mError: ${error.message}\x1b[0m\r\n`
3158
+ }));
3159
+ }
3160
+ }
3161
+ });
3162
+
3163
+ ws.on('close', () => {
3164
+ // shell client disconnected
3165
+
3166
+ if (ptySessionKey) {
3167
+ const session = ptySessionsMap.get(ptySessionKey);
3168
+ if (session) {
3169
+ // PTY session kept alive
3170
+ session.ws = null;
3171
+
3172
+ session.timeoutId = setTimeout(() => {
3173
+ // PTY session timeout
3174
+ if (session.pty && session.pty.kill) {
3175
+ session.pty.kill();
3176
+ }
3177
+ ptySessionsMap.delete(ptySessionKey);
3178
+ }, PTY_SESSION_TIMEOUT);
3179
+ }
3180
+ }
3181
+ });
3182
+
3183
+ ws.on('error', (error) => {
3184
+ // shell error
3185
+ });
3186
+ }
3187
+ // Audio transcription endpoint
3188
+ // Priority: 1) Self-hosted Whisper (WHISPER_URL), 2) BYOK OpenAI key, 3) Server OpenAI key
3189
+ app.post('/api/transcribe', authenticateToken, async (req, res) => {
3190
+ try {
3191
+ const multer = (await import('multer')).default;
3192
+ const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25 * 1024 * 1024 } });
3193
+
3194
+ upload.single('audio')(req, res, async (err) => {
3195
+ if (err) {
3196
+ return res.status(400).json({ error: 'Failed to process audio file' });
3197
+ }
3198
+
3199
+ if (!req.file) {
3200
+ return res.status(400).json({ error: 'No audio file provided' });
3201
+ }
3202
+
3203
+ const mode = req.body?.mode || 'default';
3204
+ let transcribedText = '';
3205
+
3206
+ try {
3207
+ const FormData = (await import('form-data')).default;
3208
+
3209
+ // --- Strategy 1: Self-hosted Whisper (no API key needed) ---
3210
+ const whisperUrl = process.env.WHISPER_URL;
3211
+ if (whisperUrl) {
3212
+ try {
3213
+ const formData = new FormData();
3214
+ formData.append('file', req.file.buffer, {
3215
+ filename: req.file.originalname || 'audio.webm',
3216
+ contentType: req.file.mimetype || 'audio/webm'
3217
+ });
3218
+ formData.append('model', 'whisper-1');
3219
+ formData.append('response_format', 'json');
3220
+ formData.append('language', 'en');
3221
+
3222
+ const endpoint = whisperUrl.replace(/\/+$/, '') + '/v1/audio/transcriptions';
3223
+ const response = await fetch(endpoint, {
3224
+ method: 'POST',
3225
+ headers: formData.getHeaders(),
3226
+ body: formData
3227
+ });
3228
+
3229
+ if (response.ok) {
3230
+ const data = await response.json();
3231
+ transcribedText = data.text || '';
3232
+ }
3233
+ } catch {
3234
+ // Self-hosted whisper failed, try next strategy
3235
+ }
3236
+ }
3237
+
3238
+ // --- Strategy 2: OpenAI Whisper API (BYOK or server key) ---
3239
+ if (!transcribedText) {
3240
+ const userOpenaiKey = req.user?.id
3241
+ ? await getUserProviderKey(req.user.id, 'openai_key')
3242
+ : null;
3243
+ const apiKey = userOpenaiKey || process.env.OPENAI_API_KEY;
3244
+
3245
+ if (apiKey) {
3246
+ const formData = new FormData();
3247
+ formData.append('file', req.file.buffer, {
3248
+ filename: req.file.originalname || 'audio.webm',
3249
+ contentType: req.file.mimetype || 'audio/webm'
3250
+ });
3251
+ formData.append('model', 'whisper-1');
3252
+ formData.append('response_format', 'json');
3253
+ formData.append('language', 'en');
3254
+
3255
+ const response = await fetch('https://api.openai.com/v1/audio/transcriptions', {
3256
+ method: 'POST',
3257
+ headers: {
3258
+ 'Authorization': `Bearer ${apiKey}`,
3259
+ ...formData.getHeaders()
3260
+ },
3261
+ body: formData
3262
+ });
3263
+
3264
+ if (response.ok) {
3265
+ const data = await response.json();
3266
+ transcribedText = data.text || '';
3267
+ }
3268
+ }
3269
+ }
3270
+
3271
+ // No STT engine worked
3272
+ if (!transcribedText) {
3273
+ return res.status(500).json({ error: 'Speech recognition is temporarily unavailable. Please try again.' });
3274
+ }
3275
+
3276
+ // Default mode — return raw transcription
3277
+ if (mode === 'default') {
3278
+ return res.json({ text: transcribedText });
3279
+ }
3280
+
3281
+ // Enhancement modes (prompt, vibe, architect) — use OpenAI GPT if available
3282
+ try {
3283
+ const userOpenaiKey = req.user?.id
3284
+ ? await getUserProviderKey(req.user.id, 'openai_key')
3285
+ : null;
3286
+ const enhanceKey = userOpenaiKey || process.env.OPENAI_API_KEY;
3287
+
3288
+ if (enhanceKey) {
3289
+ const OpenAI = (await import('openai')).default;
3290
+ const openai = new OpenAI({ apiKey: enhanceKey });
3291
+
3292
+ let prompt, systemMessage, temperature = 0.7, maxTokens = 800;
3293
+
3294
+ switch (mode) {
3295
+ case 'prompt':
3296
+ systemMessage = 'You are an expert prompt engineer who creates clear, detailed, and effective prompts.';
3297
+ prompt = `Transform the following rough instruction into a clear, detailed, and context-aware AI prompt.\n\nYour enhanced prompt should:\n1. Be specific and unambiguous\n2. Include relevant context and constraints\n3. Specify the desired output format\n4. Use clear, actionable language\n\nRough instruction: "${transcribedText}"\n\nEnhanced prompt:`;
3298
+ break;
3299
+ case 'vibe':
3300
+ case 'instructions':
3301
+ case 'architect':
3302
+ systemMessage = 'You are a helpful assistant that formats ideas into clear, actionable instructions for AI agents.';
3303
+ temperature = 0.5;
3304
+ prompt = `Transform the following idea into clear, well-structured instructions that an AI agent can easily understand and execute.\n\nIMPORTANT RULES:\n- Format as clear, step-by-step instructions\n- Add reasonable implementation details based on common patterns\n- Only include details directly related to what was asked\n- Do NOT add features or functionality not mentioned\n- Keep the original intent and scope intact\n\nIdea: "${transcribedText}"\n\nAgent instructions:`;
3305
+ break;
3306
+ }
3307
+
3308
+ if (prompt) {
3309
+ const completion = await openai.chat.completions.create({
3310
+ model: 'gpt-4o-mini',
3311
+ messages: [
3312
+ { role: 'system', content: systemMessage },
3313
+ { role: 'user', content: prompt }
3314
+ ],
3315
+ temperature,
3316
+ max_tokens: maxTokens
3317
+ });
3318
+ transcribedText = completion.choices[0]?.message?.content || transcribedText;
3319
+ }
3320
+ }
3321
+ // If no OpenAI key for enhancement, just return raw transcription
3322
+ } catch {
3323
+ // Enhancement failed silently — return raw transcription
3324
+ }
3325
+
3326
+ res.json({ text: transcribedText });
3327
+
3328
+ } catch {
3329
+ res.status(500).json({ error: 'Transcription failed. Please try again.' });
3330
+ }
3331
+ });
3332
+ } catch {
3333
+ res.status(500).json({ error: 'Internal server error' });
3334
+ }
3335
+ });
3336
+
3337
+ // Image upload endpoint
3338
+ app.post('/api/projects/:projectName/upload-images', authenticateToken, authorizeProject, async (req, res) => {
3339
+ try {
3340
+ const multer = (await import('multer')).default;
3341
+ const path = (await import('path')).default;
3342
+ const fs = (await import('fs')).promises;
3343
+ const os = (await import('os')).default;
3344
+
3345
+ // Configure multer for image uploads
3346
+ const storage = multer.diskStorage({
3347
+ destination: async (req, file, cb) => {
3348
+ const uploadDir = path.join(os.tmpdir(), 'claude-ui-uploads', String(req.user.id));
3349
+ await fs.mkdir(uploadDir, { recursive: true });
3350
+ cb(null, uploadDir);
3351
+ },
3352
+ filename: (req, file, cb) => {
3353
+ const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
3354
+ const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_');
3355
+ cb(null, uniqueSuffix + '-' + sanitizedName);
3356
+ }
3357
+ });
3358
+
3359
+ const fileFilter = (req, file, cb) => {
3360
+ const allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
3361
+ if (allowedMimes.includes(file.mimetype)) {
3362
+ cb(null, true);
3363
+ } else {
3364
+ cb(new Error('Invalid file type. Only JPEG, PNG, GIF, WebP, and SVG are allowed.'));
3365
+ }
3366
+ };
3367
+
3368
+ const upload = multer({
3369
+ storage,
3370
+ fileFilter,
3371
+ limits: {
3372
+ fileSize: 5 * 1024 * 1024, // 5MB
3373
+ files: 5
3374
+ }
3375
+ });
3376
+
3377
+ // Handle multipart form data
3378
+ upload.array('images', 5)(req, res, async (err) => {
3379
+ if (err) {
3380
+ const uploadError = err.code === 'LIMIT_FILE_SIZE' ? 'File too large (max 5MB)' : err.code === 'LIMIT_FILE_COUNT' ? 'Too many files (max 5)' : 'Invalid file upload';
3381
+ return res.status(400).json({ error: uploadError });
3382
+ }
3383
+
3384
+ if (!req.files || req.files.length === 0) {
3385
+ return res.status(400).json({ error: 'No image files provided' });
3386
+ }
3387
+
3388
+ try {
3389
+ // Process uploaded images
3390
+ const processedImages = await Promise.all(
3391
+ req.files.map(async (file) => {
3392
+ // Read file and convert to base64
3393
+ const buffer = await fs.readFile(file.path);
3394
+ const base64 = buffer.toString('base64');
3395
+ const mimeType = file.mimetype;
3396
+
3397
+ // Clean up temp file immediately
3398
+ await fs.unlink(file.path);
3399
+
3400
+ return {
3401
+ name: file.originalname,
3402
+ data: `data:${mimeType};base64,${base64}`,
3403
+ size: file.size,
3404
+ mimeType: mimeType
3405
+ };
3406
+ })
3407
+ );
3408
+
3409
+ res.json({ images: processedImages });
3410
+ } catch (error) {
3411
+ // image processing error handled silently
3412
+ // Clean up any remaining files
3413
+ await Promise.all(req.files.map(f => fs.unlink(f.path).catch(() => { })));
3414
+ res.status(500).json({ error: 'Failed to process images' });
3415
+ }
3416
+ });
3417
+ } catch (error) {
3418
+ // image upload error handled silently
3419
+ res.status(500).json({ error: 'Internal server error' });
3420
+ }
3421
+ });
3422
+
3423
+ // Get token usage for a specific session
3424
+ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authenticateToken, authorizeProject, async (req, res) => {
3425
+ try {
3426
+ const { projectName, sessionId } = req.params;
3427
+ const { provider = 'claude' } = req.query;
3428
+ const homeDir = os.homedir();
3429
+
3430
+ // Allow only safe characters in sessionId
3431
+ const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
3432
+ if (!safeSessionId) {
3433
+ return res.status(400).json({ error: 'Invalid sessionId' });
3434
+ }
3435
+
3436
+ // Handle Cursor sessions - they use SQLite and don't have token usage info
3437
+ if (provider === 'cursor') {
3438
+ return res.json({
3439
+ used: 0,
3440
+ total: 0,
3441
+ breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
3442
+ unsupported: true,
3443
+ message: 'Token usage tracking not available for Cursor sessions'
3444
+ });
3445
+ }
3446
+
3447
+ // Handle Codex sessions
3448
+ if (provider === 'codex') {
3449
+ const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
3450
+
3451
+ // Find the session file by searching for the session ID
3452
+ const findSessionFile = async (dir) => {
3453
+ try {
3454
+ const entries = await fsPromises.readdir(dir, { withFileTypes: true });
3455
+ for (const entry of entries) {
3456
+ const fullPath = path.join(dir, entry.name);
3457
+ if (entry.isDirectory()) {
3458
+ const found = await findSessionFile(fullPath);
3459
+ if (found) return found;
3460
+ } else if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) {
3461
+ return fullPath;
3462
+ }
3463
+ }
3464
+ } catch (error) {
3465
+ // Skip directories we can't read
3466
+ }
3467
+ return null;
3468
+ };
3469
+
3470
+ const sessionFilePath = await findSessionFile(codexSessionsDir);
3471
+
3472
+ if (!sessionFilePath) {
3473
+ return res.status(404).json({ error: 'Codex session file not found', sessionId: safeSessionId });
3474
+ }
3475
+
3476
+ // Read and parse the Codex JSONL file
3477
+ let fileContent;
3478
+ try {
3479
+ fileContent = await fsPromises.readFile(sessionFilePath, 'utf8');
3480
+ } catch (error) {
3481
+ if (error.code === 'ENOENT') {
3482
+ return res.status(404).json({ error: 'Session file not found', path: sessionFilePath });
3483
+ }
3484
+ throw error;
3485
+ }
3486
+ const lines = fileContent.trim().split('\n');
3487
+ let totalTokens = 0;
3488
+ let contextWindow = 200000; // Default for Codex/OpenAI
3489
+
3490
+ // Find the latest token_count event with info (scan from end)
3491
+ for (let i = lines.length - 1; i >= 0; i--) {
3492
+ try {
3493
+ const entry = JSON.parse(lines[i]);
3494
+
3495
+ // Codex stores token info in event_msg with type: "token_count"
3496
+ if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
3497
+ const tokenInfo = entry.payload.info;
3498
+ if (tokenInfo.total_token_usage) {
3499
+ totalTokens = tokenInfo.total_token_usage.total_tokens || 0;
3500
+ }
3501
+ if (tokenInfo.model_context_window) {
3502
+ contextWindow = tokenInfo.model_context_window;
3503
+ }
3504
+ break; // Stop after finding the latest token count
3505
+ }
3506
+ } catch (parseError) {
3507
+ // Skip lines that can't be parsed
3508
+ continue;
3509
+ }
3510
+ }
3511
+
3512
+ return res.json({
3513
+ used: totalTokens,
3514
+ total: contextWindow
3515
+ });
3516
+ }
3517
+
3518
+ // Handle Claude sessions (default)
3519
+ // Extract actual project path
3520
+ let projectPath;
3521
+ try {
3522
+ projectPath = await extractProjectDirectory(projectName);
3523
+ } catch (error) {
3524
+ // project dir extraction error handled silently
3525
+ return res.status(500).json({ error: 'Failed to determine project path' });
3526
+ }
3527
+
3528
+ // Construct the JSONL file path
3529
+ // Claude stores session files in ~/.claude/projects/[encoded-project-path]/[session-id].jsonl
3530
+ // The encoding replaces /, spaces, ~, and _ with -
3531
+ const encodedPath = projectPath.replace(/[\\/:\s~_]/g, '-');
3532
+ const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
3533
+
3534
+ const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
3535
+
3536
+ // Constrain to projectDir
3537
+ const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath));
3538
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
3539
+ return res.status(400).json({ error: 'Invalid path' });
3540
+ }
3541
+
3542
+ // Read and parse the JSONL file
3543
+ let fileContent;
3544
+ try {
3545
+ fileContent = await fsPromises.readFile(jsonlPath, 'utf8');
3546
+ } catch (error) {
3547
+ if (error.code === 'ENOENT') {
3548
+ return res.status(404).json({ error: 'Session file not found', path: jsonlPath });
3549
+ }
3550
+ throw error; // Re-throw other errors to be caught by outer try-catch
3551
+ }
3552
+ const lines = fileContent.trim().split('\n');
3553
+
3554
+ const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10);
3555
+ const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000;
3556
+ let inputTokens = 0;
3557
+ let cacheCreationTokens = 0;
3558
+ let cacheReadTokens = 0;
3559
+
3560
+ // Find the latest assistant message with usage data (scan from end)
3561
+ for (let i = lines.length - 1; i >= 0; i--) {
3562
+ try {
3563
+ const entry = JSON.parse(lines[i]);
3564
+
3565
+ // Only count assistant messages which have usage data
3566
+ if (entry.type === 'assistant' && entry.message?.usage) {
3567
+ const usage = entry.message.usage;
3568
+
3569
+ // Use token counts from latest assistant message only
3570
+ inputTokens = usage.input_tokens || 0;
3571
+ cacheCreationTokens = usage.cache_creation_input_tokens || 0;
3572
+ cacheReadTokens = usage.cache_read_input_tokens || 0;
3573
+
3574
+ break; // Stop after finding the latest assistant message
3575
+ }
3576
+ } catch (parseError) {
3577
+ // Skip lines that can't be parsed
3578
+ continue;
3579
+ }
3580
+ }
3581
+
3582
+ // Calculate total context usage (excluding output_tokens, as per ccusage)
3583
+ const totalUsed = inputTokens + cacheCreationTokens + cacheReadTokens;
3584
+
3585
+ res.json({
3586
+ used: totalUsed,
3587
+ total: contextWindow,
3588
+ breakdown: {
3589
+ input: inputTokens,
3590
+ cacheCreation: cacheCreationTokens,
3591
+ cacheRead: cacheReadTokens
3592
+ }
3593
+ });
3594
+ } catch (error) {
3595
+ // token usage read error
3596
+ res.status(500).json({ error: 'Failed to read session token usage' });
3597
+ }
3598
+ });
3599
+
3600
+ // Serve React app for all other routes (excluding static files and API routes)
3601
+ app.get('*', (req, res) => {
3602
+ // Skip API routes — they should be handled by their own routers
3603
+ if (req.path.startsWith('/api/') || req.path === '/mcp' || req.path === '/relay' || req.path === '/health') {
3604
+ return res.status(404).json({ error: 'Not found' });
3605
+ }
3606
+ // Skip requests for static assets (files with extensions)
3607
+ if (path.extname(req.path)) {
3608
+ return res.status(404).send('Not found');
3609
+ }
3610
+
3611
+ // If a JWT token is in the query param and no session cookie exists,
3612
+ // set the cookie now so the client-side AuthContext can authenticate on subsequent API calls.
3613
+ if (req.query?.token && !req.cookies?.session) {
3614
+ try {
3615
+ const decoded = jwt.verify(req.query.token, JWT_SECRET);
3616
+ if (decoded?.userId) {
3617
+ const isSecure = process.env.NODE_ENV === 'production' || !!process.env.RAILWAY_ENVIRONMENT;
3618
+ res.cookie('session', req.query.token, {
3619
+ httpOnly: true,
3620
+ secure: isSecure,
3621
+ sameSite: isSecure ? 'none' : 'strict',
3622
+ maxAge: 30 * 24 * 60 * 60 * 1000,
3623
+ path: '/',
3624
+ });
3625
+ }
3626
+ } catch (e) {
3627
+ // Invalid token — just serve the page without setting cookie
3628
+ }
3629
+ }
3630
+
3631
+ // Only serve index.html for HTML routes, not for static assets
3632
+ // Static assets should already be handled by express.static middleware above
3633
+ const indexPath = path.join(__dirname, '../client/dist/index.html');
3634
+
3635
+ // Check if dist/index.html exists (production build available)
3636
+ if (fs.existsSync(indexPath)) {
3637
+ // Set no-cache headers for HTML to prevent service worker issues
3638
+ res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
3639
+ res.setHeader('Pragma', 'no-cache');
3640
+ res.setHeader('Expires', '0');
3641
+ res.sendFile(indexPath);
3642
+ } else {
3643
+ // In development, redirect to Vite dev server only if dist doesn't exist
3644
+ res.redirect(`http://localhost:${process.env.VITE_PORT || 5173}`);
3645
+ }
3646
+ });
3647
+
3648
+ // Helper function to convert permissions to rwx format
3649
+ function permToRwx(perm) {
3650
+ const r = perm & 4 ? 'r' : '-';
3651
+ const w = perm & 2 ? 'w' : '-';
3652
+ const x = perm & 1 ? 'x' : '-';
3653
+ return r + w + x;
3654
+ }
3655
+
3656
+ async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden = true) {
3657
+ // Using fsPromises from import
3658
+ const items = [];
3659
+
3660
+ try {
3661
+ const entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
3662
+
3663
+ for (const entry of entries) {
3664
+ // Debug: log all entries including hidden files
3665
+
3666
+
3667
+ // Skip heavy build directories and VCS directories
3668
+ if (entry.name === 'node_modules' ||
3669
+ entry.name === 'dist' ||
3670
+ entry.name === 'build' ||
3671
+ entry.name === '.git' ||
3672
+ entry.name === '.svn' ||
3673
+ entry.name === '.hg') continue;
3674
+
3675
+ const itemPath = path.join(dirPath, entry.name);
3676
+ const item = {
3677
+ name: entry.name,
3678
+ path: itemPath,
3679
+ type: entry.isDirectory() ? 'directory' : 'file'
3680
+ };
3681
+
3682
+ // Get file stats for additional metadata
3683
+ try {
3684
+ const stats = await fsPromises.stat(itemPath);
3685
+ item.size = stats.size;
3686
+ item.modified = stats.mtime.toISOString();
3687
+
3688
+ // Convert permissions to rwx format
3689
+ const mode = stats.mode;
3690
+ const ownerPerm = (mode >> 6) & 7;
3691
+ const groupPerm = (mode >> 3) & 7;
3692
+ const otherPerm = mode & 7;
3693
+ item.permissions = ((mode >> 6) & 7).toString() + ((mode >> 3) & 7).toString() + (mode & 7).toString();
3694
+ item.permissionsRwx = permToRwx(ownerPerm) + permToRwx(groupPerm) + permToRwx(otherPerm);
3695
+ } catch (statError) {
3696
+ // If stat fails, provide default values
3697
+ item.size = 0;
3698
+ item.modified = null;
3699
+ item.permissions = '000';
3700
+ item.permissionsRwx = '---------';
3701
+ }
3702
+
3703
+ if (entry.isDirectory() && currentDepth < maxDepth) {
3704
+ // Recursively get subdirectories but limit depth
3705
+ try {
3706
+ // Check if we can access the directory before trying to read it
3707
+ await fsPromises.access(item.path, fs.constants.R_OK);
3708
+ item.children = await getFileTree(item.path, maxDepth, currentDepth + 1, showHidden);
3709
+ } catch (e) {
3710
+ // Silently skip directories we can't access (permission denied, etc.)
3711
+ item.children = [];
3712
+ }
3713
+ }
3714
+
3715
+ items.push(item);
3716
+ }
3717
+ } catch (error) {
3718
+ // Only log non-permission errors to avoid spam
3719
+ if (error.code !== 'EACCES' && error.code !== 'EPERM') {
3720
+ // directory read error handled silently
3721
+ }
3722
+ }
3723
+
3724
+ return items.sort((a, b) => {
3725
+ if (a.type !== b.type) {
3726
+ return a.type === 'directory' ? -1 : 1;
3727
+ }
3728
+ return a.name.localeCompare(b.name);
3729
+ });
3730
+ }
3731
+
3732
+ const PORT = process.env.PORT || 3001;
3733
+
3734
+ // Initialize database and start server
3735
+ async function startServer() {
3736
+ try {
3737
+ // Initialize authentication database
3738
+ await initializeDatabase();
3739
+
3740
+ // In local mode, ensure a default user exists (no signup needed)
3741
+ if (IS_LOCAL) {
3742
+ const hasUsers = await userDb.hasUsers();
3743
+ if (!hasUsers) {
3744
+ const localUsername = os.userInfo().username || 'local';
3745
+ const dummyHash = crypto.randomBytes(32).toString('hex');
3746
+ await userDb.createUser(localUsername, dummyHash);
3747
+ console.log(`${c.ok('[LOCAL]')} Created local user: ${c.bright(localUsername)}`);
3748
+ }
3749
+ console.log(`${c.info('[MODE]')} Running in ${c.bright('LOCAL')} mode (no login required)`);
3750
+ }
3751
+
3752
+ // Check if running in production mode (dist folder exists OR NODE_ENV/RAILWAY set)
3753
+ const distIndexPath = path.join(__dirname, '../client/dist/index.html');
3754
+ const isProduction = fs.existsSync(distIndexPath) || process.env.NODE_ENV === 'production' || !!process.env.RAILWAY_ENVIRONMENT;
3755
+
3756
+ // Log Claude implementation mode
3757
+ console.log(`${c.info('[INFO]')} Using Claude Agents SDK for Claude integration`);
3758
+ console.log(`${c.info('[INFO]')} Running in ${c.bright(isProduction ? 'PRODUCTION' : 'DEVELOPMENT')} mode`);
3759
+
3760
+ if (!isProduction) {
3761
+ console.log(`${c.warn('[WARN]')} Note: Requests will be proxied to Vite dev server at ${c.dim('http://localhost:' + (process.env.VITE_PORT || 5173))}`);
3762
+ }
3763
+
3764
+ server.listen(PORT, '0.0.0.0', async () => {
3765
+ const appInstallPath = path.join(__dirname, '..');
3766
+
3767
+ console.log('');
3768
+ console.log(c.dim('═'.repeat(63)));
3769
+ console.log(` ${c.bright('Upfyn-Code Server - Ready')}`);
3770
+ console.log(c.dim('═'.repeat(63)));
3771
+ console.log('');
3772
+ console.log(`${c.info('[INFO]')} Server URL: ${c.bright('http://0.0.0.0:' + PORT)}`);
3773
+ console.log(`${c.info('[INFO]')} MCP Server: ${c.bright('http://0.0.0.0:' + PORT + '/mcp')}`);
3774
+ console.log(`${c.info('[INFO]')} Installed at: ${c.dim(appInstallPath)}`);
3775
+ console.log(`${c.tip('[TIP]')} Run "uc status" for full configuration details`);
3776
+
3777
+ // Start workflow cron scheduler
3778
+ initScheduler().catch(err => console.warn('[Scheduler]', err.message));
3779
+ console.log('');
3780
+
3781
+ // Start watching the projects folder for changes (skip on Vercel)
3782
+ if (!process.env.VERCEL) {
3783
+ await setupProjectsWatcher();
3784
+ }
3785
+ });
3786
+ } catch (error) {
3787
+ console.error('[ERROR] Failed to start server:', error);
3788
+ process.exit(1);
3789
+ }
3790
+ }
3791
+
3792
+ // Only start server when not running on Vercel (Vercel uses the exported app)
3793
+ if (!process.env.VERCEL) {
3794
+ startServer();
3795
+ }
3796
+
3797
+ // Export for Vercel serverless and testing
3798
+ export default app;
3799
+ export { app, server, relayConnections, sendRelayCommand };