chore: rebuild CentOS7 release package
This commit is contained in:
737
server.js
737
server.js
@@ -41,6 +41,16 @@ const IS_BUN_SINGLE_EXECUTABLE = !!process.versions?.bun
|
||||
const CCWEB_MCP_SERVER_ARG = '--ccweb-mcp-server';
|
||||
const CODEX_APP_WORKER_ARG = '--codex-app-worker';
|
||||
|
||||
function ccwebMcpServerCommandSpec() {
|
||||
if (IS_BUN_SINGLE_EXECUTABLE) {
|
||||
return { command: process.execPath, args: [CCWEB_MCP_SERVER_ARG] };
|
||||
}
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: [path.join(APP_DIR, 'server.js'), CCWEB_MCP_SERVER_ARG],
|
||||
};
|
||||
}
|
||||
|
||||
// Load .env
|
||||
const envPath = path.join(APP_DIR, '.env');
|
||||
if (fs.existsSync(envPath)) {
|
||||
@@ -645,6 +655,15 @@ const activeCodexAppTurns = new Map();
|
||||
// ccweb MCP child agents tracked from Codex App native collaboration mode:
|
||||
// childThreadId -> { parentSessionId, parentThreadId, spawnToolId, ...state }
|
||||
const ccwebMcpChildThreads = new Map();
|
||||
const CODEX_APP_MCP_STARTUP_STATUS_METHOD = 'mcpServer/startupStatus/updated';
|
||||
const CODEX_APP_MCP_DEFAULT_SERVER = 'ccweb';
|
||||
const CODEX_APP_MCP_RELOAD_STATUS_WAIT_MS = 1200;
|
||||
const CODEX_APP_MCP_RELOAD_TRACK_MS = 15000;
|
||||
const codexAppMcpStartupStatusByServer = new Map();
|
||||
// sessionId -> { threadId, requestedAt, expiresAt, reloadRequestId }
|
||||
const pendingCodexAppMcpReloads = new Map();
|
||||
// sessionId -> Set<{ requestedAt, timer, resolve }>
|
||||
const codexAppMcpStatusWaiters = new Map();
|
||||
// 等待目标对话完成后回传给来源对话的跨对话请求:requestId -> metadata
|
||||
const pendingCrossConversationReplies = new Map();
|
||||
// Pending Codex app-server guided input requests: requestId -> { sessionId, resolve, timer }
|
||||
@@ -670,6 +689,14 @@ let MODEL_MAP = {
|
||||
const VALID_AGENTS = new Set(['claude', 'codex', 'codexapp']);
|
||||
const VALID_PERMISSION_MODES = new Set(['default', 'plan', 'yolo']);
|
||||
const MCP_CONVERSATION_TITLE_MAX_CHARS = 120;
|
||||
const MCP_PROMPT_TITLE_MAX_CHARS = 160;
|
||||
const MCP_PROMPT_DESCRIPTION_MAX_CHARS = 2000;
|
||||
const MCP_PROMPT_QUESTION_MAX_COUNT = 10;
|
||||
const MCP_PROMPT_OPTION_MAX_COUNT = 8;
|
||||
const MCP_PROMPT_QUESTION_MAX_CHARS = 4000;
|
||||
const MCP_PROMPT_OPTION_MAX_CHARS = 1000;
|
||||
const MCP_PROMPT_ANSWER_MAX_CHARS = 4000;
|
||||
const MCP_PROMPT_RESPONSE_MAX_CHARS = 20000;
|
||||
|
||||
// Codex 默认模型优先读取 ~/.codex/config.toml,缺失时再回退到旧默认值。
|
||||
const FALLBACK_CODEX_MODEL = 'gpt-5.4';
|
||||
@@ -2272,6 +2299,7 @@ function summarizeSkillForMention(skill, configuredMcpNames = new Set()) {
|
||||
function buildCcwebMcpRuntimeConfig(session, options = {}) {
|
||||
const env = codexAppCcwebMcpEnv(session, options);
|
||||
if (!env) return null;
|
||||
const commandSpec = ccwebMcpServerCommandSpec();
|
||||
return {
|
||||
server: 'ccweb',
|
||||
name: 'ccweb',
|
||||
@@ -2279,8 +2307,8 @@ function buildCcwebMcpRuntimeConfig(session, options = {}) {
|
||||
type: 'stdio',
|
||||
description: 'ccweb 内置 MCP server,可用于跨会话协作。',
|
||||
config: {
|
||||
command: process.execPath,
|
||||
args: [CCWEB_MCP_SERVER_ARG],
|
||||
command: commandSpec.command,
|
||||
args: commandSpec.args,
|
||||
env,
|
||||
startup_timeout_sec: 10,
|
||||
tool_timeout_sec: 60,
|
||||
@@ -2340,6 +2368,7 @@ function listComposerMcpItems(options = {}) {
|
||||
server: 'ccweb',
|
||||
source: 'mcp:ccweb',
|
||||
itemType: 'tool',
|
||||
action: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2418,6 +2447,8 @@ function listComposerSuggestions(trigger, query, sessionId, agent, session = nul
|
||||
const skillItems = isCodexLikeAgent(agent) ? loadCodexSkills({ session }) : [];
|
||||
if (trigger === '/') {
|
||||
const mcpItems = filterComposerItems(listComposerMcpItems({ sessionId, session, agent }), query);
|
||||
const promptUserMcpItems = mcpItems.filter((item) => item.kind === 'mcp' && item.server === 'ccweb' && item.name === 'ccweb_prompt_user');
|
||||
const otherMcpItems = mcpItems.filter((item) => !(item.kind === 'mcp' && item.server === 'ccweb' && item.name === 'ccweb_prompt_user'));
|
||||
const commands = filterComposerItems(COMPOSER_COMMANDS.map((cmd) => ({
|
||||
kind: 'command',
|
||||
name: cmd.name,
|
||||
@@ -2425,7 +2456,7 @@ function listComposerSuggestions(trigger, query, sessionId, agent, session = nul
|
||||
description: cmd.description,
|
||||
insertion: cmd.insertion,
|
||||
})), query.replace(/^\//, ''));
|
||||
return mergeComposerSuggestionGroups(commands, mcpItems);
|
||||
return mergeComposerSuggestionGroups(commands, promptUserMcpItems, otherMcpItems);
|
||||
}
|
||||
if (trigger === '$') {
|
||||
const skills = filterComposerItems(skillItems, query);
|
||||
@@ -2850,6 +2881,344 @@ function getRuntimeSessionId(session) {
|
||||
return session.claudeSessionId || null;
|
||||
}
|
||||
|
||||
function mcpStatusObject(value) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
||||
}
|
||||
|
||||
function firstPresentValue(...values) {
|
||||
for (const value of values) {
|
||||
if (value === undefined || value === null) continue;
|
||||
if (typeof value === 'string' && !value.trim()) continue;
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function redactMcpStatusText(text) {
|
||||
return String(text || '')
|
||||
.replace(/\b(Bearer)\s+[A-Za-z0-9._~+/=-]+/gi, '$1 [redacted]')
|
||||
.replace(/\b((?:[A-Z0-9_]*_)?(?:TOKEN|API_KEY|SECRET|PASSWORD|AUTHORIZATION))\b\s*[:=]\s*["']?[^"',\s}]+/gi, '$1=[redacted]')
|
||||
.replace(/("(?:[^"]*(?:token|api[_-]?key|secret|password|authorization)[^"]*)"\s*:\s*)"[^"]*"/gi, '$1"[redacted]"');
|
||||
}
|
||||
|
||||
function safeMcpStatusString(value, maxChars = 500) {
|
||||
if (value === undefined || value === null) return '';
|
||||
let text = '';
|
||||
if (typeof value === 'string') {
|
||||
text = value;
|
||||
} else if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
text = String(value);
|
||||
} else if (mcpStatusObject(value)) {
|
||||
text = firstPresentValue(value.message, value.error, value.reason, value.detail);
|
||||
if (text === null) {
|
||||
try {
|
||||
text = JSON.stringify(value);
|
||||
} catch {
|
||||
text = String(value);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
text = String(value);
|
||||
}
|
||||
const redacted = redactMcpStatusText(text).trim();
|
||||
if (!redacted) return '';
|
||||
return redacted.length > maxChars ? `${redacted.slice(0, maxChars)}...` : redacted;
|
||||
}
|
||||
|
||||
function normalizeCodexAppMcpStartupStatus(value) {
|
||||
const raw = safeMcpStatusString(value, 80).toLowerCase();
|
||||
if (!raw) return 'unknown';
|
||||
if (/^(ready|running|ok|success|succeeded|started|available)$/.test(raw)) return 'ready';
|
||||
if (/^(starting|pending|loading|initializing|launching|connecting)$/.test(raw)) return 'starting';
|
||||
if (/^(failed|failure|error|errored|crashed)$/.test(raw)) return 'failed';
|
||||
if (/^(cancelled|canceled|disabled|stopped)$/.test(raw)) return 'cancelled';
|
||||
return raw;
|
||||
}
|
||||
|
||||
function codexAppMcpStatusKey(name) {
|
||||
return safeMcpStatusString(name || CODEX_APP_MCP_DEFAULT_SERVER, 120).toLowerCase() || CODEX_APP_MCP_DEFAULT_SERVER;
|
||||
}
|
||||
|
||||
function publicCodexAppMcpStatusRecord(record = {}) {
|
||||
const name = safeMcpStatusString(record.name || record.server || CODEX_APP_MCP_DEFAULT_SERVER, 120) || CODEX_APP_MCP_DEFAULT_SERVER;
|
||||
const status = normalizeCodexAppMcpStartupStatus(record.status || record.state || 'unknown');
|
||||
return {
|
||||
server: name,
|
||||
name,
|
||||
status,
|
||||
rawStatus: safeMcpStatusString(record.rawStatus || record.status || status, 80) || status,
|
||||
message: safeMcpStatusString(record.message || record.error || '', 500),
|
||||
threadId: safeMcpStatusString(record.threadId || '', 160) || null,
|
||||
updatedAt: safeMcpStatusString(record.updatedAt || new Date().toISOString(), 80),
|
||||
source: safeMcpStatusString(record.source || 'notification', 40) || 'notification',
|
||||
};
|
||||
}
|
||||
|
||||
function parseCodexAppMcpStartupStatus(params = {}) {
|
||||
const direct = mcpStatusObject(params) || {};
|
||||
const statusObject = mcpStatusObject(direct.status) || mcpStatusObject(direct.startupStatus) || mcpStatusObject(direct.serverStatus) || {};
|
||||
const serverObject = mcpStatusObject(direct.server) || mcpStatusObject(direct.mcpServer) || mcpStatusObject(statusObject.server) || {};
|
||||
const name = safeMcpStatusString(firstPresentValue(
|
||||
direct.name,
|
||||
direct.serverName,
|
||||
direct.mcpServerName,
|
||||
typeof direct.server === 'string' ? direct.server : null,
|
||||
typeof direct.mcpServer === 'string' ? direct.mcpServer : null,
|
||||
serverObject.name,
|
||||
serverObject.server,
|
||||
statusObject.name,
|
||||
statusObject.server,
|
||||
statusObject.serverName
|
||||
), 120);
|
||||
const rawStatus = firstPresentValue(
|
||||
typeof direct.status === 'string' ? direct.status : null,
|
||||
direct.state,
|
||||
direct.startupState,
|
||||
statusObject.status,
|
||||
statusObject.state,
|
||||
statusObject.startupState,
|
||||
direct.ready === true ? 'ready' : null,
|
||||
direct.ok === false ? 'failed' : null
|
||||
);
|
||||
const threadId = safeMcpStatusString(firstPresentValue(
|
||||
direct.threadId,
|
||||
direct.thread?.id,
|
||||
statusObject.threadId,
|
||||
statusObject.thread?.id
|
||||
), 160) || null;
|
||||
if (!name && rawStatus === null && !threadId) return null;
|
||||
const status = normalizeCodexAppMcpStartupStatus(rawStatus);
|
||||
return publicCodexAppMcpStatusRecord({
|
||||
name: name || CODEX_APP_MCP_DEFAULT_SERVER,
|
||||
status,
|
||||
rawStatus: rawStatus || status,
|
||||
message: firstPresentValue(
|
||||
direct.message,
|
||||
direct.error,
|
||||
direct.reason,
|
||||
direct.detail,
|
||||
statusObject.message,
|
||||
statusObject.error,
|
||||
statusObject.reason,
|
||||
statusObject.detail
|
||||
),
|
||||
threadId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
source: 'notification',
|
||||
});
|
||||
}
|
||||
|
||||
function ensureCodexAppMcpStartupState(session) {
|
||||
if (!session || typeof session !== 'object') return null;
|
||||
if (!mcpStatusObject(session.codexAppMcpStartupStatus)) {
|
||||
session.codexAppMcpStartupStatus = {};
|
||||
}
|
||||
const state = session.codexAppMcpStartupStatus;
|
||||
if (!mcpStatusObject(state.servers)) state.servers = {};
|
||||
return state;
|
||||
}
|
||||
|
||||
function findCodexAppMcpStatusRecord(state, serverName = CODEX_APP_MCP_DEFAULT_SERVER) {
|
||||
const servers = mcpStatusObject(state?.servers) || {};
|
||||
const key = codexAppMcpStatusKey(serverName);
|
||||
if (servers[key]) return publicCodexAppMcpStatusRecord(servers[key]);
|
||||
return Object.values(servers)
|
||||
.map((record) => publicCodexAppMcpStatusRecord(record))
|
||||
.find((record) => codexAppMcpStatusKey(record.name) === key) || null;
|
||||
}
|
||||
|
||||
function buildCodexAppMcpStatusSummary(session, options = {}) {
|
||||
const state = mcpStatusObject(session?.codexAppMcpStartupStatus) || {};
|
||||
const serversObject = mcpStatusObject(state.servers) || {};
|
||||
const servers = Object.values(serversObject).map((record) => publicCodexAppMcpStatusRecord(record));
|
||||
let current = findCodexAppMcpStatusRecord(state, options.serverName || CODEX_APP_MCP_DEFAULT_SERVER);
|
||||
const reloadRequestedAt = safeMcpStatusString(options.reloadRequestedAt || state.reloadRequestedAt || '', 80) || null;
|
||||
if (!current) {
|
||||
const threadId = safeMcpStatusString(state.threadId || getRuntimeSessionId(session) || '', 160) || null;
|
||||
current = publicCodexAppMcpStatusRecord({
|
||||
name: options.serverName || CODEX_APP_MCP_DEFAULT_SERVER,
|
||||
status: reloadRequestedAt ? 'pending' : 'unknown',
|
||||
rawStatus: reloadRequestedAt ? 'pending' : 'unknown',
|
||||
message: reloadRequestedAt ? '已请求重载,等待 app-server 上报启动状态' : '尚未收到 app-server 启动状态',
|
||||
threadId,
|
||||
updatedAt: reloadRequestedAt || new Date().toISOString(),
|
||||
source: reloadRequestedAt ? 'pending' : 'unknown',
|
||||
});
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
reloadRequestedAt,
|
||||
reloadRequestId: safeMcpStatusString(state.reloadRequestId || '', 80) || null,
|
||||
hasStartupStatus: current.source === 'notification',
|
||||
servers,
|
||||
};
|
||||
}
|
||||
|
||||
function markCodexAppMcpReloadPending(session, sessionId) {
|
||||
const requestedAt = new Date().toISOString();
|
||||
const threadId = getRuntimeSessionId(session) || null;
|
||||
const reloadRequestId = crypto.randomUUID();
|
||||
const state = ensureCodexAppMcpStartupState(session);
|
||||
if (!state) return { requestedAt, summary: null };
|
||||
state.reloadRequestedAt = requestedAt;
|
||||
state.reloadRequestId = reloadRequestId;
|
||||
state.updatedAt = requestedAt;
|
||||
state.threadId = threadId;
|
||||
state.servers[codexAppMcpStatusKey(CODEX_APP_MCP_DEFAULT_SERVER)] = publicCodexAppMcpStatusRecord({
|
||||
name: CODEX_APP_MCP_DEFAULT_SERVER,
|
||||
status: 'pending',
|
||||
rawStatus: 'pending',
|
||||
message: '已请求重载,等待 app-server 上报启动状态',
|
||||
threadId,
|
||||
updatedAt: requestedAt,
|
||||
source: 'pending',
|
||||
});
|
||||
pendingCodexAppMcpReloads.set(sessionId, {
|
||||
threadId,
|
||||
requestedAt,
|
||||
expiresAt: Date.now() + CODEX_APP_MCP_RELOAD_TRACK_MS,
|
||||
reloadRequestId,
|
||||
});
|
||||
saveSession(session);
|
||||
return {
|
||||
requestedAt,
|
||||
summary: buildCodexAppMcpStatusSummary(session, { reloadRequestedAt: requestedAt }),
|
||||
};
|
||||
}
|
||||
|
||||
function cleanupExpiredCodexAppMcpReloads() {
|
||||
const now = Date.now();
|
||||
for (const [sessionId, pending] of pendingCodexAppMcpReloads.entries()) {
|
||||
if ((pending.expiresAt || 0) <= now) pendingCodexAppMcpReloads.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
function isFinalCodexAppMcpStatus(status) {
|
||||
return status === 'ready' || status === 'failed' || status === 'cancelled';
|
||||
}
|
||||
|
||||
function isFreshCodexAppMcpSummary(summary, requestedAt) {
|
||||
if (!summary || summary.source !== 'notification') return false;
|
||||
if (!requestedAt) return true;
|
||||
const updated = Date.parse(summary.updatedAt || '');
|
||||
const requested = Date.parse(requestedAt || '');
|
||||
if (!Number.isFinite(updated) || !Number.isFinite(requested)) return true;
|
||||
return updated >= requested;
|
||||
}
|
||||
|
||||
function resolveCodexAppMcpStatusWaiters(sessionId, summary) {
|
||||
const waiters = codexAppMcpStatusWaiters.get(sessionId);
|
||||
if (!waiters || waiters.size === 0) return;
|
||||
for (const waiter of Array.from(waiters)) {
|
||||
if (!isFreshCodexAppMcpSummary(summary, waiter.requestedAt) || !isFinalCodexAppMcpStatus(summary.status)) continue;
|
||||
clearTimeout(waiter.timer);
|
||||
waiters.delete(waiter);
|
||||
waiter.resolve(summary);
|
||||
}
|
||||
if (waiters.size === 0) codexAppMcpStatusWaiters.delete(sessionId);
|
||||
}
|
||||
|
||||
function waitForCodexAppMcpStatusAfterReload(sessionId, requestedAt, timeoutMs = CODEX_APP_MCP_RELOAD_STATUS_WAIT_MS) {
|
||||
const current = buildCodexAppMcpStatusSummary(loadSession(sessionId), { reloadRequestedAt: requestedAt });
|
||||
if (isFreshCodexAppMcpSummary(current, requestedAt) && isFinalCodexAppMcpStatus(current.status)) {
|
||||
return Promise.resolve(current);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const waiter = {
|
||||
requestedAt,
|
||||
resolve,
|
||||
timer: setTimeout(() => {
|
||||
const waiters = codexAppMcpStatusWaiters.get(sessionId);
|
||||
if (waiters) {
|
||||
waiters.delete(waiter);
|
||||
if (waiters.size === 0) codexAppMcpStatusWaiters.delete(sessionId);
|
||||
}
|
||||
resolve(buildCodexAppMcpStatusSummary(loadSession(sessionId), { reloadRequestedAt: requestedAt }));
|
||||
}, timeoutMs),
|
||||
};
|
||||
let waiters = codexAppMcpStatusWaiters.get(sessionId);
|
||||
if (!waiters) {
|
||||
waiters = new Set();
|
||||
codexAppMcpStatusWaiters.set(sessionId, waiters);
|
||||
}
|
||||
waiters.add(waiter);
|
||||
});
|
||||
}
|
||||
|
||||
function updateCodexAppSessionMcpStatus(sessionId, statusRecord) {
|
||||
const normalizedId = sanitizeId(sessionId || '');
|
||||
if (!normalizedId) return null;
|
||||
const session = loadSession(normalizedId);
|
||||
if (!session || !isCodexAppSession(session)) return null;
|
||||
const state = ensureCodexAppMcpStartupState(session);
|
||||
if (!state) return null;
|
||||
const record = publicCodexAppMcpStatusRecord({
|
||||
...statusRecord,
|
||||
threadId: statusRecord.threadId || state.threadId || getRuntimeSessionId(session) || null,
|
||||
source: 'notification',
|
||||
});
|
||||
const key = codexAppMcpStatusKey(record.name);
|
||||
state.servers[key] = record;
|
||||
state.updatedAt = record.updatedAt;
|
||||
state.threadId = record.threadId || state.threadId || null;
|
||||
if (key === codexAppMcpStatusKey(CODEX_APP_MCP_DEFAULT_SERVER) && isFinalCodexAppMcpStatus(record.status)) {
|
||||
pendingCodexAppMcpReloads.delete(normalizedId);
|
||||
}
|
||||
saveSession(session);
|
||||
const summary = buildCodexAppMcpStatusSummary(session);
|
||||
resolveCodexAppMcpStatusWaiters(normalizedId, summary);
|
||||
sendSessionEventToViewers(normalizedId, {
|
||||
type: 'mcp_startup_status',
|
||||
sessionId: normalizedId,
|
||||
status: summary,
|
||||
mcpStatus: summary,
|
||||
});
|
||||
return summary;
|
||||
}
|
||||
|
||||
function markCodexAppMcpReloadFailed(sessionId, message) {
|
||||
return updateCodexAppSessionMcpStatus(sessionId, {
|
||||
name: CODEX_APP_MCP_DEFAULT_SERVER,
|
||||
status: 'failed',
|
||||
rawStatus: 'failed',
|
||||
message,
|
||||
updatedAt: new Date().toISOString(),
|
||||
source: 'notification',
|
||||
});
|
||||
}
|
||||
|
||||
function codexAppMcpStatusTargetSessionIds(statusRecord, routed) {
|
||||
cleanupExpiredCodexAppMcpReloads();
|
||||
const targetSessionIds = new Set();
|
||||
if (routed?.sessionId) targetSessionIds.add(routed.sessionId);
|
||||
for (const [sessionId, pending] of pendingCodexAppMcpReloads.entries()) {
|
||||
if (statusRecord.threadId && pending.threadId && statusRecord.threadId !== pending.threadId) continue;
|
||||
targetSessionIds.add(sessionId);
|
||||
}
|
||||
return targetSessionIds;
|
||||
}
|
||||
|
||||
function handleCodexAppMcpStartupStatusNotification(notification, routed) {
|
||||
if (notification?.method !== CODEX_APP_MCP_STARTUP_STATUS_METHOD) return false;
|
||||
const statusRecord = parseCodexAppMcpStartupStatus(notification.params || {});
|
||||
if (!statusRecord) {
|
||||
plog('WARN', 'codex_app_mcp_startup_status_unparsed', { method: notification.method });
|
||||
return true;
|
||||
}
|
||||
codexAppMcpStartupStatusByServer.set(codexAppMcpStatusKey(statusRecord.name), statusRecord);
|
||||
const targetSessionIds = codexAppMcpStatusTargetSessionIds(statusRecord, routed);
|
||||
for (const sessionId of targetSessionIds) {
|
||||
updateCodexAppSessionMcpStatus(sessionId, statusRecord);
|
||||
}
|
||||
plog('INFO', 'codex_app_mcp_startup_status_updated', {
|
||||
server: statusRecord.name,
|
||||
status: statusRecord.status,
|
||||
threadId: statusRecord.threadId,
|
||||
targetSessions: targetSessionIds.size,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleReloadMcpApi(req, res, rawSessionId) {
|
||||
const token = extractBearerToken(req);
|
||||
if (!token || !activeTokens.has(token)) {
|
||||
@@ -2874,6 +3243,7 @@ async function handleReloadMcpApi(req, res, rawSessionId) {
|
||||
});
|
||||
}
|
||||
|
||||
let reloadRequestedAt = null;
|
||||
try {
|
||||
const clientResult = getCodexAppClient();
|
||||
if (clientResult.error) {
|
||||
@@ -2886,13 +3256,17 @@ async function handleReloadMcpApi(req, res, rawSessionId) {
|
||||
|
||||
const client = clientResult.client;
|
||||
await client.start();
|
||||
const pendingMcp = markCodexAppMcpReloadPending(session, sessionId);
|
||||
reloadRequestedAt = pendingMcp.requestedAt;
|
||||
const result = typeof client.reloadMcpServers === 'function'
|
||||
? await client.reloadMcpServers()
|
||||
: await client.request('config/mcpServer/reload', {}, 30000);
|
||||
const mcpStatus = await waitForCodexAppMcpStatusAfterReload(sessionId, reloadRequestedAt);
|
||||
|
||||
plog('INFO', 'codex_app_mcp_reload_requested', {
|
||||
sessionId: sessionId.slice(0, 8),
|
||||
threadId: getRuntimeSessionId(session) || null,
|
||||
status: mcpStatus?.status || pendingMcp.summary?.status || 'pending',
|
||||
});
|
||||
|
||||
return jsonResponse(res, 200, {
|
||||
@@ -2900,15 +3274,21 @@ async function handleReloadMcpApi(req, res, rawSessionId) {
|
||||
sessionId,
|
||||
threadId: getRuntimeSessionId(session) || null,
|
||||
result: result || {},
|
||||
mcpStatus: mcpStatus || pendingMcp.summary || buildCodexAppMcpStatusSummary(loadSession(sessionId), { reloadRequestedAt }),
|
||||
});
|
||||
} catch (err) {
|
||||
const unsupported = err?.code === -32601 || /not found|unknown|unsupported|method/i.test(String(err?.message || ''));
|
||||
const message = unsupported
|
||||
? `当前 Codex app-server 不支持重载 MCP,请重启 Codex App。${err?.message ? `(${err.message})` : ''}`
|
||||
: `重载 MCP 失败: ${err?.message || err}`;
|
||||
const mcpStatus = reloadRequestedAt
|
||||
? markCodexAppMcpReloadFailed(sessionId, message)
|
||||
: buildCodexAppMcpStatusSummary(session);
|
||||
return jsonResponse(res, unsupported ? 501 : 500, {
|
||||
ok: false,
|
||||
code: unsupported ? 'codexapp_reload_mcp_unsupported' : 'codexapp_reload_mcp_failed',
|
||||
message: unsupported
|
||||
? `当前 Codex app-server 不支持重载 MCP,请重启 Codex App。${err?.message ? `(${err.message})` : ''}`
|
||||
: `重载 MCP 失败: ${err?.message || err}`,
|
||||
message,
|
||||
mcpStatus,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3938,6 +4318,18 @@ function findViewingSessionWs(sessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function sendSessionEventToViewers(sessionId, payload) {
|
||||
const normalizedId = sanitizeId(sessionId || '');
|
||||
if (!normalizedId) return 0;
|
||||
let sent = 0;
|
||||
for (const [client, viewedSessionId] of wsSessionMap.entries()) {
|
||||
if (viewedSessionId !== normalizedId || client?.readyState !== 1) continue;
|
||||
wsSend(client, payload);
|
||||
sent += 1;
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
function getInternalMcpRequestToken(req) {
|
||||
return String(req.headers['x-cc-web-mcp-token'] || '').trim() || extractBearerToken(req);
|
||||
}
|
||||
@@ -4003,6 +4395,228 @@ function listConversationSummaries(args = {}, sourceSessionId = '') {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMcpPromptText(value, maxChars) {
|
||||
if (value === null || value === undefined) return '';
|
||||
return truncateTextValue(String(value).trim(), maxChars, '...');
|
||||
}
|
||||
|
||||
function uniqueMcpPromptId(value, fallback, used) {
|
||||
const base = normalizeMcpPromptText(value, 80)
|
||||
.replace(/\s+/g, '_')
|
||||
.replace(/[^\w.-]/g, '')
|
||||
|| fallback;
|
||||
let id = base;
|
||||
let index = 2;
|
||||
while (used.has(id)) {
|
||||
id = `${base}_${index}`;
|
||||
index += 1;
|
||||
}
|
||||
used.add(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
function normalizeMcpPromptOption(rawOption, index, usedIds) {
|
||||
const raw = rawOption && typeof rawOption === 'object' ? rawOption : {};
|
||||
const label = normalizeMcpPromptText(raw.label ?? raw.title ?? raw.value ?? raw.answerText, 240);
|
||||
const answerText = normalizeMcpPromptText(raw.answerText ?? raw.answer ?? label, MCP_PROMPT_ANSWER_MAX_CHARS);
|
||||
if (!label && !answerText) return null;
|
||||
const id = uniqueMcpPromptId(raw.id ?? raw.value ?? label, `option_${index + 1}`, usedIds);
|
||||
return {
|
||||
id,
|
||||
label: label || answerText.slice(0, 80) || `选项 ${index + 1}`,
|
||||
description: normalizeMcpPromptText(raw.description ?? raw.desc, MCP_PROMPT_OPTION_MAX_CHARS),
|
||||
answerText,
|
||||
recommended: raw.recommended === true,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMcpPromptQuestion(rawQuestion, index, usedIds) {
|
||||
const raw = rawQuestion && typeof rawQuestion === 'object' ? rawQuestion : {};
|
||||
const title = normalizeMcpPromptText(raw.title ?? raw.header, MCP_PROMPT_TITLE_MAX_CHARS);
|
||||
const question = normalizeMcpPromptText(raw.question ?? raw.prompt ?? raw.text, MCP_PROMPT_QUESTION_MAX_CHARS);
|
||||
if (!title && !question) return null;
|
||||
const id = uniqueMcpPromptId(raw.id, `question_${index + 1}`, usedIds);
|
||||
const rawOptions = Array.isArray(raw.options) ? raw.options : [];
|
||||
const optionIds = new Set();
|
||||
const options = rawOptions
|
||||
.slice(0, MCP_PROMPT_OPTION_MAX_COUNT)
|
||||
.map((option, optionIndex) => normalizeMcpPromptOption(option, optionIndex, optionIds))
|
||||
.filter(Boolean);
|
||||
const mode = String(raw.selectionMode || raw.mode || '').trim();
|
||||
const selectionMode = mode === 'multi' || mode === 'multiple'
|
||||
? 'multi'
|
||||
: mode === 'none' || options.length === 0
|
||||
? 'none'
|
||||
: 'single';
|
||||
return {
|
||||
id,
|
||||
title: title || `问题 ${index + 1}`,
|
||||
question,
|
||||
required: raw.required !== false,
|
||||
selectionMode,
|
||||
answerPlaceholder: normalizeMcpPromptText(raw.answerPlaceholder ?? raw.placeholder, 240),
|
||||
defaultAnswer: normalizeMcpPromptText(raw.defaultAnswer ?? raw.answerText, MCP_PROMPT_ANSWER_MAX_CHARS),
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCcwebPromptUserArgs(args = {}) {
|
||||
const rawQuestions = Array.isArray(args.questions) ? args.questions : [];
|
||||
const usedQuestionIds = new Set();
|
||||
const questions = rawQuestions
|
||||
.slice(0, MCP_PROMPT_QUESTION_MAX_COUNT)
|
||||
.map((question, index) => normalizeMcpPromptQuestion(question, index, usedQuestionIds))
|
||||
.filter(Boolean);
|
||||
if (questions.length === 0) {
|
||||
return mcpToolError('missing_questions', 'ccweb_prompt_user 需要至少一个有效问题。');
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
title: normalizeMcpPromptText(args.title, MCP_PROMPT_TITLE_MAX_CHARS) || '需要用户确认',
|
||||
description: normalizeMcpPromptText(args.description ?? args.instructions, MCP_PROMPT_DESCRIPTION_MAX_CHARS),
|
||||
questions,
|
||||
};
|
||||
}
|
||||
|
||||
function createCcwebPromptUser(args = {}, sourceSessionId = '') {
|
||||
const sourceId = sanitizeId(sourceSessionId || '');
|
||||
if (!sourceId) return mcpToolError('missing_source_conversation', '缺少来源对话 ID。');
|
||||
const session = loadSession(sourceId);
|
||||
if (!session) return mcpToolError('source_not_found', '来源对话不存在。', { sourceConversationId: sourceId });
|
||||
|
||||
const normalized = normalizeCcwebPromptUserArgs(args);
|
||||
if (!normalized.ok) return normalized;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const promptId = crypto.randomUUID();
|
||||
const prompt = {
|
||||
id: promptId,
|
||||
status: 'pending',
|
||||
title: normalized.title,
|
||||
description: normalized.description,
|
||||
questions: normalized.questions,
|
||||
answers: {},
|
||||
createdAt: now,
|
||||
submittedAt: null,
|
||||
};
|
||||
const message = {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: now,
|
||||
ccwebPrompt: prompt,
|
||||
};
|
||||
|
||||
session.messages = Array.isArray(session.messages) ? session.messages : [];
|
||||
session.messages.push(message);
|
||||
session.updated = now;
|
||||
if (!findViewingSessionWs(sourceId)) session.hasUnread = true;
|
||||
saveSession(session);
|
||||
|
||||
sendSessionEventToViewers(sourceId, {
|
||||
type: 'session_message',
|
||||
sessionId: sourceId,
|
||||
message,
|
||||
});
|
||||
broadcastSessionList();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
promptId,
|
||||
status: 'rendered',
|
||||
sourceConversationId: sourceId,
|
||||
questionCount: normalized.questions.length,
|
||||
message: '已在 ccweb 前台展示问题,等待用户提交。',
|
||||
};
|
||||
}
|
||||
|
||||
function findCcwebPromptMessage(session, promptId) {
|
||||
const normalizedPromptId = String(promptId || '').trim();
|
||||
if (!normalizedPromptId || !Array.isArray(session?.messages)) return null;
|
||||
for (let index = session.messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = session.messages[index];
|
||||
if (message?.ccwebPrompt?.id === normalizedPromptId) {
|
||||
return { message, index, prompt: message.ccwebPrompt };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function removeCcwebPromptMessage(session, promptId) {
|
||||
const found = findCcwebPromptMessage(session, promptId);
|
||||
if (!found || !Array.isArray(session?.messages)) return null;
|
||||
session.messages.splice(found.index, 1);
|
||||
return found;
|
||||
}
|
||||
|
||||
function selectedPromptOptionIds(rawAnswer, question) {
|
||||
const raw = rawAnswer && typeof rawAnswer === 'object' ? rawAnswer : {};
|
||||
const source = Array.isArray(raw.selectedOptionIds)
|
||||
? raw.selectedOptionIds
|
||||
: Array.isArray(raw.selectedOptions)
|
||||
? raw.selectedOptions
|
||||
: Array.isArray(raw.optionIds)
|
||||
? raw.optionIds
|
||||
: raw.selectedOptionId || raw.selectedOption || raw.optionId
|
||||
? [raw.selectedOptionId || raw.selectedOption || raw.optionId]
|
||||
: [];
|
||||
const valid = new Set((question.options || []).map((option) => option.id));
|
||||
const ids = source.map((item) => String(item || '').trim()).filter((item) => valid.has(item));
|
||||
if (question.selectionMode !== 'multi') return ids.slice(0, 1);
|
||||
return [...new Set(ids)];
|
||||
}
|
||||
|
||||
function normalizeCcwebPromptUserAnswers(prompt, rawAnswers = {}) {
|
||||
const raw = rawAnswers && typeof rawAnswers === 'object' ? rawAnswers : {};
|
||||
const answers = {};
|
||||
const answerList = [];
|
||||
for (const question of prompt.questions || []) {
|
||||
const rawAnswer = raw[question.id] && typeof raw[question.id] === 'object' ? raw[question.id] : {};
|
||||
const selectedOptionIds = question.selectionMode === 'none' ? [] : selectedPromptOptionIds(rawAnswer, question);
|
||||
const selectedOptions = selectedOptionIds
|
||||
.map((id) => (question.options || []).find((option) => option.id === id))
|
||||
.filter(Boolean);
|
||||
let answerText = normalizeMcpPromptText(
|
||||
rawAnswer.answerText ?? rawAnswer.answer ?? rawAnswer.text ?? '',
|
||||
MCP_PROMPT_ANSWER_MAX_CHARS
|
||||
);
|
||||
if (!answerText && selectedOptions.length > 0) {
|
||||
answerText = selectedOptions.map((option) => option.answerText || option.label).filter(Boolean).join('\n');
|
||||
}
|
||||
if (!answerText && question.defaultAnswer) answerText = question.defaultAnswer;
|
||||
if (question.required && !answerText) {
|
||||
return mcpToolError('missing_answer', `问题「${question.title || question.id}」需要填写答案。`, {
|
||||
promptId: prompt.id,
|
||||
questionId: question.id,
|
||||
});
|
||||
}
|
||||
const answer = {
|
||||
questionId: question.id,
|
||||
selectedOptionIds,
|
||||
selectedOptionLabels: selectedOptions.map((option) => option.label),
|
||||
answerText,
|
||||
};
|
||||
answers[question.id] = answer;
|
||||
answerList.push({ question, answer });
|
||||
}
|
||||
return { ok: true, answers, answerList };
|
||||
}
|
||||
|
||||
function buildCcwebPromptUserResponseText(prompt, answerList) {
|
||||
const lines = ['我已回答 ccweb 提示的问题:'];
|
||||
if (prompt.title) {
|
||||
lines.push('', `表单:${prompt.title}`);
|
||||
}
|
||||
answerList.forEach(({ question, answer }, index) => {
|
||||
lines.push('', `${index + 1}. ${question.title || question.id}`);
|
||||
if (question.question) lines.push(`问题:${question.question}`);
|
||||
if (answer.selectedOptionLabels.length > 0) {
|
||||
lines.push(`选择:${answer.selectedOptionLabels.join(',')}`);
|
||||
}
|
||||
lines.push(`答案:${answer.answerText || '(空)'}`);
|
||||
});
|
||||
return truncateTextValue(lines.join('\n'), MCP_PROMPT_RESPONSE_MAX_CHARS);
|
||||
}
|
||||
|
||||
function createMcpConversation(args = {}, sourceSessionId = '', sourceHopCount = 0) {
|
||||
const sourceId = sanitizeId(sourceSessionId || '');
|
||||
const normalizedHopCount = Math.max(0, Number.parseInt(String(sourceHopCount || 0), 10) || 0);
|
||||
@@ -4455,6 +5069,8 @@ function callInternalMcpTool(tool, args, sourceSessionId, sourceHopCount) {
|
||||
return getPendingCrossConversationReply(args, sourceSessionId);
|
||||
case 'ccweb_request_reply':
|
||||
return requestCrossConversationReply(args, sourceSessionId, sourceHopCount);
|
||||
case 'ccweb_prompt_user':
|
||||
return createCcwebPromptUser(args, sourceSessionId);
|
||||
default:
|
||||
return mcpToolError('unknown_tool', `未知 MCP 工具: ${tool}`);
|
||||
}
|
||||
@@ -5499,6 +6115,12 @@ wss.on('connection', (ws, req) => {
|
||||
case 'codex_app_user_input_response':
|
||||
handleCodexAppUserInputResponse(ws, msg);
|
||||
break;
|
||||
case 'ccweb_prompt_user_response':
|
||||
handleCcwebPromptUserResponse(ws, msg);
|
||||
break;
|
||||
case 'ccweb_prompt_user_dismiss':
|
||||
handleCcwebPromptUserDismiss(ws, msg);
|
||||
break;
|
||||
case 'codex_app_approval_response':
|
||||
handleCodexAppApprovalResponse(ws, msg);
|
||||
break;
|
||||
@@ -7168,6 +7790,7 @@ const {
|
||||
loadCodexConfig,
|
||||
prepareCodexCustomRuntime,
|
||||
ccwebMcpServerArg: CCWEB_MCP_SERVER_ARG,
|
||||
ccwebMcpServerArgs: ccwebMcpServerCommandSpec().args,
|
||||
internalMcpUrl: `http://127.0.0.1:${PORT}/api/internal/mcp`,
|
||||
internalMcpToken: INTERNAL_MCP_TOKEN,
|
||||
nodePath: process.execPath,
|
||||
@@ -7559,6 +8182,7 @@ function findCodexAppRouteByRuntime(params = {}) {
|
||||
|
||||
function handleCodexAppNotification(notification) {
|
||||
const routed = findCodexAppRouteByRuntime(notification?.params || {});
|
||||
if (handleCodexAppMcpStartupStatusNotification(notification, routed)) return;
|
||||
if (!routed) {
|
||||
plog('INFO', 'codex_app_notification_unrouted', {
|
||||
method: notification?.method || '',
|
||||
@@ -7836,6 +8460,107 @@ function resolvePendingCodexAppUserInputsForSession(sessionId) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleCcwebPromptUserResponse(ws, msg = {}) {
|
||||
const sessionId = sanitizeId(msg.sessionId || wsSessionMap.get(ws) || '');
|
||||
const promptId = String(msg.promptId || '').trim();
|
||||
const fail = (code, message, extra = {}) => {
|
||||
wsSend(ws, { type: 'error', sessionId, code, message, ...extra });
|
||||
return { ok: false, code, message, ...extra };
|
||||
};
|
||||
|
||||
if (!sessionId) return fail('missing_session_id', '缺少会话 ID。');
|
||||
if (!promptId) return fail('missing_prompt_id', '缺少 promptId。');
|
||||
if (activeProcesses.has(sessionId) && !activeCodexAppTurns.has(sessionId)) {
|
||||
return fail('session_running', '当前会话正在运行,暂不能提交 ccweb 表单答案。');
|
||||
}
|
||||
|
||||
const session = loadSession(sessionId);
|
||||
if (!session) return fail('session_not_found', '会话不存在。');
|
||||
|
||||
const found = findCcwebPromptMessage(session, promptId);
|
||||
if (!found) return fail('prompt_not_found', 'ccweb 表单不存在或已过期。', { promptId });
|
||||
if (found.prompt.status && found.prompt.status !== 'pending') {
|
||||
return fail('prompt_already_completed', 'ccweb 表单已经提交。', { promptId, status: found.prompt.status });
|
||||
}
|
||||
|
||||
const normalized = normalizeCcwebPromptUserAnswers(found.prompt, msg.answers || {});
|
||||
if (!normalized.ok) return fail(normalized.code || 'bad_answers', normalized.message || '答案无效。', normalized);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const submittedPrompt = {
|
||||
...found.prompt,
|
||||
status: 'submitted',
|
||||
answers: normalized.answers,
|
||||
submittedAt: now,
|
||||
submitMessageId: crypto.randomUUID(),
|
||||
};
|
||||
removeCcwebPromptMessage(session, promptId);
|
||||
session.updated = now;
|
||||
saveSession(session);
|
||||
|
||||
sendSessionEventToViewers(sessionId, {
|
||||
type: 'ccweb_prompt_user_remove',
|
||||
sessionId,
|
||||
promptId,
|
||||
prompt: submittedPrompt,
|
||||
});
|
||||
|
||||
const responseText = buildCcwebPromptUserResponseText(submittedPrompt, normalized.answerList);
|
||||
const result = handleMessage(ws, {
|
||||
type: 'message',
|
||||
text: responseText,
|
||||
sessionId,
|
||||
mode: session.permissionMode || 'yolo',
|
||||
agent: getSessionAgent(session),
|
||||
clientMessageId: submittedPrompt.submitMessageId,
|
||||
}, {
|
||||
emitUserMessage: true,
|
||||
runtimeText: responseText,
|
||||
skipPendingCrossConversationFlush: true,
|
||||
});
|
||||
|
||||
if (!result?.ok) {
|
||||
return fail(result?.code || 'submit_failed', result?.message || '提交 ccweb 表单答案失败。', { promptId });
|
||||
}
|
||||
return { ok: true, sessionId, promptId };
|
||||
}
|
||||
|
||||
function handleCcwebPromptUserDismiss(ws, msg = {}) {
|
||||
const sessionId = sanitizeId(msg.sessionId || wsSessionMap.get(ws) || '');
|
||||
const promptId = String(msg.promptId || '').trim();
|
||||
const fail = (code, message, extra = {}) => {
|
||||
wsSend(ws, { type: 'error', sessionId, code, message, ...extra });
|
||||
return { ok: false, code, message, ...extra };
|
||||
};
|
||||
|
||||
if (!sessionId) return fail('missing_session_id', '缺少会话 ID。');
|
||||
if (!promptId) return fail('missing_prompt_id', '缺少 promptId。');
|
||||
|
||||
const session = loadSession(sessionId);
|
||||
if (!session) return fail('session_not_found', '会话不存在。');
|
||||
const found = removeCcwebPromptMessage(session, promptId);
|
||||
if (!found) return fail('prompt_not_found', 'ccweb 表单不存在或已被清理。', { promptId });
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const dismissedPrompt = {
|
||||
...found.prompt,
|
||||
status: 'dismissed',
|
||||
dismissedAt: now,
|
||||
};
|
||||
session.updated = now;
|
||||
saveSession(session);
|
||||
|
||||
sendSessionEventToViewers(sessionId, {
|
||||
type: 'ccweb_prompt_user_remove',
|
||||
sessionId,
|
||||
promptId,
|
||||
prompt: dismissedPrompt,
|
||||
reason: 'dismissed',
|
||||
});
|
||||
broadcastSessionList();
|
||||
return { ok: true, promptId, status: 'dismissed' };
|
||||
}
|
||||
|
||||
function codexAppRecord(value) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user