Merge branch 'main' into main

This commit is contained in:
viper151
2025-12-31 08:48:30 +01:00
committed by GitHub
36 changed files with 4145 additions and 1269 deletions

View File

@@ -16,6 +16,7 @@ import { query } from '@anthropic-ai/claude-agent-sdk';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { CLAUDE_MODELS } from '../shared/modelConstants.js';
// Session tracking: Map of session IDs to active query instances
const activeSessions = new Map();
@@ -57,7 +58,7 @@ function mapCliOptionsToSDK(options = {}) {
// Add plan mode default tools
if (permissionMode === 'plan') {
const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite'];
const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch'];
for (const tool of planModeTools) {
if (!allowedTools.includes(tool)) {
allowedTools.push(tool);
@@ -76,8 +77,9 @@ function mapCliOptionsToSDK(options = {}) {
}
// Map model (default to sonnet)
// Map model (default to sonnet)
sdkOptions.model = options.model || 'sonnet';
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m]
sdkOptions.model = options.model || CLAUDE_MODELS.DEFAULT;
console.log(`Using model: ${sdkOptions.model}`);
// Map system prompt configuration
sdkOptions.systemPrompt = {
@@ -183,7 +185,7 @@ function extractTokenBudget(resultMessage) {
// This is the user's budget limit, not the model's context window
const contextWindow = parseInt(process.env.CONTEXT_WINDOW) || 160000;
console.log(`📊 Token calculation: input=${inputTokens}, output=${outputTokens}, cache=${cacheReadTokens + cacheCreationTokens}, total=${totalUsed}/${contextWindow}`);
console.log(`Token calculation: input=${inputTokens}, output=${outputTokens}, cache=${cacheReadTokens + cacheCreationTokens}, total=${totalUsed}/${contextWindow}`);
return {
used: totalUsed,
@@ -239,7 +241,7 @@ async function handleImages(command, images, cwd) {
modifiedCommand = command + imageNote;
}
console.log(`📸 Processed ${tempImagePaths.length} images to temp directory: ${tempDir}`);
console.log(`Processed ${tempImagePaths.length} images to temp directory: ${tempDir}`);
return { modifiedCommand, tempImagePaths, tempDir };
} catch (error) {
console.error('Error processing images for SDK:', error);
@@ -272,7 +274,7 @@ async function cleanupTempFiles(tempImagePaths, tempDir) {
);
}
console.log(`🧹 Cleaned up ${tempImagePaths.length} temp image files`);
console.log(`Cleaned up ${tempImagePaths.length} temp image files`);
} catch (error) {
console.error('Error during temp file cleanup:', error);
}
@@ -292,7 +294,7 @@ async function loadMcpConfig(cwd) {
await fs.access(claudeConfigPath);
} catch (error) {
// File doesn't exist, return null
console.log('📡 No ~/.claude.json found, proceeding without MCP servers');
console.log('No ~/.claude.json found, proceeding without MCP servers');
return null;
}
@@ -302,7 +304,7 @@ async function loadMcpConfig(cwd) {
const configContent = await fs.readFile(claudeConfigPath, 'utf8');
claudeConfig = JSON.parse(configContent);
} catch (error) {
console.error('Failed to parse ~/.claude.json:', error.message);
console.error('Failed to parse ~/.claude.json:', error.message);
return null;
}
@@ -312,7 +314,7 @@ async function loadMcpConfig(cwd) {
// Add global MCP servers
if (claudeConfig.mcpServers && typeof claudeConfig.mcpServers === 'object') {
mcpServers = { ...claudeConfig.mcpServers };
console.log(`📡 Loaded ${Object.keys(mcpServers).length} global MCP servers`);
console.log(`Loaded ${Object.keys(mcpServers).length} global MCP servers`);
}
// Add/override with project-specific MCP servers
@@ -320,20 +322,20 @@ async function loadMcpConfig(cwd) {
const projectConfig = claudeConfig.claudeProjects[cwd];
if (projectConfig && projectConfig.mcpServers && typeof projectConfig.mcpServers === 'object') {
mcpServers = { ...mcpServers, ...projectConfig.mcpServers };
console.log(`📡 Loaded ${Object.keys(projectConfig.mcpServers).length} project-specific MCP servers`);
console.log(`Loaded ${Object.keys(projectConfig.mcpServers).length} project-specific MCP servers`);
}
}
// Return null if no servers found
if (Object.keys(mcpServers).length === 0) {
console.log('📡 No MCP servers configured');
console.log('No MCP servers configured');
return null;
}
console.log(`Total MCP servers loaded: ${Object.keys(mcpServers).length}`);
console.log(`Total MCP servers loaded: ${Object.keys(mcpServers).length}`);
return mcpServers;
} catch (error) {
console.error('Error loading MCP config:', error.message);
console.error('Error loading MCP config:', error.message);
return null;
}
}
@@ -380,7 +382,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
}
// Process streaming messages
console.log('🔄 Starting async generator loop for session:', capturedSessionId || 'NEW');
console.log('Starting async generator loop for session:', capturedSessionId || 'NEW');
for await (const message of queryInstance) {
// Capture session ID from first message
if (message.session_id && !capturedSessionId) {
@@ -396,33 +398,33 @@ async function queryClaudeSDK(command, options = {}, ws) {
// Send session-created event only once for new sessions
if (!sessionId && !sessionCreatedSent) {
sessionCreatedSent = true;
ws.send(JSON.stringify({
ws.send({
type: 'session-created',
sessionId: capturedSessionId
}));
});
} else {
console.log('⚠️ Not sending session-created. sessionId:', sessionId, 'sessionCreatedSent:', sessionCreatedSent);
console.log('Not sending session-created. sessionId:', sessionId, 'sessionCreatedSent:', sessionCreatedSent);
}
} else {
console.log('⚠️ No session_id in message or already captured. message.session_id:', message.session_id, 'capturedSessionId:', capturedSessionId);
console.log('No session_id in message or already captured. message.session_id:', message.session_id, 'capturedSessionId:', capturedSessionId);
}
// Transform and send message to WebSocket
const transformedMessage = transformMessage(message);
ws.send(JSON.stringify({
ws.send({
type: 'claude-response',
data: transformedMessage
}));
});
// Extract and send token budget updates from result messages
if (message.type === 'result') {
const tokenBudget = extractTokenBudget(message);
if (tokenBudget) {
console.log('📊 Token budget from modelUsage:', tokenBudget);
ws.send(JSON.stringify({
console.log('Token budget from modelUsage:', tokenBudget);
ws.send({
type: 'token-budget',
data: tokenBudget
}));
});
}
}
}
@@ -436,14 +438,14 @@ async function queryClaudeSDK(command, options = {}, ws) {
await cleanupTempFiles(tempImagePaths, tempDir);
// Send completion event
console.log('Streaming complete, sending claude-complete event');
ws.send(JSON.stringify({
console.log('Streaming complete, sending claude-complete event');
ws.send({
type: 'claude-complete',
sessionId: capturedSessionId,
exitCode: 0,
isNewSession: !sessionId && !!command
}));
console.log('📤 claude-complete event sent');
});
console.log('claude-complete event sent');
} catch (error) {
console.error('SDK query error:', error);
@@ -457,10 +459,10 @@ async function queryClaudeSDK(command, options = {}, ws) {
await cleanupTempFiles(tempImagePaths, tempDir);
// Send error to WebSocket
ws.send(JSON.stringify({
ws.send({
type: 'claude-error',
error: error.message
}));
});
throw error;
}
@@ -480,7 +482,7 @@ async function abortClaudeSDKSession(sessionId) {
}
try {
console.log(`🛑 Aborting SDK session: ${sessionId}`);
console.log(`Aborting SDK session: ${sessionId}`);
// Call interrupt() on the query instance
await session.instance.interrupt();

View File

@@ -14,6 +14,7 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
@@ -115,7 +116,7 @@ function showStatus() {
console.log(` CONTEXT_WINDOW: ${c.dim(process.env.CONTEXT_WINDOW || '160000 (default)')}`);
// Claude projects folder
const claudeProjectsPath = path.join(process.env.HOME, '.claude', 'projects');
const claudeProjectsPath = path.join(os.homedir(), '.claude', 'projects');
const projectsExists = fs.existsSync(claudeProjectsPath);
console.log(`\n${c.info('[INFO]')} Claude Projects Folder:`);
console.log(` ${c.dim(claudeProjectsPath)}`);
@@ -130,10 +131,10 @@ function showStatus() {
console.log('\n' + c.dim('═'.repeat(60)));
console.log(`\n${c.tip('[TIP]')} Hints:`);
console.log(` ${c.dim('>')} Set DATABASE_PATH env variable to use a custom database location`);
console.log(` ${c.dim('>')} Create .env file in installation directory for persistent config`);
console.log(` ${c.dim('>')} Run "claude-code-ui" or "cloudcli start" to start the server`);
console.log(` ${c.dim('>')} Access the UI at http://localhost:3001 (or custom PORT)\n`);
console.log(` ${c.dim('>')} Use ${c.bright('cloudcli --port 8080')} to run on a custom port`);
console.log(` ${c.dim('>')} Use ${c.bright('cloudcli --database-path /path/to/db')} for custom database`);
console.log(` ${c.dim('>')} Run ${c.bright('cloudcli help')} for all options`);
console.log(` ${c.dim('>')} Access the UI at http://localhost:${process.env.PORT || '3001'}\n`);
}
// Show help
@@ -144,8 +145,8 @@ function showHelp() {
╚═══════════════════════════════════════════════════════════════╝
Usage:
claude-code-ui [command]
cloudcli [command]
claude-code-ui [command] [options]
cloudcli [command] [options]
Commands:
start Start the Claude Code UI server (default)
@@ -153,10 +154,18 @@ Commands:
help Show this help information
version Show version information
Options:
-p, --port <port> Set server port (default: 3001)
--database-path <path> Set custom database location
-h, --help Show this help information
-v, --version Show version information
Examples:
$ claude-code-ui # Start the server
$ cloudcli status # Show configuration
$ cloudcli help # Show help
$ cloudcli # Start with defaults
$ cloudcli --port 8080 # Start on port 8080
$ cloudcli -p 3000 # Short form for port
$ cloudcli start --port 4000 # Explicit start command
$ cloudcli status # Show configuration
Environment Variables:
PORT Set server port (default: 3001)
@@ -164,11 +173,6 @@ Environment Variables:
CLAUDE_CLI_PATH Set custom Claude CLI path
CONTEXT_WINDOW Set context window size (default: 160000)
Configuration:
Create a .env file in the installation directory to set
persistent environment variables. Use 'cloudcli status' to
see the installation directory path.
Documentation:
${packageJson.homepage || 'https://github.com/siteboon/claudecodeui'}
@@ -188,10 +192,45 @@ async function startServer() {
await import('./index.js');
}
// Parse CLI arguments
function parseArgs(args) {
const parsed = { command: 'start', options: {} };
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--port' || arg === '-p') {
parsed.options.port = args[++i];
} else if (arg.startsWith('--port=')) {
parsed.options.port = arg.split('=')[1];
} else if (arg === '--database-path') {
parsed.options.databasePath = args[++i];
} else if (arg.startsWith('--database-path=')) {
parsed.options.databasePath = arg.split('=')[1];
} else if (arg === '--help' || arg === '-h') {
parsed.command = 'help';
} else if (arg === '--version' || arg === '-v') {
parsed.command = 'version';
} else if (!arg.startsWith('-')) {
parsed.command = arg;
}
}
return parsed;
}
// Main CLI handler
async function main() {
const args = process.argv.slice(2);
const command = args[0] || 'start';
const { command, options } = parseArgs(args);
// Apply CLI options to environment variables
if (options.port) {
process.env.PORT = options.port;
}
if (options.databasePath) {
process.env.DATABASE_PATH = options.databasePath;
}
switch (command) {
case 'start':

View File

@@ -102,29 +102,29 @@ async function spawnCursor(command, options = {}, ws) {
// Send session-created event only once for new sessions
if (!sessionId && !sessionCreatedSent) {
sessionCreatedSent = true;
ws.send(JSON.stringify({
ws.send({
type: 'session-created',
sessionId: capturedSessionId,
model: response.model,
cwd: response.cwd
}));
});
}
}
// Send system info to frontend
ws.send(JSON.stringify({
ws.send({
type: 'cursor-system',
data: response
}));
});
}
break;
case 'user':
// Forward user message
ws.send(JSON.stringify({
ws.send({
type: 'cursor-user',
data: response
}));
});
break;
case 'assistant':
@@ -134,7 +134,7 @@ async function spawnCursor(command, options = {}, ws) {
messageBuffer += textContent;
// Send as Claude-compatible format for frontend
ws.send(JSON.stringify({
ws.send({
type: 'claude-response',
data: {
type: 'content_block_delta',
@@ -143,7 +143,7 @@ async function spawnCursor(command, options = {}, ws) {
text: textContent
}
}
}));
});
}
break;
@@ -153,37 +153,37 @@ async function spawnCursor(command, options = {}, ws) {
// Send final message if we have buffered content
if (messageBuffer) {
ws.send(JSON.stringify({
ws.send({
type: 'claude-response',
data: {
type: 'content_block_stop'
}
}));
});
}
// Send completion event
ws.send(JSON.stringify({
ws.send({
type: 'cursor-result',
sessionId: capturedSessionId || sessionId,
data: response,
success: response.subtype === 'success'
}));
});
break;
default:
// Forward any other message types
ws.send(JSON.stringify({
ws.send({
type: 'cursor-response',
data: response
}));
});
}
} catch (parseError) {
console.log('📄 Non-JSON response:', line);
// If not JSON, send as raw text
ws.send(JSON.stringify({
ws.send({
type: 'cursor-output',
data: line
}));
});
}
}
});
@@ -191,10 +191,10 @@ async function spawnCursor(command, options = {}, ws) {
// Handle stderr
cursorProcess.stderr.on('data', (data) => {
console.error('Cursor CLI stderr:', data.toString());
ws.send(JSON.stringify({
ws.send({
type: 'cursor-error',
error: data.toString()
}));
});
});
// Handle process completion
@@ -205,12 +205,12 @@ async function spawnCursor(command, options = {}, ws) {
const finalSessionId = capturedSessionId || sessionId || processKey;
activeCursorProcesses.delete(finalSessionId);
ws.send(JSON.stringify({
ws.send({
type: 'claude-complete',
sessionId: finalSessionId,
exitCode: code,
isNewSession: !sessionId && !!command // Flag to indicate this was a new session
}));
});
if (code === 0) {
resolve();
@@ -226,12 +226,12 @@ async function spawnCursor(command, options = {}, ws) {
// Clean up process reference on error
const finalSessionId = capturedSessionId || sessionId || processKey;
activeCursorProcesses.delete(finalSessionId);
ws.send(JSON.stringify({
ws.send({
type: 'cursor-error',
error: error.message
}));
});
reject(error);
});

View File

@@ -60,6 +60,7 @@ import mime from 'mime-types';
import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js';
import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions } from './claude-sdk.js';
import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js';
import gitRoutes from './routes/git.js';
import authRoutes from './routes/auth.js';
import mcpRoutes from './routes/mcp.js';
@@ -72,6 +73,7 @@ import agentRoutes from './routes/agent.js';
import projectsRoutes from './routes/projects.js';
import cliAuthRoutes from './routes/cli-auth.js';
import userRoutes from './routes/user.js';
import codexRoutes from './routes/codex.js';
import { initializeDatabase } from './database/db.js';
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
@@ -82,7 +84,7 @@ const connectedClients = new Set();
// Setup file system watcher for Claude projects folder using chokidar
async function setupProjectsWatcher() {
const chokidar = (await import('chokidar')).default;
const claudeProjectsPath = path.join(process.env.HOME, '.claude', 'projects');
const claudeProjectsPath = path.join(os.homedir(), '.claude', 'projects');
if (projectsWatcher) {
projectsWatcher.close();
@@ -211,7 +213,17 @@ const wss = new WebSocketServer({
app.locals.wss = wss;
app.use(cors());
app.use(express.json({ limit: '50mb' }));
app.use(express.json({
limit: '50mb',
type: (req) => {
// Skip multipart/form-data requests (for file uploads like images)
const contentType = req.headers['content-type'] || '';
if (contentType.includes('multipart/form-data')) {
return false;
}
return contentType.includes('json');
}
}));
app.use(express.urlencoded({ limit: '50mb', extended: true }));
// Public health check endpoint (no authentication required)
@@ -258,6 +270,9 @@ app.use('/api/cli', authenticateToken, cliAuthRoutes);
// User API Routes (protected)
app.use('/api/user', authenticateToken, userRoutes);
// Codex API Routes (protected)
app.use('/api/codex', authenticateToken, codexRoutes);
// Agent API Routes (uses API key authentication)
app.use('/api/agent', agentRoutes);
@@ -702,6 +717,32 @@ wss.on('connection', (ws, request) => {
}
});
/**
* WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface
*/
class WebSocketWriter {
constructor(ws) {
this.ws = ws;
this.sessionId = null;
this.isWebSocketWriter = true; // Marker for transport detection
}
send(data) {
if (this.ws.readyState === 1) { // WebSocket.OPEN
// Providers send raw objects, we stringify for WebSocket
this.ws.send(JSON.stringify(data));
}
}
setSessionId(sessionId) {
this.sessionId = sessionId;
}
getSessionId() {
return this.sessionId;
}
}
// Handle chat WebSocket connections
function handleChatConnection(ws) {
console.log('[INFO] Chat WebSocket connected');
@@ -709,6 +750,9 @@ function handleChatConnection(ws) {
// Add to connected clients for project updates
connectedClients.add(ws);
// Wrap WebSocket with writer for consistent interface with SSEStreamWriter
const writer = new WebSocketWriter(ws);
ws.on('message', async (message) => {
try {
const data = JSON.parse(message);
@@ -719,13 +763,19 @@ function handleChatConnection(ws) {
console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
// Use Claude Agents SDK
await queryClaudeSDK(data.command, data.options, ws);
await queryClaudeSDK(data.command, data.options, writer);
} else if (data.type === 'cursor-command') {
console.log('[DEBUG] Cursor message:', data.command || '[Continue/Resume]');
console.log('📁 Project:', data.options?.cwd || 'Unknown');
console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
console.log('🤖 Model:', data.options?.model || 'default');
await spawnCursor(data.command, data.options, ws);
await spawnCursor(data.command, data.options, writer);
} else if (data.type === 'codex-command') {
console.log('[DEBUG] Codex message:', data.command || '[Continue/Resume]');
console.log('📁 Project:', data.options?.projectPath || data.options?.cwd || 'Unknown');
console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
console.log('🤖 Model:', data.options?.model || 'default');
await queryCodex(data.command, data.options, writer);
} else if (data.type === 'cursor-resume') {
// Backward compatibility: treat as cursor-command with resume and no prompt
console.log('[DEBUG] Cursor resume session (compat):', data.sessionId);
@@ -733,7 +783,7 @@ function handleChatConnection(ws) {
sessionId: data.sessionId,
resume: true,
cwd: data.options?.cwd
}, ws);
}, writer);
} else if (data.type === 'abort-session') {
console.log('[DEBUG] Abort session request:', data.sessionId);
const provider = data.provider || 'claude';
@@ -741,26 +791,28 @@ function handleChatConnection(ws) {
if (provider === 'cursor') {
success = abortCursorSession(data.sessionId);
} else if (provider === 'codex') {
success = abortCodexSession(data.sessionId);
} else {
// Use Claude Agents SDK
success = await abortClaudeSDKSession(data.sessionId);
}
ws.send(JSON.stringify({
writer.send({
type: 'session-aborted',
sessionId: data.sessionId,
provider,
success
}));
});
} else if (data.type === 'cursor-abort') {
console.log('[DEBUG] Abort Cursor session:', data.sessionId);
const success = abortCursorSession(data.sessionId);
ws.send(JSON.stringify({
writer.send({
type: 'session-aborted',
sessionId: data.sessionId,
provider: 'cursor',
success
}));
});
} else if (data.type === 'check-session-status') {
// Check if a specific session is currently processing
const provider = data.provider || 'claude';
@@ -769,34 +821,37 @@ function handleChatConnection(ws) {
if (provider === 'cursor') {
isActive = isCursorSessionActive(sessionId);
} else if (provider === 'codex') {
isActive = isCodexSessionActive(sessionId);
} else {
// Use Claude Agents SDK
isActive = isClaudeSDKSessionActive(sessionId);
}
ws.send(JSON.stringify({
writer.send({
type: 'session-status',
sessionId,
provider,
isProcessing: isActive
}));
});
} else if (data.type === 'get-active-sessions') {
// Get all currently active sessions
const activeSessions = {
claude: getActiveClaudeSDKSessions(),
cursor: getActiveCursorSessions()
cursor: getActiveCursorSessions(),
codex: getActiveCodexSessions()
};
ws.send(JSON.stringify({
writer.send({
type: 'active-sessions',
sessions: activeSessions
}));
});
}
} catch (error) {
console.error('[ERROR] Chat WebSocket error:', error.message);
ws.send(JSON.stringify({
writer.send({
type: 'error',
error: error.message
}));
});
}
});
@@ -827,9 +882,31 @@ function handleShellConnection(ws) {
const initialCommand = data.initialCommand;
const isPlainShell = data.isPlainShell || (!!initialCommand && !hasSession) || provider === 'plain-shell';
ptySessionKey = `${projectPath}_${sessionId || 'default'}`;
// Login commands (Claude/Cursor auth) should never reuse cached sessions
const isLoginCommand = initialCommand && (
initialCommand.includes('setup-token') ||
initialCommand.includes('cursor-agent login') ||
initialCommand.includes('auth login')
);
const existingSession = ptySessionsMap.get(ptySessionKey);
// Include command hash in session key so different commands get separate sessions
const commandSuffix = isPlainShell && initialCommand
? `_cmd_${Buffer.from(initialCommand).toString('base64').slice(0, 16)}`
: '';
ptySessionKey = `${projectPath}_${sessionId || 'default'}${commandSuffix}`;
// Kill any existing login session before starting fresh
if (isLoginCommand) {
const oldSession = ptySessionsMap.get(ptySessionKey);
if (oldSession) {
console.log('🧹 Cleaning up existing login session:', ptySessionKey);
if (oldSession.timeoutId) clearTimeout(oldSession.timeoutId);
if (oldSession.pty && oldSession.pty.kill) oldSession.pty.kill();
ptySessionsMap.delete(ptySessionKey);
}
}
const existingSession = isLoginCommand ? null : ptySessionsMap.get(ptySessionKey);
if (existingSession) {
console.log('♻️ Reconnecting to existing PTY session:', ptySessionKey);
shellProcess = existingSession.pty;
@@ -938,7 +1015,7 @@ function handleShellConnection(ws) {
name: 'xterm-256color',
cols: termCols,
rows: termRows,
cwd: process.env.HOME || (os.platform() === 'win32' ? process.env.USERPROFILE : '/'),
cwd: os.homedir(),
env: {
...process.env,
TERM: 'xterm-256color',
@@ -1332,8 +1409,98 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, async (r
app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => {
try {
const { projectName, sessionId } = req.params;
const { provider = 'claude' } = req.query;
const homeDir = os.homedir();
// Allow only safe characters in sessionId
const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
if (!safeSessionId) {
return res.status(400).json({ error: 'Invalid sessionId' });
}
// Handle Cursor sessions - they use SQLite and don't have token usage info
if (provider === 'cursor') {
return res.json({
used: 0,
total: 0,
breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
unsupported: true,
message: 'Token usage tracking not available for Cursor sessions'
});
}
// Handle Codex sessions
if (provider === 'codex') {
const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
// Find the session file by searching for the session ID
const findSessionFile = async (dir) => {
try {
const entries = await fsPromises.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
const found = await findSessionFile(fullPath);
if (found) return found;
} else if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) {
return fullPath;
}
}
} catch (error) {
// Skip directories we can't read
}
return null;
};
const sessionFilePath = await findSessionFile(codexSessionsDir);
if (!sessionFilePath) {
return res.status(404).json({ error: 'Codex session file not found', sessionId: safeSessionId });
}
// Read and parse the Codex JSONL file
let fileContent;
try {
fileContent = await fsPromises.readFile(sessionFilePath, 'utf8');
} catch (error) {
if (error.code === 'ENOENT') {
return res.status(404).json({ error: 'Session file not found', path: sessionFilePath });
}
throw error;
}
const lines = fileContent.trim().split('\n');
let totalTokens = 0;
let contextWindow = 200000; // Default for Codex/OpenAI
// Find the latest token_count event with info (scan from end)
for (let i = lines.length - 1; i >= 0; i--) {
try {
const entry = JSON.parse(lines[i]);
// Codex stores token info in event_msg with type: "token_count"
if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
const tokenInfo = entry.payload.info;
if (tokenInfo.total_token_usage) {
totalTokens = tokenInfo.total_token_usage.total_tokens || 0;
}
if (tokenInfo.model_context_window) {
contextWindow = tokenInfo.model_context_window;
}
break; // Stop after finding the latest token count
}
} catch (parseError) {
// Skip lines that can't be parsed
continue;
}
}
return res.json({
used: totalTokens,
total: contextWindow
});
}
// Handle Claude sessions (default)
// Extract actual project path
let projectPath;
try {
@@ -1349,11 +1516,6 @@ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authentica
const encodedPath = projectPath.replace(/[\\/:\s~_]/g, '-');
const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
// Allow only safe characters in sessionId
const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
if (!safeSessionId) {
return res.status(400).json({ error: 'Invalid sessionId' });
}
const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
// Constrain to projectDir

388
server/openai-codex.js Normal file
View File

@@ -0,0 +1,388 @@
/**
* OpenAI Codex SDK Integration
* =============================
*
* This module provides integration with the OpenAI Codex SDK for non-interactive
* chat sessions. It mirrors the pattern used in claude-sdk.js for consistency.
*
* ## Usage
*
* - queryCodex(command, options, ws) - Execute a prompt with streaming via WebSocket
* - abortCodexSession(sessionId) - Cancel an active session
* - isCodexSessionActive(sessionId) - Check if a session is running
* - getActiveCodexSessions() - List all active sessions
*/
import { Codex } from '@openai/codex-sdk';
// Track active sessions
const activeCodexSessions = new Map();
/**
* Transform Codex SDK event to WebSocket message format
* @param {object} event - SDK event
* @returns {object} - Transformed event for WebSocket
*/
function transformCodexEvent(event) {
// Map SDK event types to a consistent format
switch (event.type) {
case 'item.started':
case 'item.updated':
case 'item.completed':
const item = event.item;
if (!item) {
return { type: event.type, item: null };
}
// Transform based on item type
switch (item.type) {
case 'agent_message':
return {
type: 'item',
itemType: 'agent_message',
message: {
role: 'assistant',
content: item.text
}
};
case 'reasoning':
return {
type: 'item',
itemType: 'reasoning',
message: {
role: 'assistant',
content: item.text,
isReasoning: true
}
};
case 'command_execution':
return {
type: 'item',
itemType: 'command_execution',
command: item.command,
output: item.aggregated_output,
exitCode: item.exit_code,
status: item.status
};
case 'file_change':
return {
type: 'item',
itemType: 'file_change',
changes: item.changes,
status: item.status
};
case 'mcp_tool_call':
return {
type: 'item',
itemType: 'mcp_tool_call',
server: item.server,
tool: item.tool,
arguments: item.arguments,
result: item.result,
error: item.error,
status: item.status
};
case 'web_search':
return {
type: 'item',
itemType: 'web_search',
query: item.query
};
case 'todo_list':
return {
type: 'item',
itemType: 'todo_list',
items: item.items
};
case 'error':
return {
type: 'item',
itemType: 'error',
message: {
role: 'error',
content: item.message
}
};
default:
return {
type: 'item',
itemType: item.type,
item: item
};
}
case 'turn.started':
return {
type: 'turn_started'
};
case 'turn.completed':
return {
type: 'turn_complete',
usage: event.usage
};
case 'turn.failed':
return {
type: 'turn_failed',
error: event.error
};
case 'thread.started':
return {
type: 'thread_started',
threadId: event.id
};
case 'error':
return {
type: 'error',
message: event.message
};
default:
return {
type: event.type,
data: event
};
}
}
/**
* Map permission mode to Codex SDK options
* @param {string} permissionMode - 'default', 'acceptEdits', or 'bypassPermissions'
* @returns {object} - { sandboxMode, approvalPolicy }
*/
function mapPermissionModeToCodexOptions(permissionMode) {
switch (permissionMode) {
case 'acceptEdits':
return {
sandboxMode: 'workspace-write',
approvalPolicy: 'never'
};
case 'bypassPermissions':
return {
sandboxMode: 'danger-full-access',
approvalPolicy: 'never'
};
case 'default':
default:
return {
sandboxMode: 'workspace-write',
approvalPolicy: 'untrusted'
};
}
}
/**
* Execute a Codex query with streaming
* @param {string} command - The prompt to send
* @param {object} options - Options including cwd, sessionId, model, permissionMode
* @param {WebSocket|object} ws - WebSocket connection or response writer
*/
export async function queryCodex(command, options = {}, ws) {
const {
sessionId,
cwd,
projectPath,
model,
permissionMode = 'default'
} = options;
const workingDirectory = cwd || projectPath || process.cwd();
const { sandboxMode, approvalPolicy } = mapPermissionModeToCodexOptions(permissionMode);
let codex;
let thread;
let currentSessionId = sessionId;
try {
// Initialize Codex SDK
codex = new Codex();
// Thread options with sandbox and approval settings
const threadOptions = {
workingDirectory,
skipGitRepoCheck: true,
sandboxMode,
approvalPolicy,
model
};
// Start or resume thread
if (sessionId) {
thread = codex.resumeThread(sessionId, threadOptions);
} else {
thread = codex.startThread(threadOptions);
}
// Get the thread ID
currentSessionId = thread.id || sessionId || `codex-${Date.now()}`;
// Track the session
activeCodexSessions.set(currentSessionId, {
thread,
codex,
status: 'running',
startedAt: new Date().toISOString()
});
// Send session created event
sendMessage(ws, {
type: 'session-created',
sessionId: currentSessionId,
provider: 'codex'
});
// Execute with streaming
const streamedTurn = await thread.runStreamed(command);
for await (const event of streamedTurn.events) {
// Check if session was aborted
const session = activeCodexSessions.get(currentSessionId);
if (!session || session.status === 'aborted') {
break;
}
if (event.type === 'item.started' || event.type === 'item.updated') {
continue;
}
const transformed = transformCodexEvent(event);
sendMessage(ws, {
type: 'codex-response',
data: transformed,
sessionId: currentSessionId
});
// Extract and send token usage if available (normalized to match Claude format)
if (event.type === 'turn.completed' && event.usage) {
const totalTokens = (event.usage.input_tokens || 0) + (event.usage.output_tokens || 0);
sendMessage(ws, {
type: 'token-budget',
data: {
used: totalTokens,
total: 200000 // Default context window for Codex models
}
});
}
}
// Send completion event
sendMessage(ws, {
type: 'codex-complete',
sessionId: currentSessionId
});
} catch (error) {
console.error('[Codex] Error:', error);
sendMessage(ws, {
type: 'codex-error',
error: error.message,
sessionId: currentSessionId
});
} finally {
// Update session status
if (currentSessionId) {
const session = activeCodexSessions.get(currentSessionId);
if (session) {
session.status = 'completed';
}
}
}
}
/**
* Abort an active Codex session
* @param {string} sessionId - Session ID to abort
* @returns {boolean} - Whether abort was successful
*/
export function abortCodexSession(sessionId) {
const session = activeCodexSessions.get(sessionId);
if (!session) {
return false;
}
session.status = 'aborted';
// The SDK doesn't have a direct abort method, but marking status
// will cause the streaming loop to exit
return true;
}
/**
* Check if a session is active
* @param {string} sessionId - Session ID to check
* @returns {boolean} - Whether session is active
*/
export function isCodexSessionActive(sessionId) {
const session = activeCodexSessions.get(sessionId);
return session?.status === 'running';
}
/**
* Get all active sessions
* @returns {Array} - Array of active session info
*/
export function getActiveCodexSessions() {
const sessions = [];
for (const [id, session] of activeCodexSessions.entries()) {
if (session.status === 'running') {
sessions.push({
id,
status: session.status,
startedAt: session.startedAt
});
}
}
return sessions;
}
/**
* Helper to send message via WebSocket or writer
* @param {WebSocket|object} ws - WebSocket or response writer
* @param {object} data - Data to send
*/
function sendMessage(ws, data) {
try {
if (ws.isSSEStreamWriter || ws.isWebSocketWriter) {
// Writer handles stringification (SSEStreamWriter or WebSocketWriter)
ws.send(data);
} else if (typeof ws.send === 'function') {
// Raw WebSocket - stringify here
ws.send(JSON.stringify(data));
}
} catch (error) {
console.error('[Codex] Error sending message:', error);
}
}
// Clean up old completed sessions periodically
setInterval(() => {
const now = Date.now();
const maxAge = 30 * 60 * 1000; // 30 minutes
for (const [id, session] of activeCodexSessions.entries()) {
if (session.status !== 'running') {
const startedAt = new Date(session.startedAt).getTime();
if (now - startedAt > maxAge) {
activeCodexSessions.delete(id);
}
}
}
}, 5 * 60 * 1000); // Every 5 minutes

View File

@@ -204,7 +204,7 @@ function clearProjectDirectoryCache() {
// Load project configuration file
async function loadProjectConfig() {
const configPath = path.join(process.env.HOME, '.claude', 'project-config.json');
const configPath = path.join(os.homedir(), '.claude', 'project-config.json');
try {
const configData = await fs.readFile(configPath, 'utf8');
return JSON.parse(configData);
@@ -216,7 +216,7 @@ async function loadProjectConfig() {
// Save project configuration file
async function saveProjectConfig(config) {
const claudeDir = path.join(process.env.HOME, '.claude');
const claudeDir = path.join(os.homedir(), '.claude');
const configPath = path.join(claudeDir, 'project-config.json');
// Ensure the .claude directory exists
@@ -266,9 +266,17 @@ async function extractProjectDirectory(projectName) {
if (projectDirectoryCache.has(projectName)) {
return projectDirectoryCache.get(projectName);
}
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
// Check project config for originalPath (manually added projects via UI or platform)
// This handles projects with dashes in their directory names correctly
const config = await loadProjectConfig();
if (config[projectName]?.originalPath) {
const originalPath = config[projectName].originalPath;
projectDirectoryCache.set(projectName, originalPath);
return originalPath;
}
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
const cwdCounts = new Map();
let latestTimestamp = 0;
let latestCwd = null;
@@ -372,7 +380,7 @@ async function extractProjectDirectory(projectName) {
}
async function getProjects() {
const claudeDir = path.join(process.env.HOME, '.claude', 'projects');
const claudeDir = path.join(os.homedir(), '.claude', 'projects');
const config = await loadProjectConfig();
const projects = [];
const existingProjects = new Set();
@@ -425,7 +433,15 @@ async function getProjects() {
console.warn(`Could not load Cursor sessions for project ${entry.name}:`, e.message);
project.cursorSessions = [];
}
// Also fetch Codex sessions for this project
try {
project.codexSessions = await getCodexSessions(actualProjectDir);
} catch (e) {
console.warn(`Could not load Codex sessions for project ${entry.name}:`, e.message);
project.codexSessions = [];
}
// Add TaskMaster detection
try {
const taskMasterResult = await detectTaskMasterFolder(actualProjectDir);
@@ -478,16 +494,24 @@ async function getProjects() {
isCustomName: !!projectConfig.displayName,
isManuallyAdded: true,
sessions: [],
cursorSessions: []
cursorSessions: [],
codexSessions: []
};
// Try to fetch Cursor sessions for manual projects too
try {
project.cursorSessions = await getCursorSessions(actualProjectDir);
} catch (e) {
console.warn(`Could not load Cursor sessions for manual project ${projectName}:`, e.message);
}
// Try to fetch Codex sessions for manual projects too
try {
project.codexSessions = await getCodexSessions(actualProjectDir);
} catch (e) {
console.warn(`Could not load Codex sessions for manual project ${projectName}:`, e.message);
}
// Add TaskMaster detection for manual projects
try {
const taskMasterResult = await detectTaskMasterFolder(actualProjectDir);
@@ -522,7 +546,7 @@ async function getProjects() {
}
async function getSessions(projectName, limit = 5, offset = 0) {
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
try {
const files = await fs.readdir(projectDir);
@@ -804,7 +828,7 @@ async function parseJsonlSessions(filePath) {
// Get messages for a specific session with pagination support
async function getSessionMessages(projectName, sessionId, limit = null, offset = 0) {
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
try {
const files = await fs.readdir(projectDir);
@@ -893,7 +917,7 @@ async function renameProject(projectName, newDisplayName) {
// Delete a session from a project
async function deleteSession(projectName, sessionId) {
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
try {
const files = await fs.readdir(projectDir);
@@ -956,7 +980,7 @@ async function isProjectEmpty(projectName) {
// Delete an empty project
async function deleteProject(projectName) {
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
try {
// First check if the project is empty
@@ -996,7 +1020,7 @@ async function addProjectManually(projectPath, displayName = null) {
// Check if project already exists in config
const config = await loadProjectConfig();
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
if (config[projectName]) {
throw new Error(`Project already configured for path: ${absolutePath}`);
@@ -1141,6 +1165,420 @@ async function getCursorSessions(projectPath) {
}
// Fetch Codex sessions for a given project path
async function getCodexSessions(projectPath) {
try {
const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
const sessions = [];
// Check if the directory exists
try {
await fs.access(codexSessionsDir);
} catch (error) {
// No Codex sessions directory
return [];
}
// Recursively find all .jsonl files in the sessions directory
const findJsonlFiles = async (dir) => {
const files = [];
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...await findJsonlFiles(fullPath));
} else if (entry.name.endsWith('.jsonl')) {
files.push(fullPath);
}
}
} catch (error) {
// Skip directories we can't read
}
return files;
};
const jsonlFiles = await findJsonlFiles(codexSessionsDir);
// Process each file to find sessions matching the project path
for (const filePath of jsonlFiles) {
try {
const sessionData = await parseCodexSessionFile(filePath);
// Check if this session matches the project path
if (sessionData && sessionData.cwd === projectPath) {
sessions.push({
id: sessionData.id,
summary: sessionData.summary || 'Codex Session',
messageCount: sessionData.messageCount || 0,
lastActivity: sessionData.timestamp ? new Date(sessionData.timestamp) : new Date(),
cwd: sessionData.cwd,
model: sessionData.model,
filePath: filePath,
provider: 'codex'
});
}
} catch (error) {
console.warn(`Could not parse Codex session file ${filePath}:`, error.message);
}
}
// Sort sessions by last activity (newest first)
sessions.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity));
// Return only the first 5 sessions for performance
return sessions.slice(0, 5);
} catch (error) {
console.error('Error fetching Codex sessions:', error);
return [];
}
}
// Parse a Codex session JSONL file to extract metadata
async function parseCodexSessionFile(filePath) {
try {
const fileStream = fsSync.createReadStream(filePath);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity
});
let sessionMeta = null;
let lastTimestamp = null;
let lastUserMessage = null;
let messageCount = 0;
for await (const line of rl) {
if (line.trim()) {
try {
const entry = JSON.parse(line);
// Track timestamp
if (entry.timestamp) {
lastTimestamp = entry.timestamp;
}
// Extract session metadata
if (entry.type === 'session_meta' && entry.payload) {
sessionMeta = {
id: entry.payload.id,
cwd: entry.payload.cwd,
model: entry.payload.model || entry.payload.model_provider,
timestamp: entry.timestamp,
git: entry.payload.git
};
}
// Count messages and extract user messages for summary
if (entry.type === 'event_msg' && entry.payload?.type === 'user_message') {
messageCount++;
if (entry.payload.text) {
lastUserMessage = entry.payload.text;
}
}
if (entry.type === 'response_item' && entry.payload?.type === 'message') {
messageCount++;
}
} catch (parseError) {
// Skip malformed lines
}
}
}
if (sessionMeta) {
return {
...sessionMeta,
timestamp: lastTimestamp || sessionMeta.timestamp,
summary: lastUserMessage ?
(lastUserMessage.length > 50 ? lastUserMessage.substring(0, 50) + '...' : lastUserMessage) :
'Codex Session',
messageCount
};
}
return null;
} catch (error) {
console.error('Error parsing Codex session file:', error);
return null;
}
}
// Get messages for a specific Codex session
async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
try {
const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
// Find the session file by searching for the session ID
const findSessionFile = async (dir) => {
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
const found = await findSessionFile(fullPath);
if (found) return found;
} else if (entry.name.includes(sessionId) && entry.name.endsWith('.jsonl')) {
return fullPath;
}
}
} catch (error) {
// Skip directories we can't read
}
return null;
};
const sessionFilePath = await findSessionFile(codexSessionsDir);
if (!sessionFilePath) {
console.warn(`Codex session file not found for session ${sessionId}`);
return { messages: [], total: 0, hasMore: false };
}
const messages = [];
let tokenUsage = null;
const fileStream = fsSync.createReadStream(sessionFilePath);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity
});
// Helper to extract text from Codex content array
const extractText = (content) => {
if (!Array.isArray(content)) return content;
return content
.map(item => {
if (item.type === 'input_text' || item.type === 'output_text') {
return item.text;
}
if (item.type === 'text') {
return item.text;
}
return '';
})
.filter(Boolean)
.join('\n');
};
for await (const line of rl) {
if (line.trim()) {
try {
const entry = JSON.parse(line);
// Extract token usage from token_count events (keep latest)
if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
const info = entry.payload.info;
if (info.total_token_usage) {
tokenUsage = {
used: info.total_token_usage.total_tokens || 0,
total: info.model_context_window || 200000
};
}
}
// Extract messages from response_item
if (entry.type === 'response_item' && entry.payload?.type === 'message') {
const content = entry.payload.content;
const role = entry.payload.role || 'assistant';
const textContent = extractText(content);
// Skip system context messages (environment_context)
if (textContent?.includes('<environment_context>')) {
continue;
}
// Only add if there's actual content
if (textContent?.trim()) {
messages.push({
type: role === 'user' ? 'user' : 'assistant',
timestamp: entry.timestamp,
message: {
role: role,
content: textContent
}
});
}
}
if (entry.type === 'response_item' && entry.payload?.type === 'reasoning') {
const summaryText = entry.payload.summary
?.map(s => s.text)
.filter(Boolean)
.join('\n');
if (summaryText?.trim()) {
messages.push({
type: 'thinking',
timestamp: entry.timestamp,
message: {
role: 'assistant',
content: summaryText
}
});
}
}
if (entry.type === 'response_item' && entry.payload?.type === 'function_call') {
let toolName = entry.payload.name;
let toolInput = entry.payload.arguments;
// Map Codex tool names to Claude equivalents
if (toolName === 'shell_command') {
toolName = 'Bash';
try {
const args = JSON.parse(entry.payload.arguments);
toolInput = JSON.stringify({ command: args.command });
} catch (e) {
// Keep original if parsing fails
}
}
messages.push({
type: 'tool_use',
timestamp: entry.timestamp,
toolName: toolName,
toolInput: toolInput,
toolCallId: entry.payload.call_id
});
}
if (entry.type === 'response_item' && entry.payload?.type === 'function_call_output') {
messages.push({
type: 'tool_result',
timestamp: entry.timestamp,
toolCallId: entry.payload.call_id,
output: entry.payload.output
});
}
if (entry.type === 'response_item' && entry.payload?.type === 'custom_tool_call') {
const toolName = entry.payload.name || 'custom_tool';
const input = entry.payload.input || '';
if (toolName === 'apply_patch') {
// Parse Codex patch format and convert to Claude Edit format
const fileMatch = input.match(/\*\*\* Update File: (.+)/);
const filePath = fileMatch ? fileMatch[1].trim() : 'unknown';
// Extract old and new content from patch
const lines = input.split('\n');
const oldLines = [];
const newLines = [];
for (const line of lines) {
if (line.startsWith('-') && !line.startsWith('---')) {
oldLines.push(line.substring(1));
} else if (line.startsWith('+') && !line.startsWith('+++')) {
newLines.push(line.substring(1));
}
}
messages.push({
type: 'tool_use',
timestamp: entry.timestamp,
toolName: 'Edit',
toolInput: JSON.stringify({
file_path: filePath,
old_string: oldLines.join('\n'),
new_string: newLines.join('\n')
}),
toolCallId: entry.payload.call_id
});
} else {
messages.push({
type: 'tool_use',
timestamp: entry.timestamp,
toolName: toolName,
toolInput: input,
toolCallId: entry.payload.call_id
});
}
}
if (entry.type === 'response_item' && entry.payload?.type === 'custom_tool_call_output') {
messages.push({
type: 'tool_result',
timestamp: entry.timestamp,
toolCallId: entry.payload.call_id,
output: entry.payload.output || ''
});
}
} catch (parseError) {
// Skip malformed lines
}
}
}
// Sort by timestamp
messages.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0));
const total = messages.length;
// Apply pagination if limit is specified
if (limit !== null) {
const startIndex = Math.max(0, total - offset - limit);
const endIndex = total - offset;
const paginatedMessages = messages.slice(startIndex, endIndex);
const hasMore = startIndex > 0;
return {
messages: paginatedMessages,
total,
hasMore,
offset,
limit,
tokenUsage
};
}
return { messages, tokenUsage };
} catch (error) {
console.error(`Error reading Codex session messages for ${sessionId}:`, error);
return { messages: [], total: 0, hasMore: false };
}
}
async function deleteCodexSession(sessionId) {
try {
const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
const findJsonlFiles = async (dir) => {
const files = [];
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...await findJsonlFiles(fullPath));
} else if (entry.name.endsWith('.jsonl')) {
files.push(fullPath);
}
}
} catch (error) {}
return files;
};
const jsonlFiles = await findJsonlFiles(codexSessionsDir);
for (const filePath of jsonlFiles) {
const sessionData = await parseCodexSessionFile(filePath);
if (sessionData && sessionData.id === sessionId) {
await fs.unlink(filePath);
return true;
}
}
throw new Error(`Codex session file not found for session ${sessionId}`);
} catch (error) {
console.error(`Error deleting Codex session ${sessionId}:`, error);
throw error;
}
}
export {
getProjects,
getSessions,
@@ -1154,5 +1592,8 @@ export {
loadProjectConfig,
saveProjectConfig,
extractProjectDirectory,
clearProjectDirectoryCache
clearProjectDirectoryCache,
getCodexSessions,
getCodexSessionMessages,
deleteCodexSession
};

View File

@@ -8,7 +8,9 @@ import { userDb, apiKeysDb, githubTokensDb } from '../database/db.js';
import { addProjectManually } from '../projects.js';
import { queryClaudeSDK } from '../claude-sdk.js';
import { spawnCursor } from '../cursor-cli.js';
import { queryCodex } from '../openai-codex.js';
import { Octokit } from '@octokit/rest';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
const router = express.Router();
@@ -449,6 +451,7 @@ class SSEStreamWriter {
constructor(res) {
this.res = res;
this.sessionId = null;
this.isSSEStreamWriter = true; // Marker for transport detection
}
send(data) {
@@ -456,7 +459,7 @@ class SSEStreamWriter {
return;
}
// Format as SSE
// Format as SSE - providers send raw objects, we stringify
this.res.write(`data: ${JSON.stringify(data)}\n\n`);
}
@@ -633,9 +636,14 @@ class ResponseCollector {
* - true: Returns text/event-stream with incremental updates
* - false: Returns complete JSON response after completion
*
* @param {string} model - (Optional) Model identifier for Cursor provider.
* Only applicable when provider='cursor'.
* Examples: 'gpt-4', 'claude-3-opus', etc.
* @param {string} model - (Optional) Model identifier for providers.
*
* Claude models: 'sonnet' (default), 'opus', 'haiku', 'opusplan', 'sonnet[1m]'
* Cursor models: 'gpt-5' (default), 'gpt-5.2', 'gpt-5.2-high', 'sonnet-4.5', 'opus-4.5',
* 'gemini-3-pro', 'composer-1', 'auto', 'gpt-5.1', 'gpt-5.1-high',
* 'gpt-5.1-codex', 'gpt-5.1-codex-high', 'gpt-5.1-codex-max',
* 'gpt-5.1-codex-max-high', 'opus-4.1', 'grok', and thinking variants
* Codex models: 'gpt-5.2' (default), 'gpt-5.1-codex-max', 'o3', 'o4-mini'
*
* @param {boolean} cleanup - (Optional) Auto-cleanup project directory after completion.
* Default: true
@@ -846,8 +854,8 @@ router.post('/', validateExternalApiKey, async (req, res) => {
return res.status(400).json({ error: 'message is required' });
}
if (!['claude', 'cursor'].includes(provider)) {
return res.status(400).json({ error: 'provider must be "claude" or "cursor"' });
if (!['claude', 'cursor', 'codex'].includes(provider)) {
return res.status(400).json({ error: 'provider must be "claude", "cursor", or "codex"' });
}
// Validate GitHub branch/PR creation requirements
@@ -938,6 +946,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
projectPath: finalProjectPath,
cwd: finalProjectPath,
sessionId: null, // New session
model: model,
permissionMode: 'bypassPermissions' // Bypass all permissions for API calls
}, writer);
@@ -951,6 +960,16 @@ router.post('/', validateExternalApiKey, async (req, res) => {
model: model || undefined,
skipPermissions: true // Bypass permissions for Cursor
}, writer);
} else if (provider === 'codex') {
console.log('🤖 Starting Codex SDK session');
await queryCodex(message.trim(), {
projectPath: finalProjectPath,
cwd: finalProjectPath,
sessionId: null,
model: model || CODEX_MODELS.DEFAULT,
permissionMode: 'bypassPermissions'
}, writer);
}
// Handle GitHub branch and PR creation after successful agent completion

View File

@@ -54,6 +54,26 @@ router.get('/cursor/status', async (req, res) => {
}
});
router.get('/codex/status', async (req, res) => {
try {
const result = await checkCodexCredentials();
res.json({
authenticated: result.authenticated,
email: result.email,
error: result.error
});
} catch (error) {
console.error('Error checking Codex auth status:', error);
res.status(500).json({
authenticated: false,
email: null,
error: error.message
});
}
});
async function checkClaudeCredentials() {
try {
const credPath = path.join(os.homedir(), '.claude', '.credentials.json');
@@ -177,4 +197,67 @@ function checkCursorStatus() {
});
}
async function checkCodexCredentials() {
try {
const authPath = path.join(os.homedir(), '.codex', 'auth.json');
const content = await fs.readFile(authPath, 'utf8');
const auth = JSON.parse(content);
// Tokens are nested under 'tokens' key
const tokens = auth.tokens || {};
// Check for valid tokens (id_token or access_token)
if (tokens.id_token || tokens.access_token) {
// Try to extract email from id_token JWT payload
let email = 'Authenticated';
if (tokens.id_token) {
try {
// JWT is base64url encoded: header.payload.signature
const parts = tokens.id_token.split('.');
if (parts.length >= 2) {
// Decode the payload (second part)
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
email = payload.email || payload.user || 'Authenticated';
}
} catch {
// If JWT decoding fails, use fallback
email = 'Authenticated';
}
}
return {
authenticated: true,
email
};
}
// Also check for OPENAI_API_KEY as fallback auth method
if (auth.OPENAI_API_KEY) {
return {
authenticated: true,
email: 'API Key Auth'
};
}
return {
authenticated: false,
email: null,
error: 'No valid tokens found'
};
} catch (error) {
if (error.code === 'ENOENT') {
return {
authenticated: false,
email: null,
error: 'Codex not configured'
};
}
return {
authenticated: false,
email: null,
error: error.message
};
}
}
export default router;

310
server/routes/codex.js Normal file
View File

@@ -0,0 +1,310 @@
import express from 'express';
import { spawn } from 'child_process';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import TOML from '@iarna/toml';
import { getCodexSessions, getCodexSessionMessages, deleteCodexSession } from '../projects.js';
const router = express.Router();
router.get('/config', async (req, res) => {
try {
const configPath = path.join(os.homedir(), '.codex', 'config.toml');
const content = await fs.readFile(configPath, 'utf8');
const config = TOML.parse(content);
res.json({
success: true,
config: {
model: config.model || null,
mcpServers: config.mcp_servers || {},
approvalMode: config.approval_mode || 'suggest'
}
});
} catch (error) {
if (error.code === 'ENOENT') {
res.json({
success: true,
config: {
model: null,
mcpServers: {},
approvalMode: 'suggest'
}
});
} else {
console.error('Error reading Codex config:', error);
res.status(500).json({ success: false, error: error.message });
}
}
});
router.get('/sessions', async (req, res) => {
try {
const { projectPath } = req.query;
if (!projectPath) {
return res.status(400).json({ success: false, error: 'projectPath query parameter required' });
}
const sessions = await getCodexSessions(projectPath);
res.json({ success: true, sessions });
} catch (error) {
console.error('Error fetching Codex sessions:', error);
res.status(500).json({ success: false, error: error.message });
}
});
router.get('/sessions/:sessionId/messages', async (req, res) => {
try {
const { sessionId } = req.params;
const { limit, offset } = req.query;
const result = await getCodexSessionMessages(
sessionId,
limit ? parseInt(limit, 10) : null,
offset ? parseInt(offset, 10) : 0
);
res.json({ success: true, ...result });
} catch (error) {
console.error('Error fetching Codex session messages:', error);
res.status(500).json({ success: false, error: error.message });
}
});
router.delete('/sessions/:sessionId', async (req, res) => {
try {
const { sessionId } = req.params;
await deleteCodexSession(sessionId);
res.json({ success: true });
} catch (error) {
console.error(`Error deleting Codex session ${req.params.sessionId}:`, error);
res.status(500).json({ success: false, error: error.message });
}
});
// MCP Server Management Routes
router.get('/mcp/cli/list', async (req, res) => {
try {
const proc = spawn('codex', ['mcp', 'list'], { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
proc.stdout.on('data', (data) => { stdout += data.toString(); });
proc.stderr.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => {
if (code === 0) {
res.json({ success: true, output: stdout, servers: parseCodexListOutput(stdout) });
} else {
res.status(500).json({ error: 'Codex CLI command failed', details: stderr });
}
});
proc.on('error', (error) => {
res.status(500).json({ error: 'Failed to run Codex CLI', details: error.message });
});
} catch (error) {
res.status(500).json({ error: 'Failed to list MCP servers', details: error.message });
}
});
router.post('/mcp/cli/add', async (req, res) => {
try {
const { name, command, args = [], env = {} } = req.body;
if (!name || !command) {
return res.status(400).json({ error: 'name and command are required' });
}
// Build: codex mcp add <name> [-e KEY=VAL]... -- <command> [args...]
let cliArgs = ['mcp', 'add', name];
Object.entries(env).forEach(([key, value]) => {
cliArgs.push('-e', `${key}=${value}`);
});
cliArgs.push('--', command);
if (args && args.length > 0) {
cliArgs.push(...args);
}
const proc = spawn('codex', cliArgs, { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
proc.stdout.on('data', (data) => { stdout += data.toString(); });
proc.stderr.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => {
if (code === 0) {
res.json({ success: true, output: stdout, message: `MCP server "${name}" added successfully` });
} else {
res.status(400).json({ error: 'Codex CLI command failed', details: stderr });
}
});
proc.on('error', (error) => {
res.status(500).json({ error: 'Failed to run Codex CLI', details: error.message });
});
} catch (error) {
res.status(500).json({ error: 'Failed to add MCP server', details: error.message });
}
});
router.delete('/mcp/cli/remove/:name', async (req, res) => {
try {
const { name } = req.params;
const proc = spawn('codex', ['mcp', 'remove', name], { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
proc.stdout.on('data', (data) => { stdout += data.toString(); });
proc.stderr.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => {
if (code === 0) {
res.json({ success: true, output: stdout, message: `MCP server "${name}" removed successfully` });
} else {
res.status(400).json({ error: 'Codex CLI command failed', details: stderr });
}
});
proc.on('error', (error) => {
res.status(500).json({ error: 'Failed to run Codex CLI', details: error.message });
});
} catch (error) {
res.status(500).json({ error: 'Failed to remove MCP server', details: error.message });
}
});
router.get('/mcp/cli/get/:name', async (req, res) => {
try {
const { name } = req.params;
const proc = spawn('codex', ['mcp', 'get', name], { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
proc.stdout.on('data', (data) => { stdout += data.toString(); });
proc.stderr.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => {
if (code === 0) {
res.json({ success: true, output: stdout, server: parseCodexGetOutput(stdout) });
} else {
res.status(404).json({ error: 'Codex CLI command failed', details: stderr });
}
});
proc.on('error', (error) => {
res.status(500).json({ error: 'Failed to run Codex CLI', details: error.message });
});
} catch (error) {
res.status(500).json({ error: 'Failed to get MCP server details', details: error.message });
}
});
router.get('/mcp/config/read', async (req, res) => {
try {
const configPath = path.join(os.homedir(), '.codex', 'config.toml');
let configData = null;
try {
const fileContent = await fs.readFile(configPath, 'utf8');
configData = TOML.parse(fileContent);
} catch (error) {
// Config file doesn't exist
}
if (!configData) {
return res.json({ success: false, message: 'No Codex configuration file found', servers: [] });
}
const servers = [];
if (configData.mcp_servers && typeof configData.mcp_servers === 'object') {
for (const [name, config] of Object.entries(configData.mcp_servers)) {
servers.push({
id: name,
name: name,
type: 'stdio',
scope: 'user',
config: {
command: config.command || '',
args: config.args || [],
env: config.env || {}
},
raw: config
});
}
}
res.json({ success: true, configPath, servers });
} catch (error) {
res.status(500).json({ error: 'Failed to read Codex configuration', details: error.message });
}
});
function parseCodexListOutput(output) {
const servers = [];
const lines = output.split('\n').filter(line => line.trim());
for (const line of lines) {
if (line.includes(':')) {
const colonIndex = line.indexOf(':');
const name = line.substring(0, colonIndex).trim();
if (!name) continue;
const rest = line.substring(colonIndex + 1).trim();
let description = rest;
let status = 'unknown';
if (rest.includes('✓') || rest.includes('✗')) {
const statusMatch = rest.match(/(.*?)\s*-\s*([✓✗].*)$/);
if (statusMatch) {
description = statusMatch[1].trim();
status = statusMatch[2].includes('✓') ? 'connected' : 'failed';
}
}
servers.push({ name, type: 'stdio', status, description });
}
}
return servers;
}
function parseCodexGetOutput(output) {
try {
const jsonMatch = output.match(/\{[\s\S]*\}/);
if (jsonMatch) {
return JSON.parse(jsonMatch[0]);
}
const server = { raw_output: output };
const lines = output.split('\n');
for (const line of lines) {
if (line.includes('Name:')) server.name = line.split(':')[1]?.trim();
else if (line.includes('Type:')) server.type = line.split(':')[1]?.trim();
else if (line.includes('Command:')) server.command = line.split(':')[1]?.trim();
}
return server;
} catch (error) {
return { raw_output: output, parse_error: error.message };
}
}
export default router;

View File

@@ -4,6 +4,7 @@ import path from 'path';
import { fileURLToPath } from 'url';
import os from 'os';
import matter from 'gray-matter';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -182,23 +183,15 @@ Custom commands can be created in:
},
'/model': async (args, context) => {
// Read available models from config or defaults
// Read available models from centralized constants
const availableModels = {
claude: [
'claude-sonnet-4.5',
'claude-sonnet-4',
'claude-opus-4',
'claude-sonnet-3.5'
],
cursor: [
'gpt-5',
'sonnet-4',
'opus-4.1'
]
claude: CLAUDE_MODELS.OPTIONS.map(o => o.value),
cursor: CURSOR_MODELS.OPTIONS.map(o => o.value),
codex: CODEX_MODELS.OPTIONS.map(o => o.value)
};
const currentProvider = context?.provider || 'claude';
const currentModel = context?.model || 'claude-sonnet-4.5';
const currentModel = context?.model || CLAUDE_MODELS.DEFAULT;
return {
type: 'builtin',
@@ -216,50 +209,6 @@ Custom commands can be created in:
};
},
'/cost': async (args, context) => {
// Calculate token usage and cost
const sessionId = context?.sessionId;
const tokenUsage = context?.tokenUsage || { used: 0, total: 200000 };
const costPerMillion = {
'claude-sonnet-4.5': { input: 3, output: 15 },
'claude-sonnet-4': { input: 3, output: 15 },
'claude-opus-4': { input: 15, output: 75 },
'gpt-5': { input: 5, output: 15 }
};
const model = context?.model || 'claude-sonnet-4.5';
const rates = costPerMillion[model] || costPerMillion['claude-sonnet-4.5'];
// Estimate 70% input, 30% output
const estimatedInputTokens = Math.floor(tokenUsage.used * 0.7);
const estimatedOutputTokens = Math.floor(tokenUsage.used * 0.3);
const inputCost = (estimatedInputTokens / 1000000) * rates.input;
const outputCost = (estimatedOutputTokens / 1000000) * rates.output;
const totalCost = inputCost + outputCost;
return {
type: 'builtin',
action: 'cost',
data: {
tokenUsage: {
used: tokenUsage.used,
total: tokenUsage.total,
percentage: ((tokenUsage.used / tokenUsage.total) * 100).toFixed(1)
},
cost: {
input: inputCost.toFixed(4),
output: outputCost.toFixed(4),
total: totalCost.toFixed(4),
currency: 'USD'
},
model,
rates
}
};
},
'/status': async (args, context) => {
// Read version from package.json
const packageJsonPath = path.join(path.dirname(__dirname), '..', 'package.json');

View File

@@ -6,6 +6,7 @@ import { spawn } from 'child_process';
import sqlite3 from 'sqlite3';
import { open } from 'sqlite';
import crypto from 'crypto';
import { CURSOR_MODELS } from '../../shared/modelConstants.js';
const router = express.Router();
@@ -33,7 +34,7 @@ router.get('/config', async (req, res) => {
config: {
version: 1,
model: {
modelId: "gpt-5",
modelId: CURSOR_MODELS.DEFAULT,
displayName: "GPT-5"
},
permissions: {