@ian2018cs/agenthub 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/LICENSE +675 -0
  2. package/README.md +330 -0
  3. package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  4. package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  5. package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  6. package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  7. package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  8. package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  9. package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  10. package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  11. package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  12. package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  13. package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  14. package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  15. package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  16. package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  17. package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  18. package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  19. package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  20. package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  21. package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  22. package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  23. package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  24. package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  25. package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  26. package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  27. package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  28. package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  29. package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  30. package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  31. package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  32. package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  33. package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  34. package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  35. package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  36. package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  37. package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  38. package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  39. package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  40. package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  41. package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  42. package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  43. package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  44. package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  45. package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  46. package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  47. package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  48. package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  49. package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  50. package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  51. package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  52. package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  53. package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  54. package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  55. package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  56. package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  57. package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  58. package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  59. package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  60. package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  61. package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  62. package/dist/assets/index-B4ru3EJb.css +32 -0
  63. package/dist/assets/index-DDFuyrpY.js +154 -0
  64. package/dist/assets/vendor-codemirror-C_VWDoZS.js +39 -0
  65. package/dist/assets/vendor-icons-CJV4dnDL.js +326 -0
  66. package/dist/assets/vendor-katex-DK8hFnhL.js +261 -0
  67. package/dist/assets/vendor-markdown-VwNYkg_0.js +35 -0
  68. package/dist/assets/vendor-react-BeVl62c0.js +59 -0
  69. package/dist/assets/vendor-syntax-CdGaPJRS.js +16 -0
  70. package/dist/assets/vendor-utils-00TdZexr.js +1 -0
  71. package/dist/assets/vendor-xterm-CvdiG4-n.js +66 -0
  72. package/dist/clear-cache.html +85 -0
  73. package/dist/convert-icons.md +53 -0
  74. package/dist/favicon.png +0 -0
  75. package/dist/favicon.svg +9 -0
  76. package/dist/generate-icons.js +49 -0
  77. package/dist/icons/claude-ai-icon.svg +1 -0
  78. package/dist/icons/codex-white.svg +3 -0
  79. package/dist/icons/codex.svg +3 -0
  80. package/dist/icons/cursor-white.svg +12 -0
  81. package/dist/icons/cursor.svg +1 -0
  82. package/dist/icons/generate-icons.md +19 -0
  83. package/dist/icons/icon-128x128.png +0 -0
  84. package/dist/icons/icon-128x128.svg +12 -0
  85. package/dist/icons/icon-144x144.png +0 -0
  86. package/dist/icons/icon-144x144.svg +12 -0
  87. package/dist/icons/icon-152x152.png +0 -0
  88. package/dist/icons/icon-152x152.svg +12 -0
  89. package/dist/icons/icon-192x192.png +0 -0
  90. package/dist/icons/icon-192x192.svg +12 -0
  91. package/dist/icons/icon-384x384.png +0 -0
  92. package/dist/icons/icon-384x384.svg +12 -0
  93. package/dist/icons/icon-512x512.png +0 -0
  94. package/dist/icons/icon-512x512.svg +12 -0
  95. package/dist/icons/icon-72x72.png +0 -0
  96. package/dist/icons/icon-72x72.svg +12 -0
  97. package/dist/icons/icon-96x96.png +0 -0
  98. package/dist/icons/icon-96x96.svg +12 -0
  99. package/dist/icons/icon-template.svg +12 -0
  100. package/dist/index.html +57 -0
  101. package/dist/logo-128.png +0 -0
  102. package/dist/logo-256.png +0 -0
  103. package/dist/logo-32.png +0 -0
  104. package/dist/logo-512.png +0 -0
  105. package/dist/logo-64.png +0 -0
  106. package/dist/logo.svg +17 -0
  107. package/dist/manifest.json +61 -0
  108. package/dist/screenshots/cli-selection.png +0 -0
  109. package/dist/screenshots/desktop-main.png +0 -0
  110. package/dist/screenshots/mobile-chat.png +0 -0
  111. package/dist/screenshots/tools-modal.png +0 -0
  112. package/dist/sw.js +49 -0
  113. package/package.json +113 -0
  114. package/server/claude-sdk.js +791 -0
  115. package/server/cli.js +330 -0
  116. package/server/database/auth.db +0 -0
  117. package/server/database/db.js +523 -0
  118. package/server/database/init.sql +23 -0
  119. package/server/index.js +1678 -0
  120. package/server/load-env.js +27 -0
  121. package/server/middleware/auth.js +118 -0
  122. package/server/projects.js +899 -0
  123. package/server/routes/admin.js +89 -0
  124. package/server/routes/auth.js +144 -0
  125. package/server/routes/commands.js +570 -0
  126. package/server/routes/mcp-utils.js +37 -0
  127. package/server/routes/mcp.js +593 -0
  128. package/server/routes/projects.js +216 -0
  129. package/server/routes/skills.js +891 -0
  130. package/server/routes/usage.js +206 -0
  131. package/server/services/pricing.js +196 -0
  132. package/server/services/usage-scanner.js +283 -0
  133. package/server/services/user-directories.js +123 -0
  134. package/server/utils/commandParser.js +303 -0
  135. package/server/utils/mcp-detector.js +73 -0
  136. package/shared/modelConstants.js +23 -0
