@epiphytic/claudecodeui 1.0.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 (142) hide show
  1. package/LICENSE +675 -0
  2. package/README.md +414 -0
  3. package/dist/api-docs.html +879 -0
  4. package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  5. package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  6. package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  7. package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  8. package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  9. package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  10. package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  11. package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  12. package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  13. package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  14. package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  15. package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  16. package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  17. package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  18. package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  19. package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  20. package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  21. package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  22. package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  23. package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  24. package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  25. package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  26. package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  27. package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  28. package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  29. package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  30. package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  31. package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  32. package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  33. package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  34. package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  35. package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  36. package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  37. package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  38. package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  39. package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  40. package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  41. package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  42. package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  43. package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  44. package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  45. package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  46. package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  47. package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  48. package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  49. package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  50. package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  51. package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  52. package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  53. package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  54. package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  55. package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  56. package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  57. package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  58. package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  59. package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  60. package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  61. package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  62. package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  63. package/dist/assets/index-DfR9xEkp.css +32 -0
  64. package/dist/assets/index-DvlVn6Eb.js +1231 -0
  65. package/dist/assets/vendor-codemirror-CJLzwpLB.js +39 -0
  66. package/dist/assets/vendor-react-DcyRfQm3.js +59 -0
  67. package/dist/assets/vendor-xterm-DfaPXD3y.js +66 -0
  68. package/dist/clear-cache.html +85 -0
  69. package/dist/convert-icons.md +53 -0
  70. package/dist/favicon.png +0 -0
  71. package/dist/favicon.svg +9 -0
  72. package/dist/generate-icons.js +49 -0
  73. package/dist/icons/claude-ai-icon.svg +1 -0
  74. package/dist/icons/codex-white.svg +3 -0
  75. package/dist/icons/codex.svg +3 -0
  76. package/dist/icons/cursor-white.svg +12 -0
  77. package/dist/icons/cursor.svg +1 -0
  78. package/dist/icons/generate-icons.md +19 -0
  79. package/dist/icons/icon-128x128.png +0 -0
  80. package/dist/icons/icon-128x128.svg +12 -0
  81. package/dist/icons/icon-144x144.png +0 -0
  82. package/dist/icons/icon-144x144.svg +12 -0
  83. package/dist/icons/icon-152x152.png +0 -0
  84. package/dist/icons/icon-152x152.svg +12 -0
  85. package/dist/icons/icon-192x192.png +0 -0
  86. package/dist/icons/icon-192x192.svg +12 -0
  87. package/dist/icons/icon-384x384.png +0 -0
  88. package/dist/icons/icon-384x384.svg +12 -0
  89. package/dist/icons/icon-512x512.png +0 -0
  90. package/dist/icons/icon-512x512.svg +12 -0
  91. package/dist/icons/icon-72x72.png +0 -0
  92. package/dist/icons/icon-72x72.svg +12 -0
  93. package/dist/icons/icon-96x96.png +0 -0
  94. package/dist/icons/icon-96x96.svg +12 -0
  95. package/dist/icons/icon-template.svg +12 -0
  96. package/dist/index.html +52 -0
  97. package/dist/logo-128.png +0 -0
  98. package/dist/logo-256.png +0 -0
  99. package/dist/logo-32.png +0 -0
  100. package/dist/logo-512.png +0 -0
  101. package/dist/logo-64.png +0 -0
  102. package/dist/logo.svg +17 -0
  103. package/dist/manifest.json +61 -0
  104. package/dist/screenshots/cli-selection.png +0 -0
  105. package/dist/screenshots/desktop-main.png +0 -0
  106. package/dist/screenshots/mobile-chat.png +0 -0
  107. package/dist/screenshots/tools-modal.png +0 -0
  108. package/dist/sw.js +107 -0
  109. package/package.json +120 -0
  110. package/server/claude-sdk.js +721 -0
  111. package/server/cli.js +469 -0
  112. package/server/cursor-cli.js +267 -0
  113. package/server/database/db.js +554 -0
  114. package/server/database/init.sql +54 -0
  115. package/server/index.js +2120 -0
  116. package/server/middleware/auth.js +161 -0
  117. package/server/openai-codex.js +389 -0
  118. package/server/orchestrator/client.js +989 -0
  119. package/server/orchestrator/github-auth.js +308 -0
  120. package/server/orchestrator/index.js +216 -0
  121. package/server/orchestrator/protocol.js +299 -0
  122. package/server/orchestrator/proxy.js +364 -0
  123. package/server/orchestrator/status-tracker.js +226 -0
  124. package/server/projects.js +1604 -0
  125. package/server/routes/agent.js +1230 -0
  126. package/server/routes/auth.js +135 -0
  127. package/server/routes/cli-auth.js +341 -0
  128. package/server/routes/codex.js +345 -0
  129. package/server/routes/commands.js +521 -0
  130. package/server/routes/cursor.js +795 -0
  131. package/server/routes/git.js +1128 -0
  132. package/server/routes/mcp-utils.js +48 -0
  133. package/server/routes/mcp.js +650 -0
  134. package/server/routes/projects.js +378 -0
  135. package/server/routes/settings.js +178 -0
  136. package/server/routes/taskmaster.js +1963 -0
  137. package/server/routes/user.js +106 -0
  138. package/server/utils/commandParser.js +303 -0
  139. package/server/utils/gitConfig.js +24 -0
  140. package/server/utils/mcp-detector.js +198 -0
  141. package/server/utils/taskmaster-websocket.js +129 -0
  142. package/shared/modelConstants.js +65 -0
