@siteboon/claude-code-ui 1.16.3 → 1.17.1

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.
package/server/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
- // Load environment variables from .env file
2
+ // Load environment variables before other imports execute
3
+ import './load-env.js';
3
4
  import fs from 'fs';
4
5
  import path from 'path';
5
6
  import { fileURLToPath } from 'url';
@@ -28,22 +29,6 @@ const c = {
28
29
  dim: (text) => `${colors.dim}${text}${colors.reset}`,
29
30
  };
30
31
 
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
32
  console.log('PORT from env:', process.env.PORT);
48
33
 
49
34
  import express from 'express';
@@ -76,9 +61,26 @@ import userRoutes from './routes/user.js';
76
61
  import codexRoutes from './routes/codex.js';
77
62
  import { initializeDatabase } from './database/db.js';
78
63
  import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
79
-
80
- // File system watcher for projects folder
81
- let projectsWatcher = null;
64
+ import { IS_PLATFORM } from './constants/config.js';
65
+
66
+ // File system watchers for provider project/session folders
67
+ const PROVIDER_WATCH_PATHS = [
68
+ { provider: 'claude', rootPath: path.join(os.homedir(), '.claude', 'projects') },
69
+ { provider: 'cursor', rootPath: path.join(os.homedir(), '.cursor', 'chats') },
70
+ { provider: 'codex', rootPath: path.join(os.homedir(), '.codex', 'sessions') }
71
+ ];
72
+ const WATCHER_IGNORED_PATTERNS = [
73
+ '**/node_modules/**',
74
+ '**/.git/**',
75
+ '**/dist/**',
76
+ '**/build/**',
77
+ '**/*.tmp',
78
+ '**/*.swp',
79
+ '**/.DS_Store'
80
+ ];
81
+ const WATCHER_DEBOUNCE_MS = 300;
82
+ let projectsWatchers = [];
83
+ let projectsWatcherDebounceTimer = null;
82
84
  const connectedClients = new Set();
83
85
  let isGetProjectsRunning = false; // Flag to prevent reentrant calls
84
86
 
@@ -95,94 +97,110 @@ function broadcastProgress(progress) {
95
97
  });
96
98
  }
97
99
 
