@siteboon/claude-code-ui 1.12.0 → 1.13.1

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
@@ -60,6 +60,7 @@ import mime from 'mime-types';
60
60
  import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js';
61
61
  import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions } from './claude-sdk.js';
62
62
  import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
63
+ import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js';
63
64
  import gitRoutes from './routes/git.js';
64
65
  import authRoutes from './routes/auth.js';
65
66
  import mcpRoutes from './routes/mcp.js';
@@ -70,6 +71,9 @@ import commandsRoutes from './routes/commands.js';
70
71
  import settingsRoutes from './routes/settings.js';
71
72
  import agentRoutes from './routes/agent.js';
72
73
  import projectsRoutes from './routes/projects.js';
74
+ import cliAuthRoutes from './routes/cli-auth.js';
75
+ import userRoutes from './routes/user.js';
76
+ import codexRoutes from './routes/codex.js';
73
77
  import { initializeDatabase } from './database/db.js';
74
78
  import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
75
79
 
@@ -164,6 +168,9 @@ async function setupProjectsWatcher() {
164
168
  const app = express();
165
169
  const server = http.createServer(app);
166
170
 
171
+ const ptySessionsMap = new Map();
172
+ const PTY_SESSION_TIMEOUT = 30 * 60 * 1000;
173
+
167
174
  // Single WebSocket server that handles both paths
168
175
  const wss = new WebSocketServer({
169
176
  server,
@@ -206,7 +213,17 @@ const wss = new WebSocketServer({
206
213
  app.locals.wss = wss;
207
214
 
208
215
  app.use(cors());
209
- app.use(express.json({ limit: '50mb' }));
216
+ app.use(express.json({
217
+ limit: '50mb',
218
+ type: (req) => {
219
+ // Skip multipart/form-data requests (for file uploads like images)
220
+ const contentType = req.headers['content-type'] || '';
221
+ if (contentType.includes('multipart/form-data')) {
222
+ return false;
223
+ }
224
+ return contentType.includes('json');
225
+ }
226
+ }));
210
227
  app.use(express.urlencoded({ limit: '50mb', extended: true }));
211
228
 
212
229
  // Public health check endpoint (no authentication required)
@@ -247,6 +264,15 @@ app.use('/api/commands', authenticateToken, commandsRoutes);
247
264
  // Settings API Routes (protected)
248
265
  app.use('/api/settings', authenticateToken, settingsRoutes);
249
266
 
267
+ // CLI Authentication API Routes (protected)
268
+ app.use('/api/cli', authenticateToken, cliAuthRoutes);
269
+
270
+ // User API Routes (protected)
271
+ app.use('/api/user', authenticateToken, userRoutes);
272
+
273
+ // Codex API Routes (protected)
274
+ app.use('/api/codex', authenticateToken, codexRoutes);
275
+
250
276
  // Agent API Routes (uses API key authentication)
251
277
  app.use('/api/agent', agentRoutes);
252
278
 
@@ -397,9 +423,12 @@ app.put('/api/projects/:projectName/rename', authenticateToken, async (req, res)
397
423
  app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken, async (req, res) => {
398
424
  try {
399
425
  const { projectName, sessionId } = req.params;
426
+ console.log(`[API] Deleting session: ${sessionId} from project: ${projectName}`);
400
427
  await deleteSession(projectName, sessionId);
428
+ console.log(`[API] Session ${sessionId} deleted successfully`);
401
429
  res.json({ success: true });
402
430
  } catch (error) {
431
+ console.error(`[API] Error deleting session ${req.params.sessionId}:`, error);
403
432
  res.status(500).json({ error: error.message });
404
433
  }
405
434
  });
@@ -688,6 +717,32 @@ wss.on('connection', (ws, request) => {
688
717
  }
689
718
  });
690
719
 
720
+ /**
721
+ * WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface
722
+ */
723
+ class WebSocketWriter {
724
+ constructor(ws) {
725
+ this.ws = ws;
726
+ this.sessionId = null;
727
+ this.isWebSocketWriter = true; // Marker for transport detection
728
+ }
729
+
730
+ send(data) {
731
+ if (this.ws.readyState === 1) { // WebSocket.OPEN
732
+ // Providers send raw objects, we stringify for WebSocket
733
+ this.ws.send(JSON.stringify(data));
734
+ }
735
+ }
736
+
737
+ setSessionId(sessionId) {
738
+ this.sessionId = sessionId;
739
+ }
740
+
741
+ getSessionId() {
742
+ return this.sessionId;
743
+ }
744
+ }
745
+
691
746
  // Handle chat WebSocket connections
692
747
  function handleChatConnection(ws) {
693
748
  console.log('[INFO] Chat WebSocket connected');
@@ -695,6 +750,9 @@ function handleChatConnection(ws) {
695
750
  // Add to connected clients for project updates
696
751
  connectedClients.add(ws);
697
752
 
753
+ // Wrap WebSocket with writer for consistent interface with SSEStreamWriter
754
+ const writer = new WebSocketWriter(ws);
755
+
698
756
  ws.on('message', async (message) => {
699
757
  try {
700
758
  const data = JSON.parse(message);
@@ -705,13 +763,19 @@ function handleChatConnection(ws) {
705
763
  console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
706
764
 
707
765
  // Use Claude Agents SDK
708
- await queryClaudeSDK(data.command, data.options, ws);
766
+ await queryClaudeSDK(data.command, data.options, writer);
709
767
  } else if (data.type === 'cursor-command') {
710
768
  console.log('[DEBUG] Cursor message:', data.command || '[Continue/Resume]');
711
769
  console.log('📁 Project:', data.options?.cwd || 'Unknown');
712
770
  console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
713
771
  console.log('🤖 Model:', data.options?.model || 'default');
714
- await spawnCursor(data.command, data.options, ws);
772
+ await spawnCursor(data.command, data.options, writer);
773
+ } else if (data.type === 'codex-command') {
774
+ console.log('[DEBUG] Codex message:', data.command || '[Continue/Resume]');
775
+ console.log('📁 Project:', data.options?.projectPath || data.options?.cwd || 'Unknown');
776
+ console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
777
+ console.log('🤖 Model:', data.options?.model || 'default');
778
+ await queryCodex(data.command, data.options, writer);
715
779
  } else if (data.type === 'cursor-resume') {
716
780
  // Backward compatibility: treat as cursor-command with resume and no prompt
717
781
  console.log('[DEBUG] Cursor resume session (compat):', data.sessionId);
@@ -719,7 +783,7 @@ function handleChatConnection(ws) {
719
783
  sessionId: data.sessionId,
720
784
  resume: true,
721
785
  cwd: data.options?.cwd
722
- }, ws);
786
+ }, writer);
723
787
  } else if (data.type === 'abort-session') {
724
788
  console.log('[DEBUG] Abort session request:', data.sessionId);
725
789
  const provider = data.provider || 'claude';
@@ -727,26 +791,28 @@ function handleChatConnection(ws) {
727
791
 
728
792
  if (provider === 'cursor') {
729
793
  success = abortCursorSession(data.sessionId);
794
+ } else if (provider === 'codex') {
795
+ success = abortCodexSession(data.sessionId);
730
796
  } else {
731
797
  // Use Claude Agents SDK
732
798
  success = await abortClaudeSDKSession(data.sessionId);
733
799
  }
734
800
 
735
- ws.send(JSON.stringify({
801
+ writer.send({
736
802
  type: 'session-aborted',
737
803
  sessionId: data.sessionId,
738
804
  provider,
739
805
  success
740
- }));
806
+ });
741
807
  } else if (data.type === 'cursor-abort') {
742
808
  console.log('[DEBUG] Abort Cursor session:', data.sessionId);
743
809
  const success = abortCursorSession(data.sessionId);
744
- ws.send(JSON.stringify({
810
+ writer.send({
745
811
  type: 'session-aborted',
746
812
  sessionId: data.sessionId,
747
813
  provider: 'cursor',
748
814
  success
749
- }));
815
+ });
750
816
  } else if (data.type === 'check-session-status') {
751
817
  // Check if a specific session is currently processing
752
818
  const provider = data.provider || 'claude';
@@ -755,34 +821,37 @@ function handleChatConnection(ws) {
755
821
 
756
822
  if (provider === 'cursor') {
757
823
  isActive = isCursorSessionActive(sessionId);
824
+ } else if (provider === 'codex') {
825
+ isActive = isCodexSessionActive(sessionId);
758
826
  } else {
759
827
  // Use Claude Agents SDK
760
828
  isActive = isClaudeSDKSessionActive(sessionId);
761
829
  }
762
830
 
763
- ws.send(JSON.stringify({
831
+ writer.send({
764
832
  type: 'session-status',
765
833
  sessionId,
766
834
  provider,
767
835
  isProcessing: isActive
768
- }));
836
+ });
769
837
  } else if (data.type === 'get-active-sessions') {
770
838
  // Get all currently active sessions
771
839
  const activeSessions = {
772
840
  claude: getActiveClaudeSDKSessions(),
773
- cursor: getActiveCursorSessions()
841
+ cursor: getActiveCursorSessions(),
842
+ codex: getActiveCodexSessions()
774
843
  };
775
- ws.send(JSON.stringify({
844
+ writer.send({
776
845
  type: 'active-sessions',
777
846
  sessions: activeSessions
778
- }));
847
+ });
779
848
  }
780
849
  } catch (error) {
781
850
  console.error('[ERROR] Chat WebSocket error:', error.message);
782
- ws.send(JSON.stringify({
851
+ writer.send({
783
852
  type: 'error',
784
853
  error: error.message
785
- }));
854
+ });
786
855
  }
787
856
  });
788
857
 
@@ -797,6 +866,8 @@ function handleChatConnection(ws) {
797
866
  function handleShellConnection(ws) {
798
867
  console.log('🐚 Shell client connected');
799
868
  let shellProcess = null;
869
+ let ptySessionKey = null;
870
+ let outputBuffer = [];
800
871
 
801
872
  ws.on('message', async (message) => {
802
873
  try {
@@ -804,7 +875,6 @@ function handleShellConnection(ws) {
804
875
  console.log('📨 Shell message received:', data.type);
805
876
 
806
877
  if (data.type === 'init') {
807
- // Initialize shell with project path and session info
808
878
  const projectPath = data.projectPath || process.cwd();
809
879
  const sessionId = data.sessionId;
810
880
  const hasSession = data.hasSession;
@@ -812,6 +882,57 @@ function handleShellConnection(ws) {
812
882
  const initialCommand = data.initialCommand;
813
883
  const isPlainShell = data.isPlainShell || (!!initialCommand && !hasSession) || provider === 'plain-shell';
814
884
 
885
+ // Login commands (Claude/Cursor auth) should never reuse cached sessions
886
+ const isLoginCommand = initialCommand && (
887
+ initialCommand.includes('setup-token') ||
888
+ initialCommand.includes('cursor-agent login') ||
889
+ initialCommand.includes('auth login')
890
+ );
891
+
892
+ // Include command hash in session key so different commands get separate sessions
893
+ const commandSuffix = isPlainShell && initialCommand
894
+ ? `_cmd_${Buffer.from(initialCommand).toString('base64').slice(0, 16)}`
895
+ : '';
896
+ ptySessionKey = `${projectPath}_${sessionId || 'default'}${commandSuffix}`;
897
+
898
+ // Kill any existing login session before starting fresh
899
+ if (isLoginCommand) {
900
+ const oldSession = ptySessionsMap.get(ptySessionKey);
901
+ if (oldSession) {
902
+ console.log('🧹 Cleaning up existing login session:', ptySessionKey);
903
+ if (oldSession.timeoutId) clearTimeout(oldSession.timeoutId);
904
+ if (oldSession.pty && oldSession.pty.kill) oldSession.pty.kill();
905
+ ptySessionsMap.delete(ptySessionKey);
906
+ }
907
+ }
908
+
909
+ const existingSession = isLoginCommand ? null : ptySessionsMap.get(ptySessionKey);
910
+ if (existingSession) {
911
+ console.log('♻️ Reconnecting to existing PTY session:', ptySessionKey);
912
+ shellProcess = existingSession.pty;
913
+
914
+ clearTimeout(existingSession.timeoutId);
915
+
916
+ ws.send(JSON.stringify({
917
+ type: 'output',
918
+ data: `\x1b[36m[Reconnected to existing session]\x1b[0m\r\n`
919
+ }));
920
+
921
+ if (existingSession.buffer && existingSession.buffer.length > 0) {
922
+ console.log(`📜 Sending ${existingSession.buffer.length} buffered messages`);
923
+ existingSession.buffer.forEach(bufferedData => {
924
+ ws.send(JSON.stringify({
925
+ type: 'output',
926
+ data: bufferedData
927
+ }));
928
+ });
929
+ }
930
+
931
+ existingSession.ws = ws;
932
+
933
+ return;
934
+ }
935
+
815
936
  console.log('[INFO] Starting shell in:', projectPath);
816
937
  console.log('📋 Session info:', hasSession ? `Resume session ${sessionId}` : (isPlainShell ? 'Plain shell mode' : 'New session'));
817
938
  console.log('🤖 Provider:', isPlainShell ? 'plain-shell' : provider);
@@ -885,10 +1006,15 @@ function handleShellConnection(ws) {
885
1006
  const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
886
1007
  const shellArgs = os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand];
887
1008
 
1009
+ // Use terminal dimensions from client if provided, otherwise use defaults
1010
+ const termCols = data.cols || 80;
1011
+ const termRows = data.rows || 24;
1012
+ console.log('📐 Using terminal dimensions:', termCols, 'x', termRows);
1013
+
888
1014
  shellProcess = pty.spawn(shell, shellArgs, {
889
1015
  name: 'xterm-256color',
890
- cols: 80,
891
- rows: 24,
1016
+ cols: termCols,
1017
+ rows: termRows,
892
1018
  cwd: process.env.HOME || (os.platform() === 'win32' ? process.env.USERPROFILE : '/'),
893
1019
  env: {
894
1020
  ...process.env,
@@ -902,9 +1028,28 @@ function handleShellConnection(ws) {
902
1028
 
903
1029
  console.log('🟢 Shell process started with PTY, PID:', shellProcess.pid);
904
1030
 
1031
+ ptySessionsMap.set(ptySessionKey, {
1032
+ pty: shellProcess,
1033
+ ws: ws,
1034
+ buffer: [],
1035
+ timeoutId: null,
1036
+ projectPath,
1037
+ sessionId
1038
+ });
1039
+
905
1040
  // Handle data output
906
1041
  shellProcess.onData((data) => {
907
- if (ws.readyState === WebSocket.OPEN) {
1042
+ const session = ptySessionsMap.get(ptySessionKey);
1043
+ if (!session) return;
1044
+
1045
+ if (session.buffer.length < 5000) {
1046
+ session.buffer.push(data);
1047
+ } else {
1048
+ session.buffer.shift();
1049
+ session.buffer.push(data);
1050
+ }
1051
+
1052
+ if (session.ws && session.ws.readyState === WebSocket.OPEN) {
908
1053
  let outputData = data;
909
1054
 
910
1055
  // Check for various URL opening patterns
@@ -928,7 +1073,7 @@ function handleShellConnection(ws) {
928
1073
  console.log('[DEBUG] Detected URL for opening:', url);
929
1074
 
930
1075
  // Send URL opening message to client
931
- ws.send(JSON.stringify({
1076
+ session.ws.send(JSON.stringify({
932
1077
  type: 'url_open',
933
1078
  url: url
934
1079
  }));
@@ -941,7 +1086,7 @@ function handleShellConnection(ws) {
941
1086
  });
942
1087
 
943
1088
  // Send regular output
944
- ws.send(JSON.stringify({
1089
+ session.ws.send(JSON.stringify({
945
1090
  type: 'output',
946
1091
  data: outputData
947
1092
  }));
@@ -951,12 +1096,17 @@ function handleShellConnection(ws) {
951
1096
  // Handle process exit
952
1097
  shellProcess.onExit((exitCode) => {
953
1098
  console.log('🔚 Shell process exited with code:', exitCode.exitCode, 'signal:', exitCode.signal);
954
- if (ws.readyState === WebSocket.OPEN) {
955
- ws.send(JSON.stringify({
1099
+ const session = ptySessionsMap.get(ptySessionKey);
1100
+ if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {
1101
+ session.ws.send(JSON.stringify({
956
1102
  type: 'output',
957
1103
  data: `\r\n\x1b[33mProcess exited with code ${exitCode.exitCode}${exitCode.signal ? ` (${exitCode.signal})` : ''}\x1b[0m\r\n`
958
1104
  }));
959
1105
  }
1106
+ if (session && session.timeoutId) {
1107
+ clearTimeout(session.timeoutId);
1108
+ }
1109
+ ptySessionsMap.delete(ptySessionKey);
960
1110
  shellProcess = null;
961
1111
  });
962
1112
 
@@ -999,9 +1149,21 @@ function handleShellConnection(ws) {
999
1149
 
1000
1150
  ws.on('close', () => {
1001
1151
  console.log('🔌 Shell client disconnected');
1002
- if (shellProcess && shellProcess.kill) {
1003
- console.log('🔴 Killing shell process:', shellProcess.pid);
1004
- shellProcess.kill();
1152
+
1153
+ if (ptySessionKey) {
1154
+ const session = ptySessionsMap.get(ptySessionKey);
1155
+ if (session) {
1156
+ console.log('⏳ PTY session kept alive, will timeout in 30 minutes:', ptySessionKey);
1157
+ session.ws = null;
1158
+
1159
+ session.timeoutId = setTimeout(() => {
1160
+ console.log('⏰ PTY session timeout, killing process:', ptySessionKey);
1161
+ if (session.pty && session.pty.kill) {
1162
+ session.pty.kill();
1163
+ }
1164
+ ptySessionsMap.delete(ptySessionKey);
1165
+ }, PTY_SESSION_TIMEOUT);
1166
+ }
1005
1167
  }
1006
1168
  });
1007
1169
 
@@ -1247,8 +1409,98 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, async (r
1247
1409
  app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => {
1248
1410
  try {
1249
1411
  const { projectName, sessionId } = req.params;
1412
+ const { provider = 'claude' } = req.query;
1250
1413
  const homeDir = os.homedir();
1251
1414
 
1415
+ // Allow only safe characters in sessionId
1416
+ const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
1417
+ if (!safeSessionId) {
1418
+ return res.status(400).json({ error: 'Invalid sessionId' });
1419
+ }
1420
+
1421
+ // Handle Cursor sessions - they use SQLite and don't have token usage info
1422
+ if (provider === 'cursor') {
1423
+ return res.json({
1424
+ used: 0,
1425
+ total: 0,
1426
+ breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
1427
+ unsupported: true,
1428
+ message: 'Token usage tracking not available for Cursor sessions'
1429
+ });
1430
+ }
1431
+
1432
+ // Handle Codex sessions
1433
+ if (provider === 'codex') {
1434
+ const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
1435
+
1436
+ // Find the session file by searching for the session ID
1437
+ const findSessionFile = async (dir) => {
1438
+ try {
1439
+ const entries = await fsPromises.readdir(dir, { withFileTypes: true });
1440
+ for (const entry of entries) {
1441
+ const fullPath = path.join(dir, entry.name);
1442
+ if (entry.isDirectory()) {
1443
+ const found = await findSessionFile(fullPath);
1444
+ if (found) return found;
1445
+ } else if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) {
1446
+ return fullPath;
1447
+ }
1448
+ }
1449
+ } catch (error) {
1450
+ // Skip directories we can't read
1451
+ }
1452
+ return null;
1453
+ };
1454
+
1455
+ const sessionFilePath = await findSessionFile(codexSessionsDir);
1456
+
1457
+ if (!sessionFilePath) {
1458
+ return res.status(404).json({ error: 'Codex session file not found', sessionId: safeSessionId });
1459
+ }
1460
+
1461
+ // Read and parse the Codex JSONL file
1462
+ let fileContent;
1463
+ try {
1464
+ fileContent = await fsPromises.readFile(sessionFilePath, 'utf8');
1465
+ } catch (error) {
1466
+ if (error.code === 'ENOENT') {
1467
+ return res.status(404).json({ error: 'Session file not found', path: sessionFilePath });
1468
+ }
1469
+ throw error;
1470
+ }
1471
+ const lines = fileContent.trim().split('\n');
1472
+ let totalTokens = 0;
1473
+ let contextWindow = 200000; // Default for Codex/OpenAI
1474
+
1475
+ // Find the latest token_count event with info (scan from end)
1476
+ for (let i = lines.length - 1; i >= 0; i--) {
1477
+ try {
1478
+ const entry = JSON.parse(lines[i]);
1479
+
1480
+ // Codex stores token info in event_msg with type: "token_count"
1481
+ if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
1482
+ const tokenInfo = entry.payload.info;
1483
+ if (tokenInfo.total_token_usage) {
1484
+ totalTokens = tokenInfo.total_token_usage.total_tokens || 0;
1485
+ }
1486
+ if (tokenInfo.model_context_window) {
1487
+ contextWindow = tokenInfo.model_context_window;
1488
+ }
1489
+ break; // Stop after finding the latest token count
1490
+ }
1491
+ } catch (parseError) {
1492
+ // Skip lines that can't be parsed
1493
+ continue;
1494
+ }
1495
+ }
1496
+
1497
+ return res.json({
1498
+ used: totalTokens,
1499
+ total: contextWindow
1500
+ });
1501
+ }
1502
+
1503
+ // Handle Claude sessions (default)
1252
1504
  // Extract actual project path
1253
1505
  let projectPath;
1254
1506
  try {
@@ -1264,11 +1516,6 @@ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authentica
1264
1516
  const encodedPath = projectPath.replace(/[\\/:\s~_]/g, '-');
1265
1517
  const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
1266
1518
 
1267
- // Allow only safe characters in sessionId
1268
- const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
1269
- if (!safeSessionId) {
1270
- return res.status(400).json({ error: 'Invalid sessionId' });
1271
- }
1272
1519
  const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
1273
1520
 
1274
1521
  // Constrain to projectDir