feat: add cross conversation messaging

This commit is contained in:
shiyue
2026-06-12 17:46:37 +08:00
parent 8b2173be8f
commit 04e15c9c89
7 changed files with 1033 additions and 111 deletions

View File

@@ -9,6 +9,10 @@ function createAgentRuntime(deps) {
getDefaultCodexModel,
loadCodexConfig,
prepareCodexCustomRuntime,
ccwebMcpServerPath,
internalMcpUrl,
internalMcpToken,
nodePath,
wsSend,
truncateObj,
sanitizeToolInput,
@@ -18,6 +22,39 @@ function createAgentRuntime(deps) {
getRuntimeSessionId,
} = deps;
function tomlString(value) {
return JSON.stringify(String(value || ''));
}
function tomlStringArray(values) {
return `[${values.map((value) => tomlString(value)).join(',')}]`;
}
function createCcwebMcpEnv(session, options = {}) {
if (!ccwebMcpServerPath || !internalMcpUrl || !internalMcpToken || !session?.id) return null;
const rawHopCount = Number.parseInt(String(options.mcpContext?.hopCount || 0), 10);
const hopCount = Number.isFinite(rawHopCount) ? Math.max(0, rawHopCount) : 0;
return {
CC_WEB_MCP_URL: internalMcpUrl,
CC_WEB_MCP_TOKEN: internalMcpToken,
CC_WEB_SOURCE_SESSION_ID: session.id,
CC_WEB_CROSS_HOP_COUNT: String(hopCount),
};
}
function appendCcwebMcpConfig(args, mcpEnv) {
if (!mcpEnv) return;
const envVars = Object.keys(mcpEnv);
args.push(
'-c', 'mcp_servers.ccweb.type="stdio"',
'-c', `mcp_servers.ccweb.command=${tomlString(nodePath || 'node')}`,
'-c', `mcp_servers.ccweb.args=${tomlStringArray([ccwebMcpServerPath])}`,
'-c', `mcp_servers.ccweb.env_vars=${tomlStringArray(envVars)}`,
'-c', 'mcp_servers.ccweb.startup_timeout_sec=10',
'-c', 'mcp_servers.ccweb.tool_timeout_sec=60'
);
}
function buildClaudeSpawnSpec(session, options = {}) {
const hasAttachments = Array.isArray(options.attachments) && options.attachments.length > 0;
const args = ['-p', '--output-format', 'stream-json', '--verbose'];
@@ -75,6 +112,8 @@ function createAgentRuntime(deps) {
const runtimeId = getRuntimeSessionId(session);
const args = ['exec'];
args.push('--json', '--skip-git-repo-check');
const ccwebMcpEnv = createCcwebMcpEnv(session, options);
appendCcwebMcpConfig(args, ccwebMcpEnv);
const permMode = session.permissionMode || 'yolo';
// `-s/--sandbox` is an option for `codex exec`, but not for `codex exec resume`.
@@ -126,7 +165,7 @@ function createAgentRuntime(deps) {
args.push('-');
}
const env = { ...processEnv };
const env = { ...processEnv, ...(ccwebMcpEnv || {}) };
delete env.CC_WEB_PASSWORD;
delete env.CLAUDECODE;
delete env.CLAUDE_CODE;

251
lib/ccweb-mcp-server.js Normal file
View File

@@ -0,0 +1,251 @@
#!/usr/bin/env node
'use strict';
const http = require('http');
const https = require('https');
const SERVER_INFO = {
name: 'ccweb',
version: '1.0.0',
};
const TOOLS = [
{
name: 'ccweb_list_conversations',
description: '列出当前 ccweb 中可投递消息的对话。只返回 ID、标题、Agent、运行状态和更新时间不返回对话正文。',
inputSchema: {
type: 'object',
properties: {
agent: {
type: 'string',
enum: ['claude', 'codex'],
description: '可选。只返回指定 Agent 的对话。',
},
status: {
type: 'string',
enum: ['all', 'running', 'idle'],
description: '可选。按运行状态过滤,默认 all。',
},
limit: {
type: 'integer',
minimum: 1,
maximum: 100,
description: '可选。最多返回多少条,默认 50。',
},
},
additionalProperties: false,
},
},
{
name: 'ccweb_send_message',
description: '向指定 ccweb 对话发送一条消息,并以“来自某对话”的气泡在目标对话中展示。',
inputSchema: {
type: 'object',
properties: {
targetConversationId: {
type: 'string',
description: '目标对话 ID。',
},
content: {
type: 'string',
description: '要发送到目标对话的纯文本消息。',
},
},
required: ['targetConversationId', 'content'],
additionalProperties: false,
},
},
];
function writeMessage(message) {
process.stdout.write(`${JSON.stringify(message)}\n`);
}
function jsonRpcResult(id, result) {
writeMessage({ jsonrpc: '2.0', id, result });
}
function jsonRpcError(id, code, message, data) {
const error = { code, message };
if (data !== undefined) error.data = data;
writeMessage({ jsonrpc: '2.0', id, error });
}
function parseInteger(value, fallback = 0) {
const parsed = Number.parseInt(String(value || ''), 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
function callCcweb(tool, args) {
return new Promise((resolve) => {
const urlText = String(process.env.CC_WEB_MCP_URL || '').trim();
const token = String(process.env.CC_WEB_MCP_TOKEN || '').trim();
if (!urlText || !token) {
resolve({
ok: false,
code: 'mcp_not_configured',
message: 'ccweb MCP 环境变量未配置完整。',
});
return;
}
let url;
try {
url = new URL(urlText);
} catch {
resolve({
ok: false,
code: 'mcp_bad_url',
message: 'ccweb MCP 内部地址无效。',
});
return;
}
const body = JSON.stringify({
tool,
args: args && typeof args === 'object' ? args : {},
sourceSessionId: process.env.CC_WEB_SOURCE_SESSION_ID || '',
sourceHopCount: parseInteger(process.env.CC_WEB_CROSS_HOP_COUNT, 0),
});
const transport = url.protocol === 'https:' ? https : http;
const req = transport.request(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
'X-CC-Web-MCP-Token': token,
},
timeout: 60000,
}, (res) => {
let data = '';
res.setEncoding('utf8');
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
try {
const payload = JSON.parse(data || '{}');
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(payload);
} else {
resolve({
ok: false,
code: payload.code || 'ccweb_http_error',
message: payload.message || `ccweb 内部接口返回 HTTP ${res.statusCode}`,
statusCode: res.statusCode,
});
}
} catch {
resolve({
ok: false,
code: 'ccweb_bad_response',
message: 'ccweb 内部接口返回了无法解析的响应。',
statusCode: res.statusCode,
});
}
});
});
req.on('timeout', () => {
req.destroy();
resolve({
ok: false,
code: 'ccweb_timeout',
message: 'ccweb 内部接口调用超时。',
});
});
req.on('error', (err) => {
resolve({
ok: false,
code: 'ccweb_request_failed',
message: err.message || 'ccweb 内部接口调用失败。',
});
});
req.write(body);
req.end();
});
}
function toolResponse(payload) {
const text = JSON.stringify(payload, null, 2);
return {
content: [{ type: 'text', text }],
structuredContent: payload,
isError: !payload?.ok,
};
}
async function handleRequest(message) {
const { id, method } = message;
const hasId = Object.prototype.hasOwnProperty.call(message, 'id');
if (!hasId) return;
try {
switch (method) {
case 'initialize': {
jsonRpcResult(id, {
protocolVersion: message.params?.protocolVersion || '2024-11-05',
capabilities: { tools: {} },
serverInfo: SERVER_INFO,
});
break;
}
case 'ping':
jsonRpcResult(id, {});
break;
case 'tools/list':
jsonRpcResult(id, { tools: TOOLS });
break;
case 'tools/call': {
const name = String(message.params?.name || '');
const args = message.params?.arguments || {};
if (!TOOLS.some((tool) => tool.name === name)) {
jsonRpcResult(id, toolResponse({
ok: false,
code: 'unknown_tool',
message: `未知工具: ${name}`,
}));
break;
}
const payload = await callCcweb(name, args);
jsonRpcResult(id, toolResponse(payload));
break;
}
case 'resources/list':
jsonRpcResult(id, { resources: [] });
break;
case 'prompts/list':
jsonRpcResult(id, { prompts: [] });
break;
default:
jsonRpcError(id, -32601, `Method not found: ${method}`);
}
} catch (err) {
jsonRpcError(id, -32603, err.message || 'Internal error');
}
}
let lineBuffer = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
lineBuffer += chunk;
let index;
while ((index = lineBuffer.indexOf('\n')) >= 0) {
const line = lineBuffer.slice(0, index).trim();
lineBuffer = lineBuffer.slice(index + 1);
if (!line) continue;
let message;
try {
message = JSON.parse(line);
} catch (err) {
jsonRpcError(null, -32700, 'Parse error', err.message);
continue;
}
handleRequest(message);
}
});
process.stdin.on('end', () => {
process.exit(0);
});