98
- // Setup file system watcher for Claude projects folder using chokidar
100
+ // Setup file system watchers for Claude, Cursor, and Codex project/session folders
99
101
  async function setupProjectsWatcher() {
100
102
  const chokidar = (await import('chokidar')).default;
101
- const claudeProjectsPath = path.join(os.homedir(), '.claude', 'projects');
102
103
 
103
- if (projectsWatcher) {
104
- projectsWatcher.close();
104
+ if (projectsWatcherDebounceTimer) {
105
+ clearTimeout(projectsWatcherDebounceTimer);
106
+ projectsWatcherDebounceTimer = null;
105
107
  }
106
108
 
107
- try {
108
- // Initialize chokidar watcher with optimized settings
109
- projectsWatcher = chokidar.watch(claudeProjectsPath, {
110
- ignored: [
111
- '**/node_modules/**',
112
- '**/.git/**',
113
- '**/dist/**',
114
- '**/build/**',
115
- '**/*.tmp',
116
- '**/*.swp',
117
- '**/.DS_Store'
118
- ],
119
- persistent: true,
120
- ignoreInitial: true, // Don't fire events for existing files on startup
121
- followSymlinks: false,
122
- depth: 10, // Reasonable depth limit
123
- awaitWriteFinish: {
124
- stabilityThreshold: 100, // Wait 100ms for file to stabilize
125
- pollInterval: 50
109
+ await Promise.all(
110
+ projectsWatchers.map(async (watcher) => {
111
+ try {
112
+ await watcher.close();
113
+ } catch (error) {
114
+ console.error('[WARN] Failed to close watcher:', error);
126
115
  }
127
- });
128
-
129
- // Debounce function to prevent excessive notifications
130
- let debounceTimer;
131
- const debouncedUpdate = async (eventType, filePath) => {
132
- clearTimeout(debounceTimer);
133
- debounceTimer = setTimeout(async () => {
134
- // Prevent reentrant calls
135
- if (isGetProjectsRunning) {
136
- return;
137
- }
116
+ })
117
+ );
118
+ projectsWatchers = [];
138
119
 
139
- try {
140
- isGetProjectsRunning = true;
120
+ const debouncedUpdate = (eventType, filePath, provider, rootPath) => {
121
+ if (projectsWatcherDebounceTimer) {
122
+ clearTimeout(projectsWatcherDebounceTimer);
123
+ }
141
124
 
142
- // Clear project directory cache when files change
143
- clearProjectDirectoryCache();
125
+ projectsWatcherDebounceTimer = setTimeout(async () => {
126
+ // Prevent reentrant calls
127
+ if (isGetProjectsRunning) {
128
+ return;
129
+ }
144
130
 
145
- // Get updated projects list
146
- const updatedProjects = await getProjects(broadcastProgress);
131
+ try {
132
+ isGetProjectsRunning = true;
133
+
134
+ // Clear project directory cache when files change
135
+ clearProjectDirectoryCache();
136
+
137
+ // Get updated projects list
138
+ const updatedProjects = await getProjects(broadcastProgress);
139
+
140
+ // Notify all connected clients about the project changes
141
+ const updateMessage = JSON.stringify({
142
+ type: 'projects_updated',
143
+ projects: updatedProjects,
144
+ timestamp: new Date().toISOString(),
145
+ changeType: eventType,
146
+ changedFile: path.relative(rootPath, filePath),
147
+ watchProvider: provider
148
+ });
147
149
 
148
- // Notify all connected clients about the project changes
149
- const updateMessage = JSON.stringify({
150
- type: 'projects_updated',
151
- projects: updatedProjects,
152
- timestamp: new Date().toISOString(),
153
- changeType: eventType,
154
- changedFile: path.relative(claudeProjectsPath, filePath)
155
- });
150
+ connectedClients.forEach(client => {
151
+ if (client.readyState === WebSocket.OPEN) {
152
+ client.send(updateMessage);
153
+ }
154
+ });
156
155
 
157
- connectedClients.forEach(client => {
158
- if (client.readyState === WebSocket.OPEN) {
159
- client.send(updateMessage);
160
- }
161
- });
156
+ } catch (error) {
157
+ console.error('[ERROR] Error handling project changes:', error);
158
+ } finally {
159
+ isGetProjectsRunning = false;
160
+ }
161
+ }, WATCHER_DEBOUNCE_MS);
162
+ };
162
163
 
163
- } catch (error) {
164
- console.error('[ERROR] Error handling project changes:', error);
165
- } finally {
166
- isGetProjectsRunning = false;
164
+ for (const { provider, rootPath } of PROVIDER_WATCH_PATHS) {
165
+ try {
166
+ // chokidar v4 emits ENOENT via the "error" event for missing roots and will not auto-recover.
167
+ // Ensure provider folders exist before creating the watcher so watching stays active.
168
+ await fsPromises.mkdir(rootPath, { recursive: true });
169
+
170
+ // Initialize chokidar watcher with optimized settings
171
+ const watcher = chokidar.watch(rootPath, {
172
+ ignored: WATCHER_IGNORED_PATTERNS,
173
+ persistent: true,
174
+ ignoreInitial: true, // Don't fire events for existing files on startup
175
+ followSymlinks: false,
176
+ depth: 10, // Reasonable depth limit
177
+ awaitWriteFinish: {
178
+ stabilityThreshold: 100, // Wait 100ms for file to stabilize
179
+ pollInterval: 50
167
180
  }
168
- }, 300); // 300ms debounce (slightly faster than before)
169
- };
170
-
171
- // Set up event listeners
172
- projectsWatcher
173
- .on('add', (filePath) => debouncedUpdate('add', filePath))
174
- .on('change', (filePath) => debouncedUpdate('change', filePath))
175
- .on('unlink', (filePath) => debouncedUpdate('unlink', filePath))
176
- .on('addDir', (dirPath) => debouncedUpdate('addDir', dirPath))
177
- .on('unlinkDir', (dirPath) => debouncedUpdate('unlinkDir', dirPath))
178
- .on('error', (error) => {
179
- console.error('[ERROR] Chokidar watcher error:', error);
180
- })
181
- .on('ready', () => {
182
181
  });
183
182
 
184
- } catch (error) {
185
- console.error('[ERROR] Failed to setup projects watcher:', error);
183
+ // Set up event listeners
184
+ watcher
185
+ .on('add', (filePath) => debouncedUpdate('add', filePath, provider, rootPath))
186
+ .on('change', (filePath) => debouncedUpdate('change', filePath, provider, rootPath))
187
+ .on('unlink', (filePath) => debouncedUpdate('unlink', filePath, provider, rootPath))
188
+ .on('addDir', (dirPath) => debouncedUpdate('addDir', dirPath, provider, rootPath))
189
+ .on('unlinkDir', (dirPath) => debouncedUpdate('unlinkDir', dirPath, provider, rootPath))
190
+ .on('error', (error) => {
191
+ console.error(`[ERROR] ${provider} watcher error:`, error);
192
+ })
193
+ .on('ready', () => {
194
+ });
195
+
196
+ projectsWatchers.push(watcher);
197
+ } catch (error) {
198
+ console.error(`[ERROR] Failed to setup ${provider} watcher for ${rootPath}:`, error);
199
+ }
200
+ }
201
+
202
+ if (projectsWatchers.length === 0) {
203
+ console.error('[ERROR] Failed to setup any provider watchers');
186
204
  }
187
205
  }
188
206
 
@@ -192,6 +210,69 @@ const server = http.createServer(app);
192
210
 
193
211
  const ptySessionsMap = new Map();
194
212
  const PTY_SESSION_TIMEOUT = 30 * 60 * 1000;
213
+ const SHELL_URL_PARSE_BUFFER_LIMIT = 32768;
214
+ const ANSI_ESCAPE_SEQUENCE_REGEX = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\))/g;
215
+ const TRAILING_URL_PUNCTUATION_REGEX = /[)\]}>.,;:!?]+$/;
216
+
217
+ function stripAnsiSequences(value = '') {
218
+ return value.replace(ANSI_ESCAPE_SEQUENCE_REGEX, '');
219
+ }
220
+
221
+ function normalizeDetectedUrl(url) {
222
+ if (!url || typeof url !== 'string') return null;
223
+
224
+ const cleaned = url.trim().replace(TRAILING_URL_PUNCTUATION_REGEX, '');
225
+ if (!cleaned) return null;
226
+
227
+ try {
228
+ const parsed = new URL(cleaned);
229
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
230
+ return null;
231
+ }
232
+ return parsed.toString();
233
+ } catch {
234
+ return null;
235
+ }
236
+ }
237
+
238
+ function extractUrlsFromText(value = '') {
239
+ const directMatches = value.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/gi) || [];
240
+
241
+ // Handle wrapped terminal URLs split across lines by terminal width.
242
+ const wrappedMatches = [];
243
+ const continuationRegex = /^[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$/;
244
+ const lines = value.split(/\r?\n/);
245
+ for (let i = 0; i < lines.length; i++) {
246
+ const line = lines[i].trim();
247
+ const startMatch = line.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/i);
248
+ if (!startMatch) continue;
249
+
250
+ let combined = startMatch[0];
251
+ let j = i + 1;
252
+ while (j < lines.length) {
253
+ const continuation = lines[j].trim();
254
+ if (!continuation) break;
255
+ if (!continuationRegex.test(continuation)) break;
256
+ combined += continuation;
257
+ j++;
258
+ }
259
+
260
+ wrappedMatches.push(combined.replace(/\r?\n\s*/g, ''));
261
+ }
262
+
263
+ return Array.from(new Set([...directMatches, ...wrappedMatches]));
264
+ }
265
+
266
+ function shouldAutoOpenUrlFromOutput(value = '') {
267
+ const normalized = value.toLowerCase();
268
+ return (
269
+ normalized.includes('browser didn\'t open') ||
270
+ normalized.includes('open this url') ||
271
+ normalized.includes('continue in your browser') ||
272
+ normalized.includes('press enter to open') ||
273
+ normalized.includes('open_url:')
274
+ );
275
+ }
195
276
 
