chore: rebuild CentOS7 release package

This commit is contained in:
shiyue
2026-07-05 17:28:39 +08:00
parent faf6adceb7
commit a3d83080cb
7 changed files with 516 additions and 78 deletions

301
server.js
View File

@@ -705,6 +705,7 @@ let MODEL_MAP = {
const VALID_AGENTS = new Set(['claude', 'codex', 'codexapp']);
const VALID_PERMISSION_MODES = new Set(['default', 'plan', 'yolo']);
const VALID_TITLE_SOURCES = new Set(['derived', 'llm', 'manual', 'system']);
const MCP_CONVERSATION_TITLE_MAX_CHARS = 120;
const MCP_PROMPT_TITLE_MAX_CHARS = 160;
const MCP_PROMPT_DESCRIPTION_MAX_CHARS = 2000;
@@ -718,7 +719,12 @@ const MCP_PROMPT_RESPONSE_MAX_CHARS = 20000;
// Codex 默认模型优先读取 ~/.codex/config.toml缺失时再回退到旧默认值。
const FALLBACK_CODEX_MODEL = 'gpt-5.4';
const CODEX_REASONING_LEVELS = new Set(['low', 'medium', 'high', 'xhigh']);
const CCWEB_TITLE_TOOL_INSTRUCTIONS = [
'Use the ccweb title tool sparingly. For a new chat, call "mcp__ccweb__ccweb_set_title" or "ccweb_set_title" once after the user\'s initial request is clear, and set a concise task title.',
'Do not rename the chat for routine progress, substeps, implementation details, or slightly better wording. Rename only when the user\'s primary objective changes substantially and the existing title would be misleading.',
].join('\n');
const CODEX_APP_COLLABORATION_INSTRUCTIONS = [
CCWEB_TITLE_TOOL_INSTRUCTIONS,
'Codex sub-agent spawning rules:',
'- Treat omitted fork_context the same as fork_context: true: a full-history fork inherits the parent agent type, model, and reasoning effort.',
'- If you call spawn_agent with fork_context omitted or true, do not set agent_type, model, or reasoning_effort.',
@@ -2889,6 +2895,12 @@ function normalizeSession(session) {
session.agent = normalizeAgent(session.agent);
if (!Object.prototype.hasOwnProperty.call(session, 'pinnedAt')) session.pinnedAt = null;
if (session.pinnedAt && Number.isNaN(new Date(session.pinnedAt).getTime())) session.pinnedAt = null;
const titleSource = String(session.titleSource || '').trim().toLowerCase();
if (titleSource && VALID_TITLE_SOURCES.has(titleSource)) {
session.titleSource = titleSource;
} else if (Object.prototype.hasOwnProperty.call(session, 'titleSource')) {
delete session.titleSource;
}
if (!Object.prototype.hasOwnProperty.call(session, 'claudeSessionId')) session.claudeSessionId = null;
if (!Object.prototype.hasOwnProperty.call(session, 'codexThreadId')) session.codexThreadId = null;
if (!Object.prototype.hasOwnProperty.call(session, 'codexAppThreadId')) session.codexAppThreadId = null;
@@ -3660,6 +3672,18 @@ function jsonStringFieldFromPreview(text, key) {
}
}
function jsonNestedStringFieldFromPreview(text, objectKey, key) {
const pattern = new RegExp(`"${objectKey}"\\s*:\\s*\\{[\\s\\S]{0,1200}?"${key}"\\s*:\\s*("(?:(?:\\\\.)|[^"\\\\])*"|null)`);
const match = pattern.exec(text);
if (!match) return null;
if (match[1] === 'null') return null;
try {
return JSON.parse(match[1]);
} catch {
return null;
}
}
function jsonBooleanFieldFromPreview(text, key) {
const pattern = new RegExp(`"${key}"\\s*:\\s*(true|false)`);
const match = pattern.exec(text);
@@ -3695,6 +3719,8 @@ function loadSessionMetaFromFile(filePath) {
updated: session.updated || session.created || stat.mtime.toISOString(),
created: session.created || null,
pinnedAt: session.pinnedAt || null,
titleSource: session.titleSource || null,
createdFromKind: sessionCreatedFromKind(session) || null,
hasUnread: !!session.hasUnread,
agent: getSessionAgent(session),
cwd,
@@ -3712,6 +3738,8 @@ function loadSessionMetaFromFile(filePath) {
updated: jsonStringFieldFromPreview(preview, 'updated') || jsonStringFieldFromPreview(preview, 'updatedAt') || stat.mtime.toISOString(),
created: jsonStringFieldFromPreview(preview, 'created') || null,
pinnedAt: jsonStringFieldFromPreview(preview, 'pinnedAt') || null,
titleSource: jsonStringFieldFromPreview(preview, 'titleSource') || null,
createdFromKind: jsonNestedStringFieldFromPreview(preview, 'createdFrom', 'kind') || null,
hasUnread: jsonBooleanFieldFromPreview(preview, 'hasUnread'),
agent: normalizeAgent(jsonStringFieldFromPreview(preview, 'agent')),
cwd,
@@ -4357,6 +4385,8 @@ function sendSessionList(ws) {
title: meta.title || 'Untitled',
updated: meta.updated,
pinnedAt: meta.pinnedAt || null,
titleSource: meta.titleSource || null,
createdFromKind: meta.createdFromKind || null,
hasUnread: !!meta.hasUnread,
agent: normalizeAgent(meta.agent),
cwd: meta.cwd || '',
@@ -5135,6 +5165,8 @@ function callInternalMcpTool(tool, args, sourceSessionId, sourceHopCount) {
switch (tool) {
case 'ccweb_list_conversations':
return listConversationSummaries(args, sanitizeId(sourceSessionId || ''));
case 'ccweb_set_title':
return setCurrentConversationTitle(args, sourceSessionId);
case 'ccweb_create_conversation':
return createMcpConversation(args, sourceSessionId, sourceHopCount);
case 'ccweb_send_message':
@@ -6268,7 +6300,7 @@ wss.on('connection', (ws, req) => {
switch (msg.type) {
case 'message':
if (msg.text && msg.text.trim().startsWith('/')) {
handleSlashCommand(ws, msg.text.trim(), msg.sessionId, msg.agent);
handleSlashCommand(ws, msg.text.trim(), msg.sessionId, msg.agent, msg);
} else {
handleMessage(ws, msg);
}
@@ -6817,24 +6849,33 @@ function cancelCodexAppGoalCommand(sessionId, ws = null) {
return true;
}
async function handleCodexAppGoalSlashCommand(ws, text, session) {
async function handleCodexAppGoalSlashCommand(ws, text, session, source = {}) {
const command = parseCodexGoalCommand(text);
if (!command) return;
const sendGoalResponse = (targetWs, payload, options = {}) => {
wsSend(targetWs, attachClientRequestId({
...payload,
...(options.preserveComposerDraft ? { preserveComposerDraft: true } : {}),
}, source));
};
const sendGoalSystemMessage = (targetWs, message, extra = {}, options = {}) => {
sendGoalResponse(targetWs, { type: 'system_message', message, ...extra }, options);
};
if (!session) {
wsSend(ws, { type: 'system_message', message: '请先进入一个 Codex App 会话后再执行 /goal。' });
sendGoalSystemMessage(ws, '请先进入一个 Codex App 会话后再执行 /goal。', {}, { preserveComposerDraft: true });
return;
}
if (!isCodexAppSession(session)) {
wsSend(ws, { type: 'system_message', message: '当前 /goal 仅支持 Codex App 会话。旧 Codex/Claude 会话没有 app-server goal RPC。' });
sendGoalSystemMessage(ws, '当前 /goal 仅支持 Codex App 会话。旧 Codex/Claude 会话没有 app-server goal RPC。', { sessionId: session.id }, { preserveComposerDraft: true });
return;
}
if (command.error) {
wsSend(ws, { type: 'system_message', sessionId: session.id, message: command.error });
sendGoalSystemMessage(ws, command.error, { sessionId: session.id }, { preserveComposerDraft: true });
return;
}
if (activeCodexAppGoalCommands.has(session.id)) {
wsSend(ws, { type: 'system_message', sessionId: session.id, message: 'Codex App Goal 正在同步,请稍候。' });
sendGoalSystemMessage(ws, 'Codex App Goal 正在同步,请稍候。', { sessionId: session.id }, { preserveComposerDraft: true });
return;
}
@@ -6846,7 +6887,7 @@ async function handleCodexAppGoalSlashCommand(ws, text, session) {
startedAt: new Date().toISOString(),
};
activeCodexAppGoalCommands.set(session.id, activeGoalCommand);
wsSend(ws, { type: 'system_message', sessionId: session.id, message: '正在同步 Goal...' });
sendGoalSystemMessage(ws, '正在同步 Goal...', { sessionId: session.id });
broadcastSessionList();
try {
@@ -6857,11 +6898,7 @@ async function handleCodexAppGoalSlashCommand(ws, text, session) {
if (!isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) return;
const goal = normalizeCodexThreadGoal(response?.goal, threadId);
const targetWs = activeGoalCommand.ws || ws;
wsSend(targetWs, {
type: 'system_message',
sessionId: session.id,
message: goal ? formatCodexGoalUsage(goal) : '用法: /goal <目标描述>',
});
sendGoalSystemMessage(targetWs, goal ? formatCodexGoalUsage(goal) : '用法: /goal <目标描述>', { sessionId: session.id });
sendSessionList(targetWs);
return;
}
@@ -6870,11 +6907,7 @@ async function handleCodexAppGoalSlashCommand(ws, text, session) {
const response = await client.request('thread/goal/clear', { threadId }, 30000);
if (!isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) return;
const targetWs = activeGoalCommand.ws || ws;
wsSend(targetWs, {
type: 'system_message',
sessionId: session.id,
message: response?.cleared ? 'Goal cleared' : 'No goal to clear',
});
sendGoalSystemMessage(targetWs, response?.cleared ? 'Goal cleared' : 'No goal to clear', { sessionId: session.id });
sendSessionList(targetWs);
return;
}
@@ -6887,18 +6920,14 @@ async function handleCodexAppGoalSlashCommand(ws, text, session) {
if (!isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) return;
const goal = normalizeCodexThreadGoal(response?.goal, threadId);
const targetWs = activeGoalCommand.ws || ws;
wsSend(targetWs, {
type: 'system_message',
sessionId: session.id,
message: goal ? formatCodexGoalUsage(goal) : 'Goal updated',
});
sendGoalSystemMessage(targetWs, goal ? formatCodexGoalUsage(goal) : 'Goal updated', { sessionId: session.id });
sendSessionList(targetWs);
} catch (err) {
const message = isCodexGoalUnsupportedError(err)
? '当前 Codex app-server 不支持 /goal请升级 Codex 或启用 goals feature。'
: `Goal failed: ${err?.message || err}`;
if (isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) {
wsSend(activeGoalCommand.ws || ws, { type: 'system_message', sessionId: session.id, message });
sendGoalSystemMessage(activeGoalCommand.ws || ws, message, { sessionId: session.id }, { preserveComposerDraft: true });
}
} finally {
finishCodexAppGoalCommand(session.id, activeGoalCommand);
@@ -6906,19 +6935,31 @@ async function handleCodexAppGoalSlashCommand(ws, text, session) {
}
// === Slash Command Handler ===
function handleSlashCommand(ws, text, sessionId, fallbackAgent) {
function handleSlashCommand(ws, text, sessionId, fallbackAgent, source = {}) {
const parts = text.split(/\s+/);
const cmd = parts[0].toLowerCase();
let session = sessionId ? loadSession(sessionId) : null;
const agent = session ? getSessionAgent(session) : normalizeAgent(fallbackAgent);
const codexLikeAgent = agent === 'codex' || agent === 'codexapp';
const responseSessionId = session?.id || sessionId || undefined;
const sendSlashResponse = (payload, options = {}) => {
const base = {
...(responseSessionId && payload.sessionId === undefined ? { sessionId: responseSessionId } : {}),
...payload,
...(options.preserveComposerDraft ? { preserveComposerDraft: true } : {}),
};
wsSend(ws, attachClientRequestId(base, source));
};
const sendSlashSystemMessage = (message, extra = {}, options = {}) => {
sendSlashResponse({ type: 'system_message', message, ...extra }, options);
};
if (session && isCodexAppSession(session) && activeCodexAppTurns.has(sessionId)) {
wsSend(ws, { type: 'system_message', message: 'Codex App 运行中暂不支持 slash 指令,请等待完成或点击停止。' });
sendSlashSystemMessage('Codex App 运行中暂不支持 slash 指令,请等待完成或点击停止。', {}, { preserveComposerDraft: true });
return;
}
if (session && isCodexAppSession(session) && activeCodexAppGoalCommands.has(sessionId)) {
wsSend(ws, { type: 'system_message', sessionId, message: 'Codex App Goal 正在同步,请稍候。' });
sendSlashSystemMessage('Codex App Goal 正在同步,请稍候。', { sessionId }, { preserveComposerDraft: true });
return;
}
@@ -6937,21 +6978,22 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) {
clearRuntimeSessionId(session);
session.updated = new Date().toISOString();
saveSession(session);
wsSend(ws, attachClientRequestId({
sendSlashResponse({
type: 'session_info',
sessionId: session.id,
messages: [],
title: session.title,
pinnedAt: session.pinnedAt || null,
...publicTitleMetadata(session),
mode: session.permissionMode || 'yolo',
model: sessionModelLabel(session),
agent: getSessionAgent(session),
cwd: session.cwd || null,
totalCost: session.totalCost || 0,
totalUsage: session.totalUsage || null,
}, { sessionId }));
});
}
wsSend(ws, { type: 'system_message', message: '会话已清除,上下文已重置。' });
sendSlashSystemMessage('会话已清除,上下文已重置。');
break;
}
@@ -6960,7 +7002,7 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) {
if (codexLikeAgent) {
if (!modelInput) {
const current = session?.model || getDefaultCodexModel();
wsSend(ws, { type: 'system_message', message: `当前 ${agent === 'codexapp' ? 'Codex App' : 'Codex'} 模型: ${current}\n用法: /model <模型名>` });
sendSlashSystemMessage(`当前 ${agent === 'codexapp' ? 'Codex App' : 'Codex'} 模型: ${current}\n用法: /model <模型名>`);
} else {
if (session) {
session.model = modelInput;
@@ -6968,15 +7010,15 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) {
saveSession(session);
}
wsSend(ws, { type: 'model_changed', model: modelInput });
wsSend(ws, { type: 'system_message', message: `${agent === 'codexapp' ? 'Codex App' : 'Codex'} 模型已切换为: ${modelInput}` });
sendSlashSystemMessage(`${agent === 'codexapp' ? 'Codex App' : 'Codex'} 模型已切换为: ${modelInput}`);
}
} else if (!modelInput) {
const current = session?.model ? modelShortName(session.model) || session.model : 'opus (默认)';
wsSend(ws, { type: 'system_message', message: `当前模型: ${current}\n可选: opus, sonnet, haiku` });
sendSlashSystemMessage(`当前模型: ${current}\n可选: opus, sonnet, haiku`);
} else {
const modelKey = modelInput.toLowerCase();
if (!MODEL_MAP[modelKey]) {
wsSend(ws, { type: 'system_message', message: `无效模型: ${modelInput}\n可选: opus, sonnet, haiku` });
sendSlashSystemMessage(`无效模型: ${modelInput}\n可选: opus, sonnet, haiku`, {}, { preserveComposerDraft: true });
} else {
const model = MODEL_MAP[modelKey];
if (session) {
@@ -6985,7 +7027,7 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) {
saveSession(session);
}
wsSend(ws, { type: 'model_changed', model: modelKey });
wsSend(ws, { type: 'system_message', message: `模型已切换为: ${modelKey}` });
sendSlashSystemMessage(`模型已切换为: ${modelKey}`);
}
}
break;
@@ -6994,96 +7036,104 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) {
case '/cost': {
if (codexLikeAgent) {
const usage = session?.totalUsage || { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 };
wsSend(ws, {
sendSlashResponse({
type: 'system_message',
message: `当前会话累计 Token: 输入 ${usage.inputTokens},缓存 ${usage.cachedInputTokens},输出 ${usage.outputTokens}`,
});
} else {
const cost = session?.totalCost || 0;
wsSend(ws, { type: 'system_message', message: `当前会话累计费用: $${cost.toFixed(4)}` });
sendSlashSystemMessage(`当前会话累计费用: $${cost.toFixed(4)}`);
}
break;
}
case '/goal': {
handleCodexAppGoalSlashCommand(ws, text, session).catch((err) => {
wsSend(ws, {
handleCodexAppGoalSlashCommand(ws, text, session, source).catch((err) => {
sendSlashResponse({
type: 'system_message',
sessionId: session?.id,
message: `Goal failed: ${err?.message || err}`,
});
}, { preserveComposerDraft: true });
});
break;
}
case '/compact': {
if (!sessionId || !session) {
wsSend(ws, { type: 'system_message', message: '当前没有可压缩的会话。请先进入一个已进行过对话的会话后再执行 /compact。' });
sendSlashSystemMessage('当前没有可压缩的会话。请先进入一个已进行过对话的会话后再执行 /compact。', {}, { preserveComposerDraft: true });
break;
}
if (isSessionRunning(sessionId)) {
wsSend(ws, { type: 'system_message', message: '当前会话正在处理中,请先等待完成或点击停止,再执行 /compact。' });
sendSlashSystemMessage('当前会话正在处理中,请先等待完成或点击停止,再执行 /compact。', {}, { preserveComposerDraft: true });
break;
}
if (isCodexAppSession(session)) {
wsSend(ws, { type: 'system_message', message: 'Codex App 模式暂不支持 /compact请切换到旧 Codex 模式或等待后续接入。' });
sendSlashSystemMessage('Codex App 模式暂不支持 /compact请切换到旧 Codex 模式或等待后续接入。', {}, { preserveComposerDraft: true });
break;
}
const runtimeId = getRuntimeSessionId(session);
if (!runtimeId) {
wsSend(ws, {
sendSlashResponse({
type: 'system_message',
message: agent === 'codex'
? '当前会话尚未建立 Codex 上下文,暂时无需压缩。'
: '当前会话尚未建立 Claude 上下文,暂时无需压缩。',
});
}, { preserveComposerDraft: true });
break;
}
wsSend(ws, { type: 'system_message', message: compactStartMessage(agent) });
sendSlashSystemMessage(compactStartMessage(agent));
pendingSlashCommands.set(session.id, { kind: 'compact' });
handleMessage(ws, { text: '/compact', sessionId: session.id, mode: session.permissionMode || 'yolo' }, { hideInHistory: true });
handleMessage(ws, {
text: '/compact',
sessionId: session.id,
mode: session.permissionMode || 'yolo',
requestId: source?.requestId,
preserveComposerDraft: true,
}, { hideInHistory: true });
break;
}
case '/init': {
if (!sessionId || !session) {
wsSend(ws, { type: 'system_message', message: '请先进入一个会话后再执行 /init。' });
sendSlashSystemMessage('请先进入一个会话后再执行 /init。', {}, { preserveComposerDraft: true });
break;
}
if (isSessionRunning(sessionId)) {
wsSend(ws, { type: 'system_message', message: '当前会话正在处理中,请先等待完成或点击停止。' });
sendSlashSystemMessage('当前会话正在处理中,请先等待完成或点击停止。', {}, { preserveComposerDraft: true });
break;
}
wsSend(ws, { type: 'system_message', message: initStartMessage(agent) });
sendSlashSystemMessage(initStartMessage(agent));
pendingSlashCommands.set(session.id, { kind: 'init' });
handleMessage(ws, {
text: codexLikeAgent ? buildCodexInitPrompt(session.cwd) : '/init',
sessionId: session.id,
mode: session.permissionMode || 'yolo',
requestId: source?.requestId,
preserveComposerDraft: true,
}, { hideInHistory: true });
break;
}
case '/mode': {
const modeInput = parts[1];
const VALID_MODES = ['default', 'plan', 'yolo'];
const MODE_DESC = { default: '默认(需权限审批,受限操作)', plan: 'Plan需确认计划后执行', yolo: 'YOLO跳过所有权限检查' };
if (!modeInput) {
const cur = session?.permissionMode || 'yolo';
wsSend(ws, { type: 'system_message', message: `当前模式: ${MODE_DESC[cur] || cur}\n可选: default, plan, yolo` });
} else if (VALID_MODES.includes(modeInput.toLowerCase())) {
const mode = modeInput.toLowerCase();
if (session) {
session.permissionMode = mode;
// Mode switching should not reset runtime context (Claude/Codex both resume).
session.updated = new Date().toISOString();
saveSession(session);
}
wsSend(ws, { type: 'system_message', message: `权限模式已切换为: ${MODE_DESC[mode]}` });
wsSend(ws, { type: 'mode_changed', mode });
} else {
wsSend(ws, { type: 'system_message', message: `无效模式: ${modeInput}\n可选: default, plan, yolo` });
case '/mode': {
const modeInput = parts[1];
const VALID_MODES = ['default', 'plan', 'yolo'];
const MODE_DESC = { default: '默认(需权限审批,受限操作)', plan: 'Plan需确认计划后执行', yolo: 'YOLO跳过所有权限检查' };
if (!modeInput) {
const cur = session?.permissionMode || 'yolo';
sendSlashSystemMessage(`当前模式: ${MODE_DESC[cur] || cur}\n可选: default, plan, yolo`);
} else if (VALID_MODES.includes(modeInput.toLowerCase())) {
const mode = modeInput.toLowerCase();
if (session) {
session.permissionMode = mode;
// Mode switching should not reset runtime context (Claude/Codex both resume).
session.updated = new Date().toISOString();
saveSession(session);
}
sendSlashSystemMessage(`权限模式已切换为: ${MODE_DESC[mode]}`);
wsSend(ws, { type: 'mode_changed', mode });
} else {
sendSlashSystemMessage(`无效模式: ${modeInput}\n可选: default, plan, yolo`, {}, { preserveComposerDraft: true });
}
break;
}
@@ -7094,7 +7144,7 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) {
'/mode [模式] — 查看/切换权限模式default, plan, yolo\n' +
'/cost — 查看当前会话累计统计\n' +
'/help — 显示本帮助';
wsSend(ws, {
sendSlashResponse({
type: 'system_message',
message: codexLikeAgent
? base + `\n/model [名称] — 查看/切换 ${agent === 'codexapp' ? 'Codex App' : 'Codex'} 模型(自由输入)${agent === 'codexapp' ? '\n/goal [目标] — 设置/查看持久目标;支持 pause/resume/clear' : ''}\n/init — 分析项目并生成/更新 AGENTS.md${agent === 'codexapp' ? '\n/compact — Codex App 模式暂不支持' : '\n/compact — 执行 Codex /compact 压缩上下文'}`
@@ -7104,7 +7154,7 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) {
}
default:
wsSend(ws, { type: 'system_message', message: `未知指令: ${cmd}\n输入 /help 查看可用指令` });
sendSlashSystemMessage(`未知指令: ${cmd}\n输入 /help 查看可用指令`, {}, { preserveComposerDraft: true });
}
}
@@ -7115,6 +7165,80 @@ function normalizeConversationTitle(title, fallback = 'New Chat') {
return truncateTextValue(normalized, MCP_CONVERSATION_TITLE_MAX_CHARS, '...');
}
function normalizeSetTitleInput(title) {
const normalized = String(title || '').replace(/\s+/g, ' ').trim();
return normalized ? truncateTextValue(normalized, MCP_CONVERSATION_TITLE_MAX_CHARS, '...') : '';
}
function sessionCreatedFromKind(session) {
return String(session?.createdFrom?.kind || '').trim().toLowerCase();
}
function isTitleLockedByUser(session) {
return String(session?.titleSource || '').trim().toLowerCase() === 'manual';
}
function publicTitleMetadata(session) {
return {
titleSource: String(session?.titleSource || '').trim().toLowerCase() || null,
createdFromKind: sessionCreatedFromKind(session) || null,
};
}
function setCurrentConversationTitle(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 previousTitle = session.title || 'Untitled';
const normalizedTitle = normalizeSetTitleInput(args.title);
if (!normalizedTitle) {
return mcpToolError('invalid_title', 'title 不能为空。', {
changed: false,
ignored: false,
title: previousTitle,
previousTitle,
lockedByUser: isTitleLockedByUser(session),
});
}
if (isTitleLockedByUser(session)) {
return {
ok: true,
changed: false,
ignored: true,
reason: 'ignored because title is manually locked',
title: previousTitle,
previousTitle,
lockedByUser: true,
};
}
const changed = previousTitle !== normalizedTitle;
session.title = normalizedTitle;
session.titleSource = 'llm';
saveSession(session);
sendSessionEventToViewers(session.id, {
type: 'session_renamed',
sessionId: session.id,
title: session.title,
...publicTitleMetadata(session),
});
broadcastSessionList();
return {
ok: true,
changed,
ignored: false,
reason: changed ? 'title_updated' : 'unchanged',
title: session.title,
previousTitle,
lockedByUser: false,
};
}
function resolveConversationAgent(rawAgent, fallbackAgent = 'claude', options = {}) {
const value = String(rawAgent || '').trim().toLowerCase();
if (value) {
@@ -7223,6 +7347,7 @@ function createPersistentConversationSession(args = {}, options = {}) {
const session = {
id: crypto.randomUUID(),
title: normalizeConversationTitle(args.title),
titleSource: 'system',
created: now,
updated: now,
pinnedAt: null,
@@ -7259,6 +7384,7 @@ function buildSessionInfoPayload(session) {
messages,
title: session.title,
pinnedAt: session.pinnedAt || null,
...publicTitleMetadata(session),
mode: session.permissionMode || 'yolo',
model: sessionModelLabel(session),
agent: getSessionAgent(session),
@@ -7474,6 +7600,7 @@ function handleLoadSession(ws, msg) {
messages: recentMessages,
title: refreshedSession.title,
pinnedAt: refreshedSession.pinnedAt || null,
...publicTitleMetadata(refreshedSession),
mode: refreshedSession.permissionMode || 'yolo',
model: sessionModelLabel(refreshedSession),
agent: getSessionAgent(refreshedSession),
@@ -7641,10 +7768,11 @@ function handleRenameSession(ws, sessionId, title) {
const session = loadSession(sessionId);
if (session) {
session.title = String(title).slice(0, 100);
session.titleSource = 'manual';
session.updated = new Date().toISOString();
saveSession(session);
sendSessionList(ws);
wsSend(ws, { type: 'session_renamed', sessionId, title: session.title });
wsSend(ws, { type: 'session_renamed', sessionId, title: session.title, ...publicTitleMetadata(session) });
}
}
@@ -7791,7 +7919,13 @@ function handleMessage(ws, msg, options = {}) {
const { text, sessionId, mode } = msg;
const { hideInHistory = false } = options;
const fail = (code, message) => {
wsSend(ws, { type: 'error', code, message });
wsSend(ws, attachClientRequestId({
type: 'error',
code,
message,
...(sessionId ? { sessionId } : {}),
...(msg.preserveComposerDraft ? { preserveComposerDraft: true } : {}),
}, msg));
return { ok: false, code, message };
};
const textValue = typeof text === 'string' ? text : '';
@@ -7852,6 +7986,7 @@ function handleMessage(ws, msg, options = {}) {
session = {
id,
title: derivedTitle,
titleSource: 'derived',
created: new Date().toISOString(),
updated: new Date().toISOString(),
pinnedAt: null,
@@ -7890,6 +8025,7 @@ function handleMessage(ws, msg, options = {}) {
if (session.title === 'New Chat' || session.title === 'Untitled') {
session.title = derivedTitle;
session.titleSource = 'derived';
}
let persistedUserMessage = null;
@@ -8132,6 +8268,7 @@ const {
saveSession,
setRuntimeSessionId,
getRuntimeSessionId,
titleToolInstructions: CCWEB_TITLE_TOOL_INSTRUCTIONS,
});
const codexAppRuntime = createCodexAppRuntime({
@@ -8575,6 +8712,23 @@ function codexAppCommunicationDynamicTools() {
additionalProperties: false,
},
},
{
name: 'ccweb_set_title',
namespace: 'ccweb',
description: '设置当前 cc-web 对话标题。只作用于当前来源对话;用户手动重命名后会成功返回但忽略,不再覆盖用户标题。',
inputSchema: {
type: 'object',
required: ['title'],
properties: {
title: {
type: 'string',
maxLength: MCP_CONVERSATION_TITLE_MAX_CHARS,
description: '新的当前对话标题。应简短概括用户主要目标。',
},
},
additionalProperties: false,
},
},
{
name: 'ccweb_send_message',
namespace: 'ccweb',
@@ -8700,6 +8854,7 @@ function handleCodexAppDynamicToolCall(routed, params = {}) {
if (namespace && namespace !== 'ccweb') return null;
if (
tool !== 'ccweb_list_conversations' &&
tool !== 'ccweb_set_title' &&
tool !== 'ccweb_create_conversation' &&
tool !== 'ccweb_send_message' &&
tool !== 'ccweb_list_pending_replies' &&
@@ -10058,6 +10213,7 @@ function handleImportNativeSession(ws, msg) {
messages: transportMessages,
title: session.title,
pinnedAt: session.pinnedAt || null,
...publicTitleMetadata(session),
mode: session.permissionMode,
model: sessionModelLabel(session),
agent: getSessionAgent(session),
@@ -10230,6 +10386,7 @@ function handleImportCodexSession(ws, msg) {
messages: transportMessages,
title: session.title,
pinnedAt: session.pinnedAt || null,
...publicTitleMetadata(session),
mode: session.permissionMode,
model: sessionModelLabel(session),
agent: getSessionAgent(session),