fix cross-conversation replies and mobile session switching
This commit is contained in:
453
server.js
453
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(() => {
|
||||
|
||||
Reference in New Issue
Block a user