feat: add cross conversation messaging
This commit is contained in:
@@ -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
251
lib/ccweb-mcp-server.js
Normal 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);
|
||||
});
|
||||
173
public/app.js
173
public/app.js
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
325
server.js
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user