@@ -0,0 +1,2120 @@
1
+ #!/usr/bin/env node
2
+ // Load environment variables from .env file
3
+ import fs from "fs";
4
+ import path from "path";
5
+ import { fileURLToPath } from "url";
6
+ import { dirname } from "path";
7
+
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = dirname(__filename);
10
+
11
+ // ANSI color codes for terminal output
12
+ const colors = {
13
+ reset: "\x1b[0m",
14
+ bright: "\x1b[1m",
15
+ cyan: "\x1b[36m",
16
+ green: "\x1b[32m",
17
+ yellow: "\x1b[33m",
18
+ blue: "\x1b[34m",
19
+ dim: "\x1b[2m",
20
+ };
21
+
22
+ const c = {
23
+ info: (text) => `${colors.cyan}${text}${colors.reset}`,
24
+ ok: (text) => `${colors.green}${text}${colors.reset}`,
25
+ warn: (text) => `${colors.yellow}${text}${colors.reset}`,
26
+ tip: (text) => `${colors.blue}${text}${colors.reset}`,
27
+ bright: (text) => `${colors.bright}${text}${colors.reset}`,
28
+ dim: (text) => `${colors.dim}${text}${colors.reset}`,
29
+ };
30
+
31
+ try {
32
+ const envPath = path.join(__dirname, "../.env");
33
+ const envFile = fs.readFileSync(envPath, "utf8");
34
+ envFile.split("\n").forEach((line) => {
35
+ const trimmedLine = line.trim();
36
+ if (trimmedLine && !trimmedLine.startsWith("#")) {
37
+ const [key, ...valueParts] = trimmedLine.split("=");
38
+ if (key && valueParts.length > 0 && !process.env[key]) {
39
+ process.env[key] = valueParts.join("=").trim();
40
+ }
41
+ }
42
+ });
43
+ } catch (e) {
44
+ console.log("No .env file found or error reading it:", e.message);
45
+ }
46
+
47
+ console.log("PORT from env:", process.env.PORT);
48
+
49
+ import express from "express";
50
+ import { WebSocketServer, WebSocket } from "ws";
51
+ import os from "os";
52
+ import http from "http";
53
+ import cors from "cors";
54
+ import { promises as fsPromises } from "fs";
55
+ import { spawn } from "child_process";
56
+ import pty from "node-pty";
57
+ import fetch from "node-fetch";
58
+ import mime from "mime-types";
59
+
60
+ import {
61
+ getProjects,
62
+ getSessions,
63
+ getSessionMessages,
64
+ renameProject,
65
+ deleteSession,
66
+ deleteProject,
67
+ addProjectManually,
68
+ extractProjectDirectory,
69
+ clearProjectDirectoryCache,
70
+ } from "./projects.js";
71
+ import {
72
+ queryClaudeSDK,
73
+ abortClaudeSDKSession,
74
+ isClaudeSDKSessionActive,
75
+ getActiveClaudeSDKSessions,
76
+ resolveToolApproval,
77
+ } from "./claude-sdk.js";
78
+ import {
79
+ spawnCursor,
80
+ abortCursorSession,
81
+ isCursorSessionActive,
82
+ getActiveCursorSessions,
83
+ } from "./cursor-cli.js";
84
+ import {
85
+ queryCodex,
86
+ abortCodexSession,
87
+ isCodexSessionActive,
88
+ getActiveCodexSessions,
89
+ } from "./openai-codex.js";
90
+ import gitRoutes from "./routes/git.js";
91
+ import authRoutes from "./routes/auth.js";
92
+ import mcpRoutes from "./routes/mcp.js";
93
+ import cursorRoutes from "./routes/cursor.js";
94
+ import taskmasterRoutes from "./routes/taskmaster.js";
95
+ import mcpUtilsRoutes from "./routes/mcp-utils.js";
96
+ import commandsRoutes from "./routes/commands.js";
97
+ import settingsRoutes from "./routes/settings.js";
98
+ import agentRoutes from "./routes/agent.js";
99
+ import projectsRoutes from "./routes/projects.js";
100
+ import cliAuthRoutes from "./routes/cli-auth.js";
101
+ import userRoutes from "./routes/user.js";
102
+ import codexRoutes from "./routes/codex.js";
103
+ import { initializeDatabase } from "./database/db.js";
104
+ import {
105
+ validateApiKey,
106
+ authenticateToken,
107
+ authenticateWebSocket,
108
+ } from "./middleware/auth.js";
109
+ import { initializeOrchestrator, StatusValues } from "./orchestrator/index.js";
110
+
111
+ // File system watcher for projects folder
112
+ let projectsWatcher = null;
113
+ const connectedClients = new Set();
114
+
115
+ // Orchestrator integration (initialized in startServer if enabled)
116
+ let orchestrator = null;
117
+ let orchestratorStatusHooks = null;
118
+
119
+ // Setup file system watcher for Claude projects folder using chokidar
120
+ async function setupProjectsWatcher() {
121
+ const chokidar = (await import("chokidar")).default;
122
+ const claudeProjectsPath = path.join(os.homedir(), ".claude", "projects");
123
+
124
+ if (projectsWatcher) {
125
+ projectsWatcher.close();
126
+ }
127
+
128
+ try {
129
+ // Initialize chokidar watcher with optimized settings
130
+ projectsWatcher = chokidar.watch(claudeProjectsPath, {
131
+ ignored: [
132
+ "**/node_modules/**",
133
+ "**/.git/**",
134
+ "**/dist/**",
135
+ "**/build/**",
136
+ "**/*.tmp",
137
+ "**/*.swp",
138
+ "**/.DS_Store",
139
+ ],
140
+ persistent: true,
141
+ ignoreInitial: true, // Don't fire events for existing files on startup
142
+ followSymlinks: false,
143
+ depth: 10, // Reasonable depth limit
144
+ awaitWriteFinish: {
145
+ stabilityThreshold: 100, // Wait 100ms for file to stabilize
146
+ pollInterval: 50,
147
+ },
148
+ });
149
+
150
+ // Debounce function to prevent excessive notifications
151
+ let debounceTimer;
152
+ const debouncedUpdate = async (eventType, filePath) => {
153
+ clearTimeout(debounceTimer);
154
+ debounceTimer = setTimeout(async () => {
155
+ try {
156
+ // Clear project directory cache when files change
157
+ clearProjectDirectoryCache();
158
+
159
+ // Get updated projects list
160
+ const updatedProjects = await getProjects();
161
+
162
+ // Notify all connected clients about the project changes
163
+ const updateMessage = JSON.stringify({
164
+ type: "projects_updated",
165
+ projects: updatedProjects,
166
+ timestamp: new Date().toISOString(),
167
+ changeType: eventType,
168
+ changedFile: path.relative(claudeProjectsPath, filePath),
169
+ });
170
+
171
+ connectedClients.forEach((client) => {
172
+ if (client.readyState === WebSocket.OPEN) {
173
+ client.send(updateMessage);
174
+ }
175
+ });
176
+ } catch (error) {
177
+ console.error("[ERROR] Error handling project changes:", error);
178
+ }
179
+ }, 300); // 300ms debounce (slightly faster than before)
180
+ };
181
+
182
+ // Set up event listeners
183
+ projectsWatcher
184
+ .on("add", (filePath) => debouncedUpdate("add", filePath))
185
+ .on("change", (filePath) => debouncedUpdate("change", filePath))
186
+ .on("unlink", (filePath) => debouncedUpdate("unlink", filePath))
187
+ .on("addDir", (dirPath) => debouncedUpdate("addDir", dirPath))
188
+ .on("unlinkDir", (dirPath) => debouncedUpdate("unlinkDir", dirPath))
189
+ .on("error", (error) => {
190
+ console.error("[ERROR] Chokidar watcher error:", error);
191
+ })
192
+ .on("ready", () => {});
193
+ } catch (error) {
194
+ console.error("[ERROR] Failed to setup projects watcher:", error);
195
+ }
196
+ }
197
+
198
+ const app = express();
199
+ const server = http.createServer(app);
200
+
201
+ const ptySessionsMap = new Map();
202
+ const PTY_SESSION_TIMEOUT = 30 * 60 * 1000;
203
+
204
+ // Single WebSocket server that handles both paths
205
+ const wss = new WebSocketServer({
206
+ server,
207
+ verifyClient: (info) => {
208
+ console.log("WebSocket connection attempt to:", info.req.url);
209
+
210
+ // Platform mode: always allow connection
211
+ if (process.env.VITE_IS_PLATFORM === "true") {
212
+ const user = authenticateWebSocket(null); // Will return first user
213
+ if (!user) {
214
+ console.log("[WARN] Platform mode: No user found in database");
215
+ return false;
216
+ }
217
+ info.req.user = user;
218
+ console.log(
219
+ "[OK] Platform mode WebSocket authenticated for user:",
220
+ user.username,
221
+ );
222
+ return true;
223
+ }
224
+
225
+ // Normal mode: verify token
226
+ // Extract token from query parameters or headers
227
+ const url = new URL(info.req.url, "http://localhost");
228
+ const token =
229
+ url.searchParams.get("token") ||
230
+ info.req.headers.authorization?.split(" ")[1];
231
+
232
+ // Verify token
233
+ const user = authenticateWebSocket(token);
234
+ if (!user) {
235
+ console.log("[WARN] WebSocket authentication failed");
236
+ return false;
237
+ }
238
+
239
+ // Store user info in the request for later use
240
+ info.req.user = user;
241
+ console.log("[OK] WebSocket authenticated for user:", user.username);
242
+ return true;
243
+ },
244
+ });
245
+
246
+ // Make WebSocket server available to routes
247
+ app.locals.wss = wss;
248
+
249
+ app.use(cors());
250
+ app.use(
251
+ express.json({
252
+ limit: "50mb",
253
+ type: (req) => {
254
+ // Skip multipart/form-data requests (for file uploads like images)
255
+ const contentType = req.headers["content-type"] || "";
256
+ if (contentType.includes("multipart/form-data")) {
257
+ return false;
258
+ }
259
+ return contentType.includes("json");
260
+ },
261
+ }),
262
+ );
263
+ app.use(express.urlencoded({ limit: "50mb", extended: true }));
264
+
265
+ // Public health check endpoint (no authentication required)
266
+ app.get("/health", (req, res) => {
267
+ res.json({
268
+ status: "ok",
269
+ timestamp: new Date().toISOString(),
270
+ });
271
+ });
272
+
273
+ // Optional API key validation (if configured)
274
+ app.use("/api", validateApiKey);
275
+
276
+ // Authentication routes (public)
277
+ app.use("/api/auth", authRoutes);
278
+
279
+ // Projects API Routes (protected)
280
+ app.use("/api/projects", authenticateToken, projectsRoutes);
281
+
282
+ // Git API Routes (protected)
283
+ app.use("/api/git", authenticateToken, gitRoutes);
284
+
285
+ // MCP API Routes (protected)
286
+ app.use("/api/mcp", authenticateToken, mcpRoutes);
287
+
288
+ // Cursor API Routes (protected)
289
+ app.use("/api/cursor", authenticateToken, cursorRoutes);
290
+
291
+ // TaskMaster API Routes (protected)
292
+ app.use("/api/taskmaster", authenticateToken, taskmasterRoutes);
293
+
294
+ // MCP utilities
295
+ app.use("/api/mcp-utils", authenticateToken, mcpUtilsRoutes);
296
+
297
+ // Commands API Routes (protected)
298
+ app.use("/api/commands", authenticateToken, commandsRoutes);
299
+
300
+ // Settings API Routes (protected)
301
+ app.use("/api/settings", authenticateToken, settingsRoutes);
302
+
303
+ // CLI Authentication API Routes (protected)
304
+ app.use("/api/cli", authenticateToken, cliAuthRoutes);
305
+
306
+ // User API Routes (protected)
307
+ app.use("/api/user", authenticateToken, userRoutes);
308
+
309
+ // Codex API Routes (protected)
310
+ app.use("/api/codex", authenticateToken, codexRoutes);
311
+
312
+ // Agent API Routes (uses API key authentication)
313
+ app.use("/api/agent", agentRoutes);
314
+
315
+ // Serve public files (like api-docs.html)
316
+ app.use(express.static(path.join(__dirname, "../public")));
317
+
318
+ // Static files served after API routes
319
+ // Add cache control: HTML files should not be cached, but assets can be cached
320
+ app.use(
321
+ express.static(path.join(__dirname, "../dist"), {
322
+ setHeaders: (res, filePath) => {
323
+ if (filePath.endsWith(".html")) {
324
+ // Prevent HTML caching to avoid service worker issues after builds
325
+ res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
326
+ res.setHeader("Pragma", "no-cache");
327
+ res.setHeader("Expires", "0");
328
+ } else if (
329
+ filePath.match(/\.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico)$/)
330
+ ) {
331
+ // Cache static assets for 1 year (they have hashed names)
332
+ res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
333
+ }
334
+ },
335
+ }),
336
+ );
337
+
338
+ // API Routes (protected)
339
+ // /api/config endpoint removed - no longer needed
340
+ // Frontend now uses window.location for WebSocket URLs
341
+
342
+ // System update endpoint
343
+ app.post("/api/system/update", authenticateToken, async (req, res) => {
344
+ try {
345
+ // Get the project root directory (parent of server directory)
346
+ const projectRoot = path.join(__dirname, "..");
347
+
348
+ console.log("Starting system update from directory:", projectRoot);
349
+
350
+ // Run the update command
351
+ const updateCommand = "git checkout main && git pull && npm install";
352
+
353
+ const child = spawn("sh", ["-c", updateCommand], {
354
+ cwd: projectRoot,
355
+ env: process.env,
356
+ });
357
+
358
+ let output = "";
359
+ let errorOutput = "";
360
+
361
+ child.stdout.on("data", (data) => {
362
+ const text = data.toString();
363
+ output += text;
364
+ console.log("Update output:", text);
365
+ });
366
+
367
+ child.stderr.on("data", (data) => {
368
+ const text = data.toString();
369
+ errorOutput += text;
370
+ console.error("Update error:", text);
371
+ });
372
+
373
+ child.on("close", (code) => {
374
+ if (code === 0) {
375
+ res.json({
376
+ success: true,
377
+ output: output || "Update completed successfully",
378
+ message:
379
+ "Update completed. Please restart the server to apply changes.",
380
+ });
381
+ } else {
382
+ res.status(500).json({
383
+ success: false,
384
+ error: "Update command failed",
385
+ output: output,
386
+ errorOutput: errorOutput,
387
+ });
388
+ }
389
+ });
390
+
391
+ child.on("error", (error) => {
392
+ console.error("Update process error:", error);
393
+ res.status(500).json({
394
+ success: false,
395
+ error: error.message,
396
+ });
397
+ });
398
+ } catch (error) {
399
+ console.error("System update error:", error);
400
+ res.status(500).json({
401
+ success: false,
402
+ error: error.message,
403
+ });
404
+ }
405
+ });
406
+
407
+ app.get("/api/projects", authenticateToken, async (req, res) => {
408
+ try {
409
+ const projects = await getProjects();
410
+ res.json(projects);
411
+ } catch (error) {
412
+ res.status(500).json({ error: error.message });
413
+ }
414
+ });
415
+
416
+ app.get(
417
+ "/api/projects/:projectName/sessions",
418
+ authenticateToken,
419
+ async (req, res) => {
420
+ try {
421
+ const { limit = 5, offset = 0 } = req.query;
422
+ const result = await getSessions(
423
+ req.params.projectName,
424
+ parseInt(limit),
425
+ parseInt(offset),
426
+ );
427
+ res.json(result);
428
+ } catch (error) {
429
+ res.status(500).json({ error: error.message });
430
+ }
431
+ },
432
+ );
433
+
434
+ // Get messages for a specific session
435
+ app.get(
436
+ "/api/projects/:projectName/sessions/:sessionId/messages",
437
+ authenticateToken,
438
+ async (req, res) => {
439
+ try {
440
+ const { projectName, sessionId } = req.params;
441
+ const { limit, offset } = req.query;
442
+
443
+ // Parse limit and offset if provided
444
+ const parsedLimit = limit ? parseInt(limit, 10) : null;
445
+ const parsedOffset = offset ? parseInt(offset, 10) : 0;
446
+
447
+ const result = await getSessionMessages(
448
+ projectName,
449
+ sessionId,
450
+ parsedLimit,
451
+ parsedOffset,
452
+ );
453
+
454
+ // Handle both old and new response formats
455
+ if (Array.isArray(result)) {
456
+ // Backward compatibility: no pagination parameters were provided
457
+ res.json({ messages: result });
458
+ } else {
459
+ // New format with pagination info
460
+ res.json(result);
461
+ }
462
+ } catch (error) {
463
+ res.status(500).json({ error: error.message });
464
+ }
465
+ },
466
+ );
467
+
468
+ // Rename project endpoint
469
+ app.put(
470
+ "/api/projects/:projectName/rename",
471
+ authenticateToken,
472
+ async (req, res) => {
473
+ try {
474
+ const { displayName } = req.body;
475
+ await renameProject(req.params.projectName, displayName);
476
+ res.json({ success: true });
477
+ } catch (error) {
478
+ res.status(500).json({ error: error.message });
479
+ }
480
+ },
481
+ );
482
+
483
+ // Delete session endpoint
484
+ app.delete(
485
+ "/api/projects/:projectName/sessions/:sessionId",
486
+ authenticateToken,
487
+ async (req, res) => {
488
+ try {
489
+ const { projectName, sessionId } = req.params;
490
+ console.log(
491
+ `[API] Deleting session: ${sessionId} from project: ${projectName}`,
492
+ );
493
+ await deleteSession(projectName, sessionId);
494
+ console.log(`[API] Session ${sessionId} deleted successfully`);
495
+ res.json({ success: true });
496
+ } catch (error) {
497
+ console.error(
498
+ `[API] Error deleting session ${req.params.sessionId}:`,
499
+ error,
500
+ );
501
+ res.status(500).json({ error: error.message });
502
+ }
503
+ },
504
+ );
505
+
506
+ // Delete project endpoint (only if empty)
507
+ app.delete(
508
+ "/api/projects/:projectName",
509
+ authenticateToken,
510
+ async (req, res) => {
511
+ try {
512
+ const { projectName } = req.params;
513
+ await deleteProject(projectName);
514
+ res.json({ success: true });
515
+ } catch (error) {
516
+ res.status(500).json({ error: error.message });
517
+ }
518
+ },
519
+ );
520
+
521
+ // Create project endpoint
522
+ app.post("/api/projects/create", authenticateToken, async (req, res) => {
523
+ try {
524
+ const { path: projectPath } = req.body;
525
+
526
+ if (!projectPath || !projectPath.trim()) {
527
+ return res.status(400).json({ error: "Project path is required" });
528
+ }
529
+
530
+ const project = await addProjectManually(projectPath.trim());
531
+ res.json({ success: true, project });
532
+ } catch (error) {
533
+ console.error("Error creating project:", error);
534
+ res.status(500).json({ error: error.message });
535
+ }
536
+ });
537
+
538
+ // Browse filesystem endpoint for project suggestions - uses existing getFileTree
539
+ app.get("/api/browse-filesystem", authenticateToken, async (req, res) => {
540
+ try {
541
+ const { path: dirPath } = req.query;
542
+
543
+ // Default to home directory if no path provided
544
+ const homeDir = os.homedir();
545
+ let targetPath = dirPath ? dirPath.replace("~", homeDir) : homeDir;
546
+
547
+ // Resolve and normalize the path
548
+ targetPath = path.resolve(targetPath);
549
+
550
+ // Security check - ensure path is accessible
551
+ try {
552
+ await fs.promises.access(targetPath);
553
+ const stats = await fs.promises.stat(targetPath);
554
+
555
+ if (!stats.isDirectory()) {
556
+ return res.status(400).json({ error: "Path is not a directory" });
557
+ }
558
+ } catch (err) {
559
+ return res.status(404).json({ error: "Directory not accessible" });
560
+ }
561
+
562
+ // Use existing getFileTree function with shallow depth (only direct children)
563
+ const fileTree = await getFileTree(targetPath, 1, 0, false); // maxDepth=1, showHidden=false
564
+
565
+ // Filter only directories and format for suggestions
566
+ const directories = fileTree
567
+ .filter((item) => item.type === "directory")
568
+ .map((item) => ({
569
+ path: item.path,
570
+ name: item.name,
571
+ type: "directory",
572
+ }))
573
+ .slice(0, 20); // Limit results
574
+
575
+ // Add common directories if browsing home directory
576
+ const suggestions = [];
577
+ if (targetPath === homeDir) {
578
+ const commonDirs = [
579
+ "Desktop",
580
+ "Documents",
581
+ "Projects",
582
+ "Development",
583
+ "Dev",
584
+ "Code",
585
+ "workspace",
586
+ ];
587
+ const existingCommon = directories.filter((dir) =>
588
+ commonDirs.includes(dir.name),
589
+ );
590
+ const otherDirs = directories.filter(
591
+ (dir) => !commonDirs.includes(dir.name),
592
+ );
593
+
594
+ suggestions.push(...existingCommon, ...otherDirs);
595
+ } else {
596
+ suggestions.push(...directories);
597
+ }
598
+
599
+ res.json({
600
+ path: targetPath,
601
+ suggestions: suggestions,
602
+ });
603
+ } catch (error) {
604
+ console.error("Error browsing filesystem:", error);
605
+ res.status(500).json({ error: "Failed to browse filesystem" });
606
+ }
607
+ });
608
+
609
+ // Read file content endpoint
610
+ app.get(
611
+ "/api/projects/:projectName/file",
612
+ authenticateToken,
613
+ async (req, res) => {
614
+ try {
615
+ const { projectName } = req.params;
616
+ const { filePath } = req.query;
617
+
618
+ console.log("[DEBUG] File read request:", projectName, filePath);
619
+
620
+ // Security: ensure the requested path is inside the project root
621
+ if (!filePath) {
622
+ return res.status(400).json({ error: "Invalid file path" });
623
+ }
624
+
625
+ const projectRoot = await extractProjectDirectory(projectName).catch(
626
+ () => null,
627
+ );
628
+ if (!projectRoot) {
629
+ return res.status(404).json({ error: "Project not found" });
630
+ }
631
+
632
+ // Handle both absolute and relative paths
633
+ const resolved = path.isAbsolute(filePath)
634
+ ? path.resolve(filePath)
635
+ : path.resolve(projectRoot, filePath);
636
+ const normalizedRoot = path.resolve(projectRoot) + path.sep;
637
+ if (!resolved.startsWith(normalizedRoot)) {
638
+ return res
639
+ .status(403)
640
+ .json({ error: "Path must be under project root" });
641
+ }
642
+
643
+ const content = await fsPromises.readFile(resolved, "utf8");
644
+ res.json({ content, path: resolved });
645
+ } catch (error) {
646
+ console.error("Error reading file:", error);
647
+ if (error.code === "ENOENT") {
648
+ res.status(404).json({ error: "File not found" });
649
+ } else if (error.code === "EACCES") {
650
+ res.status(403).json({ error: "Permission denied" });
651
+ } else {
652
+ res.status(500).json({ error: error.message });
653
+ }
654
+ }
655
+ },
656
+ );
657
+
658
+ // Serve binary file content endpoint (for images, etc.)
659
+ app.get(
660
+ "/api/projects/:projectName/files/content",
661
+ authenticateToken,
662
+ async (req, res) => {
663
+ try {
664
+ const { projectName } = req.params;
665
+ const { path: filePath } = req.query;
666
+
667
+ console.log("[DEBUG] Binary file serve request:", projectName, filePath);
668
+
669
+ // Security: ensure the requested path is inside the project root
670
+ if (!filePath) {
671
+ return res.status(400).json({ error: "Invalid file path" });
672
+ }
673
+
674
+ const projectRoot = await extractProjectDirectory(projectName).catch(
675
+ () => null,
676
+ );
677
+ if (!projectRoot) {
678
+ return res.status(404).json({ error: "Project not found" });
679
+ }
680
+
681
+ const resolved = path.resolve(filePath);
682
+ const normalizedRoot = path.resolve(projectRoot) + path.sep;
683
+ if (!resolved.startsWith(normalizedRoot)) {
684
+ return res
685
+ .status(403)
686
+ .json({ error: "Path must be under project root" });
687
+ }
688
+
689
+ // Check if file exists
690
+ try {
691
+ await fsPromises.access(resolved);
692
+ } catch (error) {
693
+ return res.status(404).json({ error: "File not found" });
694
+ }
695
+
696
+ // Get file extension and set appropriate content type
697
+ const mimeType = mime.lookup(resolved) || "application/octet-stream";
698
+ res.setHeader("Content-Type", mimeType);
699
+
700
+ // Stream the file
701
+ const fileStream = fs.createReadStream(resolved);
702
+ fileStream.pipe(res);
703
+
704
+ fileStream.on("error", (error) => {
705
+ console.error("Error streaming file:", error);
706
+ if (!res.headersSent) {
707
+ res.status(500).json({ error: "Error reading file" });
708
+ }
709
+ });
710
+ } catch (error) {
711
+ console.error("Error serving binary file:", error);
712
+ if (!res.headersSent) {
713
+ res.status(500).json({ error: error.message });
714
+ }
715
+ }
716
+ },
717
+ );
718
+
719
+ // Save file content endpoint
720
+ app.put(
721
+ "/api/projects/:projectName/file",
722
+ authenticateToken,
723
+ async (req, res) => {
724
+ try {
725
+ const { projectName } = req.params;
726
+ const { filePath, content } = req.body;
727
+
728
+ console.log("[DEBUG] File save request:", projectName, filePath);
729
+
730
+ // Security: ensure the requested path is inside the project root
731
+ if (!filePath) {
732
+ return res.status(400).json({ error: "Invalid file path" });
733
+ }
734
+
735
+ if (content === undefined) {
736
+ return res.status(400).json({ error: "Content is required" });
737
+ }
738
+
739
+ const projectRoot = await extractProjectDirectory(projectName).catch(
740
+ () => null,
741
+ );
742
+ if (!projectRoot) {
743
+ return res.status(404).json({ error: "Project not found" });
744
+ }
745
+
746
+ // Handle both absolute and relative paths
747
+ const resolved = path.isAbsolute(filePath)
748
+ ? path.resolve(filePath)
749
+ : path.resolve(projectRoot, filePath);
750
+ const normalizedRoot = path.resolve(projectRoot) + path.sep;
751
+ if (!resolved.startsWith(normalizedRoot)) {
752
+ return res
753
+ .status(403)
754
+ .json({ error: "Path must be under project root" });
755
+ }
756
+
757
+ // Write the new content
758
+ await fsPromises.writeFile(resolved, content, "utf8");
759
+
760
+ res.json({
761
+ success: true,
762
+ path: resolved,
763
+ message: "File saved successfully",
764
+ });
765
+ } catch (error) {
766
+ console.error("Error saving file:", error);
767
+ if (error.code === "ENOENT") {
768
+ res.status(404).json({ error: "File or directory not found" });
769
+ } else if (error.code === "EACCES") {
770
+ res.status(403).json({ error: "Permission denied" });
771
+ } else {
772
+ res.status(500).json({ error: error.message });
773
+ }
774
+ }
775
+ },
776
+ );
777
+
778
+ app.get(
779
+ "/api/projects/:projectName/files",
780
+ authenticateToken,
781
+ async (req, res) => {
782
+ try {
783
+ // Using fsPromises from import
784
+
785
+ // Use extractProjectDirectory to get the actual project path
786
+ let actualPath;
787
+ try {
788
+ actualPath = await extractProjectDirectory(req.params.projectName);
789
+ } catch (error) {
790
+ console.error("Error extracting project directory:", error);
791
+ // Fallback to simple dash replacement
792
+ actualPath = req.params.projectName.replace(/-/g, "/");
793
+ }
794
+
795
+ // Check if path exists
796
+ try {
797
+ await fsPromises.access(actualPath);
798
+ } catch (e) {
799
+ return res
800
+ .status(404)
801
+ .json({ error: `Project path not found: ${actualPath}` });
802
+ }
803
+
804
+ const files = await getFileTree(actualPath, 10, 0, true);
805
+ const hiddenFiles = files.filter((f) => f.name.startsWith("."));
806
+ res.json(files);
807
+ } catch (error) {
808
+ console.error("[ERROR] File tree error:", error.message);
809
+ res.status(500).json({ error: error.message });
810
+ }
811
+ },
812
+ );
813
+
814
+ // WebSocket connection handler that routes based on URL path
815
+ wss.on("connection", (ws, request) => {
816
+ const url = request.url;
817
+ console.log("[INFO] Client connected to:", url);
818
+
819
+ // Parse URL to get pathname without query parameters
820
+ const urlObj = new URL(url, "http://localhost");
821
+ const pathname = urlObj.pathname;
822
+
823
+ if (pathname === "/shell") {
824
+ handleShellConnection(ws);
825
+ } else if (pathname === "/ws") {
826
+ handleChatConnection(ws);
827
+ } else {
828
+ console.log("[WARN] Unknown WebSocket path:", pathname);
829
+ ws.close();
830
+ }
831
+ });
832
+
833
+ /**
834
+ * WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface
835
+ */
836
+ class WebSocketWriter {
837
+ constructor(ws) {
838
+ this.ws = ws;
839
+ this.sessionId = null;
840
+ this.isWebSocketWriter = true; // Marker for transport detection
841
+ }
842
+
843
+ send(data) {
844
+ if (this.ws.readyState === 1) {
845
+ // WebSocket.OPEN
846
+ // Providers send raw objects, we stringify for WebSocket
847
+ this.ws.send(JSON.stringify(data));
848
+ }
849
+ }
850
+
851
+ setSessionId(sessionId) {
852
+ this.sessionId = sessionId;
853
+ }
854
+
855
+ getSessionId() {
856
+ return this.sessionId;
857
+ }
858
+ }
859
+
860
+ // Handle chat WebSocket connections
861
+ function handleChatConnection(ws) {
862
+ console.log("[INFO] Chat WebSocket connected");
863
+
864
+ // Add to connected clients for project updates
865
+ connectedClients.add(ws);
866
+
867
+ // Generate unique connection ID for orchestrator status tracking
868
+ const connectionId = `ws-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
869
+
870
+ // Track connection for orchestrator status (if enabled)
871
+ if (orchestratorStatusHooks) {
872
+ orchestratorStatusHooks.onConnectionOpen(connectionId);
873
+ }
874
+
875
+ // Wrap WebSocket with writer for consistent interface with SSEStreamWriter
876
+ const writer = new WebSocketWriter(ws);
877
+
878
+ ws.on("message", async (message) => {
879
+ // Delegate to shared message handler
880
+ await handleChatMessage(ws, writer, message.toString());
881
+ });
882
+
883
+ ws.on("close", () => {
884
+ console.log("🔌 Chat client disconnected");
885
+ // Remove from connected clients
886
+ connectedClients.delete(ws);
887
+ // Track disconnection for orchestrator status (if enabled)
888
+ if (orchestratorStatusHooks) {
889
+ orchestratorStatusHooks.onConnectionClose(connectionId);
890
+ }
891
+ });
892
+ }
893
+
894
+ // Handle chat message processing (shared between WebSocket and orchestrator proxy)
895
+ async function handleChatMessage(ws, writer, messageData) {
896
+ try {
897
+ const data =
898
+ typeof messageData === "string" ? JSON.parse(messageData) : messageData;
899
+ const sessionIdForTracking =
900
+ data.options?.sessionId || data.sessionId || `session-${Date.now()}`;
901
+
902
+ if (data.type === "claude-command") {
903
+ console.log("[DEBUG] User message:", data.command || "[Continue/Resume]");
904
+ console.log("📁 Project:", data.options?.projectPath || "Unknown");
905
+ console.log("🔄 Session:", data.options?.sessionId ? "Resume" : "New");
906
+
907
+ // Track busy status for orchestrator
908
+ if (orchestratorStatusHooks) {
909
+ orchestratorStatusHooks.onQueryStart(sessionIdForTracking);
910
+ }
911
+
912
+ try {
913
+ // Use Claude Agents SDK
914
+ await queryClaudeSDK(data.command, data.options, writer);
915
+ } finally {
916
+ // Mark as no longer busy
917
+ if (orchestratorStatusHooks) {
918
+ orchestratorStatusHooks.onQueryEnd(sessionIdForTracking);
919
+ }
920
+ }
921
+ } else if (data.type === "cursor-command") {
922
+ console.log(
923
+ "[DEBUG] Cursor message:",
924
+ data.command || "[Continue/Resume]",
925
+ );
926
+ console.log("📁 Project:", data.options?.cwd || "Unknown");
927
+ console.log("🔄 Session:", data.options?.sessionId ? "Resume" : "New");
928
+ console.log("🤖 Model:", data.options?.model || "default");
929
+
930
+ if (orchestratorStatusHooks) {
931
+ orchestratorStatusHooks.onQueryStart(sessionIdForTracking);
932
+ }
933
+
934
+ try {
935
+ await spawnCursor(data.command, data.options, writer);
936
+ } finally {
937
+ if (orchestratorStatusHooks) {
938
+ orchestratorStatusHooks.onQueryEnd(sessionIdForTracking);
939
+ }
940
+ }
941
+ } else if (data.type === "codex-command") {
942
+ console.log(
943
+ "[DEBUG] Codex message:",
944
+ data.command || "[Continue/Resume]",
945
+ );
946
+ console.log(
947
+ "📁 Project:",
948
+ data.options?.projectPath || data.options?.cwd || "Unknown",
949
+ );
950
+ console.log("🔄 Session:", data.options?.sessionId ? "Resume" : "New");
951
+ console.log("🤖 Model:", data.options?.model || "default");
952
+
953
+ if (orchestratorStatusHooks) {
954
+ orchestratorStatusHooks.onQueryStart(sessionIdForTracking);
955
+ }
956
+
957
+ try {
958
+ await queryCodex(data.command, data.options, writer);
959
+ } finally {
960
+ if (orchestratorStatusHooks) {
961
+ orchestratorStatusHooks.onQueryEnd(sessionIdForTracking);
962
+ }
963
+ }
964
+ } else if (data.type === "cursor-resume") {
965
+ // Backward compatibility: treat as cursor-command with resume and no prompt
966
+ console.log("[DEBUG] Cursor resume session (compat):", data.sessionId);
967
+ await spawnCursor(
968
+ "",
969
+ {
970
+ sessionId: data.sessionId,
971
+ resume: true,
972
+ cwd: data.options?.cwd,
973
+ },
974
+ writer,
975
+ );
976
+ } else if (data.type === "abort-session") {
977
+ console.log("[DEBUG] Abort session request:", data.sessionId);
978
+ const provider = data.provider || "claude";
979
+ let success;
980
+
981
+ if (provider === "cursor") {
982
+ success = abortCursorSession(data.sessionId);
983
+ } else if (provider === "codex") {
984
+ success = abortCodexSession(data.sessionId);
985
+ } else {
986
+ // Use Claude Agents SDK
987
+ success = await abortClaudeSDKSession(data.sessionId);
988
+ }
989
+
990
+ writer.send({
991
+ type: "session-aborted",
992
+ sessionId: data.sessionId,
993
+ provider,
994
+ success,
995
+ });
996
+ } else if (data.type === "claude-permission-response") {
997
+ // Relay UI approval decisions back into the SDK control flow.
998
+ if (data.requestId) {
999
+ resolveToolApproval(data.requestId, {
1000
+ allow: Boolean(data.allow),
1001
+ updatedInput: data.updatedInput,
1002
+ message: data.message,
1003
+ rememberEntry: data.rememberEntry,
1004
+ });
1005
+ }
1006
+ } else if (data.type === "cursor-abort") {
1007
+ console.log("[DEBUG] Abort Cursor session:", data.sessionId);
1008
+ const success = abortCursorSession(data.sessionId);
1009
+ writer.send({
1010
+ type: "session-aborted",
1011
+ sessionId: data.sessionId,
1012
+ provider: "cursor",
1013
+ success,
1014
+ });
1015
+ } else if (data.type === "check-session-status") {
1016
+ // Check if a specific session is currently processing
1017
+ const provider = data.provider || "claude";
1018
+ const sessionId = data.sessionId;
1019
+ let isActive;
1020
+
1021
+ if (provider === "cursor") {
1022
+ isActive = isCursorSessionActive(sessionId);
1023
+ } else if (provider === "codex") {
1024
+ isActive = isCodexSessionActive(sessionId);
1025
+ } else {
1026
+ // Use Claude Agents SDK
1027
+ isActive = isClaudeSDKSessionActive(sessionId);
1028
+ }
1029
+
1030
+ writer.send({
1031
+ type: "session-status",
1032
+ sessionId,
1033
+ provider,
1034
+ isProcessing: isActive,
1035
+ });
1036
+ } else if (data.type === "get-active-sessions") {
1037
+ // Get all currently active sessions
1038
+ const activeSessions = {
1039
+ claude: getActiveClaudeSDKSessions(),
1040
+ cursor: getActiveCursorSessions(),
1041
+ codex: getActiveCodexSessions(),
1042
+ };
1043
+ writer.send({
1044
+ type: "active-sessions",
1045
+ sessions: activeSessions,
1046
+ });
1047
+ }
1048
+ } catch (error) {
1049
+ console.error("[ERROR] Chat message handling error:", error.message);
1050
+ writer.send({
1051
+ type: "error",
1052
+ error: error.message,
1053
+ });
1054
+ }
1055
+ }
1056
+
1057
+ // Handle shell WebSocket connections
1058
+ function handleShellConnection(ws) {
1059
+ console.log("🐚 Shell client connected");
1060
+ let shellProcess = null;
1061
+ let ptySessionKey = null;
1062
+ let outputBuffer = [];
1063
+
1064
+ ws.on("message", async (message) => {
1065
+ try {
1066
+ const data = JSON.parse(message);
1067
+ console.log("📨 Shell message received:", data.type);
1068
+
1069
+ if (data.type === "init") {
1070
+ const projectPath = data.projectPath || process.cwd();
1071
+ const sessionId = data.sessionId;
1072
+ const hasSession = data.hasSession;
1073
+ const provider = data.provider || "claude";
1074
+ const initialCommand = data.initialCommand;
1075
+ const isPlainShell =
1076
+ data.isPlainShell ||
1077
+ (!!initialCommand && !hasSession) ||
1078
+ provider === "plain-shell";
1079
+
1080
+ // Login commands (Claude/Cursor auth) should never reuse cached sessions
1081
+ const isLoginCommand =
1082
+ initialCommand &&
1083
+ (initialCommand.includes("setup-token") ||
1084
+ initialCommand.includes("cursor-agent login") ||
1085
+ initialCommand.includes("auth login"));
1086
+
1087
+ // Include command hash in session key so different commands get separate sessions
1088
+ const commandSuffix =
1089
+ isPlainShell && initialCommand
1090
+ ? `_cmd_${Buffer.from(initialCommand).toString("base64").slice(0, 16)}`
1091
+ : "";
1092
+ ptySessionKey = `${projectPath}_${sessionId || "default"}${commandSuffix}`;
1093
+
1094
+ // Kill any existing login session before starting fresh
1095
+ if (isLoginCommand) {
1096
+ const oldSession = ptySessionsMap.get(ptySessionKey);
1097
+ if (oldSession) {
1098
+ console.log(
1099
+ "🧹 Cleaning up existing login session:",
1100
+ ptySessionKey,
1101
+ );
1102
+ if (oldSession.timeoutId) clearTimeout(oldSession.timeoutId);
1103
+ if (oldSession.pty && oldSession.pty.kill) oldSession.pty.kill();
1104
+ ptySessionsMap.delete(ptySessionKey);
1105
+ }
1106
+ }
1107
+
1108
+ const existingSession = isLoginCommand
1109
+ ? null
1110
+ : ptySessionsMap.get(ptySessionKey);
1111
+ if (existingSession) {
1112
+ console.log(
1113
+ "♻️ Reconnecting to existing PTY session:",
1114
+ ptySessionKey,
1115
+ );
1116
+ shellProcess = existingSession.pty;
1117
+
1118
+ clearTimeout(existingSession.timeoutId);
1119
+
1120
+ ws.send(
1121
+ JSON.stringify({
1122
+ type: "output",
1123
+ data: `\x1b[36m[Reconnected to existing session]\x1b[0m\r\n`,
1124
+ }),
1125
+ );
1126
+
1127
+ if (existingSession.buffer && existingSession.buffer.length > 0) {
1128
+ console.log(
1129
+ `📜 Sending ${existingSession.buffer.length} buffered messages`,
1130
+ );
1131
+ existingSession.buffer.forEach((bufferedData) => {
1132
+ ws.send(
1133
+ JSON.stringify({
1134
+ type: "output",
1135
+ data: bufferedData,
1136
+ }),
1137
+ );
1138
+ });
1139
+ }
1140
+
1141
+ existingSession.ws = ws;
1142
+
1143
+ return;
1144
+ }
1145
+
1146
+ console.log("[INFO] Starting shell in:", projectPath);
1147
+ console.log(
1148
+ "📋 Session info:",
1149
+ hasSession
1150
+ ? `Resume session ${sessionId}`
1151
+ : isPlainShell
1152
+ ? "Plain shell mode"
1153
+ : "New session",
1154
+ );
1155
+ console.log("🤖 Provider:", isPlainShell ? "plain-shell" : provider);
1156
+ if (initialCommand) {
1157
+ console.log("⚡ Initial command:", initialCommand);
1158
+ }
1159
+
1160
+ // First send a welcome message
1161
+ let welcomeMsg;
1162
+ if (isPlainShell) {
1163
+ welcomeMsg = `\x1b[36mStarting terminal in: ${projectPath}\x1b[0m\r\n`;
1164
+ } else {
1165
+ const providerName = provider === "cursor" ? "Cursor" : "Claude";
1166
+ welcomeMsg = hasSession
1167
+ ? `\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n`
1168
+ : `\x1b[36mStarting new ${providerName} session in: ${projectPath}\x1b[0m\r\n`;
1169
+ }
1170
+
1171
+ ws.send(
1172
+ JSON.stringify({
1173
+ type: "output",
1174
+ data: welcomeMsg,
1175
+ }),
1176
+ );
1177
+
1178
+ try {
1179
+ // Prepare the shell command adapted to the platform and provider
1180
+ let shellCommand;
1181
+ if (isPlainShell) {
1182
+ // Plain shell mode - just run the initial command in the project directory
1183
+ if (os.platform() === "win32") {
1184
+ shellCommand = `Set-Location -Path "${projectPath}"; ${initialCommand}`;
1185
+ } else {
1186
+ shellCommand = `cd "${projectPath}" && ${initialCommand}`;
1187
+ }
1188
+ } else if (provider === "cursor") {
1189
+ // Use cursor-agent command
1190
+ if (os.platform() === "win32") {
1191
+ if (hasSession && sessionId) {
1192
+ shellCommand = `Set-Location -Path "${projectPath}"; cursor-agent --resume="${sessionId}"`;
1193
+ } else {
1194
+ shellCommand = `Set-Location -Path "${projectPath}"; cursor-agent`;
1195
+ }
1196
+ } else {
1197
+ if (hasSession && sessionId) {
1198
+ shellCommand = `cd "${projectPath}" && cursor-agent --resume="${sessionId}"`;
1199
+ } else {
1200
+ shellCommand = `cd "${projectPath}" && cursor-agent`;
1201
+ }
1202
+ }
1203
+ } else {
1204
+ // Use claude command (default) or initialCommand if provided
1205
+ const command = initialCommand || "claude";
1206
+ if (os.platform() === "win32") {
1207
+ if (hasSession && sessionId) {
1208
+ // Try to resume session, but with fallback to new session if it fails
1209
+ shellCommand = `Set-Location -Path "${projectPath}"; claude --resume ${sessionId}; if ($LASTEXITCODE -ne 0) { claude }`;
1210
+ } else {
1211
+ shellCommand = `Set-Location -Path "${projectPath}"; ${command}`;
1212
+ }
1213
+ } else {
1214
+ if (hasSession && sessionId) {
1215
+ shellCommand = `cd "${projectPath}" && claude --resume ${sessionId} || claude`;
1216
+ } else {
1217
+ shellCommand = `cd "${projectPath}" && ${command}`;
1218
+ }
1219
+ }
1220
+ }
1221
+
1222
+ console.log("🔧 Executing shell command:", shellCommand);
1223
+
1224
+ // Use appropriate shell based on platform
1225
+ const shell = os.platform() === "win32" ? "powershell.exe" : "bash";
1226
+ const shellArgs =
1227
+ os.platform() === "win32"
1228
+ ? ["-Command", shellCommand]
1229
+ : ["-c", shellCommand];
1230
+
1231
+ // Use terminal dimensions from client if provided, otherwise use defaults
1232
+ const termCols = data.cols || 80;
1233
+ const termRows = data.rows || 24;
1234
+ console.log("📐 Using terminal dimensions:", termCols, "x", termRows);
1235
+
1236
+ shellProcess = pty.spawn(shell, shellArgs, {
1237
+ name: "xterm-256color",
1238
+ cols: termCols,
1239
+ rows: termRows,
1240
+ cwd: os.homedir(),
1241
+ env: {
1242
+ ...process.env,
1243
+ TERM: "xterm-256color",
1244
+ COLORTERM: "truecolor",
1245
+ FORCE_COLOR: "3",
1246
+ // Override browser opening commands to echo URL for detection
1247
+ BROWSER:
1248
+ os.platform() === "win32"
1249
+ ? 'echo "OPEN_URL:"'
1250
+ : 'echo "OPEN_URL:"',
1251
+ },
1252
+ });
1253
+
1254
+ console.log(
1255
+ "🟢 Shell process started with PTY, PID:",
1256
+ shellProcess.pid,
1257
+ );
1258
+
1259
+ ptySessionsMap.set(ptySessionKey, {
1260
+ pty: shellProcess,
1261
+ ws: ws,
1262
+ buffer: [],
1263
+ timeoutId: null,
1264
+ projectPath,
1265
+ sessionId,
1266
+ });
1267
+
1268
+ // Handle data output
1269
+ shellProcess.onData((data) => {
1270
+ const session = ptySessionsMap.get(ptySessionKey);
1271
+ if (!session) return;
1272
+
1273
+ if (session.buffer.length < 5000) {
1274
+ session.buffer.push(data);
1275
+ } else {
1276
+ session.buffer.shift();
1277
+ session.buffer.push(data);
1278
+ }
1279
+
1280
+ if (session.ws && session.ws.readyState === WebSocket.OPEN) {
1281
+ let outputData = data;
1282
+
1283
+ // Check for various URL opening patterns
1284
+ const patterns = [
1285
+ // Direct browser opening commands
1286
+ /(?:xdg-open|open|start)\s+(https?:\/\/[^\s\x1b\x07]+)/g,
1287
+ // BROWSER environment variable override
1288
+ /OPEN_URL:\s*(https?:\/\/[^\s\x1b\x07]+)/g,
1289
+ // Git and other tools opening URLs
1290
+ /Opening\s+(https?:\/\/[^\s\x1b\x07]+)/gi,
1291
+ // General URL patterns that might be opened
1292
+ /Visit:\s*(https?:\/\/[^\s\x1b\x07]+)/gi,
1293
+ /View at:\s*(https?:\/\/[^\s\x1b\x07]+)/gi,
1294
+ /Browse to:\s*(https?:\/\/[^\s\x1b\x07]+)/gi,
1295
+ ];
1296
+
1297
+ patterns.forEach((pattern) => {
1298
+ let match;
1299
+ while ((match = pattern.exec(data)) !== null) {
1300
+ const url = match[1];
1301
+ console.log("[DEBUG] Detected URL for opening:", url);
1302
+
1303
+ // Send URL opening message to client
1304
+ session.ws.send(
1305
+ JSON.stringify({
1306
+ type: "url_open",
1307
+ url: url,
1308
+ }),
1309
+ );
1310
+
1311
+ // Replace the OPEN_URL pattern with a user-friendly message
1312
+ if (pattern.source.includes("OPEN_URL")) {
1313
+ outputData = outputData.replace(
1314
+ match[0],
1315
+ `[INFO] Opening in browser: ${url}`,
1316
+ );
1317
+ }
1318
+ }
1319
+ });
1320
+
1321
+ // Send regular output
1322
+ session.ws.send(
1323
+ JSON.stringify({
1324
+ type: "output",
1325
+ data: outputData,
1326
+ }),
1327
+ );
1328
+ }
1329
+ });
1330
+
1331
+ // Handle process exit
1332
+ shellProcess.onExit((exitCode) => {
1333
+ console.log(
1334
+ "🔚 Shell process exited with code:",
1335
+ exitCode.exitCode,
1336
+ "signal:",
1337
+ exitCode.signal,
1338
+ );
1339
+ const session = ptySessionsMap.get(ptySessionKey);
1340
+ if (
1341
+ session &&
1342
+ session.ws &&
1343
+ session.ws.readyState === WebSocket.OPEN
1344
+ ) {
1345
+ session.ws.send(
1346
+ JSON.stringify({
1347
+ type: "output",
1348
+ data: `\r\n\x1b[33mProcess exited with code ${exitCode.exitCode}${exitCode.signal ? ` (${exitCode.signal})` : ""}\x1b[0m\r\n`,
1349
+ }),
1350
+ );
1351
+ }
1352
+ if (session && session.timeoutId) {
1353
+ clearTimeout(session.timeoutId);
1354
+ }
1355
+ ptySessionsMap.delete(ptySessionKey);
1356
+ shellProcess = null;
1357
+ });
1358
+ } catch (spawnError) {
1359
+ console.error("[ERROR] Error spawning process:", spawnError);
1360
+ ws.send(
1361
+ JSON.stringify({
1362
+ type: "output",
1363
+ data: `\r\n\x1b[31mError: ${spawnError.message}\x1b[0m\r\n`,
1364
+ }),
1365
+ );
1366
+ }
1367
+ } else if (data.type === "input") {
1368
+ // Send input to shell process
1369
+ if (shellProcess && shellProcess.write) {
1370
+ try {
1371
+ shellProcess.write(data.data);
1372
+ } catch (error) {
1373
+ console.error("Error writing to shell:", error);
1374
+ }
1375
+ } else {
1376
+ console.warn("No active shell process to send input to");
1377
+ }
1378
+ } else if (data.type === "resize") {
1379
+ // Handle terminal resize
1380
+ if (shellProcess && shellProcess.resize) {
1381
+ console.log("Terminal resize requested:", data.cols, "x", data.rows);
1382
+ shellProcess.resize(data.cols, data.rows);
1383
+ }
1384
+ }
1385
+ } catch (error) {
1386
+ console.error("[ERROR] Shell WebSocket error:", error.message);
1387
+ if (ws.readyState === WebSocket.OPEN) {
1388
+ ws.send(
1389
+ JSON.stringify({
1390
+ type: "output",
1391
+ data: `\r\n\x1b[31mError: ${error.message}\x1b[0m\r\n`,
1392
+ }),
1393
+ );
1394
+ }
1395
+ }
1396
+ });
1397
+
1398
+ ws.on("close", () => {
1399
+ console.log("🔌 Shell client disconnected");
1400
+
1401
+ if (ptySessionKey) {
1402
+ const session = ptySessionsMap.get(ptySessionKey);
1403
+ if (session) {
1404
+ console.log(
1405
+ "⏳ PTY session kept alive, will timeout in 30 minutes:",
1406
+ ptySessionKey,
1407
+ );
1408
+ session.ws = null;
1409
+
1410
+ session.timeoutId = setTimeout(() => {
1411
+ console.log(
1412
+ "⏰ PTY session timeout, killing process:",
1413
+ ptySessionKey,
1414
+ );
1415
+ if (session.pty && session.pty.kill) {
1416
+ session.pty.kill();
1417
+ }
1418
+ ptySessionsMap.delete(ptySessionKey);
1419
+ }, PTY_SESSION_TIMEOUT);
1420
+ }
1421
+ }
1422
+ });
1423
+
1424
+ ws.on("error", (error) => {
1425
+ console.error("[ERROR] Shell WebSocket error:", error);
1426
+ });
1427
+ }
1428
+ // Audio transcription endpoint
1429
+ app.post("/api/transcribe", authenticateToken, async (req, res) => {
1430
+ try {
1431
+ const multer = (await import("multer")).default;
1432
+ const upload = multer({ storage: multer.memoryStorage() });
1433
+
1434
+ // Handle multipart form data
1435
+ upload.single("audio")(req, res, async (err) => {
1436
+ if (err) {
1437
+ return res.status(400).json({ error: "Failed to process audio file" });
1438
+ }
1439
+
1440
+ if (!req.file) {
1441
+ return res.status(400).json({ error: "No audio file provided" });
1442
+ }
1443
+
1444
+ const apiKey = process.env.OPENAI_API_KEY;
1445
+ if (!apiKey) {
1446
+ return res.status(500).json({
1447
+ error:
1448
+ "OpenAI API key not configured. Please set OPENAI_API_KEY in server environment.",
1449
+ });
1450
+ }
1451
+
1452
+ try {
1453
+ // Create form data for OpenAI
1454
+ const FormData = (await import("form-data")).default;
1455
+ const formData = new FormData();
1456
+ formData.append("file", req.file.buffer, {
1457
+ filename: req.file.originalname,
1458
+ contentType: req.file.mimetype,
1459
+ });
1460
+ formData.append("model", "whisper-1");
1461
+ formData.append("response_format", "json");
1462
+ formData.append("language", "en");
1463
+
1464
+ // Make request to OpenAI
1465
+ const response = await fetch(
1466
+ "https://api.openai.com/v1/audio/transcriptions",
1467
+ {
1468
+ method: "POST",
1469
+ headers: {
1470
+ Authorization: `Bearer ${apiKey}`,
1471
+ ...formData.getHeaders(),
1472
+ },
1473
+ body: formData,
1474
+ },
1475
+ );
1476
+
1477
+ if (!response.ok) {
1478
+ const errorData = await response.json().catch(() => ({}));
1479
+ throw new Error(
1480
+ errorData.error?.message || `Whisper API error: ${response.status}`,
1481
+ );
1482
+ }
1483
+
1484
+ const data = await response.json();
1485
+ let transcribedText = data.text || "";
1486
+
1487
+ // Check if enhancement mode is enabled
1488
+ const mode = req.body.mode || "default";
1489
+
1490
+ // If no transcribed text, return empty
1491
+ if (!transcribedText) {
1492
+ return res.json({ text: "" });
1493
+ }
1494
+
1495
+ // If default mode, return transcribed text without enhancement
1496
+ if (mode === "default") {
1497
+ return res.json({ text: transcribedText });
1498
+ }
1499
+
1500
+ // Handle different enhancement modes
1501
+ try {
1502
+ const OpenAI = (await import("openai")).default;
1503
+ const openai = new OpenAI({ apiKey });
1504
+
1505
+ let prompt,
1506
+ systemMessage,
1507
+ temperature = 0.7,
1508
+ maxTokens = 800;
1509
+
1510
+ switch (mode) {
1511
+ case "prompt":
1512
+ systemMessage =
1513
+ "You are an expert prompt engineer who creates clear, detailed, and effective prompts.";
1514
+ prompt = `You are an expert prompt engineer. Transform the following rough instruction into a clear, detailed, and context-aware AI prompt.
1515
+
1516
+ Your enhanced prompt should:
1517
+ 1. Be specific and unambiguous
1518
+ 2. Include relevant context and constraints
1519
+ 3. Specify the desired output format
1520
+ 4. Use clear, actionable language
1521
+ 5. Include examples where helpful
1522
+ 6. Consider edge cases and potential ambiguities
1523
+
1524
+ Transform this rough instruction into a well-crafted prompt:
1525
+ "${transcribedText}"
1526
+
1527
+ Enhanced prompt:`;
1528
+ break;
1529
+
1530
+ case "vibe":
1531
+ case "instructions":
1532
+ case "architect":
1533
+ systemMessage =
1534
+ "You are a helpful assistant that formats ideas into clear, actionable instructions for AI agents.";
1535
+ temperature = 0.5; // Lower temperature for more controlled output
1536
+ prompt = `Transform the following idea into clear, well-structured instructions that an AI agent can easily understand and execute.
1537
+
1538
+ IMPORTANT RULES:
1539
+ - Format as clear, step-by-step instructions
1540
+ - Add reasonable implementation details based on common patterns
1541
+ - Only include details directly related to what was asked
1542
+ - Do NOT add features or functionality not mentioned
1543
+ - Keep the original intent and scope intact
1544
+ - Use clear, actionable language an agent can follow
1545
+
1546
+ Transform this idea into agent-friendly instructions:
1547
+ "${transcribedText}"
1548
+
1549
+ Agent instructions:`;
1550
+ break;
1551
+
1552
+ default:
1553
+ // No enhancement needed
1554
+ break;
1555
+ }
1556
+
1557
+ // Only make GPT call if we have a prompt
1558
+ if (prompt) {
1559
+ const completion = await openai.chat.completions.create({
1560
+ model: "gpt-4o-mini",
1561
+ messages: [
1562
+ { role: "system", content: systemMessage },
1563
+ { role: "user", content: prompt },
1564
+ ],
1565
+ temperature: temperature,
1566
+ max_tokens: maxTokens,
1567
+ });
1568
+
1569
+ transcribedText =
1570
+ completion.choices[0].message.content || transcribedText;
1571
+ }
1572
+ } catch (gptError) {
1573
+ console.error("GPT processing error:", gptError);
1574
+ // Fall back to original transcription if GPT fails
1575
+ }
1576
+
1577
+ res.json({ text: transcribedText });
1578
+ } catch (error) {
1579
+ console.error("Transcription error:", error);
1580
+ res.status(500).json({ error: error.message });
1581
+ }
1582
+ });
1583
+ } catch (error) {
1584
+ console.error("Endpoint error:", error);
1585
+ res.status(500).json({ error: "Internal server error" });
1586
+ }
1587
+ });
1588
+
1589
+ // Image upload endpoint
1590
+ app.post(
1591
+ "/api/projects/:projectName/upload-images",
1592
+ authenticateToken,
1593
+ async (req, res) => {
1594
+ try {
1595
+ const multer = (await import("multer")).default;
1596
+ const path = (await import("path")).default;
1597
+ const fs = (await import("fs")).promises;
1598
+ const os = (await import("os")).default;
1599
+
1600
+ // Configure multer for image uploads
1601
+ const storage = multer.diskStorage({
1602
+ destination: async (req, file, cb) => {
1603
+ const uploadDir = path.join(
1604
+ os.tmpdir(),
1605
+ "claude-ui-uploads",
1606
+ String(req.user.id),
1607
+ );
1608
+ await fs.mkdir(uploadDir, { recursive: true });
1609
+ cb(null, uploadDir);
1610
+ },
1611
+ filename: (req, file, cb) => {
1612
+ const uniqueSuffix =
1613
+ Date.now() + "-" + Math.round(Math.random() * 1e9);
1614
+ const sanitizedName = file.originalname.replace(
1615
+ /[^a-zA-Z0-9.-]/g,
1616
+ "_",
1617
+ );
1618
+ cb(null, uniqueSuffix + "-" + sanitizedName);
1619
+ },
1620
+ });
1621
+
1622
+ const fileFilter = (req, file, cb) => {
1623
+ const allowedMimes = [
1624
+ "image/jpeg",
1625
+ "image/png",
1626
+ "image/gif",
1627
+ "image/webp",
1628
+ "image/svg+xml",
1629
+ ];
1630
+ if (allowedMimes.includes(file.mimetype)) {
1631
+ cb(null, true);
1632
+ } else {
1633
+ cb(
1634
+ new Error(
1635
+ "Invalid file type. Only JPEG, PNG, GIF, WebP, and SVG are allowed.",
1636
+ ),
1637
+ );
1638
+ }
1639
+ };
1640
+
1641
+ const upload = multer({
1642
+ storage,
1643
+ fileFilter,
1644
+ limits: {
1645
+ fileSize: 5 * 1024 * 1024, // 5MB
1646
+ files: 5,
1647
+ },
1648
+ });
1649
+
1650
+ // Handle multipart form data
1651
+ upload.array("images", 5)(req, res, async (err) => {
1652
+ if (err) {
1653
+ return res.status(400).json({ error: err.message });
1654
+ }
1655
+
1656
+ if (!req.files || req.files.length === 0) {
1657
+ return res.status(400).json({ error: "No image files provided" });
1658
+ }
1659
+
1660
+ try {
1661
+ // Process uploaded images
1662
+ const processedImages = await Promise.all(
1663
+ req.files.map(async (file) => {
1664
+ // Read file and convert to base64
1665
+ const buffer = await fs.readFile(file.path);
1666
+ const base64 = buffer.toString("base64");
1667
+ const mimeType = file.mimetype;
1668
+
1669
+ // Clean up temp file immediately
1670
+ await fs.unlink(file.path);
1671
+
1672
+ return {
1673
+ name: file.originalname,
1674
+ data: `data:${mimeType};base64,${base64}`,
1675
+ size: file.size,
1676
+ mimeType: mimeType,
1677
+ };
1678
+ }),
1679
+ );
1680
+
1681
+ res.json({ images: processedImages });
1682
+ } catch (error) {
1683
+ console.error("Error processing images:", error);
1684
+ // Clean up any remaining files
1685
+ await Promise.all(
1686
+ req.files.map((f) => fs.unlink(f.path).catch(() => {})),
1687
+ );
1688
+ res.status(500).json({ error: "Failed to process images" });
1689
+ }
1690
+ });
1691
+ } catch (error) {
1692
+ console.error("Error in image upload endpoint:", error);
1693
+ res.status(500).json({ error: "Internal server error" });
1694
+ }
1695
+ },
1696
+ );
1697
+
1698
+ // Get token usage for a specific session
1699
+ app.get(
1700
+ "/api/projects/:projectName/sessions/:sessionId/token-usage",
1701
+ authenticateToken,
1702
+ async (req, res) => {
1703
+ try {
1704
+ const { projectName, sessionId } = req.params;
1705
+ const { provider = "claude" } = req.query;
1706
+ const homeDir = os.homedir();
1707
+
1708
+ // Allow only safe characters in sessionId
1709
+ const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, "");
1710
+ if (!safeSessionId) {
1711
+ return res.status(400).json({ error: "Invalid sessionId" });
1712
+ }
1713
+
1714
+ // Handle Cursor sessions - they use SQLite and don't have token usage info
1715
+ if (provider === "cursor") {
1716
+ return res.json({
1717
+ used: 0,
1718
+ total: 0,
1719
+ breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
1720
+ unsupported: true,
1721
+ message: "Token usage tracking not available for Cursor sessions",
1722
+ });
1723
+ }
1724
+
1725
+ // Handle Codex sessions
1726
+ if (provider === "codex") {
1727
+ const codexSessionsDir = path.join(homeDir, ".codex", "sessions");
1728
+
1729
+ // Find the session file by searching for the session ID
1730
+ const findSessionFile = async (dir) => {
1731
+ try {
1732
+ const entries = await fsPromises.readdir(dir, {
1733
+ withFileTypes: true,
1734
+ });
1735
+ for (const entry of entries) {
1736
+ const fullPath = path.join(dir, entry.name);
1737
+ if (entry.isDirectory()) {
1738
+ const found = await findSessionFile(fullPath);
1739
+ if (found) return found;
1740
+ } else if (
1741
+ entry.name.includes(safeSessionId) &&
1742
+ entry.name.endsWith(".jsonl")
1743
+ ) {
1744
+ return fullPath;
1745
+ }
1746
+ }
1747
+ } catch (error) {
1748
+ // Skip directories we can't read
1749
+ }
1750
+ return null;
1751
+ };
1752
+
1753
+ const sessionFilePath = await findSessionFile(codexSessionsDir);
1754
+
1755
+ if (!sessionFilePath) {
1756
+ return res.status(404).json({
1757
+ error: "Codex session file not found",
1758
+ sessionId: safeSessionId,
1759
+ });
1760
+ }
1761
+
1762
+ // Read and parse the Codex JSONL file
1763
+ let fileContent;
1764
+ try {
1765
+ fileContent = await fsPromises.readFile(sessionFilePath, "utf8");
1766
+ } catch (error) {
1767
+ if (error.code === "ENOENT") {
1768
+ return res
1769
+ .status(404)
1770
+ .json({ error: "Session file not found", path: sessionFilePath });
1771
+ }
1772
+ throw error;
1773
+ }
1774
+ const lines = fileContent.trim().split("\n");
1775
+ let totalTokens = 0;
1776
+ let contextWindow = 200000; // Default for Codex/OpenAI
1777
+
1778
+ // Find the latest token_count event with info (scan from end)
1779
+ for (let i = lines.length - 1; i >= 0; i--) {
1780
+ try {
1781
+ const entry = JSON.parse(lines[i]);
1782
+
1783
+ // Codex stores token info in event_msg with type: "token_count"
1784
+ if (
1785
+ entry.type === "event_msg" &&
1786
+ entry.payload?.type === "token_count" &&
1787
+ entry.payload?.info
1788
+ ) {
1789
+ const tokenInfo = entry.payload.info;
1790
+ if (tokenInfo.total_token_usage) {
1791
+ totalTokens = tokenInfo.total_token_usage.total_tokens || 0;
1792
+ }
1793
+ if (tokenInfo.model_context_window) {
1794
+ contextWindow = tokenInfo.model_context_window;
1795
+ }
1796
+ break; // Stop after finding the latest token count
1797
+ }
1798
+ } catch (parseError) {
1799
+ // Skip lines that can't be parsed
1800
+ continue;
1801
+ }
1802
+ }
1803
+
1804
+ return res.json({
1805
+ used: totalTokens,
1806
+ total: contextWindow,
1807
+ });
1808
+ }
1809
+
1810
+ // Handle Claude sessions (default)
1811
+ // Extract actual project path
1812
+ let projectPath;
1813
+ try {
1814
+ projectPath = await extractProjectDirectory(projectName);
1815
+ } catch (error) {
1816
+ console.error("Error extracting project directory:", error);
1817
+ return res
1818
+ .status(500)
1819
+ .json({ error: "Failed to determine project path" });
1820
+ }
1821
+
1822
+ // Construct the JSONL file path
1823
+ // Claude stores session files in ~/.claude/projects/[encoded-project-path]/[session-id].jsonl
1824
+ // The encoding replaces /, spaces, ~, and _ with -
1825
+ const encodedPath = projectPath.replace(/[\\/:\s~_]/g, "-");
1826
+ const projectDir = path.join(homeDir, ".claude", "projects", encodedPath);
1827
+
1828
+ const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
1829
+
1830
+ // Constrain to projectDir
1831
+ const rel = path.relative(
1832
+ path.resolve(projectDir),
1833
+ path.resolve(jsonlPath),
1834
+ );
1835
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
1836
+ return res.status(400).json({ error: "Invalid path" });
1837
+ }
1838
+
1839
+ // Read and parse the JSONL file
1840
+ let fileContent;
1841
+ try {
1842
+ fileContent = await fsPromises.readFile(jsonlPath, "utf8");
1843
+ } catch (error) {
1844
+ if (error.code === "ENOENT") {
1845
+ return res
1846
+ .status(404)
1847
+ .json({ error: "Session file not found", path: jsonlPath });
1848
+ }
1849
+ throw error; // Re-throw other errors to be caught by outer try-catch
1850
+ }
1851
+ const lines = fileContent.trim().split("\n");
1852
+
1853
+ const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10);
1854
+ const contextWindow = Number.isFinite(parsedContextWindow)
1855
+ ? parsedContextWindow
1856
+ : 160000;
1857
+ let inputTokens = 0;
1858
+ let cacheCreationTokens = 0;
1859
+ let cacheReadTokens = 0;
1860
+
1861
+ // Find the latest assistant message with usage data (scan from end)
1862
+ for (let i = lines.length - 1; i >= 0; i--) {
1863
+ try {
1864
+ const entry = JSON.parse(lines[i]);
1865
+
1866
+ // Only count assistant messages which have usage data
1867
+ if (entry.type === "assistant" && entry.message?.usage) {
1868
+ const usage = entry.message.usage;
1869
+
1870
+ // Use token counts from latest assistant message only
1871
+ inputTokens = usage.input_tokens || 0;
1872
+ cacheCreationTokens = usage.cache_creation_input_tokens || 0;
1873
+ cacheReadTokens = usage.cache_read_input_tokens || 0;
1874
+
1875
+ break; // Stop after finding the latest assistant message
1876
+ }
1877
+ } catch (parseError) {
1878
+ // Skip lines that can't be parsed
1879
+ continue;
1880
+ }
1881
+ }
1882
+
1883
+ // Calculate total context usage (excluding output_tokens, as per ccusage)
1884
+ const totalUsed = inputTokens + cacheCreationTokens + cacheReadTokens;
1885
+
1886
+ res.json({
1887
+ used: totalUsed,
1888
+ total: contextWindow,
1889
+ breakdown: {
1890
+ input: inputTokens,
1891
+ cacheCreation: cacheCreationTokens,
1892
+ cacheRead: cacheReadTokens,
1893
+ },
1894
+ });
1895
+ } catch (error) {
1896
+ console.error("Error reading session token usage:", error);
1897
+ res.status(500).json({ error: "Failed to read session token usage" });
1898
+ }
1899
+ },
1900
+ );
1901
+
1902
+ // Serve React app for all other routes (excluding static files)
1903
+ app.get("*", (req, res) => {
1904
+ // Skip requests for static assets (files with extensions)
1905
+ if (path.extname(req.path)) {
1906
+ return res.status(404).send("Not found");
1907
+ }
1908
+
1909
+ // Only serve index.html for HTML routes, not for static assets
1910
+ // Static assets should already be handled by express.static middleware above
1911
+ const indexPath = path.join(__dirname, "../dist/index.html");
1912
+
1913
+ // Check if dist/index.html exists (production build available)
1914
+ if (fs.existsSync(indexPath)) {
1915
+ // Set no-cache headers for HTML to prevent service worker issues
1916
+ res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
1917
+ res.setHeader("Pragma", "no-cache");
1918
+ res.setHeader("Expires", "0");
1919
+ res.sendFile(indexPath);
1920
+ } else {
1921
+ // In development, redirect to Vite dev server only if dist doesn't exist
1922
+ res.redirect(`http://localhost:${process.env.VITE_PORT || 5173}`);
1923
+ }
1924
+ });
1925
+
1926
+ // Helper function to convert permissions to rwx format
1927
+ function permToRwx(perm) {
1928
+ const r = perm & 4 ? "r" : "-";
1929
+ const w = perm & 2 ? "w" : "-";
1930
+ const x = perm & 1 ? "x" : "-";
1931
+ return r + w + x;
1932
+ }
1933
+
1934
+ async function getFileTree(
1935
+ dirPath,
1936
+ maxDepth = 3,
1937
+ currentDepth = 0,
1938
+ showHidden = true,
1939
+ ) {
1940
+ // Using fsPromises from import
1941
+ const items = [];
1942
+
1943
+ try {
1944
+ const entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
1945
+
1946
+ for (const entry of entries) {
1947
+ // Debug: log all entries including hidden files
1948
+
1949
+ // Skip heavy build directories and VCS directories
1950
+ if (
1951
+ entry.name === "node_modules" ||
1952
+ entry.name === "dist" ||
1953
+ entry.name === "build" ||
1954
+ entry.name === ".git" ||
1955
+ entry.name === ".svn" ||
1956
+ entry.name === ".hg"
1957
+ )
1958
+ continue;
1959
+
1960
+ const itemPath = path.join(dirPath, entry.name);
1961
+ const item = {
1962
+ name: entry.name,
1963
+ path: itemPath,
1964
+ type: entry.isDirectory() ? "directory" : "file",
1965
+ };
1966
+
1967
+ // Get file stats for additional metadata
1968
+ try {
1969
+ const stats = await fsPromises.stat(itemPath);
1970
+ item.size = stats.size;
1971
+ item.modified = stats.mtime.toISOString();
1972
+
1973
+ // Convert permissions to rwx format
1974
+ const mode = stats.mode;
1975
+ const ownerPerm = (mode >> 6) & 7;
1976
+ const groupPerm = (mode >> 3) & 7;
1977
+ const otherPerm = mode & 7;
1978
+ item.permissions =
1979
+ ((mode >> 6) & 7).toString() +
1980
+ ((mode >> 3) & 7).toString() +
1981
+ (mode & 7).toString();
1982
+ item.permissionsRwx =
1983
+ permToRwx(ownerPerm) + permToRwx(groupPerm) + permToRwx(otherPerm);
1984
+ } catch (statError) {
1985
+ // If stat fails, provide default values
1986
+ item.size = 0;
1987
+ item.modified = null;
1988
+ item.permissions = "000";
1989
+ item.permissionsRwx = "---------";
1990
+ }
1991
+
1992
+ if (entry.isDirectory() && currentDepth < maxDepth) {
1993
+ // Recursively get subdirectories but limit depth
1994
+ try {
1995
+ // Check if we can access the directory before trying to read it
1996
+ await fsPromises.access(item.path, fs.constants.R_OK);
1997
+ item.children = await getFileTree(
1998
+ item.path,
1999
+ maxDepth,
2000
+ currentDepth + 1,
2001
+ showHidden,
2002
+ );
2003
+ } catch (e) {
2004
+ // Silently skip directories we can't access (permission denied, etc.)
2005
+ item.children = [];
2006
+ }
2007
+ }
2008
+
2009
+ items.push(item);
2010
+ }
2011
+ } catch (error) {
2012
+ // Only log non-permission errors to avoid spam
2013
+ if (error.code !== "EACCES" && error.code !== "EPERM") {
2014
+ console.error("Error reading directory:", error);
2015
+ }
2016
+ }
2017
+
2018
+ return items.sort((a, b) => {
2019
+ if (a.type !== b.type) {
2020
+ return a.type === "directory" ? -1 : 1;
2021
+ }
2022
+ return a.name.localeCompare(b.name);
2023
+ });
2024
+ }
2025
+
2026
+ const PORT = process.env.PORT || 3001;
2027
+
2028
+ // Initialize database and start server
2029
+ async function startServer() {
2030
+ try {
2031
+ // Initialize authentication database
2032
+ await initializeDatabase();
2033
+
2034
+ // Check if running in production mode (dist folder exists)
2035
+ const distIndexPath = path.join(__dirname, "../dist/index.html");
2036
+ const isProduction = fs.existsSync(distIndexPath);
2037
+
2038
+ // Log Claude implementation mode
2039
+ console.log(
2040
+ `${c.info("[INFO]")} Using Claude Agents SDK for Claude integration`,
2041
+ );
2042
+ console.log(
2043
+ `${c.info("[INFO]")} Running in ${c.bright(isProduction ? "PRODUCTION" : "DEVELOPMENT")} mode`,
2044
+ );
2045
+
2046
+ if (!isProduction) {
2047
+ console.log(
2048
+ `${c.warn("[WARN]")} Note: Requests will be proxied to Vite dev server at ${c.dim("http://localhost:" + (process.env.VITE_PORT || 5173))}`,
2049
+ );
2050
+ }
2051
+
2052
+ // Initialize orchestrator integration (if configured)
2053
+ const orchestratorMode = process.env.ORCHESTRATOR_MODE || "standalone";
2054
+ if (orchestratorMode === "client") {
2055
+ console.log(
2056
+ `${c.info("[INFO]")} Orchestrator mode: ${c.bright("client")}`,
2057
+ );
2058
+
2059
+ try {
2060
+ const result = await initializeOrchestrator({
2061
+ handlers: {
2062
+ handleChatMessage,
2063
+ },
2064
+ });
2065
+
2066
+ if (result) {
2067
+ orchestrator = result;
2068
+ orchestratorStatusHooks = result.statusHooks;
2069
+ console.log(
2070
+ `${c.ok("[OK]")} Connected to orchestrator at ${c.dim(process.env.ORCHESTRATOR_URL)}`,
2071
+ );
2072
+ }
2073
+ } catch (orchError) {
2074
+ console.warn(
2075
+ `${c.warn("[WARN]")} Orchestrator connection failed: ${orchError.message}`,
2076
+ );
2077
+ console.log(`${c.info("[INFO]")} Continuing in standalone mode`);
2078
+ }
2079
+ } else {
2080
+ console.log(
2081
+ `${c.info("[INFO]")} Orchestrator mode: ${c.dim("standalone")}`,
2082
+ );
2083
+ }
2084
+
2085
+ server.listen(PORT, "0.0.0.0", async () => {
2086
+ const appInstallPath = path.join(__dirname, "..");
2087
+
2088
+ console.log("");
2089
+ console.log(c.dim("═".repeat(63)));
2090
+ console.log(` ${c.bright("Claude Code UI Server - Ready")}`);
2091
+ console.log(c.dim("═".repeat(63)));
2092
+ console.log("");
2093
+ console.log(
2094
+ `${c.info("[INFO]")} Server URL: ${c.bright("http://0.0.0.0:" + PORT)}`,
2095
+ );
2096
+ console.log(`${c.info("[INFO]")} Installed at: ${c.dim(appInstallPath)}`);
2097
+
2098
+ // Show orchestrator status
2099
+ if (orchestrator && orchestrator.client) {
2100
+ const state = orchestrator.client.getState();
2101
+ console.log(
2102
+ `${c.info("[INFO]")} Orchestrator: ${state.isConnected ? c.ok("Connected") : c.warn("Disconnected")} (${c.dim(state.clientId)})`,
2103
+ );
2104
+ }
2105
+
2106
+ console.log(
2107
+ `${c.tip("[TIP]")} Run "cloudcli status" for full configuration details`,
2108
+ );
2109
+ console.log("");
2110
+
2111
+ // Start watching the projects folder for changes
2112
+ await setupProjectsWatcher();
2113
+ });
2114
+ } catch (error) {
2115
+ console.error("[ERROR] Failed to start server:", error);
2116
+ process.exit(1);
2117
+ }
2118
+ }
2119
+
2120
+ startServer();