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 `
${time}
`; } function hasAgentMessageBoundaryAtEnd(text) { return /\n\s*(?:---|\*\*\*|___)\s*$/.test(text) || /(?:^|\n)\s*\s*$/.test(text); } function hasAgentMessageBoundaryAtStart(text) { return /^\s*(?:---|\*\*\*|___)\s*\n/.test(text) || /^\s*\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 };