@siteboon/claude-code-ui 1.21.0 → 1.23.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
@@ -45,7 +45,7 @@ import fetch from 'node-fetch';
45
45
  import mime from 'mime-types';
46
46
 
47
47
  import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js';
48
- import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval } from './claude-sdk.js';
48
+ import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval, getPendingApprovalsForSession, reconnectSessionWriter } 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
51
  import { spawnGemini, abortGeminiSession, isGeminiSessionActive, getActiveGeminiSessions } from './gemini-cli.js';
@@ -64,10 +64,12 @@ import cliAuthRoutes from './routes/cli-auth.js';
64
64
  import userRoutes from './routes/user.js';
65
65
  import codexRoutes from './routes/codex.js';
66
66
  import geminiRoutes from './routes/gemini.js';
67
- import { initializeDatabase } from './database/db.js';
67
+ import { initializeDatabase, sessionNamesDb, applyCustomSessionNames } from './database/db.js';
68
68
  import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
69
69
  import { IS_PLATFORM } from './constants/config.js';
70
70
 
71
+ const VALID_PROVIDERS = ['claude', 'codex', 'cursor', 'gemini'];
72
+
71
73
  // File system watchers for provider project/session folders
72
74
  const PROVIDER_WATCH_PATHS = [
73
75
  { provider: 'claude', rootPath: path.join(os.homedir(), '.claude', 'projects') },
@@ -493,6 +495,7 @@ app.get('/api/projects/:projectName/sessions', authenticateToken, async (req, re
493
495
  try {
494
496
  const { limit = 5, offset = 0 } = req.query;
495
497
  const result = await getSessions(req.params.projectName, parseInt(limit), parseInt(offset));
498
+ applyCustomSessionNames(result.sessions, 'claude');
496
499
  res.json(result);
497
500
  } catch (error) {
498
501
  res.status(500).json({ error: error.message });
@@ -541,6 +544,7 @@ app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken,
541
544
  const { projectName, sessionId } = req.params;
542
545
  console.log(`[API] Deleting session: ${sessionId} from project: ${projectName}`);
543
546
  await deleteSession(projectName, sessionId);
547
+ sessionNamesDb.deleteName(sessionId, 'claude');
544
548
  console.log(`[API] Session ${sessionId} deleted successfully`);
545
549
  res.json({ success: true });
546
550
  } catch (error) {
@@ -549,6 +553,32 @@ app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken,
549
553
  }
550
554
  });
551
555
 
556
+ // Rename session endpoint
557
+ app.put('/api/sessions/:sessionId/rename', authenticateToken, async (req, res) => {
558
+ try {
559
+ const { sessionId } = req.params;
560
+ const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
561
+ if (!safeSessionId || safeSessionId !== String(sessionId)) {
562
+ return res.status(400).json({ error: 'Invalid sessionId' });
563
+ }
564
+ const { summary, provider } = req.body;
565
+ if (!summary || typeof summary !== 'string' || summary.trim() === '') {
566
+ return res.status(400).json({ error: 'Summary is required' });
567
+ }
568
+ if (summary.trim().length > 500) {
569
+ return res.status(400).json({ error: 'Summary must not exceed 500 characters' });
570
+ }
571
+ if (!provider || !VALID_PROVIDERS.includes(provider)) {
572
+ return res.status(400).json({ error: `Provider must be one of: ${VALID_PROVIDERS.join(', ')}` });
573
+ }
574
+ sessionNamesDb.setName(safeSessionId, provider, summary.trim());
575
+ res.json({ success: true });
576
+ } catch (error) {
577
+ console.error(`[API] Error renaming session ${req.params.sessionId}:`, error);
578
+ res.status(500).json({ error: error.message });
579
+ }
580
+ });
581
+
552
582
  // Delete project endpoint (force=true to delete with sessions)
553
583
  app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => {
554
584
  try {
@@ -884,6 +914,436 @@ app.get('/api/projects/:projectName/files', authenticateToken, async (req, res)
884
914
  }
885
915
  });
886
916
 
917
+ // ============================================================================
918
+ // FILE OPERATIONS API ENDPOINTS
919
+ // ============================================================================
920
+
921
+ /**
922
+ * Validate that a path is within the project root
923
+ * @param {string} projectRoot - The project root path
924
+ * @param {string} targetPath - The path to validate
925
+ * @returns {{ valid: boolean, resolved?: string, error?: string }}
926
+ */
927
+ function validatePathInProject(projectRoot, targetPath) {
928
+ const resolved = path.isAbsolute(targetPath)
929
+ ? path.resolve(targetPath)
930
+ : path.resolve(projectRoot, targetPath);
931
+ const normalizedRoot = path.resolve(projectRoot) + path.sep;
932
+ if (!resolved.startsWith(normalizedRoot)) {
933
+ return { valid: false, error: 'Path must be under project root' };
934
+ }
935
+ return { valid: true, resolved };
936
+ }
937
+
938
+ /**
939
+ * Validate filename - check for invalid characters
940
+ * @param {string} name - The filename to validate
941
+ * @returns {{ valid: boolean, error?: string }}
942
+ */
943
+ function validateFilename(name) {
944
+ if (!name || !name.trim()) {
945
+ return { valid: false, error: 'Filename cannot be empty' };
946
+ }
947
+ // Check for invalid characters (Windows + Unix)
948
+ const invalidChars = /[<>:"/\\|?*\x00-\x1f]/;
949
+ if (invalidChars.test(name)) {
950
+ return { valid: false, error: 'Filename contains invalid characters' };
951
+ }
952
+ // Check for reserved names (Windows)
953
+ const reserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i;
954
+ if (reserved.test(name)) {
955
+ return { valid: false, error: 'Filename is a reserved name' };
956
+ }
957
+ // Check for dots only
958
+ if (/^\.+$/.test(name)) {
959
+ return { valid: false, error: 'Filename cannot be only dots' };
960
+ }
961
+ return { valid: true };
962
+ }
963
+
964
+ // POST /api/projects/:projectName/files/create - Create new file or directory
965
+ app.post('/api/projects/:projectName/files/create', authenticateToken, async (req, res) => {
966
+ try {
967
+ const { projectName } = req.params;
968
+ const { path: parentPath, type, name } = req.body;
969
+
970
+ // Validate input
971
+ if (!name || !type) {
972
+ return res.status(400).json({ error: 'Name and type are required' });
973
+ }
974
+
975
+ if (!['file', 'directory'].includes(type)) {
976
+ return res.status(400).json({ error: 'Type must be "file" or "directory"' });
977
+ }
978
+
979
+ const nameValidation = validateFilename(name);
980
+ if (!nameValidation.valid) {
981
+ return res.status(400).json({ error: nameValidation.error });
982
+ }
983
+
984
+ // Get project root
985
+ const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
986
+ if (!projectRoot) {
987
+ return res.status(404).json({ error: 'Project not found' });
988
+ }
989
+
990
+ // Build and validate target path
991
+ const targetDir = parentPath || '';
992
+ const targetPath = targetDir ? path.join(targetDir, name) : name;
993
+ const validation = validatePathInProject(projectRoot, targetPath);
994
+ if (!validation.valid) {
995
+ return res.status(403).json({ error: validation.error });
996
+ }
997
+
998
+ const resolvedPath = validation.resolved;
999
+
1000
+ // Check if already exists
1001
+ try {
1002
+ await fsPromises.access(resolvedPath);
1003
+ return res.status(409).json({ error: `${type === 'file' ? 'File' : 'Directory'} already exists` });
1004
+ } catch {
1005
+ // Doesn't exist, which is what we want
1006
+ }
1007
+
1008
+ // Create file or directory
1009
+ if (type === 'directory') {
1010
+ await fsPromises.mkdir(resolvedPath, { recursive: false });
1011
+ } else {
1012
+ // Ensure parent directory exists
1013
+ const parentDir = path.dirname(resolvedPath);
1014
+ try {
1015
+ await fsPromises.access(parentDir);
1016
+ } catch {
1017
+ await fsPromises.mkdir(parentDir, { recursive: true });
1018
+ }
1019
+ await fsPromises.writeFile(resolvedPath, '', 'utf8');
1020
+ }
1021
+
1022
+ res.json({
1023
+ success: true,
1024
+ path: resolvedPath,
1025
+ name,
1026
+ type,
1027
+ message: `${type === 'file' ? 'File' : 'Directory'} created successfully`
1028
+ });
1029
+ } catch (error) {
1030
+ console.error('Error creating file/directory:', error);
1031
+ if (error.code === 'EACCES') {
1032
+ res.status(403).json({ error: 'Permission denied' });
1033
+ } else if (error.code === 'ENOENT') {
1034
+ res.status(404).json({ error: 'Parent directory not found' });
1035
+ } else {
1036
+ res.status(500).json({ error: error.message });
1037
+ }
1038
+ }
1039
+ });
1040
+
1041
+ // PUT /api/projects/:projectName/files/rename - Rename file or directory
1042
+ app.put('/api/projects/:projectName/files/rename', authenticateToken, async (req, res) => {
1043
+ try {
1044
+ const { projectName } = req.params;
1045
+ const { oldPath, newName } = req.body;
1046
+
1047
+ // Validate input
1048
+ if (!oldPath || !newName) {
1049
+ return res.status(400).json({ error: 'oldPath and newName are required' });
1050
+ }
1051
+
1052
+ const nameValidation = validateFilename(newName);
1053
+ if (!nameValidation.valid) {
1054
+ return res.status(400).json({ error: nameValidation.error });
1055
+ }
1056
+
1057
+ // Get project root
1058
+ const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
1059
+ if (!projectRoot) {
1060
+ return res.status(404).json({ error: 'Project not found' });
1061
+ }
1062
+
1063
+ // Validate old path
1064
+ const oldValidation = validatePathInProject(projectRoot, oldPath);
1065
+ if (!oldValidation.valid) {
1066
+ return res.status(403).json({ error: oldValidation.error });
1067
+ }
1068
+
1069
+ const resolvedOldPath = oldValidation.resolved;
1070
+
1071
+ // Check if old path exists
1072
+ try {
1073
+ await fsPromises.access(resolvedOldPath);
1074
+ } catch {
1075
+ return res.status(404).json({ error: 'File or directory not found' });
1076
+ }
1077
+
1078
+ // Build and validate new path
1079
+ const parentDir = path.dirname(resolvedOldPath);
1080
+ const resolvedNewPath = path.join(parentDir, newName);
1081
+ const newValidation = validatePathInProject(projectRoot, resolvedNewPath);
1082
+ if (!newValidation.valid) {
1083
+ return res.status(403).json({ error: newValidation.error });
1084
+ }
1085
+
1086
+ // Check if new path already exists
1087
+ try {
1088
+ await fsPromises.access(resolvedNewPath);
1089
+ return res.status(409).json({ error: 'A file or directory with this name already exists' });
1090
+ } catch {
1091
+ // Doesn't exist, which is what we want
1092
+ }
1093
+
1094
+ // Rename
1095
+ await fsPromises.rename(resolvedOldPath, resolvedNewPath);
1096
+
1097
+ res.json({
1098
+ success: true,
1099
+ oldPath: resolvedOldPath,
1100
+ newPath: resolvedNewPath,
1101
+ newName,
1102
+ message: 'Renamed successfully'
1103
+ });
1104
+ } catch (error) {
1105
+ console.error('Error renaming file/directory:', error);
1106
+ if (error.code === 'EACCES') {
1107
+ res.status(403).json({ error: 'Permission denied' });
1108
+ } else if (error.code === 'ENOENT') {
1109
+ res.status(404).json({ error: 'File or directory not found' });
1110
+ } else if (error.code === 'EXDEV') {
1111
+ res.status(400).json({ error: 'Cannot move across different filesystems' });
1112
+ } else {
1113
+ res.status(500).json({ error: error.message });
1114
+ }
1115
+ }
1116
+ });
1117
+
1118
+ // DELETE /api/projects/:projectName/files - Delete file or directory
1119
+ app.delete('/api/projects/:projectName/files', authenticateToken, async (req, res) => {
1120
+ try {
1121
+ const { projectName } = req.params;
1122
+ const { path: targetPath, type } = req.body;
1123
+
1124
+ // Validate input
1125
+ if (!targetPath) {
1126
+ return res.status(400).json({ error: 'Path is required' });
1127
+ }
1128
+
1129
+ // Get project root
1130
+ const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
1131
+ if (!projectRoot) {
1132
+ return res.status(404).json({ error: 'Project not found' });
1133
+ }
1134
+
1135
+ // Validate path
1136
+ const validation = validatePathInProject(projectRoot, targetPath);
1137
+ if (!validation.valid) {
1138
+ return res.status(403).json({ error: validation.error });
1139
+ }
1140
+
1141
+ const resolvedPath = validation.resolved;
1142
+
1143
+ // Check if path exists and get stats
1144
+ let stats;
1145
+ try {
1146
+ stats = await fsPromises.stat(resolvedPath);
1147
+ } catch {
1148
+ return res.status(404).json({ error: 'File or directory not found' });
1149
+ }
1150
+
1151
+ // Prevent deleting the project root itself
1152
+ if (resolvedPath === path.resolve(projectRoot)) {
1153
+ return res.status(403).json({ error: 'Cannot delete project root directory' });
1154
+ }
1155
+
1156
+ // Delete based on type
1157
+ if (stats.isDirectory()) {
1158
+ await fsPromises.rm(resolvedPath, { recursive: true, force: true });
1159
+ } else {
1160
+ await fsPromises.unlink(resolvedPath);
1161
+ }
1162
+
1163
+ res.json({
1164
+ success: true,
1165
+ path: resolvedPath,
1166
+ type: stats.isDirectory() ? 'directory' : 'file',
1167
+ message: 'Deleted successfully'
1168
+ });
1169
+ } catch (error) {
1170
+ console.error('Error deleting file/directory:', error);
1171
+ if (error.code === 'EACCES') {
1172
+ res.status(403).json({ error: 'Permission denied' });
1173
+ } else if (error.code === 'ENOENT') {
1174
+ res.status(404).json({ error: 'File or directory not found' });
1175
+ } else if (error.code === 'ENOTEMPTY') {
1176
+ res.status(400).json({ error: 'Directory is not empty' });
1177
+ } else {
1178
+ res.status(500).json({ error: error.message });
1179
+ }
1180
+ }
1181
+ });
1182
+
1183
+ // POST /api/projects/:projectName/files/upload - Upload files
1184
+ // Dynamic import of multer for file uploads
1185
+ const uploadFilesHandler = async (req, res) => {
1186
+ // Dynamic import of multer
1187
+ const multer = (await import('multer')).default;
1188
+
1189
+ const uploadMiddleware = multer({
1190
+ storage: multer.diskStorage({
1191
+ destination: (req, file, cb) => {
1192
+ cb(null, os.tmpdir());
1193
+ },
1194
+ filename: (req, file, cb) => {
1195
+ // Use a unique temp name, but preserve original name in file.originalname
1196
+ // Note: file.originalname may contain path separators for folder uploads
1197
+ const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
1198
+ // For temp file, just use a safe unique name without the path
1199
+ cb(null, `upload-${uniqueSuffix}`);
1200
+ }
1201
+ }),
1202
+ limits: {
1203
+ fileSize: 50 * 1024 * 1024, // 50MB limit
1204
+ files: 20 // Max 20 files at once
1205
+ }
1206
+ });
1207
+
1208
+ // Use multer middleware
1209
+ uploadMiddleware.array('files', 20)(req, res, async (err) => {
1210
+ if (err) {
1211
+ console.error('Multer error:', err);
1212
+ if (err.code === 'LIMIT_FILE_SIZE') {
1213
+ return res.status(400).json({ error: 'File too large. Maximum size is 50MB.' });
1214
+ }
1215
+ if (err.code === 'LIMIT_FILE_COUNT') {
1216
+ return res.status(400).json({ error: 'Too many files. Maximum is 20 files.' });
1217
+ }
1218
+ return res.status(500).json({ error: err.message });
1219
+ }
1220
+
1221
+ try {
1222
+ const { projectName } = req.params;
1223
+ const { targetPath, relativePaths } = req.body;
1224
+
1225
+ // Parse relative paths if provided (for folder uploads)
1226
+ let filePaths = [];
1227
+ if (relativePaths) {
1228
+ try {
1229
+ filePaths = JSON.parse(relativePaths);
1230
+ } catch (e) {
1231
+ console.log('[DEBUG] Failed to parse relativePaths:', relativePaths);
1232
+ }
1233
+ }
1234
+
1235
+ console.log('[DEBUG] File upload request:', {
1236
+ projectName,
1237
+ targetPath: JSON.stringify(targetPath),
1238
+ targetPathType: typeof targetPath,
1239
+ filesCount: req.files?.length,
1240
+ relativePaths: filePaths
1241
+ });
1242
+
1243
+ if (!req.files || req.files.length === 0) {
1244
+ return res.status(400).json({ error: 'No files provided' });
1245
+ }
1246
+
1247
+ // Get project root
1248
+ const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
1249
+ if (!projectRoot) {
1250
+ return res.status(404).json({ error: 'Project not found' });
1251
+ }
1252
+
1253
+ console.log('[DEBUG] Project root:', projectRoot);
1254
+
1255
+ // Validate and resolve target path
1256
+ // If targetPath is empty or '.', use project root directly
1257
+ const targetDir = targetPath || '';
1258
+ let resolvedTargetDir;
1259
+
1260
+ console.log('[DEBUG] Target dir:', JSON.stringify(targetDir));
1261
+
1262
+ if (!targetDir || targetDir === '.' || targetDir === './') {
1263
+ // Empty path means upload to project root
1264
+ resolvedTargetDir = path.resolve(projectRoot);
1265
+ console.log('[DEBUG] Using project root as target:', resolvedTargetDir);
1266
+ } else {
1267
+ const validation = validatePathInProject(projectRoot, targetDir);
1268
+ if (!validation.valid) {
1269
+ console.log('[DEBUG] Path validation failed:', validation.error);
1270
+ return res.status(403).json({ error: validation.error });
1271
+ }
1272
+ resolvedTargetDir = validation.resolved;
1273
+ console.log('[DEBUG] Resolved target dir:', resolvedTargetDir);
1274
+ }
1275
+
1276
+ // Ensure target directory exists
1277
+ try {
1278
+ await fsPromises.access(resolvedTargetDir);
1279
+ } catch {
1280
+ await fsPromises.mkdir(resolvedTargetDir, { recursive: true });
1281
+ }
1282
+
1283
+ // Move uploaded files from temp to target directory
1284
+ const uploadedFiles = [];
1285
+ console.log('[DEBUG] Processing files:', req.files.map(f => ({ originalname: f.originalname, path: f.path })));
1286
+ for (let i = 0; i < req.files.length; i++) {
1287
+ const file = req.files[i];
1288
+ // Use relative path if provided (for folder uploads), otherwise use originalname
1289
+ const fileName = (filePaths && filePaths[i]) ? filePaths[i] : file.originalname;
1290
+ console.log('[DEBUG] Processing file:', fileName, '(originalname:', file.originalname + ')');
1291
+ const destPath = path.join(resolvedTargetDir, fileName);
1292
+
1293
+ // Validate destination path
1294
+ const destValidation = validatePathInProject(projectRoot, destPath);
1295
+ if (!destValidation.valid) {
1296
+ console.log('[DEBUG] Destination validation failed for:', destPath);
1297
+ // Clean up temp file
1298
+ await fsPromises.unlink(file.path).catch(() => {});
1299
+ continue;
1300
+ }
1301
+
1302
+ // Ensure parent directory exists (for nested files from folder upload)
1303
+ const parentDir = path.dirname(destPath);
1304
+ try {
1305
+ await fsPromises.access(parentDir);
1306
+ } catch {
1307
+ await fsPromises.mkdir(parentDir, { recursive: true });
1308
+ }
1309
+
1310
+ // Move file (copy + unlink to handle cross-device scenarios)
1311
+ await fsPromises.copyFile(file.path, destPath);
1312
+ await fsPromises.unlink(file.path);
1313
+
1314
+ uploadedFiles.push({
1315
+ name: fileName,
1316
+ path: destPath,
1317
+ size: file.size,
1318
+ mimeType: file.mimetype
1319
+ });
1320
+ }
1321
+
1322
+ res.json({
1323
+ success: true,
1324
+ files: uploadedFiles,
1325
+ targetPath: resolvedTargetDir,
1326
+ message: `Uploaded ${uploadedFiles.length} file(s) successfully`
1327
+ });
1328
+ } catch (error) {
1329
+ console.error('Error uploading files:', error);
1330
+ // Clean up any remaining temp files
1331
+ if (req.files) {
1332
+ for (const file of req.files) {
1333
+ await fsPromises.unlink(file.path).catch(() => {});
1334
+ }
1335
+ }
1336
+ if (error.code === 'EACCES') {
1337
+ res.status(403).json({ error: 'Permission denied' });
1338
+ } else {
1339
+ res.status(500).json({ error: error.message });
1340
+ }
1341
+ }
1342
+ });
1343
+ };
1344
+
1345
+ app.post('/api/projects/:projectName/files/upload', authenticateToken, uploadFilesHandler);
1346
+
887
1347
  // WebSocket connection handler that routes based on URL path
888
1348
  wss.on('connection', (ws, request) => {
889
1349
  const url = request.url;
@@ -920,6 +1380,10 @@ class WebSocketWriter {
920
1380
  }
921
1381
  }
922
1382
 
1383
+ updateWebSocket(newRawWs) {
1384
+ this.ws = newRawWs;
1385
+ }
1386
+
923
1387
  setSessionId(sessionId) {
924
1388
  this.sessionId = sessionId;
925
1389
  }
@@ -1034,6 +1498,11 @@ function handleChatConnection(ws) {
1034
1498
  } else {
1035
1499
  // Use Claude Agents SDK
1036
1500
  isActive = isClaudeSDKSessionActive(sessionId);
1501
+ if (isActive) {
1502
+ // Reconnect the session's writer to the new WebSocket so
1503
+ // subsequent SDK output flows to the refreshed client.
1504
+ reconnectSessionWriter(sessionId, ws);
1505
+ }
1037
1506
  }
1038
1507
 
1039
1508
  writer.send({
@@ -1042,6 +1511,17 @@ function handleChatConnection(ws) {
1042
1511
  provider,
1043
1512
  isProcessing: isActive
1044
1513
  });
1514
+ } else if (data.type === 'get-pending-permissions') {
1515
+ // Return pending permission requests for a session
1516
+ const sessionId = data.sessionId;
1517
+ if (sessionId && isClaudeSDKSessionActive(sessionId)) {
1518
+ const pending = getPendingApprovalsForSession(sessionId);
1519
+ writer.send({
1520
+ type: 'pending-permissions-response',
1521
+ sessionId,
1522
+ data: pending
1523
+ });
1524
+ }
1045
1525
  } else if (data.type === 'get-active-sessions') {
1046
1526
  // Get all currently active sessions
1047
1527
  const activeSessions = {
@@ -1218,7 +1698,7 @@ function handleShellConnection(ws) {
1218
1698
  if (hasSession && sessionId) {
1219
1699
  try {
1220
1700
  // 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).
1701
+ // The UI only knows about its internal generated `sessionId` (e.g. gemini_1234).
1222
1702
  // We must fetch the mapping from the backend session manager to pass the native `cliSessionId` to the shell.
1223
1703
  const sess = sessionManager.getSession(sessionId);
1224
1704
  if (sess && sess.cliSessionId) {
@@ -1682,7 +2162,7 @@ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authentica
1682
2162
 
1683
2163
  // Allow only safe characters in sessionId
1684
2164
  const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
1685
- if (!safeSessionId) {
2165
+ if (!safeSessionId || safeSessionId !== String(sessionId)) {
1686
2166
  return res.status(400).json({ error: 'Invalid sessionId' });
1687
2167
  }
1688
2168
 
@@ -1791,8 +2271,8 @@ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authentica
1791
2271
 
1792
2272
  // Construct the JSONL file path
1793
2273
  // 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, '-');
2274
+ // The encoding replaces any non-alphanumeric character (except -) with -
2275
+ const encodedPath = projectPath.replace(/[^a-zA-Z0-9-]/g, '-');
1796
2276
  const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
1797
2277
 
1798
2278
  const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);