feat: Introducing Codex to the Claude code UI project. Improve the Settings and Onboarding UX to accomodate more agents.

This commit is contained in:
simosmik
2025-12-27 22:30:32 +00:00
parent 7a173071f1
commit fbbf7465fb
26 changed files with 3719 additions and 1053 deletions

View File

@@ -78,7 +78,7 @@ function mapCliOptionsToSDK(options = {}) {
// Map model (default to sonnet)
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m]
sdkOptions.model = options.model || 'sonnet';
console.log(`🤖 Using model: ${sdkOptions.model}`);
console.log(`Using model: ${sdkOptions.model}`);
// Map system prompt configuration
sdkOptions.systemPrompt = {
@@ -184,7 +184,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,
@@ -240,7 +240,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);
@@ -273,7 +273,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);
}
@@ -293,7 +293,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;
}
@@ -303,7 +303,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;
}
@@ -313,7 +313,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
@@ -321,20 +321,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;
}
}
@@ -381,7 +381,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) {
@@ -402,10 +402,10 @@ async function queryClaudeSDK(command, options = {}, ws) {
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
@@ -419,7 +419,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
if (message.type === 'result') {
const tokenBudget = extractTokenBudget(message);
if (tokenBudget) {
console.log('📊 Token budget from modelUsage:', tokenBudget);
console.log('Token budget from modelUsage:', tokenBudget);
ws.send(JSON.stringify({
type: 'token-budget',
data: tokenBudget
@@ -437,14 +437,14 @@ async function queryClaudeSDK(command, options = {}, ws) {
await cleanupTempFiles(tempImagePaths, tempDir);
// Send completion event
console.log('Streaming complete, sending claude-complete event');
console.log('Streaming complete, sending claude-complete event');
ws.send(JSON.stringify({
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);
@@ -481,7 +481,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

@@ -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';
@@ -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);
@@ -726,6 +741,12 @@ function handleChatConnection(ws) {
console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
console.log('🤖 Model:', data.options?.model || 'default');
await spawnCursor(data.command, data.options, ws);
} 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, ws);
} 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);
@@ -741,6 +762,8 @@ 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);
@@ -769,6 +792,8 @@ function handleChatConnection(ws) {
if (provider === 'cursor') {
isActive = isCursorSessionActive(sessionId);
} else if (provider === 'codex') {
isActive = isCodexSessionActive(sessionId);
} else {
// Use Claude Agents SDK
isActive = isClaudeSDKSessionActive(sessionId);
@@ -784,7 +809,8 @@ function handleChatConnection(ws) {
// Get all currently active sessions
const activeSessions = {
claude: getActiveClaudeSDKSessions(),
cursor: getActiveCursorSessions()
cursor: getActiveCursorSessions(),
codex: getActiveCodexSessions()
};
ws.send(JSON.stringify({
type: 'active-sessions',
@@ -1354,8 +1380,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 {
@@ -1371,11 +1487,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

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

@@ -0,0 +1,387 @@
/**
* 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
};
// 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 (typeof ws.send === 'function') {
// WebSocket
ws.send(JSON.stringify(data));
} else if (typeof ws.write === 'function') {
// SSE writer (for agent API)
ws.write(`data: ${JSON.stringify(data)}\n\n`);
}
} 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

@@ -425,7 +425,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 +486,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);
@@ -1141,6 +1157,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 +1584,8 @@ export {
loadProjectConfig,
saveProjectConfig,
extractProjectDirectory,
clearProjectDirectoryCache
clearProjectDirectoryCache,
getCodexSessions,
getCodexSessionMessages,
deleteCodexSession
};

View File

@@ -8,6 +8,7 @@ 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';
const router = express.Router();
@@ -846,8 +847,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
@@ -951,6 +952,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 || 'gpt-5.2',
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;