feat: add compact Codex child agent tracking
This commit is contained in:
@@ -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'],
|
||||
});
|
||||
|
||||
|
||||
369
public/app.js
369
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,
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
document.documentElement.dataset.dividerTime = dividerTime;
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="style.css?v=20260616-runtime-insert-controls">
|
||||
<link rel="stylesheet" href="style.css?v=20260616-child-agent-close-state">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -150,6 +150,6 @@
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.1/marked.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||||
<script src="app.js?v=20260616-runtime-insert-controls"></script>
|
||||
<script src="app.js?v=20260616-child-agent-close-state"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
142
public/style.css
142
public/style.css
@@ -2334,11 +2334,18 @@ html[data-divider-time='hide'] .msg-bubble .agent-message-divider span {
|
||||
.code-block-wrapper.preview-mode pre { display: none; }
|
||||
|
||||
/* Tool calls */
|
||||
.msg-tools {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 4px 6px;
|
||||
}
|
||||
.tool-call {
|
||||
margin: 8px 0;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
.tool-call.codex-command {
|
||||
border-color: rgba(91, 126, 161, 0.24);
|
||||
@@ -2351,12 +2358,16 @@ html[data-divider-time='hide'] .msg-bubble .agent-message-divider span {
|
||||
border-color: rgba(93, 138, 84, 0.24);
|
||||
}
|
||||
.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));
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
.tool-call.ccweb-mcp-child-agent-tool-call.collab-agent-inline {
|
||||
display: inline-flex;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
overflow: visible;
|
||||
border-radius: 0;
|
||||
}
|
||||
.tool-call summary {
|
||||
padding: 8px 12px;
|
||||
@@ -2468,8 +2479,8 @@ html[data-divider-time='hide'] .msg-bubble .agent-message-divider span {
|
||||
word-break: normal;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(252, 253, 255, 0.96), rgba(242, 247, 252, 0.98));
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.tool-call-content.todo-list-content .todo-list-container {
|
||||
@@ -2519,6 +2530,7 @@ html[data-divider-time='hide'] .msg-bubble .agent-message-divider span {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
.tool-group-summary {
|
||||
padding: 8px 12px;
|
||||
@@ -2539,12 +2551,19 @@ html[data-divider-time='hide'] .msg-bubble .agent-message-divider span {
|
||||
.tool-group[open] > .tool-group-summary::before { transform: rotate(90deg); }
|
||||
.tool-group-summary:hover { background: var(--bg-tertiary); }
|
||||
.tool-group-inner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 4px 6px;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
.tool-group-inner .tool-call {
|
||||
margin: 4px 0;
|
||||
}
|
||||
.tool-group-inner .tool-call.ccweb-mcp-child-agent-tool-call.collab-agent-inline {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* AskUserQuestion preview */
|
||||
.ask-user-question {
|
||||
@@ -4237,48 +4256,65 @@ html[data-theme='coolvibe'] .settings-back:hover {
|
||||
}
|
||||
|
||||
.collab-agent-stack {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.collab-agent-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.collab-agent-title-wrap {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.collab-agent-kicker {
|
||||
font-size: 10px;
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
color: var(--info);
|
||||
}
|
||||
.collab-agent-title {
|
||||
font-size: 14px;
|
||||
min-width: 0;
|
||||
max-width: 56px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
line-height: 1.2;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.collab-agent-meta {
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.collab-agent-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.collab-agent-overall-status,
|
||||
.collab-agent-item-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 24px;
|
||||
padding: 0 9px;
|
||||
min-height: 20px;
|
||||
padding: 0 7px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
@@ -4319,31 +4355,41 @@ html[data-theme='coolvibe'] .settings-back:hover {
|
||||
}
|
||||
.collab-agent-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.collab-agent-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(91, 126, 161, 0.14);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.74);
|
||||
appearance: none;
|
||||
max-width: 100%;
|
||||
min-height: 40px;
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid rgba(91, 126, 161, 0.16);
|
||||
border-radius: 7px;
|
||||
background: rgba(255, 255, 255, 0.58);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.collab-agent-item-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
gap: 4px;
|
||||
}
|
||||
.collab-agent-item-label {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
max-width: 150px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.35;
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.collab-agent-item-role {
|
||||
font-size: 11px;
|
||||
@@ -4356,10 +4402,10 @@ html[data-theme='coolvibe'] .settings-back:hover {
|
||||
line-height: 1.55;
|
||||
}
|
||||
.collab-agent-item-footer {
|
||||
align-self: flex-start;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
line-height: 1.1;
|
||||
display: none;
|
||||
}
|
||||
.collab-agent-item-footer:hover {
|
||||
color: var(--info);
|
||||
@@ -4371,9 +4417,10 @@ html[data-theme='coolvibe'] .settings-back:hover {
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
color: var(--text-secondary);
|
||||
padding: 4px 9px;
|
||||
min-height: 22px;
|
||||
padding: 0 8px;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -4448,11 +4495,18 @@ html[data-theme='coolvibe'] .settings-back:hover {
|
||||
opacity: 0.6;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.collab-agent-header,
|
||||
.collab-agent-item-row {
|
||||
flex-direction: column;
|
||||
.collab-agent-header {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.collab-agent-title-wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.collab-agent-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
.collab-agent-item-label {
|
||||
max-width: 96px;
|
||||
}
|
||||
}
|
||||
.import-item-btn:hover { background: var(--accent-hover); }
|
||||
|
||||
@@ -4942,8 +4996,8 @@ 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.ccweb-mcp-child-agent-tool-call {
|
||||
background: var(--dark-panel-bg);
|
||||
border-color: var(--note-border);
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@@ -4970,6 +5024,10 @@ html[data-theme='coolvibe'] .settings-back:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .tool-call-content.collab-agent-content {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .tool-call-state,
|
||||
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .session-item-status,
|
||||
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .collab-agent-overall-status,
|
||||
|
||||
53
server.js
53
server.js
@@ -2,6 +2,7 @@ const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const os = require('os');
|
||||
const { spawn, spawnSync } = require('child_process');
|
||||
const { WebSocketServer } = require('ws');
|
||||
const { createAgentRuntime } = require('./lib/agent-runtime');
|
||||
@@ -69,6 +70,50 @@ const CROSS_CONVERSATION_MAX_CONTENT_CHARS = readPositiveIntEnv('CC_WEB_CROSS_CO
|
||||
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 CODEX_APP_PROCESS_ENV_STRIP_KEYS = [
|
||||
'CC_WEB_MCP_URL',
|
||||
'CC_WEB_MCP_TOKEN',
|
||||
'CC_WEB_SOURCE_SESSION_ID',
|
||||
'CC_WEB_CROSS_HOP_COUNT',
|
||||
'CODEX_THREAD_ID',
|
||||
'CODEX_CI',
|
||||
];
|
||||
const PROCESS_CLEAN_PATH_FALLBACK = [
|
||||
path.join(os.homedir(), '.local/bin'),
|
||||
'/usr/local/sbin',
|
||||
'/usr/local/bin',
|
||||
'/usr/sbin',
|
||||
'/usr/bin',
|
||||
'/sbin',
|
||||
'/bin',
|
||||
].join(path.delimiter);
|
||||
|
||||
function isCodexInjectedPathEntry(value) {
|
||||
const normalized = String(value || '').replace(/\\/g, '/');
|
||||
if (!normalized) return true;
|
||||
if (normalized.includes('/.codex/tmp/arg0')) return true;
|
||||
return normalized.includes('/node_modules/@openai/codex/')
|
||||
&& /\/(?:codex-path|path)$/.test(normalized);
|
||||
}
|
||||
|
||||
function cleanProcessPathValue(value) {
|
||||
const override = String(process.env.CC_WEB_PROCESS_CLEAN_PATH || '').trim();
|
||||
const source = override || value || PROCESS_CLEAN_PATH_FALLBACK;
|
||||
const fallbackEntries = PROCESS_CLEAN_PATH_FALLBACK.split(path.delimiter).filter(Boolean);
|
||||
const output = [];
|
||||
const seen = new Set();
|
||||
const addEntry = (entry) => {
|
||||
const text = String(entry || '').trim();
|
||||
if (!text || seen.has(text) || isCodexInjectedPathEntry(text)) return;
|
||||
seen.add(text);
|
||||
output.push(text);
|
||||
};
|
||||
|
||||
String(source).split(path.delimiter).forEach(addEntry);
|
||||
fallbackEntries.forEach(addEntry);
|
||||
return output.length > 0 ? output.join(path.delimiter) : PROCESS_CLEAN_PATH_FALLBACK;
|
||||
}
|
||||
|
||||
const PERSIST_TRUNCATED_TEXT = '[cc-web: 内容过大,已截断以保护服务稳定性]';
|
||||
const IMAGE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
|
||||
const TEXT_PREVIEW_EXTENSIONS = new Set([
|
||||
@@ -6260,6 +6305,12 @@ function buildCodexAppClientSpec() {
|
||||
if (runtimeConfig?.error) return { error: runtimeConfig.error };
|
||||
|
||||
const env = { ...process.env };
|
||||
const strippedEnvKeys = [];
|
||||
for (const key of CODEX_APP_PROCESS_ENV_STRIP_KEYS) {
|
||||
if (Object.prototype.hasOwnProperty.call(env, key)) strippedEnvKeys.push(key);
|
||||
delete env[key];
|
||||
}
|
||||
env.PATH = cleanProcessPathValue(env.PATH);
|
||||
delete env.CC_WEB_PASSWORD;
|
||||
delete env.CLAUDECODE;
|
||||
delete env.CLAUDE_CODE;
|
||||
@@ -6286,6 +6337,7 @@ function buildCodexAppClientSpec() {
|
||||
env,
|
||||
cwd: process.env.HOME || process.env.USERPROFILE || process.cwd(),
|
||||
signature,
|
||||
strippedEnvKeys,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6328,6 +6380,7 @@ function getCodexAppClient() {
|
||||
plog('INFO', 'codex_app_client_created', {
|
||||
worker: CODEX_APP_WORKER_ENABLED,
|
||||
command: path.basename(spec.command || ''),
|
||||
strippedEnvKeys: spec.strippedEnvKeys || [],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user