@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/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
- limit: '50mb',
321
- type: (req) => {
322
- // Skip multipart/form-data requests (for file uploads like images)
323
- const contentType = req.headers['content-type'] || '';
324
- if (contentType.includes('multipart/form-data')) {
325
- return false;
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
- res.json({
335
- status: 'ok',
336
- timestamp: new Date().toISOString()
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
- setHeaders: (res, filePath) => {
389
- if (filePath.endsWith('.html')) {
390
- // Prevent HTML caching to avoid service worker issues after builds
391
- res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
392
- res.setHeader('Pragma', 'no-cache');
393
- res.setHeader('Expires', '0');
394
- } else if (filePath.match(/\.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico)$/)) {
395
- // Cache static assets for 1 year (they have hashed names)
396
- res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
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 = 'git checkout main && git pull && npm install';
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
- constructor(ws) {
898
- this.ws = ws;
899
- this.sessionId = null;
900
- this.isWebSocketWriter = true; // Marker for transport detection
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
- setSessionId(sessionId) {
911
- this.sessionId = sessionId;
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
- getSessionId() {
915
- return this.sessionId;
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
- try {
1606
- const { projectName, sessionId } = req.params;
1607
- const { provider = 'claude' } = req.query;
1608
- const homeDir = os.homedir();
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
- // Handle Cursor sessions - they use SQLite and don't have token usage info
1617
- if (provider === 'cursor') {
1618
- return res.json({
1619
- used: 0,
1620
- total: 0,
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
- // Handle Codex sessions
1628
- if (provider === 'codex') {
1629
- const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
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
- // Find the session file by searching for the session ID
1632
- const findSessionFile = async (dir) => {
1633
- try {
1634
- const entries = await fsPromises.readdir(dir, { withFileTypes: true });
1635
- for (const entry of entries) {
1636
- const fullPath = path.join(dir, entry.name);
1637
- if (entry.isDirectory()) {
1638
- const found = await findSessionFile(fullPath);
1639
- if (found) return found;
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
- const sessionFilePath = await findSessionFile(codexSessionsDir);
1711
+ // Handle Codex sessions
1712
+ if (provider === 'codex') {
1713
+ const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
1651
1714
 
1652
- if (!sessionFilePath) {
1653
- return res.status(404).json({ error: 'Codex session file not found', sessionId: safeSessionId });
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
- // Read and parse the Codex JSONL file
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
- // Codex stores token info in event_msg with type: "token_count"
1676
- if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
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
- if (tokenInfo.model_context_window) {
1682
- contextWindow = tokenInfo.model_context_window;
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
- break; // Stop after finding the latest token count
1685
- }
1686
- } catch (parseError) {
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
- return res.json({
1693
- used: totalTokens,
1694
- total: contextWindow
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
- // Handle Claude sessions (default)
1699
- // Extract actual project path
1700
- let projectPath;
1701
- try {
1702
- projectPath = await extractProjectDirectory(projectName);
1703
- } catch (error) {
1704
- console.error('Error extracting project directory:', error);
1705
- return res.status(500).json({ error: 'Failed to determine project path' });
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
- // Construct the JSONL file path
1709
- // Claude stores session files in ~/.claude/projects/[encoded-project-path]/[session-id].jsonl
1710
- // The encoding replaces /, spaces, ~, and _ with -
1711
- const encodedPath = projectPath.replace(/[\\/:\s~_]/g, '-');
1712
- const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
1776
+ return res.json({
1777
+ used: totalTokens,
1778
+ total: contextWindow
1779
+ });
1780
+ }
1713
1781
 
1714
- const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
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
- // Constrain to projectDir
1717
- const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath));
1718
- if (rel.startsWith('..') || path.isAbsolute(rel)) {
1719
- return res.status(400).json({ error: 'Invalid path' });
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
- // Read and parse the JSONL file
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
- const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10);
1735
- const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000;
1736
- let inputTokens = 0;
1737
- let cacheCreationTokens = 0;
1738
- let cacheReadTokens = 0;
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
- // Find the latest assistant message with usage data (scan from end)
1741
- for (let i = lines.length - 1; i >= 0; i--) {
1742
- try {
1743
- const entry = JSON.parse(lines[i]);
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
- // Only count assistant messages which have usage data
1746
- if (entry.type === 'assistant' && entry.message?.usage) {
1747
- const usage = entry.message.usage;
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
- // Use token counts from latest assistant message only
1750
- inputTokens = usage.input_tokens || 0;
1751
- cacheCreationTokens = usage.cache_creation_input_tokens || 0;
1752
- cacheReadTokens = usage.cache_read_input_tokens || 0;
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
- break; // Stop after finding the latest assistant message
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
- // Calculate total context usage (excluding output_tokens, as per ccusage)
1763
- const totalUsed = inputTokens + cacheCreationTokens + cacheReadTokens;
1846
+ // Calculate total context usage (excluding output_tokens, as per ccusage)
1847
+ const totalUsed = inputTokens + cacheCreationTokens + cacheReadTokens;
1764
1848
 
1765
- res.json({
1766
- used: totalUsed,
1767
- total: contextWindow,
1768
- breakdown: {
1769
- input: inputTokens,
1770
- cacheCreation: cacheCreationTokens,
1771
- cacheRead: cacheReadTokens
1772
- }
1773
- });
1774
- } catch (error) {
1775
- console.error('Error reading session token usage:', error);
1776
- res.status(500).json({ error: 'Failed to read session token usage' });
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
- // Skip requests for static assets (files with extensions)
1783
- if (path.extname(req.path)) {
1784
- return res.status(404).send('Not found');
1785
- }
1786
-
1787
- // Only serve index.html for HTML routes, not for static assets
1788
- // Static assets should already be handled by express.static middleware above
1789
- const indexPath = path.join(__dirname, '../dist/index.html');
1790
-
1791
- // Check if dist/index.html exists (production build available)
1792
- if (fs.existsSync(indexPath)) {
1793
- // Set no-cache headers for HTML to prevent service worker issues
1794
- res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
1795
- res.setHeader('Pragma', 'no-cache');
1796
- res.setHeader('Expires', '0');
1797
- res.sendFile(indexPath);
1798
- } else {
1799
- // In development, redirect to Vite dev server only if dist doesn't exist
1800
- res.redirect(`http://localhost:${process.env.VITE_PORT || 5173}`);
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