@siteboon/claude-code-ui 1.20.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/gemini-cli.js +455 -0
- package/server/gemini-response-handler.js +140 -0
- package/server/index.js +304 -225
- package/server/projects.js +292 -275
- package/server/routes/agent.js +15 -4
- 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-BPHfv_yU.css +0 -32
- package/dist/assets/index-C88hdQje.js +0 -1413
- package/server/database/auth.db +0 -0
package/server/index.js
CHANGED
|
@@ -48,6 +48,8 @@ import { getProjects, getSessions, getSessionMessages, renameProject, deleteSess
|
|
|
48
48
|
import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval } from './claude-sdk.js';
|
|
49
49
|
import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
|
|
50
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';
|
|
51
53
|
import gitRoutes from './routes/git.js';
|
|
52
54
|
import authRoutes from './routes/auth.js';
|
|
53
55
|
import mcpRoutes from './routes/mcp.js';
|
|
@@ -61,6 +63,7 @@ import projectsRoutes, { WORKSPACES_ROOT, validateWorkspacePath } from './routes
|
|
|
61
63
|
import cliAuthRoutes from './routes/cli-auth.js';
|
|
62
64
|
import userRoutes from './routes/user.js';
|
|
63
65
|
import codexRoutes from './routes/codex.js';
|
|
66
|
+
import geminiRoutes from './routes/gemini.js';
|
|
64
67
|
import { initializeDatabase } from './database/db.js';
|
|
65
68
|
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
|
66
69
|
import { IS_PLATFORM } from './constants/config.js';
|
|
@@ -69,7 +72,9 @@ import { IS_PLATFORM } from './constants/config.js';
|
|
|
69
72
|
const PROVIDER_WATCH_PATHS = [
|
|
70
73
|
{ provider: 'claude', rootPath: path.join(os.homedir(), '.claude', 'projects') },
|
|
71
74
|
{ provider: 'cursor', rootPath: path.join(os.homedir(), '.cursor', 'chats') },
|
|
72
|
-
{ 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') }
|
|
73
78
|
];
|
|
74
79
|
const WATCHER_IGNORED_PATTERNS = [
|
|
75
80
|
'**/node_modules/**',
|
|
@@ -319,25 +324,25 @@ app.locals.wss = wss;
|
|
|
319
324
|
|
|
320
325
|
app.use(cors());
|
|
321
326
|
app.use(express.json({
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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');
|
|
328
335
|
}
|
|
329
|
-
return contentType.includes('json');
|
|
330
|
-
}
|
|
331
336
|
}));
|
|
332
337
|
app.use(express.urlencoded({ limit: '50mb', extended: true }));
|
|
333
338
|
|
|
334
339
|
// Public health check endpoint (no authentication required)
|
|
335
340
|
app.get('/health', (req, res) => {
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
+
res.json({
|
|
342
|
+
status: 'ok',
|
|
343
|
+
timestamp: new Date().toISOString(),
|
|
344
|
+
installMode
|
|
345
|
+
});
|
|
341
346
|
});
|
|
342
347
|
|
|
343
348
|
// Optional API key validation (if configured)
|
|
@@ -379,6 +384,9 @@ app.use('/api/user', authenticateToken, userRoutes);
|
|
|
379
384
|
// Codex API Routes (protected)
|
|
380
385
|
app.use('/api/codex', authenticateToken, codexRoutes);
|
|
381
386
|
|
|
387
|
+
// Gemini API Routes (protected)
|
|
388
|
+
app.use('/api/gemini', authenticateToken, geminiRoutes);
|
|
389
|
+
|
|
382
390
|
// Agent API Routes (uses API key authentication)
|
|
383
391
|
app.use('/api/agent', agentRoutes);
|
|
384
392
|
|
|
@@ -388,17 +396,17 @@ app.use(express.static(path.join(__dirname, '../public')));
|
|
|
388
396
|
// Static files served after API routes
|
|
389
397
|
// Add cache control: HTML files should not be cached, but assets can be cached
|
|
390
398
|
app.use(express.static(path.join(__dirname, '../dist'), {
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
+
}
|
|
400
409
|
}
|
|
401
|
-
}
|
|
402
410
|
}));
|
|
403
411
|
|
|
404
412
|
// API Routes (protected)
|
|
@@ -496,13 +504,13 @@ app.get('/api/projects/:projectName/sessions/:sessionId/messages', authenticateT
|
|
|
496
504
|
try {
|
|
497
505
|
const { projectName, sessionId } = req.params;
|
|
498
506
|
const { limit, offset } = req.query;
|
|
499
|
-
|
|
507
|
+
|
|
500
508
|
// Parse limit and offset if provided
|
|
501
509
|
const parsedLimit = limit ? parseInt(limit, 10) : null;
|
|
502
510
|
const parsedOffset = offset ? parseInt(offset, 10) : 0;
|
|
503
|
-
|
|
511
|
+
|
|
504
512
|
const result = await getSessionMessages(projectName, sessionId, parsedLimit, parsedOffset);
|
|
505
|
-
|
|
513
|
+
|
|
506
514
|
// Handle both old and new response formats
|
|
507
515
|
if (Array.isArray(result)) {
|
|
508
516
|
// Backward compatibility: no pagination parameters were provided
|
|
@@ -585,13 +593,13 @@ const expandWorkspacePath = (inputPath) => {
|
|
|
585
593
|
app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
|
586
594
|
try {
|
|
587
595
|
const { path: dirPath } = req.query;
|
|
588
|
-
|
|
596
|
+
|
|
589
597
|
console.log('[API] Browse filesystem request for path:', dirPath);
|
|
590
598
|
console.log('[API] WORKSPACES_ROOT is:', WORKSPACES_ROOT);
|
|
591
599
|
// Default to home directory if no path provided
|
|
592
600
|
const defaultRoot = WORKSPACES_ROOT;
|
|
593
601
|
let targetPath = dirPath ? expandWorkspacePath(dirPath) : defaultRoot;
|
|
594
|
-
|
|
602
|
+
|
|
595
603
|
// Resolve and normalize the path
|
|
596
604
|
targetPath = path.resolve(targetPath);
|
|
597
605
|
|
|
@@ -601,22 +609,22 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
|
|
601
609
|
return res.status(403).json({ error: validation.error });
|
|
602
610
|
}
|
|
603
611
|
const resolvedPath = validation.resolvedPath || targetPath;
|
|
604
|
-
|
|
612
|
+
|
|
605
613
|
// Security check - ensure path is accessible
|
|
606
614
|
try {
|
|
607
615
|
await fs.promises.access(resolvedPath);
|
|
608
616
|
const stats = await fs.promises.stat(resolvedPath);
|
|
609
|
-
|
|
617
|
+
|
|
610
618
|
if (!stats.isDirectory()) {
|
|
611
619
|
return res.status(400).json({ error: 'Path is not a directory' });
|
|
612
620
|
}
|
|
613
621
|
} catch (err) {
|
|
614
622
|
return res.status(404).json({ error: 'Directory not accessible' });
|
|
615
623
|
}
|
|
616
|
-
|
|
624
|
+
|
|
617
625
|
// Use existing getFileTree function with shallow depth (only direct children)
|
|
618
626
|
const fileTree = await getFileTree(resolvedPath, 1, 0, false); // maxDepth=1, showHidden=false
|
|
619
|
-
|
|
627
|
+
|
|
620
628
|
// Filter only directories and format for suggestions
|
|
621
629
|
const directories = fileTree
|
|
622
630
|
.filter(item => item.type === 'directory')
|
|
@@ -632,7 +640,7 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
|
|
632
640
|
if (!aHidden && bHidden) return -1;
|
|
633
641
|
return a.name.localeCompare(b.name);
|
|
634
642
|
});
|
|
635
|
-
|
|
643
|
+
|
|
636
644
|
// Add common directories if browsing home directory
|
|
637
645
|
const suggestions = [];
|
|
638
646
|
let resolvedWorkspaceRoot = defaultRoot;
|
|
@@ -645,17 +653,17 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
|
|
645
653
|
const commonDirs = ['Desktop', 'Documents', 'Projects', 'Development', 'Dev', 'Code', 'workspace'];
|
|
646
654
|
const existingCommon = directories.filter(dir => commonDirs.includes(dir.name));
|
|
647
655
|
const otherDirs = directories.filter(dir => !commonDirs.includes(dir.name));
|
|
648
|
-
|
|
656
|
+
|
|
649
657
|
suggestions.push(...existingCommon, ...otherDirs);
|
|
650
658
|
} else {
|
|
651
659
|
suggestions.push(...directories);
|
|
652
660
|
}
|
|
653
|
-
|
|
661
|
+
|
|
654
662
|
res.json({
|
|
655
663
|
path: resolvedPath,
|
|
656
664
|
suggestions: suggestions
|
|
657
665
|
});
|
|
658
|
-
|
|
666
|
+
|
|
659
667
|
} catch (error) {
|
|
660
668
|
console.error('Error browsing filesystem:', error);
|
|
661
669
|
res.status(500).json({ error: 'Failed to browse filesystem' });
|
|
@@ -899,26 +907,26 @@ wss.on('connection', (ws, request) => {
|
|
|
899
907
|
* WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface
|
|
900
908
|
*/
|
|
901
909
|
class WebSocketWriter {
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
send(data) {
|
|
909
|
-
if (this.ws.readyState === 1) { // WebSocket.OPEN
|
|
910
|
-
// Providers send raw objects, we stringify for WebSocket
|
|
911
|
-
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
|
|
912
914
|
}
|
|
913
|
-
}
|
|
914
915
|
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
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
|
+
}
|
|
922
|
+
|
|
923
|
+
setSessionId(sessionId) {
|
|
924
|
+
this.sessionId = sessionId;
|
|
925
|
+
}
|
|
918
926
|
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
927
|
+
getSessionId() {
|
|
928
|
+
return this.sessionId;
|
|
929
|
+
}
|
|
922
930
|
}
|
|
923
931
|
|
|
924
932
|
// Handle chat WebSocket connections
|
|
@@ -954,6 +962,12 @@ function handleChatConnection(ws) {
|
|
|
954
962
|
console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
|
|
955
963
|
console.log('🤖 Model:', data.options?.model || 'default');
|
|
956
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);
|
|
957
971
|
} else if (data.type === 'cursor-resume') {
|
|
958
972
|
// Backward compatibility: treat as cursor-command with resume and no prompt
|
|
959
973
|
console.log('[DEBUG] Cursor resume session (compat):', data.sessionId);
|
|
@@ -971,6 +985,8 @@ function handleChatConnection(ws) {
|
|
|
971
985
|
success = abortCursorSession(data.sessionId);
|
|
972
986
|
} else if (provider === 'codex') {
|
|
973
987
|
success = abortCodexSession(data.sessionId);
|
|
988
|
+
} else if (provider === 'gemini') {
|
|
989
|
+
success = abortGeminiSession(data.sessionId);
|
|
974
990
|
} else {
|
|
975
991
|
// Use Claude Agents SDK
|
|
976
992
|
success = await abortClaudeSDKSession(data.sessionId);
|
|
@@ -1013,6 +1029,8 @@ function handleChatConnection(ws) {
|
|
|
1013
1029
|
isActive = isCursorSessionActive(sessionId);
|
|
1014
1030
|
} else if (provider === 'codex') {
|
|
1015
1031
|
isActive = isCodexSessionActive(sessionId);
|
|
1032
|
+
} else if (provider === 'gemini') {
|
|
1033
|
+
isActive = isGeminiSessionActive(sessionId);
|
|
1016
1034
|
} else {
|
|
1017
1035
|
// Use Claude Agents SDK
|
|
1018
1036
|
isActive = isClaudeSDKSessionActive(sessionId);
|
|
@@ -1029,7 +1047,8 @@ function handleChatConnection(ws) {
|
|
|
1029
1047
|
const activeSessions = {
|
|
1030
1048
|
claude: getActiveClaudeSDKSessions(),
|
|
1031
1049
|
cursor: getActiveCursorSessions(),
|
|
1032
|
-
codex: getActiveCodexSessions()
|
|
1050
|
+
codex: getActiveCodexSessions(),
|
|
1051
|
+
gemini: getActiveGeminiSessions()
|
|
1033
1052
|
};
|
|
1034
1053
|
writer.send({
|
|
1035
1054
|
type: 'active-sessions',
|
|
@@ -1138,7 +1157,7 @@ function handleShellConnection(ws) {
|
|
|
1138
1157
|
if (isPlainShell) {
|
|
1139
1158
|
welcomeMsg = `\x1b[36mStarting terminal in: ${projectPath}\x1b[0m\r\n`;
|
|
1140
1159
|
} else {
|
|
1141
|
-
const providerName = provider === 'cursor' ? 'Cursor' : 'Claude';
|
|
1160
|
+
const providerName = provider === 'cursor' ? 'Cursor' : (provider === 'codex' ? 'Codex' : (provider === 'gemini' ? 'Gemini' : 'Claude'));
|
|
1142
1161
|
welcomeMsg = hasSession ?
|
|
1143
1162
|
`\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n` :
|
|
1144
1163
|
`\x1b[36mStarting new ${providerName} session in: ${projectPath}\x1b[0m\r\n`;
|
|
@@ -1174,6 +1193,55 @@ function handleShellConnection(ws) {
|
|
|
1174
1193
|
shellCommand = `cd "${projectPath}" && cursor-agent`;
|
|
1175
1194
|
}
|
|
1176
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
|
+
}
|
|
1177
1245
|
} else {
|
|
1178
1246
|
// Use claude command (default) or initialCommand if provided
|
|
1179
1247
|
const command = initialCommand || 'claude';
|
|
@@ -1607,203 +1675,214 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, async (r
|
|
|
1607
1675
|
|
|
1608
1676
|
// Get token usage for a specific session
|
|
1609
1677
|
app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => {
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
// Allow only safe characters in sessionId
|
|
1616
|
-
const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
|
|
1617
|
-
if (!safeSessionId) {
|
|
1618
|
-
return res.status(400).json({ error: 'Invalid sessionId' });
|
|
1619
|
-
}
|
|
1678
|
+
try {
|
|
1679
|
+
const { projectName, sessionId } = req.params;
|
|
1680
|
+
const { provider = 'claude' } = req.query;
|
|
1681
|
+
const homeDir = os.homedir();
|
|
1620
1682
|
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
|
|
1627
|
-
unsupported: true,
|
|
1628
|
-
message: 'Token usage tracking not available for Cursor sessions'
|
|
1629
|
-
});
|
|
1630
|
-
}
|
|
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
|
+
}
|
|
1631
1688
|
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
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
|
+
}
|
|
1635
1699
|
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
} else if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) {
|
|
1646
|
-
return fullPath;
|
|
1647
|
-
}
|
|
1648
|
-
}
|
|
1649
|
-
} catch (error) {
|
|
1650
|
-
// 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
|
+
});
|
|
1651
1709
|
}
|
|
1652
|
-
return null;
|
|
1653
|
-
};
|
|
1654
1710
|
|
|
1655
|
-
|
|
1711
|
+
// Handle Codex sessions
|
|
1712
|
+
if (provider === 'codex') {
|
|
1713
|
+
const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
|
|
1656
1714
|
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
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
|
+
};
|
|
1660
1733
|
|
|
1661
|
-
|
|
1662
|
-
let fileContent;
|
|
1663
|
-
try {
|
|
1664
|
-
fileContent = await fsPromises.readFile(sessionFilePath, 'utf8');
|
|
1665
|
-
} catch (error) {
|
|
1666
|
-
if (error.code === 'ENOENT') {
|
|
1667
|
-
return res.status(404).json({ error: 'Session file not found', path: sessionFilePath });
|
|
1668
|
-
}
|
|
1669
|
-
throw error;
|
|
1670
|
-
}
|
|
1671
|
-
const lines = fileContent.trim().split('\n');
|
|
1672
|
-
let totalTokens = 0;
|
|
1673
|
-
let contextWindow = 200000; // Default for Codex/OpenAI
|
|
1674
|
-
|
|
1675
|
-
// Find the latest token_count event with info (scan from end)
|
|
1676
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1677
|
-
try {
|
|
1678
|
-
const entry = JSON.parse(lines[i]);
|
|
1734
|
+
const sessionFilePath = await findSessionFile(codexSessionsDir);
|
|
1679
1735
|
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
const tokenInfo = entry.payload.info;
|
|
1683
|
-
if (tokenInfo.total_token_usage) {
|
|
1684
|
-
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 });
|
|
1685
1738
|
}
|
|
1686
|
-
|
|
1687
|
-
|
|
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;
|
|
1688
1749
|
}
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
// Skip lines that can't be parsed
|
|
1693
|
-
continue;
|
|
1694
|
-
}
|
|
1695
|
-
}
|
|
1750
|
+
const lines = fileContent.trim().split('\n');
|
|
1751
|
+
let totalTokens = 0;
|
|
1752
|
+
let contextWindow = 200000; // Default for Codex/OpenAI
|
|
1696
1753
|
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
}
|
|
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]);
|
|
1702
1758
|
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
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
|
+
}
|
|
1775
|
+
|
|
1776
|
+
return res.json({
|
|
1777
|
+
used: totalTokens,
|
|
1778
|
+
total: contextWindow
|
|
1779
|
+
});
|
|
1780
|
+
}
|
|
1712
1781
|
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
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
|
+
}
|
|
1718
1791
|
|
|
1719
|
-
|
|
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);
|
|
1720
1797
|
|
|
1721
|
-
|
|
1722
|
-
const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath));
|
|
1723
|
-
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
1724
|
-
return res.status(400).json({ error: 'Invalid path' });
|
|
1725
|
-
}
|
|
1798
|
+
const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
|
|
1726
1799
|
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
if (error.code === 'ENOENT') {
|
|
1733
|
-
return res.status(404).json({ error: 'Session file not found', path: jsonlPath });
|
|
1734
|
-
}
|
|
1735
|
-
throw error; // Re-throw other errors to be caught by outer try-catch
|
|
1736
|
-
}
|
|
1737
|
-
const lines = fileContent.trim().split('\n');
|
|
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
|
+
}
|
|
1738
1805
|
|
|
1739
|
-
|
|
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;
|
|
1744
1823
|
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
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]);
|
|
1749
1828
|
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1829
|
+
// Only count assistant messages which have usage data
|
|
1830
|
+
if (entry.type === 'assistant' && entry.message?.usage) {
|
|
1831
|
+
const usage = entry.message.usage;
|
|
1753
1832
|
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
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;
|
|
1758
1837
|
|
|
1759
|
-
|
|
1838
|
+
break; // Stop after finding the latest assistant message
|
|
1839
|
+
}
|
|
1840
|
+
} catch (parseError) {
|
|
1841
|
+
// Skip lines that can't be parsed
|
|
1842
|
+
continue;
|
|
1843
|
+
}
|
|
1760
1844
|
}
|
|
1761
|
-
} catch (parseError) {
|
|
1762
|
-
// Skip lines that can't be parsed
|
|
1763
|
-
continue;
|
|
1764
|
-
}
|
|
1765
|
-
}
|
|
1766
1845
|
|
|
1767
|
-
|
|
1768
|
-
|
|
1846
|
+
// Calculate total context usage (excluding output_tokens, as per ccusage)
|
|
1847
|
+
const totalUsed = inputTokens + cacheCreationTokens + cacheReadTokens;
|
|
1769
1848
|
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
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
|
+
}
|
|
1783
1862
|
});
|
|
1784
1863
|
|
|
1785
1864
|
// Serve React app for all other routes (excluding static files)
|
|
1786
1865
|
app.get('*', (req, res) => {
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
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
|
+
}
|
|
1807
1886
|
});
|
|
1808
1887
|
|
|
1809
1888
|
// Helper function to convert permissions to rwx format
|