@siteboon/claude-code-ui 1.12.0 → 1.13.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
@@ -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
  });
@@ -712,6 +741,12 @@ function handleChatConnection(ws) {
712
741
  console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
713
742
  console.log('🤖 Model:', data.options?.model || 'default');
714
743
  await spawnCursor(data.command, data.options, ws);
744
+ } else if (data.type === 'codex-command') {
745
+ console.log('[DEBUG] Codex message:', data.command || '[Continue/Resume]');
746
+ console.log('📁 Project:', data.options?.projectPath || data.options?.cwd || 'Unknown');
747
+ console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
748
+ console.log('🤖 Model:', data.options?.model || 'default');
749
+ await queryCodex(data.command, data.options, ws);
715
750
  } else if (data.type === 'cursor-resume') {
716
751
  // Backward compatibility: treat as cursor-command with resume and no prompt
717
752
  console.log('[DEBUG] Cursor resume session (compat):', data.sessionId);
@@ -727,6 +762,8 @@ function handleChatConnection(ws) {
727
762
 
728
763
  if (provider === 'cursor') {
729
764
  success = abortCursorSession(data.sessionId);
765
+ } else if (provider === 'codex') {
766
+ success = abortCodexSession(data.sessionId);
730
767
  } else {
731
768
  // Use Claude Agents SDK
732
769
  success = await abortClaudeSDKSession(data.sessionId);
@@ -755,6 +792,8 @@ function handleChatConnection(ws) {
755
792
 
756
793
  if (provider === 'cursor') {
757
794
  isActive = isCursorSessionActive(sessionId);
795
+ } else if (provider === 'codex') {
796
+ isActive = isCodexSessionActive(sessionId);
758
797
  } else {
759
798
  // Use Claude Agents SDK
760
799
  isActive = isClaudeSDKSessionActive(sessionId);
@@ -770,7 +809,8 @@ function handleChatConnection(ws) {
770
809
  // Get all currently active sessions
771
810
  const activeSessions = {
772
811
  claude: getActiveClaudeSDKSessions(),
773
- cursor: getActiveCursorSessions()
812
+ cursor: getActiveCursorSessions(),
813
+ codex: getActiveCodexSessions()
774
814
  };
775
815
  ws.send(JSON.stringify({
776
816
  type: 'active-sessions',
@@ -797,6 +837,8 @@ function handleChatConnection(ws) {
797
837
  function handleShellConnection(ws) {
798
838
  console.log('🐚 Shell client connected');
799
839
  let shellProcess = null;
840
+ let ptySessionKey = null;
841
+ let outputBuffer = [];
800
842
 
801
843
  ws.on('message', async (message) => {
802
844
  try {
@@ -804,7 +846,6 @@ function handleShellConnection(ws) {
804
846
  console.log('📨 Shell message received:', data.type);
805
847
 
806
848
  if (data.type === 'init') {
807
- // Initialize shell with project path and session info
808
849
  const projectPath = data.projectPath || process.cwd();
809
850
  const sessionId = data.sessionId;
810
851
  const hasSession = data.hasSession;
@@ -812,6 +853,57 @@ function handleShellConnection(ws) {
812
853
  const initialCommand = data.initialCommand;
813
854
  const isPlainShell = data.isPlainShell || (!!initialCommand && !hasSession) || provider === 'plain-shell';
814
855
 
856
+ // Login commands (Claude/Cursor auth) should never reuse cached sessions
857
+ const isLoginCommand = initialCommand && (
858
+ initialCommand.includes('setup-token') ||
859
+ initialCommand.includes('cursor-agent login') ||
860
+ initialCommand.includes('auth login')
861
+ );
862
+
863
+ // Include command hash in session key so different commands get separate sessions
864
+ const commandSuffix = isPlainShell && initialCommand
865
+ ? `_cmd_${Buffer.from(initialCommand).toString('base64').slice(0, 16)}`
866
+ : '';
867
+ ptySessionKey = `${projectPath}_${sessionId || 'default'}${commandSuffix}`;
868
+
869
+ // Kill any existing login session before starting fresh
870
+ if (isLoginCommand) {
871
+ const oldSession = ptySessionsMap.get(ptySessionKey);
872
+ if (oldSession) {
873
+ console.log('🧹 Cleaning up existing login session:', ptySessionKey);
874
+ if (oldSession.timeoutId) clearTimeout(oldSession.timeoutId);
875
+ if (oldSession.pty && oldSession.pty.kill) oldSession.pty.kill();
876
+ ptySessionsMap.delete(ptySessionKey);
877
+ }
878
+ }
879
+
880
+ const existingSession = isLoginCommand ? null : ptySessionsMap.get(ptySessionKey);
881
+ if (existingSession) {
882
+ console.log('♻️ Reconnecting to existing PTY session:', ptySessionKey);
883
+ shellProcess = existingSession.pty;
884
+
885
+ clearTimeout(existingSession.timeoutId);
886
+
887
+ ws.send(JSON.stringify({
888
+ type: 'output',
889
+ data: `\x1b[36m[Reconnected to existing session]\x1b[0m\r\n`
890
+ }));
891
+
892
+ if (existingSession.buffer && existingSession.buffer.length > 0) {
893
+ console.log(`📜 Sending ${existingSession.buffer.length} buffered messages`);
894
+ existingSession.buffer.forEach(bufferedData => {
895
+ ws.send(JSON.stringify({
896
+ type: 'output',
897
+ data: bufferedData
898
+ }));
899
+ });
900
+ }
901
+
902
+ existingSession.ws = ws;
903
+
904
+ return;
905
+ }
906
+
815
907
  console.log('[INFO] Starting shell in:', projectPath);
816
908
  console.log('📋 Session info:', hasSession ? `Resume session ${sessionId}` : (isPlainShell ? 'Plain shell mode' : 'New session'));
817
909
  console.log('🤖 Provider:', isPlainShell ? 'plain-shell' : provider);
@@ -885,10 +977,15 @@ function handleShellConnection(ws) {
885
977
  const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
886
978
  const shellArgs = os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand];
887
979
 
980
+ // Use terminal dimensions from client if provided, otherwise use defaults
981
+ const termCols = data.cols || 80;
982
+ const termRows = data.rows || 24;
983
+ console.log('📐 Using terminal dimensions:', termCols, 'x', termRows);
984
+
888
985
  shellProcess = pty.spawn(shell, shellArgs, {
889
986
  name: 'xterm-256color',
890
- cols: 80,
891
- rows: 24,
987
+ cols: termCols,
988
+ rows: termRows,
892
989
  cwd: process.env.HOME || (os.platform() === 'win32' ? process.env.USERPROFILE : '/'),
893
990
  env: {
894
991
  ...process.env,
@@ -902,9 +999,28 @@ function handleShellConnection(ws) {
902
999
 
903
1000
  console.log('🟢 Shell process started with PTY, PID:', shellProcess.pid);
904
1001
 
1002
+ ptySessionsMap.set(ptySessionKey, {
1003
+ pty: shellProcess,
1004
+ ws: ws,
1005
+ buffer: [],
1006
+ timeoutId: null,
1007
+ projectPath,
1008
+ sessionId
1009
+ });
1010
+
905
1011
  // Handle data output
906
1012
  shellProcess.onData((data) => {
907
- if (ws.readyState === WebSocket.OPEN) {
1013
+ const session = ptySessionsMap.get(ptySessionKey);
1014
+ if (!session) return;
1015
+
1016
+ if (session.buffer.length < 5000) {
1017
+ session.buffer.push(data);
1018
+ } else {
1019
+ session.buffer.shift();
1020
+ session.buffer.push(data);
1021
+ }
1022
+
1023
+ if (session.ws && session.ws.readyState === WebSocket.OPEN) {
908
1024
  let outputData = data;
909
1025
 
910
1026
  // Check for various URL opening patterns
@@ -928,7 +1044,7 @@ function handleShellConnection(ws) {
928
1044
  console.log('[DEBUG] Detected URL for opening:', url);
929
1045
 
930
1046
  // Send URL opening message to client
931
- ws.send(JSON.stringify({
1047
+ session.ws.send(JSON.stringify({
932
1048
  type: 'url_open',
933
1049
  url: url
934
1050
  }));
@@ -941,7 +1057,7 @@ function handleShellConnection(ws) {
941
1057
  });
942
1058
 
943
1059
  // Send regular output
944
- ws.send(JSON.stringify({
1060
+ session.ws.send(JSON.stringify({
945
1061
  type: 'output',
946
1062
  data: outputData
947
1063
  }));
@@ -951,12 +1067,17 @@ function handleShellConnection(ws) {
951
1067
  // Handle process exit
952
1068
  shellProcess.onExit((exitCode) => {
953
1069
  console.log('🔚 Shell process exited with code:', exitCode.exitCode, 'signal:', exitCode.signal);
954
- if (ws.readyState === WebSocket.OPEN) {
955
- ws.send(JSON.stringify({
1070
+ const session = ptySessionsMap.get(ptySessionKey);
1071
+ if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {
1072
+ session.ws.send(JSON.stringify({
956
1073
  type: 'output',
957
1074
  data: `\r\n\x1b[33mProcess exited with code ${exitCode.exitCode}${exitCode.signal ? ` (${exitCode.signal})` : ''}\x1b[0m\r\n`
958
1075
  }));
959
1076
  }
1077
+ if (session && session.timeoutId) {
1078
+ clearTimeout(session.timeoutId);
1079
+ }
1080
+ ptySessionsMap.delete(ptySessionKey);
960
1081
  shellProcess = null;
961
1082
  });
962
1083
 
@@ -999,9 +1120,21 @@ function handleShellConnection(ws) {
999
1120
 
1000
1121
  ws.on('close', () => {
1001
1122
  console.log('🔌 Shell client disconnected');
1002
- if (shellProcess && shellProcess.kill) {
1003
- console.log('🔴 Killing shell process:', shellProcess.pid);
1004
- shellProcess.kill();
1123
+
1124
+ if (ptySessionKey) {
1125
+ const session = ptySessionsMap.get(ptySessionKey);
1126
+ if (session) {
1127
+ console.log('⏳ PTY session kept alive, will timeout in 30 minutes:', ptySessionKey);
1128
+ session.ws = null;
1129
+
1130
+ session.timeoutId = setTimeout(() => {
1131
+ console.log('⏰ PTY session timeout, killing process:', ptySessionKey);
1132
+ if (session.pty && session.pty.kill) {
1133
+ session.pty.kill();
1134
+ }
1135
+ ptySessionsMap.delete(ptySessionKey);
1136
+ }, PTY_SESSION_TIMEOUT);
1137
+ }
1005
1138
  }
1006
1139
  });
1007
1140
 
@@ -1247,8 +1380,98 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, async (r
1247
1380
  app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => {
1248
1381
  try {
1249
1382
  const { projectName, sessionId } = req.params;
1383
+ const { provider = 'claude' } = req.query;
1250
1384
  const homeDir = os.homedir();
1251
1385
 
1386
+ // Allow only safe characters in sessionId
1387
+ const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
1388
+ if (!safeSessionId) {
1389
+ return res.status(400).json({ error: 'Invalid sessionId' });
1390
+ }
1391
+
1392
+ // Handle Cursor sessions - they use SQLite and don't have token usage info
1393
+ if (provider === 'cursor') {
1394
+ return res.json({
1395
+ used: 0,
1396
+ total: 0,
1397
+ breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
1398
+ unsupported: true,
1399
+ message: 'Token usage tracking not available for Cursor sessions'
1400
+ });
1401
+ }
1402
+
1403
+ // Handle Codex sessions
1404
+ if (provider === 'codex') {
1405
+ const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
1406
+
1407
+ // Find the session file by searching for the session ID
1408
+ const findSessionFile = async (dir) => {
1409
+ try {
1410
+ const entries = await fsPromises.readdir(dir, { withFileTypes: true });
1411
+ for (const entry of entries) {
1412
+ const fullPath = path.join(dir, entry.name);
1413
+ if (entry.isDirectory()) {
1414
+ const found = await findSessionFile(fullPath);
1415
+ if (found) return found;
1416
+ } else if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) {
1417
+ return fullPath;
1418
+ }
1419
+ }
1420
+ } catch (error) {
1421
+ // Skip directories we can't read
1422
+ }
1423
+ return null;
1424
+ };
1425
+
1426
+ const sessionFilePath = await findSessionFile(codexSessionsDir);
1427
+
1428
+ if (!sessionFilePath) {
1429
+ return res.status(404).json({ error: 'Codex session file not found', sessionId: safeSessionId });
1430
+ }
1431
+
1432
+ // Read and parse the Codex JSONL file
1433
+ let fileContent;
1434
+ try {
1435
+ fileContent = await fsPromises.readFile(sessionFilePath, 'utf8');
1436
+ } catch (error) {
1437
+ if (error.code === 'ENOENT') {
1438
+ return res.status(404).json({ error: 'Session file not found', path: sessionFilePath });
1439
+ }
1440
+ throw error;
1441
+ }
1442
+ const lines = fileContent.trim().split('\n');
1443
+ let totalTokens = 0;
1444
+ let contextWindow = 200000; // Default for Codex/OpenAI
1445
+
1446
+ // Find the latest token_count event with info (scan from end)
1447
+ for (let i = lines.length - 1; i >= 0; i--) {
1448
+ try {
1449
+ const entry = JSON.parse(lines[i]);
1450
+
1451
+ // Codex stores token info in event_msg with type: "token_count"
1452
+ if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
1453
+ const tokenInfo = entry.payload.info;
1454
+ if (tokenInfo.total_token_usage) {
1455
+ totalTokens = tokenInfo.total_token_usage.total_tokens || 0;
1456
+ }
1457
+ if (tokenInfo.model_context_window) {
1458
+ contextWindow = tokenInfo.model_context_window;
1459
+ }
1460
+ break; // Stop after finding the latest token count
1461
+ }
1462
+ } catch (parseError) {
1463
+ // Skip lines that can't be parsed
1464
+ continue;
1465
+ }
1466
+ }
1467
+
1468
+ return res.json({
1469
+ used: totalTokens,
1470
+ total: contextWindow
1471
+ });
1472
+ }
1473
+
1474
+ // Handle Claude sessions (default)
1252
1475
  // Extract actual project path
1253
1476
  let projectPath;
1254
1477
  try {
@@ -1264,11 +1487,6 @@ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authentica
1264
1487
  const encodedPath = projectPath.replace(/[\\/:\s~_]/g, '-');
1265
1488
  const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
1266
1489
 
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
1490
  const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
1273
1491
 
1274
1492
  // Constrain to projectDir