Files
cc-web/lib/agent-runtime.js
2026-06-12 17:46:37 +08:00

503 lines
16 KiB
JavaScript

function createAgentRuntime(deps) {
const {
processEnv,
CLAUDE_PATH,
CODEX_PATH,
MODEL_MAP,
loadModelConfig,
applyCustomTemplateToSettings,
getDefaultCodexModel,
loadCodexConfig,
prepareCodexCustomRuntime,
ccwebMcpServerPath,
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 (!ccwebMcpServerPath || !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([ccwebMcpServerPath])}`,
'-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 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 hasParagraphBoundary = /\n\s*\n\s*$/.test(currentText) || /^\s*\n\s*\n/.test(nextText);
const separator = hasExistingText && !hasParagraphBoundary
? (/\n\s*$/.test(currentText) ? '\n' : '\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 };