fix cross-conversation replies and mobile session switching

This commit is contained in:
shiyue
2026-06-18 13:07:51 +08:00
parent a2126f4138
commit c1dc793841
7 changed files with 678 additions and 74 deletions

453
server.js
View File

@@ -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(() => {