diff --git a/lib/codex-app-worker-client.js b/lib/codex-app-worker-client.js index ba5cb4c..5c14f72 100644 --- a/lib/codex-app-worker-client.js +++ b/lib/codex-app-worker-client.js @@ -42,7 +42,7 @@ function createCodexAppWorkerClient(options = {}) { appServerRunning = false; worker = fork(workerPath, [], { cwd: options.cwd || process.cwd(), - env: process.env, + env: options.env || process.env, stdio: ['ignore', 'ignore', 'ignore', 'ipc'], }); diff --git a/public/app.js b/public/app.js index 3370456..4385622 100644 --- a/public/app.js +++ b/public/app.js @@ -2,7 +2,7 @@ (function () { 'use strict'; - const ASSET_VERSION = '20260616-composer-mcp-list'; + const ASSET_VERSION = '20260616-child-agent-close-state'; const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`; const RENDER_DEBOUNCE = 100; const COMPOSER_SUGGESTION_DEBOUNCE = 120; @@ -130,6 +130,7 @@ let generatingSessionId = null; let activeToolCalls = new Map(); let activeTodoCallTargets = new Map(); + let closedCollabAgentIds = new Set(); let toolDomSeq = 0; let toolGroupCount = 0; // 当前 .msg-tools 直接子节点数(含已有父目录) let hasGrouped = false; // 本次输出是否已触发过折叠 @@ -785,6 +786,13 @@ return value ? value.slice(0, 8) : ''; } + function shortChildAgentId(threadId) { + const value = String(threadId || ''); + if (!value) return ''; + if (value.length <= 13) return value; + return `${value.slice(0, 8)}…${value.slice(-4)}`; + } + function shortMessagePreview(text, maxLength = 60) { const value = String(text || '').replace(/\s+/g, ' ').trim(); if (!value) return '空消息'; @@ -2500,6 +2508,7 @@ uploadingAttachments = []; activeToolCalls.clear(); activeTodoCallTargets.clear(); + closedCollabAgentIds = new Set(); updateGenerationControls(); chatTitle.textContent = '新会话'; updateSessionIdBadge(); @@ -2989,6 +2998,7 @@ }); } if (msg.sessionId === currentSessionId && msg.message) { + collectClosedCollabAgentIds([msg.message]).forEach((id) => closedCollabAgentIds.add(id)); const welcome = messagesDiv.querySelector('.welcome-msg'); if (welcome) welcome.remove(); messagesDiv.appendChild(buildMsgElement(msg.message)); @@ -3167,7 +3177,12 @@ pendingText = msg.text || ''; flushRender(); if (msg.toolCalls && msg.toolCalls.length > 0) { - for (const tc of msg.toolCalls) { + const mergedCollabTool = mergeCollabAgentTools(msg.toolCalls); + const resumeToolCalls = [ + ...(mergedCollabTool ? [mergedCollabTool] : []), + ...msg.toolCalls.filter((tc) => toolKind(tc) !== 'collab_agent_tool_call'), + ]; + for (const tc of resumeToolCalls) { activeToolCalls.set(tc.id, { name: tc.name, input: tc.input, @@ -3867,14 +3882,32 @@ return text.length > 140 ? `${text.slice(0, 140)}…` : text; } + function normalizeCollabAgentAction(value) { + const raw = String(value || '').trim(); + if (!raw) return ''; + const name = raw.split(/[./]/).filter(Boolean).pop() || raw; + const normalized = name.toLowerCase().replace(/[\s_-]/g, ''); + if (normalized === 'spawn' || normalized === 'spawnagent') return 'spawn_agent'; + if (normalized === 'wait' || normalized === 'waitagent') return 'wait_agent'; + if (normalized === 'close' || normalized === 'closeagent') return 'close_agent'; + if (normalized === 'sendinput' || normalized === 'sendmessage') return 'send_input'; + if (normalized === 'resume' || normalized === 'resumeagent') return 'resume_agent'; + return normalized; + } + + function getCollabAgentAction(tool, data = null) { + const value = data?.tool || tool?.name || tool?.meta?.title || ''; + return normalizeCollabAgentAction(value); + } + function normalizeCollabAgentData(tool) { const inputData = effectiveObject(tool?.input); const resultData = effectiveObject(tool?.result); const merged = { ...inputData, ...resultData, - agentsStates: resultData.agentsStates || inputData.agentsStates || {}, - receiverThreadIds: resultData.receiverThreadIds || inputData.receiverThreadIds || [], + agentsStates: resultData.agentsStates || resultData.agents_states || inputData.agentsStates || inputData.agents_states || {}, + receiverThreadIds: resultData.receiverThreadIds || resultData.receiver_thread_ids || resultData.targets || inputData.receiverThreadIds || inputData.receiver_thread_ids || inputData.targets || [], prompt: inputData.prompt || resultData.prompt || '', tool: inputData.tool || resultData.tool || tool?.name || '', status: resultData.status || inputData.status || tool?.meta?.status || null, @@ -3896,12 +3929,178 @@ const state = value && typeof value === 'object' ? value : { status: value }; 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.candidateResult || state.finalMessage || state.summary || state.lastMessage || state.step || state.description || '').trim(); + let status = String(state.status || state.state || 'pending').trim() || 'pending'; + if (state.closedAt && collabStateTone(status) !== 'closed') status = 'closed'; + const detail = String(state.candidateResult || state.finalMessage || state.summary || state.message || state.lastMessage || state.step || state.description || '').trim(); return { id, label, role, status, detail }; }); } + function getCollabAgentIdsFromTool(tool) { + const data = normalizeCollabAgentData(tool); + const ids = new Set(); + for (const entry of collabAgentStateEntries(data)) { + if (entry.id) ids.add(entry.id); + } + if (Array.isArray(data.receiverThreadIds)) { + data.receiverThreadIds.forEach((id) => { + if (id) ids.add(String(id)); + }); + } + const directIds = [ + data.threadId, + data.thread_id, + data.agentId, + data.agent_id, + data.childThreadId, + data.child_thread_id, + data.childAgentId, + data.child_agent_id, + data.targetThreadId, + data.target_thread_id, + data.target, + ]; + directIds.forEach((id) => { + if (id) ids.add(String(id)); + }); + const directArrays = [ + data.threadIds, + data.thread_ids, + data.childThreadIds, + data.child_thread_ids, + data.agentIds, + data.agent_ids, + data.targets, + ]; + directArrays.forEach((value) => { + if (!Array.isArray(value)) return; + value.forEach((id) => { + if (id) ids.add(String(id)); + }); + }); + return Array.from(ids); + } + + function getClosedCollabAgentIdsFromTool(tool) { + if (toolKind(tool) !== 'collab_agent_tool_call') return []; + const data = normalizeCollabAgentData(tool); + const ids = new Set(); + const action = getCollabAgentAction(tool, data); + const allIds = getCollabAgentIdsFromTool(tool); + if (action === 'close_agent' || collabStateTone(data.status) === 'closed') { + allIds.forEach((id) => ids.add(id)); + } + collabAgentStateEntries(data).forEach((entry) => { + if (entry.id && collabStateTone(entry.status) === 'closed') ids.add(entry.id); + }); + return Array.from(ids); + } + + function collectClosedCollabAgentIds(messages) { + const ids = new Set(); + (Array.isArray(messages) ? messages : []).forEach((message) => { + (Array.isArray(message?.toolCalls) ? message.toolCalls : []).forEach((tool) => { + getClosedCollabAgentIdsFromTool(tool).forEach((id) => ids.add(id)); + }); + }); + return ids; + } + + function rememberClosedCollabAgentIdsFromTool(tool) { + getClosedCollabAgentIdsFromTool(tool).forEach((id) => closedCollabAgentIds.add(id)); + } + + function isGenericCollabAgentLabel(label, id) { + const value = String(label || '').trim(); + if (!value) return true; + if (/^子代理\s*\d+$/i.test(value)) return true; + return !!id && value === String(id); + } + + function mergeCollabAgentTools(tools, options = {}) { + const list = Array.isArray(tools) ? tools.filter((tool) => toolKind(tool) === 'collab_agent_tool_call') : []; + if (list.length === 0) return null; + const states = {}; + const receiverThreadIds = []; + const knownClosedIds = options.closedAgentIds instanceof Set ? options.closedAgentIds : closedCollabAgentIds; + const localClosedIds = new Set(knownClosedIds || []); + let toolName = '子代'; + let prompt = ''; + let status = ''; + let done = false; + + list.forEach((tool, toolIndex) => { + const data = normalizeCollabAgentData(tool); + const action = getCollabAgentAction(tool, data); + const isCloseAction = action === 'close_agent'; + const displayAction = String(data.tool || tool.name || '').trim(); + if (displayAction && !['wait_agent', 'close_agent'].includes(action)) toolName = displayAction; + if (!prompt && data.prompt) prompt = data.prompt; + if (isCloseAction) { + getCollabAgentIdsFromTool(tool).forEach((id) => localClosedIds.add(id)); + } + const dataStatus = isCloseAction ? 'closed' : data.status; + if (dataStatus) status = dataStatus; + done = done || !!tool.done; + + collabAgentStateEntries(data).forEach((entry) => { + if (!entry.id) return; + states[entry.id] = { + ...(states[entry.id] || {}), + ...entry, + status: isCloseAction || localClosedIds.has(entry.id) ? 'closed' : entry.status, + }; + if (collabStateTone(states[entry.id].status) === 'closed') localClosedIds.add(entry.id); + }); + + getCollabAgentIdsFromTool(tool).forEach((id) => { + if (!receiverThreadIds.includes(id)) receiverThreadIds.push(id); + states[id] = { + ...(states[id] || {}), + label: states[id]?.label || `子代理 ${receiverThreadIds.length}`, + status: isCloseAction || localClosedIds.has(id) + ? 'closed' + : (data.status || states[id]?.status || (tool.done ? 'completed' : 'running')), + }; + }); + + if (receiverThreadIds.length === 0 && list.length === 1) { + const fallbackId = tool.id || `tool-${toolIndex + 1}`; + receiverThreadIds.push(fallbackId); + states[fallbackId] = { + label: '子代理', + status: isCloseAction ? 'closed' : (data.status || (tool.done ? 'completed' : 'running')), + }; + } + }); + + receiverThreadIds.forEach((id, index) => { + states[id] = { + ...(states[id] || {}), + label: states[id]?.label || `子代理 ${index + 1}`, + status: localClosedIds.has(id) ? 'closed' : (states[id]?.status || 'pending'), + }; + }); + + const allClosed = receiverThreadIds.length > 0 + && receiverThreadIds.every((id) => collabStateTone(states[id]?.status) === 'closed'); + const mergedStatus = allClosed ? 'closed' : (status || (done ? 'completed' : 'running')); + + return { + id: list[0].id || 'collab-agent-merged', + name: list[0].name || 'ccweb_mcp_child_agent', + kind: 'collab_agent_tool_call', + done, + input: { + tool: toolName, + prompt, + status: mergedStatus, + receiverThreadIds, + agentsStates: states, + }, + }; + } + function collabStateTone(statusText) { const normalized = String(statusText || '').toLowerCase(); if (!normalized) return 'pending'; @@ -3934,6 +4133,12 @@ const stack = document.createElement('div'); stack.className = 'collab-agent-stack'; + const stateEntries = collabAgentStateEntries(data); + const threadCount = Array.isArray(data.receiverThreadIds) ? data.receiverThreadIds.length : 0; + const agentCount = stateEntries.length; + const totalCount = agentCount || threadCount || 0; + const promptText = summarizePrompt(data.prompt); + const header = document.createElement('div'); header.className = 'collab-agent-header'; @@ -3942,55 +4147,71 @@ const kicker = document.createElement('div'); kicker.className = 'collab-agent-kicker'; - kicker.textContent = 'ccweb MCP 子代理'; + kicker.textContent = '子代'; titleWrap.appendChild(kicker); const title = document.createElement('div'); title.className = 'collab-agent-title'; - title.textContent = data.tool || '协作任务'; + title.textContent = `${totalCount || 0} 个`; titleWrap.appendChild(title); const meta = document.createElement('div'); meta.className = 'collab-agent-meta'; - const threadCount = Array.isArray(data.receiverThreadIds) ? data.receiverThreadIds.length : 0; - const agentCount = collabAgentStateEntries(data).length; - meta.textContent = `${agentCount || threadCount || 0} 个子代理`; - titleWrap.appendChild(meta); + meta.textContent = ''; + if (promptText) meta.title = promptText; + if (promptText) titleWrap.appendChild(meta); header.appendChild(titleWrap); + const headerActions = document.createElement('div'); + headerActions.className = 'collab-agent-actions'; + const statusChip = document.createElement('span'); const overallTone = collabStateTone(data.status || (tool.done ? 'completed' : 'running')); statusChip.className = `collab-agent-overall-status ${overallTone}`; statusChip.textContent = collabStateLabel(data.status || (tool.done ? 'completed' : 'running')); - header.appendChild(statusChip); + headerActions.appendChild(statusChip); + + header.appendChild(headerActions); stack.appendChild(header); - const promptText = summarizePrompt(data.prompt); - if (promptText) { - const promptBlock = document.createElement('div'); - promptBlock.className = 'collab-agent-prompt'; - promptBlock.textContent = promptText; - stack.appendChild(promptBlock); - } - - const stateEntries = collabAgentStateEntries(data); if (stateEntries.length > 0) { const list = document.createElement('div'); list.className = 'collab-agent-list'; stateEntries.forEach((entry, index) => { + const tone = collabStateTone(entry.status); const item = document.createElement('div'); item.className = 'collab-agent-item'; + item.title = [ + entry.label || `子代理 ${index + 1}`, + entry.role ? `角色: ${entry.role}` : '', + entry.detail ? `结果: ${entry.detail}` : '', + entry.id ? `ID: ${entry.id}` : '', + ].filter(Boolean).join('\n'); + if (entry.id) { + item.setAttribute('role', 'button'); + item.tabIndex = 0; + item.addEventListener('click', () => { + copyTextToClipboard(entry.id, '子代理线程 ID 已复制'); + }); + item.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + copyTextToClipboard(entry.id, '子代理线程 ID 已复制'); + } + }); + } const row = document.createElement('div'); row.className = 'collab-agent-item-row'; const label = document.createElement('div'); label.className = 'collab-agent-item-label'; - label.textContent = entry.label || `子代理 ${index + 1}`; + label.textContent = !isGenericCollabAgentLabel(entry.label, entry.id) + ? entry.label + : `ID ${shortChildAgentId(entry.id || '')}`; row.appendChild(label); const chip = document.createElement('span'); - const tone = collabStateTone(entry.status); chip.className = `collab-agent-item-status ${tone}`; chip.textContent = collabStateLabel(entry.status); row.appendChild(chip); @@ -4015,28 +4236,11 @@ } item.appendChild(row); - if (entry.role) { - const role = document.createElement('div'); - role.className = 'collab-agent-item-role'; - role.textContent = entry.role; - item.appendChild(role); - } - - if (entry.detail) { - const detail = document.createElement('div'); - detail.className = 'collab-agent-item-detail'; - detail.textContent = entry.detail; - item.appendChild(detail); - } - - if (entry.id) { + if (entry.id || entry.role) { const footer = document.createElement('div'); footer.className = 'collab-agent-item-footer'; - footer.textContent = `ID ${shortSessionId(entry.id)}`; - footer.title = entry.id; - footer.addEventListener('click', () => { - copyTextToClipboard(entry.id, '子代理线程 ID 已复制'); - }); + footer.textContent = entry.role || ''; + if (!footer.textContent) footer.hidden = true; item.appendChild(footer); } @@ -4045,14 +4249,14 @@ stack.appendChild(list); } - if (Array.isArray(data.receiverThreadIds) && data.receiverThreadIds.length > 0) { + if (stateEntries.length === 0 && Array.isArray(data.receiverThreadIds) && data.receiverThreadIds.length > 0) { const threads = document.createElement('div'); threads.className = 'collab-agent-threads'; data.receiverThreadIds.forEach((threadId) => { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'collab-agent-thread-chip'; - btn.textContent = `ID ${shortSessionId(threadId)}`; + btn.textContent = `ID ${shortChildAgentId(threadId)}`; btn.title = `复制子代理线程 ID\n${threadId}`; btn.addEventListener('click', () => { copyTextToClipboard(threadId, '子代理线程 ID 已复制'); @@ -4108,7 +4312,12 @@ const bubble = el.querySelector('.msg-bubble'); const FOLD_AT = 3; let grouped = false; - for (const tc of m.toolCalls) { + const mergedCollabTool = mergeCollabAgentTools(m.toolCalls); + const renderToolCalls = [ + ...(mergedCollabTool ? [mergedCollabTool] : []), + ...m.toolCalls.filter((tc) => toolKind(tc) !== 'collab_agent_tool_call'), + ]; + for (const tc of renderToolCalls) { if (isEmptyReasoningTool(tc)) continue; const details = createToolCallElement(tc.id || `saved-${Math.random().toString(36).slice(2)}`, tc, true); @@ -4153,6 +4362,7 @@ function renderMessages(messages, options = {}) { renderEpoch++; const epoch = renderEpoch; + closedCollabAgentIds = collectClosedCollabAgentIds(messages); messagesDiv.innerHTML = ''; clearUserMessageIndex(); if (messages.length === 0) { @@ -4216,6 +4426,7 @@ function prependHistoryMessages(messages, options = {}) { if (!Array.isArray(messages) || messages.length === 0) return; + collectClosedCollabAgentIds(messages).forEach((id) => closedCollabAgentIds.add(id)); const preserveScroll = options.preserveScroll !== false; const skipScrollbar = options.skipScrollbar === true; const welcome = messagesDiv.querySelector('.welcome-msg'); @@ -4477,6 +4688,7 @@ wrapper.dataset.toolUseId = toolUseId ? String(toolUseId) : ''; wrapper.dataset.toolName = tool.name || ''; wrapper.dataset.toolKind = kind; + wrapper.dataset.childIds = getCollabAgentIdsFromTool(tool).join(','); wrapper.appendChild(buildToolContentElement({ ...tool, done })); return wrapper; } @@ -4508,6 +4720,47 @@ return details; } + function upsertCollabAgentToolCall(toolsDiv, toolUseId, tool, done) { + if (!toolsDiv || !tool) return null; + const existing = toolsDiv.querySelector(':scope > .ccweb-mcp-child-agent-tool-call[data-collab-merged="true"]') + || toolsDiv.querySelector(':scope > .tool-group .ccweb-mcp-child-agent-tool-call[data-collab-merged="true"]'); + const nextTool = { ...tool, id: toolUseId, done }; + rememberClosedCollabAgentIdsFromTool(nextTool); + const map = existing?.__collabTools instanceof Map ? existing.__collabTools : new Map(); + map.set(toolUseId || nextTool.id || `collab-${map.size + 1}`, nextTool); + const merged = mergeCollabAgentTools(Array.from(map.values())); + if (!merged) return existing; + + if (existing) { + existing.__collabTools = map; + existing.dataset.toolUseId = merged.id || existing.dataset.toolUseId || ''; + existing.dataset.childIds = getCollabAgentIdsFromTool(merged).join(','); + existing.replaceChildren(buildToolContentElement(merged)); + removeDuplicateCollabAgentNodes(toolsDiv); + return existing; + } + + const el = createToolCallElement(merged.id, merged, !!merged.done); + el.dataset.collabMerged = 'true'; + el.dataset.childIds = getCollabAgentIdsFromTool(merged).join(','); + el.__collabTools = map; + toolsDiv.appendChild(el); + removeDuplicateCollabAgentNodes(toolsDiv); + return el; + } + + function removeDuplicateCollabAgentNodes(scope) { + if (!scope) return; + const seen = new Set(); + const nodes = Array.from(scope.querySelectorAll('.ccweb-mcp-child-agent-tool-call')); + nodes.forEach((node) => { + const ids = String(node.dataset.childIds || '').split(',').filter(Boolean); + const duplicate = ids.length > 0 && ids.some((id) => seen.has(id)); + ids.forEach((id) => seen.add(id)); + if (duplicate) node.remove(); + }); + } + function appendToolCall(toolUseId, name, input, done, kind = null, meta = null, result = undefined) { const streamEl = document.getElementById('streaming-msg'); if (!streamEl) return; @@ -4519,6 +4772,12 @@ const tool = { id: toolUseId, name, input, kind, meta, done }; if (result !== undefined) tool.result = result; if (isEmptyReasoningTool(tool)) return; + if (toolKind(tool) === 'collab_agent_tool_call') { + const el = upsertCollabAgentToolCall(toolsDiv, toolUseId, tool, done); + if (el) rememberToolCallTarget(toolUseId, tool, el); + scrollToBottom(); + return; + } // 如果是 todo_list,检查是否已存在相同 id 的 todo_list if (kind === 'todo_list' && input?.id) { @@ -4592,6 +4851,13 @@ const tool = activeToolCalls.get(toolUseId) || null; const toolUseIdText = toolUseId ? String(toolUseId) : ''; const scope = getLatestAssistantToolScope(); + if (toolKind(tool) === 'collab_agent_tool_call') { + const toolsDiv = scope?.querySelector?.('.msg-tools') || scope?.querySelector?.('.msg-bubble') || scope; + const nextTool = { ...tool, result, done }; + const el = upsertCollabAgentToolCall(toolsDiv, toolUseId, nextTool, done); + if (el) rememberToolCallTarget(toolUseId, nextTool, el); + return; + } let el = tool?.domElement && tool.domElement.isConnected ? tool.domElement : null; if (!el) { @@ -4649,6 +4915,13 @@ const tool = msg?.tool; const toolUseId = msg?.toolUseId || tool?.id; if (!toolUseId || !tool) return; + const isCurrentSessionUpdate = msg.sessionId === currentSessionId; + if (isCurrentSessionUpdate) { + if (msg?.child?.threadId && collabStateTone(msg.child.status) === 'closed') { + closedCollabAgentIds.add(String(msg.child.threadId)); + } + rememberClosedCollabAgentIdsFromTool(tool); + } updateCachedSession(msg.sessionId, (snapshot) => { const messages = Array.isArray(snapshot.messages) ? snapshot.messages : []; @@ -4667,7 +4940,7 @@ } }); - if (msg.sessionId !== currentSessionId) return; + if (!isCurrentSessionUpdate) return; activeToolCalls.set(toolUseId, { id: toolUseId, name: tool.name, diff --git a/public/index.html b/public/index.html index 6e268a7..8c72ae9 100644 --- a/public/index.html +++ b/public/index.html @@ -14,7 +14,7 @@ document.documentElement.dataset.dividerTime = dividerTime; })(); - +
@@ -150,6 +150,6 @@ - +