'use strict'; // Codex CLI 執行層:spawn `codex exec --json`,逐行解析 JSONL 事件。 // Windows 上 codex 是 .cmd shim(node codex.js 的包裝),直接解析出 codex.js // 用 node 執行,避開 shell:true 的引號問題與 DEP0190 警告。 const { spawn, execSync } = require('child_process'); const fs = require('fs'); const path = require('path'); let cachedCommand = null; // 在 PATH 中尋找 codex shim,回推 @openai/codex/bin/codex.js 的實際位置 function findCodexJs() { const dirs = (process.env.PATH || '').split(path.delimiter); for (const dir of dirs) { if (!dir) continue; const shim = path.join(dir, process.platform === 'win32' ? 'codex.cmd' : 'codex'); if (!fs.existsSync(shim)) continue; const candidates = [ path.join(dir, 'node_modules', '@openai', 'codex', 'bin', 'codex.js'), // unix 下 shim 是 symlink,指向 lib/node_modules 下的實體 path.join(dir, '..', 'lib', 'node_modules', '@openai', 'codex', 'bin', 'codex.js'), ]; for (const c of candidates) { if (fs.existsSync(c)) return c; } } // 最後手段:問 npm 全域 root try { const root = execSync('npm root -g', { encoding: 'utf-8', windowsHide: true }).trim(); const c = path.join(root, '@openai', 'codex', 'bin', 'codex.js'); if (fs.existsSync(c)) return c; } catch { /* ignore */ } return null; } // 回傳 { cmd, baseArgs, shell }:實際 spawn 的指令與前置參數 function resolveCodexCommand(config) { if (cachedCommand) return cachedCommand; const custom = config && config.codexPath; if (custom) { cachedCommand = custom.endsWith('.js') ? { cmd: process.execPath, baseArgs: [custom], shell: false } : { cmd: custom, baseArgs: [], shell: false }; return cachedCommand; } if (process.platform === 'win32') { const js = findCodexJs(); if (js) { cachedCommand = { cmd: process.execPath, baseArgs: [js], shell: false }; return cachedCommand; } // 找不到實體時退回 shell(.cmd 需要 shell 才能執行) cachedCommand = { cmd: 'codex', baseArgs: [], shell: true }; return cachedCommand; } cachedCommand = { cmd: 'codex', baseArgs: [], shell: false }; return cachedCommand; } function killTree(proc) { if (process.platform === 'win32') { // node shim 的子程序(codex.exe)不會隨父程序死掉,要整棵砍 try { execSync(`taskkill /pid ${proc.pid} /T /F`, { windowsHide: true, stdio: 'ignore' }); } catch { /* ignore */ } } else { try { proc.kill('SIGKILL'); } catch { /* ignore */ } } } const PROGRESS_LABELS = { command_execution: '⚙️ 執行指令', file_change: '✏️ 修改檔案', mcp_tool_call: '🔧 呼叫工具', web_search: '🔎 搜尋網路', reasoning: '🤔 思考中', }; /** * 執行一輪 Codex 對話。 * @param {object} opts * @param {object} opts.config 載入後的設定 * @param {string} opts.prompt 使用者 prompt(走 stdin,任意內容都安全) * @param {string|null} opts.threadId 既有會話 id;null 表示開新會話 * @param {string[]} [opts.images] 附圖檔案路徑 * @param {(text: string) => void} [opts.onProgress] 進度回呼 * @returns {Promise<{text: string, threadId: string|null, usage: object|null}>} */ function runCodex({ config, prompt, threadId, images = [], onProgress }) { return new Promise((resolve, reject) => { const { cmd, baseArgs, shell } = resolveCodexCommand(config); const args = [...baseArgs, 'exec']; if (threadId) args.push('resume', threadId); args.push('--json', '--skip-git-repo-check'); if (threadId) { // resume 子指令不吃 -s / -m / --color,改走 -c 設定覆寫 args.push('-c', `sandbox_mode="${config.sandbox}"`); if (config.model) args.push('-c', `model="${config.model}"`); } else { args.push('--color', 'never', '-s', config.sandbox); if (config.model) args.push('-m', config.model); } if (config.reasoningEffort) args.push('-c', `model_reasoning_effort="${config.reasoningEffort}"`); if (config.serviceTier) args.push('-c', `service_tier="${config.serviceTier}"`); for (const img of images) args.push('-i', img); args.push('-'); // prompt 從 stdin 讀 const proc = spawn(cmd, args, { cwd: config.workDir, env: { ...process.env }, shell, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], }); proc.stdin.write(prompt); proc.stdin.end(); let buffer = ''; let stderrTail = ''; let resultThreadId = threadId || null; let usage = null; let turnCompleted = false; let turnFailedMessage = null; const messages = []; const timer = setTimeout(() => { killTree(proc); reject(new Error(`Codex 逾時(${config.timeoutMinutes} 分鐘)`)); }, config.timeoutMinutes * 60 * 1000); const handleEvent = (event) => { const item = event.item; switch (event.type) { case 'thread.started': if (event.thread_id) resultThreadId = event.thread_id; break; case 'turn.completed': turnCompleted = true; usage = event.usage || null; break; case 'turn.failed': turnFailedMessage = event.error?.message || 'turn.failed(未提供原因)'; break; case 'error': turnFailedMessage = event.message || event.error?.message || 'Codex 回報錯誤'; break; case 'item.started': case 'item.updated': { if (!onProgress || !item) break; const label = PROGRESS_LABELS[item.type]; if (!label) break; let detail = ''; if (item.type === 'command_execution' && item.command) { detail = item.command.length > 300 ? item.command.slice(0, 300) + '...' : item.command; } onProgress(`${label}...${detail ? '\n' + detail : ''}`); break; } case 'item.completed': { if (!item) break; if (item.type === 'agent_message' && item.text) { messages.push(item.text); if (onProgress) { const t = item.text.trim(); onProgress('💬 ' + (t.length > 600 ? t.slice(0, 600) + '...' : t)); } } else if (item.type === 'error' && item.message) { turnFailedMessage = item.message; } else if (item.type === 'reasoning' && item.text && onProgress) { const t = item.text.trim(); onProgress('🤔 ' + (t.length > 600 ? t.slice(0, 600) + '...' : t)); } break; } default: break; } }; proc.stdout.on('data', (chunk) => { buffer += chunk.toString(); const lines = buffer.split('\n'); buffer = lines.pop(); // 最後一段可能被截斷,留到下一批 for (const line of lines) { if (!line.trim()) continue; try { handleEvent(JSON.parse(line)); } catch { /* 非 JSON 行(log 雜訊)直接忽略 */ } } }); proc.stderr.on('data', (chunk) => { stderrTail = (stderrTail + chunk.toString()).slice(-2000); if (process.env.DEBUG) console.error('[codex stderr]', chunk.toString()); }); proc.on('close', (code) => { clearTimeout(timer); if (turnFailedMessage) { return reject(new Error(`Codex 執行失敗:${turnFailedMessage}`)); } if (turnCompleted || messages.length > 0) { return resolve({ text: messages.join('\n\n'), threadId: resultThreadId, usage }); } const hint = stderrTail .split('\n') .filter((l) => l.trim() && !/^\d{4}-\d{2}-\d{2}T.*(ERROR|WARN) codex_models_manager/.test(l)) .slice(-5) .join('\n'); reject(new Error(`codex 結束(exit code ${code})${hint ? '\n' + hint : ''}`)); }); proc.on('error', (err) => { clearTimeout(timer); reject(new Error(`無法啟動 codex:${err.message}(請確認已安裝 @openai/codex 並在 PATH 中)`)); }); }); } module.exports = { runCodex, resolveCodexCommand, findCodexJs };