Update ccweb codex app integration

This commit is contained in:
shiyue
2026-06-16 14:36:06 +08:00
parent 2e119fd7e3
commit 51838a2ce1
7 changed files with 1254 additions and 164 deletions

786
server.js
View File

@@ -9,6 +9,7 @@ const { createCodexAppServerClient } = require('./lib/codex-app-server-client');
const { createCodexAppWorkerClient } = require('./lib/codex-app-worker-client');
const { createCodexAppRuntime } = require('./lib/codex-app-runtime');
const { createCodexRolloutStore } = require('./lib/codex-rollouts');
const { TOOLS: CCWEB_MCP_TOOLS } = require('./lib/ccweb-mcp-server');
// Load .env
const envPath = path.join(__dirname, '.env');
@@ -65,6 +66,7 @@ const CODEX_APP_STATE_MAX_MAP_ENTRIES = readPositiveIntEnv('CC_WEB_CODEX_APP_STA
const RUN_OUTPUT_RECOVERY_MAX_BYTES = readPositiveIntEnv('CC_WEB_RUN_OUTPUT_RECOVERY_MAX_BYTES', 16 * 1024 * 1024, { min: 1024 * 1024 });
const RUN_OUTPUT_TAILER_MAX_READ_BYTES = readPositiveIntEnv('CC_WEB_RUN_OUTPUT_TAILER_MAX_READ_BYTES', 2 * 1024 * 1024, { min: 64 * 1024 });
const CROSS_CONVERSATION_MAX_CONTENT_CHARS = readPositiveIntEnv('CC_WEB_CROSS_CONVERSATION_MAX_CONTENT_CHARS', 64 * 1024, { min: 1024 });
const MCP_CREATE_CONVERSATION_MAX_HOP_COUNT = readPositiveIntEnv('CC_WEB_MCP_CREATE_CONVERSATION_MAX_HOP_COUNT', 3, { min: 1, max: 20 });
const CODEX_APP_WORKER_DISABLED = /^(0|false|no|off)$/i.test(String(process.env.CC_WEB_CODEX_APP_WORKER || ''));
const CODEX_APP_WORKER_ENABLED = !CODEX_APP_WORKER_DISABLED;
const PERSIST_TRUNCATED_TEXT = '[cc-web: 内容过大,已截断以保护服务稳定性]';
@@ -557,6 +559,9 @@ const activeProcesses = new Map();
// Active Codex app-server turns: sessionId -> { ws, threadId, turnId, fullText, toolCalls }
const activeCodexAppTurns = new Map();
// ccweb MCP child agents tracked from Codex App native collaboration mode:
// childThreadId -> { parentSessionId, parentThreadId, spawnToolId, ...state }
const ccwebMcpChildThreads = new Map();
// 等待目标对话完成后回传给来源对话的跨对话请求requestId -> metadata
const pendingCrossConversationReplies = new Map();
// Pending Codex app-server guided input requests: requestId -> { sessionId, resolve, timer }
@@ -578,6 +583,8 @@ let MODEL_MAP = {
};
const VALID_AGENTS = new Set(['claude', 'codex', 'codexapp']);
const VALID_PERMISSION_MODES = new Set(['default', 'plan', 'yolo']);
const MCP_CONVERSATION_TITLE_MAX_CHARS = 120;
// Codex 默认模型优先读取 ~/.codex/config.toml缺失时再回退到旧默认值。
const FALLBACK_CODEX_MODEL = 'gpt-5.4';
@@ -1567,6 +1574,39 @@ function filterComposerItems(items, query) {
return filtered.slice(0, COMPOSER_SUGGESTION_LIMIT);
}
function listComposerMcpTools() {
return CCWEB_MCP_TOOLS.map((tool) => {
const name = String(tool?.name || '').trim();
const label = `mcp:ccweb/${name}`;
return {
kind: 'mcp',
name,
label,
title: `ccweb/${name}`,
description: String(tool?.description || 'MCP 工具').trim(),
insertion: label,
appendSpace: true,
server: 'ccweb',
source: 'mcp:ccweb',
};
}).filter((item) => item.name);
}
function mergeComposerSuggestionGroups(...groups) {
const merged = [];
const seen = new Set();
for (const group of groups) {
for (const item of Array.isArray(group) ? group : []) {
const key = `${item.kind || ''}:${item.server || ''}:${item.name || item.label || item.insertion || ''}`;
if (!key || seen.has(key)) continue;
seen.add(key);
merged.push(item);
if (merged.length >= COMPOSER_SUGGESTION_LIMIT) return merged;
}
}
return merged;
}
function listComposerFileSuggestions(sessionId, query) {
const session = sessionId ? loadSession(sessionId) : null;
const rootCandidate = session?.cwd || getDefaultSessionCwd();
@@ -1620,18 +1660,20 @@ function listComposerFileSuggestions(sessionId, query) {
}
function listComposerSuggestions(trigger, query, sessionId, agent) {
const mcpItems = filterComposerItems(listComposerMcpTools(), query);
if (trigger === '/') {
return filterComposerItems(COMPOSER_COMMANDS.map((cmd) => ({
const commands = filterComposerItems(COMPOSER_COMMANDS.map((cmd) => ({
kind: 'command',
name: cmd.name,
label: cmd.name,
description: cmd.description,
insertion: cmd.insertion,
})), query.replace(/^\//, ''));
return mergeComposerSuggestionGroups(commands, mcpItems);
}
if (trigger === '$') {
if (!isCodexLikeAgent(agent)) return [];
return filterComposerItems(loadCodexSkills(), query);
const skills = isCodexLikeAgent(agent) ? filterComposerItems(loadCodexSkills(), query) : [];
return mergeComposerSuggestionGroups(mcpItems, skills);
}
if (trigger === '@') {
const prompts = filterComposerItems(loadComposerPrompts().map((prompt) => ({
@@ -1644,7 +1686,7 @@ function listComposerSuggestions(trigger, query, sessionId, agent) {
appendSpace: true,
})), query);
const files = listComposerFileSuggestions(sessionId, query);
return [...prompts, ...files].slice(0, COMPOSER_SUGGESTION_LIMIT);
return mergeComposerSuggestionGroups(prompts, mcpItems, files);
}
return [];
}
@@ -2977,6 +3019,89 @@ function listConversationSummaries(args = {}, sourceSessionId = '') {
};
}
function createMcpConversation(args = {}, sourceSessionId = '', sourceHopCount = 0) {
const sourceId = sanitizeId(sourceSessionId || '');
const normalizedHopCount = Math.max(0, Number.parseInt(String(sourceHopCount || 0), 10) || 0);
if (normalizedHopCount >= MCP_CREATE_CONVERSATION_MAX_HOP_COUNT) {
return mcpToolError('hop_limit_exceeded', '跨对话创建层级过深,已拒绝继续创建新对话。', {
hopCount: normalizedHopCount,
maxHopCount: MCP_CREATE_CONVERSATION_MAX_HOP_COUNT,
});
}
const sourceSession = sourceId ? loadSession(sourceId) : null;
if (sourceId && !sourceSession) {
return mcpToolError('source_not_found', '来源对话不存在。', { sourceConversationId: sourceId });
}
const initialMessage = typeof args.initialMessage === 'string'
? truncateTextValue(args.initialMessage.trim(), CROSS_CONVERSATION_MAX_CONTENT_CHARS)
: '';
const requestReply = args.requestReply === true || args.waitForReply === true;
if (requestReply && !initialMessage) {
return mcpToolError('reply_requires_initial_message', 'requestReply=true 时必须提供 initialMessage。');
}
if (initialMessage && !sourceId) {
return mcpToolError('missing_source_conversation', '发送 initialMessage 需要来源对话 ID。');
}
const now = new Date().toISOString();
const created = createPersistentConversationSession(args, {
sourceSession,
strict: true,
requireAbsoluteCwd: true,
createdFrom: sourceSession ? {
kind: 'mcp',
sourceSessionId: sourceSession.id,
sourceTitle: sourceSession.title || 'Untitled',
hopCount: normalizedHopCount + 1,
createdAt: now,
} : null,
});
if (!created.ok) return created;
const { session } = created;
let delivery = null;
if (initialMessage) {
delivery = sendCrossConversationMessage({
targetConversationId: session.id,
content: initialMessage,
}, sourceId, normalizedHopCount, { expectReply: requestReply });
if (!delivery?.ok) {
return mcpToolError(delivery?.code || 'initial_message_failed', delivery?.message || '创建对话后发送首条消息失败。', {
conversationId: session.id,
title: session.title,
agent: getSessionAgent(session),
cwd: session.cwd || '',
mode: session.permissionMode || 'yolo',
status: isSessionRunning(session.id) ? 'running' : 'idle',
});
}
}
broadcastSessionList();
return {
ok: true,
conversationId: session.id,
title: session.title || 'Untitled',
agent: getSessionAgent(session),
cwd: session.cwd || '',
mode: session.permissionMode || 'yolo',
status: isSessionRunning(session.id) ? 'running' : 'idle',
sourceConversationId: sourceId || null,
...(delivery ? {
messageId: delivery.messageId || null,
deliveryStatus: delivery.deliveryStatus || 'delivered',
...(delivery.requestId ? {
requestId: delivery.requestId,
replyStatus: delivery.status || 'waiting',
replyDelivery: delivery.replyDelivery || 'display_only',
sourceAutoRun: delivery.sourceAutoRun === true,
} : {}),
} : {}),
};
}
function buildCrossConversationRuntimeText(sourceSession, content) {
const sourceTitle = sourceSession?.title || 'Untitled';
const sourceId = sourceSession?.id || '';
@@ -2988,6 +3113,15 @@ function buildCrossConversationReplyContent(targetSession, replyText) {
return `线程「${targetTitle}」已返回消息:\n\n${truncateTextValue(replyText, CROSS_CONVERSATION_MAX_CONTENT_CHARS)}`;
}
function hasProcessedCrossConversationReply(session, requestId) {
const normalizedRequestId = String(requestId || '').trim();
if (!normalizedRequestId || !Array.isArray(session?.messages)) return false;
return session.messages.some((message) => (
message?.crossConversation?.replyToRequestId === normalizedRequestId &&
message?.crossConversation?.processed === true
));
}
function extractCrossConversationReplyText(content) {
if (!content) return '';
if (typeof content === 'string') return content.trim();
@@ -3104,7 +3238,12 @@ function sendCrossConversationMessage(args = {}, sourceSessionId = '', sourceHop
sourceConversationId: sourceId,
targetConversationId: targetId,
targetTitle: targetSession.title || 'Untitled',
...(requestId ? { requestId, status: 'waiting' } : {}),
...(requestId ? {
requestId,
status: 'waiting',
replyDelivery: 'display_only',
sourceAutoRun: false,
} : {}),
};
}
@@ -3125,6 +3264,12 @@ function deliverCrossConversationReply(requestId) {
return false;
}
if (isSessionRunning(sourceSession.id)) return false;
if (hasProcessedCrossConversationReply(sourceSession, requestId)) {
pending.status = 'returned';
pending.returnedAt = pending.returnedAt || new Date().toISOString();
pendingCrossConversationReplies.delete(requestId);
return true;
}
const now = new Date().toISOString();
const replyMessageId = crypto.randomUUID();
@@ -3137,26 +3282,31 @@ function deliverCrossConversationReply(requestId) {
hopCount: Math.max(0, Number.parseInt(String(pending.hopCount || 0), 10) || 0) + 1,
reply: true,
replyToRequestId: requestId,
processed: true,
processedAt: now,
autoRun: false,
};
pending.status = 'delivering';
const sourceWs = findViewingSessionWs(sourceSession.id);
const result = handleMessage(sourceWs, {
text: replyContent,
sessionId: sourceSession.id,
mode: sourceSession.permissionMode || 'yolo',
agent: getSessionAgent(sourceSession),
}, {
const replyMessage = {
role: 'assistant',
content: replyContent,
timestamp: now,
crossConversation,
emitUserMessage: true,
runtimeText: buildCrossConversationRuntimeText(targetSession, replyContent),
mcpContext: { hopCount: crossConversation.hopCount },
});
if (!result?.ok) {
pending.status = 'ready';
pending.lastError = result?.code || 'send_failed';
return false;
ccwebDisplayOnly: true,
};
sourceSession.messages = Array.isArray(sourceSession.messages) ? sourceSession.messages : [];
sourceSession.messages.push(replyMessage);
sourceSession.updated = now;
const sourceWs = findViewingSessionWs(sourceSession.id);
if (!sourceWs) sourceSession.hasUnread = true;
saveSession(sourceSession);
if (sourceWs) {
wsSend(sourceWs, {
type: 'session_message',
sessionId: sourceSession.id,
message: replyMessage,
});
}
pending.status = 'returned';
@@ -3202,6 +3352,8 @@ function callInternalMcpTool(tool, args, sourceSessionId, sourceHopCount) {
switch (tool) {
case 'ccweb_list_conversations':
return listConversationSummaries(args, sanitizeId(sourceSessionId || ''));
case 'ccweb_create_conversation':
return createMcpConversation(args, sourceSessionId, sourceHopCount);
case 'ccweb_send_message':
return sendCrossConversationMessage(args, sourceSessionId, sourceHopCount);
case 'ccweb_request_reply':
@@ -3968,6 +4120,9 @@ wss.on('connection', (ws, req) => {
case 'codex_app_user_input_response':
handleCodexAppUserInputResponse(ws, msg);
break;
case 'ccweb_mcp_child_agent_close':
handleCcwebMcpChildAgentClose(ws, msg);
break;
case 'list_sessions':
sendSessionList(ws);
break;
@@ -4482,60 +4637,126 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) {
}
// === Session Handlers ===
function handleNewSession(ws, msg) {
const cwd = (msg && msg.cwd) ? String(msg.cwd) : null;
const agent = normalizeAgent(msg?.agent);
const requestedMode = ['default', 'plan', 'yolo'].includes(msg?.mode) ? msg.mode : 'yolo';
const cwdResult = resolveSessionCwd(cwd, { createMissing: !!msg?.createCwd });
if (!cwdResult.ok) {
return wsSend(ws, {
type: 'error',
code: cwdResult.code,
cwd: cwdResult.resolvedPath || cwd || null,
message: cwdResult.message,
});
function normalizeConversationTitle(title, fallback = 'New Chat') {
const normalized = String(title || '').replace(/\s+/g, ' ').trim();
if (!normalized) return fallback;
return truncateTextValue(normalized, MCP_CONVERSATION_TITLE_MAX_CHARS, '...');
}
function resolveConversationAgent(rawAgent, fallbackAgent = 'claude', options = {}) {
const value = String(rawAgent || '').trim().toLowerCase();
if (value) {
if (VALID_AGENTS.has(value)) return { ok: true, agent: value };
if (options.strict) {
return mcpToolError('invalid_agent', 'Agent 必须是 claude、codex 或 codexapp。', { agent: value });
}
}
const resolvedCwd = cwdResult.path || getDefaultSessionCwd();
const id = crypto.randomUUID();
return { ok: true, agent: VALID_AGENTS.has(fallbackAgent) ? fallbackAgent : 'claude' };
}
function resolveConversationMode(rawMode, fallbackMode = 'yolo', options = {}) {
const value = String(rawMode || '').trim().toLowerCase();
if (value) {
if (VALID_PERMISSION_MODES.has(value)) return { ok: true, mode: value };
if (options.strict) {
return mcpToolError('invalid_mode', 'mode 必须是 default、plan 或 yolo。', { mode: value });
}
}
return { ok: true, mode: VALID_PERMISSION_MODES.has(fallbackMode) ? fallbackMode : 'yolo' };
}
function createPersistentConversationSession(args = {}, options = {}) {
const sourceSession = options.sourceSession || null;
const strict = !!options.strict;
const explicitCwd = typeof args.cwd === 'string' && args.cwd.trim();
const fallbackAgent = getSessionAgent(sourceSession) || options.defaultAgent || 'claude';
const fallbackMode = sourceSession?.permissionMode || options.defaultMode || 'yolo';
const agentResult = resolveConversationAgent(args.agent, fallbackAgent, { strict });
if (!agentResult.ok) return agentResult;
const modeResult = resolveConversationMode(args.mode, fallbackMode, { strict });
if (!modeResult.ok) return modeResult;
let cwdCandidate = explicitCwd ? String(args.cwd).trim() : '';
if (explicitCwd && options.requireAbsoluteCwd && !path.isAbsolute(cwdCandidate)) {
return mcpToolError('create_conversation_cwd_relative', 'cwd 必须是已存在的绝对路径。', { cwd: cwdCandidate });
}
if (!cwdCandidate && sourceSession?.cwd) {
cwdCandidate = normalizeExistingDirPath(sourceSession.cwd) || '';
}
const cwdResult = resolveSessionCwd(cwdCandidate || null, {
createMissing: !!(options.allowCreateCwd && args.createCwd),
});
if (!cwdResult.ok) {
return mcpToolError(cwdResult.code, cwdResult.message, { cwd: cwdResult.resolvedPath || cwdCandidate || null });
}
const now = new Date().toISOString();
const agent = agentResult.agent;
const session = {
id,
title: 'New Chat',
created: new Date().toISOString(),
updated: new Date().toISOString(),
id: crypto.randomUUID(),
title: normalizeConversationTitle(args.title),
created: now,
updated: now,
pinnedAt: null,
agent,
claudeSessionId: null,
codexThreadId: null,
codexAppThreadId: null,
// For Codex/Codex App: 在会话创建时写入 ~/.codex/config.toml 中的默认模型,避免 UI 与运行时脱节
// For Claude: default to opus (1M) so --model is always passed to CLI.
// Codex/Codex App 读取 ~/.codex/config.toml 默认模型Claude 继续默认 opus 1M
model: agent === 'codex' || agent === 'codexapp' ? getDefaultCodexModel() : MODEL_MAP.opus,
permissionMode: requestedMode,
permissionMode: modeResult.mode,
totalCost: 0,
totalUsage: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 },
messages: [],
cwd: resolvedCwd,
cwd: cwdResult.path || getDefaultSessionCwd(),
};
saveSession(session);
detachWsFromActiveRuntimes(ws);
wsSessionMap.set(ws, id);
wsSend(ws, {
if (options.createdFrom) session.createdFrom = options.createdFrom;
if (!saveSession(session)) {
return mcpToolError('session_save_failed', '创建会话失败,请检查 sessions 目录写入权限。');
}
return { ok: true, session };
}
function buildSessionInfoPayload(session) {
return {
type: 'session_info',
sessionId: id,
messages: [],
sessionId: session.id,
messages: session.messages || [],
title: session.title,
pinnedAt: session.pinnedAt || null,
mode: session.permissionMode,
mode: session.permissionMode || 'yolo',
model: sessionModelLabel(session),
agent,
agent: getSessionAgent(session),
cwd: session.cwd,
totalCost: 0,
totalUsage: session.totalUsage,
totalCost: session.totalCost || 0,
totalUsage: session.totalUsage || { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 },
updated: session.updated,
hasUnread: false,
historyPending: false,
isRunning: false,
};
}
function handleNewSession(ws, msg) {
const result = createPersistentConversationSession(msg || {}, {
defaultAgent: normalizeAgent(msg?.agent),
defaultMode: 'yolo',
allowCreateCwd: true,
});
if (!result.ok) {
return wsSend(ws, {
type: 'error',
code: result.code,
cwd: result.cwd || null,
message: result.message,
});
}
const { session } = result;
detachWsFromActiveRuntimes(ws);
wsSessionMap.set(ws, session.id);
wsSend(ws, buildSessionInfoPayload(session));
sendSessionList(ws);
}
@@ -4724,6 +4945,9 @@ function deleteCodexLocalSession(session) {
function handleDeleteSession(ws, sessionId) {
pendingSlashCommands.delete(sessionId);
pendingCompactRetries.delete(sessionId);
for (const [threadId, child] of ccwebMcpChildThreads.entries()) {
if (child.parentSessionId === sessionId) ccwebMcpChildThreads.delete(threadId);
}
if (activeProcesses.has(sessionId)) {
const entry = activeProcesses.get(sessionId);
try { killProcess(entry.pid); } catch {}
@@ -4850,6 +5074,65 @@ function handleAbort(ws) {
// handleProcessComplete will be triggered by the PID monitor
}
function closeCcwebMcpChildAgent(sessionId, childThreadId, options = {}) {
const normalizedSessionId = sanitizeId(sessionId || '');
const normalizedThreadId = String(childThreadId || '').trim();
if (!normalizedSessionId || !normalizedThreadId) {
return { ok: false, code: 'missing_child_agent', message: '缺少子代理线程 ID。' };
}
const child = ccwebMcpChildThreads.get(normalizedThreadId);
if (!child || child.parentSessionId !== normalizedSessionId) {
return { ok: false, code: 'child_agent_not_found', message: '未找到可关闭的 ccweb MCP 子代理。' };
}
const now = new Date().toISOString();
child.status = 'closed';
child.closedAt = now;
child.updatedAt = now;
child.closeReason = options.reason || 'manual';
ccwebMcpChildThreads.set(normalizedThreadId, child);
if (child.turnId && codexAppClient?.isRunning()) {
codexAppClient.request('turn/interrupt', {
threadId: child.threadId,
turnId: child.turnId,
}, 30000).catch((err) => {
plog('INFO', 'ccweb_mcp_child_interrupt_failed', {
sessionId: normalizedSessionId.slice(0, 8),
childThreadId: normalizedThreadId,
error: err?.message || String(err || ''),
});
});
}
sendCcwebMcpChildAgentUpdate(normalizedSessionId, child);
return { ok: true, child: ccwebMcpChildPublicState(child) };
}
function handleCcwebMcpChildAgentClose(ws, msg = {}) {
const sessionId = sanitizeId(msg.sessionId || wsSessionMap.get(ws) || '');
const result = closeCcwebMcpChildAgent(sessionId, msg.threadId || msg.childThreadId, { reason: 'manual' });
if (!result.ok) {
wsSend(ws, {
type: 'error',
sessionId,
code: result.code,
message: result.message,
transient: true,
autoDismissMs: 6000,
});
return;
}
wsSend(ws, {
type: 'system_message',
sessionId,
tone: 'info',
transient: true,
autoDismissMs: 4000,
message: `已关闭子代理 ${result.child.label || result.child.threadId}`,
});
}
// === Runtime Message Handler ===
function handleMessage(ws, msg, options = {}) {
const { text, sessionId, mode } = msg;
@@ -5229,8 +5512,346 @@ function findCodexAppEntryByRuntime(params = {}) {
return null;
}
function parseMaybeJsonObject(value) {
if (value && typeof value === 'object' && !Array.isArray(value)) return value;
if (typeof value !== 'string') return null;
const text = value.trim();
if (!text || !text.startsWith('{')) return null;
try {
const parsed = JSON.parse(text);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
} catch {
return null;
}
}
function codexAppCollabToolName(value) {
const normalized = String(value || '').trim().toLowerCase().replace(/[\s_-]/g, '');
if (normalized === 'spawn' || normalized === 'spawnagent') return 'spawn_agent';
if (normalized === 'wait' || normalized === 'waitagent') return 'wait_agent';
if (normalized === 'sendinput' || normalized === 'sendmessage') return 'send_input';
if (normalized === 'resume' || normalized === 'resumeagent') return 'resume_agent';
if (normalized === 'close' || normalized === 'closeagent') return 'close_agent';
return normalized || '';
}
function ccwebMcpChildStatus(value, fallback = 'running') {
const normalized = String(value || '').trim().toLowerCase().replace(/[\s_-]/g, '');
if (!normalized) return fallback;
if (/^(closed|close|cleanup|cleaned)$/.test(normalized)) return 'closed';
if (/^(returned|completed|complete|done|success|succeeded|finished)$/.test(normalized)) return 'returned';
if (/^(failed|failure|error|errored)$/.test(normalized)) return 'failed';
if (/^(cancelled|canceled|aborted|interrupted)$/.test(normalized)) return 'closed';
if (/^(pending|pendinginit|queued|waiting|running|working|active|inprogress|started)$/.test(normalized)) return 'running';
return fallback;
}
function extractCcwebMcpStringArray(...values) {
for (const value of values) {
if (!Array.isArray(value)) continue;
const list = value.map((item) => String(item || '').trim()).filter(Boolean);
if (list.length > 0) return list;
}
return [];
}
function extractCcwebMcpAgentText(value) {
if (!value) return '';
if (typeof value === 'string') return value.trim();
if (typeof value.text === 'string') return value.text.trim();
if (typeof value.message === 'string') return value.message.trim();
if (typeof value.content === 'string') return value.content.trim();
if (Array.isArray(value.content)) {
return value.content.map((part) => {
if (typeof part === 'string') return part;
if (typeof part?.text === 'string') return part.text;
if (typeof part?.content === 'string') return part.content;
return '';
}).filter(Boolean).join('').trim();
}
return '';
}
function extractCcwebMcpChildCandidate(state = {}) {
if (!state || typeof state !== 'object') return '';
const direct = extractCcwebMcpAgentText(state);
if (direct) return direct;
for (const key of ['summary', 'lastMessage', 'finalMessage', 'final_message', 'output', 'result']) {
const value = state[key];
if (value === undefined || value === null) continue;
const text = extractCcwebMcpAgentText(value);
if (text) return text;
if (typeof value === 'object') {
try { return JSON.stringify(value); } catch {}
}
}
return '';
}
function ccwebMcpChildLabel(state = {}, fallbackThreadId = '') {
const label = state?.label || state?.title || state?.nickname || state?.name || state?.agent || state?.agentType || state?.agent_type || '';
return String(label || fallbackThreadId || '子代理').trim();
}
function ccwebMcpChildSummary(child = {}) {
const candidate = String(child.candidateResult || child.finalMessage || child.lastAssistantMessage || '').replace(/\s+/g, ' ').trim();
return candidate ? truncateTextValue(candidate, 180, '...') : '';
}
function ccwebMcpChildPublicState(child = {}) {
const candidateResult = child.finalMessage || child.candidateResult || '';
return {
threadId: child.threadId || '',
label: child.label || child.threadId || '子代理',
role: child.role || '',
status: child.status || 'running',
detail: ccwebMcpChildSummary(child),
candidateResult,
finalMessage: child.finalMessage || '',
spawnToolId: child.spawnToolId || '',
parentThreadId: child.parentThreadId || '',
createdAt: child.createdAt || null,
updatedAt: child.updatedAt || null,
returnedAt: child.returnedAt || null,
closedAt: child.closedAt || null,
};
}
function mergeCcwebMcpChildIntoTool(tool, child) {
if (!tool) return null;
const candidateResult = child.finalMessage || child.candidateResult || '';
const input = parseMaybeJsonObject(tool.input) || (tool.input && typeof tool.input === 'object' ? tool.input : {});
const result = parseMaybeJsonObject(tool.result) || (tool.result && typeof tool.result === 'object' ? tool.result : {});
const receiverThreadIds = Array.from(new Set([
...extractCcwebMcpStringArray(input.receiverThreadIds, input.receiver_thread_ids, input.targets),
...extractCcwebMcpStringArray(result.receiverThreadIds, result.receiver_thread_ids, result.targets),
child.threadId,
].filter(Boolean)));
const agentsStates = {
...(input.agentsStates && typeof input.agentsStates === 'object' ? input.agentsStates : {}),
...(input.agents_states && typeof input.agents_states === 'object' ? input.agents_states : {}),
...(result.agentsStates && typeof result.agentsStates === 'object' ? result.agentsStates : {}),
...(result.agents_states && typeof result.agents_states === 'object' ? result.agents_states : {}),
};
agentsStates[child.threadId] = {
...(agentsStates[child.threadId] && typeof agentsStates[child.threadId] === 'object' ? agentsStates[child.threadId] : {}),
name: child.label || agentsStates[child.threadId]?.name || child.threadId,
role: child.role || agentsStates[child.threadId]?.role || '',
status: child.status || 'running',
summary: ccwebMcpChildSummary(child),
candidateResult,
finalMessage: child.finalMessage || '',
closedAt: child.closedAt || null,
returnedAt: child.returnedAt || null,
};
const nextResult = {
...result,
status: child.status || result.status || null,
receiverThreadIds,
agentsStates,
};
tool.kind = tool.kind || 'collab_agent_tool_call';
tool.name = tool.name || 'CollabAgentToolCall';
tool.result = JSON.stringify(nextResult, null, 2);
return {
id: tool.id,
name: tool.name,
kind: tool.kind,
input: tool.input,
result: tool.result,
meta: tool.meta || null,
done: !!tool.done,
};
}
function updateCcwebMcpChildToolState(sessionId, child) {
const entry = activeCodexAppTurns.get(sessionId) || null;
let tool = entry?.toolCalls?.find((item) => item.id === child.spawnToolId) || null;
if (!tool) {
const session = loadSession(sessionId);
const messages = Array.isArray(session?.messages) ? session.messages : [];
for (let i = messages.length - 1; i >= 0 && !tool; i -= 1) {
const list = Array.isArray(messages[i]?.toolCalls) ? messages[i].toolCalls : [];
tool = list.find((item) => item.id === child.spawnToolId) || null;
}
}
return mergeCcwebMcpChildIntoTool(tool, child);
}
function updatePersistedCcwebMcpChildTool(sessionId, child) {
const session = loadSession(sessionId);
if (!session || !Array.isArray(session.messages)) return null;
let targetTool = null;
for (let i = session.messages.length - 1; i >= 0 && !targetTool; i -= 1) {
const list = Array.isArray(session.messages[i]?.toolCalls) ? session.messages[i].toolCalls : [];
targetTool = list.find((item) => item.id === child.spawnToolId) || null;
}
if (!mergeCcwebMcpChildIntoTool(targetTool, child)) return null;
session.updated = new Date().toISOString();
if (!findViewingSessionWs(sessionId)) session.hasUnread = true;
saveSession(session);
return targetTool;
}
function sendCcwebMcpChildAgentUpdate(sessionId, child) {
const activeTool = updateCcwebMcpChildToolState(sessionId, child);
const persistedTool = updatePersistedCcwebMcpChildTool(sessionId, child);
const tool = activeTool || (persistedTool ? {
id: persistedTool.id,
name: persistedTool.name,
kind: persistedTool.kind || 'collab_agent_tool_call',
input: persistedTool.input,
result: persistedTool.result,
meta: persistedTool.meta || null,
done: !!persistedTool.done,
} : null);
const payload = {
type: 'ccweb_mcp_child_agent_update',
sessionId,
toolUseId: child.spawnToolId || '',
child: ccwebMcpChildPublicState(child),
tool,
};
const targetWs = activeCodexAppTurns.get(sessionId)?.ws || findViewingSessionWs(sessionId);
if (targetWs) wsSend(targetWs, payload);
broadcastSessionList();
}
function syncCcwebMcpChildAgentsFromCollabItem(routed, item = {}) {
if (!routed?.sessionId || item?.type !== 'collabAgentToolCall') return;
const toolName = codexAppCollabToolName(item.tool || item.name);
const receiverThreadIds = extractCcwebMcpStringArray(item.receiverThreadIds, item.receiver_thread_ids, item.targets);
if (receiverThreadIds.length === 0) return;
const states = item.agentsStates && typeof item.agentsStates === 'object'
? item.agentsStates
: (item.agents_states && typeof item.agents_states === 'object' ? item.agents_states : {});
for (const threadId of receiverThreadIds) {
const state = states[threadId] && typeof states[threadId] === 'object' ? states[threadId] : {};
const existing = ccwebMcpChildThreads.get(threadId);
const now = new Date().toISOString();
const isSpawn = toolName === 'spawn_agent' || !existing;
const child = existing || {
threadId,
turnId: null,
parentSessionId: routed.sessionId,
parentThreadId: routed.entry?.threadId || item.senderThreadId || item.sender_thread_id || '',
spawnToolId: isSpawn ? item.id : '',
label: ccwebMcpChildLabel(state, threadId),
role: String(state.role || state.agent || state.agentType || state.agent_type || '').trim(),
lastAssistantMessage: '',
candidateResult: '',
finalMessage: '',
status: 'running',
summaryAttempts: 0,
createdAt: now,
updatedAt: now,
};
if (!child.spawnToolId && isSpawn) child.spawnToolId = item.id;
if (!child.spawnToolId && item.id) child.spawnToolId = item.id;
child.parentSessionId = child.parentSessionId || routed.sessionId;
child.parentThreadId = child.parentThreadId || routed.entry?.threadId || item.senderThreadId || item.sender_thread_id || '';
child.label = ccwebMcpChildLabel(state, child.label || threadId);
child.role = String(state.role || state.agent || state.agentType || state.agent_type || child.role || '').trim();
if (child.status !== 'closed') {
const candidate = extractCcwebMcpChildCandidate(state);
if (candidate) {
child.candidateResult = truncateTextValue(candidate, SESSION_MESSAGE_CONTENT_MAX_CHARS);
child.lastAssistantMessage = child.candidateResult;
}
const rawStatus = state.status || state.state || item.status;
const fallback = child.status || 'running';
const nextStatus = ccwebMcpChildStatus(rawStatus, fallback);
child.status = nextStatus === 'returned' && !child.candidateResult && !candidate ? fallback : nextStatus;
if (child.status === 'returned' && !child.returnedAt) child.returnedAt = now;
}
child.updatedAt = now;
ccwebMcpChildThreads.set(threadId, child);
sendCcwebMcpChildAgentUpdate(routed.sessionId, child);
}
}
function processCcwebMcpChildNotification(child, notification) {
const method = notification?.method || '';
const params = notification?.params || {};
const now = new Date().toISOString();
if (params.turnId && !child.turnId) child.turnId = params.turnId;
if (params.turn?.id && !child.turnId) child.turnId = params.turn.id;
if (child.status === 'closed' && method !== 'turn/completed') {
return { changed: false, done: false };
}
if (method === 'turn/started') {
child.status = 'running';
child.updatedAt = now;
return { changed: true, done: false };
}
if (method === 'item/agentMessage/delta') {
const itemId = String(params.itemId || 'agent-message');
if (!child.messageItems) child.messageItems = new Map();
const current = child.messageItems.get(itemId) || '';
const next = truncateTextValue(`${current}${String(params.delta || '')}`, SESSION_MESSAGE_CONTENT_MAX_CHARS);
child.messageItems.set(itemId, next);
child.lastAssistantMessage = next;
child.updatedAt = now;
return { changed: true, done: false };
}
if (method === 'item/completed') {
const item = params.item || {};
if (item.type === 'agentMessage') {
const text = extractCcwebMcpAgentText(item);
if (text) {
if (!child.messageItems) child.messageItems = new Map();
const finalMessage = truncateTextValue(text, SESSION_MESSAGE_CONTENT_MAX_CHARS);
child.messageItems.set(item.id || 'agent-message', finalMessage);
child.lastAssistantMessage = finalMessage;
child.finalMessage = finalMessage;
child.updatedAt = now;
return { changed: true, done: false };
}
}
return { changed: false, done: false };
}
if (method === 'turn/completed') {
if (child.status !== 'closed') {
const status = params.turn?.status || params.status || '';
child.status = ccwebMcpChildStatus(status, status && /fail|error/i.test(status) ? 'failed' : 'returned');
child.finalMessage = child.finalMessage || child.lastAssistantMessage || '';
child.candidateResult = child.finalMessage || child.candidateResult || '';
child.returnedAt = child.returnedAt || now;
}
child.updatedAt = now;
return { changed: true, done: true };
}
return { changed: false, done: false };
}
function findCodexAppRouteByRuntime(params = {}) {
const parent = findCodexAppEntryByRuntime(params);
if (parent) return { ...parent, role: 'parent' };
const threadId = params.threadId || params.thread?.id || null;
if (threadId && ccwebMcpChildThreads.has(threadId)) {
const child = ccwebMcpChildThreads.get(threadId);
return {
role: 'child',
sessionId: child.parentSessionId,
entry: activeCodexAppTurns.get(child.parentSessionId) || null,
child,
};
}
return null;
}
function handleCodexAppNotification(notification) {
const routed = findCodexAppEntryByRuntime(notification?.params || {});
const routed = findCodexAppRouteByRuntime(notification?.params || {});
if (!routed) {
plog('INFO', 'codex_app_notification_unrouted', {
method: notification?.method || '',
@@ -5240,7 +5861,17 @@ function handleCodexAppNotification(notification) {
return;
}
if (routed.role === 'child') {
const result = processCcwebMcpChildNotification(routed.child, notification);
if (result.changed) sendCcwebMcpChildAgentUpdate(routed.sessionId, routed.child);
return;
}
const result = codexAppRuntime.processCodexAppNotification(routed.entry, notification, routed.sessionId);
const item = notification?.params?.item || null;
if (item?.type === 'collabAgentToolCall') {
syncCcwebMcpChildAgentsFromCollabItem(routed, item);
}
persistCodexAppTurnState(routed.sessionId, routed.entry, { immediate: !!result?.done });
if (result?.done) {
handleCodexAppTurnComplete(routed.sessionId);
@@ -5296,10 +5927,48 @@ function codexAppCommunicationDynamicTools() {
additionalProperties: false,
},
},
{
name: 'ccweb_create_conversation',
namespace: 'ccweb',
description: '创建新的 cc-web 持久对话。只用于需要长期追踪、后续继续对话或跨项目工作区管理的场景;一次性并行研究应使用子代能力。',
inputSchema: {
type: 'object',
properties: {
agent: {
type: 'string',
enum: ['claude', 'codex', 'codexapp'],
description: '可选。新对话 Agent默认继承来源对话。',
},
cwd: {
type: 'string',
description: '可选。新对话工作目录;指定时必须是已存在的绝对路径,默认继承来源对话 cwd。',
},
title: {
type: 'string',
maxLength: MCP_CONVERSATION_TITLE_MAX_CHARS,
description: '可选。新对话标题。',
},
mode: {
type: 'string',
enum: ['default', 'plan', 'yolo'],
description: '可选。权限模式,默认继承来源对话。',
},
initialMessage: {
type: 'string',
description: '可选。创建后立即发送到新对话的首条消息。',
},
requestReply: {
type: 'boolean',
description: '可选。若为 true新对话完成本轮输出后会把回复作为已处理的只读消息写回来源对话不会再次触发来源对话运行。',
},
},
additionalProperties: false,
},
},
{
name: 'ccweb_request_reply',
namespace: 'ccweb',
description: '向另一个 cc-web 对话发送消息,并在目标对话完成本轮输出后自动把回复发当前对话。',
description: '向另一个 cc-web 对话发送消息,并在目标对话完成本轮输出后把回复作为已处理的只读消息写回当前对话;写回不会再次触发当前对话运行。',
inputSchema: {
type: 'object',
required: ['targetConversationId', 'content'],
@@ -5334,7 +6003,12 @@ function handleCodexAppDynamicToolCall(routed, params = {}) {
const tool = String(params.tool || '');
const namespace = String(params.namespace || '');
if (namespace && namespace !== 'ccweb') return null;
if (tool !== 'ccweb_list_conversations' && tool !== 'ccweb_send_message' && tool !== 'ccweb_request_reply') return null;
if (
tool !== 'ccweb_list_conversations' &&
tool !== 'ccweb_create_conversation' &&
tool !== 'ccweb_send_message' &&
tool !== 'ccweb_request_reply'
) return null;
const sourceSessionId = routed?.sessionId || '';
const sourceHopCount = Number.parseInt(String(routed?.entry?.mcpContext?.hopCount || 0), 10) || 0;