From c1dc7938410e15a0a249e7da15f11372c878e010 Mon Sep 17 00:00:00 2001 From: shiyue Date: Thu, 18 Jun 2026 13:07:51 +0800 Subject: [PATCH] fix cross-conversation replies and mobile session switching --- lib/ccweb-mcp-server.js | 32 ++- public/app.js | 129 ++++++++++-- public/index.html | 2 +- public/style.css | 27 +++ scripts/mock-codex.js | 3 + scripts/regression.js | 106 +++++++++- server.js | 453 +++++++++++++++++++++++++++++++++++----- 7 files changed, 678 insertions(+), 74 deletions(-) diff --git a/lib/ccweb-mcp-server.js b/lib/ccweb-mcp-server.js index 654b183..f0ebad6 100644 --- a/lib/ccweb-mcp-server.js +++ b/lib/ccweb-mcp-server.js @@ -54,7 +54,7 @@ const TOOLS = [ mode: { type: 'string', enum: ['default', 'plan', 'yolo'], - description: '可选。权限模式,默认继承来源对话;不会自动写死为 plan。', + description: '可选。权限模式,默认 yolo;只有显式传 default/plan/yolo 时才使用指定模式。', }, initialMessage: { type: 'string', @@ -87,6 +87,36 @@ const TOOLS = [ additionalProperties: false, }, }, + { + name: 'ccweb_list_pending_replies', + description: '列出当前来源对话等待中的跨对话回复,包括已完成但尚未处理的子对话返回摘要。', + inputSchema: { + type: 'object', + properties: { + status: { + type: 'string', + enum: ['all', 'waiting', 'ready', 'delivering', 'returned', 'failed'], + description: '可选。按回复状态过滤,默认 all。', + }, + }, + additionalProperties: false, + }, + }, + { + name: 'ccweb_get_pending_reply', + description: '读取指定 requestId 的跨对话回复状态和正文;用于主线程判断是否继续追问指定子对话。', + inputSchema: { + type: 'object', + properties: { + requestId: { + type: 'string', + description: '等待回复 requestId。', + }, + }, + required: ['requestId'], + additionalProperties: false, + }, + }, { name: 'ccweb_request_reply', description: '向指定 ccweb 对话发送一条消息,并在目标对话本轮输出完成后把回复作为已处理的只读消息写回当前对话;写回不会再次触发当前对话运行。', diff --git a/public/app.js b/public/app.js index 7630aaa..65b36b0 100644 --- a/public/app.js +++ b/public/app.js @@ -2,7 +2,7 @@ (function () { 'use strict'; - const ASSET_VERSION = '20260617-codexapp-approval'; + const ASSET_VERSION = '20260618-mobile-session-switch'; const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`; const RENDER_DEBOUNCE = 100; const COMPOSER_SUGGESTION_DEBOUNCE = 120; @@ -119,6 +119,7 @@ // --- State --- let ws = null; + let wsAuthenticated = false; let authToken = localStorage.getItem('cc-web-token'); let currentSessionId = null; let sessions = []; @@ -162,6 +163,7 @@ let codexAppUserInputModal = null; let codexAppApprovalModal = null; let pendingNewSessionRequest = null; + let pendingSessionSwitchRequest = null; let skipDeleteConfirm = localStorage.getItem('cc-web-skip-delete-confirm') === '1'; let pendingInitialSessionLoad = false; let noteMode = false; @@ -959,8 +961,10 @@ } function normalizeSessionSnapshot(payload, options = {}) { + const sessionId = payload.sessionId || payload.id || ''; return { - sessionId: payload.sessionId, + sessionId, + id: sessionId, messages: cloneMessages(payload.messages || []), title: payload.title || '新会话', mode: payload.mode || 'yolo', @@ -969,10 +973,19 @@ pinnedAt: payload.pinnedAt || null, hasUnread: !!payload.hasUnread, cwd: payload.cwd || null, + projectName: payload.projectName || '', + oversized: !!payload.oversized, + fileBytes: Number(payload.fileBytes || 0), totalCost: typeof payload.totalCost === 'number' ? payload.totalCost : 0, totalUsage: payload.totalUsage ? deepClone(payload.totalUsage) : null, updated: payload.updated || null, isRunning: !!payload.isRunning, + waitingOnChildren: !!payload.waitingOnChildren, + pendingReplyCount: Number(payload.pendingReplyCount || 0), + readyReplyCount: Number(payload.readyReplyCount || 0), + waitingReplyCount: Number(payload.waitingReplyCount || 0), + failedReplyCount: Number(payload.failedReplyCount || 0), + pendingReplies: Array.isArray(payload.pendingReplies) ? deepClone(payload.pendingReplies) : [], historyPending: !!payload.historyPending, complete: options.complete !== undefined ? !!options.complete : !payload.historyPending, }; @@ -1053,7 +1066,7 @@ const entry = sessionCache.get(sessionId); const meta = getSessionMeta(sessionId); if (!entry?.snapshot?.complete || !meta) return 'miss'; - if (entry.version === (meta.updated || null) && !meta.hasUnread && !meta.isRunning) { + if (entry.version === (meta.updated || null) && !meta.hasUnread && !meta.isRunning && !meta.waitingOnChildren) { return 'strong'; } return 'weak'; @@ -1071,6 +1084,11 @@ snapshot.updated = meta.updated || snapshot.updated; snapshot.pinnedAt = meta.pinnedAt || null; snapshot.isRunning = !!meta.isRunning; + snapshot.waitingOnChildren = !!meta.waitingOnChildren; + snapshot.pendingReplyCount = Number(meta.pendingReplyCount || 0); + snapshot.readyReplyCount = Number(meta.readyReplyCount || 0); + snapshot.waitingReplyCount = Number(meta.waitingReplyCount || 0); + snapshot.failedReplyCount = Number(meta.failedReplyCount || 0); } return snapshot; } @@ -2431,7 +2449,7 @@ const hiddenOldSessions = []; regularSessions.forEach((session, index) => { - const shouldKeepVisible = session.id === currentSessionId || session.isRunning || session.hasUnread; + const shouldKeepVisible = session.id === currentSessionId || session.isRunning || session.hasUnread || session.waitingOnChildren; const canCollapse = index >= visibleRegularLimit && isOlderThanOldSessionWindow(session, nowMs); if (canCollapse && !shouldKeepVisible) { hiddenOldSessions.push(session); @@ -2492,7 +2510,10 @@ function createSessionListItem(session) { const item = document.createElement('div'); const isPinned = !!session.pinnedAt; - item.className = `session-item${session.id === currentSessionId ? ' active' : ''}${isPinned ? ' pinned' : ''}`; + const waitingOnChildren = !!session.waitingOnChildren; + const readyReplyCount = Number(session.readyReplyCount || 0); + const waitingLabel = readyReplyCount > 0 ? `子对话已返回 ${readyReplyCount}` : `等待子对话 ${Number(session.pendingReplyCount || 0) || ''}`.trim(); + item.className = `session-item${session.id === currentSessionId ? ' active' : ''}${isPinned ? ' pinned' : ''}${waitingOnChildren ? ' waiting-children' : ''}`; item.dataset.id = session.id; const sessionCwd = getSessionEffectiveCwd(session); if (sessionCwd) item.title = sessionCwd; @@ -2501,6 +2522,7 @@ ${escapeHtml(session.title || 'Untitled')} ${isPinned ? '' : ''} ${session.isRunning ? '运行中' : ''} + ${!session.isRunning && waitingOnChildren ? `${escapeHtml(waitingLabel)}` : ''} ${session.hasUnread ? '' : ''} ${timeAgo(session.updated)} @@ -2572,6 +2594,7 @@ return; } closeSessionActionMenus(); + if (isMobileInputMode()) closeSidebar(); openSession(session.id); }); @@ -2595,12 +2618,36 @@ chatCwd.hidden = !currentCwd; } + function currentSessionWaitState() { + const meta = currentSessionId ? getSessionMeta(currentSessionId) : null; + const cached = currentSessionId ? sessionCache.get(currentSessionId)?.snapshot : null; + return { + waitingOnChildren: !!(meta?.waitingOnChildren || cached?.waitingOnChildren), + pendingReplyCount: Number(meta?.pendingReplyCount ?? cached?.pendingReplyCount ?? 0), + readyReplyCount: Number(meta?.readyReplyCount ?? cached?.readyReplyCount ?? 0), + }; + } + function setCurrentSessionRunningState(isRunning) { const running = !!isRunning; currentSessionRunning = running; if (chatRuntimeState) { - chatRuntimeState.hidden = !running; - chatRuntimeState.textContent = running ? '运行中' : ''; + const waitState = currentSessionWaitState(); + if (running) { + chatRuntimeState.hidden = false; + chatRuntimeState.classList.remove('waiting'); + chatRuntimeState.textContent = '运行中'; + } else if (waitState.waitingOnChildren) { + chatRuntimeState.hidden = false; + chatRuntimeState.classList.add('waiting'); + chatRuntimeState.textContent = waitState.readyReplyCount > 0 + ? `子对话已返回 ${waitState.readyReplyCount}` + : `等待子对话 ${waitState.pendingReplyCount || ''}`.trim(); + } else { + chatRuntimeState.hidden = true; + chatRuntimeState.classList.remove('waiting'); + chatRuntimeState.textContent = ''; + } } updateCwdBadge(); } @@ -2817,7 +2864,35 @@ renderEpoch++; loadedHistorySessionId = null; setSessionLoading(sessionId, { blocking, label: options.label }); - send({ type: 'load_session', sessionId }); + requestSessionLoad(sessionId, { blocking, label: options.label }); + } + + function requestSessionLoad(sessionId, options = {}) { + if (!sessionId) return; + pendingSessionSwitchRequest = { + sessionId, + blocking: options.blocking !== false, + label: options.label || '', + }; + if (ws && ws.readyState === 1 && wsAuthenticated) { + flushPendingSessionSwitch(); + return; + } + if (!ws || ws.readyState > 1) connect(); + } + + function flushPendingSessionSwitch() { + if (!pendingSessionSwitchRequest) return; + if (!ws || ws.readyState !== 1 || !wsAuthenticated) return; + const request = pendingSessionSwitchRequest; + pendingSessionSwitchRequest = null; + if (!activeSessionLoad) { + setSessionLoading(request.sessionId, { + blocking: request.blocking, + label: request.label || undefined, + }); + } + ws.send(JSON.stringify({ type: 'load_session', sessionId: request.sessionId })); } function showCachedSession(sessionId) { @@ -3000,6 +3075,7 @@ const socket = new WebSocket(WS_URL); ws = socket; + wsAuthenticated = false; socket.onopen = () => { if (ws !== socket) return; @@ -3018,6 +3094,14 @@ socket.onclose = () => { if (ws !== socket) return; ws = null; + wsAuthenticated = false; + if (activeSessionLoad?.sessionId && !isPageUnloading) { + pendingSessionSwitchRequest = { + sessionId: activeSessionLoad.sessionId, + blocking: activeSessionLoad.blocking, + label: sessionLoadingLabel?.textContent || '', + }; + } clearSessionLoading(); scheduleReconnect(); }; @@ -3058,10 +3142,12 @@ case 'auth_result': if (msg.success) { authToken = msg.token; + wsAuthenticated = true; localStorage.setItem('cc-web-token', msg.token); document.dispatchEvent(new CustomEvent('cc-web-auth-restored')); loginOverlay.hidden = true; app.hidden = false; + flushPendingSessionSwitch(); send({ type: 'get_codex_config' }); // Check if must change password if (msg.mustChangePassword) { @@ -3070,7 +3156,10 @@ pendingInitialSessionLoad = true; } } else { + pendingSessionSwitchRequest = null; + clearSessionLoading(); authToken = null; + wsAuthenticated = false; localStorage.removeItem('cc-web-token'); document.dispatchEvent(new CustomEvent('cc-web-auth-failed')); loginOverlay.hidden = false; @@ -3088,7 +3177,7 @@ break; case 'session_list': - sessions = msg.sessions || []; + sessions = Array.isArray(msg.sessions) ? msg.sessions.map(normalizeSessionSnapshot) : []; reconcileSessionCacheWithSessions(); renderSessionList(); if (currentSessionId) { @@ -3113,6 +3202,12 @@ projectName: snapshot.cwd ? getPathLeaf(snapshot.cwd) : session.projectName || '', title: snapshot.title || session.title, pinnedAt: snapshot.pinnedAt || session.pinnedAt || null, + isRunning: snapshot.isRunning, + waitingOnChildren: snapshot.waitingOnChildren, + pendingReplyCount: snapshot.pendingReplyCount, + readyReplyCount: snapshot.readyReplyCount, + waitingReplyCount: snapshot.waitingReplyCount, + failedReplyCount: snapshot.failedReplyCount, } : session )); @@ -3124,6 +3219,9 @@ suppressUnreadToast: false, preserveStreaming: msg.sessionId === currentSessionId && msg.isRunning, }); + if (msg.sessionId === currentSessionId) { + setCurrentSessionRunningState(!!msg.isRunning); + } if (!msg.historyPending) { if (activeSessionLoad?.sessionId === msg.sessionId) { finalizeLoadedSession(msg.sessionId); @@ -3156,6 +3254,11 @@ snapshot.messages = Array.isArray(snapshot.messages) ? snapshot.messages : []; snapshot.messages.push(deepClone(msg.message)); snapshot.updated = msg.message.timestamp || new Date().toISOString(); + if (msg.message.crossConversation?.replyToRequestId) { + snapshot.readyReplyCount = Math.max(0, Number(snapshot.readyReplyCount || 0) - 1); + snapshot.pendingReplyCount = Math.max(0, Number(snapshot.pendingReplyCount || 0) - 1); + snapshot.waitingOnChildren = Number(snapshot.pendingReplyCount || 0) > 0; + } }); } if (msg.sessionId === currentSessionId && msg.message) { @@ -3164,7 +3267,9 @@ if (welcome) welcome.remove(); messagesDiv.appendChild(buildMsgElement(msg.message)); scrollToBottom(); + setCurrentSessionRunningState(!!getSessionMeta(currentSessionId)?.isRunning); } + renderSessionList(); break; case 'session_renamed': @@ -5479,9 +5584,10 @@ const hasActiveSession = group.sessions.some((session) => session.id === currentSessionId); const hasUnreadSession = group.sessions.some((session) => session.hasUnread); const hasRunningSession = group.sessions.some((session) => session.isRunning); + const hasWaitingSession = group.sessions.some((session) => session.waitingOnChildren); const groupBodyId = `session-project-body-${groupIndex}`; const groupEl = document.createElement('section'); - groupEl.className = `session-project-group${isCollapsed ? ' collapsed' : ''}${hasActiveSession ? ' has-active-session' : ''}${hasUnreadSession ? ' has-unread-session' : ''}${hasRunningSession ? ' has-running-session' : ''}`; + groupEl.className = `session-project-group${isCollapsed ? ' collapsed' : ''}${hasActiveSession ? ' has-active-session' : ''}${hasUnreadSession ? ' has-unread-session' : ''}${hasRunningSession ? ' has-running-session' : ''}${hasWaitingSession ? ' has-waiting-session' : ''}`; const header = document.createElement('div'); header.className = 'session-project-header'; @@ -6278,9 +6384,6 @@ if (currentSessionId) { send({ type: 'set_mode', sessionId: currentSessionId, mode: currentMode }); } - if (currentMode === 'default') { - appendSystemMessage('⚠ 由于项目设计与 CLI 原生逻辑不同,默认模式的授权申请功能暂未实现,建议搭配 Plan 或 YOLO 模式使用。'); - } }); msgInput.addEventListener('input', () => { diff --git a/public/index.html b/public/index.html index d531116..0ae6a72 100644 --- a/public/index.html +++ b/public/index.html @@ -154,6 +154,6 @@ - + diff --git a/public/style.css b/public/style.css index cfb7a4b..8ea377f 100644 --- a/public/style.css +++ b/public/style.css @@ -1260,6 +1260,17 @@ body.session-loading-active { .session-project-group.has-running-session.collapsed .session-project-count::after { animation: pulse 1.1s infinite; } +.session-project-group.has-waiting-session:not(.has-running-session).collapsed .session-project-count::after { + content: ''; + width: 6px; + height: 6px; + margin-left: 5px; + border-radius: 50%; + background: rgba(120, 126, 140, 0.72); +} +.session-project-group.has-waiting-session:not(.has-running-session).collapsed .session-project-count::after { + animation: none; +} .session-item { display: flex; align-items: center; @@ -1331,6 +1342,14 @@ body.session-loading-active { background: currentColor; animation: pulse 1.1s infinite; } +.session-item-status.waiting { + background: rgba(120, 126, 140, 0.12); + border-color: rgba(120, 126, 140, 0.22); + color: #667085; +} +.session-item-status.waiting::before { + animation: none; +} .session-item-time { font-size: 11px; color: var(--text-muted); @@ -1660,6 +1679,14 @@ body.session-loading-active { background: currentColor; animation: pulse 1.1s infinite; } +.chat-runtime-state.waiting { + background: rgba(120, 126, 140, 0.12); + border-color: rgba(120, 126, 140, 0.22); + color: #667085; +} +.chat-runtime-state.waiting::before { + animation: none; +} .cost-display { display: none !important; } diff --git a/scripts/mock-codex.js b/scripts/mock-codex.js index 308c634..926ea7a 100755 --- a/scripts/mock-codex.js +++ b/scripts/mock-codex.js @@ -90,6 +90,9 @@ function sleep(ms) { if (input === 'slow cross-session prompt') { await sleep(800); } + if (input === 'very slow cross-session prompt') { + await sleep(2500); + } const responseText = input === '/compact' ? 'Codex compact finished.' diff --git a/scripts/regression.js b/scripts/regression.js index 3570ccd..aad7ccd 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -490,6 +490,11 @@ function assertFrontendGenerationControlsContract() { /allowRuntimeInsert\s*=\s*isGenerating\s*&&\s*isCodexAppAgent\(currentAgent\)/.test(controlsBlock), 'Codex App should keep the runtime insert send button visible while generating' ); + const staleDefaultApprovalWarning = ['默认模式的', '授权申请功能', '暂未实现'].join(''); + assert( + !source.includes(staleDefaultApprovalWarning), + 'Frontend should not show the stale default-mode approval warning after Codex App approvals are supported' + ); } function assertFrontendComposerMcpContract() { @@ -817,13 +822,14 @@ async function main() { assert(mcpCreate.status === 200 && mcpCreate.body?.ok, `MCP create conversation should succeed: ${JSON.stringify(mcpCreate.body)}`); assert(mcpCreate.body.agent === 'codex', 'MCP create conversation should ignore agent args and inherit the source agent'); 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.mode === 'yolo', 'MCP create conversation should default to yolo when mode is omitted'); 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.agent === 'codex', 'MCP created conversation should persist the inherited source agent'); + assert(storedMcpCreated.permissionMode === 'yolo', 'MCP created conversation should persist yolo as the default mode'); 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'); @@ -844,6 +850,7 @@ async function main() { }); assert(mcpCreateReply.status === 200 && mcpCreateReply.body?.ok, `MCP create conversation with requestReply should succeed: ${JSON.stringify(mcpCreateReply.body)}`); assert(mcpCreateReply.body.agent === 'codex', 'MCP create requestReply should inherit source agent even if args.agent is passed'); + assert(mcpCreateReply.body.mode === 'yolo', 'MCP create requestReply should default to yolo when mode is omitted'); 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); @@ -965,6 +972,103 @@ async function main() { /已返回消息/.test(String(message.content || '')) )), 'Returned cross message should not trigger the source session to run again'); + const busySourceCwd = path.join(tempRoot, 'codex-mcp-busy-source'); + mkdirp(busySourceCwd); + ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: busySourceCwd, mode: 'yolo' })); + const busySourceSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === busySourceCwd); + ws.send(JSON.stringify({ type: 'message', text: 'very slow cross-session prompt', sessionId: busySourceSession.sessionId, mode: 'yolo', agent: 'codex' })); + await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === busySourceSession.sessionId && s.isRunning)); + + const busyReplyTargetCwd = path.join(tempRoot, 'codex-mcp-busy-reply-target'); + mkdirp(busyReplyTargetCwd); + ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: busyReplyTargetCwd, mode: 'yolo' })); + const busyReplyTargetSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === busyReplyTargetCwd); + const busyRequestReply = await callInternalMcp(port, internalMcpToken, { + tool: 'ccweb_request_reply', + sourceSessionId: busySourceSession.sessionId, + sourceHopCount: 0, + args: { + targetConversationId: busyReplyTargetSession.sessionId, + content: 'busy source reply requested', + }, + }); + assert(busyRequestReply.status === 200 && busyRequestReply.body?.ok, `MCP busy source request reply should succeed: ${JSON.stringify(busyRequestReply.body)}`); + assert(busyRequestReply.body.requestId && busyRequestReply.body.status === 'waiting', 'Busy source request reply should return a waiting request id'); + await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === busyReplyTargetSession.sessionId); + await waitForJsonCondition(path.join(configDir, 'cross-conversation-replies.json'), (state) => ( + Array.isArray(state.replies) && + state.replies.some((reply) => ( + reply.requestId === busyRequestReply.body.requestId && + reply.sourceConversationId === busySourceSession.sessionId && + reply.status === 'ready' && + /busy source reply requested/.test(String(reply.replyText || '')) + )) + )); + let storedBusySource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${busySourceSession.sessionId}.json`), 'utf8')); + assert(!storedBusySource.messages.some((message) => message.crossConversation?.replyToRequestId === busyRequestReply.body.requestId), 'Busy source should not receive display-only reply while it is still running'); + + const busyPendingList = await callInternalMcp(port, internalMcpToken, { + tool: 'ccweb_list_pending_replies', + sourceSessionId: busySourceSession.sessionId, + args: { status: 'ready' }, + }); + assert(busyPendingList.status === 200 && busyPendingList.body?.ok, `MCP pending reply list should succeed: ${JSON.stringify(busyPendingList.body)}`); + assert(busyPendingList.body.waitingOnChildren === true, 'Pending reply list should report waitingOnChildren while ready reply is queued'); + assert(busyPendingList.body.readyReplyCount === 1, 'Pending reply list should count ready replies'); + assert(busyPendingList.body.replies.some((reply) => reply.requestId === busyRequestReply.body.requestId && reply.status === 'ready'), 'Pending reply list should include the queued ready reply'); + + const busyPendingDetail = await callInternalMcp(port, internalMcpToken, { + tool: 'ccweb_get_pending_reply', + sourceSessionId: busySourceSession.sessionId, + args: { requestId: busyRequestReply.body.requestId }, + }); + assert(busyPendingDetail.status === 200 && busyPendingDetail.body?.ok, `MCP pending reply detail should succeed: ${JSON.stringify(busyPendingDetail.body)}`); + assert(busyPendingDetail.body.status === 'ready', 'Pending reply detail should expose ready status'); + assert(/busy source reply requested/.test(String(busyPendingDetail.body.replyText || '')), 'Pending reply detail should expose target assistant output'); + + const busyConversationList = await callInternalMcp(port, internalMcpToken, { + tool: 'ccweb_list_conversations', + sourceSessionId: busySourceSession.sessionId, + args: { limit: 50 }, + }); + assert(busyConversationList.status === 200 && busyConversationList.body?.ok, `MCP conversation list with waiting state should succeed: ${JSON.stringify(busyConversationList.body)}`); + assert(busyConversationList.body.waitingOnChildren === true && busyConversationList.body.readyReplyCount === 1, 'MCP list should expose source waiting state'); + const busySourceSummary = busyConversationList.body.conversations.find((item) => item.id === busySourceSession.sessionId); + assert(busySourceSummary?.status === 'running', 'MCP list should still mark the busy source as running before it completes'); + assert(busySourceSummary?.waitingOnChildren === true && busySourceSummary?.readyReplyCount === 1, 'MCP list should expose queued child replies on the source conversation'); + + await nextMessage(messages, ws, (msg) => msg.type === 'background_done' && msg.sessionId === busySourceSession.sessionId, 8000); + await waitForJsonCondition(path.join(sessionsDir, `${busySourceSession.sessionId}.json`), (session) => ( + Array.isArray(session.messages) && + session.messages.some((message) => ( + message.crossConversation?.replyToRequestId === busyRequestReply.body.requestId && + message.crossConversation?.processed === true && + message.ccwebDisplayOnly === true + )) + )); + storedBusySource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${busySourceSession.sessionId}.json`), 'utf8')); + const busyReplyIndex = storedBusySource.messages.findIndex((message) => message.crossConversation?.replyToRequestId === busyRequestReply.body.requestId); + assert(busyReplyIndex > 0, 'Busy source should receive queued display-only reply after its run completes'); + assert(storedBusySource.messages[busyReplyIndex - 1]?.role === 'assistant' && /very slow cross-session prompt/.test(String(storedBusySource.messages[busyReplyIndex - 1].content || '')), 'Queued reply should be appended after the source run assistant message'); + + const returnedPendingDetail = await callInternalMcp(port, internalMcpToken, { + tool: 'ccweb_get_pending_reply', + sourceSessionId: busySourceSession.sessionId, + args: { requestId: busyRequestReply.body.requestId }, + }); + assert(returnedPendingDetail.status === 200 && returnedPendingDetail.body?.ok, 'Returned pending reply detail should remain queryable from source history'); + assert(returnedPendingDetail.body.status === 'returned' && returnedPendingDetail.body.returned === true, 'Returned pending reply detail should report returned status'); + + ws.send(JSON.stringify({ type: 'load_session', sessionId: busySourceSession.sessionId })); + const loadedBusySource = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.sessionId === busySourceSession.sessionId); + assert(loadedBusySource.isRunning === false, 'Busy source should be idle after background run completed'); + assert(loadedBusySource.waitingOnChildren === false && loadedBusySource.pendingReplyCount === 0, 'Busy source should clear waiting state after queued reply is flushed'); + + ws.send(JSON.stringify({ type: 'message', text: 'source remains usable after queued child reply', sessionId: busySourceSession.sessionId, mode: 'yolo', agent: 'codex' })); + await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === busySourceSession.sessionId); + storedBusySource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${busySourceSession.sessionId}.json`), 'utf8')); + assert(storedBusySource.messages.some((message) => message.role === 'user' && message.content === 'source remains usable after queued child reply'), 'Source conversation should accept normal user messages after queued child reply is flushed'); + const processLogAfterMcp = fs.readFileSync(path.join(logsDir, 'process.log'), 'utf8'); const mcpSpawnLine = processLogAfterMcp .trim() diff --git a/server.js b/server.js index f929e03..f8c6788 100644 --- a/server.js +++ b/server.js @@ -133,6 +133,7 @@ const MODEL_CONFIG_PATH = path.join(CONFIG_DIR, 'model.json'); const CODEX_CONFIG_PATH = path.join(CONFIG_DIR, 'codex.json'); const PROMPTS_CONFIG_PATH = path.join(CONFIG_DIR, 'prompts.json'); const BANNED_IPS_PATH = path.join(CONFIG_DIR, 'banned_ips.json'); +const CROSS_CONVERSATION_REPLIES_PATH = path.join(CONFIG_DIR, 'cross-conversation-replies.json'); fs.mkdirSync(SESSIONS_DIR, { recursive: true }); fs.mkdirSync(LOGS_DIR, { recursive: true }); @@ -3210,6 +3211,210 @@ function cleanRunDir(sessionId) { } catch {} } +function normalizeCrossConversationReplyState(raw = {}) { + const requestId = String(raw.requestId || '').trim(); + if (!requestId) return null; + const status = ['waiting', 'ready', 'delivering', 'returned', 'failed'].includes(raw.status) + ? raw.status + : 'waiting'; + const sourceConversationId = sanitizeId(raw.sourceConversationId || raw.sourceSessionId || ''); + const targetConversationId = sanitizeId(raw.targetConversationId || raw.targetSessionId || raw.conversationId || ''); + if (!sourceConversationId || !targetConversationId) return null; + return { + requestId, + messageId: String(raw.messageId || '').trim() || null, + sourceConversationId, + sourceTitle: String(raw.sourceTitle || '').trim() || 'Untitled', + targetConversationId, + targetTitle: String(raw.targetTitle || '').trim() || 'Untitled', + status, + createdAt: String(raw.createdAt || '').trim() || new Date().toISOString(), + hopCount: Math.max(0, Number.parseInt(String(raw.hopCount || 0), 10) || 0), + replyText: truncateTextValue(String(raw.replyText || ''), CROSS_CONVERSATION_MAX_CONTENT_CHARS), + completedAt: raw.completedAt || null, + returnedAt: raw.returnedAt || null, + replyMessageId: raw.replyMessageId || null, + lastError: raw.lastError || null, + }; +} + +function serializeCrossConversationReplies() { + const replies = []; + for (const pending of pendingCrossConversationReplies.values()) { + const normalized = normalizeCrossConversationReplyState(pending); + if (!normalized) continue; + if (normalized.status === 'returned') continue; + replies.push(normalized); + } + return { + version: 1, + updatedAt: new Date().toISOString(), + replies, + }; +} + +function saveCrossConversationReplies() { + try { + fs.mkdirSync(CONFIG_DIR, { recursive: true }); + writeFileAtomicSync(CROSS_CONVERSATION_REPLIES_PATH, JSON.stringify(serializeCrossConversationReplies(), null, 2)); + } catch (err) { + plog('WARN', 'cross_conversation_replies_save_failed', { + error: err?.message || String(err || ''), + }); + } +} + +function loadCrossConversationReplies() { + try { + if (!fs.existsSync(CROSS_CONVERSATION_REPLIES_PATH)) return; + const parsed = JSON.parse(fs.readFileSync(CROSS_CONVERSATION_REPLIES_PATH, 'utf8')); + const list = Array.isArray(parsed?.replies) ? parsed.replies : []; + for (const item of list) { + const normalized = normalizeCrossConversationReplyState(item); + if (!normalized || normalized.status === 'returned') continue; + pendingCrossConversationReplies.set(normalized.requestId, normalized); + } + } catch (err) { + plog('WARN', 'cross_conversation_replies_load_failed', { + error: err?.message || String(err || ''), + }); + } +} + +function setPendingCrossConversationReply(requestId, pending) { + const normalized = normalizeCrossConversationReplyState({ ...pending, requestId }); + if (!normalized) return null; + pendingCrossConversationReplies.set(normalized.requestId, normalized); + saveCrossConversationReplies(); + return normalized; +} + +function updatePendingCrossConversationReply(requestId, updater) { + const existing = pendingCrossConversationReplies.get(String(requestId || '').trim()); + if (!existing) return null; + const draft = { ...existing }; + updater(draft); + const normalized = normalizeCrossConversationReplyState(draft); + if (!normalized) { + pendingCrossConversationReplies.delete(existing.requestId); + saveCrossConversationReplies(); + return null; + } + pendingCrossConversationReplies.set(normalized.requestId, normalized); + saveCrossConversationReplies(); + return normalized; +} + +function deletePendingCrossConversationReply(requestId) { + const normalizedRequestId = String(requestId || '').trim(); + if (!normalizedRequestId) return false; + const deleted = pendingCrossConversationReplies.delete(normalizedRequestId); + if (deleted) saveCrossConversationReplies(); + return deleted; +} + +function deleteCrossConversationRepliesForSession(sessionId) { + const normalizedId = sanitizeId(sessionId || ''); + if (!normalizedId) return 0; + let deleted = 0; + for (const [requestId, pending] of pendingCrossConversationReplies.entries()) { + if (pending.sourceConversationId === normalizedId || pending.targetConversationId === normalizedId) { + pendingCrossConversationReplies.delete(requestId); + deleted += 1; + } + } + if (deleted > 0) saveCrossConversationReplies(); + return deleted; +} + +function crossConversationReplySummary(pending = {}) { + return { + requestId: pending.requestId, + status: pending.status, + sourceConversationId: pending.sourceConversationId, + sourceTitle: pending.sourceTitle || 'Untitled', + targetConversationId: pending.targetConversationId, + targetTitle: pending.targetTitle || 'Untitled', + createdAt: pending.createdAt || null, + completedAt: pending.completedAt || null, + returnedAt: pending.returnedAt || null, + replyMessageId: pending.replyMessageId || null, + preview: truncateTextValue(pending.replyText || '', 240), + }; +} + +function listCrossConversationRepliesForSource(sourceSessionId, options = {}) { + const sourceId = sanitizeId(sourceSessionId || ''); + if (!sourceId) return []; + const statuses = Array.isArray(options.statuses) && options.statuses.length > 0 + ? new Set(options.statuses) + : null; + const output = []; + for (const pending of pendingCrossConversationReplies.values()) { + if (pending.sourceConversationId !== sourceId) continue; + if (statuses && !statuses.has(pending.status)) continue; + output.push(crossConversationReplySummary(pending)); + } + output.sort((a, b) => new Date(a.completedAt || a.createdAt || 0) - new Date(b.completedAt || b.createdAt || 0)); + return output; +} + +function crossConversationWaitState(sessionId) { + const replies = listCrossConversationRepliesForSource(sessionId, { + statuses: ['waiting', 'ready', 'delivering', 'failed'], + }); + const readyReplies = replies.filter((reply) => reply.status === 'ready'); + const waitingReplies = replies.filter((reply) => reply.status === 'waiting' || reply.status === 'delivering'); + const failedReplies = replies.filter((reply) => reply.status === 'failed'); + return { + waitingOnChildren: replies.length > 0, + pendingReplyCount: replies.length, + readyReplyCount: readyReplies.length, + waitingReplyCount: waitingReplies.length, + failedReplyCount: failedReplies.length, + pendingReplies: replies, + }; +} + +function findCrossConversationReplyInTargetSession(targetSession, requestId) { + const normalizedRequestId = String(requestId || '').trim(); + const messages = Array.isArray(targetSession?.messages) ? targetSession.messages : []; + if (!normalizedRequestId || messages.length === 0) return ''; + const requestIndex = messages.findIndex((message) => ( + message?.crossConversation?.replyRequestId === normalizedRequestId + )); + if (requestIndex < 0) return ''; + for (let index = messages.length - 1; index > requestIndex; index -= 1) { + const message = messages[index]; + if (message?.role !== 'assistant') continue; + if (message?.ccwebDisplayOnly) continue; + const text = extractCrossConversationReplyText(message.content); + if (text) return text; + } + return ''; +} + +function reconcilePendingCrossConversationReplies() { + let changed = false; + for (const pending of pendingCrossConversationReplies.values()) { + if (pending.status !== 'waiting') continue; + if (isSessionRunning(pending.targetConversationId)) continue; + const targetSession = loadSession(pending.targetConversationId); + const replyText = findCrossConversationReplyInTargetSession(targetSession, pending.requestId); + if (!replyText) continue; + pending.status = 'ready'; + pending.replyText = truncateTextValue(replyText, CROSS_CONVERSATION_MAX_CONTENT_CHARS); + pending.completedAt = pending.completedAt || new Date().toISOString(); + if (targetSession?.title) pending.targetTitle = targetSession.title; + changed = true; + } + if (changed) saveCrossConversationReplies(); + for (const pending of pendingCrossConversationReplies.values()) { + if (pending.status === 'ready') deliverCrossConversationReply(pending.requestId); + } + return changed; +} + function codexAppStatePath(sessionId) { return path.join(runDir(sessionId), CODEX_APP_STATE_FILE); } @@ -3512,6 +3717,7 @@ function sendSessionList(ws) { for (const f of files) { const meta = loadSessionMetaFromFile(path.join(SESSIONS_DIR, f)); if (!meta) continue; + const waitState = crossConversationWaitState(meta.id); sessions.push({ id: meta.id, title: meta.title || 'Untitled', @@ -3522,6 +3728,11 @@ function sendSessionList(ws) { cwd: meta.cwd || '', projectName: meta.projectName || '', isRunning: isSessionRunning(meta.id), + waitingOnChildren: waitState.waitingOnChildren, + pendingReplyCount: waitState.pendingReplyCount, + readyReplyCount: waitState.readyReplyCount, + waitingReplyCount: waitState.waitingReplyCount, + failedReplyCount: waitState.failedReplyCount, oversized: !!meta.oversized, fileBytes: meta.fileBytes || 0, }); @@ -3564,6 +3775,7 @@ function clampMcpLimit(value) { } function listConversationSummaries(args = {}, sourceSessionId = '') { + reconcilePendingCrossConversationReplies(); const agentFilter = VALID_AGENTS.has(args.agent) ? args.agent : ''; const statusFilter = ['running', 'idle'].includes(args.status) ? args.status : 'all'; const limit = clampMcpLimit(args.limit); @@ -3579,6 +3791,7 @@ function listConversationSummaries(args = {}, sourceSessionId = '') { const status = running ? 'running' : 'idle'; if (agentFilter && agent !== agentFilter) continue; if (statusFilter !== 'all' && status !== statusFilter) continue; + const waitState = crossConversationWaitState(meta.id); conversations.push({ id: meta.id, title: meta.title || 'Untitled', @@ -3590,14 +3803,24 @@ function listConversationSummaries(args = {}, sourceSessionId = '') { projectName: meta.projectName || '', isCurrent: meta.id === sourceSessionId, oversized: !!meta.oversized, + waitingOnChildren: waitState.waitingOnChildren, + pendingReplyCount: waitState.pendingReplyCount, + readyReplyCount: waitState.readyReplyCount, + waitingReplyCount: waitState.waitingReplyCount, }); } } catch {} conversations.sort(compareSessionsForList); + const sourceWaitState = crossConversationWaitState(sourceSessionId); return { ok: true, currentConversationId: sourceSessionId || null, + waitingOnChildren: sourceWaitState.waitingOnChildren, + pendingReplyCount: sourceWaitState.pendingReplyCount, + readyReplyCount: sourceWaitState.readyReplyCount, + waitingReplyCount: sourceWaitState.waitingReplyCount, + pendingReplies: sourceWaitState.pendingReplies, conversations: conversations.slice(0, limit), }; } @@ -3635,6 +3858,8 @@ function createMcpConversation(args = {}, sourceSessionId = '', sourceHopCount = sourceSession, strict: true, requireAbsoluteCwd: true, + inheritSourceMode: false, + defaultMode: 'yolo', createdFrom: sourceSession ? { kind: 'mcp', sourceSessionId: sourceSession.id, @@ -3778,7 +4003,7 @@ function sendCrossConversationMessage(args = {}, sourceSessionId = '', sourceHop if (requestId) { crossConversation.expectsReply = true; crossConversation.replyRequestId = requestId; - pendingCrossConversationReplies.set(requestId, { + setPendingCrossConversationReply(requestId, { requestId, messageId, sourceConversationId: sourceId, @@ -3808,7 +4033,7 @@ function sendCrossConversationMessage(args = {}, sourceSessionId = '', sourceHop }); if (!result?.ok) { - if (requestId) pendingCrossConversationReplies.delete(requestId); + if (requestId) deletePendingCrossConversationReply(requestId); return mcpToolError(result?.code || 'send_failed', result?.message || '跨对话消息发送失败。', { sourceConversationId: sourceId, targetConversationId: targetId, @@ -3836,6 +4061,56 @@ function requestCrossConversationReply(args = {}, sourceSessionId = '', sourceHo return sendCrossConversationMessage(args, sourceSessionId, sourceHopCount, { expectReply: true }); } +function listPendingCrossConversationReplies(args = {}, sourceSessionId = '') { + reconcilePendingCrossConversationReplies(); + const sourceId = sanitizeId(sourceSessionId || ''); + if (!sourceId) return mcpToolError('missing_source_conversation', '缺少来源对话 ID。'); + const status = String(args.status || 'all').trim(); + const statuses = status && status !== 'all' ? [status] : ['waiting', 'ready', 'delivering', 'failed']; + const validStatuses = statuses.filter((item) => ['waiting', 'ready', 'delivering', 'returned', 'failed'].includes(item)); + const replies = listCrossConversationRepliesForSource(sourceId, { + statuses: validStatuses.length > 0 ? validStatuses : ['waiting', 'ready', 'delivering', 'failed'], + }); + return { + ok: true, + sourceConversationId: sourceId, + waitingOnChildren: replies.length > 0, + pendingReplyCount: replies.length, + readyReplyCount: replies.filter((reply) => reply.status === 'ready').length, + replies, + }; +} + +function getPendingCrossConversationReply(args = {}, sourceSessionId = '') { + reconcilePendingCrossConversationReplies(); + const sourceId = sanitizeId(sourceSessionId || ''); + const requestId = String(args.requestId || '').trim(); + if (!sourceId) return mcpToolError('missing_source_conversation', '缺少来源对话 ID。'); + if (!requestId) return mcpToolError('missing_request_id', '缺少 requestId。'); + const pending = pendingCrossConversationReplies.get(requestId); + if (!pending || pending.sourceConversationId !== sourceId) { + const sourceSession = loadSession(sourceId); + if (hasProcessedCrossConversationReply(sourceSession, requestId)) { + const message = sourceSession.messages.find((item) => item?.crossConversation?.replyToRequestId === requestId); + return { + ok: true, + requestId, + status: 'returned', + returned: true, + replyText: extractCrossConversationReplyText(message?.content || ''), + message: message || null, + }; + } + return mcpToolError('reply_not_found', '未找到该跨对话等待回复。', { requestId }); + } + return { + ok: true, + ...crossConversationReplySummary(pending), + returned: pending.status === 'returned', + replyText: pending.replyText || '', + }; +} + function deliverCrossConversationReply(requestId) { const pending = pendingCrossConversationReplies.get(requestId); if (!pending || pending.status !== 'ready') return false; @@ -3843,16 +4118,23 @@ function deliverCrossConversationReply(requestId) { const sourceSession = loadSession(pending.sourceConversationId); const targetSession = loadSession(pending.targetConversationId); if (!sourceSession || !targetSession) { - pending.status = 'failed'; - pending.lastError = sourceSession ? 'target_not_found' : 'source_not_found'; - pendingCrossConversationReplies.delete(requestId); + updatePendingCrossConversationReply(requestId, (draft) => { + draft.status = 'failed'; + draft.lastError = sourceSession ? 'target_not_found' : 'source_not_found'; + }); + broadcastSessionList(); + return false; + } + if (isSessionRunning(sourceSession.id)) { + broadcastSessionList(); 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); + updatePendingCrossConversationReply(requestId, (draft) => { + draft.status = 'returned'; + draft.returnedAt = draft.returnedAt || new Date().toISOString(); + }); + deletePendingCrossConversationReply(requestId); return true; } @@ -3872,7 +4154,9 @@ function deliverCrossConversationReply(requestId) { autoRun: false, }; - pending.status = 'delivering'; + updatePendingCrossConversationReply(requestId, (draft) => { + draft.status = 'delivering'; + }); const replyMessage = { role: 'assistant', content: replyContent, @@ -3894,10 +4178,12 @@ function deliverCrossConversationReply(requestId) { }); } - pending.status = 'returned'; - pending.returnedAt = now; - pending.replyMessageId = replyMessageId; - pendingCrossConversationReplies.delete(requestId); + updatePendingCrossConversationReply(requestId, (draft) => { + draft.status = 'returned'; + draft.returnedAt = now; + draft.replyMessageId = replyMessageId; + }); + deletePendingCrossConversationReply(requestId); broadcastSessionList(); return true; } @@ -3926,10 +4212,13 @@ function completeCrossConversationReply(requestId, entry = {}, targetSession = n replyText = '(目标对话没有返回可用文本。)'; } - pending.status = 'ready'; - pending.replyText = truncateTextValue(replyText, CROSS_CONVERSATION_MAX_CONTENT_CHARS); - pending.completedAt = new Date().toISOString(); - if (targetSession?.title) pending.targetTitle = targetSession.title; + updatePendingCrossConversationReply(normalizedRequestId, (draft) => { + draft.status = 'ready'; + draft.replyText = truncateTextValue(replyText, CROSS_CONVERSATION_MAX_CONTENT_CHARS); + draft.completedAt = new Date().toISOString(); + if (targetSession?.title) draft.targetTitle = targetSession.title; + }); + broadcastSessionList(); return deliverCrossConversationReply(normalizedRequestId); } @@ -3941,6 +4230,10 @@ function callInternalMcpTool(tool, args, sourceSessionId, sourceHopCount) { return createMcpConversation(args, sourceSessionId, sourceHopCount); case 'ccweb_send_message': return sendCrossConversationMessage(args, sourceSessionId, sourceHopCount); + case 'ccweb_list_pending_replies': + return listPendingCrossConversationReplies(args, sourceSessionId); + case 'ccweb_get_pending_reply': + return getPendingCrossConversationReply(args, sourceSessionId); case 'ccweb_request_reply': return requestCrossConversationReply(args, sourceSessionId, sourceHopCount); default: @@ -5444,7 +5737,9 @@ function createPersistentConversationSession(args = {}, options = {}) { 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 fallbackMode = options.inheritSourceMode === false + ? (options.defaultMode || 'yolo') + : (sourceSession?.permissionMode || options.defaultMode || 'yolo'); const agentResult = resolveConversationAgent(args.agent, fallbackAgent, { strict }); if (!agentResult.ok) return agentResult; const modeResult = resolveConversationMode(args.mode, fallbackMode, { strict }); @@ -5494,6 +5789,7 @@ function createPersistentConversationSession(args = {}, options = {}) { } function buildSessionInfoPayload(session) { + const waitState = crossConversationWaitState(session.id); return { type: 'session_info', sessionId: session.id, @@ -5510,6 +5806,12 @@ function buildSessionInfoPayload(session) { hasUnread: false, historyPending: false, isRunning: false, + waitingOnChildren: waitState.waitingOnChildren, + pendingReplyCount: waitState.pendingReplyCount, + readyReplyCount: waitState.readyReplyCount, + waitingReplyCount: waitState.waitingReplyCount, + failedReplyCount: waitState.failedReplyCount, + pendingReplies: waitState.pendingReplies, }; } @@ -5558,20 +5860,24 @@ function handleLoadHistoryPage(ws, msg = {}) { } function handleLoadSession(ws, sessionId) { + reconcilePendingCrossConversationReplies(); const session = loadSession(sessionId); if (!session) { return wsSend(ws, { type: 'error', message: 'Session not found' }); } - if (getSessionAgent(session) === 'claude' && !session.cwd && session.claudeSessionId) { - const localMeta = resolveClaudeSessionLocalMeta(session.claudeSessionId); + flushPendingCrossConversationReplies(sessionId); + const refreshedSession = loadSession(sessionId) || session; + if (getSessionAgent(refreshedSession) === 'claude' && !refreshedSession.cwd && refreshedSession.claudeSessionId) { + const localMeta = resolveClaudeSessionLocalMeta(refreshedSession.claudeSessionId); if (localMeta?.cwd) { - session.cwd = localMeta.cwd; - if (!session.importedFrom && localMeta.projectDir) session.importedFrom = localMeta.projectDir; - saveSession(session); + refreshedSession.cwd = localMeta.cwd; + if (!refreshedSession.importedFrom && localMeta.projectDir) refreshedSession.importedFrom = localMeta.projectDir; + saveSession(refreshedSession); } } - const { recentMessages, olderChunks, historyRemaining, historyBuffered } = splitHistoryMessages(session.messages); - const effectiveCwd = session.cwd || activeProcesses.get(sessionId)?.cwd || activeCodexAppTurns.get(sessionId)?.cwd || null; + const { recentMessages, olderChunks, historyRemaining, historyBuffered } = splitHistoryMessages(refreshedSession.messages); + const effectiveCwd = refreshedSession.cwd || activeProcesses.get(sessionId)?.cwd || activeCodexAppTurns.get(sessionId)?.cwd || null; + const waitState = crossConversationWaitState(sessionId); // Detach ws from any previous session's process detachWsFromActiveRuntimes(ws); @@ -5579,39 +5885,45 @@ function handleLoadSession(ws, sessionId) { wsSessionMap.set(ws, sessionId); // Read and clear unread flag - const hadUnread = !!session.hasUnread; - if (session.hasUnread) { - session.hasUnread = false; - saveSession(session); + const hadUnread = !!refreshedSession.hasUnread; + if (refreshedSession.hasUnread) { + refreshedSession.hasUnread = false; + saveSession(refreshedSession); } wsSend(ws, { type: 'session_info', - sessionId: session.id, + sessionId: refreshedSession.id, messages: recentMessages, - title: session.title, - pinnedAt: session.pinnedAt || null, - mode: session.permissionMode || 'yolo', - model: sessionModelLabel(session), - agent: getSessionAgent(session), + title: refreshedSession.title, + pinnedAt: refreshedSession.pinnedAt || null, + mode: refreshedSession.permissionMode || 'yolo', + model: sessionModelLabel(refreshedSession), + agent: getSessionAgent(refreshedSession), hasUnread: hadUnread, cwd: effectiveCwd, totalCost: session.totalCost || 0, totalUsage: session.totalUsage || null, - historyTotal: session.messages.length, + historyTotal: refreshedSession.messages.length, historyBuffered, historyCursor: historyRemaining, historyTruncated: historyRemaining > 0, historyPending: olderChunks.length > 0, - updated: session.updated, + updated: refreshedSession.updated, isRunning: isSessionRunning(sessionId), + waitingOnChildren: waitState.waitingOnChildren, + pendingReplyCount: waitState.pendingReplyCount, + readyReplyCount: waitState.readyReplyCount, + waitingReplyCount: waitState.waitingReplyCount, + failedReplyCount: waitState.failedReplyCount, + pendingReplies: waitState.pendingReplies, }); if (olderChunks.length > 0) { olderChunks.forEach((chunk, index) => { wsSend(ws, { type: 'session_history_chunk', - sessionId: session.id, + sessionId: refreshedSession.id, messages: chunk, remaining: Math.max(0, olderChunks.length - index - 1), historyCursor: index === olderChunks.length - 1 ? historyRemaining : null, @@ -5719,6 +6031,7 @@ function deleteCodexLocalSession(session) { function handleDeleteSession(ws, sessionId) { pendingSlashCommands.delete(sessionId); pendingCompactRetries.delete(sessionId); + deleteCrossConversationRepliesForSession(sessionId); for (const [threadId, child] of ccwebMcpChildThreads.entries()) { if (child.parentSessionId === sessionId) ccwebMcpChildThreads.delete(threadId); } @@ -5952,7 +6265,11 @@ function handleMessage(ws, msg, options = {}) { : `图片: ${savedAttachments[0]?.filename || 'image'}`; let session; - if (sessionId) session = loadSession(sessionId); + if (sessionId) { + reconcilePendingCrossConversationReplies(); + flushPendingCrossConversationReplies(sessionId); + session = loadSession(sessionId); + } if (!session) { const id = crypto.randomUUID(); const agent = normalizeAgent(msg.agent); @@ -6027,23 +6344,7 @@ function handleMessage(ws, msg, options = {}) { } if (!sessionId) { - wsSend(ws, { - type: 'session_info', - sessionId: currentSessionId, - messages: session.messages, - title: session.title, - pinnedAt: session.pinnedAt || null, - mode: session.permissionMode || 'yolo', - model: sessionModelLabel(session), - agent: getSessionAgent(session), - cwd: session.cwd || null, - totalCost: session.totalCost || 0, - totalUsage: session.totalUsage || null, - updated: session.updated, - hasUnread: false, - historyPending: false, - isRunning: false, - }); + wsSend(ws, buildSessionInfoPayload(session)); } if (ws && options.emitUserMessage && persistedUserMessage) { wsSend(ws, { type: 'session_message', sessionId: currentSessionId, message: persistedUserMessage }); @@ -6701,6 +7002,38 @@ function codexAppCommunicationDynamicTools() { additionalProperties: false, }, }, + { + name: 'ccweb_list_pending_replies', + namespace: 'ccweb', + description: '列出当前来源对话等待中的跨对话回复,包括已完成但尚未处理的子对话返回摘要。', + inputSchema: { + type: 'object', + properties: { + status: { + type: 'string', + enum: ['all', 'waiting', 'ready', 'delivering', 'returned', 'failed'], + description: '可选。按回复状态过滤,默认 all。', + }, + }, + additionalProperties: false, + }, + }, + { + name: 'ccweb_get_pending_reply', + namespace: 'ccweb', + description: '读取指定 requestId 的跨对话回复状态和正文;用于判断是否继续追问指定子对话。', + inputSchema: { + type: 'object', + required: ['requestId'], + properties: { + requestId: { + type: 'string', + description: '等待回复 requestId。', + }, + }, + additionalProperties: false, + }, + }, { name: 'ccweb_create_conversation', namespace: 'ccweb', @@ -6720,7 +7053,7 @@ function codexAppCommunicationDynamicTools() { mode: { type: 'string', enum: ['default', 'plan', 'yolo'], - description: '可选。权限模式,默认继承来源对话。', + description: '可选。权限模式,默认 yolo;只有显式传 default/plan/yolo 时才使用指定模式。', }, initialMessage: { type: 'string', @@ -6776,6 +7109,8 @@ function handleCodexAppDynamicToolCall(routed, params = {}) { tool !== 'ccweb_list_conversations' && tool !== 'ccweb_create_conversation' && tool !== 'ccweb_send_message' && + tool !== 'ccweb_list_pending_replies' && + tool !== 'ccweb_get_pending_reply' && tool !== 'ccweb_request_reply' ) return null; @@ -8052,7 +8387,9 @@ function handleListCwdSuggestions(ws) { } // === Startup === +loadCrossConversationReplies(); recoverProcesses(); +reconcilePendingCrossConversationReplies(); // Periodic heartbeat: log active processes status every 60s setInterval(() => {