221 lines
8.0 KiB
JavaScript
221 lines
8.0 KiB
JavaScript
'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 };
|