196
277
  // Single WebSocket server that handles both paths
197
278
  const wss = new WebSocketServer({
@@ -200,7 +281,7 @@ const wss = new WebSocketServer({
200
281
  console.log('WebSocket connection attempt to:', info.req.url);
201
282
 
202
283
  // Platform mode: always allow connection
203
- if (process.env.VITE_IS_PLATFORM === 'true') {
284
+ if (IS_PLATFORM) {
204
285
  const user = authenticateWebSocket(null); // Will return first user
205
286
  if (!user) {
206
287
  console.log('[WARN] Platform mode: No user found in database');
@@ -974,7 +1055,8 @@ function handleShellConnection(ws) {
974
1055
  console.log('🐚 Shell client connected');
975
1056
  let shellProcess = null;
976
1057
  let ptySessionKey = null;
977
- let outputBuffer = [];
1058
+ let urlDetectionBuffer = '';
1059
+ const announcedAuthUrls = new Set();
978
1060
 
979
1061
  ws.on('message', async (message) => {
980
1062
  try {
@@ -988,6 +1070,8 @@ function handleShellConnection(ws) {
988
1070
  const provider = data.provider || 'claude';
989
1071
  const initialCommand = data.initialCommand;
990
1072
  const isPlainShell = data.isPlainShell || (!!initialCommand && !hasSession) || provider === 'plain-shell';
1073
+ urlDetectionBuffer = '';
1074
+ announcedAuthUrls.clear();
991
1075
 
992
1076
  // Login commands (Claude/Cursor auth) should never reuse cached sessions
993
1077
  const isLoginCommand = initialCommand && (
@@ -1127,9 +1211,7 @@ function handleShellConnection(ws) {
1127
1211
  ...process.env,
1128
1212
  TERM: 'xterm-256color',
1129
1213
  COLORTERM: 'truecolor',
1130
- FORCE_COLOR: '3',
1131
- // Override browser opening commands to echo URL for detection
1132
- BROWSER: os.platform() === 'win32' ? 'echo "OPEN_URL:"' : 'echo "OPEN_URL:"'
1214
+ FORCE_COLOR: '3'
1133
1215
  }
1134
1216
  });
1135
1217
 
@@ -1159,38 +1241,47 @@ function handleShellConnection(ws) {
1159
1241
  if (session.ws && session.ws.readyState === WebSocket.OPEN) {
1160
1242
  let outputData = data;
1161
1243
 
1162
- // Check for various URL opening patterns
1163
- const patterns = [
1164
- // Direct browser opening commands
1165
- /(?:xdg-open|open|start)\s+(https?:\/\/[^\s\x1b\x07]+)/g,
1166
- // BROWSER environment variable override
1244
+ const cleanChunk = stripAnsiSequences(data);
1245
+ urlDetectionBuffer = `${urlDetectionBuffer}${cleanChunk}`.slice(-SHELL_URL_PARSE_BUFFER_LIMIT);
1246
+
1247
+ outputData = outputData.replace(
1167
1248
  /OPEN_URL:\s*(https?:\/\/[^\s\x1b\x07]+)/g,
1168
- // Git and other tools opening URLs
1169
- /Opening\s+(https?:\/\/[^\s\x1b\x07]+)/gi,
1170
- // General URL patterns that might be opened
1171
- /Visit:\s*(https?:\/\/[^\s\x1b\x07]+)/gi,
1172
- /View at:\s*(https?:\/\/[^\s\x1b\x07]+)/gi,
1173
- /Browse to:\s*(https?:\/\/[^\s\x1b\x07]+)/gi
1174
- ];
1175
-
1176
- patterns.forEach(pattern => {
1177
- let match;
1178
- while ((match = pattern.exec(data)) !== null) {
1179
- const url = match[1];
1180
- console.log('[DEBUG] Detected URL for opening:', url);
1181
-
1182
- // Send URL opening message to client
1249
+ '[INFO] Opening in browser: $1'
1250
+ );
1251
+
1252
+ const emitAuthUrl = (detectedUrl, autoOpen = false) => {
1253
+ const normalizedUrl = normalizeDetectedUrl(detectedUrl);
1254
+ if (!normalizedUrl) return;
1255
+
1256
+ const isNewUrl = !announcedAuthUrls.has(normalizedUrl);
1257
+ if (isNewUrl) {
1258
+ announcedAuthUrls.add(normalizedUrl);
1183
1259
  session.ws.send(JSON.stringify({
1184
- type: 'url_open',
1185
- url: url
1260
+ type: 'auth_url',
1261
+ url: normalizedUrl,
1262
+ autoOpen
1186
1263
  }));
1187
-
1188
- // Replace the OPEN_URL pattern with a user-friendly message
1189
- if (pattern.source.includes('OPEN_URL')) {
1190
- outputData = outputData.replace(match[0], `[INFO] Opening in browser: ${url}`);
1191
- }
1192
1264
  }
1193
- });
1265
+
1266
+ };
1267
+
1268
+ const normalizedDetectedUrls = extractUrlsFromText(urlDetectionBuffer)
1269
+ .map((url) => normalizeDetectedUrl(url))
1270
+ .filter(Boolean);
1271
+
1272
+ // Prefer the most complete URL if shorter prefix variants are also present.
1273
+ const dedupedDetectedUrls = Array.from(new Set(normalizedDetectedUrls)).filter((url, _, urls) =>
1274
+ !urls.some((otherUrl) => otherUrl !== url && otherUrl.startsWith(url))
1275
+ );
1276
+
1277
+ dedupedDetectedUrls.forEach((url) => emitAuthUrl(url, false));
1278
+
1279
+ if (shouldAutoOpenUrlFromOutput(cleanChunk) && dedupedDetectedUrls.length > 0) {
1280
+ const bestUrl = dedupedDetectedUrls.reduce((longest, current) =>
1281
+ current.length > longest.length ? current : longest
1282
+ );
1283
+ emitAuthUrl(bestUrl, true);
1284
+ }
1194
1285
 
1195
1286
  // Send regular output
1196
1287
  session.ws.send(JSON.stringify({
@@ -0,0 +1,24 @@
1
+ // Load environment variables from .env before other imports execute.
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import { dirname } from 'path';
6
+
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = dirname(__filename);
9
+
10
+ try {
11
+ const envPath = path.join(__dirname, '../.env');
12
+ const envFile = fs.readFileSync(envPath, 'utf8');
13
+ envFile.split('\n').forEach(line => {
14
+ const trimmedLine = line.trim();
15
+ if (trimmedLine && !trimmedLine.startsWith('#')) {
16
+ const [key, ...valueParts] = trimmedLine.split('=');
17
+ if (key && valueParts.length > 0 && !process.env[key]) {
18
+ process.env[key] = valueParts.join('=').trim();
19
+ }
20
+ }
21
+ });
22
+ } catch (e) {
23
+ console.log('No .env file found or error reading it:', e.message);
24
+ }
@@ -1,5 +1,6 @@
1
1
  import jwt from 'jsonwebtoken';
2
2
  import { userDb } from '../database/db.js';
3
+ import { IS_PLATFORM } from '../constants/config.js';
3
4
 
4
5
  // Get JWT secret from environment or use default (for development)
5
6
  const JWT_SECRET = process.env.JWT_SECRET || 'claude-ui-dev-secret-change-in-production';
@@ -21,7 +22,7 @@ const validateApiKey = (req, res, next) => {
21
22
  // JWT authentication middleware
22
23
  const authenticateToken = async (req, res, next) => {
23
24
  // Platform mode: use single database user
24
- if (process.env.VITE_IS_PLATFORM === 'true') {
25
+ if (IS_PLATFORM) {
25
26
  try {
26
27
  const user = userDb.getFirstUser();
27
28
  if (!user) {
@@ -80,7 +81,7 @@ const generateToken = (user) => {
80
81
  // WebSocket authentication function
81
82
  const authenticateWebSocket = (token) => {
82
83
  // Platform mode: bypass token validation, return first user
83
- if (process.env.VITE_IS_PLATFORM === 'true') {
84
+ if (IS_PLATFORM) {
84
85
  try {
85
86
  const user = userDb.getFirstUser();
86
87
  if (user) {
@@ -203,6 +203,7 @@ export async function queryCodex(command, options = {}, ws) {
203
203
  let codex;
204
204
  let thread;
205
205
  let currentSessionId = sessionId;
206
+ const abortController = new AbortController();
206
207
 
207
208
  try {
208
209
  // Initialize Codex SDK
@@ -232,6 +233,7 @@ export async function queryCodex(command, options = {}, ws) {
232
233
  thread,
233
234
  codex,
234
235
  status: 'running',
236
+ abortController,
235
237
  startedAt: new Date().toISOString()
236
238
  });
237
239
 
@@ -243,7 +245,9 @@ export async function queryCodex(command, options = {}, ws) {
243
245
  });
244
246
 
245
247
  // Execute with streaming
246
- const streamedTurn = await thread.runStreamed(command);
248
+ const streamedTurn = await thread.runStreamed(command, {
249
+ signal: abortController.signal
250
+ });
247
251
 
248
252
  for await (const event of streamedTurn.events) {
249
253
  // Check if session was aborted
@@ -286,20 +290,27 @@ export async function queryCodex(command, options = {}, ws) {
286
290
  });
287
291
 
288
292
  } catch (error) {
289
- console.error('[Codex] Error:', error);
290
-
291
- sendMessage(ws, {
292
- type: 'codex-error',
293
- error: error.message,
294
- sessionId: currentSessionId
295
- });
293
+ const session = currentSessionId ? activeCodexSessions.get(currentSessionId) : null;
294
+ const wasAborted =
295
+ session?.status === 'aborted' ||
296
+ error?.name === 'AbortError' ||
297
+ String(error?.message || '').toLowerCase().includes('aborted');
298
+
299
+ if (!wasAborted) {
300
+ console.error('[Codex] Error:', error);
301
+ sendMessage(ws, {
302
+ type: 'codex-error',
303
+ error: error.message,
304
+ sessionId: currentSessionId
305
+ });
306
+ }
296
307
 
297
308
  } finally {
298
309
  // Update session status
299
310
  if (currentSessionId) {
300
311
  const session = activeCodexSessions.get(currentSessionId);
301
312
  if (session) {
302
- session.status = 'completed';
313
+ session.status = session.status === 'aborted' ? 'aborted' : 'completed';
303
314
  }
304
315
  }
305
316
  }
@@ -318,9 +329,11 @@ export function abortCodexSession(sessionId) {
318
329
  }
319
330
 
320
331
  session.status = 'aborted';
321
-
322
- // The SDK doesn't have a direct abort method, but marking status
323
- // will cause the streaming loop to exit
332
+ try {
333
+ session.abortController?.abort();
334
+ } catch (error) {
335
+ console.warn(`[Codex] Failed to abort session ${sessionId}:`, error);
336
+ }
324
337
 
325
338
  return true;
326
339
  }