423 lines
13 KiB
JavaScript
423 lines
13 KiB
JavaScript
function createAgentRuntime(deps) {
|
|
const {
|
|
processEnv,
|
|
CLAUDE_PATH,
|
|
CODEX_PATH,
|
|
MODEL_MAP,
|
|
loadModelConfig,
|
|
applyCustomTemplateToSettings,
|
|
loadCodexConfig,
|
|
prepareCodexCustomRuntime,
|
|
wsSend,
|
|
truncateObj,
|
|
sanitizeToolInput,
|
|
loadSession,
|
|
saveSession,
|
|
setRuntimeSessionId,
|
|
getRuntimeSessionId,
|
|
} = deps;
|
|
|
|
function buildClaudeSpawnSpec(session, options = {}) {
|
|
const hasAttachments = Array.isArray(options.attachments) && options.attachments.length > 0;
|
|
const args = ['-p', '--output-format', 'stream-json', '--verbose'];
|
|
if (hasAttachments) args.push('--input-format', 'stream-json');
|
|
const permMode = session.permissionMode || 'yolo';
|
|
switch (permMode) {
|
|
case 'yolo':
|
|
args.push('--dangerously-skip-permissions');
|
|
break;
|
|
case 'plan':
|
|
args.push('--permission-mode', 'plan');
|
|
break;
|
|
case 'default':
|
|
args.push('--permission-mode', 'default');
|
|
break;
|
|
}
|
|
if (session.claudeSessionId) {
|
|
args.push('--resume', session.claudeSessionId);
|
|
}
|
|
if (session.model) {
|
|
args.push('--model', session.model);
|
|
}
|
|
|
|
const env = { ...processEnv };
|
|
delete env.CLAUDECODE;
|
|
delete env.CLAUDE_CODE;
|
|
delete env.CC_WEB_PASSWORD;
|
|
for (const k of Object.keys(env)) {
|
|
if (k.startsWith('ANTHROPIC_')) delete env[k];
|
|
}
|
|
|
|
const modelCfg = loadModelConfig();
|
|
if (modelCfg.mode === 'custom' && modelCfg.activeTemplate) {
|
|
const tpl = (modelCfg.templates || []).find((t) => t.name === modelCfg.activeTemplate);
|
|
if (tpl) applyCustomTemplateToSettings(tpl);
|
|
}
|
|
|
|
return {
|
|
command: CLAUDE_PATH,
|
|
args,
|
|
env,
|
|
cwd: session.cwd || processEnv.HOME || processEnv.USERPROFILE || process.cwd(),
|
|
parser: 'claude',
|
|
mode: permMode,
|
|
resume: !!session.claudeSessionId,
|
|
};
|
|
}
|
|
|
|
function buildCodexSpawnSpec(session, options = {}) {
|
|
const codexConfig = loadCodexConfig();
|
|
const runtimeConfig = prepareCodexCustomRuntime(codexConfig);
|
|
if (runtimeConfig?.error) {
|
|
return { error: runtimeConfig.error };
|
|
}
|
|
const runtimeId = getRuntimeSessionId(session);
|
|
const args = ['exec'];
|
|
args.push('--json', '--skip-git-repo-check');
|
|
|
|
const permMode = session.permissionMode || 'yolo';
|
|
// `-s/--sandbox` is an option for `codex exec`, but not for `codex exec resume`.
|
|
// When resuming, it must appear before the `resume` subcommand, otherwise Codex CLI errors
|
|
// with: "unexpected argument '-s' found".
|
|
if (runtimeId && permMode === 'plan') {
|
|
args.push('-s', 'read-only');
|
|
}
|
|
if (runtimeId) args.push('resume');
|
|
switch (permMode) {
|
|
case 'yolo':
|
|
args.push('--dangerously-bypass-approvals-and-sandbox');
|
|
break;
|
|
case 'plan':
|
|
if (!runtimeId) args.push('-s', 'read-only');
|
|
break;
|
|
case 'default':
|
|
default:
|
|
args.push('--full-auto');
|
|
break;
|
|
}
|
|
|
|
const effectiveModel = session.model;
|
|
if (effectiveModel) {
|
|
const raw = String(effectiveModel).trim();
|
|
// cc-web UI supports "gpt-5.4(high)" style selection, but Codex CLI expects:
|
|
// - model: "gpt-5.4"
|
|
// - reasoning effort: config key `model_reasoning_effort = "high"`
|
|
const m = raw.match(/^(.*)\((medium|high|xhigh)\)\s*$/i);
|
|
if (m) {
|
|
const base = String(m[1] || '').trim();
|
|
const lvl = String(m[2] || '').trim().toLowerCase();
|
|
if (base) args.push('--model', base);
|
|
// Use TOML string literal to avoid parsing ambiguity.
|
|
args.push('-c', `model_reasoning_effort="${lvl}"`);
|
|
} else {
|
|
args.push('--model', raw);
|
|
}
|
|
}
|
|
if (Array.isArray(options.attachments)) {
|
|
for (const attachment of options.attachments) {
|
|
if (attachment?.path) args.push('--image', attachment.path);
|
|
}
|
|
}
|
|
if (runtimeId) {
|
|
args.push(runtimeId, '-');
|
|
} else {
|
|
if (session.cwd) args.push('-C', session.cwd);
|
|
args.push('-');
|
|
}
|
|
|
|
const env = { ...processEnv };
|
|
delete env.CC_WEB_PASSWORD;
|
|
delete env.CLAUDECODE;
|
|
delete env.CLAUDE_CODE;
|
|
if (runtimeConfig?.mode === 'custom') {
|
|
env.CODEX_HOME = runtimeConfig.homeDir;
|
|
env.OPENAI_API_KEY = runtimeConfig.apiKey;
|
|
delete env.OPENAI_BASE_URL;
|
|
}
|
|
|
|
return {
|
|
command: CODEX_PATH,
|
|
args,
|
|
env,
|
|
cwd: session.cwd || processEnv.HOME || processEnv.USERPROFILE || process.cwd(),
|
|
parser: 'codex',
|
|
mode: permMode,
|
|
resume: !!runtimeId,
|
|
};
|
|
}
|
|
|
|
function codexToolName(item) {
|
|
switch (item?.type) {
|
|
case 'command_execution':
|
|
return 'CommandExecution';
|
|
case 'mcp_tool_call':
|
|
return 'McpToolCall';
|
|
case 'file_change':
|
|
return 'FileChange';
|
|
case 'reasoning':
|
|
return 'Reasoning';
|
|
default:
|
|
return item?.type || 'CodexItem';
|
|
}
|
|
}
|
|
|
|
function codexToolInput(item) {
|
|
if (!item) return null;
|
|
if (item.type === 'command_execution') return { command: item.command || '' };
|
|
return truncateObj(item, 500);
|
|
}
|
|
|
|
function codexToolMeta(item) {
|
|
if (!item) return null;
|
|
switch (item.type) {
|
|
case 'command_execution':
|
|
return {
|
|
kind: 'command_execution',
|
|
title: 'Shell Command',
|
|
subtitle: item.command || '',
|
|
exitCode: typeof item.exit_code === 'number' ? item.exit_code : null,
|
|
status: item.status || null,
|
|
};
|
|
case 'mcp_tool_call':
|
|
return {
|
|
kind: 'mcp_tool_call',
|
|
title: 'MCP Tool',
|
|
subtitle: item.tool_name || item.name || item.server_name || '',
|
|
status: item.status || null,
|
|
};
|
|
case 'file_change':
|
|
return {
|
|
kind: 'file_change',
|
|
title: 'File Change',
|
|
subtitle: item.path || item.file_path || '',
|
|
status: item.status || null,
|
|
};
|
|
case 'reasoning':
|
|
return {
|
|
kind: 'reasoning',
|
|
title: 'Reasoning',
|
|
subtitle: typeof item.text === 'string' ? item.text.slice(0, 120) : '',
|
|
status: item.status || null,
|
|
};
|
|
default:
|
|
return {
|
|
kind: item.type || 'codex_item',
|
|
title: codexToolName(item),
|
|
subtitle: '',
|
|
status: item.status || null,
|
|
};
|
|
}
|
|
}
|
|
|
|
function codexToolResult(item) {
|
|
if (!item) return '';
|
|
if (typeof item.aggregated_output === 'string' && item.aggregated_output) return item.aggregated_output;
|
|
if (typeof item.text === 'string' && item.text) return item.text;
|
|
return JSON.stringify(truncateObj(item, 1200));
|
|
}
|
|
|
|
function ensureCodexToolCall(entry, item) {
|
|
let tc = entry.toolCalls.find((t) => t.id === item.id);
|
|
if (tc) {
|
|
tc.name = codexToolName(item);
|
|
tc.kind = item.type || tc.kind || null;
|
|
tc.meta = codexToolMeta(item) || tc.meta || null;
|
|
if (tc.input == null) tc.input = codexToolInput(item);
|
|
return tc;
|
|
}
|
|
tc = {
|
|
name: codexToolName(item),
|
|
id: item.id,
|
|
kind: item.type || null,
|
|
meta: codexToolMeta(item),
|
|
input: codexToolInput(item),
|
|
done: false,
|
|
};
|
|
entry.toolCalls.push(tc);
|
|
wsSend(entry.ws, {
|
|
type: 'tool_start',
|
|
name: tc.name,
|
|
toolUseId: item.id,
|
|
input: tc.input,
|
|
kind: tc.kind,
|
|
meta: tc.meta,
|
|
});
|
|
return tc;
|
|
}
|
|
|
|
function processClaudeEvent(entry, event, sessionId) {
|
|
if (!event || !event.type) return;
|
|
|
|
switch (event.type) {
|
|
case 'system':
|
|
if (event.session_id) {
|
|
const session = loadSession(sessionId);
|
|
if (session) {
|
|
session.claudeSessionId = event.session_id;
|
|
saveSession(session);
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'assistant': {
|
|
const content = event.message?.content;
|
|
if (!Array.isArray(content)) break;
|
|
|
|
for (const block of content) {
|
|
if (block.type === 'text' && block.text) {
|
|
entry.fullText += block.text;
|
|
wsSend(entry.ws, { type: 'text_delta', text: block.text });
|
|
} else if (block.type === 'tool_use') {
|
|
const toolInput = sanitizeToolInput(block.name, block.input);
|
|
const tc = { name: block.name, id: block.id, input: toolInput, done: false };
|
|
entry.toolCalls.push(tc);
|
|
wsSend(entry.ws, { type: 'tool_start', name: block.name, toolUseId: block.id, input: tc.input });
|
|
} else if (block.type === 'tool_result') {
|
|
const resultText = typeof block.content === 'string'
|
|
? block.content
|
|
: Array.isArray(block.content)
|
|
? block.content.map((c) => c.text || '').join('\n')
|
|
: JSON.stringify(block.content);
|
|
const tc = entry.toolCalls.find((t) => t.id === block.tool_use_id);
|
|
if (tc) {
|
|
tc.done = true;
|
|
tc.result = resultText.slice(0, 2000);
|
|
}
|
|
wsSend(entry.ws, { type: 'tool_end', toolUseId: block.tool_use_id, result: resultText.slice(0, 2000) });
|
|
}
|
|
}
|
|
|
|
if (event.session_id) {
|
|
const session = loadSession(sessionId);
|
|
if (session && !session.claudeSessionId) {
|
|
session.claudeSessionId = event.session_id;
|
|
saveSession(session);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'result': {
|
|
const session = loadSession(sessionId);
|
|
if (session) {
|
|
if (event.session_id) session.claudeSessionId = event.session_id;
|
|
if (event.total_cost_usd) session.totalCost = (session.totalCost || 0) + event.total_cost_usd;
|
|
saveSession(session);
|
|
}
|
|
entry.lastCost = event.total_cost_usd || null;
|
|
if (entry.ws && event.total_cost_usd !== undefined) {
|
|
wsSend(entry.ws, { type: 'cost', costUsd: session?.totalCost || 0 });
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
function processCodexEvent(entry, event, sessionId) {
|
|
if (!event || !event.type) return;
|
|
|
|
switch (event.type) {
|
|
case 'thread.started': {
|
|
if (!event.thread_id) break;
|
|
const session = loadSession(sessionId);
|
|
if (session) {
|
|
setRuntimeSessionId(session, event.thread_id);
|
|
saveSession(session);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'item.started': {
|
|
const item = event.item;
|
|
if (!item || !item.id || item.type === 'agent_message') break;
|
|
ensureCodexToolCall(entry, item);
|
|
break;
|
|
}
|
|
|
|
case 'item.completed': {
|
|
const item = event.item;
|
|
if (!item || !item.id) break;
|
|
if (item.type === 'agent_message') {
|
|
if (item.text) {
|
|
let parsedContent = null;
|
|
try {
|
|
parsedContent = JSON.parse(item.text);
|
|
} catch {}
|
|
|
|
if (parsedContent && Array.isArray(parsedContent)) {
|
|
if (!entry.contentBlocks) entry.contentBlocks = [];
|
|
entry.contentBlocks.push(...parsedContent);
|
|
const textOnly = parsedContent.filter(b => b.type === 'text').map(b => b.text || '').join('');
|
|
entry.fullText += textOnly;
|
|
wsSend(entry.ws, { type: 'content_blocks', blocks: parsedContent });
|
|
} else {
|
|
entry.fullText += item.text;
|
|
wsSend(entry.ws, { type: 'text_delta', text: item.text });
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
const tc = ensureCodexToolCall(entry, item);
|
|
const resultText = codexToolResult(item).slice(0, 2000);
|
|
tc.done = true;
|
|
tc.result = resultText;
|
|
wsSend(entry.ws, {
|
|
type: 'tool_end',
|
|
toolUseId: item.id,
|
|
result: resultText,
|
|
kind: tc.kind,
|
|
meta: tc.meta,
|
|
});
|
|
break;
|
|
}
|
|
|
|
case 'turn.completed': {
|
|
const usage = event.usage || null;
|
|
entry.lastUsage = usage;
|
|
const session = loadSession(sessionId);
|
|
if (session && usage) {
|
|
session.totalUsage = {
|
|
inputTokens: (session.totalUsage?.inputTokens || 0) + (usage.input_tokens || 0),
|
|
cachedInputTokens: (session.totalUsage?.cachedInputTokens || 0) + (usage.cached_input_tokens || 0),
|
|
outputTokens: (session.totalUsage?.outputTokens || 0) + (usage.output_tokens || 0),
|
|
};
|
|
saveSession(session);
|
|
wsSend(entry.ws, { type: 'usage', totalUsage: session.totalUsage });
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'turn.failed': {
|
|
const message = event.error?.message || 'Codex 任务失败';
|
|
entry.lastError = message;
|
|
break;
|
|
}
|
|
|
|
case 'error':
|
|
if (event.message) {
|
|
if (/^Reconnecting\.\.\./.test(event.message)) {
|
|
wsSend(entry.ws, { type: 'system_message', message: event.message });
|
|
} else {
|
|
entry.lastError = event.message;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
function processRuntimeEvent(entry, event, sessionId) {
|
|
if (entry.agent === 'codex') processCodexEvent(entry, event, sessionId);
|
|
else processClaudeEvent(entry, event, sessionId);
|
|
}
|
|
|
|
return {
|
|
buildClaudeSpawnSpec,
|
|
buildCodexSpawnSpec,
|
|
processClaudeEvent,
|
|
processCodexEvent,
|
|
processRuntimeEvent,
|
|
};
|
|
}
|
|
|
|
module.exports = { createAgentRuntime };
|