@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.
@@ -266,8 +266,16 @@ async function extractProjectDirectory(projectName) {
266
266
  if (projectDirectoryCache.has(projectName)) {
267
267
  return projectDirectoryCache.get(projectName);
268
268
  }
269
-
270
-
269
+
270
+ // Check project config for originalPath (manually added projects via UI or platform)
271
+ // This handles projects with dashes in their directory names correctly
272
+ const config = await loadProjectConfig();
273
+ if (config[projectName]?.originalPath) {
274
+ const originalPath = config[projectName].originalPath;
275
+ projectDirectoryCache.set(projectName, originalPath);
276
+ return originalPath;
277
+ }
278
+
271
279
  const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
272
280
  const cwdCounts = new Map();
273
281
  let latestTimestamp = 0;
@@ -425,7 +433,15 @@ async function getProjects() {
425
433
  console.warn(`Could not load Cursor sessions for project ${entry.name}:`, e.message);
426
434
  project.cursorSessions = [];
427
435
  }
428
-
436
+
437
+ // Also fetch Codex sessions for this project
438
+ try {
439
+ project.codexSessions = await getCodexSessions(actualProjectDir);
440
+ } catch (e) {
441
+ console.warn(`Could not load Codex sessions for project ${entry.name}:`, e.message);
442
+ project.codexSessions = [];
443
+ }
444
+
429
445
  // Add TaskMaster detection
430
446
  try {
431
447
  const taskMasterResult = await detectTaskMasterFolder(actualProjectDir);
@@ -478,16 +494,24 @@ async function getProjects() {
478
494
  isCustomName: !!projectConfig.displayName,
479
495
  isManuallyAdded: true,
480
496
  sessions: [],
481
- cursorSessions: []
497
+ cursorSessions: [],
498
+ codexSessions: []
482
499
  };
483
-
500
+
484
501
  // Try to fetch Cursor sessions for manual projects too
485
502
  try {
486
503
  project.cursorSessions = await getCursorSessions(actualProjectDir);
487
504
  } catch (e) {
488
505
  console.warn(`Could not load Cursor sessions for manual project ${projectName}:`, e.message);
489
506
  }
490
-
507
+
508
+ // Try to fetch Codex sessions for manual projects too
509
+ try {
510
+ project.codexSessions = await getCodexSessions(actualProjectDir);
511
+ } catch (e) {
512
+ console.warn(`Could not load Codex sessions for manual project ${projectName}:`, e.message);
513
+ }
514
+
491
515
  // Add TaskMaster detection for manual projects
492
516
  try {
493
517
  const taskMasterResult = await detectTaskMasterFolder(actualProjectDir);
@@ -1141,6 +1165,420 @@ async function getCursorSessions(projectPath) {
1141
1165
  }
1142
1166
 
1143
1167
 
1168
+ // Fetch Codex sessions for a given project path
1169
+ async function getCodexSessions(projectPath) {
1170
+ try {
1171
+ const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
1172
+ const sessions = [];
1173
+
1174
+ // Check if the directory exists
1175
+ try {
1176
+ await fs.access(codexSessionsDir);
1177
+ } catch (error) {
1178
+ // No Codex sessions directory
1179
+ return [];
1180
+ }
1181
+
1182
+ // Recursively find all .jsonl files in the sessions directory
1183
+ const findJsonlFiles = async (dir) => {
1184
+ const files = [];
1185
+ try {
1186
+ const entries = await fs.readdir(dir, { withFileTypes: true });
1187
+ for (const entry of entries) {
1188
+ const fullPath = path.join(dir, entry.name);
1189
+ if (entry.isDirectory()) {
1190
+ files.push(...await findJsonlFiles(fullPath));
1191
+ } else if (entry.name.endsWith('.jsonl')) {
1192
+ files.push(fullPath);
1193
+ }
1194
+ }
1195
+ } catch (error) {
1196
+ // Skip directories we can't read
1197
+ }
1198
+ return files;
1199
+ };
1200
+
1201
+ const jsonlFiles = await findJsonlFiles(codexSessionsDir);
1202
+
1203
+ // Process each file to find sessions matching the project path
1204
+ for (const filePath of jsonlFiles) {
1205
+ try {
1206
+ const sessionData = await parseCodexSessionFile(filePath);
1207
+
1208
+ // Check if this session matches the project path
1209
+ if (sessionData && sessionData.cwd === projectPath) {
1210
+ sessions.push({
1211
+ id: sessionData.id,
1212
+ summary: sessionData.summary || 'Codex Session',
1213
+ messageCount: sessionData.messageCount || 0,
1214
+ lastActivity: sessionData.timestamp ? new Date(sessionData.timestamp) : new Date(),
1215
+ cwd: sessionData.cwd,
1216
+ model: sessionData.model,
1217
+ filePath: filePath,
1218
+ provider: 'codex'
1219
+ });
1220
+ }
1221
+ } catch (error) {
1222
+ console.warn(`Could not parse Codex session file ${filePath}:`, error.message);
1223
+ }
1224
+ }
1225
+
1226
+ // Sort sessions by last activity (newest first)
1227
+ sessions.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity));
1228
+
1229
+ // Return only the first 5 sessions for performance
1230
+ return sessions.slice(0, 5);
1231
+
1232
+ } catch (error) {
1233
+ console.error('Error fetching Codex sessions:', error);
1234
+ return [];
1235
+ }
1236
+ }
1237
+
1238
+ // Parse a Codex session JSONL file to extract metadata
1239
+ async function parseCodexSessionFile(filePath) {
1240
+ try {
1241
+ const fileStream = fsSync.createReadStream(filePath);
1242
+ const rl = readline.createInterface({
1243
+ input: fileStream,
1244
+ crlfDelay: Infinity
1245
+ });
1246
+
1247
+ let sessionMeta = null;
1248
+ let lastTimestamp = null;
1249
+ let lastUserMessage = null;
1250
+ let messageCount = 0;
1251
+
1252
+ for await (const line of rl) {
1253
+ if (line.trim()) {
1254
+ try {
1255
+ const entry = JSON.parse(line);
1256
+
1257
+ // Track timestamp
1258
+ if (entry.timestamp) {
1259
+ lastTimestamp = entry.timestamp;
1260
+ }
1261
+
1262
+ // Extract session metadata
1263
+ if (entry.type === 'session_meta' && entry.payload) {
1264
+ sessionMeta = {
1265
+ id: entry.payload.id,
1266
+ cwd: entry.payload.cwd,
1267
+ model: entry.payload.model || entry.payload.model_provider,
1268
+ timestamp: entry.timestamp,
1269
+ git: entry.payload.git
1270
+ };
1271
+ }
1272
+
1273
+ // Count messages and extract user messages for summary
1274
+ if (entry.type === 'event_msg' && entry.payload?.type === 'user_message') {
1275
+ messageCount++;
1276
+ if (entry.payload.text) {
1277
+ lastUserMessage = entry.payload.text;
1278
+ }
1279
+ }
1280
+
1281
+ if (entry.type === 'response_item' && entry.payload?.type === 'message') {
1282
+ messageCount++;
1283
+ }
1284
+
1285
+ } catch (parseError) {
1286
+ // Skip malformed lines
1287
+ }
1288
+ }
1289
+ }
1290
+
1291
+ if (sessionMeta) {
1292
+ return {
1293
+ ...sessionMeta,
1294
+ timestamp: lastTimestamp || sessionMeta.timestamp,
1295
+ summary: lastUserMessage ?
1296
+ (lastUserMessage.length > 50 ? lastUserMessage.substring(0, 50) + '...' : lastUserMessage) :
1297
+ 'Codex Session',
1298
+ messageCount
1299
+ };
1300
+ }
1301
+
1302
+ return null;
1303
+
1304
+ } catch (error) {
1305
+ console.error('Error parsing Codex session file:', error);
1306
+ return null;
1307
+ }
1308
+ }
1309
+
1310
+ // Get messages for a specific Codex session
1311
+ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
1312
+ try {
1313
+ const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
1314
+
1315
+ // Find the session file by searching for the session ID
1316
+ const findSessionFile = async (dir) => {
1317
+ try {
1318
+ const entries = await fs.readdir(dir, { withFileTypes: true });
1319
+ for (const entry of entries) {
1320
+ const fullPath = path.join(dir, entry.name);
1321
+ if (entry.isDirectory()) {
1322
+ const found = await findSessionFile(fullPath);
1323
+ if (found) return found;
1324
+ } else if (entry.name.includes(sessionId) && entry.name.endsWith('.jsonl')) {
1325
+ return fullPath;
1326
+ }
1327
+ }
1328
+ } catch (error) {
1329
+ // Skip directories we can't read
1330
+ }
1331
+ return null;
1332
+ };
1333
+
1334
+ const sessionFilePath = await findSessionFile(codexSessionsDir);
1335
+
1336
+ if (!sessionFilePath) {
1337
+ console.warn(`Codex session file not found for session ${sessionId}`);
1338
+ return { messages: [], total: 0, hasMore: false };
1339
+ }
1340
+
1341
+ const messages = [];
1342
+ let tokenUsage = null;
1343
+ const fileStream = fsSync.createReadStream(sessionFilePath);
1344
+ const rl = readline.createInterface({
1345
+ input: fileStream,
1346
+ crlfDelay: Infinity
1347
+ });
1348
+
1349
+ // Helper to extract text from Codex content array
1350
+ const extractText = (content) => {
1351
+ if (!Array.isArray(content)) return content;
1352
+ return content
1353
+ .map(item => {
1354
+ if (item.type === 'input_text' || item.type === 'output_text') {
1355
+ return item.text;
1356
+ }
1357
+ if (item.type === 'text') {
1358
+ return item.text;
1359
+ }
1360
+ return '';
1361
+ })
1362
+ .filter(Boolean)
1363
+ .join('\n');
1364
+ };
1365
+
1366
+ for await (const line of rl) {
1367
+ if (line.trim()) {
1368
+ try {
1369
+ const entry = JSON.parse(line);
1370
+
1371
+ // Extract token usage from token_count events (keep latest)
1372
+ if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
1373
+ const info = entry.payload.info;
1374
+ if (info.total_token_usage) {
1375
+ tokenUsage = {
1376
+ used: info.total_token_usage.total_tokens || 0,
1377
+ total: info.model_context_window || 200000
1378
+ };
1379
+ }
1380
+ }
1381
+
1382
+ // Extract messages from response_item
1383
+ if (entry.type === 'response_item' && entry.payload?.type === 'message') {
1384
+ const content = entry.payload.content;
1385
+ const role = entry.payload.role || 'assistant';
1386
+ const textContent = extractText(content);
1387
+
1388
+ // Skip system context messages (environment_context)
1389
+ if (textContent?.includes('<environment_context>')) {
1390
+ continue;
1391
+ }
1392
+
1393
+ // Only add if there's actual content
1394
+ if (textContent?.trim()) {
1395
+ messages.push({
1396
+ type: role === 'user' ? 'user' : 'assistant',
1397
+ timestamp: entry.timestamp,
1398
+ message: {
1399
+ role: role,
1400
+ content: textContent
1401
+ }
1402
+ });
1403
+ }
1404
+ }
1405
+
1406
+ if (entry.type === 'response_item' && entry.payload?.type === 'reasoning') {
1407
+ const summaryText = entry.payload.summary
1408
+ ?.map(s => s.text)
1409
+ .filter(Boolean)
1410
+ .join('\n');
1411
+ if (summaryText?.trim()) {
1412
+ messages.push({
1413
+ type: 'thinking',
1414
+ timestamp: entry.timestamp,
1415
+ message: {
1416
+ role: 'assistant',
1417
+ content: summaryText
1418
+ }
1419
+ });
1420
+ }
1421
+ }
1422
+
1423
+ if (entry.type === 'response_item' && entry.payload?.type === 'function_call') {
1424
+ let toolName = entry.payload.name;
1425
+ let toolInput = entry.payload.arguments;
1426
+
1427
+ // Map Codex tool names to Claude equivalents
1428
+ if (toolName === 'shell_command') {
1429
+ toolName = 'Bash';
1430
+ try {
1431
+ const args = JSON.parse(entry.payload.arguments);
1432
+ toolInput = JSON.stringify({ command: args.command });
1433
+ } catch (e) {
1434
+ // Keep original if parsing fails
1435
+ }
1436
+ }
1437
+
1438
+ messages.push({
1439
+ type: 'tool_use',
1440
+ timestamp: entry.timestamp,
1441
+ toolName: toolName,
1442
+ toolInput: toolInput,
1443
+ toolCallId: entry.payload.call_id
1444
+ });
1445
+ }
1446
+
1447
+ if (entry.type === 'response_item' && entry.payload?.type === 'function_call_output') {
1448
+ messages.push({
1449
+ type: 'tool_result',
1450
+ timestamp: entry.timestamp,
1451
+ toolCallId: entry.payload.call_id,
1452
+ output: entry.payload.output
1453
+ });
1454
+ }
1455
+
1456
+ if (entry.type === 'response_item' && entry.payload?.type === 'custom_tool_call') {
1457
+ const toolName = entry.payload.name || 'custom_tool';
1458
+ const input = entry.payload.input || '';
1459
+
1460
+ if (toolName === 'apply_patch') {
1461
+ // Parse Codex patch format and convert to Claude Edit format
1462
+ const fileMatch = input.match(/\*\*\* Update File: (.+)/);
1463
+ const filePath = fileMatch ? fileMatch[1].trim() : 'unknown';
1464
+
1465
+ // Extract old and new content from patch
1466
+ const lines = input.split('\n');
1467
+ const oldLines = [];
1468
+ const newLines = [];
1469
+
1470
+ for (const line of lines) {
1471
+ if (line.startsWith('-') && !line.startsWith('---')) {
1472
+ oldLines.push(line.substring(1));
1473
+ } else if (line.startsWith('+') && !line.startsWith('+++')) {
1474
+ newLines.push(line.substring(1));
1475
+ }
1476
+ }
1477
+
1478
+ messages.push({
1479
+ type: 'tool_use',
1480
+ timestamp: entry.timestamp,
1481
+ toolName: 'Edit',
1482
+ toolInput: JSON.stringify({
1483
+ file_path: filePath,
1484
+ old_string: oldLines.join('\n'),
1485
+ new_string: newLines.join('\n')
1486
+ }),
1487
+ toolCallId: entry.payload.call_id
1488
+ });
1489
+ } else {
1490
+ messages.push({
1491
+ type: 'tool_use',
1492
+ timestamp: entry.timestamp,
1493
+ toolName: toolName,
1494
+ toolInput: input,
1495
+ toolCallId: entry.payload.call_id
1496
+ });
1497
+ }
1498
+ }
1499
+
1500
+ if (entry.type === 'response_item' && entry.payload?.type === 'custom_tool_call_output') {
1501
+ messages.push({
1502
+ type: 'tool_result',
1503
+ timestamp: entry.timestamp,
1504
+ toolCallId: entry.payload.call_id,
1505
+ output: entry.payload.output || ''
1506
+ });
1507
+ }
1508
+
1509
+ } catch (parseError) {
1510
+ // Skip malformed lines
1511
+ }
1512
+ }
1513
+ }
1514
+
1515
+ // Sort by timestamp
1516
+ messages.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0));
1517
+
1518
+ const total = messages.length;
1519
+
1520
+ // Apply pagination if limit is specified
1521
+ if (limit !== null) {
1522
+ const startIndex = Math.max(0, total - offset - limit);
1523
+ const endIndex = total - offset;
1524
+ const paginatedMessages = messages.slice(startIndex, endIndex);
1525
+ const hasMore = startIndex > 0;
1526
+
1527
+ return {
1528
+ messages: paginatedMessages,
1529
+ total,
1530
+ hasMore,
1531
+ offset,
1532
+ limit,
1533
+ tokenUsage
1534
+ };
1535
+ }
1536
+
1537
+ return { messages, tokenUsage };
1538
+
1539
+ } catch (error) {
1540
+ console.error(`Error reading Codex session messages for ${sessionId}:`, error);
1541
+ return { messages: [], total: 0, hasMore: false };
1542
+ }
1543
+ }
1544
+
1545
+ async function deleteCodexSession(sessionId) {
1546
+ try {
1547
+ const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
1548
+
1549
+ const findJsonlFiles = async (dir) => {
1550
+ const files = [];
1551
+ try {
1552
+ const entries = await fs.readdir(dir, { withFileTypes: true });
1553
+ for (const entry of entries) {
1554
+ const fullPath = path.join(dir, entry.name);
1555
+ if (entry.isDirectory()) {
1556
+ files.push(...await findJsonlFiles(fullPath));
1557
+ } else if (entry.name.endsWith('.jsonl')) {
1558
+ files.push(fullPath);
1559
+ }
1560
+ }
1561
+ } catch (error) {}
1562
+ return files;
1563
+ };
1564
+
1565
+ const jsonlFiles = await findJsonlFiles(codexSessionsDir);
1566
+
1567
+ for (const filePath of jsonlFiles) {
1568
+ const sessionData = await parseCodexSessionFile(filePath);
1569
+ if (sessionData && sessionData.id === sessionId) {
1570
+ await fs.unlink(filePath);
1571
+ return true;
1572
+ }
1573
+ }
1574
+
1575
+ throw new Error(`Codex session file not found for session ${sessionId}`);
1576
+ } catch (error) {
1577
+ console.error(`Error deleting Codex session ${sessionId}:`, error);
1578
+ throw error;
1579
+ }
1580
+ }
1581
+
1144
1582
  export {
1145
1583
  getProjects,
1146
1584
  getSessions,
@@ -1154,5 +1592,8 @@ export {
1154
1592
  loadProjectConfig,
1155
1593
  saveProjectConfig,
1156
1594
  extractProjectDirectory,
1157
- clearProjectDirectoryCache
1595
+ clearProjectDirectoryCache,
1596
+ getCodexSessions,
1597
+ getCodexSessionMessages,
1598
+ deleteCodexSession
1158
1599
  };
@@ -4,16 +4,45 @@ import path from 'path';
4
4
  import os from 'os';
5
5
  import { promises as fs } from 'fs';
6
6
  import crypto from 'crypto';
7
- import { apiKeysDb, githubTokensDb } from '../database/db.js';
7
+ import { userDb, apiKeysDb, githubTokensDb } from '../database/db.js';
8
8
  import { addProjectManually } from '../projects.js';
9
9
  import { queryClaudeSDK } from '../claude-sdk.js';
10
10
  import { spawnCursor } from '../cursor-cli.js';
11
+ import { queryCodex } from '../openai-codex.js';
11
12
  import { Octokit } from '@octokit/rest';
13
+ import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
12
14
 
13
15
  const router = express.Router();
14
16
 
15
- // Middleware to validate API key for external requests
17
+ /**
18
+ * Middleware to authenticate agent API requests.
19
+ *
20
+ * Supports two authentication modes:
21
+ * 1. Platform mode (VITE_IS_PLATFORM=true): For managed/hosted deployments where
22
+ * authentication is handled by an external proxy. Requests are trusted and
23
+ * the default user context is used.
24
+ *
25
+ * 2. API key mode (default): For self-hosted deployments where users authenticate
26
+ * via API keys created in the UI. Keys are validated against the local database.
27
+ */
16
28
  const validateExternalApiKey = (req, res, next) => {
29
+ // Platform mode: Authentication is handled externally (e.g., by a proxy layer).
30
+ // Trust the request and use the default user context.
31
+ if (process.env.VITE_IS_PLATFORM === 'true') {
32
+ try {
33
+ const user = userDb.getFirstUser();
34
+ if (!user) {
35
+ return res.status(500).json({ error: 'Platform mode: No user found in database' });
36
+ }
37
+ req.user = user;
38
+ return next();
39
+ } catch (error) {
40
+ console.error('Platform mode error:', error);
41
+ return res.status(500).json({ error: 'Platform mode: Failed to fetch user' });
42
+ }
43
+ }
44
+
45
+ // Self-hosted mode: Validate API key from header or query parameter
17
46
  const apiKey = req.headers['x-api-key'] || req.query.apiKey;
18
47
 
19
48
  if (!apiKey) {
@@ -422,6 +451,7 @@ class SSEStreamWriter {
422
451
  constructor(res) {
423
452
  this.res = res;
424
453
  this.sessionId = null;
454
+ this.isSSEStreamWriter = true; // Marker for transport detection
425
455
  }
426
456
 
427
457
  send(data) {
@@ -429,7 +459,7 @@ class SSEStreamWriter {
429
459
  return;
430
460
  }
431
461
 
432
- // Format as SSE
462
+ // Format as SSE - providers send raw objects, we stringify
433
463
  this.res.write(`data: ${JSON.stringify(data)}\n\n`);
434
464
  }
435
465
 
@@ -606,9 +636,14 @@ class ResponseCollector {
606
636
  * - true: Returns text/event-stream with incremental updates
607
637
  * - false: Returns complete JSON response after completion
608
638
  *
609
- * @param {string} model - (Optional) Model identifier for Cursor provider.
610
- * Only applicable when provider='cursor'.
611
- * Examples: 'gpt-4', 'claude-3-opus', etc.
639
+ * @param {string} model - (Optional) Model identifier for providers.
640
+ *
641
+ * Claude models: 'sonnet' (default), 'opus', 'haiku', 'opusplan', 'sonnet[1m]'
642
+ * Cursor models: 'gpt-5' (default), 'gpt-5.2', 'gpt-5.2-high', 'sonnet-4.5', 'opus-4.5',
643
+ * 'gemini-3-pro', 'composer-1', 'auto', 'gpt-5.1', 'gpt-5.1-high',
644
+ * 'gpt-5.1-codex', 'gpt-5.1-codex-high', 'gpt-5.1-codex-max',
645
+ * 'gpt-5.1-codex-max-high', 'opus-4.1', 'grok', and thinking variants
646
+ * Codex models: 'gpt-5.2' (default), 'gpt-5.1-codex-max', 'o3', 'o4-mini'
612
647
  *
613
648
  * @param {boolean} cleanup - (Optional) Auto-cleanup project directory after completion.
614
649
  * Default: true
@@ -819,8 +854,8 @@ router.post('/', validateExternalApiKey, async (req, res) => {
819
854
  return res.status(400).json({ error: 'message is required' });
820
855
  }
821
856
 
822
- if (!['claude', 'cursor'].includes(provider)) {
823
- return res.status(400).json({ error: 'provider must be "claude" or "cursor"' });
857
+ if (!['claude', 'cursor', 'codex'].includes(provider)) {
858
+ return res.status(400).json({ error: 'provider must be "claude", "cursor", or "codex"' });
824
859
  }
825
860
 
826
861
  // Validate GitHub branch/PR creation requirements
@@ -911,6 +946,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
911
946
  projectPath: finalProjectPath,
912
947
  cwd: finalProjectPath,
913
948
  sessionId: null, // New session
949
+ model: model,
914
950
  permissionMode: 'bypassPermissions' // Bypass all permissions for API calls
915
951
  }, writer);
916
952
 
@@ -924,6 +960,16 @@ router.post('/', validateExternalApiKey, async (req, res) => {
924
960
  model: model || undefined,
925
961
  skipPermissions: true // Bypass permissions for Cursor
926
962
  }, writer);
963
+ } else if (provider === 'codex') {
964
+ console.log('🤖 Starting Codex SDK session');
965
+
966
+ await queryCodex(message.trim(), {
967
+ projectPath: finalProjectPath,
968
+ cwd: finalProjectPath,
969
+ sessionId: null,
970
+ model: model || CODEX_MODELS.DEFAULT,
971
+ permissionMode: 'bypassPermissions'
972
+ }, writer);
927
973
  }
928
974
 
929
975
  // Handle GitHub branch and PR creation after successful agent completion