diff --git a/lib/ccweb-mcp-server.js b/lib/ccweb-mcp-server.js index 59625f9..4ebf81e 100644 --- a/lib/ccweb-mcp-server.js +++ b/lib/ccweb-mcp-server.js @@ -36,6 +36,43 @@ const TOOLS = [ additionalProperties: false, }, }, + { + name: 'ccweb_create_conversation', + description: '创建一个新的 ccweb 持久对话。只用于需要在会话列表中长期追踪、后续可继续对话的工作流;一次性并行研究应优先使用子代能力。', + inputSchema: { + type: 'object', + properties: { + agent: { + type: 'string', + enum: ['claude', 'codex', 'codexapp'], + description: '可选。新对话使用的 Agent,默认继承来源对话。', + }, + cwd: { + type: 'string', + description: '可选。新对话工作目录;指定时必须是已存在的绝对路径,默认继承来源对话 cwd。', + }, + title: { + type: 'string', + maxLength: 120, + description: '可选。新对话标题。', + }, + mode: { + type: 'string', + enum: ['default', 'plan', 'yolo'], + description: '可选。权限模式,默认继承来源对话;不会自动写死为 plan。', + }, + initialMessage: { + type: 'string', + description: '可选。创建后立即发送到新对话的首条消息。', + }, + requestReply: { + type: 'boolean', + description: '可选。若为 true,会在新对话完成本轮输出后把回复作为已处理的只读消息写回来源对话,不会再次触发来源对话运行。默认 false。', + }, + }, + additionalProperties: false, + }, + }, { name: 'ccweb_send_message', description: '向指定 ccweb 对话发送一条消息,并以“来自某对话”的气泡在目标对话中展示。', @@ -57,7 +94,7 @@ const TOOLS = [ }, { name: 'ccweb_request_reply', - description: '向指定 ccweb 对话发送一条消息,并在目标对话本轮输出完成后自动把回复发回当前对话。', + description: '向指定 ccweb 对话发送一条消息,并在目标对话本轮输出完成后把回复作为已处理的只读消息写回当前对话;写回不会再次触发当前对话运行。', inputSchema: { type: 'object', properties: { @@ -244,27 +281,31 @@ async function handleRequest(message) { } } -let lineBuffer = ''; +module.exports = { TOOLS }; -process.stdin.setEncoding('utf8'); -process.stdin.on('data', (chunk) => { - lineBuffer += chunk; - let index; - while ((index = lineBuffer.indexOf('\n')) >= 0) { - const line = lineBuffer.slice(0, index).trim(); - lineBuffer = lineBuffer.slice(index + 1); - if (!line) continue; - let message; - try { - message = JSON.parse(line); - } catch (err) { - jsonRpcError(null, -32700, 'Parse error', err.message); - continue; +if (require.main === module) { + let lineBuffer = ''; + + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (chunk) => { + lineBuffer += chunk; + let index; + while ((index = lineBuffer.indexOf('\n')) >= 0) { + const line = lineBuffer.slice(0, index).trim(); + lineBuffer = lineBuffer.slice(index + 1); + if (!line) continue; + let message; + try { + message = JSON.parse(line); + } catch (err) { + jsonRpcError(null, -32700, 'Parse error', err.message); + continue; + } + handleRequest(message); } - handleRequest(message); - } -}); + }); -process.stdin.on('end', () => { - process.exit(0); -}); + process.stdin.on('end', () => { + process.exit(0); + }); +} diff --git a/public/app.js b/public/app.js index 4574be0..3370456 100644 --- a/public/app.js +++ b/public/app.js @@ -2,7 +2,7 @@ (function () { 'use strict'; - const ASSET_VERSION = '20260615-codexapp-steer-status-session-menu'; + const ASSET_VERSION = '20260616-composer-mcp-list'; const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`; const RENDER_DEBOUNCE = 100; const COMPOSER_SUGGESTION_DEBOUNCE = 120; @@ -274,6 +274,21 @@ pendingNotesByTarget.delete(draftKey); } + function updateGenerationControls() { + const noteActive = !!noteMode; + const allowRuntimeInsert = isGenerating && isCodexAppAgent(currentAgent) && !noteActive; + const sendLabel = noteActive ? '记录笔记' : (allowRuntimeInsert ? '插入' : '发送'); + if (sendBtn) { + sendBtn.classList.toggle('note-send', noteActive); + sendBtn.title = sendLabel; + sendBtn.setAttribute('aria-label', sendLabel); + sendBtn.hidden = isGenerating ? !(noteActive || allowRuntimeInsert) : false; + } + if (abortBtn) { + abortBtn.hidden = !isGenerating; + } + } + function updateNoteModeUI() { const active = !!noteMode; if (noteModeBtn) { @@ -283,16 +298,11 @@ noteModeBtn.setAttribute('aria-label', active ? '关闭笔记模式' : '笔记模式'); } if (inputWrapper) inputWrapper.classList.toggle('note-mode-active', active); - if (sendBtn) { - const allowRuntimeInsert = isGenerating && isCodexAppAgent(currentAgent) && !active; - sendBtn.classList.toggle('note-send', active); - sendBtn.title = active ? '记录笔记' : (allowRuntimeInsert ? '插入' : '发送'); - sendBtn.hidden = isGenerating ? (!active && !allowRuntimeInsert) : false; - } if (msgInput) { msgInput.placeholder = active ? '记录笔记,稍后从气泡发送…' : defaultMsgInputPlaceholder; } if (active) hideCmdMenu(); + updateGenerationControls(); } function createNoteActionButton(action, label, title = label) { @@ -800,11 +810,19 @@ } function buildUserOutlineItems() { - return Array.from(userMessageIndex.values()).map((entry) => ({ - id: entry.id, - targetMessageId: entry.element?.id || '', - label: shortMessagePreview(entry.content, 64), - })).filter((entry) => entry.targetMessageId); + const seen = new Set(); + return Array.from(messagesDiv.querySelectorAll('.msg.user[data-message-id]')).map((element) => { + const id = String(element.dataset.messageId || '').trim(); + if (!id || seen.has(id)) return null; + seen.add(id); + const indexed = userMessageIndex.get(id); + const content = indexed?.content || element.querySelector('.msg-text')?.textContent || ''; + return { + id, + targetMessageId: element.id || '', + label: shortMessagePreview(content, 64), + }; + }).filter((entry) => entry && entry.targetMessageId); } function updateUserOutlinePanel() { @@ -844,8 +862,11 @@ function scrollToMessage(anchorId) { if (!anchorId) return; const target = document.getElementById(anchorId); - if (!target) return; - target.scrollIntoView({ block: 'start', behavior: 'smooth' }); + if (!target || !messagesDiv.contains(target)) return; + const containerRect = messagesDiv.getBoundingClientRect(); + const targetRect = target.getBoundingClientRect(); + const targetTop = messagesDiv.scrollTop + targetRect.top - containerRect.top - 12; + messagesDiv.scrollTo({ top: Math.max(0, targetTop), behavior: 'smooth' }); } function updateSessionIdBadge() { @@ -2445,6 +2466,7 @@ currentMode = localStorage.getItem(getAgentModeStorageKey(currentAgent)) || 'yolo'; modeSelect.value = currentMode; updateAgentScopedUI(); + updateGenerationControls(); } function closeAgentMenu() { @@ -2478,8 +2500,7 @@ uploadingAttachments = []; activeToolCalls.clear(); activeTodoCallTargets.clear(); - sendBtn.hidden = false; - abortBtn.hidden = true; + updateGenerationControls(); chatTitle.textContent = '新会话'; updateSessionIdBadge(); updateCwdBadge(); @@ -2501,8 +2522,7 @@ if (isGenerating && !preserveStreaming) { isGenerating = false; generatingSessionId = null; - sendBtn.hidden = false; - abortBtn.hidden = true; + updateGenerationControls(); pendingText = ''; window.pendingContentBlocks = []; activeToolCalls.clear(); @@ -3105,6 +3125,10 @@ showCodexAppUserInputModal(msg); break; + case 'ccweb_mcp_child_agent_update': + applyCcwebMcpChildAgentUpdate(msg); + break; + case 'mode_changed': if (msg.mode && MODE_LABELS[msg.mode]) { currentMode = msg.mode; @@ -3132,8 +3156,7 @@ if (!isGenerating || !document.getElementById('streaming-msg')) { startGenerating(msg.sessionId || currentSessionId); } else { - sendBtn.hidden = true; - abortBtn.hidden = false; + updateGenerationControls(); toolGroupCount = 0; hasGrouped = false; activeToolCalls.clear(); @@ -3287,8 +3310,6 @@ activeTodoCallTargets.clear(); toolGroupCount = 0; hasGrouped = false; - sendBtn.hidden = true; - abortBtn.hidden = false; updateNoteModeUI(); // 不禁用输入框,允许用户继续输入(但无法发送) @@ -3317,8 +3338,6 @@ if (sessionId && currentSessionId && sessionId !== currentSessionId) return; isGenerating = false; generatingSessionId = null; - sendBtn.hidden = false; - abortBtn.hidden = true; updateNoteModeUI(); setCurrentSessionRunningState(false); msgInput.focus(); @@ -3454,7 +3473,7 @@ function createMsgElement(role, content, attachments = [], meta = {}) { const div = document.createElement('div'); - const isCrossConversation = role === 'user' && !!meta.crossConversation; + const isCrossConversation = !!meta.crossConversation; const isCrossConversationReply = isCrossConversation && !!(meta.crossConversation.reply || meta.crossConversation.replyToRequestId); const resolvedMessageId = meta?.messageId || meta?.id || createLocalId('user'); div.className = `msg ${role}${role === 'assistant' ? ' agent-' + currentAgent : ''}${isCrossConversation ? ' cross-conversation' : ''}${isCrossConversationReply ? ' cross-conversation-reply' : ''}`; @@ -3512,36 +3531,37 @@ const bubble = document.createElement('div'); bubble.className = 'msg-bubble'; - if (role === 'user') { - if (isCrossConversation) { - const source = meta.crossConversation || {}; - const sourceTitle = source.sourceTitle || '未命名对话'; - const sourceId = source.sourceSessionId || ''; - const sourceMeta = document.createElement('div'); - sourceMeta.className = 'cross-conversation-meta'; + if (isCrossConversation) { + const source = meta.crossConversation || {}; + const sourceTitle = source.sourceTitle || '未命名对话'; + const sourceId = source.sourceSessionId || ''; + const sourceMeta = document.createElement('div'); + sourceMeta.className = 'cross-conversation-meta'; - const label = document.createElement('span'); - label.className = 'cross-conversation-label'; - label.textContent = isCrossConversationReply - ? `来自「${sourceTitle}」的回复` - : `来自「${sourceTitle}」的对话`; - sourceMeta.appendChild(label); + const label = document.createElement('span'); + label.className = 'cross-conversation-label'; + label.textContent = isCrossConversationReply + ? `来自「${sourceTitle}」的回复` + : `来自「${sourceTitle}」的对话`; + sourceMeta.appendChild(label); - if (sourceId) { - const copyBtn = document.createElement('button'); - copyBtn.type = 'button'; - copyBtn.className = 'cross-conversation-id-btn'; - copyBtn.textContent = `ID ${shortSessionId(sourceId)}`; - copyBtn.title = `复制来源会话 ID\n${sourceId}`; - copyBtn.addEventListener('click', (event) => { - event.stopPropagation(); - copyTextToClipboard(sourceId, '来源会话 ID 已复制'); - }); - sourceMeta.appendChild(copyBtn); - } - - bubble.appendChild(sourceMeta); + if (sourceId) { + const copyBtn = document.createElement('button'); + copyBtn.type = 'button'; + copyBtn.className = 'cross-conversation-id-btn'; + copyBtn.textContent = `ID ${shortSessionId(sourceId)}`; + copyBtn.title = `复制来源会话 ID\n${sourceId}`; + copyBtn.addEventListener('click', (event) => { + event.stopPropagation(); + copyTextToClipboard(sourceId, '来源会话 ID 已复制'); + }); + sourceMeta.appendChild(copyBtn); } + + bubble.appendChild(sourceMeta); + } + + if (role === 'user') { if (content) { const textNode = document.createElement('div'); textNode.className = 'msg-text'; @@ -3877,7 +3897,7 @@ const label = String(state.label || state.title || state.nickname || state.name || `子代理 ${index + 1}`); const role = String(state.role || state.agent || state.agentType || '').trim(); const status = String(state.status || state.state || 'pending').trim() || 'pending'; - const detail = String(state.summary || state.lastMessage || state.step || state.description || '').trim(); + const detail = String(state.candidateResult || state.finalMessage || state.summary || state.lastMessage || state.step || state.description || '').trim(); return { id, label, role, status, detail }; }); } @@ -3885,7 +3905,8 @@ function collabStateTone(statusText) { const normalized = String(statusText || '').toLowerCase(); if (!normalized) return 'pending'; - if (/(done|completed|success|finished|idle)/.test(normalized)) return 'done'; + if (/(closed|close)/.test(normalized)) return 'closed'; + if (/(returned|done|completed|success|finished|idle)/.test(normalized)) return 'done'; if (/(fail|error|cancel|aborted|rejected)/.test(normalized)) return 'error'; if (/(running|working|active|inprogress|in_progress|executing)/.test(normalized)) return 'running'; return 'pending'; @@ -3895,7 +3916,9 @@ const normalized = String(statusText || '').trim(); if (!normalized) return '等待中'; const lower = normalized.toLowerCase(); - if (/(done|completed|success|finished)/.test(lower)) return '已完成'; + if (/(closed|close)/.test(lower)) return '已关闭'; + if (/(returned)/.test(lower)) return '已返回'; + if (/(done|completed|success|finished)/.test(lower)) return '已返回'; if (/(fail|error|rejected)/.test(lower)) return '失败'; if (/(cancel|aborted)/.test(lower)) return '已取消'; if (/(running|working|active|inprogress|in_progress|executing)/.test(lower)) return '进行中'; @@ -3919,7 +3942,7 @@ const kicker = document.createElement('div'); kicker.className = 'collab-agent-kicker'; - kicker.textContent = 'Codex App 子代理'; + kicker.textContent = 'ccweb MCP 子代理'; titleWrap.appendChild(kicker); const title = document.createElement('div'); @@ -3971,6 +3994,25 @@ chip.className = `collab-agent-item-status ${tone}`; chip.textContent = collabStateLabel(entry.status); row.appendChild(chip); + + if (entry.id && tone !== 'closed') { + const closeBtn = document.createElement('button'); + closeBtn.type = 'button'; + closeBtn.className = 'collab-agent-close-btn'; + closeBtn.textContent = '关闭'; + closeBtn.title = `关闭子代理\n${entry.id}`; + closeBtn.addEventListener('click', (event) => { + event.stopPropagation(); + closeBtn.disabled = true; + closeBtn.textContent = '关闭中'; + send({ + type: 'ccweb_mcp_child_agent_close', + sessionId: currentSessionId, + threadId: entry.id, + }); + }); + row.appendChild(closeBtn); + } item.appendChild(row); if (entry.role) { @@ -4032,7 +4074,9 @@ } function isGroupableToolCall(node) { - return !!(node?.classList?.contains('tool-call') && node.dataset.toolKind !== 'todo_list'); + return !!(node?.classList?.contains('tool-call') + && node.dataset.toolKind !== 'todo_list' + && node.dataset.toolKind !== 'collab_agent_tool_call'); } function rememberToolCallTarget(toolUseId, tool, element) { @@ -4425,6 +4469,18 @@ } function createToolCallElement(toolUseId, tool, done) { + const kind = toolKind(tool); + if (kind === 'collab_agent_tool_call') { + const wrapper = document.createElement('div'); + wrapper.className = 'tool-call ccweb-mcp-child-agent-tool-call collab-agent-inline'; + wrapper.id = `tool-node-${++toolDomSeq}`; + wrapper.dataset.toolUseId = toolUseId ? String(toolUseId) : ''; + wrapper.dataset.toolName = tool.name || ''; + wrapper.dataset.toolKind = kind; + wrapper.appendChild(buildToolContentElement({ ...tool, done })); + return wrapper; + } + const details = document.createElement('details'); details.className = 'tool-call'; details.id = `tool-node-${++toolDomSeq}`; @@ -4439,7 +4495,6 @@ // - For non-Codex sessions, auto-open in-flight command execution so users can watch output. // - For Codex sessions, keep everything collapsed by default (less noise), including in-flight commands. const agent = normalizeAgent(currentAgent); - const kind = toolKind(tool); if (tool.name === 'AskUserQuestion') { details.open = true; } else if (!isCodexLikeAgent(agent) && !done && kind === 'command_execution') { @@ -4552,6 +4607,10 @@ el = findLatestToolCallElement(scope, (candidate) => candidate.dataset.toolUseId === toolUseIdText); } + if (!el) { + el = findLatestToolCallElement(messagesDiv, (candidate) => candidate.dataset.toolUseId === toolUseIdText); + } + if (!el && tool?.kind === 'todo_list' && tool?.input?.id) { el = findTodoToolCallByTodoId(scope, tool.input.id); } @@ -4586,6 +4645,41 @@ } } + function applyCcwebMcpChildAgentUpdate(msg) { + const tool = msg?.tool; + const toolUseId = msg?.toolUseId || tool?.id; + if (!toolUseId || !tool) return; + + updateCachedSession(msg.sessionId, (snapshot) => { + const messages = Array.isArray(snapshot.messages) ? snapshot.messages : []; + for (let i = messages.length - 1; i >= 0; i -= 1) { + const calls = Array.isArray(messages[i]?.toolCalls) ? messages[i].toolCalls : []; + const target = calls.find((item) => item.id === toolUseId); + if (!target) continue; + target.name = tool.name || target.name; + target.kind = tool.kind || target.kind; + target.input = tool.input !== undefined ? tool.input : target.input; + target.result = tool.result !== undefined ? tool.result : target.result; + target.meta = tool.meta || target.meta || null; + target.done = tool.done !== undefined ? !!tool.done : target.done; + snapshot.updated = new Date().toISOString(); + break; + } + }); + + if (msg.sessionId !== currentSessionId) return; + activeToolCalls.set(toolUseId, { + id: toolUseId, + name: tool.name, + input: tool.input, + result: tool.result, + kind: tool.kind || null, + meta: tool.meta || null, + done: !!tool.done, + }); + updateToolCall(toolUseId, tool.result, !!tool.done); + } + function getDeleteConfirmMessage(agent) { const normalized = normalizeAgent(agent); if (normalized === 'codex') { @@ -5098,7 +5192,9 @@ ? 'Prompt' : item.kind === 'file' ? (item.itemType === 'directory' ? 'Dir' : 'File') - : 'Cmd'; + : item.kind === 'mcp' + ? 'MCP' + : 'Cmd'; return `
${kindLabel} @@ -5127,7 +5223,6 @@ if (token.trigger === '/') { showCmdMenu(token, getLocalSlashSuggestions(token.query)); - return; } clearTimeout(composerSuggestionTimer); diff --git a/public/index.html b/public/index.html index 2296edd..6e268a7 100644 --- a/public/index.html +++ b/public/index.html @@ -14,7 +14,7 @@ document.documentElement.dataset.dividerTime = dividerTime; })(); - + @@ -150,6 +150,6 @@ - + diff --git a/public/style.css b/public/style.css index 84486a7..180c2bf 100644 --- a/public/style.css +++ b/public/style.css @@ -1986,14 +1986,14 @@ body.session-loading-active { background: rgba(255, 255, 255, 0.2); border-color: rgba(255, 255, 255, 0.28); } -.msg.user.cross-conversation .msg-avatar { +.msg.cross-conversation .msg-avatar { background: var(--info); color: #fff; } -.msg.user.cross-conversation-reply .msg-avatar { +.msg.cross-conversation-reply .msg-avatar { background: var(--success); } -.msg.user.cross-conversation .msg-bubble { +.msg.cross-conversation .msg-bubble { background: linear-gradient(180deg, rgba(255, 255, 255, 0.76), transparent), rgba(91, 126, 161, 0.1); @@ -2001,20 +2001,20 @@ body.session-loading-active { color: var(--text-primary); box-shadow: 0 10px 22px rgba(45, 31, 20, 0.05); } -.msg.user.cross-conversation-reply .msg-bubble { +.msg.cross-conversation-reply .msg-bubble { background: linear-gradient(180deg, rgba(255, 255, 255, 0.78), transparent), rgba(93, 138, 84, 0.12); border-color: rgba(93, 138, 84, 0.28); } -.msg.user.cross-conversation-reply .cross-conversation-meta, -.msg.user.cross-conversation-reply .cross-conversation-id-btn { +.msg.cross-conversation-reply .cross-conversation-meta, +.msg.cross-conversation-reply .cross-conversation-id-btn { color: var(--success); } -.msg.user.cross-conversation-reply .cross-conversation-id-btn { +.msg.cross-conversation-reply .cross-conversation-id-btn { border-color: rgba(93, 138, 84, 0.28); } -.msg.user.cross-conversation-reply .cross-conversation-id-btn:hover { +.msg.cross-conversation-reply .cross-conversation-id-btn:hover { background: rgba(93, 138, 84, 0.14); } .cross-conversation-meta { @@ -2350,11 +2350,14 @@ html[data-divider-time='hide'] .msg-bubble .agent-message-divider span { .tool-call.codex-file-change { border-color: rgba(93, 138, 84, 0.24); } -.tool-call.codex-collab-agent-tool-call { +.tool-call.ccweb-mcp-child-agent-tool-call { border-color: rgba(91, 126, 161, 0.28); background: linear-gradient(180deg, rgba(255, 255, 255, 0.66), rgba(91, 126, 161, 0.04)); } +.tool-call.ccweb-mcp-child-agent-tool-call.collab-agent-inline { + overflow: visible; +} .tool-call summary { padding: 8px 12px; cursor: pointer; @@ -2463,6 +2466,8 @@ html[data-divider-time='hide'] .msg-bubble .agent-message-divider span { font-family: inherit; white-space: normal; word-break: normal; + max-height: none; + overflow: visible; background: linear-gradient(180deg, rgba(252, 253, 255, 0.96), rgba(242, 247, 252, 0.98)); color: var(--text-primary); @@ -4293,6 +4298,11 @@ html[data-theme='coolvibe'] .settings-back:hover { background: rgba(192, 85, 58, 0.14); color: var(--danger); } +.collab-agent-overall-status.closed, +.collab-agent-item-status.closed { + background: rgba(122, 110, 100, 0.12); + color: var(--text-muted); +} .collab-agent-overall-status.pending, .collab-agent-item-status.pending { background: rgba(91, 126, 161, 0.12); @@ -4354,6 +4364,28 @@ html[data-theme='coolvibe'] .settings-back:hover { .collab-agent-item-footer:hover { color: var(--info); } +.collab-agent-close-btn { + appearance: none; + flex-shrink: 0; + border: 1px solid rgba(122, 110, 100, 0.18); + border-radius: 999px; + background: rgba(255, 255, 255, 0.86); + color: var(--text-secondary); + padding: 4px 9px; + font: inherit; + font-size: 11px; + font-weight: 800; + cursor: pointer; +} +.collab-agent-close-btn:hover { + color: var(--danger); + border-color: rgba(192, 85, 58, 0.22); + background: rgba(192, 85, 58, 0.08); +} +.collab-agent-close-btn:disabled { + cursor: default; + opacity: 0.6; +} .collab-agent-threads { display: flex; flex-wrap: wrap; @@ -4777,10 +4809,11 @@ html[data-theme='coolvibe'] .settings-back:hover { :is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .file-browser-path, :is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .file-browser-pane, :is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .file-browser-preview-content, -:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .collab-agent-prompt, -:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .collab-agent-item, -:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .collab-agent-thread-chip, -:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .todo-list-container { + :is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .collab-agent-prompt, + :is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .collab-agent-item, + :is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .collab-agent-thread-chip, + :is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .collab-agent-close-btn, + :is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .todo-list-container { background: var(--dark-panel-soft); border-color: var(--border-color); color: var(--text-primary); @@ -4857,8 +4890,8 @@ html[data-theme='coolvibe'] .settings-back:hover { } :is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .msg.assistant .msg-bubble, -:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .msg.user.cross-conversation .msg-bubble, -:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .msg.user.cross-conversation-reply .msg-bubble { +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .msg.cross-conversation .msg-bubble, +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .msg.cross-conversation-reply .msg-bubble { background: var(--bg-bubble-assistant); border-color: var(--border-color); color: var(--text-primary); @@ -4908,7 +4941,7 @@ html[data-theme='coolvibe'] .settings-back:hover { } :is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .tool-call.codex-command, -:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .tool-call.codex-collab-agent-tool-call { +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .tool-call.ccweb-mcp-child-agent-tool-call { background: var(--dark-panel-bg); border-color: var(--note-border); box-shadow: none; diff --git a/scripts/mock-codex-app-server.js b/scripts/mock-codex-app-server.js index b72665e..819fd5a 100755 --- a/scripts/mock-codex-app-server.js +++ b/scripts/mock-codex-app-server.js @@ -105,6 +105,57 @@ function threadPayload(thread) { }; } +function emitChildCollabTurn(threadId, turnId, finalMessage) { + const childThread = ensureThread(threadId); + childThread.activeTurnId = turnId; + send({ + method: 'turn/started', + params: { + threadId, + turn: { + id: turnId, + status: 'running', + items: [], + }, + }, + }); + send({ + method: 'item/agentMessage/delta', + params: { + threadId, + turnId, + itemId: 'agent-msg', + delta: finalMessage, + }, + }); + send({ + method: 'item/completed', + params: { + threadId, + turnId, + completedAtMs: Date.now(), + item: { + id: 'agent-msg', + type: 'agentMessage', + content: [{ type: 'text', text: finalMessage }], + status: 'completed', + }, + }, + }); + send({ + method: 'turn/completed', + params: { + threadId, + turn: { + id: turnId, + status: 'completed', + items: [], + }, + }, + }); + childThread.activeTurnId = null; +} + function completeTurn(thread, turnId, text, status = 'completed') { if (thread.activeTurnId !== turnId) return; const suffix = thread.steers.length > 0 ? ` | steer: ${thread.steers.join(' | ')}` : ''; @@ -205,7 +256,7 @@ function completeTurn(thread, turnId, text, status = 'completed') { }); } - if (/subagent|collab/i.test(text)) { + if (/subagent/i.test(text)) { send({ method: 'item/started', params: { @@ -266,6 +317,16 @@ function completeTurn(thread, turnId, text, status = 'completed') { }, }, }); + emitChildCollabTurn( + 'child-thread-a', + 'child-turn-a', + '子代理最终消息:结构化渲染和关闭按钮链路已完成。' + ); + emitChildCollabTurn( + 'child-thread-b', + 'child-turn-b', + '子代理最终消息:事件路由、持久化和状态推送检查通过。' + ); } send({ diff --git a/scripts/regression.js b/scripts/regression.js index 47434e2..ee1ccbd 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -9,6 +9,7 @@ const WebSocket = require('ws'); const REPO_DIR = path.resolve(__dirname, '..'); const SERVER_PATH = path.join(REPO_DIR, 'server.js'); +const PUBLIC_APP_PATH = path.join(REPO_DIR, 'public', 'app.js'); const MOCK_CLAUDE = path.join(REPO_DIR, 'scripts', 'mock-claude.js'); const MOCK_CODEX = path.join(REPO_DIR, 'scripts', 'mock-codex.js'); const MOCK_CODEX_APP_SERVER = path.join(REPO_DIR, 'scripts', 'mock-codex-app-server.js'); @@ -66,6 +67,23 @@ async function waitForFile(filePath, timeoutMs = 10000) { throw new Error(`Timed out waiting for file: ${filePath}`); } +async function waitForJsonCondition(filePath, predicate, timeoutMs = 5000) { + const started = Date.now(); + let lastError = null; + while (Date.now() - started < timeoutMs) { + try { + if (fs.existsSync(filePath)) { + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')); + if (predicate(parsed)) return parsed; + } + } catch (err) { + lastError = err; + } + await sleep(50); + } + throw new Error(`Timed out waiting for JSON condition: ${filePath}${lastError ? ` (${lastError.message})` : ''}`); +} + async function withServer(env, fn) { const child = spawn('/usr/bin/node', [SERVER_PATH], { cwd: REPO_DIR, @@ -417,7 +435,64 @@ function createFakeCodexConfig(homeDir, { model = 'gpt-5.5', reasoningEffort = ' ].join('\n')); } +function assertFrontendGenerationControlsContract() { + const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8'); + const controlsStart = source.indexOf('function updateGenerationControls()'); + const controlsEnd = source.indexOf('\n function updateNoteModeUI()', controlsStart); + assert(controlsStart >= 0 && controlsEnd > controlsStart, 'Frontend should define updateGenerationControls before updateNoteModeUI'); + + for (const target of ['sendBtn.hidden', 'abortBtn.hidden']) { + const regex = new RegExp(`${target.replace('.', '\\.')}\\s*=`, 'g'); + let match; + while ((match = regex.exec(source))) { + assert( + match.index > controlsStart && match.index < controlsEnd, + `${target} should only be assigned in updateGenerationControls` + ); + } + } + + const resumeStart = source.indexOf("case 'resume_generating':"); + const resumeEnd = source.indexOf("case 'error':", resumeStart); + assert(resumeStart >= 0 && resumeEnd > resumeStart, 'Frontend should keep an explicit resume_generating handler'); + const resumeBlock = source.slice(resumeStart, resumeEnd); + assert( + resumeBlock.includes('updateGenerationControls();'), + 'resume_generating should refresh send/abort controls when reusing an existing streaming bubble' + ); + + const controlsBlock = source.slice(controlsStart, controlsEnd); + assert( + /allowRuntimeInsert\s*=\s*isGenerating\s*&&\s*isCodexAppAgent\(currentAgent\)/.test(controlsBlock), + 'Codex App should keep the runtime insert send button visible while generating' + ); +} + +function assertFrontendComposerMcpContract() { + const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8'); + const requestStart = source.indexOf('function requestComposerSuggestions()'); + const requestEnd = source.indexOf('\n function handleComposerSuggestions', requestStart); + assert(requestStart >= 0 && requestEnd > requestStart, 'Frontend should define requestComposerSuggestions before handleComposerSuggestions'); + + const requestBlock = source.slice(requestStart, requestEnd); + const slashStart = requestBlock.indexOf("if (token.trigger === '/')"); + const slashEnd = requestBlock.indexOf('clearTimeout(composerSuggestionTimer);', slashStart); + assert(slashStart >= 0 && slashEnd > slashStart, 'Slash composer branch should precede backend debounce'); + const slashBlock = requestBlock.slice(slashStart, slashEnd); + assert(slashBlock.includes('getLocalSlashSuggestions(token.query)'), 'Slash composer should keep local fallback suggestions'); + assert(!/\breturn\s*;/.test(slashBlock), 'Slash composer should continue to backend suggestions so MCP items can be merged'); + + const menuStart = source.indexOf('function showCmdMenu(token, items)'); + const menuEnd = source.indexOf('\n function requestComposerSuggestions()', menuStart); + assert(menuStart >= 0 && menuEnd > menuStart, 'Frontend should define showCmdMenu before requestComposerSuggestions'); + const menuBlock = source.slice(menuStart, menuEnd); + assert(/item\.kind\s*===\s*'mcp'/.test(menuBlock) && menuBlock.includes("'MCP'"), 'Composer menu should render MCP item labels'); +} + async function main() { + assertFrontendGenerationControlsContract(); + assertFrontendComposerMcpContract(); + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-regression-')); const configDir = path.join(tempRoot, 'config'); const sessionsDir = path.join(tempRoot, 'sessions'); @@ -555,14 +630,26 @@ async function main() { const slashComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-slash'); assert(slashComposer.items.some((item) => item.kind === 'command' && item.name === '/model'), 'Composer slash suggestions should include /model'); + ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-slash-mcp', trigger: '/', query: 'ccweb', sessionId: codexSession.sessionId, agent: 'codex' })); + const slashMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-slash-mcp'); + assert(slashMcpComposer.items.some((item) => item.kind === 'mcp' && item.name === 'ccweb_list_conversations'), 'Composer slash suggestions should include ccweb MCP tools'); + ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-skill', trigger: '$', query: 'reg', sessionId: codexSession.sessionId, agent: 'codex' })); const skillComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-skill'); assert(skillComposer.items.some((item) => item.kind === 'skill' && item.name === 'regression-skill'), 'Composer skill suggestions should include local Codex skill'); + ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-skill-mcp', trigger: '$', query: 'ccweb', sessionId: codexSession.sessionId, agent: 'codex' })); + const skillMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-skill-mcp'); + assert(skillMcpComposer.items.some((item) => item.kind === 'mcp' && item.name === 'ccweb_list_conversations'), 'Composer skill trigger suggestions should include ccweb MCP tools'); + ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-prompt', trigger: '@', query: 'ship', sessionId: codexSession.sessionId, agent: 'codex' })); const promptComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-prompt'); assert(promptComposer.items.some((item) => item.kind === 'prompt' && item.name === 'shipit'), 'Composer prompt suggestions should include configured prompt'); + ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-prompt-mcp', trigger: '@', query: 'ccweb', sessionId: codexSession.sessionId, agent: 'codex' })); + const promptMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-prompt-mcp'); + assert(promptMcpComposer.items.some((item) => item.kind === 'mcp' && item.name === 'ccweb_list_conversations'), 'Composer prompt trigger suggestions should include ccweb MCP tools'); + ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-file', trigger: '@', query: 'context', sessionId: codexSession.sessionId, agent: 'codex' })); const fileComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-file'); assert(fileComposer.items.some((item) => item.kind === 'file' && item.name === 'context.txt'), 'Composer file suggestions should include cwd file'); @@ -597,6 +684,69 @@ async function main() { assert(mcpList.body.currentConversationId === codexSession.sessionId, 'MCP list should return current source conversation id'); assert(mcpList.body.conversations.some((item) => item.id === codexSession.sessionId && !item.summary), 'MCP list should return lightweight session metadata without summary'); + const mcpRelativeCreate = await callInternalMcp(port, internalMcpToken, { + tool: 'ccweb_create_conversation', + sourceSessionId: codexSession.sessionId, + args: { cwd: 'relative-project', title: 'Relative path should fail' }, + }); + assert(mcpRelativeCreate.status === 400 && mcpRelativeCreate.body?.code === 'create_conversation_cwd_relative', 'MCP create conversation should reject relative cwd'); + + const mcpCreate = await callInternalMcp(port, internalMcpToken, { + tool: 'ccweb_create_conversation', + sourceSessionId: codexSession.sessionId, + sourceHopCount: 0, + args: { + agent: 'codex', + title: 'MCP Created Conversation', + initialMessage: 'mcp created initial prompt', + }, + }); + assert(mcpCreate.status === 200 && mcpCreate.body?.ok, `MCP create conversation should succeed: ${JSON.stringify(mcpCreate.body)}`); + assert(mcpCreate.body.cwd === codexInitCwd, 'MCP create conversation should inherit source cwd by default'); + assert(mcpCreate.body.mode === 'plan', 'MCP create conversation should inherit source mode by default'); + assert(mcpCreate.body.status === 'running', 'MCP create with initialMessage should start the new conversation'); + assert(mcpCreate.body.messageId, 'MCP create with initialMessage should return the delivered message id'); + await nextMessage(messages, ws, (msg) => msg.type === 'background_done' && msg.sessionId === mcpCreate.body.conversationId); + const storedMcpCreated = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${mcpCreate.body.conversationId}.json`), 'utf8')); + assert(storedMcpCreated.title === 'MCP Created Conversation', 'MCP created conversation should persist the requested title'); + assert(storedMcpCreated.createdFrom?.sourceSessionId === codexSession.sessionId, 'MCP created conversation should persist source metadata'); + assert(storedMcpCreated.messages.some((message) => message.content === 'mcp created initial prompt' && message.crossConversation?.sourceSessionId === codexSession.sessionId), 'MCP created conversation should persist the initial cross-conversation message'); + assert(storedMcpCreated.messages.some((message) => message.role === 'assistant' && /mcp created initial prompt/.test(String(message.content || ''))), 'MCP created conversation should run the initial prompt'); + + const mcpReplyCreateCwd = path.join(tempRoot, 'mcp-create-reply'); + mkdirp(mcpReplyCreateCwd); + const mcpCreateReply = await callInternalMcp(port, internalMcpToken, { + tool: 'ccweb_create_conversation', + sourceSessionId: codexSession.sessionId, + sourceHopCount: 0, + args: { + agent: 'codex', + cwd: mcpReplyCreateCwd, + title: 'MCP Reply Conversation', + initialMessage: 'mcp create request reply', + requestReply: true, + }, + }); + assert(mcpCreateReply.status === 200 && mcpCreateReply.body?.ok, `MCP create conversation with requestReply should succeed: ${JSON.stringify(mcpCreateReply.body)}`); + assert(mcpCreateReply.body.cwd === mcpReplyCreateCwd, 'MCP create conversation should use an explicit absolute cwd'); + assert(mcpCreateReply.body.requestId && mcpCreateReply.body.replyStatus === 'waiting', 'MCP create requestReply should return a waiting request id'); + await nextMessage(messages, ws, (msg) => msg.type === 'background_done' && msg.sessionId === mcpCreateReply.body.conversationId); + await waitForJsonCondition(path.join(sessionsDir, `${codexSession.sessionId}.json`), (session) => ( + Array.isArray(session.messages) && + session.messages.some((message) => ( + message.crossConversation?.replyToRequestId === mcpCreateReply.body.requestId && + message.crossConversation?.processed === true + )) + )); + const storedMcpCreateReply = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${mcpCreateReply.body.conversationId}.json`), 'utf8')); + const storedMcpCreateSource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8')); + assert(storedMcpCreateReply.messages.some((message) => message.crossConversation?.replyRequestId === mcpCreateReply.body.requestId), 'MCP create requestReply should persist waiting metadata on the new conversation'); + assert(storedMcpCreateSource.messages.some((message) => ( + message.crossConversation?.replyToRequestId === mcpCreateReply.body.requestId && + message.crossConversation?.processed === true && + message.crossConversation?.autoRun === false + )), 'MCP create requestReply should send a processed display-only reply back to source'); + const crossTargetCwd = path.join(tempRoot, 'codex-mcp-cross-target'); mkdirp(crossTargetCwd); ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: crossTargetCwd, mode: 'yolo' })); @@ -660,6 +810,7 @@ async function main() { }); assert(requestReply.status === 200 && requestReply.body?.ok, `MCP request reply should succeed: ${JSON.stringify(requestReply.body)}`); assert(requestReply.body.requestId && requestReply.body.status === 'waiting', 'MCP request reply should return a waiting request id'); + assert(requestReply.body.replyDelivery === 'display_only' && requestReply.body.sourceAutoRun === false, 'MCP request reply should declare display-only delivery without source auto-run'); const requestReplyTargetBubble = await nextMessage(messages, ws, (msg) => ( msg.type === 'session_message' && msg.sessionId === crossReplyTargetSession.sessionId && @@ -669,9 +820,12 @@ async function main() { )); assert(requestReplyTargetBubble.message.crossConversation.hopCount === 1, 'Request reply target message should persist hop count'); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === crossReplyTargetSession.sessionId); - await nextMessage(messages, ws, (msg) => ( - (msg.type === 'done' || msg.type === 'background_done') && - msg.sessionId === codexSession.sessionId + await waitForJsonCondition(path.join(sessionsDir, `${codexSession.sessionId}.json`), (session) => ( + Array.isArray(session.messages) && + session.messages.some((message) => ( + message.crossConversation?.replyToRequestId === requestReply.body.requestId && + message.crossConversation?.processed === true + )) )); const storedReplyTarget = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${crossReplyTargetSession.sessionId}.json`), 'utf8')); @@ -682,14 +836,18 @@ async function main() { const storedReplyMessageIndex = storedReplySource.messages.findIndex((message) => message.crossConversation?.replyToRequestId === requestReply.body.requestId); assert(storedReplyMessageIndex >= 0, 'Request reply should send the target reply back to source session'); const storedReplyMessage = storedReplySource.messages[storedReplyMessageIndex]; + assert(storedReplyMessage.role === 'assistant', 'Returned cross message should be persisted as display-only assistant content'); assert(storedReplyMessage.crossConversation.reply === true, 'Returned cross message should be marked as a reply'); + assert(storedReplyMessage.crossConversation.processed === true, 'Returned cross message should persist a processed marker'); + assert(storedReplyMessage.crossConversation.autoRun === false, 'Returned cross message should not auto-run the source session again'); + assert(storedReplyMessage.ccwebDisplayOnly === true, 'Returned cross message should be marked display-only'); assert(/线程「/.test(storedReplyMessage.content || '') && /已返回消息/.test(storedReplyMessage.content || ''), 'Returned cross message should include reply heading'); assert(/Codex mock handled/.test(storedReplyMessage.content || ''), 'Returned cross message should include target assistant output'); - assert(storedReplySource.messages.slice(storedReplyMessageIndex + 1).some((message) => ( + assert(!storedReplySource.messages.slice(storedReplyMessageIndex + 1).some((message) => ( message.role === 'assistant' && /Codex mock handled/.test(String(message.content || '')) && /已返回消息/.test(String(message.content || '')) - )), 'Returned cross message should trigger the source session to run again'); + )), 'Returned cross message should not trigger the source session to run again'); const processLogAfterMcp = fs.readFileSync(path.join(logsDir, 'process.log'), 'utf8'); const mcpSpawnLine = processLogAfterMcp @@ -884,15 +1042,43 @@ async function main() { await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId); ws.send(JSON.stringify({ type: 'message', text: 'codexapp subagent prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' })); + const ccwebMcpChildRunning = await nextMessage(messages, ws, (msg) => + msg.type === 'ccweb_mcp_child_agent_update' && + msg.sessionId === codexAppSession.sessionId && + msg.child?.threadId === 'child-thread-a' && + msg.child?.status === 'running' + ); + assert(ccwebMcpChildRunning.toolUseId === 'tool-collab', 'ccweb MCP child update should reference the parent collab tool'); const codexAppCollabTool = await nextMessage(messages, ws, (msg) => msg.type === 'tool_end' && msg.sessionId === codexAppSession.sessionId && msg.toolUseId === 'tool-collab'); assert(codexAppCollabTool.kind === 'collab_agent_tool_call', 'Codex App should surface collab agent tool calls'); - assert(/child-thread-a/.test(codexAppCollabTool.result || ''), 'Codex App collab tool should include child thread ids'); + assert(/child-thread-a/.test(codexAppCollabTool.result || ''), 'ccweb MCP collab tool should include child thread ids'); + const ccwebMcpChildReturned = await nextMessage(messages, ws, (msg) => + msg.type === 'ccweb_mcp_child_agent_update' && + msg.sessionId === codexAppSession.sessionId && + msg.child?.threadId === 'child-thread-a' && + msg.child?.status === 'returned' && + /子代理最终消息/.test(msg.child?.candidateResult || '') + ); + assert(/finalMessage/.test(ccwebMcpChildReturned.tool?.result || ''), 'ccweb MCP child final message should be merged into the parent tool result'); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId); + ws.send(JSON.stringify({ type: 'ccweb_mcp_child_agent_close', sessionId: codexAppSession.sessionId, threadId: 'child-thread-a' })); + const ccwebMcpChildClosed = await nextMessage(messages, ws, (msg) => + msg.type === 'ccweb_mcp_child_agent_update' && + msg.sessionId === codexAppSession.sessionId && + msg.child?.threadId === 'child-thread-a' && + msg.child?.status === 'closed' + ); + assert(/"status": "closed"/.test(ccwebMcpChildClosed.tool?.result || ''), 'ccweb MCP child close should update the parent collab tool state'); storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8')); const hasCollabTool = storedCodexApp.messages .flatMap((message) => Array.isArray(message.toolCalls) ? message.toolCalls : []) .some((tool) => tool.kind === 'collab_agent_tool_call'); - assert(hasCollabTool, 'Codex App collab tool should be persisted into session history'); + assert(hasCollabTool, 'ccweb MCP collab tool should be persisted into session history'); + const persistedClosedCollabTool = storedCodexApp.messages + .flatMap((message) => Array.isArray(message.toolCalls) ? message.toolCalls : []) + .reverse() + .find((tool) => tool.id === 'tool-collab'); + assert(/"status": "closed"/.test(persistedClosedCollabTool?.result || ''), 'ccweb MCP manual child close should persist closed state'); ws.send(JSON.stringify({ type: 'message', text: 'codexapp collaboration plan probe', sessionId: codexAppSession.sessionId, mode: 'plan', agent: 'codexapp' })); const codexAppPlanCollab = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /collaboration mode:/.test(msg.text || '')); diff --git a/server.js b/server.js index a057e10..05d7ec8 100644 --- a/server.js +++ b/server.js @@ -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;