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

View File

@@ -21,6 +21,7 @@ function createAgentRuntime(deps) {
saveSession,
setRuntimeSessionId,
getRuntimeSessionId,
titleToolInstructions,
} = deps;
function readRuntimePositiveIntEnv(name, fallback, options = {}) {
@@ -179,6 +180,9 @@ function createAgentRuntime(deps) {
const ccwebMcpEnv = createCcwebMcpEnv(session, options);
appendCcwebMcpConfig(args, ccwebMcpEnv);
appendProjectMcpConfigs(args, options.projectMcpConfigs);
if (titleToolInstructions) {
args.push('-c', `developer_instructions=${tomlString(titleToolInstructions)}`);
}
const permMode = session.permissionMode || 'yolo';
// `-s/--sandbox` is an option for `codex exec`, but not for `codex exec resume`.

View File

@@ -36,6 +36,22 @@ const TOOLS = [
additionalProperties: false,
},
},
{
name: 'ccweb_set_title',
description: '设置当前 ccweb 对话标题。只作用于当前来源对话;用户手动重命名后会成功返回但忽略,不再覆盖用户标题。',
inputSchema: {
type: 'object',
properties: {
title: {
type: 'string',
maxLength: 120,
description: '新的当前对话标题。应简短概括用户主要目标。',
},
},
required: ['title'],
additionalProperties: false,
},
},
{
name: 'ccweb_create_conversation',
description: '创建一个新的 ccweb 持久对话。Agent 固定继承来源对话,不作为参数指定;只用于需要在会话列表中长期追踪、后续可继续对话的工作流;一次性并行研究应优先使用子代能力。',

View File

@@ -78,6 +78,7 @@
const SESSION_LOAD_OVERLAY_TIMEOUT_MS = 12_000;
const SESSION_LOAD_REQUEST_TIMEOUT_MS = 45_000;
const SESSION_RESUME_FALLBACK_MS = 1_500;
const PENDING_SLASH_DRAFT_LIMIT = 30;
const MODEL_OPTIONS = [
{ value: 'opus', label: 'Opus', desc: '最强大1M 上下文' },
@@ -237,6 +238,7 @@
const pendingNotesByTarget = new Map();
const queuedMessagesByTarget = new Map();
const userMessageIndex = new Map();
const pendingSlashDraftsByRequestId = new Map();
const expandedOldSessionGroups = new Set();
document.documentElement.dataset.dividerTime = showAgentDividerTime ? 'show' : 'hide';
@@ -1334,6 +1336,37 @@
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
function rememberPendingSlashDraft(requestId, text, sessionId, agent) {
const id = String(requestId || '').trim();
if (!id) return;
while (pendingSlashDraftsByRequestId.size >= PENDING_SLASH_DRAFT_LIMIT) {
const oldest = pendingSlashDraftsByRequestId.keys().next().value;
if (!oldest) break;
pendingSlashDraftsByRequestId.delete(oldest);
}
pendingSlashDraftsByRequestId.set(id, {
text,
sessionId: sessionId || null,
agent: normalizeAgent(agent),
createdAt: Date.now(),
});
}
function applyPendingSlashDraftResponse(msg) {
const requestId = String(msg?.requestId || '').trim();
if (!requestId) return false;
const draft = pendingSlashDraftsByRequestId.get(requestId);
if (!draft) return false;
pendingSlashDraftsByRequestId.delete(requestId);
if (!msg.preserveComposerDraft) return false;
if (draft.sessionId && currentSessionId && draft.sessionId !== currentSessionId) return false;
if (normalizeAgent(draft.agent) !== normalizeAgent(currentAgent)) return false;
if (msgInput.value !== '' || pendingAttachments.length > 0) return false;
msgInput.value = draft.text;
autoResize();
return true;
}
function clearUserMessageIndex() {
userMessageIndex.clear();
}
@@ -1756,6 +1789,8 @@
model: payload.model || '',
agent: normalizeAgent(payload.agent),
pinnedAt: payload.pinnedAt || null,
titleSource: payload.titleSource || null,
createdFromKind: payload.createdFromKind || null,
hasUnread: !!payload.hasUnread,
cwd: payload.cwd || null,
projectName: payload.projectName || '',
@@ -1860,6 +1895,8 @@
agent: normalizeAgent(snapshot.agent),
updated: snapshot.updated || new Date().toISOString(),
pinnedAt: snapshot.pinnedAt || null,
titleSource: snapshot.titleSource || null,
createdFromKind: snapshot.createdFromKind || null,
hasUnread: !!snapshot.hasUnread,
isRunning: !!snapshot.isRunning,
waitingOnChildren: !!snapshot.waitingOnChildren,
@@ -1880,6 +1917,8 @@
cwd: nextMeta.cwd || session.cwd || '',
projectName: nextMeta.cwd ? getPathLeaf(nextMeta.cwd) : session.projectName || nextMeta.projectName || '',
title: nextMeta.title || session.title,
titleSource: nextMeta.titleSource || session.titleSource || null,
createdFromKind: nextMeta.createdFromKind || session.createdFromKind || null,
};
});
if (!found) {
@@ -2496,12 +2535,25 @@
function updateCcwebPromptAnswerFromSelection(questionEl, question) {
const textarea = questionEl.querySelector('.ccweb-prompt-answer');
if (!textarea) return;
const previousSelectionText = textarea.dataset.ccwebPromptSelectionText || '';
const selectedIds = Array.from(questionEl.querySelectorAll('.ccweb-prompt-option.is-selected'))
.map((button) => button.dataset.optionId || '')
.filter(Boolean);
const selectedOptions = (question.options || []).filter((option) => selectedIds.includes(option.id));
const answerText = selectedOptions.map((option) => option.answerText || option.label || '').filter(Boolean).join('\n');
if (answerText) textarea.value = answerText;
if (answerText) {
textarea.value = answerText;
textarea.dataset.ccwebPromptSelectionText = answerText;
return;
}
if (previousSelectionText && textarea.value === previousSelectionText) textarea.value = '';
delete textarea.dataset.ccwebPromptSelectionText;
}
function syncCcwebPromptOptionState(questionEl) {
questionEl.querySelectorAll('.ccweb-prompt-option').forEach((button) => {
button.setAttribute('aria-pressed', button.classList.contains('is-selected') ? 'true' : 'false');
});
}
function selectCcwebPromptOption(questionEl, question, optionId) {
@@ -2511,10 +2563,20 @@
if (button.dataset.optionId === optionId) button.classList.toggle('is-selected');
});
} else {
const shouldDeselect = buttons.some((button) => button.dataset.optionId === optionId && button.classList.contains('is-selected'));
buttons.forEach((button) => {
button.classList.toggle('is-selected', button.dataset.optionId === optionId);
button.classList.toggle('is-selected', !shouldDeselect && button.dataset.optionId === optionId);
});
}
syncCcwebPromptOptionState(questionEl);
updateCcwebPromptAnswerFromSelection(questionEl, question);
}
function clearCcwebPromptSelection(questionEl, question) {
questionEl.querySelectorAll('.ccweb-prompt-option.is-selected').forEach((button) => {
button.classList.remove('is-selected');
});
syncCcwebPromptOptionState(questionEl);
updateCcwebPromptAnswerFromSelection(questionEl, question);
}
@@ -2568,6 +2630,7 @@
button.type = 'button';
button.className = 'ccweb-prompt-option';
button.dataset.optionId = option.id || '';
button.setAttribute('aria-pressed', 'false');
const label = document.createElement('span');
label.className = 'ccweb-prompt-option-label';
label.textContent = option.label || option.id || '选项';
@@ -2587,6 +2650,12 @@
button.addEventListener('click', () => selectCcwebPromptOption(questionEl, question, option.id));
optionList.appendChild(button);
});
const clearChoice = document.createElement('button');
clearChoice.type = 'button';
clearChoice.className = 'ccweb-prompt-clear-choice';
clearChoice.textContent = '不选择';
clearChoice.addEventListener('click', () => clearCcwebPromptSelection(questionEl, question));
optionList.appendChild(clearChoice);
questionEl.appendChild(optionList);
}
@@ -2595,6 +2664,9 @@
answer.rows = 4;
answer.placeholder = question.answerPlaceholder || '填写你的答案...';
answer.value = question.defaultAnswer || '';
answer.addEventListener('input', () => {
delete answer.dataset.ccwebPromptSelectionText;
});
questionEl.appendChild(answer);
const recommended = ccwebPromptRecommendedOption(question);
@@ -3904,10 +3976,11 @@
function createSessionListItem(session) {
const item = document.createElement('div');
const isPinned = !!session.pinnedAt;
const isLlmCreated = String(session.createdFromKind || '').toLowerCase() === 'mcp';
const waitingOnChildren = !!session.waitingOnChildren;
const readyReplyCount = Number(session.readyReplyCount || 0);
const waitingLabel = readyReplyCount > 0 ? `子对话已返回 ${readyReplyCount}` : `等待子对话 ${Number(session.pendingReplyCount || 0) || ''}`.trim();
item.className = `session-item${session.id === currentSessionId ? ' active' : ''}${isPinned ? ' pinned' : ''}${waitingOnChildren ? ' waiting-children' : ''}`;
item.className = `session-item${session.id === currentSessionId ? ' active' : ''}${isPinned ? ' pinned' : ''}${isLlmCreated ? ' llm-created' : ''}${waitingOnChildren ? ' waiting-children' : ''}`;
item.dataset.id = session.id;
const sessionCwd = getSessionEffectiveCwd(session);
if (sessionCwd) item.title = sessionCwd;
@@ -5239,8 +5312,17 @@
break;
case 'session_renamed':
sessions = sessions.map((session) => session.id === msg.sessionId ? { ...session, title: msg.title } : session);
updateCachedSession(msg.sessionId, (snapshot) => { snapshot.title = msg.title; });
sessions = sessions.map((session) => session.id === msg.sessionId ? {
...session,
title: msg.title,
...(msg.titleSource !== undefined ? { titleSource: msg.titleSource || null } : {}),
...(msg.createdFromKind !== undefined ? { createdFromKind: msg.createdFromKind || null } : {}),
} : session);
updateCachedSession(msg.sessionId, (snapshot) => {
snapshot.title = msg.title;
if (msg.titleSource !== undefined) snapshot.titleSource = msg.titleSource || null;
if (msg.createdFromKind !== undefined) snapshot.createdFromKind = msg.createdFromKind || null;
});
if (msg.sessionId === currentSessionId) {
chatTitle.textContent = msg.title;
}
@@ -5348,6 +5430,7 @@
case 'system_message':
if (!isCurrentSessionEvent(msg)) break;
applyPendingSlashDraftResponse(msg);
appendSystemMessage(msg.message, {
tone: msg.tone,
transient: msg.transient,
@@ -5512,6 +5595,7 @@
break;
}
if (pendingNewSessionRequest) pendingNewSessionRequest = null;
applyPendingSlashDraftResponse(msg);
appendError(msg.message, {
transient: msg.transient,
autoDismissMs: msg.autoDismissMs,
@@ -8328,7 +8412,9 @@
autoResize();
return;
}
send({ type: 'message', text, sessionId: currentSessionId, mode: currentMode, agent: currentAgent });
const requestId = createLocalId('slash');
rememberPendingSlashDraft(requestId, text, currentSessionId, currentAgent);
send({ type: 'message', text, sessionId: currentSessionId, mode: currentMode, agent: currentAgent, requestId });
msgInput.value = '';
autoResize();
return;

View File

@@ -1318,6 +1318,18 @@ body.session-loading-active {
border-radius: 999px;
background: var(--accent);
}
.session-item.llm-created:not(.pinned)::before {
content: '';
position: absolute;
left: 4px;
top: 7px;
width: 9px;
height: 9px;
border-top: 2px solid var(--accent);
border-left: 2px solid var(--accent);
border-radius: 4px 0 0 0;
opacity: 0.88;
}
.session-item-main {
flex: 1;
min-width: 0;
@@ -5139,6 +5151,25 @@ html[data-theme='coolvibe'] .settings-back:hover {
font-size: 10px;
font-weight: 900;
}
.ccweb-prompt-clear-choice {
grid-column: 1 / -1;
justify-self: start;
border: 1px solid rgba(221, 208, 192, 0.9);
border-radius: 7px;
background: rgba(255, 255, 255, 0.56);
color: var(--text-muted);
font: inherit;
font-size: 12px;
line-height: 1.25;
padding: 4px 8px;
cursor: pointer;
transition: border-color 0.16s ease, color 0.16s ease, background 0.16s ease;
}
.ccweb-prompt-clear-choice:hover {
border-color: var(--accent);
background: rgba(255, 249, 242, 0.96);
color: var(--accent);
}
.ccweb-prompt-option-label {
min-width: 0;
overflow-wrap: anywhere;
@@ -6372,7 +6403,14 @@ html[data-theme='coolvibe'] .settings-back:hover {
border-color: var(--text-muted);
}
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-clear-choice {
background: rgba(0, 0, 0, 0.16);
border-color: var(--theme-card-hover-border);
color: var(--text-muted);
}
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-option:hover,
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-clear-choice:hover,
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-tab-nav-btn:hover,
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-secondary:hover,
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .pending-ccweb-prompt-action:hover {

View File

@@ -11,6 +11,7 @@ const REPO_DIR = path.resolve(__dirname, '..');
const SERVER_PATH = path.join(REPO_DIR, 'server.js');
const PUBLIC_APP_PATH = path.join(REPO_DIR, 'public', 'app.js');
const PUBLIC_INDEX_PATH = path.join(REPO_DIR, 'public', 'index.html');
const PUBLIC_STYLE_PATH = path.join(REPO_DIR, 'public', 'style.css');
const MOCK_CLAUDE = path.join(REPO_DIR, 'scripts', 'mock-claude.js');
const MOCK_CODEX = path.join(REPO_DIR, 'scripts', 'mock-codex.js');
const MOCK_CODEX_APP_SERVER = path.join(REPO_DIR, 'scripts', 'mock-codex-app-server.js');
@@ -563,6 +564,34 @@ function assertFrontendComposerMcpContract() {
assert(source.includes("className = 'msg-mentions'"), 'Frontend should render a dedicated mention strip container');
}
function assertFrontendSlashDraftPreservationContract() {
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
assert(source.includes('const pendingSlashDraftsByRequestId = new Map();'), 'Frontend should keep request-scoped slash drafts');
assert(source.includes('function rememberPendingSlashDraft(requestId, text, sessionId, agent)'), 'Frontend should define slash draft capture');
assert(source.includes('function applyPendingSlashDraftResponse(msg)'), 'Frontend should define slash draft response handling');
assert(source.includes('if (!msg.preserveComposerDraft) return false;'), 'Frontend should restore slash drafts only for explicit failure responses');
assert(source.includes("if (msgInput.value !== '' || pendingAttachments.length > 0) return false;"), 'Frontend should not overwrite user input typed after a slash request');
assert(source.includes('rememberPendingSlashDraft(requestId, text, currentSessionId, currentAgent);'), 'Frontend should remember slash draft before clearing the composer');
assert(source.includes("send({ type: 'message', text, sessionId: currentSessionId, mode: currentMode, agent: currentAgent, requestId });"), 'Frontend slash sends should carry requestId');
assert((source.match(/applyPendingSlashDraftResponse\(msg\);/g) || []).length >= 2, 'Frontend should handle slash draft restoration on system_message and error');
const sendStart = source.indexOf('function sendMessage()');
const slashStart = source.indexOf("if (text.startsWith('/'))", sendStart);
const rememberStart = source.indexOf('rememberPendingSlashDraft(requestId, text, currentSessionId, currentAgent);', slashStart);
const modelPickerStart = source.indexOf("if (text === '/model' || text === '/model ')", slashStart);
const modePickerStart = source.indexOf("if (text === '/mode' || text === '/mode ')", slashStart);
assert(slashStart >= 0 && rememberStart > slashStart, 'Frontend should keep slash send branch explicit');
assert(modelPickerStart > slashStart && modelPickerStart < rememberStart, 'Frontend /model picker should stay local and clear normally');
assert(modePickerStart > slashStart && modePickerStart < rememberStart, 'Frontend /mode picker should stay local and clear normally');
assert(serverSource.includes('handleSlashCommand(ws, msg.text.trim(), msg.sessionId, msg.agent, msg);'), 'Server should pass client request metadata into slash handler');
assert(serverSource.includes('function handleSlashCommand(ws, text, sessionId, fallbackAgent, source = {})'), 'Server slash handler should accept request metadata');
assert(serverSource.includes('wsSend(ws, attachClientRequestId(base, source));'), 'Server slash responses should echo requestId');
assert(serverSource.includes('preserveComposerDraft: true'), 'Server should explicitly mark slash failures as draft-preserving');
assert(serverSource.includes('wsSend(ws, attachClientRequestId({') && serverSource.includes('...(msg.preserveComposerDraft ? { preserveComposerDraft: true } : {})'), 'Server runtime errors should echo requestId and draft-preservation metadata');
}
function assertMockCodexAppPromptUserNotTextTriggered() {
const source = fs.readFileSync(MOCK_CODEX_APP_SERVER, 'utf8');
assert(!source.includes('codexapp runtime prompt mcp'), 'Mock Codex App should not expose a text-triggered ccweb_prompt_user path');
@@ -589,6 +618,10 @@ function assertFrontendCcwebPromptContract() {
assert(source.includes('card.dataset.viewMode = normalized'), 'Frontend should switch ccweb prompt card view mode');
assert(source.includes('m.ccwebPrompt'), 'Message rebuild should render persisted ccweb prompt messages');
assert(source.includes("className = 'ccweb-prompt-answer'"), 'Each ccweb prompt question should expose an editable answer textarea');
assert(source.includes('function clearCcwebPromptSelection(questionEl, question)'), 'ccweb prompt options should expose an explicit no-selection action');
assert(source.includes("clearChoice.textContent = '不选择';"), 'ccweb prompt should render a visible no-selection control');
assert(source.includes("const shouldDeselect = buttons.some"), 'Single-select ccweb prompt options should allow clicking the selected option to deselect');
assert(source.includes("textarea.dataset.ccwebPromptSelectionText"), 'ccweb prompt should clear only option-generated answer text when selections are removed');
}
function assertFrontendMarkdownLinkContract() {
@@ -614,6 +647,21 @@ function assertFrontendMcpReloadContract() {
assert(source.includes('MCP 启动失败'), 'Frontend should expose a failed startup toast');
}
function assertSetTitleMcpContract() {
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const styleSource = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8');
assert(serverSource.includes("case 'ccweb_set_title':"), 'Server should route ccweb_set_title through internal MCP');
assert(serverSource.includes('function setCurrentConversationTitle'), 'Server should implement current-conversation title setting');
assert(serverSource.includes('ignored because title is manually locked'), 'ccweb_set_title should report manual title locks as ignored success');
assert(serverSource.includes("titleSource = 'manual'"), 'Manual UI rename should mark titleSource as manual');
assert(serverSource.includes('CCWEB_TITLE_TOOL_INSTRUCTIONS'), 'Server should define model guidance for the title tool');
assert(frontendSource.includes("createdFromKind || ''") && frontendSource.includes("' llm-created'"), 'Frontend should mark MCP-created sessions with llm-created class');
assert(frontendSource.includes('snapshot.titleSource') && frontendSource.includes('snapshot.createdFromKind'), 'Frontend should preserve title metadata in session snapshots');
assert(styleSource.includes('.session-item.llm-created:not(.pinned)::before'), 'LLM-created marker should be hidden when the session is pinned');
}
function assertSessionSwitchResilienceContract() {
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
@@ -713,10 +761,12 @@ function assertSessionSwitchResilienceContract() {
async function main() {
assertFrontendGenerationControlsContract();
assertFrontendComposerMcpContract();
assertFrontendSlashDraftPreservationContract();
assertFrontendCcwebPromptContract();
assertFrontendMarkdownLinkContract();
assertMockCodexAppPromptUserNotTextTriggered();
assertFrontendMcpReloadContract();
assertSetTitleMcpContract();
assertSessionSwitchResilienceContract();
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-regression-'));
@@ -969,6 +1019,7 @@ async function main() {
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-slash-mcp', trigger: '/', query: 'ccweb', sessionId: codexSession.sessionId, agent: 'codex' }));
const slashMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-slash-mcp');
assert(slashMcpComposer.items.some((item) => item.kind === 'mcp' && item.name === 'ccweb_list_conversations'), 'Composer slash suggestions should include ccweb MCP tools');
assert(slashMcpComposer.items.some((item) => item.kind === 'mcp' && item.name === 'ccweb_set_title'), 'Composer slash suggestions should include ccweb_set_title MCP tool');
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-slash-mcp-config', trigger: '/', query: 'reg', sessionId: codexSession.sessionId, agent: 'codex' }));
const slashMcpConfigComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-slash-mcp-config');
@@ -990,6 +1041,17 @@ async function main() {
assert(!slashMcpRuntimeComposer.items.some((item) => item.kind === 'mcp' && item.itemType === 'server' && item.name === 'regRuntime'), 'Composer slash suggestions should not infer MCP servers from session tool names');
assert(!slashMcpRuntimeComposer.items.some((item) => item.kind === 'mcp' && item.itemType === 'server' && item.name === 'reg-state'), 'Composer slash suggestions should not infer MCP servers from mcp:server labels');
ws.send(JSON.stringify({ type: 'message', text: '/grilling', sessionId: codexSession.sessionId, mode: 'plan', agent: 'codex', requestId: 'reg-unknown-slash-draft' }));
const unknownSlashDraft = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.requestId === 'reg-unknown-slash-draft');
assert(unknownSlashDraft.sessionId === codexSession.sessionId, 'Unknown slash draft response should stay scoped to the active session');
assert(unknownSlashDraft.preserveComposerDraft === true, 'Unknown slash command should tell the frontend to restore the composer draft');
assert(/未知指令: \/grilling/.test(unknownSlashDraft.message || ''), 'Unknown slash command should still show the normal failure hint');
ws.send(JSON.stringify({ type: 'message', text: '/help', sessionId: codexSession.sessionId, mode: 'plan', agent: 'codex', requestId: 'reg-help-slash-draft' }));
const helpSlashDraft = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.requestId === 'reg-help-slash-draft');
assert(!helpSlashDraft.preserveComposerDraft, 'Successful slash command responses should clear pending drafts without restoring input');
assert(/可用指令/.test(helpSlashDraft.message || ''), 'Slash /help should keep the normal help output');
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-skill', trigger: '$', query: 'reg', sessionId: codexSession.sessionId, agent: 'codex' }));
const skillComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-skill');
assert(skillComposer.items.some((item) => item.kind === 'skill' && item.name === 'regression-skill'), 'Composer skill suggestions should include local Codex skill');
@@ -1066,6 +1128,72 @@ async function main() {
assert(mcpList.body.currentConversationId === codexSession.sessionId, 'MCP list should return current source conversation id');
assert(mcpList.body.conversations.some((item) => item.id === codexSession.sessionId && !item.summary), 'MCP list should return lightweight session metadata without summary');
const codexTitleSessionPath = path.join(sessionsDir, `${codexSession.sessionId}.json`);
const storedBeforeMcpTitle = JSON.parse(fs.readFileSync(codexTitleSessionPath, 'utf8'));
const mcpSetTitle = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_set_title',
sourceSessionId: codexSession.sessionId,
args: { title: 'Concise MCP Title' },
});
assert(mcpSetTitle.status === 200 && mcpSetTitle.body?.ok, `MCP set title should succeed: ${JSON.stringify(mcpSetTitle.body)}`);
assert(mcpSetTitle.body.changed === true && mcpSetTitle.body.ignored === false, 'MCP set title should report a real title change');
assert(mcpSetTitle.body.previousTitle === storedBeforeMcpTitle.title, 'MCP set title should return previousTitle');
assert(mcpSetTitle.body.lockedByUser === false, 'MCP set title should not report a user lock before manual rename');
const mcpTitleRenamed = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_renamed' &&
msg.sessionId === codexSession.sessionId &&
msg.title === 'Concise MCP Title'
));
assert(mcpTitleRenamed.titleSource === 'llm', 'MCP set title should push llm titleSource to current viewers');
const mcpTitleList = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_list' &&
msg.sessions.some((session) => (
session.id === codexSession.sessionId &&
session.title === 'Concise MCP Title' &&
session.titleSource === 'llm'
))
));
assert(mcpTitleList.sessions.some((session) => session.id === codexSession.sessionId && session.updated === storedBeforeMcpTitle.updated), 'MCP set title should not bump session updated sorting timestamp');
const storedAfterMcpTitle = JSON.parse(fs.readFileSync(codexTitleSessionPath, 'utf8'));
assert(storedAfterMcpTitle.title === 'Concise MCP Title', 'MCP set title should persist title');
assert(storedAfterMcpTitle.titleSource === 'llm', 'MCP set title should persist llm titleSource');
assert(storedAfterMcpTitle.updated === storedBeforeMcpTitle.updated, 'MCP set title should not modify updated timestamp');
const mcpEmptyTitle = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_set_title',
sourceSessionId: codexSession.sessionId,
args: { title: ' ' },
});
assert(mcpEmptyTitle.status === 400 && mcpEmptyTitle.body?.code === 'invalid_title', 'MCP set title should reject empty titles');
ws.send(JSON.stringify({ type: 'rename_session', sessionId: codexSession.sessionId, title: 'Manual Locked Title' }));
const manualTitleRenamed = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_renamed' &&
msg.sessionId === codexSession.sessionId &&
msg.title === 'Manual Locked Title'
));
assert(manualTitleRenamed.titleSource === 'manual', 'Manual rename should push manual titleSource');
await nextMessage(messages, ws, (msg) => (
msg.type === 'session_list' &&
msg.sessions.some((session) => (
session.id === codexSession.sessionId &&
session.title === 'Manual Locked Title' &&
session.titleSource === 'manual'
))
));
const mcpLockedTitle = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_set_title',
sourceSessionId: codexSession.sessionId,
args: { title: 'Ignored LLM Title' },
});
assert(mcpLockedTitle.status === 200 && mcpLockedTitle.body?.ok, 'MCP set title should return ok when manually locked');
assert(mcpLockedTitle.body.changed === false && mcpLockedTitle.body.ignored === true, 'MCP set title should report ignored under manual lock');
assert(mcpLockedTitle.body.reason === 'ignored because title is manually locked', 'MCP set title should explain manual lock ignores');
assert(mcpLockedTitle.body.title === 'Manual Locked Title' && mcpLockedTitle.body.lockedByUser === true, 'MCP set title should preserve manual title under lock');
const storedAfterManualLock = JSON.parse(fs.readFileSync(codexTitleSessionPath, 'utf8'));
assert(storedAfterManualLock.title === 'Manual Locked Title', 'Manual locked title should remain persisted after ignored MCP set title');
assert(storedAfterManualLock.titleSource === 'manual', 'Manual locked titleSource should remain manual after ignored MCP set title');
const mcpRelativeCreate = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_create_conversation',
sourceSessionId: codexSession.sessionId,
@@ -1092,11 +1220,20 @@ async function main() {
await nextMessage(messages, ws, (msg) => msg.type === 'background_done' && msg.sessionId === mcpCreate.body.conversationId);
const storedMcpCreated = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${mcpCreate.body.conversationId}.json`), 'utf8'));
assert(storedMcpCreated.title === 'MCP Created Conversation', 'MCP created conversation should persist the requested title');
assert(storedMcpCreated.titleSource === 'system', 'MCP created conversation title should not be treated as user manual title');
assert(storedMcpCreated.agent === 'codex', 'MCP created conversation should persist the inherited source agent');
assert(storedMcpCreated.permissionMode === 'yolo', 'MCP created conversation should persist yolo as the default mode');
assert(storedMcpCreated.createdFrom?.kind === 'mcp', 'MCP created conversation should persist mcp creation kind');
assert(storedMcpCreated.createdFrom?.sourceSessionId === codexSession.sessionId, 'MCP created conversation should persist source metadata');
assert(storedMcpCreated.messages.some((message) => message.content === 'mcp created initial prompt' && message.crossConversation?.sourceSessionId === codexSession.sessionId), 'MCP created conversation should persist the initial cross-conversation message');
assert(storedMcpCreated.messages.some((message) => message.role === 'assistant' && /mcp created initial prompt/.test(String(message.content || ''))), 'MCP created conversation should run the initial prompt');
await nextMessage(messages, ws, (msg) => (
msg.type === 'session_list' &&
msg.sessions.some((session) => (
session.id === mcpCreate.body.conversationId &&
session.createdFromKind === 'mcp'
))
));
const mcpReplyCreateCwd = path.join(tempRoot, 'mcp-create-reply');
mkdirp(mcpReplyCreateCwd);

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),