mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
611 lines
20 KiB
JavaScript
611 lines
20 KiB
JavaScript
/**
|
|
* Codex CLI Execution Wrapper
|
|
*
|
|
* This module handles spawning and managing Codex CLI processes
|
|
* for executing OpenAI model queries.
|
|
*/
|
|
|
|
const { spawn } = require('child_process');
|
|
const { EventEmitter } = require('events');
|
|
const readline = require('readline');
|
|
const path = require('path');
|
|
const CodexCliDetector = require('./codex-cli-detector');
|
|
const codexConfigManager = require('./codex-config-manager');
|
|
|
|
/**
|
|
* Message types from Codex CLI JSON output
|
|
*/
|
|
const CODEX_EVENT_TYPES = {
|
|
THREAD_STARTED: 'thread.started',
|
|
ITEM_STARTED: 'item.started',
|
|
ITEM_COMPLETED: 'item.completed',
|
|
THREAD_COMPLETED: 'thread.completed',
|
|
ERROR: 'error'
|
|
};
|
|
|
|
/**
|
|
* Codex Executor - Manages Codex CLI process execution
|
|
*/
|
|
class CodexExecutor extends EventEmitter {
|
|
constructor() {
|
|
super();
|
|
this.currentProcess = null;
|
|
this.codexPath = null;
|
|
}
|
|
|
|
/**
|
|
* Find and cache the Codex CLI path
|
|
* @returns {string|null} Path to codex executable
|
|
*/
|
|
findCodexPath() {
|
|
if (this.codexPath) {
|
|
return this.codexPath;
|
|
}
|
|
|
|
const installation = CodexCliDetector.detectCodexInstallation();
|
|
if (installation.installed && installation.path) {
|
|
this.codexPath = installation.path;
|
|
return this.codexPath;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Execute a Codex CLI query
|
|
* @param {Object} options Execution options
|
|
* @param {string} options.prompt The prompt to execute
|
|
* @param {string} options.model Model to use (default: gpt-5.1-codex-max)
|
|
* @param {string} options.cwd Working directory
|
|
* @param {string} options.systemPrompt System prompt (optional, will be prepended to prompt)
|
|
* @param {number} options.maxTurns Not used - Codex CLI doesn't support this parameter
|
|
* @param {string[]} options.allowedTools Not used - Codex CLI doesn't support this parameter
|
|
* @param {Object} options.env Environment variables
|
|
* @param {Object} options.mcpServers MCP servers configuration (for configuring Codex TOML)
|
|
* @returns {AsyncGenerator} Generator yielding messages
|
|
*/
|
|
async *execute(options) {
|
|
const {
|
|
prompt,
|
|
model = 'gpt-5.1-codex-max',
|
|
cwd = process.cwd(),
|
|
systemPrompt,
|
|
maxTurns, // Not used by Codex CLI
|
|
allowedTools, // Not used by Codex CLI
|
|
env = {},
|
|
mcpServers = null
|
|
} = options;
|
|
|
|
const codexPath = this.findCodexPath();
|
|
if (!codexPath) {
|
|
yield {
|
|
type: 'error',
|
|
error: 'Codex CLI not found. Please install it with: npm install -g @openai/codex@latest'
|
|
};
|
|
return;
|
|
}
|
|
|
|
// Configure MCP server if provided
|
|
if (mcpServers && mcpServers['automaker-tools']) {
|
|
try {
|
|
// Get the absolute path to the MCP server script
|
|
const mcpServerScriptPath = path.resolve(__dirname, 'mcp-server-stdio.js');
|
|
|
|
// Verify the script exists
|
|
const fs = require('fs');
|
|
if (!fs.existsSync(mcpServerScriptPath)) {
|
|
console.warn(`[CodexExecutor] MCP server script not found at ${mcpServerScriptPath}, skipping MCP configuration`);
|
|
} else {
|
|
// Configure Codex TOML to use the MCP server
|
|
await codexConfigManager.configureMcpServer(cwd, mcpServerScriptPath);
|
|
console.log('[CodexExecutor] Configured automaker-tools MCP server for Codex CLI');
|
|
}
|
|
} catch (error) {
|
|
console.error('[CodexExecutor] Failed to configure MCP server:', error);
|
|
// Continue execution even if MCP config fails - Codex will work without MCP tools
|
|
}
|
|
}
|
|
|
|
// Combine system prompt with main prompt if provided
|
|
// Codex CLI doesn't support --system-prompt argument, so we prepend it to the prompt
|
|
let combinedPrompt = prompt;
|
|
console.log('[CodexExecutor] Original prompt length:', prompt?.length || 0);
|
|
if (systemPrompt) {
|
|
combinedPrompt = `${systemPrompt}\n\n---\n\n${prompt}`;
|
|
console.log('[CodexExecutor] System prompt prepended to main prompt');
|
|
console.log('[CodexExecutor] System prompt length:', systemPrompt.length);
|
|
console.log('[CodexExecutor] Combined prompt length:', combinedPrompt.length);
|
|
}
|
|
|
|
// Build command arguments
|
|
// Note: maxTurns and allowedTools are not supported by Codex CLI
|
|
console.log('[CodexExecutor] Building command arguments...');
|
|
const args = this.buildArgs({
|
|
prompt: combinedPrompt,
|
|
model
|
|
});
|
|
|
|
console.log('[CodexExecutor] Executing command:', codexPath);
|
|
console.log('[CodexExecutor] Number of args:', args.length);
|
|
console.log('[CodexExecutor] Args (without prompt):', args.slice(0, -1).join(' '));
|
|
console.log('[CodexExecutor] Prompt length in args:', args[args.length - 1]?.length || 0);
|
|
console.log('[CodexExecutor] Prompt preview (first 200 chars):', args[args.length - 1]?.substring(0, 200));
|
|
console.log('[CodexExecutor] Working directory:', cwd);
|
|
|
|
// Spawn the process
|
|
const processEnv = {
|
|
...process.env,
|
|
...env,
|
|
// Ensure OPENAI_API_KEY is available
|
|
OPENAI_API_KEY: env.OPENAI_API_KEY || process.env.OPENAI_API_KEY
|
|
};
|
|
|
|
// Log API key status (without exposing the key)
|
|
if (processEnv.OPENAI_API_KEY) {
|
|
console.log('[CodexExecutor] OPENAI_API_KEY is set (length:', processEnv.OPENAI_API_KEY.length, ')');
|
|
} else {
|
|
console.warn('[CodexExecutor] WARNING: OPENAI_API_KEY is not set!');
|
|
}
|
|
|
|
console.log('[CodexExecutor] Spawning process...');
|
|
const proc = spawn(codexPath, args, {
|
|
cwd,
|
|
env: processEnv,
|
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
});
|
|
|
|
this.currentProcess = proc;
|
|
console.log('[CodexExecutor] Process spawned with PID:', proc.pid);
|
|
|
|
// Track process events
|
|
proc.on('error', (error) => {
|
|
console.error('[CodexExecutor] Process error:', error);
|
|
});
|
|
|
|
proc.on('spawn', () => {
|
|
console.log('[CodexExecutor] Process spawned successfully');
|
|
});
|
|
|
|
// Collect stderr output as it comes in
|
|
let stderr = '';
|
|
let hasOutput = false;
|
|
let stdoutChunks = [];
|
|
let stderrChunks = [];
|
|
|
|
proc.stderr.on('data', (data) => {
|
|
const errorText = data.toString();
|
|
stderr += errorText;
|
|
stderrChunks.push(errorText);
|
|
hasOutput = true;
|
|
console.error('[CodexExecutor] stderr chunk received (', data.length, 'bytes):', errorText.substring(0, 200));
|
|
});
|
|
|
|
proc.stderr.on('end', () => {
|
|
console.log('[CodexExecutor] stderr stream ended. Total chunks:', stderrChunks.length, 'Total length:', stderr.length);
|
|
});
|
|
|
|
proc.stdout.on('data', (data) => {
|
|
const text = data.toString();
|
|
stdoutChunks.push(text);
|
|
hasOutput = true;
|
|
console.log('[CodexExecutor] stdout chunk received (', data.length, 'bytes):', text.substring(0, 200));
|
|
});
|
|
|
|
proc.stdout.on('end', () => {
|
|
console.log('[CodexExecutor] stdout stream ended. Total chunks:', stdoutChunks.length);
|
|
});
|
|
|
|
// Create readline interface for parsing JSONL output
|
|
console.log('[CodexExecutor] Creating readline interface...');
|
|
const rl = readline.createInterface({
|
|
input: proc.stdout,
|
|
crlfDelay: Infinity
|
|
});
|
|
|
|
// Track accumulated content for converting to Claude format
|
|
let accumulatedText = '';
|
|
let toolUses = [];
|
|
let lastOutputTime = Date.now();
|
|
const OUTPUT_TIMEOUT = 30000; // 30 seconds timeout for no output
|
|
let lineCount = 0;
|
|
let jsonParseErrors = 0;
|
|
|
|
// Set up timeout check
|
|
const checkTimeout = setInterval(() => {
|
|
const timeSinceLastOutput = Date.now() - lastOutputTime;
|
|
if (timeSinceLastOutput > OUTPUT_TIMEOUT && !hasOutput) {
|
|
console.warn('[CodexExecutor] No output received for', timeSinceLastOutput, 'ms. Process still alive:', !proc.killed);
|
|
}
|
|
}, 5000);
|
|
|
|
console.log('[CodexExecutor] Starting to read lines from stdout...');
|
|
|
|
// Process stdout line by line (JSONL format)
|
|
try {
|
|
for await (const line of rl) {
|
|
hasOutput = true;
|
|
lastOutputTime = Date.now();
|
|
lineCount++;
|
|
|
|
console.log('[CodexExecutor] Line', lineCount, 'received (length:', line.length, '):', line.substring(0, 100));
|
|
|
|
if (!line.trim()) {
|
|
console.log('[CodexExecutor] Skipping empty line');
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const event = JSON.parse(line);
|
|
console.log('[CodexExecutor] Successfully parsed JSON event. Type:', event.type, 'Keys:', Object.keys(event));
|
|
|
|
const convertedMsg = this.convertToClaudeFormat(event);
|
|
console.log('[CodexExecutor] Converted message:', convertedMsg ? { type: convertedMsg.type } : 'null');
|
|
|
|
if (convertedMsg) {
|
|
// Accumulate text content
|
|
if (convertedMsg.type === 'assistant' && convertedMsg.message?.content) {
|
|
for (const block of convertedMsg.message.content) {
|
|
if (block.type === 'text') {
|
|
accumulatedText += block.text;
|
|
console.log('[CodexExecutor] Accumulated text block (total length:', accumulatedText.length, ')');
|
|
} else if (block.type === 'tool_use') {
|
|
toolUses.push(block);
|
|
console.log('[CodexExecutor] Tool use detected:', block.name);
|
|
}
|
|
}
|
|
}
|
|
console.log('[CodexExecutor] Yielding message of type:', convertedMsg.type);
|
|
yield convertedMsg;
|
|
} else {
|
|
console.log('[CodexExecutor] Converted message is null, skipping');
|
|
}
|
|
} catch (parseError) {
|
|
jsonParseErrors++;
|
|
// Non-JSON output, yield as text
|
|
console.log('[CodexExecutor] JSON parse error (', jsonParseErrors, 'total):', parseError.message);
|
|
console.log('[CodexExecutor] Non-JSON line content:', line.substring(0, 200));
|
|
yield {
|
|
type: 'assistant',
|
|
message: {
|
|
content: [{ type: 'text', text: line + '\n' }]
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
console.log('[CodexExecutor] Finished reading all lines. Total lines:', lineCount, 'JSON errors:', jsonParseErrors);
|
|
} catch (readError) {
|
|
console.error('[CodexExecutor] Error reading from readline:', readError);
|
|
throw readError;
|
|
} finally {
|
|
clearInterval(checkTimeout);
|
|
console.log('[CodexExecutor] Cleaned up timeout checker');
|
|
}
|
|
|
|
// Handle process completion
|
|
console.log('[CodexExecutor] Waiting for process to close...');
|
|
const exitCode = await new Promise((resolve) => {
|
|
proc.on('close', (code, signal) => {
|
|
console.log('[CodexExecutor] Process closed with code:', code, 'signal:', signal);
|
|
resolve(code);
|
|
});
|
|
});
|
|
|
|
this.currentProcess = null;
|
|
console.log('[CodexExecutor] Process completed. Exit code:', exitCode, 'Has output:', hasOutput, 'Stderr length:', stderr.length);
|
|
|
|
// Wait a bit for any remaining stderr data to be collected
|
|
console.log('[CodexExecutor] Waiting 200ms for any remaining stderr data...');
|
|
await new Promise(resolve => setTimeout(resolve, 200));
|
|
console.log('[CodexExecutor] Final stderr length:', stderr.length, 'Final stdout chunks:', stdoutChunks.length);
|
|
|
|
if (exitCode !== 0) {
|
|
const errorMessage = stderr.trim()
|
|
? `Codex CLI exited with code ${exitCode}.\n\nError output:\n${stderr}`
|
|
: `Codex CLI exited with code ${exitCode}. No error output captured.`;
|
|
|
|
console.error('[CodexExecutor] Process failed with exit code', exitCode);
|
|
console.error('[CodexExecutor] Error message:', errorMessage);
|
|
console.error('[CodexExecutor] Stderr chunks:', stderrChunks.length, 'Stdout chunks:', stdoutChunks.length);
|
|
|
|
yield {
|
|
type: 'error',
|
|
error: errorMessage
|
|
};
|
|
} else if (!hasOutput && !stderr) {
|
|
// Process exited successfully but produced no output - might be API key issue
|
|
const warningMessage = 'Codex CLI completed but produced no output. This might indicate:\n' +
|
|
'- Missing or invalid OPENAI_API_KEY\n' +
|
|
'- Codex CLI configuration issue\n' +
|
|
'- The process completed without generating any response\n\n' +
|
|
`Debug info: Exit code ${exitCode}, stdout chunks: ${stdoutChunks.length}, stderr chunks: ${stderrChunks.length}, lines read: ${lineCount}`;
|
|
|
|
console.warn('[CodexExecutor] No output detected:', warningMessage);
|
|
console.warn('[CodexExecutor] Stdout chunks:', stdoutChunks);
|
|
console.warn('[CodexExecutor] Stderr chunks:', stderrChunks);
|
|
|
|
yield {
|
|
type: 'error',
|
|
error: warningMessage
|
|
};
|
|
} else {
|
|
console.log('[CodexExecutor] Process completed successfully. Exit code:', exitCode, 'Lines processed:', lineCount);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build command arguments for Codex CLI
|
|
* Only includes supported arguments based on Codex CLI help:
|
|
* - --model: Model to use
|
|
* - --json: JSON output format
|
|
* - --full-auto: Non-interactive automatic execution
|
|
*
|
|
* Note: Codex CLI does NOT support:
|
|
* - --system-prompt (system prompt is prepended to main prompt)
|
|
* - --max-turns (not available in CLI)
|
|
* - --tools (not available in CLI)
|
|
*
|
|
* @param {Object} options Options
|
|
* @returns {string[]} Command arguments
|
|
*/
|
|
buildArgs(options) {
|
|
const { prompt, model } = options;
|
|
|
|
console.log('[CodexExecutor] buildArgs called with model:', model, 'prompt length:', prompt?.length || 0);
|
|
|
|
const args = ['exec'];
|
|
|
|
// Add model (required for most use cases)
|
|
if (model) {
|
|
args.push('--model', model);
|
|
console.log('[CodexExecutor] Added model argument:', model);
|
|
}
|
|
|
|
// Add JSON output flag for structured parsing
|
|
args.push('--json');
|
|
console.log('[CodexExecutor] Added --json flag');
|
|
|
|
// Add full-auto mode (non-interactive)
|
|
// This enables automatic execution with workspace-write sandbox
|
|
args.push('--full-auto');
|
|
console.log('[CodexExecutor] Added --full-auto flag');
|
|
|
|
// Add the prompt at the end
|
|
args.push(prompt);
|
|
console.log('[CodexExecutor] Added prompt (length:', prompt?.length || 0, ')');
|
|
|
|
console.log('[CodexExecutor] Final args count:', args.length);
|
|
return args;
|
|
}
|
|
|
|
/**
|
|
* Map Claude tool names to Codex tool names
|
|
* @param {string[]} tools Array of tool names
|
|
* @returns {string[]} Mapped tool names
|
|
*/
|
|
mapToolsToCodex(tools) {
|
|
const toolMap = {
|
|
'Read': 'read',
|
|
'Write': 'write',
|
|
'Edit': 'edit',
|
|
'Bash': 'bash',
|
|
'Glob': 'glob',
|
|
'Grep': 'grep',
|
|
'WebSearch': 'web-search',
|
|
'WebFetch': 'web-fetch'
|
|
};
|
|
|
|
return tools
|
|
.map(tool => toolMap[tool] || tool.toLowerCase())
|
|
.filter(tool => tool); // Remove undefined
|
|
}
|
|
|
|
/**
|
|
* Convert Codex JSONL event to Claude SDK message format
|
|
* @param {Object} event Codex event object
|
|
* @returns {Object|null} Claude-format message or null
|
|
*/
|
|
convertToClaudeFormat(event) {
|
|
console.log('[CodexExecutor] Converting event:', JSON.stringify(event).substring(0, 200));
|
|
const { type, data, item, thread_id } = event;
|
|
|
|
switch (type) {
|
|
case CODEX_EVENT_TYPES.THREAD_STARTED:
|
|
case 'thread.started':
|
|
// Session initialization
|
|
return {
|
|
type: 'session_start',
|
|
sessionId: thread_id || data?.thread_id || event.thread_id
|
|
};
|
|
|
|
case CODEX_EVENT_TYPES.ITEM_COMPLETED:
|
|
case 'item.completed':
|
|
// Codex uses 'item' field, not 'data'
|
|
return this.convertItemCompleted(item || data);
|
|
|
|
case CODEX_EVENT_TYPES.ITEM_STARTED:
|
|
case 'item.started':
|
|
// Convert item.started events - these indicate tool/command usage
|
|
const startedItem = item || data;
|
|
if (startedItem?.type === 'command_execution' && startedItem?.command) {
|
|
return {
|
|
type: 'assistant',
|
|
message: {
|
|
content: [{
|
|
type: 'tool_use',
|
|
name: 'bash',
|
|
input: { command: startedItem.command }
|
|
}]
|
|
}
|
|
};
|
|
}
|
|
// For other item.started types, return null (we'll show the completed version)
|
|
return null;
|
|
|
|
case CODEX_EVENT_TYPES.THREAD_COMPLETED:
|
|
case 'thread.completed':
|
|
return {
|
|
type: 'complete',
|
|
sessionId: thread_id || data?.thread_id || event.thread_id
|
|
};
|
|
|
|
case CODEX_EVENT_TYPES.ERROR:
|
|
case 'error':
|
|
return {
|
|
type: 'error',
|
|
error: data?.message || item?.message || event.message || 'Unknown error from Codex CLI'
|
|
};
|
|
|
|
case 'turn.started':
|
|
// Turn started - just a marker, no need to convert
|
|
return null;
|
|
|
|
default:
|
|
// Pass through other events
|
|
console.log('[CodexExecutor] Unhandled event type:', type);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert item.completed event to Claude format
|
|
* @param {Object} item Event item data
|
|
* @returns {Object|null} Claude-format message
|
|
*/
|
|
convertItemCompleted(item) {
|
|
if (!item) {
|
|
console.log('[CodexExecutor] convertItemCompleted: item is null/undefined');
|
|
return null;
|
|
}
|
|
|
|
const itemType = item.type || item.item_type;
|
|
console.log('[CodexExecutor] convertItemCompleted: itemType =', itemType, 'item keys:', Object.keys(item));
|
|
|
|
switch (itemType) {
|
|
case 'reasoning':
|
|
// Thinking/reasoning output - Codex uses 'text' field
|
|
const reasoningText = item.text || item.content || '';
|
|
console.log('[CodexExecutor] Converting reasoning, text length:', reasoningText.length);
|
|
return {
|
|
type: 'assistant',
|
|
message: {
|
|
content: [{
|
|
type: 'thinking',
|
|
thinking: reasoningText
|
|
}]
|
|
}
|
|
};
|
|
|
|
case 'agent_message':
|
|
case 'message':
|
|
// Assistant text message
|
|
const messageText = item.content || item.text || '';
|
|
console.log('[CodexExecutor] Converting message, text length:', messageText.length);
|
|
return {
|
|
type: 'assistant',
|
|
message: {
|
|
content: [{
|
|
type: 'text',
|
|
text: messageText
|
|
}]
|
|
}
|
|
};
|
|
|
|
case 'command_execution':
|
|
// Command execution - show both the command and its output
|
|
const command = item.command || '';
|
|
const output = item.aggregated_output || item.output || '';
|
|
console.log('[CodexExecutor] Converting command_execution, command:', command.substring(0, 50), 'output length:', output.length);
|
|
|
|
// Return as text message showing the command and output
|
|
return {
|
|
type: 'assistant',
|
|
message: {
|
|
content: [{
|
|
type: 'text',
|
|
text: `\`\`\`bash\n${command}\n\`\`\`\n\n${output}`
|
|
}]
|
|
}
|
|
};
|
|
|
|
case 'tool_use':
|
|
// Tool use
|
|
return {
|
|
type: 'assistant',
|
|
message: {
|
|
content: [{
|
|
type: 'tool_use',
|
|
name: item.tool || item.command || 'unknown',
|
|
input: item.input || item.args || {}
|
|
}]
|
|
}
|
|
};
|
|
|
|
case 'tool_result':
|
|
// Tool result
|
|
return {
|
|
type: 'tool_result',
|
|
tool_use_id: item.tool_use_id,
|
|
content: item.output || item.result
|
|
};
|
|
|
|
case 'todo_list':
|
|
// Todo list - convert to text format
|
|
const todos = item.items || [];
|
|
const todoText = todos.map((t, i) => `${i + 1}. ${t.text || t}`).join('\n');
|
|
console.log('[CodexExecutor] Converting todo_list, items:', todos.length);
|
|
return {
|
|
type: 'assistant',
|
|
message: {
|
|
content: [{
|
|
type: 'text',
|
|
text: `**Todo List:**\n${todoText}`
|
|
}]
|
|
}
|
|
};
|
|
|
|
default:
|
|
// Generic text output
|
|
const text = item.text || item.content || item.aggregated_output;
|
|
if (text) {
|
|
console.log('[CodexExecutor] Converting default item type, text length:', text.length);
|
|
return {
|
|
type: 'assistant',
|
|
message: {
|
|
content: [{
|
|
type: 'text',
|
|
text: String(text)
|
|
}]
|
|
}
|
|
};
|
|
}
|
|
console.log('[CodexExecutor] convertItemCompleted: No text content found, returning null');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Abort current execution
|
|
*/
|
|
abort() {
|
|
if (this.currentProcess) {
|
|
console.log('[CodexExecutor] Aborting current process');
|
|
this.currentProcess.kill('SIGTERM');
|
|
this.currentProcess = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if execution is in progress
|
|
* @returns {boolean} Whether execution is in progress
|
|
*/
|
|
isRunning() {
|
|
return this.currentProcess !== null;
|
|
}
|
|
}
|
|
|
|
// Singleton instance
|
|
const codexExecutor = new CodexExecutor();
|
|
|
|
module.exports = codexExecutor;
|