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 `