@siteboon/claude-code-ui 1.20.1 → 1.22.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 +34 -13
- package/dist/assets/index-B6iL1dXV.css +32 -0
- package/dist/assets/index-Br2fwqOq.js +1397 -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 +5 -3
- package/server/claude-sdk.js +4 -0
- package/server/gemini-cli.js +455 -0
- package/server/gemini-response-handler.js +140 -0
- package/server/index.js +738 -229
- 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' });
|
|
@@ -876,6 +884,436 @@ app.get('/api/projects/:projectName/files', authenticateToken, async (req, res)
|
|
|
876
884
|
}
|
|
877
885
|
});
|
|
878
886
|
|
|
887
|
+
// ============================================================================
|
|
888
|
+
// FILE OPERATIONS API ENDPOINTS
|
|
889
|
+
// ============================================================================
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Validate that a path is within the project root
|
|
893
|
+
* @param {string} projectRoot - The project root path
|
|
894
|
+
* @param {string} targetPath - The path to validate
|
|
895
|
+
* @returns {{ valid: boolean, resolved?: string, error?: string }}
|
|
896
|
+
*/
|
|
897
|
+
function validatePathInProject(projectRoot, targetPath) {
|
|
898
|
+
const resolved = path.isAbsolute(targetPath)
|
|
899
|
+
? path.resolve(targetPath)
|
|
900
|
+
: path.resolve(projectRoot, targetPath);
|
|
901
|
+
const normalizedRoot = path.resolve(projectRoot) + path.sep;
|
|
902
|
+
if (!resolved.startsWith(normalizedRoot)) {
|
|
903
|
+
return { valid: false, error: 'Path must be under project root' };
|
|
904
|
+
}
|
|
905
|
+
return { valid: true, resolved };
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* Validate filename - check for invalid characters
|
|
910
|
+
* @param {string} name - The filename to validate
|
|
911
|
+
* @returns {{ valid: boolean, error?: string }}
|
|
912
|
+
*/
|
|
913
|
+
function validateFilename(name) {
|
|
914
|
+
if (!name || !name.trim()) {
|
|
915
|
+
return { valid: false, error: 'Filename cannot be empty' };
|
|
916
|
+
}
|
|
917
|
+
// Check for invalid characters (Windows + Unix)
|
|
918
|
+
const invalidChars = /[<>:"/\\|?*\x00-\x1f]/;
|
|
919
|
+
if (invalidChars.test(name)) {
|
|
920
|
+
return { valid: false, error: 'Filename contains invalid characters' };
|
|
921
|
+
}
|
|
922
|
+
// Check for reserved names (Windows)
|
|
923
|
+
const reserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i;
|
|
924
|
+
if (reserved.test(name)) {
|
|
925
|
+
return { valid: false, error: 'Filename is a reserved name' };
|
|
926
|
+
}
|
|
927
|
+
// Check for dots only
|
|
928
|
+
if (/^\.+$/.test(name)) {
|
|
929
|
+
return { valid: false, error: 'Filename cannot be only dots' };
|
|
930
|
+
}
|
|
931
|
+
return { valid: true };
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// POST /api/projects/:projectName/files/create - Create new file or directory
|
|
935
|
+
app.post('/api/projects/:projectName/files/create', authenticateToken, async (req, res) => {
|
|
936
|
+
try {
|
|
937
|
+
const { projectName } = req.params;
|
|
938
|
+
const { path: parentPath, type, name } = req.body;
|
|
939
|
+
|
|
940
|
+
// Validate input
|
|
941
|
+
if (!name || !type) {
|
|
942
|
+
return res.status(400).json({ error: 'Name and type are required' });
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
if (!['file', 'directory'].includes(type)) {
|
|
946
|
+
return res.status(400).json({ error: 'Type must be "file" or "directory"' });
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
const nameValidation = validateFilename(name);
|
|
950
|
+
if (!nameValidation.valid) {
|
|
951
|
+
return res.status(400).json({ error: nameValidation.error });
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// Get project root
|
|
955
|
+
const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
|
|
956
|
+
if (!projectRoot) {
|
|
957
|
+
return res.status(404).json({ error: 'Project not found' });
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// Build and validate target path
|
|
961
|
+
const targetDir = parentPath || '';
|
|
962
|
+
const targetPath = targetDir ? path.join(targetDir, name) : name;
|
|
963
|
+
const validation = validatePathInProject(projectRoot, targetPath);
|
|
964
|
+
if (!validation.valid) {
|
|
965
|
+
return res.status(403).json({ error: validation.error });
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
const resolvedPath = validation.resolved;
|
|
969
|
+
|
|
970
|
+
// Check if already exists
|
|
971
|
+
try {
|
|
972
|
+
await fsPromises.access(resolvedPath);
|
|
973
|
+
return res.status(409).json({ error: `${type === 'file' ? 'File' : 'Directory'} already exists` });
|
|
974
|
+
} catch {
|
|
975
|
+
// Doesn't exist, which is what we want
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// Create file or directory
|
|
979
|
+
if (type === 'directory') {
|
|
980
|
+
await fsPromises.mkdir(resolvedPath, { recursive: false });
|
|
981
|
+
} else {
|
|
982
|
+
// Ensure parent directory exists
|
|
983
|
+
const parentDir = path.dirname(resolvedPath);
|
|
984
|
+
try {
|
|
985
|
+
await fsPromises.access(parentDir);
|
|
986
|
+
} catch {
|
|
987
|
+
await fsPromises.mkdir(parentDir, { recursive: true });
|
|
988
|
+
}
|
|
989
|
+
await fsPromises.writeFile(resolvedPath, '', 'utf8');
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
res.json({
|
|
993
|
+
success: true,
|
|
994
|
+
path: resolvedPath,
|
|
995
|
+
name,
|
|
996
|
+
type,
|
|
997
|
+
message: `${type === 'file' ? 'File' : 'Directory'} created successfully`
|
|
998
|
+
});
|
|
999
|
+
} catch (error) {
|
|
1000
|
+
console.error('Error creating file/directory:', error);
|
|
1001
|
+
if (error.code === 'EACCES') {
|
|
1002
|
+
res.status(403).json({ error: 'Permission denied' });
|
|
1003
|
+
} else if (error.code === 'ENOENT') {
|
|
1004
|
+
res.status(404).json({ error: 'Parent directory not found' });
|
|
1005
|
+
} else {
|
|
1006
|
+
res.status(500).json({ error: error.message });
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
// PUT /api/projects/:projectName/files/rename - Rename file or directory
|
|
1012
|
+
app.put('/api/projects/:projectName/files/rename', authenticateToken, async (req, res) => {
|
|
1013
|
+
try {
|
|
1014
|
+
const { projectName } = req.params;
|
|
1015
|
+
const { oldPath, newName } = req.body;
|
|
1016
|
+
|
|
1017
|
+
// Validate input
|
|
1018
|
+
if (!oldPath || !newName) {
|
|
1019
|
+
return res.status(400).json({ error: 'oldPath and newName are required' });
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
const nameValidation = validateFilename(newName);
|
|
1023
|
+
if (!nameValidation.valid) {
|
|
1024
|
+
return res.status(400).json({ error: nameValidation.error });
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// Get project root
|
|
1028
|
+
const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
|
|
1029
|
+
if (!projectRoot) {
|
|
1030
|
+
return res.status(404).json({ error: 'Project not found' });
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// Validate old path
|
|
1034
|
+
const oldValidation = validatePathInProject(projectRoot, oldPath);
|
|
1035
|
+
if (!oldValidation.valid) {
|
|
1036
|
+
return res.status(403).json({ error: oldValidation.error });
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
const resolvedOldPath = oldValidation.resolved;
|
|
1040
|
+
|
|
1041
|
+
// Check if old path exists
|
|
1042
|
+
try {
|
|
1043
|
+
await fsPromises.access(resolvedOldPath);
|
|
1044
|
+
} catch {
|
|
1045
|
+
return res.status(404).json({ error: 'File or directory not found' });
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// Build and validate new path
|
|
1049
|
+
const parentDir = path.dirname(resolvedOldPath);
|
|
1050
|
+
const resolvedNewPath = path.join(parentDir, newName);
|
|
1051
|
+
const newValidation = validatePathInProject(projectRoot, resolvedNewPath);
|
|
1052
|
+
if (!newValidation.valid) {
|
|
1053
|
+
return res.status(403).json({ error: newValidation.error });
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// Check if new path already exists
|
|
1057
|
+
try {
|
|
1058
|
+
await fsPromises.access(resolvedNewPath);
|
|
1059
|
+
return res.status(409).json({ error: 'A file or directory with this name already exists' });
|
|
1060
|
+
} catch {
|
|
1061
|
+
// Doesn't exist, which is what we want
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// Rename
|
|
1065
|
+
await fsPromises.rename(resolvedOldPath, resolvedNewPath);
|
|
1066
|
+
|
|
1067
|
+
res.json({
|
|
1068
|
+
success: true,
|
|
1069
|
+
oldPath: resolvedOldPath,
|
|
1070
|
+
newPath: resolvedNewPath,
|
|
1071
|
+
newName,
|
|
1072
|
+
message: 'Renamed successfully'
|
|
1073
|
+
});
|
|
1074
|
+
} catch (error) {
|
|
1075
|
+
console.error('Error renaming file/directory:', error);
|
|
1076
|
+
if (error.code === 'EACCES') {
|
|
1077
|
+
res.status(403).json({ error: 'Permission denied' });
|
|
1078
|
+
} else if (error.code === 'ENOENT') {
|
|
1079
|
+
res.status(404).json({ error: 'File or directory not found' });
|
|
1080
|
+
} else if (error.code === 'EXDEV') {
|
|
1081
|
+
res.status(400).json({ error: 'Cannot move across different filesystems' });
|
|
1082
|
+
} else {
|
|
1083
|
+
res.status(500).json({ error: error.message });
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
// DELETE /api/projects/:projectName/files - Delete file or directory
|
|
1089
|
+
app.delete('/api/projects/:projectName/files', authenticateToken, async (req, res) => {
|
|
1090
|
+
try {
|
|
1091
|
+
const { projectName } = req.params;
|
|
1092
|
+
const { path: targetPath, type } = req.body;
|
|
1093
|
+
|
|
1094
|
+
// Validate input
|
|
1095
|
+
if (!targetPath) {
|
|
1096
|
+
return res.status(400).json({ error: 'Path is required' });
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// Get project root
|
|
1100
|
+
const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
|
|
1101
|
+
if (!projectRoot) {
|
|
1102
|
+
return res.status(404).json({ error: 'Project not found' });
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// Validate path
|
|
1106
|
+
const validation = validatePathInProject(projectRoot, targetPath);
|
|
1107
|
+
if (!validation.valid) {
|
|
1108
|
+
return res.status(403).json({ error: validation.error });
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
const resolvedPath = validation.resolved;
|
|
1112
|
+
|
|
1113
|
+
// Check if path exists and get stats
|
|
1114
|
+
let stats;
|
|
1115
|
+
try {
|
|
1116
|
+
stats = await fsPromises.stat(resolvedPath);
|
|
1117
|
+
} catch {
|
|
1118
|
+
return res.status(404).json({ error: 'File or directory not found' });
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// Prevent deleting the project root itself
|
|
1122
|
+
if (resolvedPath === path.resolve(projectRoot)) {
|
|
1123
|
+
return res.status(403).json({ error: 'Cannot delete project root directory' });
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// Delete based on type
|
|
1127
|
+
if (stats.isDirectory()) {
|
|
1128
|
+
await fsPromises.rm(resolvedPath, { recursive: true, force: true });
|
|
1129
|
+
} else {
|
|
1130
|
+
await fsPromises.unlink(resolvedPath);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
res.json({
|
|
1134
|
+
success: true,
|
|
1135
|
+
path: resolvedPath,
|
|
1136
|
+
type: stats.isDirectory() ? 'directory' : 'file',
|
|
1137
|
+
message: 'Deleted successfully'
|
|
1138
|
+
});
|
|
1139
|
+
} catch (error) {
|
|
1140
|
+
console.error('Error deleting file/directory:', error);
|
|
1141
|
+
if (error.code === 'EACCES') {
|
|
1142
|
+
res.status(403).json({ error: 'Permission denied' });
|
|
1143
|
+
} else if (error.code === 'ENOENT') {
|
|
1144
|
+
res.status(404).json({ error: 'File or directory not found' });
|
|
1145
|
+
} else if (error.code === 'ENOTEMPTY') {
|
|
1146
|
+
res.status(400).json({ error: 'Directory is not empty' });
|
|
1147
|
+
} else {
|
|
1148
|
+
res.status(500).json({ error: error.message });
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
// POST /api/projects/:projectName/files/upload - Upload files
|
|
1154
|
+
// Dynamic import of multer for file uploads
|
|
1155
|
+
const uploadFilesHandler = async (req, res) => {
|
|
1156
|
+
// Dynamic import of multer
|
|
1157
|
+
const multer = (await import('multer')).default;
|
|
1158
|
+
|
|
1159
|
+
const uploadMiddleware = multer({
|
|
1160
|
+
storage: multer.diskStorage({
|
|
1161
|
+
destination: (req, file, cb) => {
|
|
1162
|
+
cb(null, os.tmpdir());
|
|
1163
|
+
},
|
|
1164
|
+
filename: (req, file, cb) => {
|
|
1165
|
+
// Use a unique temp name, but preserve original name in file.originalname
|
|
1166
|
+
// Note: file.originalname may contain path separators for folder uploads
|
|
1167
|
+
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
|
1168
|
+
// For temp file, just use a safe unique name without the path
|
|
1169
|
+
cb(null, `upload-${uniqueSuffix}`);
|
|
1170
|
+
}
|
|
1171
|
+
}),
|
|
1172
|
+
limits: {
|
|
1173
|
+
fileSize: 50 * 1024 * 1024, // 50MB limit
|
|
1174
|
+
files: 20 // Max 20 files at once
|
|
1175
|
+
}
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
// Use multer middleware
|
|
1179
|
+
uploadMiddleware.array('files', 20)(req, res, async (err) => {
|
|
1180
|
+
if (err) {
|
|
1181
|
+
console.error('Multer error:', err);
|
|
1182
|
+
if (err.code === 'LIMIT_FILE_SIZE') {
|
|
1183
|
+
return res.status(400).json({ error: 'File too large. Maximum size is 50MB.' });
|
|
1184
|
+
}
|
|
1185
|
+
if (err.code === 'LIMIT_FILE_COUNT') {
|
|
1186
|
+
return res.status(400).json({ error: 'Too many files. Maximum is 20 files.' });
|
|
1187
|
+
}
|
|
1188
|
+
return res.status(500).json({ error: err.message });
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
try {
|
|
1192
|
+
const { projectName } = req.params;
|
|
1193
|
+
const { targetPath, relativePaths } = req.body;
|
|
1194
|
+
|
|
1195
|
+
// Parse relative paths if provided (for folder uploads)
|
|
1196
|
+
let filePaths = [];
|
|
1197
|
+
if (relativePaths) {
|
|
1198
|
+
try {
|
|
1199
|
+
filePaths = JSON.parse(relativePaths);
|
|
1200
|
+
} catch (e) {
|
|
1201
|
+
console.log('[DEBUG] Failed to parse relativePaths:', relativePaths);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
console.log('[DEBUG] File upload request:', {
|
|
1206
|
+
projectName,
|
|
1207
|
+
targetPath: JSON.stringify(targetPath),
|
|
1208
|
+
targetPathType: typeof targetPath,
|
|
1209
|
+
filesCount: req.files?.length,
|
|
1210
|
+
relativePaths: filePaths
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
if (!req.files || req.files.length === 0) {
|
|
1214
|
+
return res.status(400).json({ error: 'No files provided' });
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// Get project root
|
|
1218
|
+
const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
|
|
1219
|
+
if (!projectRoot) {
|
|
1220
|
+
return res.status(404).json({ error: 'Project not found' });
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
console.log('[DEBUG] Project root:', projectRoot);
|
|
1224
|
+
|
|
1225
|
+
// Validate and resolve target path
|
|
1226
|
+
// If targetPath is empty or '.', use project root directly
|
|
1227
|
+
const targetDir = targetPath || '';
|
|
1228
|
+
let resolvedTargetDir;
|
|
1229
|
+
|
|
1230
|
+
console.log('[DEBUG] Target dir:', JSON.stringify(targetDir));
|
|
1231
|
+
|
|
1232
|
+
if (!targetDir || targetDir === '.' || targetDir === './') {
|
|
1233
|
+
// Empty path means upload to project root
|
|
1234
|
+
resolvedTargetDir = path.resolve(projectRoot);
|
|
1235
|
+
console.log('[DEBUG] Using project root as target:', resolvedTargetDir);
|
|
1236
|
+
} else {
|
|
1237
|
+
const validation = validatePathInProject(projectRoot, targetDir);
|
|
1238
|
+
if (!validation.valid) {
|
|
1239
|
+
console.log('[DEBUG] Path validation failed:', validation.error);
|
|
1240
|
+
return res.status(403).json({ error: validation.error });
|
|
1241
|
+
}
|
|
1242
|
+
resolvedTargetDir = validation.resolved;
|
|
1243
|
+
console.log('[DEBUG] Resolved target dir:', resolvedTargetDir);
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
// Ensure target directory exists
|
|
1247
|
+
try {
|
|
1248
|
+
await fsPromises.access(resolvedTargetDir);
|
|
1249
|
+
} catch {
|
|
1250
|
+
await fsPromises.mkdir(resolvedTargetDir, { recursive: true });
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
// Move uploaded files from temp to target directory
|
|
1254
|
+
const uploadedFiles = [];
|
|
1255
|
+
console.log('[DEBUG] Processing files:', req.files.map(f => ({ originalname: f.originalname, path: f.path })));
|
|
1256
|
+
for (let i = 0; i < req.files.length; i++) {
|
|
1257
|
+
const file = req.files[i];
|
|
1258
|
+
// Use relative path if provided (for folder uploads), otherwise use originalname
|
|
1259
|
+
const fileName = (filePaths && filePaths[i]) ? filePaths[i] : file.originalname;
|
|
1260
|
+
console.log('[DEBUG] Processing file:', fileName, '(originalname:', file.originalname + ')');
|
|
1261
|
+
const destPath = path.join(resolvedTargetDir, fileName);
|
|
1262
|
+
|
|
1263
|
+
// Validate destination path
|
|
1264
|
+
const destValidation = validatePathInProject(projectRoot, destPath);
|
|
1265
|
+
if (!destValidation.valid) {
|
|
1266
|
+
console.log('[DEBUG] Destination validation failed for:', destPath);
|
|
1267
|
+
// Clean up temp file
|
|
1268
|
+
await fsPromises.unlink(file.path).catch(() => {});
|
|
1269
|
+
continue;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
// Ensure parent directory exists (for nested files from folder upload)
|
|
1273
|
+
const parentDir = path.dirname(destPath);
|
|
1274
|
+
try {
|
|
1275
|
+
await fsPromises.access(parentDir);
|
|
1276
|
+
} catch {
|
|
1277
|
+
await fsPromises.mkdir(parentDir, { recursive: true });
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
// Move file (copy + unlink to handle cross-device scenarios)
|
|
1281
|
+
await fsPromises.copyFile(file.path, destPath);
|
|
1282
|
+
await fsPromises.unlink(file.path);
|
|
1283
|
+
|
|
1284
|
+
uploadedFiles.push({
|
|
1285
|
+
name: fileName,
|
|
1286
|
+
path: destPath,
|
|
1287
|
+
size: file.size,
|
|
1288
|
+
mimeType: file.mimetype
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
res.json({
|
|
1293
|
+
success: true,
|
|
1294
|
+
files: uploadedFiles,
|
|
1295
|
+
targetPath: resolvedTargetDir,
|
|
1296
|
+
message: `Uploaded ${uploadedFiles.length} file(s) successfully`
|
|
1297
|
+
});
|
|
1298
|
+
} catch (error) {
|
|
1299
|
+
console.error('Error uploading files:', error);
|
|
1300
|
+
// Clean up any remaining temp files
|
|
1301
|
+
if (req.files) {
|
|
1302
|
+
for (const file of req.files) {
|
|
1303
|
+
await fsPromises.unlink(file.path).catch(() => {});
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
if (error.code === 'EACCES') {
|
|
1307
|
+
res.status(403).json({ error: 'Permission denied' });
|
|
1308
|
+
} else {
|
|
1309
|
+
res.status(500).json({ error: error.message });
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
});
|
|
1313
|
+
};
|
|
1314
|
+
|
|
1315
|
+
app.post('/api/projects/:projectName/files/upload', authenticateToken, uploadFilesHandler);
|
|
1316
|
+
|
|
879
1317
|
// WebSocket connection handler that routes based on URL path
|
|
880
1318
|
wss.on('connection', (ws, request) => {
|
|
881
1319
|
const url = request.url;
|
|
@@ -899,26 +1337,26 @@ wss.on('connection', (ws, request) => {
|
|
|
899
1337
|
* WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface
|
|
900
1338
|
*/
|
|
901
1339
|
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));
|
|
1340
|
+
constructor(ws) {
|
|
1341
|
+
this.ws = ws;
|
|
1342
|
+
this.sessionId = null;
|
|
1343
|
+
this.isWebSocketWriter = true; // Marker for transport detection
|
|
912
1344
|
}
|
|
913
|
-
}
|
|
914
1345
|
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
1346
|
+
send(data) {
|
|
1347
|
+
if (this.ws.readyState === 1) { // WebSocket.OPEN
|
|
1348
|
+
// Providers send raw objects, we stringify for WebSocket
|
|
1349
|
+
this.ws.send(JSON.stringify(data));
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
918
1352
|
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
1353
|
+
setSessionId(sessionId) {
|
|
1354
|
+
this.sessionId = sessionId;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
getSessionId() {
|
|
1358
|
+
return this.sessionId;
|
|
1359
|
+
}
|
|
922
1360
|
}
|
|
923
1361
|
|
|
924
1362
|
// Handle chat WebSocket connections
|
|
@@ -954,6 +1392,12 @@ function handleChatConnection(ws) {
|
|
|
954
1392
|
console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
|
|
955
1393
|
console.log('🤖 Model:', data.options?.model || 'default');
|
|
956
1394
|
await queryCodex(data.command, data.options, writer);
|
|
1395
|
+
} else if (data.type === 'gemini-command') {
|
|
1396
|
+
console.log('[DEBUG] Gemini message:', data.command || '[Continue/Resume]');
|
|
1397
|
+
console.log('📁 Project:', data.options?.projectPath || data.options?.cwd || 'Unknown');
|
|
1398
|
+
console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
|
|
1399
|
+
console.log('🤖 Model:', data.options?.model || 'default');
|
|
1400
|
+
await spawnGemini(data.command, data.options, writer);
|
|
957
1401
|
} else if (data.type === 'cursor-resume') {
|
|
958
1402
|
// Backward compatibility: treat as cursor-command with resume and no prompt
|
|
959
1403
|
console.log('[DEBUG] Cursor resume session (compat):', data.sessionId);
|
|
@@ -971,6 +1415,8 @@ function handleChatConnection(ws) {
|
|
|
971
1415
|
success = abortCursorSession(data.sessionId);
|
|
972
1416
|
} else if (provider === 'codex') {
|
|
973
1417
|
success = abortCodexSession(data.sessionId);
|
|
1418
|
+
} else if (provider === 'gemini') {
|
|
1419
|
+
success = abortGeminiSession(data.sessionId);
|
|
974
1420
|
} else {
|
|
975
1421
|
// Use Claude Agents SDK
|
|
976
1422
|
success = await abortClaudeSDKSession(data.sessionId);
|
|
@@ -1013,6 +1459,8 @@ function handleChatConnection(ws) {
|
|
|
1013
1459
|
isActive = isCursorSessionActive(sessionId);
|
|
1014
1460
|
} else if (provider === 'codex') {
|
|
1015
1461
|
isActive = isCodexSessionActive(sessionId);
|
|
1462
|
+
} else if (provider === 'gemini') {
|
|
1463
|
+
isActive = isGeminiSessionActive(sessionId);
|
|
1016
1464
|
} else {
|
|
1017
1465
|
// Use Claude Agents SDK
|
|
1018
1466
|
isActive = isClaudeSDKSessionActive(sessionId);
|
|
@@ -1029,7 +1477,8 @@ function handleChatConnection(ws) {
|
|
|
1029
1477
|
const activeSessions = {
|
|
1030
1478
|
claude: getActiveClaudeSDKSessions(),
|
|
1031
1479
|
cursor: getActiveCursorSessions(),
|
|
1032
|
-
codex: getActiveCodexSessions()
|
|
1480
|
+
codex: getActiveCodexSessions(),
|
|
1481
|
+
gemini: getActiveGeminiSessions()
|
|
1033
1482
|
};
|
|
1034
1483
|
writer.send({
|
|
1035
1484
|
type: 'active-sessions',
|
|
@@ -1138,7 +1587,7 @@ function handleShellConnection(ws) {
|
|
|
1138
1587
|
if (isPlainShell) {
|
|
1139
1588
|
welcomeMsg = `\x1b[36mStarting terminal in: ${projectPath}\x1b[0m\r\n`;
|
|
1140
1589
|
} else {
|
|
1141
|
-
const providerName = provider === 'cursor' ? 'Cursor' : 'Claude';
|
|
1590
|
+
const providerName = provider === 'cursor' ? 'Cursor' : (provider === 'codex' ? 'Codex' : (provider === 'gemini' ? 'Gemini' : 'Claude'));
|
|
1142
1591
|
welcomeMsg = hasSession ?
|
|
1143
1592
|
`\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n` :
|
|
1144
1593
|
`\x1b[36mStarting new ${providerName} session in: ${projectPath}\x1b[0m\r\n`;
|
|
@@ -1174,6 +1623,55 @@ function handleShellConnection(ws) {
|
|
|
1174
1623
|
shellCommand = `cd "${projectPath}" && cursor-agent`;
|
|
1175
1624
|
}
|
|
1176
1625
|
}
|
|
1626
|
+
|
|
1627
|
+
} else if (provider === 'codex') {
|
|
1628
|
+
// Use codex command
|
|
1629
|
+
if (os.platform() === 'win32') {
|
|
1630
|
+
if (hasSession && sessionId) {
|
|
1631
|
+
// Try to resume session, but with fallback to a new session if it fails
|
|
1632
|
+
shellCommand = `Set-Location -Path "${projectPath}"; codex resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { codex }`;
|
|
1633
|
+
} else {
|
|
1634
|
+
shellCommand = `Set-Location -Path "${projectPath}"; codex`;
|
|
1635
|
+
}
|
|
1636
|
+
} else {
|
|
1637
|
+
if (hasSession && sessionId) {
|
|
1638
|
+
// Try to resume session, but with fallback to a new session if it fails
|
|
1639
|
+
shellCommand = `cd "${projectPath}" && codex resume "${sessionId}" || codex`;
|
|
1640
|
+
} else {
|
|
1641
|
+
shellCommand = `cd "${projectPath}" && codex`;
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
} else if (provider === 'gemini') {
|
|
1645
|
+
// Use gemini command
|
|
1646
|
+
const command = initialCommand || 'gemini';
|
|
1647
|
+
let resumeId = sessionId;
|
|
1648
|
+
if (hasSession && sessionId) {
|
|
1649
|
+
try {
|
|
1650
|
+
// Gemini CLI enforces its own native session IDs, unlike other agents that accept arbitrary string names.
|
|
1651
|
+
// The UI only knows about its internal generated `sessionId` (e.g. gemini_1234).
|
|
1652
|
+
// We must fetch the mapping from the backend session manager to pass the native `cliSessionId` to the shell.
|
|
1653
|
+
const sess = sessionManager.getSession(sessionId);
|
|
1654
|
+
if (sess && sess.cliSessionId) {
|
|
1655
|
+
resumeId = sess.cliSessionId;
|
|
1656
|
+
}
|
|
1657
|
+
} catch (err) {
|
|
1658
|
+
console.error('Failed to get Gemini CLI session ID:', err);
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
if (os.platform() === 'win32') {
|
|
1663
|
+
if (hasSession && resumeId) {
|
|
1664
|
+
shellCommand = `Set-Location -Path "${projectPath}"; ${command} --resume "${resumeId}"`;
|
|
1665
|
+
} else {
|
|
1666
|
+
shellCommand = `Set-Location -Path "${projectPath}"; ${command}`;
|
|
1667
|
+
}
|
|
1668
|
+
} else {
|
|
1669
|
+
if (hasSession && resumeId) {
|
|
1670
|
+
shellCommand = `cd "${projectPath}" && ${command} --resume "${resumeId}"`;
|
|
1671
|
+
} else {
|
|
1672
|
+
shellCommand = `cd "${projectPath}" && ${command}`;
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1177
1675
|
} else {
|
|
1178
1676
|
// Use claude command (default) or initialCommand if provided
|
|
1179
1677
|
const command = initialCommand || 'claude';
|
|
@@ -1607,203 +2105,214 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, async (r
|
|
|
1607
2105
|
|
|
1608
2106
|
// Get token usage for a specific session
|
|
1609
2107
|
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
|
-
}
|
|
2108
|
+
try {
|
|
2109
|
+
const { projectName, sessionId } = req.params;
|
|
2110
|
+
const { provider = 'claude' } = req.query;
|
|
2111
|
+
const homeDir = os.homedir();
|
|
1620
2112
|
|
|
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
|
-
}
|
|
2113
|
+
// Allow only safe characters in sessionId
|
|
2114
|
+
const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
|
|
2115
|
+
if (!safeSessionId) {
|
|
2116
|
+
return res.status(400).json({ error: 'Invalid sessionId' });
|
|
2117
|
+
}
|
|
1631
2118
|
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
2119
|
+
// Handle Cursor sessions - they use SQLite and don't have token usage info
|
|
2120
|
+
if (provider === 'cursor') {
|
|
2121
|
+
return res.json({
|
|
2122
|
+
used: 0,
|
|
2123
|
+
total: 0,
|
|
2124
|
+
breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
|
|
2125
|
+
unsupported: true,
|
|
2126
|
+
message: 'Token usage tracking not available for Cursor sessions'
|
|
2127
|
+
});
|
|
2128
|
+
}
|
|
1635
2129
|
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
2130
|
+
// Handle Gemini sessions - they are raw logs in our current setup
|
|
2131
|
+
if (provider === 'gemini') {
|
|
2132
|
+
return res.json({
|
|
2133
|
+
used: 0,
|
|
2134
|
+
total: 0,
|
|
2135
|
+
breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
|
|
2136
|
+
unsupported: true,
|
|
2137
|
+
message: 'Token usage tracking not available for Gemini sessions'
|
|
2138
|
+
});
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
// Handle Codex sessions
|
|
2142
|
+
if (provider === 'codex') {
|
|
2143
|
+
const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
|
|
2144
|
+
|
|
2145
|
+
// Find the session file by searching for the session ID
|
|
2146
|
+
const findSessionFile = async (dir) => {
|
|
2147
|
+
try {
|
|
2148
|
+
const entries = await fsPromises.readdir(dir, { withFileTypes: true });
|
|
2149
|
+
for (const entry of entries) {
|
|
2150
|
+
const fullPath = path.join(dir, entry.name);
|
|
2151
|
+
if (entry.isDirectory()) {
|
|
2152
|
+
const found = await findSessionFile(fullPath);
|
|
2153
|
+
if (found) return found;
|
|
2154
|
+
} else if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) {
|
|
2155
|
+
return fullPath;
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
} catch (error) {
|
|
2159
|
+
// Skip directories we can't read
|
|
2160
|
+
}
|
|
2161
|
+
return null;
|
|
2162
|
+
};
|
|
2163
|
+
|
|
2164
|
+
const sessionFilePath = await findSessionFile(codexSessionsDir);
|
|
2165
|
+
|
|
2166
|
+
if (!sessionFilePath) {
|
|
2167
|
+
return res.status(404).json({ error: 'Codex session file not found', sessionId: safeSessionId });
|
|
1647
2168
|
}
|
|
1648
|
-
|
|
2169
|
+
|
|
2170
|
+
// Read and parse the Codex JSONL file
|
|
2171
|
+
let fileContent;
|
|
2172
|
+
try {
|
|
2173
|
+
fileContent = await fsPromises.readFile(sessionFilePath, 'utf8');
|
|
2174
|
+
} catch (error) {
|
|
2175
|
+
if (error.code === 'ENOENT') {
|
|
2176
|
+
return res.status(404).json({ error: 'Session file not found', path: sessionFilePath });
|
|
2177
|
+
}
|
|
2178
|
+
throw error;
|
|
2179
|
+
}
|
|
2180
|
+
const lines = fileContent.trim().split('\n');
|
|
2181
|
+
let totalTokens = 0;
|
|
2182
|
+
let contextWindow = 200000; // Default for Codex/OpenAI
|
|
2183
|
+
|
|
2184
|
+
// Find the latest token_count event with info (scan from end)
|
|
2185
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
2186
|
+
try {
|
|
2187
|
+
const entry = JSON.parse(lines[i]);
|
|
2188
|
+
|
|
2189
|
+
// Codex stores token info in event_msg with type: "token_count"
|
|
2190
|
+
if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
|
|
2191
|
+
const tokenInfo = entry.payload.info;
|
|
2192
|
+
if (tokenInfo.total_token_usage) {
|
|
2193
|
+
totalTokens = tokenInfo.total_token_usage.total_tokens || 0;
|
|
2194
|
+
}
|
|
2195
|
+
if (tokenInfo.model_context_window) {
|
|
2196
|
+
contextWindow = tokenInfo.model_context_window;
|
|
2197
|
+
}
|
|
2198
|
+
break; // Stop after finding the latest token count
|
|
2199
|
+
}
|
|
2200
|
+
} catch (parseError) {
|
|
2201
|
+
// Skip lines that can't be parsed
|
|
2202
|
+
continue;
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
return res.json({
|
|
2207
|
+
used: totalTokens,
|
|
2208
|
+
total: contextWindow
|
|
2209
|
+
});
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
// Handle Claude sessions (default)
|
|
2213
|
+
// Extract actual project path
|
|
2214
|
+
let projectPath;
|
|
2215
|
+
try {
|
|
2216
|
+
projectPath = await extractProjectDirectory(projectName);
|
|
1649
2217
|
} catch (error) {
|
|
1650
|
-
|
|
2218
|
+
console.error('Error extracting project directory:', error);
|
|
2219
|
+
return res.status(500).json({ error: 'Failed to determine project path' });
|
|
1651
2220
|
}
|
|
1652
|
-
return null;
|
|
1653
|
-
};
|
|
1654
2221
|
|
|
1655
|
-
|
|
2222
|
+
// Construct the JSONL file path
|
|
2223
|
+
// Claude stores session files in ~/.claude/projects/[encoded-project-path]/[session-id].jsonl
|
|
2224
|
+
// The encoding replaces any non-alphanumeric character (except -) with -
|
|
2225
|
+
const encodedPath = projectPath.replace(/[^a-zA-Z0-9-]/g, '-');
|
|
2226
|
+
const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
|
|
1656
2227
|
|
|
1657
|
-
|
|
1658
|
-
return res.status(404).json({ error: 'Codex session file not found', sessionId: safeSessionId });
|
|
1659
|
-
}
|
|
2228
|
+
const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
|
|
1660
2229
|
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
} catch (error) {
|
|
1666
|
-
if (error.code === 'ENOENT') {
|
|
1667
|
-
return res.status(404).json({ error: 'Session file not found', path: sessionFilePath });
|
|
2230
|
+
// Constrain to projectDir
|
|
2231
|
+
const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath));
|
|
2232
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
2233
|
+
return res.status(400).json({ error: 'Invalid path' });
|
|
1668
2234
|
}
|
|
1669
|
-
throw error;
|
|
1670
|
-
}
|
|
1671
|
-
const lines = fileContent.trim().split('\n');
|
|
1672
|
-
let totalTokens = 0;
|
|
1673
|
-
let contextWindow = 200000; // Default for Codex/OpenAI
|
|
1674
2235
|
|
|
1675
|
-
|
|
1676
|
-
|
|
2236
|
+
// Read and parse the JSONL file
|
|
2237
|
+
let fileContent;
|
|
1677
2238
|
try {
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
const tokenInfo = entry.payload.info;
|
|
1683
|
-
if (tokenInfo.total_token_usage) {
|
|
1684
|
-
totalTokens = tokenInfo.total_token_usage.total_tokens || 0;
|
|
1685
|
-
}
|
|
1686
|
-
if (tokenInfo.model_context_window) {
|
|
1687
|
-
contextWindow = tokenInfo.model_context_window;
|
|
2239
|
+
fileContent = await fsPromises.readFile(jsonlPath, 'utf8');
|
|
2240
|
+
} catch (error) {
|
|
2241
|
+
if (error.code === 'ENOENT') {
|
|
2242
|
+
return res.status(404).json({ error: 'Session file not found', path: jsonlPath });
|
|
1688
2243
|
}
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
// Skip lines that can't be parsed
|
|
1693
|
-
continue;
|
|
1694
|
-
}
|
|
1695
|
-
}
|
|
1696
|
-
|
|
1697
|
-
return res.json({
|
|
1698
|
-
used: totalTokens,
|
|
1699
|
-
total: contextWindow
|
|
1700
|
-
});
|
|
1701
|
-
}
|
|
2244
|
+
throw error; // Re-throw other errors to be caught by outer try-catch
|
|
2245
|
+
}
|
|
2246
|
+
const lines = fileContent.trim().split('\n');
|
|
1702
2247
|
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
} catch (error) {
|
|
1709
|
-
console.error('Error extracting project directory:', error);
|
|
1710
|
-
return res.status(500).json({ error: 'Failed to determine project path' });
|
|
1711
|
-
}
|
|
2248
|
+
const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10);
|
|
2249
|
+
const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000;
|
|
2250
|
+
let inputTokens = 0;
|
|
2251
|
+
let cacheCreationTokens = 0;
|
|
2252
|
+
let cacheReadTokens = 0;
|
|
1712
2253
|
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
|
|
2254
|
+
// Find the latest assistant message with usage data (scan from end)
|
|
2255
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
2256
|
+
try {
|
|
2257
|
+
const entry = JSON.parse(lines[i]);
|
|
1718
2258
|
|
|
1719
|
-
|
|
2259
|
+
// Only count assistant messages which have usage data
|
|
2260
|
+
if (entry.type === 'assistant' && entry.message?.usage) {
|
|
2261
|
+
const usage = entry.message.usage;
|
|
1720
2262
|
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
}
|
|
2263
|
+
// Use token counts from latest assistant message only
|
|
2264
|
+
inputTokens = usage.input_tokens || 0;
|
|
2265
|
+
cacheCreationTokens = usage.cache_creation_input_tokens || 0;
|
|
2266
|
+
cacheReadTokens = usage.cache_read_input_tokens || 0;
|
|
1726
2267
|
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
}
|
|
1735
|
-
throw error; // Re-throw other errors to be caught by outer try-catch
|
|
1736
|
-
}
|
|
1737
|
-
const lines = fileContent.trim().split('\n');
|
|
1738
|
-
|
|
1739
|
-
const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10);
|
|
1740
|
-
const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000;
|
|
1741
|
-
let inputTokens = 0;
|
|
1742
|
-
let cacheCreationTokens = 0;
|
|
1743
|
-
let cacheReadTokens = 0;
|
|
1744
|
-
|
|
1745
|
-
// Find the latest assistant message with usage data (scan from end)
|
|
1746
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1747
|
-
try {
|
|
1748
|
-
const entry = JSON.parse(lines[i]);
|
|
1749
|
-
|
|
1750
|
-
// Only count assistant messages which have usage data
|
|
1751
|
-
if (entry.type === 'assistant' && entry.message?.usage) {
|
|
1752
|
-
const usage = entry.message.usage;
|
|
1753
|
-
|
|
1754
|
-
// Use token counts from latest assistant message only
|
|
1755
|
-
inputTokens = usage.input_tokens || 0;
|
|
1756
|
-
cacheCreationTokens = usage.cache_creation_input_tokens || 0;
|
|
1757
|
-
cacheReadTokens = usage.cache_read_input_tokens || 0;
|
|
1758
|
-
|
|
1759
|
-
break; // Stop after finding the latest assistant message
|
|
1760
|
-
}
|
|
1761
|
-
} catch (parseError) {
|
|
1762
|
-
// Skip lines that can't be parsed
|
|
1763
|
-
continue;
|
|
1764
|
-
}
|
|
1765
|
-
}
|
|
2268
|
+
break; // Stop after finding the latest assistant message
|
|
2269
|
+
}
|
|
2270
|
+
} catch (parseError) {
|
|
2271
|
+
// Skip lines that can't be parsed
|
|
2272
|
+
continue;
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
1766
2275
|
|
|
1767
|
-
|
|
1768
|
-
|
|
2276
|
+
// Calculate total context usage (excluding output_tokens, as per ccusage)
|
|
2277
|
+
const totalUsed = inputTokens + cacheCreationTokens + cacheReadTokens;
|
|
1769
2278
|
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
2279
|
+
res.json({
|
|
2280
|
+
used: totalUsed,
|
|
2281
|
+
total: contextWindow,
|
|
2282
|
+
breakdown: {
|
|
2283
|
+
input: inputTokens,
|
|
2284
|
+
cacheCreation: cacheCreationTokens,
|
|
2285
|
+
cacheRead: cacheReadTokens
|
|
2286
|
+
}
|
|
2287
|
+
});
|
|
2288
|
+
} catch (error) {
|
|
2289
|
+
console.error('Error reading session token usage:', error);
|
|
2290
|
+
res.status(500).json({ error: 'Failed to read session token usage' });
|
|
2291
|
+
}
|
|
1783
2292
|
});
|
|
1784
2293
|
|
|
1785
2294
|
// Serve React app for all other routes (excluding static files)
|
|
1786
2295
|
app.get('*', (req, res) => {
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
2296
|
+
// Skip requests for static assets (files with extensions)
|
|
2297
|
+
if (path.extname(req.path)) {
|
|
2298
|
+
return res.status(404).send('Not found');
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
// Only serve index.html for HTML routes, not for static assets
|
|
2302
|
+
// Static assets should already be handled by express.static middleware above
|
|
2303
|
+
const indexPath = path.join(__dirname, '../dist/index.html');
|
|
2304
|
+
|
|
2305
|
+
// Check if dist/index.html exists (production build available)
|
|
2306
|
+
if (fs.existsSync(indexPath)) {
|
|
2307
|
+
// Set no-cache headers for HTML to prevent service worker issues
|
|
2308
|
+
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
2309
|
+
res.setHeader('Pragma', 'no-cache');
|
|
2310
|
+
res.setHeader('Expires', '0');
|
|
2311
|
+
res.sendFile(indexPath);
|
|
2312
|
+
} else {
|
|
2313
|
+
// In development, redirect to Vite dev server only if dist doesn't exist
|
|
2314
|
+
res.redirect(`http://localhost:${process.env.VITE_PORT || 5173}`);
|
|
2315
|
+
}
|
|
1807
2316
|
});
|
|
1808
2317
|
|
|
1809
2318
|
// Helper function to convert permissions to rwx format
|