@siteboon/claude-code-ui 1.19.1 → 1.21.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.
- package/README.md +1 -0
- package/dist/assets/index-Cxnz_sny.css +32 -0
- package/dist/assets/index-DN2ZJcRJ.js +1381 -0
- package/dist/assets/{vendor-codemirror-l-lAmaJ1.js → vendor-codemirror-BMLq5tLB.js} +8 -8
- package/dist/assets/{vendor-xterm-DfaPXD3y.js → vendor-xterm-CJZjLICi.js} +10 -10
- package/dist/icons/gemini-ai-icon.svg +1 -0
- package/dist/index.html +4 -4
- package/package.json +4 -3
- package/server/claude-sdk.js +3 -0
- package/server/database/db.js +18 -2
- package/server/gemini-cli.js +455 -0
- package/server/gemini-response-handler.js +140 -0
- package/server/index.js +311 -227
- package/server/load-env.js +5 -0
- package/server/projects.js +292 -275
- package/server/routes/agent.js +15 -4
- package/server/routes/auth.js +3 -3
- package/server/routes/cli-auth.js +114 -0
- package/server/routes/gemini.js +46 -0
- package/server/sessionManager.js +226 -0
- package/shared/modelConstants.js +19 -0
- package/dist/assets/index-0DqtvI36.js +0 -1413
- package/dist/assets/index-BPHfv_yU.css +0 -32
- package/server/database/auth.db +0 -0
package/server/index.js
CHANGED
|
@@ -9,6 +9,8 @@ import { dirname } from 'path';
|
|
|
9
9
|
const __filename = fileURLToPath(import.meta.url);
|
|
10
10
|
const __dirname = dirname(__filename);
|
|
11
11
|
|
|
12
|
+
const installMode = fs.existsSync(path.join(__dirname, '..', '.git')) ? 'git' : 'npm';
|
|
13
|
+
|
|
12
14
|
// ANSI color codes for terminal output
|
|
13
15
|
const colors = {
|
|
14
16
|
reset: '\x1b[0m',
|
|
@@ -46,6 +48,8 @@ import { getProjects, getSessions, getSessionMessages, renameProject, deleteSess
|
|
|
46
48
|
import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval } from './claude-sdk.js';
|
|
47
49
|
import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
|
|
48
50
|
import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js';
|
|
51
|
+
import { spawnGemini, abortGeminiSession, isGeminiSessionActive, getActiveGeminiSessions } from './gemini-cli.js';
|
|
52
|
+
import sessionManager from './sessionManager.js';
|
|
49
53
|
import gitRoutes from './routes/git.js';
|
|
50
54
|
import authRoutes from './routes/auth.js';
|
|
51
55
|
import mcpRoutes from './routes/mcp.js';
|
|
@@ -59,6 +63,7 @@ import projectsRoutes, { WORKSPACES_ROOT, validateWorkspacePath } from './routes
|
|
|
59
63
|
import cliAuthRoutes from './routes/cli-auth.js';
|
|
60
64
|
import userRoutes from './routes/user.js';
|
|
61
65
|
import codexRoutes from './routes/codex.js';
|
|
66
|
+
import geminiRoutes from './routes/gemini.js';
|
|
62
67
|
import { initializeDatabase } from './database/db.js';
|
|
63
68
|
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
|
64
69
|
import { IS_PLATFORM } from './constants/config.js';
|
|
@@ -67,7 +72,9 @@ import { IS_PLATFORM } from './constants/config.js';
|
|
|
67
72
|
const PROVIDER_WATCH_PATHS = [
|
|
68
73
|
{ provider: 'claude', rootPath: path.join(os.homedir(), '.claude', 'projects') },
|
|
69
74
|
{ provider: 'cursor', rootPath: path.join(os.homedir(), '.cursor', 'chats') },
|
|
70
|
-
{ provider: 'codex', rootPath: path.join(os.homedir(), '.codex', 'sessions') }
|
|
75
|
+
{ provider: 'codex', rootPath: path.join(os.homedir(), '.codex', 'sessions') },
|
|
76
|
+
{ provider: 'gemini', rootPath: path.join(os.homedir(), '.gemini', 'projects') },
|
|
77
|
+
{ provider: 'gemini_sessions', rootPath: path.join(os.homedir(), '.gemini', 'sessions') }
|
|
71
78
|
];
|
|
72
79
|
const WATCHER_IGNORED_PATTERNS = [
|
|
73
80
|
'**/node_modules/**',
|
|
@@ -317,24 +324,25 @@ app.locals.wss = wss;
|
|
|
317
324
|
|
|
318
325
|
app.use(cors());
|
|
319
326
|
app.use(express.json({
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
327
|
+
limit: '50mb',
|
|
328
|
+
type: (req) => {
|
|
329
|
+
// Skip multipart/form-data requests (for file uploads like images)
|
|
330
|
+
const contentType = req.headers['content-type'] || '';
|
|
331
|
+
if (contentType.includes('multipart/form-data')) {
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
return contentType.includes('json');
|
|
326
335
|
}
|
|
327
|
-
return contentType.includes('json');
|
|
328
|
-
}
|
|
329
336
|
}));
|
|
330
337
|
app.use(express.urlencoded({ limit: '50mb', extended: true }));
|
|
331
338
|
|
|
332
339
|
// Public health check endpoint (no authentication required)
|
|
333
340
|
app.get('/health', (req, res) => {
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
341
|
+
res.json({
|
|
342
|
+
status: 'ok',
|
|
343
|
+
timestamp: new Date().toISOString(),
|
|
344
|
+
installMode
|
|
345
|
+
});
|
|
338
346
|
});
|
|
339
347
|
|
|
340
348
|
// Optional API key validation (if configured)
|
|
@@ -376,6 +384,9 @@ app.use('/api/user', authenticateToken, userRoutes);
|
|
|
376
384
|
// Codex API Routes (protected)
|
|
377
385
|
app.use('/api/codex', authenticateToken, codexRoutes);
|
|
378
386
|
|
|
387
|
+
// Gemini API Routes (protected)
|
|
388
|
+
app.use('/api/gemini', authenticateToken, geminiRoutes);
|
|
389
|
+
|
|
379
390
|
// Agent API Routes (uses API key authentication)
|
|
380
391
|
app.use('/api/agent', agentRoutes);
|
|
381
392
|
|
|
@@ -385,17 +396,17 @@ app.use(express.static(path.join(__dirname, '../public')));
|
|
|
385
396
|
// Static files served after API routes
|
|
386
397
|
// Add cache control: HTML files should not be cached, but assets can be cached
|
|
387
398
|
app.use(express.static(path.join(__dirname, '../dist'), {
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
399
|
+
setHeaders: (res, filePath) => {
|
|
400
|
+
if (filePath.endsWith('.html')) {
|
|
401
|
+
// Prevent HTML caching to avoid service worker issues after builds
|
|
402
|
+
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
403
|
+
res.setHeader('Pragma', 'no-cache');
|
|
404
|
+
res.setHeader('Expires', '0');
|
|
405
|
+
} else if (filePath.match(/\.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico)$/)) {
|
|
406
|
+
// Cache static assets for 1 year (they have hashed names)
|
|
407
|
+
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
|
408
|
+
}
|
|
397
409
|
}
|
|
398
|
-
}
|
|
399
410
|
}));
|
|
400
411
|
|
|
401
412
|
// API Routes (protected)
|
|
@@ -410,11 +421,13 @@ app.post('/api/system/update', authenticateToken, async (req, res) => {
|
|
|
410
421
|
|
|
411
422
|
console.log('Starting system update from directory:', projectRoot);
|
|
412
423
|
|
|
413
|
-
// Run the update command
|
|
414
|
-
const updateCommand =
|
|
424
|
+
// Run the update command based on install mode
|
|
425
|
+
const updateCommand = installMode === 'git'
|
|
426
|
+
? 'git checkout main && git pull && npm install'
|
|
427
|
+
: 'npm install -g @siteboon/claude-code-ui@latest';
|
|
415
428
|
|
|
416
429
|
const child = spawn('sh', ['-c', updateCommand], {
|
|
417
|
-
cwd: projectRoot,
|
|
430
|
+
cwd: installMode === 'git' ? projectRoot : os.homedir(),
|
|
418
431
|
env: process.env
|
|
419
432
|
});
|
|
420
433
|
|
|
@@ -491,13 +504,13 @@ app.get('/api/projects/:projectName/sessions/:sessionId/messages', authenticateT
|
|
|
491
504
|
try {
|
|
492
505
|
const { projectName, sessionId } = req.params;
|
|
493
506
|
const { limit, offset } = req.query;
|
|
494
|
-
|
|
507
|
+
|
|
495
508
|
// Parse limit and offset if provided
|
|
496
509
|
const parsedLimit = limit ? parseInt(limit, 10) : null;
|
|
497
510
|
const parsedOffset = offset ? parseInt(offset, 10) : 0;
|
|
498
|
-
|
|
511
|
+
|
|
499
512
|
const result = await getSessionMessages(projectName, sessionId, parsedLimit, parsedOffset);
|
|
500
|
-
|
|
513
|
+
|
|
501
514
|
// Handle both old and new response formats
|
|
502
515
|
if (Array.isArray(result)) {
|
|
503
516
|
// Backward compatibility: no pagination parameters were provided
|
|
@@ -580,13 +593,13 @@ const expandWorkspacePath = (inputPath) => {
|
|
|
580
593
|
app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
|
581
594
|
try {
|
|
582
595
|
const { path: dirPath } = req.query;
|
|
583
|
-
|
|
596
|
+
|
|
584
597
|
console.log('[API] Browse filesystem request for path:', dirPath);
|
|
585
598
|
console.log('[API] WORKSPACES_ROOT is:', WORKSPACES_ROOT);
|
|
586
599
|
// Default to home directory if no path provided
|
|
587
600
|
const defaultRoot = WORKSPACES_ROOT;
|
|
588
601
|
let targetPath = dirPath ? expandWorkspacePath(dirPath) : defaultRoot;
|
|
589
|
-
|
|
602
|
+
|
|
590
603
|
// Resolve and normalize the path
|
|
591
604
|
targetPath = path.resolve(targetPath);
|
|
592
605
|
|
|
@@ -596,22 +609,22 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
|
|
596
609
|
return res.status(403).json({ error: validation.error });
|
|
597
610
|
}
|
|
598
611
|
const resolvedPath = validation.resolvedPath || targetPath;
|
|
599
|
-
|
|
612
|
+
|
|
600
613
|
// Security check - ensure path is accessible
|
|
601
614
|
try {
|
|
602
615
|
await fs.promises.access(resolvedPath);
|
|
603
616
|
const stats = await fs.promises.stat(resolvedPath);
|
|
604
|
-
|
|
617
|
+
|
|
605
618
|
if (!stats.isDirectory()) {
|
|
606
619
|
return res.status(400).json({ error: 'Path is not a directory' });
|
|
607
620
|
}
|
|
608
621
|
} catch (err) {
|
|
609
622
|
return res.status(404).json({ error: 'Directory not accessible' });
|
|
610
623
|
}
|
|
611
|
-
|
|
624
|
+
|
|
612
625
|
// Use existing getFileTree function with shallow depth (only direct children)
|
|
613
626
|
const fileTree = await getFileTree(resolvedPath, 1, 0, false); // maxDepth=1, showHidden=false
|
|
614
|
-
|
|
627
|
+
|
|
615
628
|
// Filter only directories and format for suggestions
|
|
616
629
|
const directories = fileTree
|
|
617
630
|
.filter(item => item.type === 'directory')
|
|
@@ -627,7 +640,7 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
|
|
627
640
|
if (!aHidden && bHidden) return -1;
|
|
628
641
|
return a.name.localeCompare(b.name);
|
|
629
642
|
});
|
|
630
|
-
|
|
643
|
+
|
|
631
644
|
// Add common directories if browsing home directory
|
|
632
645
|
const suggestions = [];
|
|
633
646
|
let resolvedWorkspaceRoot = defaultRoot;
|
|
@@ -640,17 +653,17 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
|
|
640
653
|
const commonDirs = ['Desktop', 'Documents', 'Projects', 'Development', 'Dev', 'Code', 'workspace'];
|
|
641
654
|
const existingCommon = directories.filter(dir => commonDirs.includes(dir.name));
|
|
642
655
|
const otherDirs = directories.filter(dir => !commonDirs.includes(dir.name));
|
|
643
|
-
|
|
656
|
+
|
|
644
657
|
suggestions.push(...existingCommon, ...otherDirs);
|
|
645
658
|
} else {
|
|
646
659
|
suggestions.push(...directories);
|
|
647
660
|
}
|
|
648
|
-
|
|
661
|
+
|
|
649
662
|
res.json({
|
|
650
663
|
path: resolvedPath,
|
|
651
664
|
suggestions: suggestions
|
|
652
665
|
});
|
|
653
|
-
|
|
666
|
+
|
|
654
667
|
} catch (error) {
|
|
655
668
|
console.error('Error browsing filesystem:', error);
|
|
656
669
|
res.status(500).json({ error: 'Failed to browse filesystem' });
|
|
@@ -894,26 +907,26 @@ wss.on('connection', (ws, request) => {
|
|
|
894
907
|
* WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface
|
|
895
908
|
*/
|
|
896
909
|
class WebSocketWriter {
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
send(data) {
|
|
904
|
-
if (this.ws.readyState === 1) { // WebSocket.OPEN
|
|
905
|
-
// Providers send raw objects, we stringify for WebSocket
|
|
906
|
-
this.ws.send(JSON.stringify(data));
|
|
910
|
+
constructor(ws) {
|
|
911
|
+
this.ws = ws;
|
|
912
|
+
this.sessionId = null;
|
|
913
|
+
this.isWebSocketWriter = true; // Marker for transport detection
|
|
907
914
|
}
|
|
908
|
-
}
|
|
909
915
|
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
916
|
+
send(data) {
|
|
917
|
+
if (this.ws.readyState === 1) { // WebSocket.OPEN
|
|
918
|
+
// Providers send raw objects, we stringify for WebSocket
|
|
919
|
+
this.ws.send(JSON.stringify(data));
|
|
920
|
+
}
|
|
921
|
+
}
|
|
913
922
|
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
923
|
+
setSessionId(sessionId) {
|
|
924
|
+
this.sessionId = sessionId;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
getSessionId() {
|
|
928
|
+
return this.sessionId;
|
|
929
|
+
}
|
|
917
930
|
}
|
|
918
931
|
|
|
919
932
|
// Handle chat WebSocket connections
|
|
@@ -949,6 +962,12 @@ function handleChatConnection(ws) {
|
|
|
949
962
|
console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
|
|
950
963
|
console.log('🤖 Model:', data.options?.model || 'default');
|
|
951
964
|
await queryCodex(data.command, data.options, writer);
|
|
965
|
+
} else if (data.type === 'gemini-command') {
|
|
966
|
+
console.log('[DEBUG] Gemini message:', data.command || '[Continue/Resume]');
|
|
967
|
+
console.log('📁 Project:', data.options?.projectPath || data.options?.cwd || 'Unknown');
|
|
968
|
+
console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
|
|
969
|
+
console.log('🤖 Model:', data.options?.model || 'default');
|
|
970
|
+
await spawnGemini(data.command, data.options, writer);
|
|
952
971
|
} else if (data.type === 'cursor-resume') {
|
|
953
972
|
// Backward compatibility: treat as cursor-command with resume and no prompt
|
|
954
973
|
console.log('[DEBUG] Cursor resume session (compat):', data.sessionId);
|
|
@@ -966,6 +985,8 @@ function handleChatConnection(ws) {
|
|
|
966
985
|
success = abortCursorSession(data.sessionId);
|
|
967
986
|
} else if (provider === 'codex') {
|
|
968
987
|
success = abortCodexSession(data.sessionId);
|
|
988
|
+
} else if (provider === 'gemini') {
|
|
989
|
+
success = abortGeminiSession(data.sessionId);
|
|
969
990
|
} else {
|
|
970
991
|
// Use Claude Agents SDK
|
|
971
992
|
success = await abortClaudeSDKSession(data.sessionId);
|
|
@@ -1008,6 +1029,8 @@ function handleChatConnection(ws) {
|
|
|
1008
1029
|
isActive = isCursorSessionActive(sessionId);
|
|
1009
1030
|
} else if (provider === 'codex') {
|
|
1010
1031
|
isActive = isCodexSessionActive(sessionId);
|
|
1032
|
+
} else if (provider === 'gemini') {
|
|
1033
|
+
isActive = isGeminiSessionActive(sessionId);
|
|
1011
1034
|
} else {
|
|
1012
1035
|
// Use Claude Agents SDK
|
|
1013
1036
|
isActive = isClaudeSDKSessionActive(sessionId);
|
|
@@ -1024,7 +1047,8 @@ function handleChatConnection(ws) {
|
|
|
1024
1047
|
const activeSessions = {
|
|
1025
1048
|
claude: getActiveClaudeSDKSessions(),
|
|
1026
1049
|
cursor: getActiveCursorSessions(),
|
|
1027
|
-
codex: getActiveCodexSessions()
|
|
1050
|
+
codex: getActiveCodexSessions(),
|
|
1051
|
+
gemini: getActiveGeminiSessions()
|
|
1028
1052
|
};
|
|
1029
1053
|
writer.send({
|
|
1030
1054
|
type: 'active-sessions',
|
|
@@ -1133,7 +1157,7 @@ function handleShellConnection(ws) {
|
|
|
1133
1157
|
if (isPlainShell) {
|
|
1134
1158
|
welcomeMsg = `\x1b[36mStarting terminal in: ${projectPath}\x1b[0m\r\n`;
|
|
1135
1159
|
} else {
|
|
1136
|
-
const providerName = provider === 'cursor' ? 'Cursor' : 'Claude';
|
|
1160
|
+
const providerName = provider === 'cursor' ? 'Cursor' : (provider === 'codex' ? 'Codex' : (provider === 'gemini' ? 'Gemini' : 'Claude'));
|
|
1137
1161
|
welcomeMsg = hasSession ?
|
|
1138
1162
|
`\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n` :
|
|
1139
1163
|
`\x1b[36mStarting new ${providerName} session in: ${projectPath}\x1b[0m\r\n`;
|
|
@@ -1169,6 +1193,55 @@ function handleShellConnection(ws) {
|
|
|
1169
1193
|
shellCommand = `cd "${projectPath}" && cursor-agent`;
|
|
1170
1194
|
}
|
|
1171
1195
|
}
|
|
1196
|
+
|
|
1197
|
+
} else if (provider === 'codex') {
|
|
1198
|
+
// Use codex command
|
|
1199
|
+
if (os.platform() === 'win32') {
|
|
1200
|
+
if (hasSession && sessionId) {
|
|
1201
|
+
// Try to resume session, but with fallback to a new session if it fails
|
|
1202
|
+
shellCommand = `Set-Location -Path "${projectPath}"; codex resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { codex }`;
|
|
1203
|
+
} else {
|
|
1204
|
+
shellCommand = `Set-Location -Path "${projectPath}"; codex`;
|
|
1205
|
+
}
|
|
1206
|
+
} else {
|
|
1207
|
+
if (hasSession && sessionId) {
|
|
1208
|
+
// Try to resume session, but with fallback to a new session if it fails
|
|
1209
|
+
shellCommand = `cd "${projectPath}" && codex resume "${sessionId}" || codex`;
|
|
1210
|
+
} else {
|
|
1211
|
+
shellCommand = `cd "${projectPath}" && codex`;
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
} else if (provider === 'gemini') {
|
|
1215
|
+
// Use gemini command
|
|
1216
|
+
const command = initialCommand || 'gemini';
|
|
1217
|
+
let resumeId = sessionId;
|
|
1218
|
+
if (hasSession && sessionId) {
|
|
1219
|
+
try {
|
|
1220
|
+
// Gemini CLI enforces its own native session IDs, unlike other agents that accept arbitrary string names.
|
|
1221
|
+
// The UI only knows about its internal generated `sessionId` (e.g. gemini_1234).
|
|
1222
|
+
// We must fetch the mapping from the backend session manager to pass the native `cliSessionId` to the shell.
|
|
1223
|
+
const sess = sessionManager.getSession(sessionId);
|
|
1224
|
+
if (sess && sess.cliSessionId) {
|
|
1225
|
+
resumeId = sess.cliSessionId;
|
|
1226
|
+
}
|
|
1227
|
+
} catch (err) {
|
|
1228
|
+
console.error('Failed to get Gemini CLI session ID:', err);
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
if (os.platform() === 'win32') {
|
|
1233
|
+
if (hasSession && resumeId) {
|
|
1234
|
+
shellCommand = `Set-Location -Path "${projectPath}"; ${command} --resume "${resumeId}"`;
|
|
1235
|
+
} else {
|
|
1236
|
+
shellCommand = `Set-Location -Path "${projectPath}"; ${command}`;
|
|
1237
|
+
}
|
|
1238
|
+
} else {
|
|
1239
|
+
if (hasSession && resumeId) {
|
|
1240
|
+
shellCommand = `cd "${projectPath}" && ${command} --resume "${resumeId}"`;
|
|
1241
|
+
} else {
|
|
1242
|
+
shellCommand = `cd "${projectPath}" && ${command}`;
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1172
1245
|
} else {
|
|
1173
1246
|
// Use claude command (default) or initialCommand if provided
|
|
1174
1247
|
const command = initialCommand || 'claude';
|
|
@@ -1602,203 +1675,214 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, async (r
|
|
|
1602
1675
|
|
|
1603
1676
|
// Get token usage for a specific session
|
|
1604
1677
|
app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => {
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
// Allow only safe characters in sessionId
|
|
1611
|
-
const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
|
|
1612
|
-
if (!safeSessionId) {
|
|
1613
|
-
return res.status(400).json({ error: 'Invalid sessionId' });
|
|
1614
|
-
}
|
|
1678
|
+
try {
|
|
1679
|
+
const { projectName, sessionId } = req.params;
|
|
1680
|
+
const { provider = 'claude' } = req.query;
|
|
1681
|
+
const homeDir = os.homedir();
|
|
1615
1682
|
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
|
|
1622
|
-
unsupported: true,
|
|
1623
|
-
message: 'Token usage tracking not available for Cursor sessions'
|
|
1624
|
-
});
|
|
1625
|
-
}
|
|
1683
|
+
// Allow only safe characters in sessionId
|
|
1684
|
+
const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
|
|
1685
|
+
if (!safeSessionId) {
|
|
1686
|
+
return res.status(400).json({ error: 'Invalid sessionId' });
|
|
1687
|
+
}
|
|
1626
1688
|
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1689
|
+
// Handle Cursor sessions - they use SQLite and don't have token usage info
|
|
1690
|
+
if (provider === 'cursor') {
|
|
1691
|
+
return res.json({
|
|
1692
|
+
used: 0,
|
|
1693
|
+
total: 0,
|
|
1694
|
+
breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
|
|
1695
|
+
unsupported: true,
|
|
1696
|
+
message: 'Token usage tracking not available for Cursor sessions'
|
|
1697
|
+
});
|
|
1698
|
+
}
|
|
1630
1699
|
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
} else if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) {
|
|
1641
|
-
return fullPath;
|
|
1642
|
-
}
|
|
1643
|
-
}
|
|
1644
|
-
} catch (error) {
|
|
1645
|
-
// Skip directories we can't read
|
|
1700
|
+
// Handle Gemini sessions - they are raw logs in our current setup
|
|
1701
|
+
if (provider === 'gemini') {
|
|
1702
|
+
return res.json({
|
|
1703
|
+
used: 0,
|
|
1704
|
+
total: 0,
|
|
1705
|
+
breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
|
|
1706
|
+
unsupported: true,
|
|
1707
|
+
message: 'Token usage tracking not available for Gemini sessions'
|
|
1708
|
+
});
|
|
1646
1709
|
}
|
|
1647
|
-
return null;
|
|
1648
|
-
};
|
|
1649
1710
|
|
|
1650
|
-
|
|
1711
|
+
// Handle Codex sessions
|
|
1712
|
+
if (provider === 'codex') {
|
|
1713
|
+
const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
|
|
1651
1714
|
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1715
|
+
// Find the session file by searching for the session ID
|
|
1716
|
+
const findSessionFile = async (dir) => {
|
|
1717
|
+
try {
|
|
1718
|
+
const entries = await fsPromises.readdir(dir, { withFileTypes: true });
|
|
1719
|
+
for (const entry of entries) {
|
|
1720
|
+
const fullPath = path.join(dir, entry.name);
|
|
1721
|
+
if (entry.isDirectory()) {
|
|
1722
|
+
const found = await findSessionFile(fullPath);
|
|
1723
|
+
if (found) return found;
|
|
1724
|
+
} else if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) {
|
|
1725
|
+
return fullPath;
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
} catch (error) {
|
|
1729
|
+
// Skip directories we can't read
|
|
1730
|
+
}
|
|
1731
|
+
return null;
|
|
1732
|
+
};
|
|
1655
1733
|
|
|
1656
|
-
|
|
1657
|
-
let fileContent;
|
|
1658
|
-
try {
|
|
1659
|
-
fileContent = await fsPromises.readFile(sessionFilePath, 'utf8');
|
|
1660
|
-
} catch (error) {
|
|
1661
|
-
if (error.code === 'ENOENT') {
|
|
1662
|
-
return res.status(404).json({ error: 'Session file not found', path: sessionFilePath });
|
|
1663
|
-
}
|
|
1664
|
-
throw error;
|
|
1665
|
-
}
|
|
1666
|
-
const lines = fileContent.trim().split('\n');
|
|
1667
|
-
let totalTokens = 0;
|
|
1668
|
-
let contextWindow = 200000; // Default for Codex/OpenAI
|
|
1669
|
-
|
|
1670
|
-
// Find the latest token_count event with info (scan from end)
|
|
1671
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1672
|
-
try {
|
|
1673
|
-
const entry = JSON.parse(lines[i]);
|
|
1734
|
+
const sessionFilePath = await findSessionFile(codexSessionsDir);
|
|
1674
1735
|
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
const tokenInfo = entry.payload.info;
|
|
1678
|
-
if (tokenInfo.total_token_usage) {
|
|
1679
|
-
totalTokens = tokenInfo.total_token_usage.total_tokens || 0;
|
|
1736
|
+
if (!sessionFilePath) {
|
|
1737
|
+
return res.status(404).json({ error: 'Codex session file not found', sessionId: safeSessionId });
|
|
1680
1738
|
}
|
|
1681
|
-
|
|
1682
|
-
|
|
1739
|
+
|
|
1740
|
+
// Read and parse the Codex JSONL file
|
|
1741
|
+
let fileContent;
|
|
1742
|
+
try {
|
|
1743
|
+
fileContent = await fsPromises.readFile(sessionFilePath, 'utf8');
|
|
1744
|
+
} catch (error) {
|
|
1745
|
+
if (error.code === 'ENOENT') {
|
|
1746
|
+
return res.status(404).json({ error: 'Session file not found', path: sessionFilePath });
|
|
1747
|
+
}
|
|
1748
|
+
throw error;
|
|
1683
1749
|
}
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
// Skip lines that can't be parsed
|
|
1688
|
-
continue;
|
|
1689
|
-
}
|
|
1690
|
-
}
|
|
1750
|
+
const lines = fileContent.trim().split('\n');
|
|
1751
|
+
let totalTokens = 0;
|
|
1752
|
+
let contextWindow = 200000; // Default for Codex/OpenAI
|
|
1691
1753
|
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
}
|
|
1754
|
+
// Find the latest token_count event with info (scan from end)
|
|
1755
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1756
|
+
try {
|
|
1757
|
+
const entry = JSON.parse(lines[i]);
|
|
1697
1758
|
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1759
|
+
// Codex stores token info in event_msg with type: "token_count"
|
|
1760
|
+
if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
|
|
1761
|
+
const tokenInfo = entry.payload.info;
|
|
1762
|
+
if (tokenInfo.total_token_usage) {
|
|
1763
|
+
totalTokens = tokenInfo.total_token_usage.total_tokens || 0;
|
|
1764
|
+
}
|
|
1765
|
+
if (tokenInfo.model_context_window) {
|
|
1766
|
+
contextWindow = tokenInfo.model_context_window;
|
|
1767
|
+
}
|
|
1768
|
+
break; // Stop after finding the latest token count
|
|
1769
|
+
}
|
|
1770
|
+
} catch (parseError) {
|
|
1771
|
+
// Skip lines that can't be parsed
|
|
1772
|
+
continue;
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1707
1775
|
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1776
|
+
return res.json({
|
|
1777
|
+
used: totalTokens,
|
|
1778
|
+
total: contextWindow
|
|
1779
|
+
});
|
|
1780
|
+
}
|
|
1713
1781
|
|
|
1714
|
-
|
|
1782
|
+
// Handle Claude sessions (default)
|
|
1783
|
+
// Extract actual project path
|
|
1784
|
+
let projectPath;
|
|
1785
|
+
try {
|
|
1786
|
+
projectPath = await extractProjectDirectory(projectName);
|
|
1787
|
+
} catch (error) {
|
|
1788
|
+
console.error('Error extracting project directory:', error);
|
|
1789
|
+
return res.status(500).json({ error: 'Failed to determine project path' });
|
|
1790
|
+
}
|
|
1715
1791
|
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1792
|
+
// Construct the JSONL file path
|
|
1793
|
+
// Claude stores session files in ~/.claude/projects/[encoded-project-path]/[session-id].jsonl
|
|
1794
|
+
// The encoding replaces /, spaces, ~, and _ with -
|
|
1795
|
+
const encodedPath = projectPath.replace(/[\\/:\s~_]/g, '-');
|
|
1796
|
+
const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
|
|
1721
1797
|
|
|
1722
|
-
|
|
1723
|
-
let fileContent;
|
|
1724
|
-
try {
|
|
1725
|
-
fileContent = await fsPromises.readFile(jsonlPath, 'utf8');
|
|
1726
|
-
} catch (error) {
|
|
1727
|
-
if (error.code === 'ENOENT') {
|
|
1728
|
-
return res.status(404).json({ error: 'Session file not found', path: jsonlPath });
|
|
1729
|
-
}
|
|
1730
|
-
throw error; // Re-throw other errors to be caught by outer try-catch
|
|
1731
|
-
}
|
|
1732
|
-
const lines = fileContent.trim().split('\n');
|
|
1798
|
+
const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
|
|
1733
1799
|
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1800
|
+
// Constrain to projectDir
|
|
1801
|
+
const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath));
|
|
1802
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
1803
|
+
return res.status(400).json({ error: 'Invalid path' });
|
|
1804
|
+
}
|
|
1739
1805
|
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1806
|
+
// Read and parse the JSONL file
|
|
1807
|
+
let fileContent;
|
|
1808
|
+
try {
|
|
1809
|
+
fileContent = await fsPromises.readFile(jsonlPath, 'utf8');
|
|
1810
|
+
} catch (error) {
|
|
1811
|
+
if (error.code === 'ENOENT') {
|
|
1812
|
+
return res.status(404).json({ error: 'Session file not found', path: jsonlPath });
|
|
1813
|
+
}
|
|
1814
|
+
throw error; // Re-throw other errors to be caught by outer try-catch
|
|
1815
|
+
}
|
|
1816
|
+
const lines = fileContent.trim().split('\n');
|
|
1817
|
+
|
|
1818
|
+
const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10);
|
|
1819
|
+
const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000;
|
|
1820
|
+
let inputTokens = 0;
|
|
1821
|
+
let cacheCreationTokens = 0;
|
|
1822
|
+
let cacheReadTokens = 0;
|
|
1823
|
+
|
|
1824
|
+
// Find the latest assistant message with usage data (scan from end)
|
|
1825
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1826
|
+
try {
|
|
1827
|
+
const entry = JSON.parse(lines[i]);
|
|
1744
1828
|
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1829
|
+
// Only count assistant messages which have usage data
|
|
1830
|
+
if (entry.type === 'assistant' && entry.message?.usage) {
|
|
1831
|
+
const usage = entry.message.usage;
|
|
1748
1832
|
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1833
|
+
// Use token counts from latest assistant message only
|
|
1834
|
+
inputTokens = usage.input_tokens || 0;
|
|
1835
|
+
cacheCreationTokens = usage.cache_creation_input_tokens || 0;
|
|
1836
|
+
cacheReadTokens = usage.cache_read_input_tokens || 0;
|
|
1753
1837
|
|
|
1754
|
-
|
|
1838
|
+
break; // Stop after finding the latest assistant message
|
|
1839
|
+
}
|
|
1840
|
+
} catch (parseError) {
|
|
1841
|
+
// Skip lines that can't be parsed
|
|
1842
|
+
continue;
|
|
1843
|
+
}
|
|
1755
1844
|
}
|
|
1756
|
-
} catch (parseError) {
|
|
1757
|
-
// Skip lines that can't be parsed
|
|
1758
|
-
continue;
|
|
1759
|
-
}
|
|
1760
|
-
}
|
|
1761
1845
|
|
|
1762
|
-
|
|
1763
|
-
|
|
1846
|
+
// Calculate total context usage (excluding output_tokens, as per ccusage)
|
|
1847
|
+
const totalUsed = inputTokens + cacheCreationTokens + cacheReadTokens;
|
|
1764
1848
|
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1849
|
+
res.json({
|
|
1850
|
+
used: totalUsed,
|
|
1851
|
+
total: contextWindow,
|
|
1852
|
+
breakdown: {
|
|
1853
|
+
input: inputTokens,
|
|
1854
|
+
cacheCreation: cacheCreationTokens,
|
|
1855
|
+
cacheRead: cacheReadTokens
|
|
1856
|
+
}
|
|
1857
|
+
});
|
|
1858
|
+
} catch (error) {
|
|
1859
|
+
console.error('Error reading session token usage:', error);
|
|
1860
|
+
res.status(500).json({ error: 'Failed to read session token usage' });
|
|
1861
|
+
}
|
|
1778
1862
|
});
|
|
1779
1863
|
|
|
1780
1864
|
// Serve React app for all other routes (excluding static files)
|
|
1781
1865
|
app.get('*', (req, res) => {
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1866
|
+
// Skip requests for static assets (files with extensions)
|
|
1867
|
+
if (path.extname(req.path)) {
|
|
1868
|
+
return res.status(404).send('Not found');
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
// Only serve index.html for HTML routes, not for static assets
|
|
1872
|
+
// Static assets should already be handled by express.static middleware above
|
|
1873
|
+
const indexPath = path.join(__dirname, '../dist/index.html');
|
|
1874
|
+
|
|
1875
|
+
// Check if dist/index.html exists (production build available)
|
|
1876
|
+
if (fs.existsSync(indexPath)) {
|
|
1877
|
+
// Set no-cache headers for HTML to prevent service worker issues
|
|
1878
|
+
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
1879
|
+
res.setHeader('Pragma', 'no-cache');
|
|
1880
|
+
res.setHeader('Expires', '0');
|
|
1881
|
+
res.sendFile(indexPath);
|
|
1882
|
+
} else {
|
|
1883
|
+
// In development, redirect to Vite dev server only if dist doesn't exist
|
|
1884
|
+
res.redirect(`http://localhost:${process.env.VITE_PORT || 5173}`);
|
|
1885
|
+
}
|
|
1802
1886
|
});
|
|
1803
1887
|
|
|
1804
1888
|
// Helper function to convert permissions to rwx format
|