523 lines
17 KiB
JavaScript
523 lines
17 KiB
JavaScript
function createAgentRuntime(deps) {
|
|
const {
|
|
processEnv,
|
|
CLAUDE_PATH,
|
|
CODEX_PATH,
|
|
MODEL_MAP,
|
|
loadModelConfig,
|
|
applyCustomTemplateToSettings,
|
|
getDefaultCodexModel,
|
|
loadCodexConfig,
|
|
prepareCodexCustomRuntime,
|
|
ccwebMcpServerArg,
|
|
internalMcpUrl,
|
|
internalMcpToken,
|
|
nodePath,
|
|
wsSend,
|
|
truncateObj,
|
|
sanitizeToolInput,
|
|
loadSession,
|
|
saveSession,
|
|
setRuntimeSessionId,
|
|
getRuntimeSessionId,
|
|
} = deps;
|
|
|
|
function tomlString(value) {
|
|
return JSON.stringify(String(value || ''));
|
|
}
|
|
|
|
function tomlStringArray(values) {
|
|
return `[${values.map((value) => tomlString(value)).join(',')}]`;
|
|
}
|
|
|
|
function createCcwebMcpEnv(session, options = {}) {
|
|
if (!ccwebMcpServerArg || !internalMcpUrl || !internalMcpToken || !session?.id) return null;
|
|
const rawHopCount = Number.parseInt(String(options.mcpContext?.hopCount || 0), 10);
|
|
const hopCount = Number.isFinite(rawHopCount) ? Math.max(0, rawHopCount) : 0;
|
|
return {
|
|
CC_WEB_MCP_URL: internalMcpUrl,
|
|
CC_WEB_MCP_TOKEN: internalMcpToken,
|
|
CC_WEB_SOURCE_SESSION_ID: session.id,
|
|
CC_WEB_CROSS_HOP_COUNT: String(hopCount),
|
|
};
|
|
}
|
|
|
|
function appendCcwebMcpConfig(args, mcpEnv) {
|
|
if (!mcpEnv) return;
|
|
const envVars = Object.keys(mcpEnv);
|
|
args.push(
|
|
'-c', 'mcp_servers.ccweb.type="stdio"',
|
|
'-c', `mcp_servers.ccweb.command=${tomlString(nodePath || 'node')}`,
|
|
'-c', `mcp_servers.ccweb.args=${tomlStringArray([ccwebMcpServerArg])}`,
|
|
'-c', `mcp_servers.ccweb.env_vars=${tomlStringArray(envVars)}`,
|
|
'-c', 'mcp_servers.ccweb.startup_timeout_sec=10',
|
|
'-c', 'mcp_servers.ccweb.tool_timeout_sec=60'
|
|
);
|
|
}
|
|
|
|
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 ccwebMcpEnv = createCcwebMcpEnv(session, options);
|
|
appendCcwebMcpConfig(args, ccwebMcpEnv);
|
|
|
|
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 || getDefaultCodexModel();
|
|
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(/^(.*)\((low|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, ...(ccwebMcpEnv || {}) };
|
|
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 formatAgentMessageDividerTime(date = new Date()) {
|
|
const pad = (value) => String(value).padStart(2, '0');
|
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
}
|
|
|
|
function createAgentMessageDivider() {
|
|
const time = formatAgentMessageDividerTime();
|
|
return `<div class="agent-message-divider" data-divider-time="${time}"><span>${time}</span></div>`;
|
|
}
|
|
|
|
function hasAgentMessageBoundaryAtEnd(text) {
|
|
return /\n\s*(?:---|\*\*\*|___)\s*$/.test(text)
|
|
|| /(?:^|\n)\s*<div\s+class=["']agent-message-divider["'][\s\S]*?<\/div>\s*$/.test(text);
|
|
}
|
|
|
|
function hasAgentMessageBoundaryAtStart(text) {
|
|
return /^\s*(?:---|\*\*\*|___)\s*\n/.test(text)
|
|
|| /^\s*<div\s+class=["']agent-message-divider["'][\s\S]*?<\/div>\s*/.test(text);
|
|
}
|
|
|
|
function sendRuntime(entry, sessionId, payload) {
|
|
wsSend(entry.ws, { ...payload, sessionId });
|
|
}
|
|
|
|
function appendCodexAgentText(entry, text) {
|
|
const nextText = String(text || '');
|
|
if (!nextText) return '';
|
|
|
|
const currentText = entry.fullText || '';
|
|
const hasExistingText = /\S/.test(currentText);
|
|
const hasVisualBoundary = hasAgentMessageBoundaryAtEnd(currentText) || hasAgentMessageBoundaryAtStart(nextText);
|
|
const separator = hasExistingText && !hasVisualBoundary
|
|
? (/\n\s*$/.test(currentText) ? `\n${createAgentMessageDivider()}\n\n` : `\n\n${createAgentMessageDivider()}\n\n`)
|
|
: '';
|
|
const chunk = separator + nextText;
|
|
entry.fullText += chunk;
|
|
return chunk;
|
|
}
|
|
|
|
function ensureCodexToolCall(entry, item, sessionId) {
|
|
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);
|
|
sendRuntime(entry, sessionId, {
|
|
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;
|
|
sendRuntime(entry, sessionId, { 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);
|
|
sendRuntime(entry, sessionId, { 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);
|
|
}
|
|
sendRuntime(entry, sessionId, { 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) {
|
|
sendRuntime(entry, sessionId, { 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, sessionId);
|
|
break;
|
|
}
|
|
|
|
case 'item.updated': {
|
|
const item = event.item;
|
|
if (!item || !item.id || item.type === 'agent_message') break;
|
|
const tc = ensureCodexToolCall(entry, item, sessionId);
|
|
const resultText = codexToolResult(item).slice(0, 2000);
|
|
tc.done = false;
|
|
tc.result = resultText;
|
|
|
|
sendRuntime(entry, sessionId, {
|
|
type: 'tool_update',
|
|
toolUseId: item.id,
|
|
name: tc.name,
|
|
input: tc.input,
|
|
result: resultText,
|
|
kind: tc.kind,
|
|
meta: tc.meta,
|
|
});
|
|
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('');
|
|
appendCodexAgentText(entry, textOnly);
|
|
sendRuntime(entry, sessionId, { type: 'content_blocks', blocks: parsedContent });
|
|
} else {
|
|
const textChunk = appendCodexAgentText(entry, item.text);
|
|
sendRuntime(entry, sessionId, { type: 'text_delta', text: textChunk });
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
const tc = ensureCodexToolCall(entry, item, sessionId);
|
|
const resultText = codexToolResult(item).slice(0, 2000);
|
|
tc.done = true;
|
|
tc.result = resultText;
|
|
|
|
sendRuntime(entry, sessionId, {
|
|
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);
|
|
sendRuntime(entry, sessionId, { 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)) {
|
|
sendRuntime(entry, sessionId, { 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 };
|