chore: rebuild CentOS7 release package

This commit is contained in:
shiyue
2026-06-27 19:47:52 +08:00
parent 911dd84c35
commit cd37ecf10b
14 changed files with 3128 additions and 653 deletions

737
server.js
View File

@@ -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 : {};
}