View File

@@ -81,6 +81,7 @@
let isGenerating = false;
let reconnectAttempts = 0;
let reconnectTimer = null;
let isPageUnloading = false;
let pendingText = '';
let renderTimer = null;
let generatingSessionId = null;
@@ -135,6 +136,7 @@
const importSessionBtn = $('#import-session-btn');
const sessionList = $('#session-list');
const chatTitle = $('#chat-title');
const chatSessionIdBtn = $('#chat-session-id-btn');
const chatAgentBtn = $('#chat-agent-btn');
const chatAgentMenu = $('#chat-agent-menu');
const chatRuntimeState = $('#chat-runtime-state');
@@ -648,6 +650,51 @@
return sessions.find((s) => s.id === sessionId) || null;
}
function shortSessionId(sessionId) {
const value = String(sessionId || '');
return value ? value.slice(0, 8) : '';
}
function updateSessionIdBadge() {
if (!chatSessionIdBtn) return;
if (!currentSessionId) {
chatSessionIdBtn.hidden = true;
chatSessionIdBtn.textContent = 'ID';
chatSessionIdBtn.title = '复制当前会话 ID';
return;
}
chatSessionIdBtn.hidden = false;
chatSessionIdBtn.textContent = `ID ${shortSessionId(currentSessionId)}`;
chatSessionIdBtn.title = `复制当前会话 ID\n${currentSessionId}`;
chatSessionIdBtn.setAttribute('aria-label', `复制当前会话 ID ${currentSessionId}`);
}
async function copyTextToClipboard(text, successText = '已复制') {
const value = String(text || '');
if (!value) return false;
try {
if (navigator.clipboard?.writeText && window.isSecureContext) {
await navigator.clipboard.writeText(value);
} else {
const textarea = document.createElement('textarea');
textarea.value = value;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
textarea.style.top = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
textarea.remove();
}
showToast(successText);
return true;
} catch {
showToast('复制失败');
return false;
}
}
function deepClone(value) {
if (value === null || value === undefined) return value;
return JSON.parse(JSON.stringify(value));
@@ -1797,6 +1844,7 @@
${session.hasUnread ? '<span class="session-unread-dot"></span>' : ''}
<span class="session-item-time">${timeAgo(session.updated)}</span>
<div class="session-item-actions">
<button class="session-item-btn copy-id" title="复制 ID">ID</button>
<button class="session-item-btn edit" title="重命名">✎</button>
<button class="session-item-btn delete" title="删除">×</button>
</div>
@@ -1804,6 +1852,11 @@
item.addEventListener('click', (e) => {
const target = e.target;
if (target.classList.contains('copy-id')) {
e.stopPropagation();
copyTextToClipboard(session.id, '会话 ID 已复制');
return;
}
if (target.classList.contains('delete')) {
e.stopPropagation();
const doDelete = () => {
@@ -1920,6 +1973,7 @@
sendBtn.hidden = false;
abortBtn.hidden = true;
chatTitle.textContent = '新会话';
updateSessionIdBadge();
updateCwdBadge();
messagesDiv.innerHTML = buildWelcomeMarkup(currentAgent);
setStatsDisplay(null);
@@ -1949,6 +2003,7 @@
loadedHistorySessionId = snapshot.sessionId;
setLastSessionForAgent(snapshot.agent, currentSessionId);
chatTitle.textContent = snapshot.title || '新会话';
updateSessionIdBadge();
setCurrentAgent(snapshotAgent);
migratePendingNotesToSession(snapshot.sessionId, snapshotAgent);
setCurrentSessionRunningState(snapshot.isRunning);
@@ -2221,26 +2276,49 @@
};
// --- WebSocket ---
function connect() {
if (ws && ws.readyState <= 1) return;
ws = new WebSocket(WS_URL);
function isBrowserOnline() {
return !('onLine' in navigator) || navigator.onLine;
}
ws.onopen = () => {
function canConnectWs() {
return !isPageUnloading && isBrowserOnline();
}
function clearReconnectTimer() {
if (!reconnectTimer) return;
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
function connect() {
if (!canConnectWs()) return;
if (ws && ws.readyState <= 1) return;
clearReconnectTimer();
const socket = new WebSocket(WS_URL);
ws = socket;
socket.onopen = () => {
if (ws !== socket) return;
reconnectAttempts = 0;
clearReconnectTimer();
if (authToken) send({ type: 'auth', token: authToken });
};
ws.onmessage = (e) => {
socket.onmessage = (e) => {
if (ws !== socket) return;
let msg;
try { msg = JSON.parse(e.data); } catch { return; }
handleServerMessage(msg);
};
ws.onclose = () => {
socket.onclose = () => {
if (ws !== socket) return;
ws = null;
clearSessionLoading();
scheduleReconnect();
};
ws.onerror = () => {};
socket.onerror = () => {};
}
function send(data) {
@@ -2248,6 +2326,7 @@
}
function scheduleReconnect() {
if (!canConnectWs()) return;
if (reconnectTimer) return;
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
reconnectAttempts++;
@@ -2367,6 +2446,22 @@
}
break;
case 'session_message':
if (msg.sessionId && msg.message) {
updateCachedSession(msg.sessionId, (snapshot) => {
snapshot.messages = Array.isArray(snapshot.messages) ? snapshot.messages : [];
snapshot.messages.push(deepClone(msg.message));
snapshot.updated = msg.message.timestamp || new Date().toISOString();
});
}
if (msg.sessionId === currentSessionId && msg.message) {
const welcome = messagesDiv.querySelector('.welcome-msg');
if (welcome) welcome.remove();
messagesDiv.appendChild(buildMsgElement(msg.message));
scrollToBottom();
}
break;
case 'session_renamed':
sessions = sessions.map((session) => session.id === msg.sessionId ? { ...session, title: msg.title } : session);
updateCachedSession(msg.sessionId, (snapshot) => { snapshot.title = msg.title; });
@@ -2755,9 +2850,10 @@
catch { return escapeHtml(text); }
}
function createMsgElement(role, content, attachments = []) {
function createMsgElement(role, content, attachments = [], meta = {}) {
const div = document.createElement('div');
div.className = `msg ${role}${role === 'assistant' ? ' agent-' + currentAgent : ''}`;
const isCrossConversation = role === 'user' && !!meta.crossConversation;
div.className = `msg ${role}${role === 'assistant' ? ' agent-' + currentAgent : ''}${isCrossConversation ? ' cross-conversation' : ''}`;
if (role === 'system') {
const bubble = document.createElement('div');
@@ -2769,7 +2865,9 @@
const avatar = document.createElement('div');
avatar.className = 'msg-avatar';
if (role === 'user') {
if (isCrossConversation) {
avatar.textContent = '↗';
} else if (role === 'user') {
avatar.textContent = 'U';
} else if (currentAgent === 'codex') {
avatar.innerHTML = `<img src="/codex.png" width="24" height="24" style="display:block;" alt="Codex">`;
@@ -2781,6 +2879,33 @@
bubble.className = 'msg-bubble';
if (role === 'user') {
if (isCrossConversation) {
const source = meta.crossConversation || {};
const sourceTitle = source.sourceTitle || '未命名对话';
const sourceId = source.sourceSessionId || '';
const sourceMeta = document.createElement('div');
sourceMeta.className = 'cross-conversation-meta';
const label = document.createElement('span');
label.className = 'cross-conversation-label';
label.textContent = `来自「${sourceTitle}」的对话`;
sourceMeta.appendChild(label);
if (sourceId) {
const copyBtn = document.createElement('button');
copyBtn.type = 'button';
copyBtn.className = 'cross-conversation-id-btn';
copyBtn.textContent = `ID ${shortSessionId(sourceId)}`;
copyBtn.title = `复制来源会话 ID\n${sourceId}`;
copyBtn.addEventListener('click', (event) => {
event.stopPropagation();
copyTextToClipboard(sourceId, '来源会话 ID 已复制');
});
sourceMeta.appendChild(copyBtn);
}
bubble.appendChild(sourceMeta);
}
if (content) {
const textNode = document.createElement('div');
textNode.className = 'msg-text';
@@ -3052,7 +3177,7 @@
}
function buildMsgElement(m) {
const el = createMsgElement(m.role, m.content, m.attachments || []);
const el = createMsgElement(m.role, m.content, m.attachments || [], m);
if (m.role === 'assistant' && m.toolCalls && m.toolCalls.length > 0) {
const bubble = el.querySelector('.msg-bubble');
const FOLD_AT = 3;
@@ -3822,6 +3947,12 @@
});
}
if (chatSessionIdBtn) {
chatSessionIdBtn.addEventListener('click', () => {
copyTextToClipboard(currentSessionId, '当前会话 ID 已复制');
});
}
// --- Header title editing (contenteditable) ---
chatTitle.addEventListener('click', () => {
if (!currentSessionId || chatTitle.contentEditable === 'true') return;
@@ -5932,6 +6063,25 @@
renderSessionList();
connect();
window.addEventListener('resize', updateCwdBadge);
window.addEventListener('online', () => {
reconnectAttempts = 0;
connect();
});
window.addEventListener('offline', clearReconnectTimer);
window.addEventListener('pagehide', () => {
isPageUnloading = true;
clearReconnectTimer();
if (ws && ws.readyState <= 1) {
ws.close(1001, 'pagehide');
}
});
window.addEventListener('pageshow', () => {
isPageUnloading = false;
if (!ws || ws.readyState > 1) {
reconnectAttempts = 0;
connect();
}
});
// Register Service Worker for mobile push notifications
if ('serviceWorker' in navigator) {
@@ -5950,6 +6100,7 @@
if (document.visibilityState !== 'visible') return;
if (!ws || ws.readyState > 1) {
// WS is dead, force reconnect
reconnectAttempts = 0;
connect();
} else if (ws.readyState === 1 && currentSessionId) {
// Preserve active streaming UI when returning to foreground.

View File

@@ -60,6 +60,7 @@
<header class="chat-header">
<button id="menu-btn" class="menu-btn" title="菜单"></button>
<span id="chat-title" class="chat-title">新会话</span>
<button id="chat-session-id-btn" class="chat-session-id-btn" type="button" title="复制当前会话 ID" hidden>ID</button>
<button id="chat-agent-btn" class="chat-agent-btn" type="button" aria-haspopup="menu" aria-expanded="false">Claude</button>
<div id="chat-agent-menu" class="chat-agent-menu" hidden>
<button type="button" class="chat-agent-option active" data-agent="claude">Claude</button>

View File

@@ -804,6 +804,12 @@ body.session-loading-active {
line-height: 1;
}
.session-item-btn:hover { color: var(--text-primary); background: var(--bg-tertiary); }
.session-item-btn.copy-id {
min-width: 24px;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.02em;
}
.session-item-btn.delete:hover { color: var(--danger); background: var(--accent-light); }
/* Inline edit in sidebar */
.session-item-edit-input {
@@ -872,6 +878,26 @@ body.session-loading-active {
transition: background 0.15s;
}
.chat-title:hover { background: var(--bg-tertiary); }
.chat-session-id-btn {
appearance: none;
max-width: 112px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 0;
border: 1px solid rgba(91, 126, 161, 0.22);
border-radius: 999px;
background: rgba(91, 126, 161, 0.1);
color: var(--info);
padding: 3px 9px;
font-size: 11px;
font-weight: 800;
cursor: pointer;
}
.chat-session-id-btn:hover {
background: rgba(91, 126, 161, 0.16);
border-color: rgba(91, 126, 161, 0.34);
}
.chat-agent-btn {
appearance: none;
font-size: 11px;
@@ -1268,6 +1294,46 @@ body.session-loading-active {
color: #fff;
border-bottom-right-radius: 4px;
}
.msg.user.cross-conversation .msg-avatar {
background: var(--info);
color: #fff;
}
.msg.user.cross-conversation .msg-bubble {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.76), transparent),
rgba(91, 126, 161, 0.1);
border: 1px solid rgba(91, 126, 161, 0.24);
color: var(--text-primary);
box-shadow: 0 10px 22px rgba(45, 31, 20, 0.05);
}
.cross-conversation-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 7px;
color: var(--info);
font-size: 11px;
font-weight: 800;
}
.cross-conversation-label {
min-width: 0;
overflow-wrap: anywhere;
}
.cross-conversation-id-btn {
appearance: none;
border: 1px solid rgba(91, 126, 161, 0.24);
border-radius: 999px;
background: rgba(255, 255, 255, 0.58);
color: var(--info);
padding: 2px 7px;
font: inherit;
font-size: 10px;
cursor: pointer;
}
.cross-conversation-id-btn:hover {
background: rgba(91, 126, 161, 0.14);
}
.msg.assistant .msg-bubble {
background: var(--bg-bubble-assistant);
border: 1px solid var(--border-color);
@@ -2149,6 +2215,12 @@ body.session-loading-active {
.chat-title {
font-size: 13px;
}
.chat-session-id-btn {
max-width: 82px;
padding-left: 7px;
padding-right: 7px;
font-size: 10px;
}
.theme-grid {
grid-template-columns: 1fr;
}

View File

@@ -11,6 +11,7 @@ const REPO_DIR = path.resolve(__dirname, '..');
const SERVER_PATH = path.join(REPO_DIR, 'server.js');
const MOCK_CLAUDE = path.join(REPO_DIR, 'scripts', 'mock-claude.js');
const MOCK_CODEX = path.join(REPO_DIR, 'scripts', 'mock-codex.js');
const HAS_SQLITE3 = spawnSync('sqlite3', ['-version'], { stdio: 'ignore' }).status === 0;
function mkdirp(dir) {
fs.mkdirSync(dir, { recursive: true });
@@ -39,6 +40,7 @@ function assert(condition, message) {
}
function sql(dbPath, statement) {
if (!HAS_SQLITE3) throw new Error('sqlite3 is not available');
const result = spawnSync('sqlite3', [dbPath, statement], { encoding: 'utf8' });
if (result.status !== 0) throw new Error(result.stderr || `sqlite3 failed: ${statement}`);
return result.stdout.trim();
@@ -102,6 +104,43 @@ function connectWs(port, password) {
});
}
function assertWsUpgradeRejected(port, pathname) {
return new Promise((resolve, reject) => {
let settled = false;
const ws = new WebSocket(`ws://127.0.0.1:${port}${pathname}`);
const timer = setTimeout(() => finish(reject, new Error(`WebSocket upgrade was not rejected for ${pathname}`)), 5000);
function finish(done, value) {
if (settled) return;
settled = true;
clearTimeout(timer);
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
try { ws.terminate(); } catch {}
}
done(value);
}
ws.on('open', () => {
finish(reject, new Error(`Unexpected WebSocket connection opened for ${pathname}`));
});
ws.on('unexpected-response', (req, res) => {
res.resume();
if (res.statusCode === 404) {
finish(resolve);
return;
}
finish(reject, new Error(`Expected 404 for ${pathname}, got ${res.statusCode}`));
});
ws.on('error', (err) => {
if (/Unexpected server response: 404/.test(err.message || '')) {
finish(resolve);
return;
}
finish(reject, err);
});
});
}
async function uploadAttachment(port, token, { filename, mime, data }) {
const response = await fetch(`http://127.0.0.1:${port}/api/attachments`, {
method: 'POST',
@@ -128,6 +167,22 @@ async function fetchAuthedJson(port, token, pathname) {
return payload;
}
async function callInternalMcp(port, token, payload) {
const response = await fetch(`http://127.0.0.1:${port}/api/internal/mcp`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CC-Web-MCP-Token': token,
},
body: JSON.stringify(payload),
});
let body = null;
try {
body = await response.json();
} catch {}
return { status: response.status, body };
}
function nextMessage(messages, ws, predicate, timeoutMs = 15000) {
const callSite = (() => {
const stack = String(new Error().stack || '').split('\n');
@@ -220,86 +275,90 @@ function createFakeCodexHistory(homeDir) {
];
fs.writeFileSync(rolloutPath, `${rolloutLines.join('\n')}\n`);
const stateDb = path.join(homeDir, '.codex', 'state_5.sqlite');
mkdirp(path.dirname(stateDb));
sql(stateDb, `
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS threads (
id TEXT PRIMARY KEY,
rollout_path TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
source TEXT NOT NULL,
model_provider TEXT NOT NULL,
cwd TEXT NOT NULL,
title TEXT NOT NULL,
sandbox_policy TEXT NOT NULL,
approval_mode TEXT NOT NULL,
tokens_used INTEGER NOT NULL DEFAULT 0,
has_user_event INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
archived_at INTEGER,
git_sha TEXT,
git_branch TEXT,
git_origin_url TEXT,
cli_version TEXT NOT NULL DEFAULT '',
first_user_message TEXT NOT NULL DEFAULT '',
agent_nickname TEXT,
agent_role TEXT,
memory_mode TEXT NOT NULL DEFAULT 'enabled'
);
CREATE TABLE IF NOT EXISTS stage1_outputs (
thread_id TEXT PRIMARY KEY,
source_updated_at INTEGER NOT NULL,
raw_memory TEXT NOT NULL,
rollout_summary TEXT NOT NULL,
generated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS thread_dynamic_tools (
thread_id TEXT NOT NULL,
position INTEGER NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL,
input_schema TEXT NOT NULL,
PRIMARY KEY(thread_id, position)
);
CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
ts_nanos INTEGER NOT NULL,
level TEXT NOT NULL,
target TEXT NOT NULL,
message TEXT,
module_path TEXT,
file TEXT,
line INTEGER,
thread_id TEXT,
process_uuid TEXT,
estimated_bytes INTEGER NOT NULL DEFAULT 0
);
INSERT INTO threads (id, rollout_path, created_at, updated_at, source, model_provider, cwd, title, sandbox_policy, approval_mode, cli_version)
VALUES ('${threadId}', '${rolloutPath.replace(/'/g, "''")}', 1, 2, 'exec', 'OpenAI', '/tmp/project-b', 'Codex import prompt', '{}', 'never', '0.114.0');
INSERT INTO logs (ts, ts_nanos, level, target, thread_id) VALUES (1, 0, 'INFO', 'test', '${threadId}');
`);
let stateDb = null;
let logsDb = null;
if (HAS_SQLITE3) {
stateDb = path.join(homeDir, '.codex', 'state_5.sqlite');
mkdirp(path.dirname(stateDb));
sql(stateDb, `
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS threads (
id TEXT PRIMARY KEY,
rollout_path TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
source TEXT NOT NULL,
model_provider TEXT NOT NULL,
cwd TEXT NOT NULL,
title TEXT NOT NULL,
sandbox_policy TEXT NOT NULL,
approval_mode TEXT NOT NULL,
tokens_used INTEGER NOT NULL DEFAULT 0,
has_user_event INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
archived_at INTEGER,
git_sha TEXT,
git_branch TEXT,
git_origin_url TEXT,
cli_version TEXT NOT NULL DEFAULT '',
first_user_message TEXT NOT NULL DEFAULT '',
agent_nickname TEXT,
agent_role TEXT,
memory_mode TEXT NOT NULL DEFAULT 'enabled'
);
CREATE TABLE IF NOT EXISTS stage1_outputs (
thread_id TEXT PRIMARY KEY,
source_updated_at INTEGER NOT NULL,
raw_memory TEXT NOT NULL,
rollout_summary TEXT NOT NULL,
generated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS thread_dynamic_tools (
thread_id TEXT NOT NULL,
position INTEGER NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL,
input_schema TEXT NOT NULL,
PRIMARY KEY(thread_id, position)
);
CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
ts_nanos INTEGER NOT NULL,
level TEXT NOT NULL,
target TEXT NOT NULL,
message TEXT,
module_path TEXT,
file TEXT,
line INTEGER,
thread_id TEXT,
process_uuid TEXT,
estimated_bytes INTEGER NOT NULL DEFAULT 0
);
INSERT INTO threads (id, rollout_path, created_at, updated_at, source, model_provider, cwd, title, sandbox_policy, approval_mode, cli_version)
VALUES ('${threadId}', '${rolloutPath.replace(/'/g, "''")}', 1, 2, 'exec', 'OpenAI', '/tmp/project-b', 'Codex import prompt', '{}', 'never', '0.114.0');
INSERT INTO logs (ts, ts_nanos, level, target, thread_id) VALUES (1, 0, 'INFO', 'test', '${threadId}');
`);
const logsDb = path.join(homeDir, '.codex', 'logs_1.sqlite');
sql(logsDb, `
CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
ts_nanos INTEGER NOT NULL,
level TEXT NOT NULL,
target TEXT NOT NULL,
message TEXT,
module_path TEXT,
file TEXT,
line INTEGER,
thread_id TEXT,
process_uuid TEXT,
estimated_bytes INTEGER NOT NULL DEFAULT 0
);
INSERT INTO logs (ts, ts_nanos, level, target, thread_id) VALUES (1, 0, 'INFO', 'test', '${threadId}');
`);
logsDb = path.join(homeDir, '.codex', 'logs_1.sqlite');
sql(logsDb, `
CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
ts_nanos INTEGER NOT NULL,
level TEXT NOT NULL,
target TEXT NOT NULL,
message TEXT,
module_path TEXT,
file TEXT,
line INTEGER,
thread_id TEXT,
process_uuid TEXT,
estimated_bytes INTEGER NOT NULL DEFAULT 0
);
INSERT INTO logs (ts, ts_nanos, level, target, thread_id) VALUES (1, 0, 'INFO', 'test', '${threadId}');
`);
}
return { threadId, rolloutPath, stateDb, logsDb };
}
@@ -344,10 +403,12 @@ async function main() {
const port = await getFreePort();
const password = 'Regression!234';
const internalMcpToken = 'RegressionMcp!234';
await withServer({
PORT: String(port),
CC_WEB_PASSWORD: password,
CC_WEB_INTERNAL_MCP_TOKEN: internalMcpToken,
CC_WEB_CONFIG_DIR: configDir,
CC_WEB_SESSIONS_DIR: sessionsDir,
CC_WEB_LOGS_DIR: logsDir,
@@ -355,6 +416,8 @@ async function main() {
CLAUDE_PATH: MOCK_CLAUDE,
CODEX_PATH: MOCK_CODEX,
}, async () => {
await assertWsUpgradeRejected(port, '/not-ws');
const { ws, messages, token } = await connectWs(port, password);
await nextMessage(messages, ws, (msg) => msg.type === 'session_list');
@@ -409,6 +472,62 @@ async function main() {
assert(codexSession.mode === 'plan', 'Codex new_session should follow requested mode');
assert(codexSession.model === 'gpt-5.5(xhigh)', 'Codex new_session should read default model from ~/.codex/config.toml');
const mcpList = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_list_conversations',
sourceSessionId: codexSession.sessionId,
args: { agent: 'codex', limit: 20 },
});
assert(mcpList.status === 200 && mcpList.body?.ok, 'MCP conversation list should succeed');
assert(mcpList.body.currentConversationId === codexSession.sessionId, 'MCP list should return current source conversation id');
assert(mcpList.body.conversations.some((item) => item.id === codexSession.sessionId && !item.summary), 'MCP list should return lightweight session metadata without summary');
const crossTargetCwd = path.join(tempRoot, 'codex-mcp-cross-target');
mkdirp(crossTargetCwd);
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: crossTargetCwd, mode: 'yolo' }));
const crossTargetSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === crossTargetCwd);
const crossSend = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_send_message',
sourceSessionId: codexSession.sessionId,
sourceHopCount: 0,
args: {
targetConversationId: crossTargetSession.sessionId,
content: 'cross hello from mcp',
},
});
assert(crossSend.status === 200 && crossSend.body?.ok, `MCP cross send should succeed: ${JSON.stringify(crossSend.body)}`);
const crossUserBubble = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_message' &&
msg.sessionId === crossTargetSession.sessionId &&
msg.message?.crossConversation?.sourceSessionId === codexSession.sessionId &&
msg.message?.content === 'cross hello from mcp'
));
assert(crossUserBubble.message.crossConversation.hopCount === 1, 'Cross message should persist hop count');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === crossTargetSession.sessionId);
const storedCrossTarget = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${crossTargetSession.sessionId}.json`), 'utf8'));
const storedCrossMessage = storedCrossTarget.messages.find((message) => message.crossConversation?.messageId === crossSend.body.messageId);
assert(storedCrossMessage?.content === 'cross hello from mcp', 'Cross message should be persisted in target session');
assert(storedCrossMessage.crossConversation.sourceTitle === codexSession.title, 'Cross message should persist source title');
assert(storedCrossTarget.messages.some((message) => message.role === 'assistant' && /来自/.test(String(message.content || ''))), 'Cross message runtime prompt should include source context for the target agent');
const hopLimit = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_send_message',
sourceSessionId: crossTargetSession.sessionId,
sourceHopCount: 1,
args: {
targetConversationId: codexSession.sessionId,
content: 'this should be blocked by hop limit',
},
});
assert(hopLimit.status === 400 && hopLimit.body?.code === 'hop_limit_exceeded', 'MCP cross send should enforce hop limit');
const processLogAfterMcp = fs.readFileSync(path.join(logsDir, 'process.log'), 'utf8');
const mcpSpawnLine = processLogAfterMcp
.trim()
.split('\n')
.find((line) => line.includes(`"event":"process_spawn"`) && line.includes(crossTargetSession.sessionId.slice(0, 8)));
assert(mcpSpawnLine && mcpSpawnLine.includes('mcp_servers.ccweb.command') && mcpSpawnLine.includes('mcp_servers.ccweb.env_vars'), 'Codex spawn should inject ccweb MCP config');
assert(!mcpSpawnLine.includes(internalMcpToken), 'Codex spawn log should not expose internal MCP token');
ws.send(JSON.stringify({ type: 'list_cwd_suggestions' }));
const cwdSuggestions = await nextMessage(messages, ws, (msg) => msg.type === 'cwd_suggestions');
assert(cwdSuggestions.defaultPath === homeDir, 'CWD suggestions should expose HOME as default path');
@@ -573,7 +692,9 @@ async function main() {
assert(!fs.existsSync(path.join(sessionsDir, `${importedSessionId}.json`)), 'Deleting Codex session did not remove session JSON');
assert(!fs.existsSync(codexFixture.rolloutPath), 'Deleting Codex session did not remove rollout file');
assert(sql(codexFixture.stateDb, `select count(*) from threads where id='${codexFixture.threadId}'`) === '0', 'Deleting Codex session did not remove thread row');
if (codexFixture.stateDb) {
assert(sql(codexFixture.stateDb, `select count(*) from threads where id='${codexFixture.threadId}'`) === '0', 'Deleting Codex session did not remove thread row');
}
ws.close();
console.log('Regression checks passed.');

325
server.js
View File

@@ -19,14 +19,17 @@ if (fs.existsSync(envPath)) {
const PORT = parseInt(process.env.PORT) || 8002;
const CLAUDE_PATH = process.env.CLAUDE_PATH || 'claude';
const CODEX_PATH = process.env.CODEX_PATH || 'codex';
const INTERNAL_MCP_TOKEN = process.env.CC_WEB_INTERNAL_MCP_TOKEN || crypto.randomBytes(32).toString('hex');
const CONFIG_DIR = process.env.CC_WEB_CONFIG_DIR || path.join(__dirname, 'config');
const SESSIONS_DIR = process.env.CC_WEB_SESSIONS_DIR || path.join(__dirname, 'sessions');
const PUBLIC_DIR = process.env.CC_WEB_PUBLIC_DIR || path.join(__dirname, 'public');
const LOGS_DIR = process.env.CC_WEB_LOGS_DIR || path.join(__dirname, 'logs');
const CCWEB_MCP_SERVER_PATH = path.join(__dirname, 'lib', 'ccweb-mcp-server.js');
const ATTACHMENTS_DIR = path.join(SESSIONS_DIR, '_attachments');
const ATTACHMENT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024;
const MAX_MESSAGE_ATTACHMENTS = 4;
const CROSS_CONVERSATION_MAX_HOPS = 1;
const FILE_BROWSER_MAX_LIST_ENTRIES = 400;
const FILE_BROWSER_MAX_PREVIEW_BYTES = 200 * 1024;
const IMAGE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
@@ -1007,6 +1010,43 @@ function jsonResponse(res, statusCode, payload) {
res.end(JSON.stringify(payload));
}
function readJsonBody(req, maxBytes = 1024 * 1024) {
return new Promise((resolve, reject) => {
const chunks = [];
let total = 0;
let finished = false;
req.on('data', (chunk) => {
if (finished) return;
total += chunk.length;
if (total > maxBytes) {
finished = true;
reject(new Error('请求体过大'));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('end', () => {
if (finished) return;
finished = true;
try {
const text = Buffer.concat(chunks).toString('utf8').trim();
resolve(text ? JSON.parse(text) : {});
} catch {
reject(new Error('请求体不是有效 JSON'));
}
});
req.on('error', (err) => {
if (finished) return;
finished = true;
reject(err);
});
});
}
function normalizeRelativeBrowserPath(input) {
return String(input || '')
.replace(/\\/g, '/')
@@ -1585,6 +1625,196 @@ function sendSessionList(ws) {
}
}
function broadcastSessionList() {
if (!wss) return;
for (const client of wss.clients) {
if (client.readyState === 1) sendSessionList(client);
}
}
function findViewingSessionWs(sessionId) {
const normalizedId = sanitizeId(sessionId || '');
if (!normalizedId) return null;
for (const [client, viewedSessionId] of wsSessionMap.entries()) {
if (viewedSessionId === normalizedId && client?.readyState === 1) return client;
}
return null;
}
function getInternalMcpRequestToken(req) {
return String(req.headers['x-cc-web-mcp-token'] || '').trim() || extractBearerToken(req);
}
function mcpToolError(code, message, extra = {}) {
return { ok: false, code, message, ...extra };
}
function clampMcpLimit(value) {
const parsed = Number.parseInt(String(value || ''), 10);
if (!Number.isFinite(parsed)) return 50;
return Math.max(1, Math.min(100, parsed));
}
function listConversationSummaries(args = {}, sourceSessionId = '') {
const agentFilter = VALID_AGENTS.has(args.agent) ? args.agent : '';
const statusFilter = ['running', 'idle'].includes(args.status) ? args.status : 'all';
const limit = clampMcpLimit(args.limit);
const conversations = [];
try {
const files = fs.readdirSync(SESSIONS_DIR).filter((name) => name.endsWith('.json'));
for (const file of files) {
try {
const session = normalizeSession(JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, file), 'utf8')));
const agent = getSessionAgent(session);
const running = activeProcesses.has(session.id);
const status = running ? 'running' : 'idle';
if (agentFilter && agent !== agentFilter) continue;
if (statusFilter !== 'all' && status !== statusFilter) continue;
const cwd = session.cwd || '';
conversations.push({
id: session.id,
title: session.title || 'Untitled',
agent,
status,
updatedAt: session.updated || session.created || null,
cwd,
projectName: cwd ? path.basename(cwd.replace(/[\\/]+$/, '')) : '',
isCurrent: session.id === sourceSessionId,
});
} catch {}
}
} catch {}
conversations.sort((a, b) => new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0));
return {
ok: true,
currentConversationId: sourceSessionId || null,
conversations: conversations.slice(0, limit),
};
}
function buildCrossConversationRuntimeText(sourceSession, content) {
const sourceTitle = sourceSession?.title || 'Untitled';
const sourceId = sourceSession?.id || '';
return `来自「${sourceTitle}」对话ID: ${sourceId})的消息:\n\n${content}`;
}
function sendCrossConversationMessage(args = {}, sourceSessionId = '', sourceHopCount = 0) {
const sourceId = sanitizeId(sourceSessionId || '');
const targetId = sanitizeId(args.targetConversationId || args.targetSessionId || args.conversationId || '');
const content = typeof args.content === 'string' ? args.content.trim() : '';
if (!sourceId) {
return mcpToolError('missing_source_conversation', '缺少来源对话 ID。');
}
if (!targetId) {
return mcpToolError('missing_target_conversation', '缺少目标对话 ID。');
}
if (!content) {
return mcpToolError('empty_content', '消息内容不能为空。');
}
const sourceSession = loadSession(sourceId);
if (!sourceSession) {
return mcpToolError('source_not_found', '来源对话不存在。', { sourceConversationId: sourceId });
}
const targetSession = loadSession(targetId);
if (!targetSession) {
return mcpToolError('target_not_found', '目标对话不存在。', { targetConversationId: targetId });
}
if (sourceId === targetId) {
return mcpToolError('same_conversation', '不能把消息发送给当前同一个对话。', { targetConversationId: targetId });
}
const normalizedHopCount = Math.max(0, Number.parseInt(String(sourceHopCount || 0), 10) || 0);
if (normalizedHopCount >= CROSS_CONVERSATION_MAX_HOPS) {
return mcpToolError('hop_limit_exceeded', '跨对话消息已达到转发跳数上限,已拒绝继续转发。', {
sourceConversationId: sourceId,
targetConversationId: targetId,
maxHops: CROSS_CONVERSATION_MAX_HOPS,
});
}
if (activeProcesses.has(targetId)) {
return mcpToolError('target_running', '目标对话正在处理中,请稍后再发送。', { targetConversationId: targetId });
}
const now = new Date().toISOString();
const messageId = crypto.randomUUID();
const crossConversation = {
messageId,
sourceSessionId: sourceSession.id,
sourceTitle: sourceSession.title || 'Untitled',
sentAt: now,
hopCount: normalizedHopCount + 1,
};
const targetWs = findViewingSessionWs(targetId);
const result = handleMessage(targetWs, {
text: content,
sessionId: targetSession.id,
mode: targetSession.permissionMode || 'yolo',
agent: getSessionAgent(targetSession),
}, {
crossConversation,
emitUserMessage: true,
runtimeText: buildCrossConversationRuntimeText(sourceSession, content),
mcpContext: { hopCount: crossConversation.hopCount },
});
if (!result?.ok) {
return mcpToolError(result?.code || 'send_failed', result?.message || '跨对话消息发送失败。', {
sourceConversationId: sourceId,
targetConversationId: targetId,
});
}
broadcastSessionList();
return {
ok: true,
messageId,
deliveryStatus: 'delivered',
sourceConversationId: sourceId,
targetConversationId: targetId,
targetTitle: targetSession.title || 'Untitled',
};
}
function callInternalMcpTool(tool, args, sourceSessionId, sourceHopCount) {
switch (tool) {
case 'ccweb_list_conversations':
return listConversationSummaries(args, sanitizeId(sourceSessionId || ''));
case 'ccweb_send_message':
return sendCrossConversationMessage(args, sourceSessionId, sourceHopCount);
default:
return mcpToolError('unknown_tool', `未知 MCP 工具: ${tool}`);
}
}
async function handleInternalMcpApi(req, res) {
const token = getInternalMcpRequestToken(req);
if (!token || token !== INTERNAL_MCP_TOKEN) {
return jsonResponse(res, 401, mcpToolError('unauthorized', 'MCP 内部接口未授权。'));
}
let payload;
try {
payload = await readJsonBody(req);
} catch (err) {
return jsonResponse(res, 400, mcpToolError('bad_request', err.message || '请求体无效。'));
}
const tool = String(payload.tool || '');
const args = payload.args && typeof payload.args === 'object' ? payload.args : {};
const sourceSessionId = sanitizeId(payload.sourceSessionId || '');
const sourceHopCount = Number.parseInt(String(payload.sourceHopCount || 0), 10) || 0;
const result = callInternalMcpTool(tool, args, sourceSessionId, sourceHopCount);
return jsonResponse(res, result.ok ? 200 : 400, result);
}
// === File Tailer ===
// Tails a file and calls onLine for each new complete line.
class FileTailer {
@@ -2016,6 +2246,10 @@ function recoverProcesses() {
const server = http.createServer((req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
if (req.method === 'POST' && url.pathname === '/api/internal/mcp') {
return handleInternalMcpApi(req, res);
}
if (req.method === 'POST' && url.pathname === '/api/attachments') {
const token = extractBearerToken(req);
if (!token || !activeTokens.has(token)) {
@@ -2160,7 +2394,26 @@ const server = http.createServer((req, res) => {
});
// === WebSocket Server ===
const wss = new WebSocketServer({ server });
const wss = new WebSocketServer({ noServer: true });
server.on('upgrade', (req, socket, head) => {
let pathname = '';
try {
pathname = new URL(req.url || '/', 'http://localhost').pathname;
} catch {
pathname = '';
}
if (pathname !== '/ws') {
socket.write('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req);
});
});
wss.on('connection', (ws, req) => {
ws._req = req;
@@ -3042,14 +3295,22 @@ function handleAbort(ws) {
function handleMessage(ws, msg, options = {}) {
const { text, sessionId, mode } = msg;
const { hideInHistory = false } = options;
const fail = (code, message) => {
wsSend(ws, { type: 'error', code, message });
return { ok: false, code, message };
};
const textValue = typeof text === 'string' ? text : '';
const runtimeTextValue = typeof options.runtimeText === 'string' ? options.runtimeText : textValue;
const attachments = Array.isArray(msg.attachments) ? msg.attachments.slice(0, MAX_MESSAGE_ATTACHMENTS) : [];
const normalizedText = textValue.trim();
const normalizedRuntimeText = runtimeTextValue.trim();
const resolvedAttachments = resolveMessageAttachments(attachments);
if (attachments.length > 0 && resolvedAttachments.length === 0) {
return wsSend(ws, { type: 'error', message: '图片附件已过期或不可用,请重新上传后再发送。' });
return fail('attachment_unavailable', '图片附件已过期或不可用,请重新上传后再发送。');
}
if (!normalizedText && resolvedAttachments.length === 0) {
return fail('empty_message', '消息内容不能为空。');
}
if (!normalizedText && resolvedAttachments.length === 0) return;
const savedAttachments = resolvedAttachments.map((attachment) => ({
id: attachment.id,
@@ -3063,7 +3324,7 @@ function handleMessage(ws, msg, options = {}) {
}));
if (sessionId && activeProcesses.has(sessionId)) {
return wsSend(ws, { type: 'error', message: '正在处理中,请先点击停止按钮。' });
return fail('session_running', '正在处理中,请先点击停止按钮。');
}
const derivedTitle = normalizedText
@@ -3095,38 +3356,45 @@ function handleMessage(ws, msg, options = {}) {
normalizeSession(session);
if (normalizedText.startsWith('/') && resolvedAttachments.length > 0) {
return wsSend(ws, { type: 'error', message: '命令消息暂不支持同时附带图片。请先发送图片说明,再单独使用 /model 或 /mode。' });
return fail('command_attachment_unsupported', '命令消息暂不支持同时附带图片。请先发送图片说明,再单独使用 /model 或 /mode。');
}
if (mode && ['default', 'plan', 'yolo'].includes(mode)) {
session.permissionMode = mode;
}
if (!hideInHistory && normalizedText !== '/compact' && getRuntimeSessionId(session)) {
pendingCompactRetries.set(session.id, { text: normalizedText, mode: session.permissionMode || 'yolo', reason: 'normal' });
if (!hideInHistory && normalizedRuntimeText !== '/compact' && getRuntimeSessionId(session)) {
pendingCompactRetries.set(session.id, { text: normalizedRuntimeText, mode: session.permissionMode || 'yolo', reason: 'normal' });
}
if (session.title === 'New Chat' || session.title === 'Untitled') {
session.title = derivedTitle;
}
let persistedUserMessage = null;
if (!hideInHistory) {
session.messages.push({
persistedUserMessage = {
role: 'user',
content: textValue,
attachments: savedAttachments,
timestamp: new Date().toISOString(),
});
};
if (options.crossConversation) {
persistedUserMessage.crossConversation = options.crossConversation;
}
session.messages.push(persistedUserMessage);
}
session.updated = new Date().toISOString();
saveSession(session);
const currentSessionId = session.id;
for (const [, entry] of activeProcesses) {
if (entry.ws === ws) entry.ws = null;
if (ws) {
for (const [, entry] of activeProcesses) {
if (entry.ws === ws) entry.ws = null;
}
wsSessionMap.set(ws, currentSessionId);
}
wsSessionMap.set(ws, currentSessionId);
if (!sessionId) {
wsSend(ws, {
@@ -3146,13 +3414,20 @@ function handleMessage(ws, msg, options = {}) {
isRunning: false,
});
}
if (ws && options.emitUserMessage && persistedUserMessage) {
wsSend(ws, { type: 'session_message', sessionId: currentSessionId, message: persistedUserMessage });
}
sendSessionList(ws);
const runtimeOptions = {
attachments: resolvedAttachments,
mcpContext: options.mcpContext || {},
};
const spawnSpec = isClaudeSession(session)
? buildClaudeSpawnSpec(session, { attachments: resolvedAttachments })
: buildCodexSpawnSpec(session, { attachments: resolvedAttachments });
? buildClaudeSpawnSpec(session, runtimeOptions)
: buildCodexSpawnSpec(session, runtimeOptions);
if (spawnSpec?.error) {
return wsSend(ws, { type: 'error', message: spawnSpec.error });
return fail('runtime_config_error', spawnSpec.error);
}
// === Detached process with file-based I/O ===
@@ -3165,7 +3440,7 @@ function handleMessage(ws, msg, options = {}) {
if (isClaudeSession(session) && resolvedAttachments.length > 0) {
const content = [];
if (textValue) content.push({ type: 'text', text: textValue });
if (runtimeTextValue) content.push({ type: 'text', text: runtimeTextValue });
for (const attachment of resolvedAttachments) {
const data = fs.readFileSync(attachment.path).toString('base64');
content.push({
@@ -3185,7 +3460,7 @@ function handleMessage(ws, msg, options = {}) {
},
})}\n`);
} else {
fs.writeFileSync(inputPath, textValue);
fs.writeFileSync(inputPath, runtimeTextValue);
}
const inputFd = fs.openSync(inputPath, 'r');
@@ -3208,7 +3483,7 @@ function handleMessage(ws, msg, options = {}) {
cleanRunDir(currentSessionId);
plog('ERROR', 'process_spawn_fail', { sessionId: currentSessionId.slice(0, 8), error: err.message });
const agent = getSessionAgent(session);
return wsSend(ws, { type: 'error', message: formatRuntimeError(agent, err.message, { exitCode: null, signal: null }) });
return fail('process_spawn_failed', formatRuntimeError(agent, err.message, { exitCode: null, signal: null }));
}
fs.closeSync(inputFd);
@@ -3225,7 +3500,7 @@ function handleMessage(ws, msg, options = {}) {
mode: spawnSpec.mode,
model: session.model || 'default',
resume: spawnSpec.resume,
args: spawnSpec.args.join(' '),
args: redactSpawnArgs(spawnSpec.args.join(' ')),
});
// Fast exit detection (while Node.js is running)
@@ -3265,6 +3540,8 @@ function handleMessage(ws, msg, options = {}) {
} catch {}
});
entry.tailer.start();
return { ok: true, sessionId: currentSessionId, pid: proc.pid };
}
function truncateObj(obj, maxLen) {
@@ -3296,6 +3573,12 @@ function sanitizeToolInput(toolName, input) {
return truncateObj(parsed, 500);
}
function redactSpawnArgs(argsText) {
return String(argsText || '')
.replace(/CC_WEB_MCP_TOKEN[^\s,\]}]*/g, 'CC_WEB_MCP_TOKEN=****')
.replace(/mcp_servers\.ccweb\.env=\{[^}]*\}/g, 'mcp_servers.ccweb.env={****}');
}
const {
buildClaudeSpawnSpec,
buildCodexSpawnSpec,
@@ -3312,6 +3595,10 @@ const {
getDefaultCodexModel,
loadCodexConfig,
prepareCodexCustomRuntime,
ccwebMcpServerPath: CCWEB_MCP_SERVER_PATH,
internalMcpUrl: `http://127.0.0.1:${PORT}/api/internal/mcp`,
internalMcpToken: INTERNAL_MCP_TOKEN,
nodePath: process.execPath,
wsSend,
truncateObj,
sanitizeToolInput,