@@ -0,0 +1,1678 @@
1
+ #!/usr/bin/env node
2
+ // Load environment variables FIRST - before any other imports
3
+ import './load-env.js';
4
+
5
+ import fs from 'fs';
6
+ import path from 'path';
7
+ import { fileURLToPath } from 'url';
8
+ import { dirname } from 'path';
9
+
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = dirname(__filename);
12
+
13
+ // ANSI color codes for terminal output
14
+ const colors = {
15
+ reset: '\x1b[0m',
16
+ bright: '\x1b[1m',
17
+ cyan: '\x1b[36m',
18
+ green: '\x1b[32m',
19
+ yellow: '\x1b[33m',
20
+ blue: '\x1b[34m',
21
+ dim: '\x1b[2m',
22
+ };
23
+
24
+ const c = {
25
+ info: (text) => `${colors.cyan}${text}${colors.reset}`,
26
+ ok: (text) => `${colors.green}${text}${colors.reset}`,
27
+ warn: (text) => `${colors.yellow}${text}${colors.reset}`,
28
+ tip: (text) => `${colors.blue}${text}${colors.reset}`,
29
+ bright: (text) => `${colors.bright}${text}${colors.reset}`,
30
+ dim: (text) => `${colors.dim}${text}${colors.reset}`,
31
+ };
32
+
33
+ console.log('PORT from env:', process.env.PORT);
34
+
35
+ import express from 'express';
36
+ import { WebSocketServer, WebSocket } from 'ws';
37
+ import os from 'os';
38
+ import http from 'http';
39
+ import cors from 'cors';
40
+ import { promises as fsPromises } from 'fs';
41
+ import { spawn } from 'child_process';
42
+ import pty from 'node-pty';
43
+ import fetch from 'node-fetch';
44
+ import mime from 'mime-types';
45
+
46
+ import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js';
47
+ import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval } from './claude-sdk.js';
48
+ import authRoutes from './routes/auth.js';
49
+ import mcpRoutes from './routes/mcp.js';
50
+ import mcpUtilsRoutes from './routes/mcp-utils.js';
51
+ import commandsRoutes from './routes/commands.js';
52
+ import projectsRoutes from './routes/projects.js';
53
+ import adminRoutes from './routes/admin.js';
54
+ import usageRoutes from './routes/usage.js';
55
+ import skillsRoutes from './routes/skills.js';
56
+ import { initializeDatabase } from './database/db.js';
57
+ import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
58
+ import { getUserPaths } from './services/user-directories.js';
59
+ import { startUsageScanner } from './services/usage-scanner.js';
60
+
61
+ // File system watcher for projects folder - per user
62
+ const userWatchers = new Map(); // Map<userUuid, { watcher, clients: Set<ws> }>
63
+ const connectedClients = new Set();
64
+
65
+ // Setup file system watcher for a specific user's Claude projects folder
66
+ async function setupUserProjectsWatcher(userUuid, ws) {
67
+ if (!userUuid) {
68
+ console.log('[WARN] Cannot setup projects watcher without userUuid');
69
+ return;
70
+ }
71
+
72
+ const chokidar = (await import('chokidar')).default;
73
+ const userPaths = getUserPaths(userUuid);
74
+ const claudeProjectsPath = path.join(userPaths.claudeDir, 'projects');
75
+
76
+ // Check if we already have a watcher for this user
77
+ if (userWatchers.has(userUuid)) {
78
+ const existing = userWatchers.get(userUuid);
79
+ existing.clients.add(ws);
80
+ console.log(`[INFO] Added client to existing watcher for user ${userUuid}`);
81
+ return;
82
+ }
83
+
84
+ try {
85
+ // Initialize chokidar watcher with optimized settings
86
+ const watcher = chokidar.watch(claudeProjectsPath, {
87
+ ignored: [
88
+ '**/node_modules/**',
89
+ '**/.git/**',
90
+ '**/dist/**',
91
+ '**/build/**',
92
+ '**/*.tmp',
93
+ '**/*.swp',
94
+ '**/.DS_Store'
95
+ ],
96
+ persistent: true,
97
+ ignoreInitial: true,
98
+ followSymlinks: false,
99
+ depth: 10,
100
+ awaitWriteFinish: {
101
+ stabilityThreshold: 100,
102
+ pollInterval: 50
103
+ }
104
+ });
105
+
106
+ const clients = new Set([ws]);
107
+ userWatchers.set(userUuid, { watcher, clients });
108
+
109
+ // Debounce function to prevent excessive notifications
110
+ let debounceTimer;
111
+ const debouncedUpdate = async (eventType, filePath) => {
112
+ clearTimeout(debounceTimer);
113
+ debounceTimer = setTimeout(async () => {
114
+ try {
115
+ // Clear project directory cache when files change
116
+ clearProjectDirectoryCache();
117
+
118
+ // Get updated projects list for this user
119
+ const updatedProjects = await getProjects(userUuid);
120
+
121
+ // Notify all connected clients for this user
122
+ const updateMessage = JSON.stringify({
123
+ type: 'projects_updated',
124
+ projects: updatedProjects,
125
+ timestamp: new Date().toISOString(),
126
+ changeType: eventType,
127
+ changedFile: path.relative(claudeProjectsPath, filePath)
128
+ });
129
+
130
+ const userWatcher = userWatchers.get(userUuid);
131
+ if (userWatcher) {
132
+ userWatcher.clients.forEach(client => {
133
+ if (client.readyState === WebSocket.OPEN) {
134
+ client.send(updateMessage);
135
+ }
136
+ });
137
+ }
138
+ } catch (error) {
139
+ console.error('[ERROR] Error handling project changes:', error);
140
+ }
141
+ }, 300);
142
+ };
143
+
144
+ // Set up event listeners
145
+ watcher
146
+ .on('add', (filePath) => debouncedUpdate('add', filePath))
147
+ .on('change', (filePath) => debouncedUpdate('change', filePath))
148
+ .on('unlink', (filePath) => debouncedUpdate('unlink', filePath))
149
+ .on('addDir', (dirPath) => debouncedUpdate('addDir', dirPath))
150
+ .on('unlinkDir', (dirPath) => debouncedUpdate('unlinkDir', dirPath))
151
+ .on('error', (error) => {
152
+ console.error('[ERROR] Chokidar watcher error:', error);
153
+ })
154
+ .on('ready', () => {
155
+ console.log(`[INFO] Projects watcher ready for user ${userUuid}`);
156
+ });
157
+
158
+ } catch (error) {
159
+ console.error('[ERROR] Failed to setup projects watcher:', error);
160
+ }
161
+ }
162
+
163
+ // Cleanup watcher for a user when client disconnects
164
+ function cleanupUserProjectsWatcher(userUuid, ws) {
165
+ if (!userUuid || !userWatchers.has(userUuid)) {
166
+ return;
167
+ }
168
+
169
+ const userWatcher = userWatchers.get(userUuid);
170
+ userWatcher.clients.delete(ws);
171
+
172
+ // If no more clients, close the watcher
173
+ if (userWatcher.clients.size === 0) {
174
+ console.log(`[INFO] Closing projects watcher for user ${userUuid} (no clients)`);
175
+ userWatcher.watcher.close();
176
+ userWatchers.delete(userUuid);
177
+ }
178
+ }
179
+
180
+
181
+ const app = express();
182
+ const server = http.createServer(app);
183
+
184
+ const ptySessionsMap = new Map();
185
+ const PTY_SESSION_TIMEOUT = 30 * 60 * 1000;
186
+
187
+ // Single WebSocket server that handles both paths
188
+ const wss = new WebSocketServer({
189
+ server,
190
+ verifyClient: (info) => {
191
+ console.log('WebSocket connection attempt to:', info.req.url);
192
+
193
+ // Platform mode: always allow connection
194
+ if (process.env.VITE_IS_PLATFORM === 'true') {
195
+ const user = authenticateWebSocket(null); // Will return first user
196
+ if (!user) {
197
+ console.log('[WARN] Platform mode: No user found in database');
198
+ return false;
199
+ }
200
+ info.req.user = user;
201
+ console.log('[OK] Platform mode WebSocket authenticated for user:', user.username);
202
+ return true;
203
+ }
204
+
205
+ // Normal mode: verify token
206
+ // Extract token from query parameters or headers
207
+ const url = new URL(info.req.url, 'http://localhost');
208
+ const token = url.searchParams.get('token') ||
209
+ info.req.headers.authorization?.split(' ')[1];
210
+
211
+ // Verify token
212
+ const user = authenticateWebSocket(token);
213
+ if (!user) {
214
+ console.log('[WARN] WebSocket authentication failed');
215
+ return false;
216
+ }
217
+
218
+ // Store user info in the request for later use
219
+ info.req.user = user;
220
+ console.log('[OK] WebSocket authenticated for user:', user.username);
221
+ return true;
222
+ }
223
+ });
224
+
225
+ // Make WebSocket server available to routes
226
+ app.locals.wss = wss;
227
+
228
+ app.use(cors());
229
+ app.use(express.json({
230
+ limit: '50mb',
231
+ type: (req) => {
232
+ // Skip multipart/form-data requests (for file uploads like images)
233
+ const contentType = req.headers['content-type'] || '';
234
+ if (contentType.includes('multipart/form-data')) {
235
+ return false;
236
+ }
237
+ return contentType.includes('json');
238
+ }
239
+ }));
240
+ app.use(express.urlencoded({ limit: '50mb', extended: true }));
241
+
242
+ // Public health check endpoint (no authentication required)
243
+ app.get('/health', (req, res) => {
244
+ res.json({
245
+ status: 'ok',
246
+ timestamp: new Date().toISOString()
247
+ });
248
+ });
249
+
250
+ // Optional API key validation (if configured)
251
+ app.use('/api', validateApiKey);
252
+
253
+ // Authentication routes (public)
254
+ app.use('/api/auth', authRoutes);
255
+
256
+ // Projects API Routes (protected)
257
+ app.use('/api/projects', authenticateToken, projectsRoutes);
258
+
259
+ // MCP API Routes (protected)
260
+ app.use('/api/mcp', authenticateToken, mcpRoutes);
261
+
262
+
263
+
264
+ // MCP utilities
265
+ app.use('/api/mcp-utils', authenticateToken, mcpUtilsRoutes);
266
+
267
+ // Commands API Routes (protected)
268
+ app.use('/api/commands', authenticateToken, commandsRoutes);
269
+
270
+ // Admin API Routes (protected, admin only)
271
+ app.use('/api/admin', adminRoutes);
272
+
273
+ // Usage API Routes (protected, admin only)
274
+ app.use('/api/admin/usage', usageRoutes);
275
+
276
+ // Skills API Routes (protected)
277
+ app.use('/api/skills', authenticateToken, skillsRoutes);
278
+
279
+ // Static files served after API routes
280
+ // Add cache control: HTML files should not be cached, but assets can be cached
281
+ app.use(express.static(path.join(__dirname, '../dist'), {
282
+ setHeaders: (res, filePath) => {
283
+ if (filePath.endsWith('.html')) {
284
+ // Prevent HTML caching to avoid service worker issues after builds
285
+ res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
286
+ res.setHeader('Pragma', 'no-cache');
287
+ res.setHeader('Expires', '0');
288
+ } else if (filePath.match(/\.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico)$/)) {
289
+ // Cache static assets for 1 year (they have hashed names)
290
+ res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
291
+ }
292
+ }
293
+ }));
294
+
295
+ // API Routes (protected)
296
+ // /api/config endpoint removed - no longer needed
297
+ // Frontend now uses window.location for WebSocket URLs
298
+
299
+ app.get('/api/projects', authenticateToken, async (req, res) => {
300
+ try {
301
+ const projects = await getProjects(req.user.uuid);
302
+ res.json(projects);
303
+ } catch (error) {
304
+ res.status(500).json({ error: error.message });
305
+ }
306
+ });
307
+
308
+ app.get('/api/projects/:projectName/sessions', authenticateToken, async (req, res) => {
309
+ try {
310
+ const { limit = 5, offset = 0 } = req.query;
311
+ const result = await getSessions(req.params.projectName, parseInt(limit), parseInt(offset), req.user.uuid);
312
+ res.json(result);
313
+ } catch (error) {
314
+ res.status(500).json({ error: error.message });
315
+ }
316
+ });
317
+
318
+ // Get messages for a specific session
319
+ app.get('/api/projects/:projectName/sessions/:sessionId/messages', authenticateToken, async (req, res) => {
320
+ try {
321
+ const { projectName, sessionId } = req.params;
322
+ const { limit, offset } = req.query;
323
+
324
+ // Parse limit and offset if provided
325
+ const parsedLimit = limit ? parseInt(limit, 10) : null;
326
+ const parsedOffset = offset ? parseInt(offset, 10) : 0;
327
+
328
+ const result = await getSessionMessages(projectName, sessionId, parsedLimit, parsedOffset, req.user.uuid);
329
+
330
+ // Handle both old and new response formats
331
+ if (Array.isArray(result)) {
332
+ // Backward compatibility: no pagination parameters were provided
333
+ res.json({ messages: result });
334
+ } else {
335
+ // New format with pagination info
336
+ res.json(result);
337
+ }
338
+ } catch (error) {
339
+ res.status(500).json({ error: error.message });
340
+ }
341
+ });
342
+
343
+ // Rename project endpoint
344
+ app.put('/api/projects/:projectName/rename', authenticateToken, async (req, res) => {
345
+ try {
346
+ const { displayName } = req.body;
347
+ await renameProject(req.params.projectName, displayName, req.user.uuid);
348
+ res.json({ success: true });
349
+ } catch (error) {
350
+ res.status(500).json({ error: error.message });
351
+ }
352
+ });
353
+
354
+ // Delete session endpoint
355
+ app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken, async (req, res) => {
356
+ try {
357
+ const { projectName, sessionId } = req.params;
358
+ console.log(`[API] Deleting session: ${sessionId} from project: ${projectName}`);
359
+ await deleteSession(projectName, sessionId, req.user.uuid);
360
+ console.log(`[API] Session ${sessionId} deleted successfully`);
361
+ res.json({ success: true });
362
+ } catch (error) {
363
+ console.error(`[API] Error deleting session ${req.params.sessionId}:`, error);
364
+ res.status(500).json({ error: error.message });
365
+ }
366
+ });
367
+
368
+ // Delete project endpoint (only if empty)
369
+ app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => {
370
+ try {
371
+ const { projectName } = req.params;
372
+ await deleteProject(projectName, req.user.uuid);
373
+ res.json({ success: true });
374
+ } catch (error) {
375
+ res.status(500).json({ error: error.message });
376
+ }
377
+ });
378
+
379
+ // Create project endpoint
380
+ app.post('/api/projects/create', authenticateToken, async (req, res) => {
381
+ try {
382
+ const { path: projectPath } = req.body;
383
+
384
+ if (!projectPath || !projectPath.trim()) {
385
+ return res.status(400).json({ error: 'Project path is required' });
386
+ }
387
+
388
+ const project = await addProjectManually(projectPath.trim(), null, req.user.uuid);
389
+ res.json({ success: true, project });
390
+ } catch (error) {
391
+ console.error('Error creating project:', error);
392
+ res.status(500).json({ error: error.message });
393
+ }
394
+ });
395
+
396
+ // Read file content endpoint
397
+ app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
398
+ try {
399
+ const { projectName } = req.params;
400
+ const { filePath } = req.query;
401
+
402
+ console.log('[DEBUG] File read request:', projectName, filePath);
403
+
404
+ // Security: ensure the requested path is inside the project root
405
+ if (!filePath) {
406
+ return res.status(400).json({ error: 'Invalid file path' });
407
+ }
408
+
409
+ const projectRoot = await extractProjectDirectory(projectName, req.user.uuid).catch(() => null);
410
+ if (!projectRoot) {
411
+ return res.status(404).json({ error: 'Project not found' });
412
+ }
413
+
414
+ // Handle both absolute and relative paths
415
+ const resolved = path.isAbsolute(filePath)
416
+ ? path.resolve(filePath)
417
+ : path.resolve(projectRoot, filePath);
418
+ const normalizedRoot = path.resolve(projectRoot) + path.sep;
419
+ if (!resolved.startsWith(normalizedRoot)) {
420
+ return res.status(403).json({ error: 'Path must be under project root' });
421
+ }
422
+
423
+ const content = await fsPromises.readFile(resolved, 'utf8');
424
+ res.json({ content, path: resolved });
425
+ } catch (error) {
426
+ console.error('Error reading file:', error);
427
+ if (error.code === 'ENOENT') {
428
+ res.status(404).json({ error: 'File not found' });
429
+ } else if (error.code === 'EACCES') {
430
+ res.status(403).json({ error: 'Permission denied' });
431
+ } else {
432
+ res.status(500).json({ error: error.message });
433
+ }
434
+ }
435
+ });
436
+
437
+ // Serve binary file content endpoint (for images, etc.)
438
+ app.get('/api/projects/:projectName/files/content', authenticateToken, async (req, res) => {
439
+ try {
440
+ const { projectName } = req.params;
441
+ const { path: filePath } = req.query;
442
+
443
+ console.log('[DEBUG] Binary file serve request:', projectName, filePath);
444
+
445
+ // Security: ensure the requested path is inside the project root
446
+ if (!filePath) {
447
+ return res.status(400).json({ error: 'Invalid file path' });
448
+ }
449
+
450
+ const projectRoot = await extractProjectDirectory(projectName, req.user.uuid).catch(() => null);
451
+ if (!projectRoot) {
452
+ return res.status(404).json({ error: 'Project not found' });
453
+ }
454
+
455
+ const resolved = path.resolve(filePath);
456
+ const normalizedRoot = path.resolve(projectRoot) + path.sep;
457
+ if (!resolved.startsWith(normalizedRoot)) {
458
+ return res.status(403).json({ error: 'Path must be under project root' });
459
+ }
460
+
461
+ // Check if file exists
462
+ try {
463
+ await fsPromises.access(resolved);
464
+ } catch (error) {
465
+ return res.status(404).json({ error: 'File not found' });
466
+ }
467
+
468
+ // Get file extension and set appropriate content type
469
+ const mimeType = mime.lookup(resolved) || 'application/octet-stream';
470
+ res.setHeader('Content-Type', mimeType);
471
+
472
+ // Stream the file
473
+ const fileStream = fs.createReadStream(resolved);
474
+ fileStream.pipe(res);
475
+
476
+ fileStream.on('error', (error) => {
477
+ console.error('Error streaming file:', error);
478
+ if (!res.headersSent) {
479
+ res.status(500).json({ error: 'Error reading file' });
480
+ }
481
+ });
482
+
483
+ } catch (error) {
484
+ console.error('Error serving binary file:', error);
485
+ if (!res.headersSent) {
486
+ res.status(500).json({ error: error.message });
487
+ }
488
+ }
489
+ });
490
+
491
+ // Save file content endpoint
492
+ app.put('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
493
+ try {
494
+ const { projectName } = req.params;
495
+ const { filePath, content } = req.body;
496
+
497
+ console.log('[DEBUG] File save request:', projectName, filePath);
498
+
499
+ // Security: ensure the requested path is inside the project root
500
+ if (!filePath) {
501
+ return res.status(400).json({ error: 'Invalid file path' });
502
+ }
503
+
504
+ if (content === undefined) {
505
+ return res.status(400).json({ error: 'Content is required' });
506
+ }
507
+
508
+ const projectRoot = await extractProjectDirectory(projectName, req.user.uuid).catch(() => null);
509
+ if (!projectRoot) {
510
+ return res.status(404).json({ error: 'Project not found' });
511
+ }
512
+
513
+ // Handle both absolute and relative paths
514
+ const resolved = path.isAbsolute(filePath)
515
+ ? path.resolve(filePath)
516
+ : path.resolve(projectRoot, filePath);
517
+ const normalizedRoot = path.resolve(projectRoot) + path.sep;
518
+ if (!resolved.startsWith(normalizedRoot)) {
519
+ return res.status(403).json({ error: 'Path must be under project root' });
520
+ }
521
+
522
+ // Write the new content
523
+ await fsPromises.writeFile(resolved, content, 'utf8');
524
+
525
+ res.json({
526
+ success: true,
527
+ path: resolved,
528
+ message: 'File saved successfully'
529
+ });
530
+ } catch (error) {
531
+ console.error('Error saving file:', error);
532
+ if (error.code === 'ENOENT') {
533
+ res.status(404).json({ error: 'File or directory not found' });
534
+ } else if (error.code === 'EACCES') {
535
+ res.status(403).json({ error: 'Permission denied' });
536
+ } else {
537
+ res.status(500).json({ error: error.message });
538
+ }
539
+ }
540
+ });
541
+
542
+ app.get('/api/projects/:projectName/files', authenticateToken, async (req, res) => {
543
+ try {
544
+
545
+ // Using fsPromises from import
546
+
547
+ // Use extractProjectDirectory to get the actual project path
548
+ let actualPath;
549
+ try {
550
+ actualPath = await extractProjectDirectory(req.params.projectName, req.user.uuid);
551
+ } catch (error) {
552
+ console.error('Error extracting project directory:', error);
553
+ // Fallback to simple dash replacement
554
+ actualPath = req.params.projectName.replace(/-/g, '/');
555
+ }
556
+
557
+ // Check if path exists
558
+ try {
559
+ await fsPromises.access(actualPath);
560
+ } catch (e) {
561
+ return res.status(404).json({ error: `Project path not found: ${actualPath}` });
562
+ }
563
+
564
+ const files = await getFileTree(actualPath, 10, 0, true);
565
+ const hiddenFiles = files.filter(f => f.name.startsWith('.'));
566
+ res.json(files);
567
+ } catch (error) {
568
+ console.error('[ERROR] File tree error:', error.message);
569
+ res.status(500).json({ error: error.message });
570
+ }
571
+ });
572
+
573
+ // WebSocket connection handler that routes based on URL path
574
+ wss.on('connection', (ws, request) => {
575
+ const url = request.url;
576
+ console.log('[INFO] Client connected to:', url);
577
+
578
+ // Get user data from WebSocket authentication
579
+ const userData = request.user;
580
+
581
+ // Parse URL to get pathname without query parameters
582
+ const urlObj = new URL(url, 'http://localhost');
583
+ const pathname = urlObj.pathname;
584
+
585
+ if (pathname === '/shell') {
586
+ handleShellConnection(ws, userData);
587
+ } else if (pathname === '/ws') {
588
+ handleChatConnection(ws, userData);
589
+ } else {
590
+ console.log('[WARN] Unknown WebSocket path:', pathname);
591
+ ws.close();
592
+ }
593
+ });
594
+
595
+ /**
596
+ * WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface
597
+ */
598
+ class WebSocketWriter {
599
+ constructor(ws) {
600
+ this.ws = ws;
601
+ this.sessionId = null;
602
+ this.isWebSocketWriter = true; // Marker for transport detection
603
+ }
604
+
605
+ send(data) {
606
+ if (this.ws.readyState === 1) { // WebSocket.OPEN
607
+ // Providers send raw objects, we stringify for WebSocket
608
+ this.ws.send(JSON.stringify(data));
609
+ }
610
+ }
611
+
612
+ setSessionId(sessionId) {
613
+ this.sessionId = sessionId;
614
+ }
615
+
616
+ getSessionId() {
617
+ return this.sessionId;
618
+ }
619
+ }
620
+
621
+ // Handle chat WebSocket connections
622
+ function handleChatConnection(ws, userData) {
623
+ console.log('[INFO] Chat WebSocket connected');
624
+
625
+ // Extract userUuid from userData (set during WebSocket authentication)
626
+ const userUuid = userData?.uuid || null;
627
+
628
+ // Add to connected clients for project updates
629
+ connectedClients.add(ws);
630
+
631
+ // Setup projects watcher for this user
632
+ if (userUuid) {
633
+ setupUserProjectsWatcher(userUuid, ws);
634
+ }
635
+
636
+ // Wrap WebSocket with writer for consistent interface with SSEStreamWriter
637
+ const writer = new WebSocketWriter(ws);
638
+
639
+ ws.on('message', async (message) => {
640
+ try {
641
+ const data = JSON.parse(message);
642
+
643
+ if (data.type === 'claude-command') {
644
+ console.log('[DEBUG] User message:', data.command || '[Continue/Resume]');
645
+ console.log('📁 Project:', data.options?.projectPath || 'Unknown');
646
+ console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
647
+
648
+ // Use Claude Agents SDK - pass userUuid for user isolation
649
+ await queryClaudeSDK(data.command, {
650
+ ...data.options,
651
+ userUuid,
652
+ }, writer);
653
+ } else if (data.type === 'abort-session') {
654
+ console.log('[DEBUG] Abort session request:', data.sessionId);
655
+ // Use Claude Agents SDK
656
+ const success = await abortClaudeSDKSession(data.sessionId);
657
+
658
+ writer.send({
659
+ type: 'session-aborted',
660
+ sessionId: data.sessionId,
661
+ provider: 'claude',
662
+ success
663
+ });
664
+ } else if (data.type === 'claude-permission-response') {
665
+ // Relay UI approval decisions back into the SDK control flow.
666
+ // This does not persist permissions; it only resolves the in-flight request,
667
+ // introduced so the SDK can resume once the user clicks Allow/Deny.
668
+ if (data.requestId) {
669
+ resolveToolApproval(data.requestId, {
670
+ allow: Boolean(data.allow),
671
+ updatedInput: data.updatedInput,
672
+ message: data.message,
673
+ rememberEntry: data.rememberEntry
674
+ });
675
+ }
676
+ } else if (data.type === 'check-session-status') {
677
+ // Check if a specific session is currently processing
678
+ const sessionId = data.sessionId;
679
+ const isActive = isClaudeSDKSessionActive(sessionId);
680
+
681
+ writer.send({
682
+ type: 'session-status',
683
+ sessionId,
684
+ provider: 'claude',
685
+ isProcessing: isActive
686
+ });
687
+ } else if (data.type === 'get-active-sessions') {
688
+ // Get all currently active sessions
689
+ const activeSessions = {
690
+ claude: getActiveClaudeSDKSessions()
691
+ };
692
+ writer.send({
693
+ type: 'active-sessions',
694
+ sessions: activeSessions
695
+ });
696
+ }
697
+ } catch (error) {
698
+ console.error('[ERROR] Chat WebSocket error:', error.message);
699
+ writer.send({
700
+ type: 'error',
701
+ error: error.message
702
+ });
703
+ }
704
+ });
705
+
706
+ ws.on('close', () => {
707
+ console.log('🔌 Chat client disconnected');
708
+ // Remove from connected clients
709
+ connectedClients.delete(ws);
710
+ // Cleanup projects watcher for this user
711
+ if (userUuid) {
712
+ cleanupUserProjectsWatcher(userUuid, ws);
713
+ }
714
+ });
715
+ }
716
+
717
+ // Handle shell WebSocket connections
718
+ function handleShellConnection(ws, userData) {
719
+ console.log('🐚 Shell client connected');
720
+ const userUuid = userData?.uuid || null;
721
+ let shellProcess = null;
722
+ let ptySessionKey = null;
723
+ let outputBuffer = [];
724
+
725
+ ws.on('message', async (message) => {
726
+ try {
727
+ const data = JSON.parse(message);
728
+ console.log('📨 Shell message received:', data.type);
729
+
730
+ if (data.type === 'init') {
731
+ const projectPath = data.projectPath || process.cwd();
732
+ const sessionId = data.sessionId;
733
+ const hasSession = data.hasSession;
734
+ const provider = data.provider || 'claude';
735
+ const initialCommand = data.initialCommand;
736
+ const isPlainShell = data.isPlainShell || (!!initialCommand && !hasSession) || provider === 'plain-shell';
737
+
738
+ // Login commands (Claude auth) should never reuse cached sessions
739
+ const isLoginCommand = initialCommand && (
740
+ initialCommand.includes('setup-token') ||
741
+ initialCommand.includes('auth login')
742
+ );
743
+
744
+ // Include command hash in session key so different commands get separate sessions
745
+ const commandSuffix = isPlainShell && initialCommand
746
+ ? `_cmd_${Buffer.from(initialCommand).toString('base64').slice(0, 16)}`
747
+ : '';
748
+ ptySessionKey = `${projectPath}_${sessionId || 'default'}${commandSuffix}`;
749
+
750
+ // Kill any existing login session before starting fresh
751
+ if (isLoginCommand) {
752
+ const oldSession = ptySessionsMap.get(ptySessionKey);
753
+ if (oldSession) {
754
+ console.log('🧹 Cleaning up existing login session:', ptySessionKey);
755
+ if (oldSession.timeoutId) clearTimeout(oldSession.timeoutId);
756
+ if (oldSession.pty && oldSession.pty.kill) oldSession.pty.kill();
757
+ ptySessionsMap.delete(ptySessionKey);
758
+ }
759
+ }
760
+
761
+ const existingSession = isLoginCommand ? null : ptySessionsMap.get(ptySessionKey);
762
+ if (existingSession) {
763
+ console.log('♻️ Reconnecting to existing PTY session:', ptySessionKey);
764
+ shellProcess = existingSession.pty;
765
+
766
+ clearTimeout(existingSession.timeoutId);
767
+
768
+ ws.send(JSON.stringify({
769
+ type: 'output',
770
+ data: `\x1b[36m[Reconnected to existing session]\x1b[0m\r\n`
771
+ }));
772
+
773
+ if (existingSession.buffer && existingSession.buffer.length > 0) {
774
+ console.log(`📜 Sending ${existingSession.buffer.length} buffered messages`);
775
+ existingSession.buffer.forEach(bufferedData => {
776
+ ws.send(JSON.stringify({
777
+ type: 'output',
778
+ data: bufferedData
779
+ }));
780
+ });
781
+ }
782
+
783
+ existingSession.ws = ws;
784
+
785
+ return;
786
+ }
787
+
788
+ console.log('[INFO] Starting shell in:', projectPath);
789
+ console.log('📋 Session info:', hasSession ? `Resume session ${sessionId}` : (isPlainShell ? 'Plain shell mode' : 'New session'));
790
+ console.log('🤖 Provider:', isPlainShell ? 'plain-shell' : provider);
791
+ if (initialCommand) {
792
+ console.log('⚡ Initial command:', initialCommand);
793
+ }
794
+
795
+ // First send a welcome message
796
+ let welcomeMsg;
797
+ if (isPlainShell) {
798
+ welcomeMsg = `\x1b[36mStarting terminal in: ${projectPath}\x1b[0m\r\n`;
799
+ } else {
800
+ welcomeMsg = hasSession ?
801
+ `\x1b[36mResuming Claude session ${sessionId} in: ${projectPath}\x1b[0m\r\n` :
802
+ `\x1b[36mStarting new Claude session in: ${projectPath}\x1b[0m\r\n`;
803
+ }
804
+
805
+ ws.send(JSON.stringify({
806
+ type: 'output',
807
+ data: welcomeMsg
808
+ }));
809
+
810
+ try {
811
+ // Prepare the shell command adapted to the platform
812
+ let shellCommand;
813
+ if (isPlainShell) {
814
+ // Plain shell mode - just run the initial command in the project directory
815
+ if (os.platform() === 'win32') {
816
+ shellCommand = `Set-Location -Path "${projectPath}"; ${initialCommand}`;
817
+ } else {
818
+ shellCommand = `cd "${projectPath}" && ${initialCommand}`;
819
+ }
820
+ } else {
821
+ // Use claude command (default) or initialCommand if provided
822
+ const command = initialCommand || 'claude';
823
+ if (os.platform() === 'win32') {
824
+ if (hasSession && sessionId) {
825
+ // Try to resume session, but with fallback to new session if it fails
826
+ shellCommand = `Set-Location -Path "${projectPath}"; claude --resume ${sessionId}; if ($LASTEXITCODE -ne 0) { claude }`;
827
+ } else {
828
+ shellCommand = `Set-Location -Path "${projectPath}"; ${command}`;
829
+ }
830
+ } else {
831
+ if (hasSession && sessionId) {
832
+ shellCommand = `cd "${projectPath}" && claude --resume ${sessionId} || claude`;
833
+ } else {
834
+ shellCommand = `cd "${projectPath}" && ${command}`;
835
+ }
836
+ }
837
+ }
838
+
839
+ console.log('🔧 Executing shell command:', shellCommand);
840
+
841
+ // Use appropriate shell based on platform
842
+ const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
843
+ const shellArgs = os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand];
844
+
845
+ // Use terminal dimensions from client if provided, otherwise use defaults
846
+ const termCols = data.cols || 80;
847
+ const termRows = data.rows || 24;
848
+ console.log('📐 Using terminal dimensions:', termCols, 'x', termRows);
849
+
850
+ // Build environment with user-specific CLAUDE_CONFIG_DIR
851
+ const shellEnv = {
852
+ ...process.env,
853
+ TERM: 'xterm-256color',
854
+ COLORTERM: 'truecolor',
855
+ FORCE_COLOR: '3',
856
+ // Override browser opening commands to echo URL for detection
857
+ BROWSER: os.platform() === 'win32' ? 'echo "OPEN_URL:"' : 'echo "OPEN_URL:"'
858
+ };
859
+
860
+ // Set CLAUDE_CONFIG_DIR for user isolation when running Claude CLI
861
+ if (userUuid) {
862
+ const userPaths = getUserPaths(userUuid);
863
+ shellEnv.CLAUDE_CONFIG_DIR = userPaths.claudeDir;
864
+ console.log('🔐 Set CLAUDE_CONFIG_DIR for user:', userPaths.claudeDir);
865
+ }
866
+
867
+ shellProcess = pty.spawn(shell, shellArgs, {
868
+ name: 'xterm-256color',
869
+ cols: termCols,
870
+ rows: termRows,
871
+ cwd: os.homedir(),
872
+ env: shellEnv
873
+ });
874
+
875
+ console.log('🟢 Shell process started with PTY, PID:', shellProcess.pid);
876
+
877
+ ptySessionsMap.set(ptySessionKey, {
878
+ pty: shellProcess,
879
+ ws: ws,
880
+ buffer: [],
881
+ timeoutId: null,
882
+ projectPath,
883
+ sessionId
884
+ });
885
+
886
+ // Handle data output
887
+ shellProcess.onData((data) => {
888
+ const session = ptySessionsMap.get(ptySessionKey);
889
+ if (!session) return;
890
+
891
+ if (session.buffer.length < 5000) {
892
+ session.buffer.push(data);
893
+ } else {
894
+ session.buffer.shift();
895
+ session.buffer.push(data);
896
+ }
897
+
898
+ if (session.ws && session.ws.readyState === WebSocket.OPEN) {
899
+ let outputData = data;
900
+
901
+ // Check for various URL opening patterns
902
+ const patterns = [
903
+ // Direct browser opening commands
904
+ /(?:xdg-open|open|start)\s+(https?:\/\/[^\s\x1b\x07]+)/g,
905
+ // BROWSER environment variable override
906
+ /OPEN_URL:\s*(https?:\/\/[^\s\x1b\x07]+)/g,
907
+ // Git and other tools opening URLs
908
+ /Opening\s+(https?:\/\/[^\s\x1b\x07]+)/gi,
909
+ // General URL patterns that might be opened
910
+ /Visit:\s*(https?:\/\/[^\s\x1b\x07]+)/gi,
911
+ /View at:\s*(https?:\/\/[^\s\x1b\x07]+)/gi,
912
+ /Browse to:\s*(https?:\/\/[^\s\x1b\x07]+)/gi
913
+ ];
914
+
915
+ patterns.forEach(pattern => {
916
+ let match;
917
+ while ((match = pattern.exec(data)) !== null) {
918
+ const url = match[1];
919
+ console.log('[DEBUG] Detected URL for opening:', url);
920
+
921
+ // Send URL opening message to client
922
+ session.ws.send(JSON.stringify({
923
+ type: 'url_open',
924
+ url: url
925
+ }));
926
+
927
+ // Replace the OPEN_URL pattern with a user-friendly message
928
+ if (pattern.source.includes('OPEN_URL')) {
929
+ outputData = outputData.replace(match[0], `[INFO] Opening in browser: ${url}`);
930
+ }
931
+ }
932
+ });
933
+
934
+ // Send regular output
935
+ session.ws.send(JSON.stringify({
936
+ type: 'output',
937
+ data: outputData
938
+ }));
939
+ }
940
+ });
941
+
942
+ // Handle process exit
943
+ shellProcess.onExit((exitCode) => {
944
+ console.log('🔚 Shell process exited with code:', exitCode.exitCode, 'signal:', exitCode.signal);
945
+ const session = ptySessionsMap.get(ptySessionKey);
946
+ if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {
947
+ session.ws.send(JSON.stringify({
948
+ type: 'output',
949
+ data: `\r\n\x1b[33mProcess exited with code ${exitCode.exitCode}${exitCode.signal ? ` (${exitCode.signal})` : ''}\x1b[0m\r\n`
950
+ }));
951
+ }
952
+ if (session && session.timeoutId) {
953
+ clearTimeout(session.timeoutId);
954
+ }
955
+ ptySessionsMap.delete(ptySessionKey);
956
+ shellProcess = null;
957
+ });
958
+
959
+ } catch (spawnError) {
960
+ console.error('[ERROR] Error spawning process:', spawnError);
961
+ ws.send(JSON.stringify({
962
+ type: 'output',
963
+ data: `\r\n\x1b[31mError: ${spawnError.message}\x1b[0m\r\n`
964
+ }));
965
+ }
966
+
967
+ } else if (data.type === 'input') {
968
+ // Send input to shell process
969
+ if (shellProcess && shellProcess.write) {
970
+ try {
971
+ shellProcess.write(data.data);
972
+ } catch (error) {
973
+ console.error('Error writing to shell:', error);
974
+ }
975
+ } else {
976
+ console.warn('No active shell process to send input to');
977
+ }
978
+ } else if (data.type === 'resize') {
979
+ // Handle terminal resize
980
+ if (shellProcess && shellProcess.resize) {
981
+ console.log('Terminal resize requested:', data.cols, 'x', data.rows);
982
+ shellProcess.resize(data.cols, data.rows);
983
+ }
984
+ } else if (data.type === 'terminate') {
985
+ // Handle terminate request - kill the PTY process
986
+ console.log('🛑 Terminate request received for session:', ptySessionKey);
987
+ const session = ptySessionsMap.get(ptySessionKey);
988
+ if (session) {
989
+ if (session.timeoutId) {
990
+ clearTimeout(session.timeoutId);
991
+ }
992
+ if (session.pty && session.pty.kill) {
993
+ session.pty.kill();
994
+ }
995
+ ptySessionsMap.delete(ptySessionKey);
996
+ console.log('✅ PTY session terminated:', ptySessionKey);
997
+ }
998
+ shellProcess = null;
999
+ }
1000
+ } catch (error) {
1001
+ console.error('[ERROR] Shell WebSocket error:', error.message);
1002
+ if (ws.readyState === WebSocket.OPEN) {
1003
+ ws.send(JSON.stringify({
1004
+ type: 'output',
1005
+ data: `\r\n\x1b[31mError: ${error.message}\x1b[0m\r\n`
1006
+ }));
1007
+ }
1008
+ }
1009
+ });
1010
+
1011
+ ws.on('close', () => {
1012
+ console.log('🔌 Shell client disconnected');
1013
+
1014
+ if (ptySessionKey) {
1015
+ const session = ptySessionsMap.get(ptySessionKey);
1016
+ if (session) {
1017
+ console.log('⏳ PTY session kept alive, will timeout in 30 minutes:', ptySessionKey);
1018
+ session.ws = null;
1019
+
1020
+ session.timeoutId = setTimeout(() => {
1021
+ console.log('⏰ PTY session timeout, killing process:', ptySessionKey);
1022
+ if (session.pty && session.pty.kill) {
1023
+ session.pty.kill();
1024
+ }
1025
+ ptySessionsMap.delete(ptySessionKey);
1026
+ }, PTY_SESSION_TIMEOUT);
1027
+ }
1028
+ }
1029
+ });
1030
+
1031
+ ws.on('error', (error) => {
1032
+ console.error('[ERROR] Shell WebSocket error:', error);
1033
+ });
1034
+ }
1035
+ // Audio transcription endpoint
1036
+ app.post('/api/transcribe', authenticateToken, async (req, res) => {
1037
+ try {
1038
+ const multer = (await import('multer')).default;
1039
+ const upload = multer({ storage: multer.memoryStorage() });
1040
+
1041
+ // Handle multipart form data
1042
+ upload.single('audio')(req, res, async (err) => {
1043
+ if (err) {
1044
+ return res.status(400).json({ error: 'Failed to process audio file' });
1045
+ }
1046
+
1047
+ if (!req.file) {
1048
+ return res.status(400).json({ error: 'No audio file provided' });
1049
+ }
1050
+
1051
+ const apiKey = process.env.OPENAI_API_KEY;
1052
+ if (!apiKey) {
1053
+ return res.status(500).json({ error: 'OpenAI API key not configured. Please set OPENAI_API_KEY in server environment.' });
1054
+ }
1055
+
1056
+ try {
1057
+ // Create form data for OpenAI
1058
+ const FormData = (await import('form-data')).default;
1059
+ const formData = new FormData();
1060
+ formData.append('file', req.file.buffer, {
1061
+ filename: req.file.originalname,
1062
+ contentType: req.file.mimetype
1063
+ });
1064
+ formData.append('model', 'whisper-1');
1065
+ formData.append('response_format', 'json');
1066
+ formData.append('language', 'en');
1067
+
1068
+ // Make request to OpenAI
1069
+ const response = await fetch('https://api.openai.com/v1/audio/transcriptions', {
1070
+ method: 'POST',
1071
+ headers: {
1072
+ 'Authorization': `Bearer ${apiKey}`,
1073
+ ...formData.getHeaders()
1074
+ },
1075
+ body: formData
1076
+ });
1077
+
1078
+ if (!response.ok) {
1079
+ const errorData = await response.json().catch(() => ({}));
1080
+ throw new Error(errorData.error?.message || `Whisper API error: ${response.status}`);
1081
+ }
1082
+
1083
+ const data = await response.json();
1084
+ let transcribedText = data.text || '';
1085
+
1086
+ // Check if enhancement mode is enabled
1087
+ const mode = req.body.mode || 'default';
1088
+
1089
+ // If no transcribed text, return empty
1090
+ if (!transcribedText) {
1091
+ return res.json({ text: '' });
1092
+ }
1093
+
1094
+ // If default mode, return transcribed text without enhancement
1095
+ if (mode === 'default') {
1096
+ return res.json({ text: transcribedText });
1097
+ }
1098
+
1099
+ // Handle different enhancement modes
1100
+ try {
1101
+ const OpenAI = (await import('openai')).default;
1102
+ const openai = new OpenAI({ apiKey });
1103
+
1104
+ let prompt, systemMessage, temperature = 0.7, maxTokens = 800;
1105
+
1106
+ switch (mode) {
1107
+ case 'prompt':
1108
+ systemMessage = 'You are an expert prompt engineer who creates clear, detailed, and effective prompts.';
1109
+ prompt = `You are an expert prompt engineer. Transform the following rough instruction into a clear, detailed, and context-aware AI prompt.
1110
+
1111
+ Your enhanced prompt should:
1112
+ 1. Be specific and unambiguous
1113
+ 2. Include relevant context and constraints
1114
+ 3. Specify the desired output format
1115
+ 4. Use clear, actionable language
1116
+ 5. Include examples where helpful
1117
+ 6. Consider edge cases and potential ambiguities
1118
+
1119
+ Transform this rough instruction into a well-crafted prompt:
1120
+ "${transcribedText}"
1121
+
1122
+ Enhanced prompt:`;
1123
+ break;
1124
+
1125
+ case 'vibe':
1126
+ case 'instructions':
1127
+ case 'architect':
1128
+ systemMessage = 'You are a helpful assistant that formats ideas into clear, actionable instructions for AI agents.';
1129
+ temperature = 0.5; // Lower temperature for more controlled output
1130
+ prompt = `Transform the following idea into clear, well-structured instructions that an AI agent can easily understand and execute.
1131
+
1132
+ IMPORTANT RULES:
1133
+ - Format as clear, step-by-step instructions
1134
+ - Add reasonable implementation details based on common patterns
1135
+ - Only include details directly related to what was asked
1136
+ - Do NOT add features or functionality not mentioned
1137
+ - Keep the original intent and scope intact
1138
+ - Use clear, actionable language an agent can follow
1139
+
1140
+ Transform this idea into agent-friendly instructions:
1141
+ "${transcribedText}"
1142
+
1143
+ Agent instructions:`;
1144
+ break;
1145
+
1146
+ default:
1147
+ // No enhancement needed
1148
+ break;
1149
+ }
1150
+
1151
+ // Only make GPT call if we have a prompt
1152
+ if (prompt) {
1153
+ const completion = await openai.chat.completions.create({
1154
+ model: 'gpt-4o-mini',
1155
+ messages: [
1156
+ { role: 'system', content: systemMessage },
1157
+ { role: 'user', content: prompt }
1158
+ ],
1159
+ temperature: temperature,
1160
+ max_tokens: maxTokens
1161
+ });
1162
+
1163
+ transcribedText = completion.choices[0].message.content || transcribedText;
1164
+ }
1165
+
1166
+ } catch (gptError) {
1167
+ console.error('GPT processing error:', gptError);
1168
+ // Fall back to original transcription if GPT fails
1169
+ }
1170
+
1171
+ res.json({ text: transcribedText });
1172
+
1173
+ } catch (error) {
1174
+ console.error('Transcription error:', error);
1175
+ res.status(500).json({ error: error.message });
1176
+ }
1177
+ });
1178
+ } catch (error) {
1179
+ console.error('Endpoint error:', error);
1180
+ res.status(500).json({ error: 'Internal server error' });
1181
+ }
1182
+ });
1183
+
1184
+ // Image upload endpoint
1185
+ app.post('/api/projects/:projectName/upload-images', authenticateToken, async (req, res) => {
1186
+ try {
1187
+ const multer = (await import('multer')).default;
1188
+ const path = (await import('path')).default;
1189
+ const fs = (await import('fs')).promises;
1190
+ const os = (await import('os')).default;
1191
+
1192
+ // Configure multer for image uploads
1193
+ const storage = multer.diskStorage({
1194
+ destination: async (req, file, cb) => {
1195
+ const uploadDir = path.join(os.tmpdir(), 'claude-ui-uploads', String(req.user.id));
1196
+ await fs.mkdir(uploadDir, { recursive: true });
1197
+ cb(null, uploadDir);
1198
+ },
1199
+ filename: (req, file, cb) => {
1200
+ const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
1201
+ const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_');
1202
+ cb(null, uniqueSuffix + '-' + sanitizedName);
1203
+ }
1204
+ });
1205
+
1206
+ const fileFilter = (req, file, cb) => {
1207
+ const allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
1208
+ if (allowedMimes.includes(file.mimetype)) {
1209
+ cb(null, true);
1210
+ } else {
1211
+ cb(new Error('Invalid file type. Only JPEG, PNG, GIF, WebP, and SVG are allowed.'));
1212
+ }
1213
+ };
1214
+
1215
+ const upload = multer({
1216
+ storage,
1217
+ fileFilter,
1218
+ limits: {
1219
+ fileSize: 5 * 1024 * 1024, // 5MB
1220
+ files: 5
1221
+ }
1222
+ });
1223
+
1224
+ // Handle multipart form data
1225
+ upload.array('images', 5)(req, res, async (err) => {
1226
+ if (err) {
1227
+ return res.status(400).json({ error: err.message });
1228
+ }
1229
+
1230
+ if (!req.files || req.files.length === 0) {
1231
+ return res.status(400).json({ error: 'No image files provided' });
1232
+ }
1233
+
1234
+ try {
1235
+ // Process uploaded images
1236
+ const processedImages = await Promise.all(
1237
+ req.files.map(async (file) => {
1238
+ // Read file and convert to base64
1239
+ const buffer = await fs.readFile(file.path);
1240
+ const base64 = buffer.toString('base64');
1241
+ const mimeType = file.mimetype;
1242
+
1243
+ // Clean up temp file immediately
1244
+ await fs.unlink(file.path);
1245
+
1246
+ return {
1247
+ name: file.originalname,
1248
+ data: `data:${mimeType};base64,${base64}`,
1249
+ size: file.size,
1250
+ mimeType: mimeType
1251
+ };
1252
+ })
1253
+ );
1254
+
1255
+ res.json({ images: processedImages });
1256
+ } catch (error) {
1257
+ console.error('Error processing images:', error);
1258
+ // Clean up any remaining files
1259
+ await Promise.all(req.files.map(f => fs.unlink(f.path).catch(() => { })));
1260
+ res.status(500).json({ error: 'Failed to process images' });
1261
+ }
1262
+ });
1263
+ } catch (error) {
1264
+ console.error('Error in image upload endpoint:', error);
1265
+ res.status(500).json({ error: 'Internal server error' });
1266
+ }
1267
+ });
1268
+
1269
+ // File upload endpoint for FileTree
1270
+ app.post('/api/projects/:projectName/upload-files', authenticateToken, async (req, res) => {
1271
+ try {
1272
+ const multer = (await import('multer')).default;
1273
+
1274
+ const { projectName } = req.params;
1275
+
1276
+ // Get project root directory
1277
+ const projectRoot = await extractProjectDirectory(projectName, req.user.uuid).catch(() => null);
1278
+ if (!projectRoot) {
1279
+ return res.status(404).json({ error: 'Project not found' });
1280
+ }
1281
+
1282
+ // Configure multer for file uploads
1283
+ const storage = multer.diskStorage({
1284
+ destination: async (req, file, cb) => {
1285
+ try {
1286
+ // Get target directory from form data, default to project root
1287
+ const targetDir = req.body.targetDir || '';
1288
+
1289
+ // Resolve and validate target path
1290
+ const resolvedTarget = path.resolve(projectRoot, targetDir);
1291
+ const normalizedRoot = path.resolve(projectRoot);
1292
+
1293
+ // Security: ensure target is within project root
1294
+ if (!resolvedTarget.startsWith(normalizedRoot)) {
1295
+ return cb(new Error('Invalid target directory'));
1296
+ }
1297
+
1298
+ // Ensure directory exists
1299
+ await fsPromises.mkdir(resolvedTarget, { recursive: true });
1300
+ cb(null, resolvedTarget);
1301
+ } catch (err) {
1302
+ console.error('Failed to create target directory:', err);
1303
+ cb(new Error('Failed to create target directory'));
1304
+ }
1305
+ },
1306
+ filename: (req, file, cb) => {
1307
+ // Decode filename from Latin1 to UTF-8 (multer uses Latin1 by default)
1308
+ const decodedName = Buffer.from(file.originalname, 'latin1').toString('utf8');
1309
+ // Sanitize filename but preserve original name
1310
+ const sanitizedName = decodedName
1311
+ .replace(/[<>:"/\\|?*\x00-\x1f]/g, '_') // Remove invalid chars
1312
+ .replace(/^\.+/, '_'); // Don't allow leading dots (hidden files)
1313
+ cb(null, sanitizedName);
1314
+ }
1315
+ });
1316
+
1317
+ // File filter - block dangerous file types
1318
+ const fileFilter = (req, file, cb) => {
1319
+ const dangerousExtensions = ['.exe', '.bat', '.cmd', '.sh', '.ps1', '.dll', '.so'];
1320
+ const decodedName = Buffer.from(file.originalname, 'latin1').toString('utf8');
1321
+ const ext = path.extname(decodedName).toLowerCase();
1322
+
1323
+ if (dangerousExtensions.includes(ext)) {
1324
+ return cb(new Error(`File type ${ext} is not allowed`));
1325
+ }
1326
+
1327
+ cb(null, true);
1328
+ };
1329
+
1330
+ const upload = multer({
1331
+ storage,
1332
+ fileFilter,
1333
+ limits: {
1334
+ files: 20 // Max 20 files at once
1335
+ }
1336
+ });
1337
+
1338
+ // Handle multipart form data
1339
+ upload.array('files', 20)(req, res, async (err) => {
1340
+ if (err) {
1341
+ if (err.code === 'LIMIT_FILE_COUNT') {
1342
+ return res.status(400).json({ error: 'Too many files. Maximum is 20 files.' });
1343
+ }
1344
+ return res.status(400).json({ error: err.message });
1345
+ }
1346
+
1347
+ if (!req.files || req.files.length === 0) {
1348
+ return res.status(400).json({ error: 'No files provided' });
1349
+ }
1350
+
1351
+ // Return list of uploaded files
1352
+ const uploadedFiles = req.files.map(file => ({
1353
+ name: file.filename,
1354
+ originalName: Buffer.from(file.originalname, 'latin1').toString('utf8'),
1355
+ path: file.path,
1356
+ size: file.size,
1357
+ mimeType: file.mimetype
1358
+ }));
1359
+
1360
+ res.json({
1361
+ success: true,
1362
+ files: uploadedFiles,
1363
+ count: uploadedFiles.length
1364
+ });
1365
+ });
1366
+
1367
+ } catch (error) {
1368
+ console.error('Error in file upload endpoint:', error);
1369
+ res.status(500).json({ error: 'Internal server error' });
1370
+ }
1371
+ });
1372
+
1373
+ // Delete file or folder endpoint
1374
+ app.delete('/api/projects/:projectName/files', authenticateToken, async (req, res) => {
1375
+ try {
1376
+ const { projectName } = req.params;
1377
+ const { filePath } = req.query;
1378
+
1379
+ if (!filePath) {
1380
+ return res.status(400).json({ error: 'File path is required' });
1381
+ }
1382
+
1383
+ // Get project root directory
1384
+ const projectRoot = await extractProjectDirectory(projectName, req.user.uuid).catch(() => null);
1385
+ if (!projectRoot) {
1386
+ return res.status(404).json({ error: 'Project not found' });
1387
+ }
1388
+
1389
+ // Resolve and validate target path
1390
+ const resolvedPath = path.resolve(projectRoot, filePath);
1391
+ const normalizedRoot = path.resolve(projectRoot);
1392
+
1393
+ // Security: ensure path is within project root
1394
+ if (!resolvedPath.startsWith(normalizedRoot) || resolvedPath === normalizedRoot) {
1395
+ return res.status(403).json({ error: 'Invalid file path' });
1396
+ }
1397
+
1398
+ // Check if file/folder exists
1399
+ try {
1400
+ await fsPromises.access(resolvedPath);
1401
+ } catch {
1402
+ return res.status(404).json({ error: 'File or folder not found' });
1403
+ }
1404
+
1405
+ // Get file stats to determine if it's a file or directory
1406
+ const stats = await fsPromises.stat(resolvedPath);
1407
+
1408
+ if (stats.isDirectory()) {
1409
+ // Delete directory recursively
1410
+ await fsPromises.rm(resolvedPath, { recursive: true, force: true });
1411
+ } else {
1412
+ // Delete file
1413
+ await fsPromises.unlink(resolvedPath);
1414
+ }
1415
+
1416
+ res.json({
1417
+ success: true,
1418
+ deleted: filePath,
1419
+ type: stats.isDirectory() ? 'directory' : 'file'
1420
+ });
1421
+
1422
+ } catch (error) {
1423
+ console.error('Error deleting file/folder:', error);
1424
+ res.status(500).json({ error: 'Failed to delete file or folder' });
1425
+ }
1426
+ });
1427
+
1428
+ // Get token usage for a specific session
1429
+ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => {
1430
+ try {
1431
+ const { projectName, sessionId } = req.params;
1432
+ const userUuid = req.user?.uuid;
1433
+
1434
+ if (!userUuid) {
1435
+ return res.status(401).json({ error: 'User authentication required' });
1436
+ }
1437
+
1438
+ // Allow only safe characters in sessionId
1439
+ const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
1440
+ if (!safeSessionId) {
1441
+ return res.status(400).json({ error: 'Invalid sessionId' });
1442
+ }
1443
+
1444
+ // Handle Claude sessions
1445
+ // Extract actual project path
1446
+ let projectPath;
1447
+ try {
1448
+ projectPath = await extractProjectDirectory(projectName, userUuid);
1449
+ } catch (error) {
1450
+ console.error('Error extracting project directory:', error);
1451
+ return res.status(500).json({ error: 'Failed to determine project path' });
1452
+ }
1453
+
1454
+ // Construct the JSONL file path using user-specific directory
1455
+ // Claude stores session files in [claudeDir]/projects/[encoded-project-path]/[session-id].jsonl
1456
+ const userPaths = getUserPaths(userUuid);
1457
+ const encodedPath = projectPath.replace(/[\\/:\s~_]/g, '-');
1458
+ const projectDir = path.join(userPaths.claudeDir, 'projects', encodedPath);
1459
+
1460
+ const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
1461
+
1462
+ // Constrain to projectDir
1463
+ const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath));
1464
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
1465
+ return res.status(400).json({ error: 'Invalid path' });
1466
+ }
1467
+
1468
+ // Read and parse the JSONL file
1469
+ let fileContent;
1470
+ try {
1471
+ fileContent = await fsPromises.readFile(jsonlPath, 'utf8');
1472
+ } catch (error) {
1473
+ if (error.code === 'ENOENT') {
1474
+ return res.status(404).json({ error: 'Session file not found', path: jsonlPath });
1475
+ }
1476
+ throw error; // Re-throw other errors to be caught by outer try-catch
1477
+ }
1478
+ const lines = fileContent.trim().split('\n');
1479
+
1480
+ const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10);
1481
+ const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000;
1482
+ let inputTokens = 0;
1483
+ let cacheCreationTokens = 0;
1484
+ let cacheReadTokens = 0;
1485
+
1486
+ // Find the latest assistant message with usage data (scan from end)
1487
+ for (let i = lines.length - 1; i >= 0; i--) {
1488
+ try {
1489
+ const entry = JSON.parse(lines[i]);
1490
+
1491
+ // Only count assistant messages which have usage data
1492
+ if (entry.type === 'assistant' && entry.message?.usage) {
1493
+ const usage = entry.message.usage;
1494
+
1495
+ // Use token counts from latest assistant message only
1496
+ inputTokens = usage.input_tokens || 0;
1497
+ cacheCreationTokens = usage.cache_creation_input_tokens || 0;
1498
+ cacheReadTokens = usage.cache_read_input_tokens || 0;
1499
+
1500
+ break; // Stop after finding the latest assistant message
1501
+ }
1502
+ } catch (parseError) {
1503
+ // Skip lines that can't be parsed
1504
+ continue;
1505
+ }
1506
+ }
1507
+
1508
+ // Calculate total context usage (excluding output_tokens, as per ccusage)
1509
+ const totalUsed = inputTokens + cacheCreationTokens + cacheReadTokens;
1510
+
1511
+ res.json({
1512
+ used: totalUsed,
1513
+ total: contextWindow,
1514
+ breakdown: {
1515
+ input: inputTokens,
1516
+ cacheCreation: cacheCreationTokens,
1517
+ cacheRead: cacheReadTokens
1518
+ }
1519
+ });
1520
+ } catch (error) {
1521
+ console.error('Error reading session token usage:', error);
1522
+ res.status(500).json({ error: 'Failed to read session token usage' });
1523
+ }
1524
+ });
1525
+
1526
+ // Serve React app for all other routes (excluding static files)
1527
+ app.get('*', (req, res) => {
1528
+ // Skip requests for static assets (files with extensions)
1529
+ if (path.extname(req.path)) {
1530
+ return res.status(404).send('Not found');
1531
+ }
1532
+
1533
+ // Only serve index.html for HTML routes, not for static assets
1534
+ // Static assets should already be handled by express.static middleware above
1535
+ const indexPath = path.join(__dirname, '../dist/index.html');
1536
+
1537
+ // Check if dist/index.html exists (production build available)
1538
+ if (fs.existsSync(indexPath)) {
1539
+ // Set no-cache headers for HTML to prevent service worker issues
1540
+ res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
1541
+ res.setHeader('Pragma', 'no-cache');
1542
+ res.setHeader('Expires', '0');
1543
+ res.sendFile(indexPath);
1544
+ } else {
1545
+ // In development, redirect to Vite dev server only if dist doesn't exist
1546
+ res.redirect(`http://localhost:${process.env.VITE_PORT || 5173}`);
1547
+ }
1548
+ });
1549
+
1550
+ // Helper function to convert permissions to rwx format
1551
+ function permToRwx(perm) {
1552
+ const r = perm & 4 ? 'r' : '-';
1553
+ const w = perm & 2 ? 'w' : '-';
1554
+ const x = perm & 1 ? 'x' : '-';
1555
+ return r + w + x;
1556
+ }
1557
+
1558
+ async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden = true) {
1559
+ // Using fsPromises from import
1560
+ const items = [];
1561
+
1562
+ try {
1563
+ const entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
1564
+
1565
+ for (const entry of entries) {
1566
+ // Debug: log all entries including hidden files
1567
+
1568
+
1569
+ // Skip heavy build directories and VCS directories
1570
+ if (entry.name === 'node_modules' ||
1571
+ entry.name === 'dist' ||
1572
+ entry.name === 'build' ||
1573
+ entry.name === '.git' ||
1574
+ entry.name === '.svn' ||
1575
+ entry.name === '.hg') continue;
1576
+
1577
+ const itemPath = path.join(dirPath, entry.name);
1578
+ const item = {
1579
+ name: entry.name,
1580
+ path: itemPath,
1581
+ type: entry.isDirectory() ? 'directory' : 'file'
1582
+ };
1583
+
1584
+ // Get file stats for additional metadata
1585
+ try {
1586
+ const stats = await fsPromises.stat(itemPath);
1587
+ item.size = stats.size;
1588
+ item.modified = stats.mtime.toISOString();
1589
+
1590
+ // Convert permissions to rwx format
1591
+ const mode = stats.mode;
1592
+ const ownerPerm = (mode >> 6) & 7;
1593
+ const groupPerm = (mode >> 3) & 7;
1594
+ const otherPerm = mode & 7;
1595
+ item.permissions = ((mode >> 6) & 7).toString() + ((mode >> 3) & 7).toString() + (mode & 7).toString();
1596
+ item.permissionsRwx = permToRwx(ownerPerm) + permToRwx(groupPerm) + permToRwx(otherPerm);
1597
+ } catch (statError) {
1598
+ // If stat fails, provide default values
1599
+ item.size = 0;
1600
+ item.modified = null;
1601
+ item.permissions = '000';
1602
+ item.permissionsRwx = '---------';
1603
+ }
1604
+
1605
+ if (entry.isDirectory() && currentDepth < maxDepth) {
1606
+ // Recursively get subdirectories but limit depth
1607
+ try {
1608
+ // Check if we can access the directory before trying to read it
1609
+ await fsPromises.access(item.path, fs.constants.R_OK);
1610
+ item.children = await getFileTree(item.path, maxDepth, currentDepth + 1, showHidden);
1611
+ } catch (e) {
1612
+ // Silently skip directories we can't access (permission denied, etc.)
1613
+ item.children = [];
1614
+ }
1615
+ }
1616
+
1617
+ items.push(item);
1618
+ }
1619
+ } catch (error) {
1620
+ // Only log non-permission errors to avoid spam
1621
+ if (error.code !== 'EACCES' && error.code !== 'EPERM') {
1622
+ console.error('Error reading directory:', error);
1623
+ }
1624
+ }
1625
+
1626
+ return items.sort((a, b) => {
1627
+ if (a.type !== b.type) {
1628
+ return a.type === 'directory' ? -1 : 1;
1629
+ }
1630
+ return a.name.localeCompare(b.name);
1631
+ });
1632
+ }
1633
+
1634
+ const PORT = process.env.PORT || 3001;
1635
+
1636
+ // Initialize database and start server
1637
+ async function startServer() {
1638
+ try {
1639
+ // Initialize authentication database
1640
+ await initializeDatabase();
1641
+
1642
+ // Check if running in production mode (dist folder exists)
1643
+ const distIndexPath = path.join(__dirname, '../dist/index.html');
1644
+ const isProduction = fs.existsSync(distIndexPath);
1645
+
1646
+ // Log Claude implementation mode
1647
+ console.log(`${c.info('[INFO]')} Using Claude Agents SDK for Claude integration`);
1648
+ console.log(`${c.info('[INFO]')} Running in ${c.bright(isProduction ? 'PRODUCTION' : 'DEVELOPMENT')} mode`);
1649
+
1650
+ if (!isProduction) {
1651
+ console.log(`${c.warn('[WARN]')} Note: Requests will be proxied to Vite dev server at ${c.dim('http://localhost:' + (process.env.VITE_PORT || 5173))}`);
1652
+ }
1653
+
1654
+ server.listen(PORT, '0.0.0.0', async () => {
1655
+ const appInstallPath = path.join(__dirname, '..');
1656
+
1657
+ console.log('');
1658
+ console.log(c.dim('═'.repeat(63)));
1659
+ console.log(` ${c.bright('Claude Code UI Server - Ready')}`);
1660
+ console.log(c.dim('═'.repeat(63)));
1661
+ console.log('');
1662
+ console.log(`${c.info('[INFO]')} Server URL: ${c.bright('http://0.0.0.0:' + PORT)}`);
1663
+ console.log(`${c.info('[INFO]')} Installed at: ${c.dim(appInstallPath)}`);
1664
+ console.log(`${c.tip('[TIP]')} Run "cloudcli status" for full configuration details`);
1665
+ console.log('');
1666
+
1667
+ // Start usage scanner service
1668
+ startUsageScanner();
1669
+
1670
+ // Projects watcher is now per-user, initialized when user connects via WebSocket
1671
+ });
1672
+ } catch (error) {
1673
+ console.error('[ERROR] Failed to start server:', error);
1674
+ process.exit(1);
1675
+ }
1676
+ }
1677
+
1678
+ startServer();