2026-08-04 16:25:39 +08:00
'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 } "` );
2026-08-14 17:13:12 +08:00
if ( config . networkAccess ) args . push ( '-c' , 'sandbox_workspace_write.network_access=true' );
2026-08-04 16:25:39 +08:00
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 ) {
2026-08-17 16:08:58 +08:00
const e = new Error ( `Codex 執行失敗: ${ turnFailedMessage } ` );
e . threadId = resultThreadId ; // 會話可能已建立,讓呼叫端能 resume 重試
// 暫時性連線問題(OpenAI 端切斷串流等),呼叫端可重試
e . transient = /stream disconnected|websocket closed|Falling back from WebSockets|connection reset|ECONNRESET|socket hang up|timed out/i . test ( turnFailedMessage );
return reject ( e );
2026-08-04 16:25:39 +08:00
}
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 };