Files
cc-web/server.js
2026-06-16 14:36:06 +08:00

7105 lines
253 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { spawn, spawnSync } = require('child_process');
const { WebSocketServer } = require('ws');
const { createAgentRuntime } = require('./lib/agent-runtime');
const { createCodexAppServerClient } = require('./lib/codex-app-server-client');
const { createCodexAppWorkerClient } = require('./lib/codex-app-worker-client');
const { createCodexAppRuntime } = require('./lib/codex-app-runtime');
const { createCodexRolloutStore } = require('./lib/codex-rollouts');
const { TOOLS: CCWEB_MCP_TOOLS } = require('./lib/ccweb-mcp-server');
// Load .env
const envPath = path.join(__dirname, '.env');
if (fs.existsSync(envPath)) {
for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) {
const m = line.match(/^([^#=]+)=(.*)$/);
if (m && !process.env[m[1].trim()]) process.env[m[1].trim()] = m[2].trim();
}
}
function readPositiveIntEnv(name, fallback, options = {}) {
const raw = Number.parseInt(String(process.env[name] || ''), 10);
const min = Number.isFinite(options.min) ? options.min : 1;
const max = Number.isFinite(options.max) ? options.max : Number.MAX_SAFE_INTEGER;
if (!Number.isFinite(raw) || raw <= 0) return fallback;
return Math.max(min, Math.min(max, raw));
}
const PORT = parseInt(process.env.PORT) || 8002;
const CLAUDE_PATH = process.env.CLAUDE_PATH || 'claude';
const CODEX_PATH = process.env.CODEX_PATH || 'codex';
const INTERNAL_MCP_TOKEN = process.env.CC_WEB_INTERNAL_MCP_TOKEN || crypto.randomBytes(32).toString('hex');
const CONFIG_DIR = process.env.CC_WEB_CONFIG_DIR || path.join(__dirname, 'config');
const SESSIONS_DIR = process.env.CC_WEB_SESSIONS_DIR || path.join(__dirname, 'sessions');
const PUBLIC_DIR = process.env.CC_WEB_PUBLIC_DIR || path.join(__dirname, 'public');
const LOGS_DIR = process.env.CC_WEB_LOGS_DIR || path.join(__dirname, 'logs');
const CCWEB_MCP_SERVER_PATH = path.join(__dirname, 'lib', 'ccweb-mcp-server.js');
const ATTACHMENTS_DIR = path.join(SESSIONS_DIR, '_attachments');
const ATTACHMENT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024;
const MAX_MESSAGE_ATTACHMENTS = 4;
const FILE_BROWSER_MAX_LIST_ENTRIES = 400;
const FILE_BROWSER_MAX_PREVIEW_BYTES = 200 * 1024;
const COMPOSER_SUGGESTION_LIMIT = 20;
const COMPOSER_FILE_CONTEXT_MAX_BYTES = 60 * 1024;
const COMPOSER_MAX_FILE_MENTIONS = 4;
const COMPOSER_MAX_PROMPT_MENTIONS = 4;
const SESSION_MAX_JSON_BYTES = readPositiveIntEnv('CC_WEB_SESSION_MAX_JSON_BYTES', 10 * 1024 * 1024, { min: 512 * 1024 });
const SESSION_LOAD_MAX_BYTES = readPositiveIntEnv('CC_WEB_SESSION_LOAD_MAX_BYTES', 32 * 1024 * 1024, { min: SESSION_MAX_JSON_BYTES });
const SESSION_META_FULL_PARSE_MAX_BYTES = readPositiveIntEnv('CC_WEB_SESSION_META_FULL_PARSE_MAX_BYTES', 512 * 1024, { min: 64 * 1024 });
const SESSION_META_PREVIEW_BYTES = readPositiveIntEnv('CC_WEB_SESSION_META_PREVIEW_BYTES', 128 * 1024, { min: 16 * 1024 });
const SESSION_PERSIST_MAX_MESSAGES = readPositiveIntEnv('CC_WEB_SESSION_PERSIST_MAX_MESSAGES', 180, { min: 20, max: 2000 });
const SESSION_MESSAGE_CONTENT_MAX_CHARS = readPositiveIntEnv('CC_WEB_SESSION_MESSAGE_CONTENT_MAX_CHARS', 96 * 1024, { min: 4096 });
const SESSION_TOOL_INPUT_MAX_CHARS = readPositiveIntEnv('CC_WEB_SESSION_TOOL_INPUT_MAX_CHARS', 16 * 1024, { min: 1024 });
const SESSION_TOOL_RESULT_MAX_CHARS = readPositiveIntEnv('CC_WEB_SESSION_TOOL_RESULT_MAX_CHARS', 32 * 1024, { min: 1024 });
const SESSION_MAX_TOOL_CALLS_PER_MESSAGE = readPositiveIntEnv('CC_WEB_SESSION_MAX_TOOL_CALLS_PER_MESSAGE', 80, { min: 1, max: 1000 });
const HISTORY_PREFETCH_CHUNKS = readPositiveIntEnv('CC_WEB_HISTORY_PREFETCH_CHUNKS', 3, { min: 0, max: 20 });
const HISTORY_MAX_CHUNKS_PER_LOAD = readPositiveIntEnv('CC_WEB_HISTORY_MAX_CHUNKS_PER_LOAD', 8, { min: 1, max: 100 });
const CODEX_APP_STATE_MAX_BYTES = readPositiveIntEnv('CC_WEB_CODEX_APP_STATE_MAX_BYTES', 2 * 1024 * 1024, { min: 128 * 1024 });
const CODEX_APP_STATE_LOAD_MAX_BYTES = readPositiveIntEnv('CC_WEB_CODEX_APP_STATE_LOAD_MAX_BYTES', 4 * 1024 * 1024, { min: CODEX_APP_STATE_MAX_BYTES });
const CODEX_APP_STATE_FULL_TEXT_MAX_CHARS = readPositiveIntEnv('CC_WEB_CODEX_APP_STATE_FULL_TEXT_MAX_CHARS', 192 * 1024, { min: 4096 });
const CODEX_APP_STATE_MAP_VALUE_MAX_CHARS = readPositiveIntEnv('CC_WEB_CODEX_APP_STATE_MAP_VALUE_MAX_CHARS', 16 * 1024, { min: 1024 });
const CODEX_APP_STATE_MAX_MAP_ENTRIES = readPositiveIntEnv('CC_WEB_CODEX_APP_STATE_MAX_MAP_ENTRIES', 80, { min: 1, max: 1000 });
const RUN_OUTPUT_RECOVERY_MAX_BYTES = readPositiveIntEnv('CC_WEB_RUN_OUTPUT_RECOVERY_MAX_BYTES', 16 * 1024 * 1024, { min: 1024 * 1024 });
const RUN_OUTPUT_TAILER_MAX_READ_BYTES = readPositiveIntEnv('CC_WEB_RUN_OUTPUT_TAILER_MAX_READ_BYTES', 2 * 1024 * 1024, { min: 64 * 1024 });
const CROSS_CONVERSATION_MAX_CONTENT_CHARS = readPositiveIntEnv('CC_WEB_CROSS_CONVERSATION_MAX_CONTENT_CHARS', 64 * 1024, { min: 1024 });
const MCP_CREATE_CONVERSATION_MAX_HOP_COUNT = readPositiveIntEnv('CC_WEB_MCP_CREATE_CONVERSATION_MAX_HOP_COUNT', 3, { min: 1, max: 20 });
const CODEX_APP_WORKER_DISABLED = /^(0|false|no|off)$/i.test(String(process.env.CC_WEB_CODEX_APP_WORKER || ''));
const CODEX_APP_WORKER_ENABLED = !CODEX_APP_WORKER_DISABLED;
const PERSIST_TRUNCATED_TEXT = '[cc-web: 内容过大,已截断以保护服务稳定性]';
const IMAGE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
const TEXT_PREVIEW_EXTENSIONS = new Set([
'.txt', '.md', '.markdown', '.json', '.jsonl', '.js', '.jsx', '.ts', '.tsx',
'.css', '.scss', '.less', '.html', '.htm', '.xml', '.svg', '.yml', '.yaml',
'.toml', '.ini', '.conf', '.config', '.env', '.log', '.sh', '.bash', '.zsh',
'.py', '.rb', '.go', '.java', '.kt', '.c', '.cc', '.cpp', '.h', '.hpp',
'.cs', '.sql', '.csv', '.tsx', '.vue', '.svelte', '.lock',
]);
const NOTIFY_CONFIG_PATH = path.join(CONFIG_DIR, 'notify.json');
const AUTH_CONFIG_PATH = path.join(CONFIG_DIR, 'auth.json');
const MODEL_CONFIG_PATH = path.join(CONFIG_DIR, 'model.json');
const CODEX_CONFIG_PATH = path.join(CONFIG_DIR, 'codex.json');
const PROMPTS_CONFIG_PATH = path.join(CONFIG_DIR, 'prompts.json');
const BANNED_IPS_PATH = path.join(CONFIG_DIR, 'banned_ips.json');
fs.mkdirSync(SESSIONS_DIR, { recursive: true });
fs.mkdirSync(LOGS_DIR, { recursive: true });
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.mkdirSync(ATTACHMENTS_DIR, { recursive: true });
// === Process Lifecycle Logger ===
const LOG_FILE = path.join(LOGS_DIR, 'process.log');
const LOG_MAX_SIZE = 2 * 1024 * 1024; // 2MB per file
function plog(level, event, data = {}) {
const entry = {
ts: new Date().toISOString(),
level,
event,
...data,
};
const line = JSON.stringify(entry) + '\n';
try {
// Simple rotation: if file > 2MB, rename to .old and start fresh
try {
const stat = fs.statSync(LOG_FILE);
if (stat.size > LOG_MAX_SIZE) {
const oldFile = LOG_FILE.replace('.log', '.old.log');
try { fs.unlinkSync(oldFile); } catch {}
fs.renameSync(LOG_FILE, oldFile);
}
} catch {}
fs.appendFileSync(LOG_FILE, line);
} catch {}
}
// === Notification System ===
const DEFAULT_SUMMARY_CONFIG = {
enabled: false,
trigger: 'background', // 'background' | 'always'
apiSource: 'claude', // 'claude' | 'codex' | 'custom'
apiBase: '',
apiKey: '',
model: '',
};
function loadNotifyConfig() {
try {
if (fs.existsSync(NOTIFY_CONFIG_PATH)) {
const raw = JSON.parse(fs.readFileSync(NOTIFY_CONFIG_PATH, 'utf8'));
// Ensure summary field exists for older configs
if (!raw.summary) raw.summary = { ...DEFAULT_SUMMARY_CONFIG };
return raw;
}
} catch {}
// First run: migrate from .env PUSHPLUS_TOKEN
const token = process.env.PUSHPLUS_TOKEN || '';
const config = {
provider: token ? 'pushplus' : 'off',
pushplus: { token },
telegram: { botToken: '', chatId: '' },
serverchan: { sendKey: '' },
feishu: { webhook: '' },
qqbot: { qmsgKey: '' },
summary: { ...DEFAULT_SUMMARY_CONFIG },
};
saveNotifyConfig(config);
return config;
}
function saveNotifyConfig(config) {
fs.writeFileSync(NOTIFY_CONFIG_PATH, JSON.stringify(config, null, 2));
}
function maskToken(str) {
if (!str || str.length <= 8) return str ? '****' : '';
return str.slice(0, 4) + '****' + str.slice(-4);
}
function getNotifyConfigMasked() {
const config = loadNotifyConfig();
const s = config.summary || {};
return {
provider: config.provider,
pushplus: { token: maskToken(config.pushplus?.token) },
telegram: { botToken: maskToken(config.telegram?.botToken), chatId: config.telegram?.chatId || '' },
serverchan: { sendKey: maskToken(config.serverchan?.sendKey) },
feishu: { webhook: maskToken(config.feishu?.webhook) },
qqbot: { qmsgKey: maskToken(config.qqbot?.qmsgKey) },
summary: {
enabled: !!s.enabled,
trigger: s.trigger || 'background',
apiSource: s.apiSource || 'claude',
apiBase: s.apiBase || '',
apiKey: maskToken(s.apiKey),
model: s.model || '',
},
};
}
// === Notification Summary ===
// Per-channel content length limits (chars)
const NOTIFY_CONTENT_LIMITS = {
telegram: 3800,
qqbot: 3800,
serverchan: 30000,
pushplus: 18000,
feishu: 18000,
};
function truncateForChannel(text, provider) {
const limit = NOTIFY_CONTENT_LIMITS[provider] || 18000;
if (text.length <= limit) return text;
return text.slice(0, limit - 20) + '\n\n[内容已截断]';
}
function getSummaryApiCredentials(summaryConfig) {
// Returns { apiBase, apiKey, model } or null
const src = summaryConfig.apiSource || 'claude';
if (src === 'claude') {
const modelCfg = loadModelConfig();
if (modelCfg.mode === 'custom' && modelCfg.activeTemplate) {
const tpl = (modelCfg.templates || []).find(t => t.name === modelCfg.activeTemplate);
if (tpl && tpl.apiKey && tpl.apiBase) {
return { apiBase: tpl.apiBase, apiKey: tpl.apiKey, model: tpl.defaultModel || tpl.opusModel || '' };
}
}
return null; // local mode — no API credentials available
}
if (src === 'codex') {
const codexCfg = loadCodexConfig();
if (codexCfg.mode === 'custom' && codexCfg.activeProfile) {
const profile = (codexCfg.profiles || []).find(p => p.name === codexCfg.activeProfile);
if (profile && profile.apiKey && profile.apiBase) {
return { apiBase: profile.apiBase, apiKey: profile.apiKey, model: summaryConfig.model || '' };
}
}
return null;
}
if (src === 'custom') {
if (summaryConfig.apiBase && summaryConfig.apiKey) {
return { apiBase: summaryConfig.apiBase, apiKey: summaryConfig.apiKey, model: summaryConfig.model || '' };
}
return null;
}
return null;
}
function callSummaryApi(creds, prompt) {
return new Promise((resolve) => {
try {
const base = creds.apiBase.replace(/\/+$/, '');
const url = new URL(base + '/v1/chat/completions');
const mod = url.protocol === 'https:' ? require('https') : require('http');
const model = creds.model || 'claude-opus-4-6';
const body = JSON.stringify({
model,
max_tokens: 1024,
messages: [{ role: 'user', content: prompt }],
});
const req = mod.request(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${creds.apiKey}`,
'Content-Length': Buffer.byteLength(body),
},
timeout: 20000,
}, (res) => {
let data = '';
res.on('data', c => data += c);
res.on('end', () => {
try {
const json = JSON.parse(data);
const text = json.choices?.[0]?.message?.content || json.content?.[0]?.text || '';
resolve({ ok: !!text, text: text.trim() });
} catch {
resolve({ ok: false, text: '' });
}
});
});
req.on('error', () => resolve({ ok: false, text: '' }));
req.on('timeout', () => { req.destroy(); resolve({ ok: false, text: '' }); });
req.write(body);
req.end();
} catch {
resolve({ ok: false, text: '' });
}
});
}
function buildSummaryPrompt(sessionTitle, lastUserMsg, fullText, isError, errorDesc) {
const userSnip = (lastUserMsg || '').slice(0, 300);
const outputSnip = (fullText || '').slice(0, 15000);
const base = `会话:${sessionTitle}\n用户请求:${userSnip}\n\n以下是助手的输出内容:\n${outputSnip}`;
if (isError) {
return base + `\n\n错误信息:${(errorDesc || '').slice(0, 300)}\n\n` +
`请用纯文本简要说明本次任务做了什么、遇到了什么问题。` +
`要求1. 不超过 200 字 2. 可以有序号和适当分段 3. 不要罗列具体代码、函数名、文件路径等细节 4. 不使用 markdown 格式(无星号、井号、横线等符号)`;
}
return base + `\n\n请用纯文本简要说明本次任务做了什么、结论是否成功。` +
`要求1. 不超过 200 字 2. 可以有序号和适当分段 3. 不要罗列具体代码、函数名、文件路径等细节 4. 不使用 markdown 格式(无星号、井号、横线等符号)`;
}
async function buildNotifyContent(entry, session, completionError, contextLimitExceeded) {
const title = session?.title || 'Untitled';
const agent = entry.agent || 'claude';
const agentLabel = agent === 'codexapp' ? 'Codex App' : agent === 'codex' ? 'Codex' : 'Claude';
const hasTools = (entry.toolCalls || []).length > 0;
// Determine notify title
let notifyTitle;
if (contextLimitExceeded) {
notifyTitle = `${title} 上下文已压缩`;
} else if (completionError) {
notifyTitle = `${title} 任务异常`;
} else if (hasTools) {
notifyTitle = `${title} 任务完成`;
} else {
notifyTitle = `${title} 回复就绪`;
}
// Context limit: fixed message, no AI
if (contextLimitExceeded) {
return { title: notifyTitle, content: `${agentLabel} 会话上下文已达上限,已自动触发压缩。\n会话: ${title}` };
}
// Check if summary is enabled and applicable
const notifyCfg = loadNotifyConfig();
const summaryCfg = notifyCfg.summary || {};
const summaryEnabled = !!summaryCfg.enabled;
if (!summaryEnabled) {
// Fallback: simple content
const lines = [`会话: ${title}`];
if (completionError) lines.push(`错误: ${completionError.slice(0, 200)}`);
return { title: notifyTitle, content: lines.join('\n') };
}
const creds = getSummaryApiCredentials(summaryCfg);
if (!creds) {
// No credentials — fallback
const lines = [`会话: ${title}`];
if (completionError) lines.push(`错误: ${completionError.slice(0, 200)}`);
return { title: notifyTitle, content: lines.join('\n') };
}
// Get last user message from session
const messages = session?.messages || [];
const lastUser = [...messages].reverse().find(m => m.role === 'user');
const lastUserMsg = typeof lastUser?.content === 'string' ? lastUser.content : '';
const prompt = buildSummaryPrompt(title, lastUserMsg, entry.fullText || '', !!completionError, completionError || '');
const result = await callSummaryApi(creds, prompt);
let bodyText;
if (result.ok && result.text) {
bodyText = result.text;
} else {
// Fallback on API failure
const lines = [`会话: ${title}`];
if (completionError) lines.push(`错误: ${completionError.slice(0, 200)}`);
if (!result.ok) lines.push('(摘要生成失败,以上为原始信息)');
bodyText = lines.join('\n');
}
return { title: notifyTitle, content: bodyText };
}
function sendNotification(title, content) {
const config = loadNotifyConfig();
if (!config.provider || config.provider === 'off') return Promise.resolve({ ok: true, skipped: true });
const https = require('https');
const truncated = truncateForChannel(content, config.provider);
return new Promise((resolve) => {
let url, data;
let isFormData = false;
switch (config.provider) {
case 'pushplus': {
if (!config.pushplus?.token) return resolve({ ok: false, error: 'PushPlus token 未配置' });
url = 'https://www.pushplus.plus/send';
data = JSON.stringify({ token: config.pushplus.token, title, content: truncated, template: 'txt' });
break;
}
case 'telegram': {
if (!config.telegram?.botToken || !config.telegram?.chatId) return resolve({ ok: false, error: 'Telegram botToken 或 chatId 未配置' });
url = `https://api.telegram.org/bot${config.telegram.botToken}/sendMessage`;
data = JSON.stringify({ chat_id: config.telegram.chatId, text: `${title}\n\n${truncated}` });
break;
}
case 'serverchan': {
if (!config.serverchan?.sendKey) return resolve({ ok: false, error: 'Server酱 sendKey 未配置' });
url = `https://sctapi.ftqq.com/${config.serverchan.sendKey}.send`;
data = JSON.stringify({ title, desp: truncated });
break;
}
case 'feishu': {
if (!config.feishu?.webhook) return resolve({ ok: false, error: '飞书 Webhook 未配置' });
url = config.feishu.webhook;
data = JSON.stringify({ msg_type: 'text', content: { text: `${title}\n\n${truncated}` } });
break;
}
case 'qqbot': {
if (!config.qqbot?.qmsgKey) return resolve({ ok: false, error: 'Qmsg Key 未配置' });
url = `https://qmsg.zendee.cn/send/${config.qqbot.qmsgKey}`;
data = `msg=${encodeURIComponent(`${title}\n\n${truncated}`)}`;
isFormData = true;
break;
}
default:
return resolve({ ok: false, error: `未知通知方式: ${config.provider}` });
}
const parsed = new URL(url);
const contentType = isFormData ? 'application/x-www-form-urlencoded' : 'application/json';
const reqOptions = {
method: 'POST',
headers: { 'Content-Type': contentType, 'Content-Length': Buffer.byteLength(data) },
};
const req = https.request(parsed, reqOptions, (res) => {
let body = '';
res.on('data', (c) => body += c);
res.on('end', () => {
plog('INFO', 'notify_response', { provider: config.provider, status: res.statusCode, body: body.slice(0, 200) });
resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, body: body.slice(0, 200) });
});
});
req.on('error', (e) => {
plog('WARN', 'notify_error', { provider: config.provider, error: e.message });
resolve({ ok: false, error: e.message });
});
req.write(data);
req.end();
});
}
// Load config on startup (ensures migration)
loadNotifyConfig();
// === Auth Config ===
function generateRandomPassword(length = 12) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
const bytes = crypto.randomBytes(length);
for (let i = 0; i < length; i++) {
result += chars[bytes[i] % chars.length];
}
return result;
}
function loadAuthConfig() {
// Priority 1: config/auth.json exists with password
try {
if (fs.existsSync(AUTH_CONFIG_PATH)) {
const config = JSON.parse(fs.readFileSync(AUTH_CONFIG_PATH, 'utf8'));
if (config.password) return config;
}
} catch {}
// Priority 2: .env has CC_WEB_PASSWORD → migrate
const envPw = process.env.CC_WEB_PASSWORD;
if (envPw && envPw !== 'changeme') {
const config = { password: envPw, mustChange: false };
saveAuthConfig(config);
return config;
}
// Priority 3: Generate random password
const pw = generateRandomPassword(12);
const config = { password: pw, mustChange: true };
saveAuthConfig(config);
console.log('========================================');
console.log(' 自动生成初始密码: ' + pw);
console.log(' 首次登录后将要求修改密码');
console.log('========================================');
return config;
}
function saveAuthConfig(config) {
fs.writeFileSync(AUTH_CONFIG_PATH, JSON.stringify(config, null, 2));
}
function validatePasswordStrength(pw) {
if (!pw || pw.length < 8) {
return { valid: false, message: '密码长度至少 8 位' };
}
let types = 0;
if (/[a-z]/.test(pw)) types++;
if (/[A-Z]/.test(pw)) types++;
if (/[0-9]/.test(pw)) types++;
if (/[^a-zA-Z0-9]/.test(pw)) types++;
if (types < 2) {
return { valid: false, message: '密码需包含至少 2 种字符类型(大写/小写/数字/特殊字符)' };
}
return { valid: true, message: '' };
}
let authConfig = loadAuthConfig();
let PASSWORD = authConfig.password;
const activeTokens = new Set();
// === Anti-brute-force ===
const AUTH_FAIL_WINDOW = 5 * 60 * 1000; // 5 minutes
const AUTH_FAIL_MAX = 3;
const authFailures = new Map(); // ip -> [timestamp, ...]
let bannedIPs = new Set();
// Tailscale / loopback whitelist — never ban these IPs.
// Extra whitelist can be provided via env var (comma/space separated):
// CC_WEB_IP_WHITELIST="<ip1>,<ip2>"
const EXTRA_WHITELIST_IPS = new Set(
String(process.env.CC_WEB_IP_WHITELIST || '')
.split(/[\s,]+/)
.map(s => s.trim())
.filter(Boolean)
.map(s => s.replace(/^::ffff:/, ''))
);
function isWhitelistedIP(ip) {
if (!ip) return false;
const cleaned = ip.replace(/^::ffff:/, '');
return cleaned === '127.0.0.1'
|| cleaned === '::1'
|| cleaned.startsWith('100.')
|| EXTRA_WHITELIST_IPS.has(cleaned);
}
function loadBannedIPs() {
try {
if (fs.existsSync(BANNED_IPS_PATH)) {
const list = JSON.parse(fs.readFileSync(BANNED_IPS_PATH, 'utf8'));
bannedIPs = new Set(Array.isArray(list) ? list : []);
}
} catch { bannedIPs = new Set(); }
}
function saveBannedIPs() {
fs.writeFileSync(BANNED_IPS_PATH, JSON.stringify([...bannedIPs], null, 2));
}
loadBannedIPs();
function getClientIP(ws) {
const req = ws._req;
if (!req) return null;
const forwarded = req.headers['x-forwarded-for'];
if (forwarded) return forwarded.split(',')[0].trim();
return req.socket?.remoteAddress || null;
}
function recordAuthFailure(ip) {
if (!ip || isWhitelistedIP(ip)) return false;
const now = Date.now();
let list = authFailures.get(ip) || [];
list.push(now);
list = list.filter(t => now - t < AUTH_FAIL_WINDOW);
authFailures.set(ip, list);
if (list.length >= AUTH_FAIL_MAX) {
bannedIPs.add(ip);
saveBannedIPs();
authFailures.delete(ip);
plog('WARN', 'ip_banned', { ip, reason: `${AUTH_FAIL_MAX} failed auth in ${AUTH_FAIL_WINDOW / 1000}s` });
return true;
}
return false;
}
// Pending slash command metadata: sessionId -> { kind: string }
const pendingSlashCommands = new Map();
// Pending compact retry metadata: sessionId -> { text: string, mode: string, reason: string }
const pendingCompactRetries = new Map();
// Active processes: sessionId -> { pid, ws, fullText, toolCalls, lastCost, tailer }
const activeProcesses = new Map();
// Active Codex app-server turns: sessionId -> { ws, threadId, turnId, fullText, toolCalls }
const activeCodexAppTurns = new Map();
// ccweb MCP child agents tracked from Codex App native collaboration mode:
// childThreadId -> { parentSessionId, parentThreadId, spawnToolId, ...state }
const ccwebMcpChildThreads = new Map();
// 等待目标对话完成后回传给来源对话的跨对话请求requestId -> metadata
const pendingCrossConversationReplies = new Map();
// Pending Codex app-server guided input requests: requestId -> { sessionId, resolve, timer }
const pendingCodexAppUserInputs = new Map();
let codexAppClient = null;
let codexAppClientSignature = '';
const CODEX_APP_STATE_FILE = 'codexapp-state.json';
const CODEX_APP_STATE_FLUSH_DELAY_MS = 250;
// Track which session each ws is viewing: ws -> sessionId
const wsSessionMap = new Map();
// Default fallback MODEL_MAP (overridden by model config at runtime)
// opus/sonnet use [1m] suffix to enable 1M context window by default
let MODEL_MAP = {
opus: 'claude-opus-4-6[1m]',
sonnet: 'claude-sonnet-4-6[1m]',
haiku: 'claude-haiku-4-5-20251001',
};
const VALID_AGENTS = new Set(['claude', 'codex', 'codexapp']);
const VALID_PERMISSION_MODES = new Set(['default', 'plan', 'yolo']);
const MCP_CONVERSATION_TITLE_MAX_CHARS = 120;
// Codex 默认模型优先读取 ~/.codex/config.toml缺失时再回退到旧默认值。
const FALLBACK_CODEX_MODEL = 'gpt-5.4';
const CODEX_REASONING_LEVELS = new Set(['low', 'medium', 'high', 'xhigh']);
const CODEX_APP_COLLABORATION_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.',
'- If you need a specific agent_type, model, or reasoning_effort, set fork_context: false and include only the necessary context in the message.',
'- Do not rely on parent turn reasoning settings for spawned agents; only set reasoning_effort on spawn_agent when the chosen child model supports it.',
].join('\n');
function getLocalCodexConfigTomlPath() {
const codexHome = String(process.env.CODEX_HOME || '').trim();
if (codexHome) return path.join(codexHome, 'config.toml');
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
return path.join(homeDir, '.codex', 'config.toml');
}
// === Model Config ===
const DEFAULT_MODEL_CONFIG = {
mode: 'local', // 'local' | 'custom'
templates: [], // array of { name, apiKey, apiBase, defaultModel, opusModel, sonnetModel, haikuModel }
activeTemplate: '', // name of active template (for 'custom' mode)
};
const DEFAULT_CODEX_CONFIG = {
mode: 'local',
activeProfile: '',
profiles: [],
enableSearch: false,
supportsSearch: false,
};
function stripTomlInlineComment(value) {
const raw = String(value || '');
let inDoubleQuote = false;
let inSingleQuote = false;
let escaped = false;
for (let i = 0; i < raw.length; i += 1) {
const ch = raw[i];
if (inDoubleQuote) {
if (escaped) {
escaped = false;
continue;
}
if (ch === '\\') {
escaped = true;
continue;
}
if (ch === '"') inDoubleQuote = false;
continue;
}
if (inSingleQuote) {
if (ch === '\'') inSingleQuote = false;
continue;
}
if (ch === '"') {
inDoubleQuote = true;
continue;
}
if (ch === '\'') {
inSingleQuote = true;
continue;
}
if (ch === '#') return raw.slice(0, i).trim();
}
return raw.trim();
}
function parseTomlStringValue(value) {
const raw = stripTomlInlineComment(value);
if (!raw) return '';
if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith('\'') && raw.endsWith('\''))) {
if (raw.startsWith('"')) {
try {
return JSON.parse(raw);
} catch {}
}
return raw.slice(1, -1);
}
return raw;
}
function loadLocalCodexTomlConfig() {
try {
const configPath = getLocalCodexConfigTomlPath();
if (!configPath || !fs.existsSync(configPath)) {
return { model: '', reasoningEffort: '' };
}
const text = fs.readFileSync(configPath, 'utf8');
const parsed = { model: '', reasoningEffort: '' };
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
if (trimmed.startsWith('[')) break;
const eqIndex = trimmed.indexOf('=');
if (eqIndex <= 0) continue;
const key = trimmed.slice(0, eqIndex).trim();
const value = trimmed.slice(eqIndex + 1).trim();
if (key === 'model') parsed.model = parseTomlStringValue(value);
if (key === 'model_reasoning_effort') parsed.reasoningEffort = parseTomlStringValue(value).toLowerCase();
}
return parsed;
} catch {
return { model: '', reasoningEffort: '' };
}
}
function getDefaultCodexModel() {
const localConfig = loadLocalCodexTomlConfig();
const model = String(localConfig.model || '').trim() || FALLBACK_CODEX_MODEL;
const reasoningEffort = String(localConfig.reasoningEffort || '').trim().toLowerCase();
if (CODEX_REASONING_LEVELS.has(reasoningEffort)) {
return `${model}(${reasoningEffort})`;
}
return model;
}
function loadModelConfig() {
try {
if (fs.existsSync(MODEL_CONFIG_PATH)) {
return JSON.parse(fs.readFileSync(MODEL_CONFIG_PATH, 'utf8'));
}
} catch {}
return JSON.parse(JSON.stringify(DEFAULT_MODEL_CONFIG));
}
function saveModelConfig(config) {
fs.writeFileSync(MODEL_CONFIG_PATH, JSON.stringify(config, null, 2));
}
function loadCodexConfig() {
try {
if (fs.existsSync(CODEX_CONFIG_PATH)) {
const raw = JSON.parse(fs.readFileSync(CODEX_CONFIG_PATH, 'utf8'));
return {
mode: raw.mode === 'custom' ? 'custom' : 'local',
activeProfile: raw.activeProfile || '',
profiles: Array.isArray(raw.profiles) ? raw.profiles.map((profile) => ({
name: String(profile?.name || '').trim(),
apiKey: String(profile?.apiKey || ''),
apiBase: String(profile?.apiBase || '').trim(),
})).filter((profile) => profile.name) : [],
enableSearch: false,
supportsSearch: false,
storedEnableSearch: !!raw.enableSearch,
};
}
} catch {}
return JSON.parse(JSON.stringify(DEFAULT_CODEX_CONFIG));
}
function saveCodexConfig(config) {
fs.writeFileSync(CODEX_CONFIG_PATH, JSON.stringify({
mode: config.mode === 'custom' ? 'custom' : 'local',
activeProfile: config.activeProfile || '',
profiles: Array.isArray(config.profiles) ? config.profiles.map((profile) => ({
name: String(profile?.name || '').trim(),
apiKey: String(profile?.apiKey || ''),
apiBase: String(profile?.apiBase || '').trim(),
})).filter((profile) => profile.name) : [],
enableSearch: false,
}, null, 2));
}
function getCodexConfigMasked() {
const config = loadCodexConfig();
return {
mode: config.mode === 'custom' ? 'custom' : 'local',
activeProfile: config.activeProfile || '',
profiles: (config.profiles || []).map((profile) => ({
name: profile.name,
apiKey: maskSecret(profile.apiKey),
apiBase: profile.apiBase || '',
})),
enableSearch: false,
supportsSearch: false,
storedEnableSearch: !!config.storedEnableSearch,
};
}
function maskSecret(str) {
if (!str || str.length <= 8) return str ? '****' : '';
return str.slice(0, 4) + '****' + str.slice(-4);
}
function getModelConfigMasked() {
const config = loadModelConfig();
return {
mode: config.mode,
activeTemplate: config.activeTemplate,
templates: (config.templates || []).map(t => ({
name: t.name,
apiKey: maskSecret(t.apiKey),
apiBase: t.apiBase || '',
defaultModel: t.defaultModel || '',
opusModel: t.opusModel || '',
sonnetModel: t.sonnetModel || '',
haikuModel: t.haikuModel || '',
})),
};
}
const CODEX_RUNTIME_HOME = path.join(CONFIG_DIR, 'codex-runtime-home');
function tomlString(value) {
return JSON.stringify(String(value || ''));
}
function prepareCodexCustomRuntime(config) {
if (!config || config.mode !== 'custom') return { mode: 'local' };
const profiles = Array.isArray(config.profiles) ? config.profiles : [];
const activeProfile = profiles.find((profile) => profile.name === config.activeProfile) || null;
if (!activeProfile) {
return { error: 'Codex 自定义配置缺少已激活的 profile。请先在设置中创建并激活一个 API 配置。' };
}
if (!activeProfile.apiKey || !activeProfile.apiBase) {
return { error: `Codex profile「${activeProfile.name}」缺少 API Key 或 API Base URL。` };
}
fs.mkdirSync(CODEX_RUNTIME_HOME, { recursive: true });
const configToml = [
'preferred_auth_method = "apikey"',
'model_provider = "openai_compat"',
'',
'[model_providers.openai_compat]',
`name = ${tomlString(activeProfile.name || 'OpenAI Compat')}`,
`base_url = ${tomlString(activeProfile.apiBase)}`,
'env_key = "OPENAI_API_KEY"',
'wire_api = "responses"',
'',
].join('\n');
fs.writeFileSync(path.join(CODEX_RUNTIME_HOME, 'config.toml'), configToml);
return {
mode: 'custom',
homeDir: CODEX_RUNTIME_HOME,
apiKey: activeProfile.apiKey,
apiBase: activeProfile.apiBase,
profileName: activeProfile.name,
};
}
// Read ~/.claude.json for model name overrides
function loadClaudeJsonModelMap() {
try {
const p = path.join(process.env.HOME || process.env.USERPROFILE || '', '.claude.json');
if (!fs.existsSync(p)) return null;
const raw = JSON.parse(fs.readFileSync(p, 'utf8'));
const env = raw?.env || {};
const map = {};
// Append [1m] to opus/sonnet for 1M context window; haiku uses model name as-is
if (env.ANTHROPIC_DEFAULT_OPUS_MODEL) map.opus = env.ANTHROPIC_DEFAULT_OPUS_MODEL + '[1m]';
if (env.ANTHROPIC_DEFAULT_SONNET_MODEL) map.sonnet = env.ANTHROPIC_DEFAULT_SONNET_MODEL + '[1m]';
if (env.ANTHROPIC_DEFAULT_HAIKU_MODEL) map.haiku = env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
// Fallback: ANTHROPIC_MODEL maps to opus slot
if (!map.opus && env.ANTHROPIC_MODEL) map.opus = env.ANTHROPIC_MODEL + '[1m]';
return Object.keys(map).length > 0 ? map : null;
} catch {
return null;
}
}
// Apply model config to runtime MODEL_MAP only (env vars are injected per-spawn, not here)
const CLAUDE_SETTINGS_PATH = path.join(process.env.HOME || process.env.USERPROFILE || '', '.claude', 'settings.json');
const SETTINGS_API_KEYS = ['ANTHROPIC_AUTH_TOKEN','ANTHROPIC_API_KEY','ANTHROPIC_BASE_URL','ANTHROPIC_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL','ANTHROPIC_DEFAULT_SONNET_MODEL','ANTHROPIC_DEFAULT_HAIKU_MODEL',
'ANTHROPIC_REASONING_MODEL'];
function applyCustomTemplateToSettings(tpl) {
let settings = {};
try { settings = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS_PATH, 'utf8')); } catch {}
const cleanedEnv = {};
for (const [k, v] of Object.entries(settings.env || {})) {
if (!SETTINGS_API_KEYS.includes(k)) cleanedEnv[k] = v;
}
if (tpl.apiKey) { cleanedEnv.ANTHROPIC_AUTH_TOKEN = tpl.apiKey; cleanedEnv.ANTHROPIC_API_KEY = tpl.apiKey; }
if (tpl.apiBase) cleanedEnv.ANTHROPIC_BASE_URL = tpl.apiBase;
if (tpl.defaultModel) cleanedEnv.ANTHROPIC_MODEL = tpl.defaultModel;
if (tpl.opusModel) cleanedEnv.ANTHROPIC_DEFAULT_OPUS_MODEL = tpl.opusModel;
if (tpl.sonnetModel) cleanedEnv.ANTHROPIC_DEFAULT_SONNET_MODEL = tpl.sonnetModel;
if (tpl.haikuModel) cleanedEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL = tpl.haikuModel;
settings.env = cleanedEnv;
// 原子写入:先写临时文件再 rename避免 Claude 子进程读到写了一半的文件
const tmpPath = CLAUDE_SETTINGS_PATH + '.tmp';
try {
fs.writeFileSync(tmpPath, JSON.stringify(settings, null, 2));
fs.renameSync(tmpPath, CLAUDE_SETTINGS_PATH);
} catch {
try { fs.unlinkSync(tmpPath); } catch {}
}
}
function applyModelConfig() {
const config = loadModelConfig();
if (config.mode === 'custom' && config.activeTemplate) {
const tpl = (config.templates || []).find(t => t.name === config.activeTemplate);
if (tpl) {
if (tpl.opusModel) MODEL_MAP.opus = tpl.opusModel.endsWith('[1m]') ? tpl.opusModel : tpl.opusModel + '[1m]';
if (tpl.sonnetModel) MODEL_MAP.sonnet = tpl.sonnetModel.endsWith('[1m]') ? tpl.sonnetModel : tpl.sonnetModel + '[1m]';
if (tpl.haikuModel) MODEL_MAP.haiku = tpl.haikuModel;
return;
}
}
// mode === 'local': read model names from ~/.claude.json
const localMap = loadClaudeJsonModelMap();
if (localMap) {
if (localMap.opus) MODEL_MAP.opus = localMap.opus;
if (localMap.sonnet) MODEL_MAP.sonnet = localMap.sonnet;
if (localMap.haiku) MODEL_MAP.haiku = localMap.haiku;
}
}
// Apply on startup
applyModelConfig();
const MIME_TYPES = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
};
// === Utility Functions ===
function wsSend(ws, data) {
if (ws && ws.readyState === 1) ws.send(JSON.stringify(data));
}
function sanitizeId(id) {
return String(id).replace(/[^a-zA-Z0-9\-]/g, '');
}
function sessionPath(id) {
return path.join(SESSIONS_DIR, `${sanitizeId(id)}.json`);
}
function runDir(sessionId) {
return path.join(SESSIONS_DIR, `${sanitizeId(sessionId)}-run`);
}
function attachmentDataPath(id, ext = '') {
return path.join(ATTACHMENTS_DIR, `${sanitizeId(id)}${ext}`);
}
function attachmentMetaPath(id) {
return path.join(ATTACHMENTS_DIR, `${sanitizeId(id)}.json`);
}
function safeFilename(name) {
return String(name || 'image')
.replace(/[\/\\?%*:|"<>]/g, '-')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 120) || 'image';
}
function contentDispositionInline(filename) {
const raw = safeFilename(filename || 'image');
const fallback = raw
.replace(/[^\x20-\x7E]/g, '_')
.replace(/["\\;]/g, '_')
.trim()
.slice(0, 120) || 'image';
return `inline; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(raw)}`;
}
function extFromMime(mime) {
switch (mime) {
case 'image/png': return '.png';
case 'image/jpeg': return '.jpg';
case 'image/webp': return '.webp';
case 'image/gif': return '.gif';
default: return '';
}
}
function loadAttachmentMeta(id) {
try {
return JSON.parse(fs.readFileSync(attachmentMetaPath(id), 'utf8'));
} catch {
return null;
}
}
function saveAttachmentMeta(meta) {
fs.writeFileSync(attachmentMetaPath(meta.id), JSON.stringify(meta, null, 2));
}
function removeAttachmentById(id) {
const meta = loadAttachmentMeta(id);
const paths = new Set([attachmentMetaPath(id)]);
if (meta?.path) paths.add(meta.path);
for (const filePath of paths) {
try {
if (filePath && fs.existsSync(filePath)) fs.unlinkSync(filePath);
} catch {}
}
}
function currentAttachmentState(meta) {
if (!meta) return 'missing';
const expiresAtMs = new Date(meta.expiresAt || 0).getTime();
if (expiresAtMs && Date.now() > expiresAtMs) return 'expired';
if (!meta.path || !fs.existsSync(meta.path)) return 'missing';
return 'available';
}
function normalizeMessageAttachments(attachments) {
if (!Array.isArray(attachments) || attachments.length === 0) return [];
const normalized = [];
for (const attachment of attachments) {
const id = sanitizeId(attachment?.id || '');
if (!id) continue;
const meta = loadAttachmentMeta(id);
const state = currentAttachmentState(meta);
if (state === 'expired') removeAttachmentById(id);
normalized.push({
id,
kind: 'image',
filename: meta?.filename || attachment?.filename || 'image',
mime: meta?.mime || attachment?.mime || 'image/png',
size: meta?.size || attachment?.size || 0,
createdAt: meta?.createdAt || attachment?.createdAt || null,
expiresAt: meta?.expiresAt || attachment?.expiresAt || null,
storageState: state === 'available' ? 'available' : 'expired',
});
}
return normalized;
}
function resolveMessageAttachments(attachments) {
const resolved = [];
for (const attachment of normalizeMessageAttachments(attachments)) {
if (attachment.storageState !== 'available') continue;
const meta = loadAttachmentMeta(attachment.id);
if (!meta?.path || !fs.existsSync(meta.path)) continue;
resolved.push({
...attachment,
path: meta.path,
});
}
return resolved;
}
function cleanupExpiredAttachments() {
try {
const files = fs.readdirSync(ATTACHMENTS_DIR).filter((name) => name.endsWith('.json'));
for (const file of files) {
const id = file.replace(/\.json$/, '');
const meta = loadAttachmentMeta(id);
if (!meta || currentAttachmentState(meta) === 'expired') {
removeAttachmentById(id);
}
}
} catch {}
}
function collectSessionAttachmentIds(session) {
const ids = new Set();
for (const message of Array.isArray(session?.messages) ? session.messages : []) {
for (const attachment of Array.isArray(message?.attachments) ? message.attachments : []) {
const id = sanitizeId(attachment?.id || '');
if (id) ids.add(id);
}
}
return Array.from(ids);
}
function extractBearerToken(req) {
const authHeader = String(req.headers.authorization || '');
const m = authHeader.match(/^Bearer\s+(.+)$/i);
return m ? m[1] : '';
}
function jsonResponse(res, statusCode, payload) {
res.writeHead(statusCode, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-cache',
});
res.end(JSON.stringify(payload));
}
function readJsonBody(req, maxBytes = 1024 * 1024) {
return new Promise((resolve, reject) => {
const chunks = [];
let total = 0;
let finished = false;
req.on('data', (chunk) => {
if (finished) return;
total += chunk.length;
if (total > maxBytes) {
finished = true;
reject(new Error('请求体过大'));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('end', () => {
if (finished) return;
finished = true;
try {
const text = Buffer.concat(chunks).toString('utf8').trim();
resolve(text ? JSON.parse(text) : {});
} catch {
reject(new Error('请求体不是有效 JSON'));
}
});
req.on('error', (err) => {
if (finished) return;
finished = true;
reject(err);
});
});
}
function normalizeRelativeBrowserPath(input) {
return String(input || '')
.replace(/\\/g, '/')
.split('/')
.filter((part) => part && part !== '.')
.join('/');
}
function isPathInside(parentPath, targetPath) {
const relative = path.relative(parentPath, targetPath);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
}
function normalizeExistingDirPath(candidate) {
if (!candidate) return null;
try {
const resolvedPath = path.resolve(String(candidate));
if (!fs.existsSync(resolvedPath)) return null;
const realPath = fs.realpathSync(resolvedPath);
if (!fs.statSync(realPath).isDirectory()) return null;
return realPath;
} catch {
return null;
}
}
function getDefaultSessionCwd() {
return normalizeExistingDirPath(process.env.HOME || process.env.USERPROFILE || process.cwd())
|| normalizeExistingDirPath(process.cwd())
|| path.resolve(process.cwd());
}
function resolveSessionCwd(candidate, options = {}) {
if (!candidate || !String(candidate).trim()) {
return { ok: true, path: getDefaultSessionCwd(), created: false };
}
const resolvedPath = path.resolve(String(candidate).trim());
try {
if (fs.existsSync(resolvedPath)) {
const realPath = fs.realpathSync(resolvedPath);
const stat = fs.statSync(realPath);
if (!stat.isDirectory()) {
return {
ok: false,
code: 'new_session_cwd_invalid',
resolvedPath,
message: '工作目录不是目录,请重新选择。',
};
}
return { ok: true, path: realPath, created: false };
}
if (!options.createMissing) {
return {
ok: false,
code: 'new_session_cwd_missing',
resolvedPath,
message: '工作目录不存在',
};
}
fs.mkdirSync(resolvedPath, { recursive: true });
const createdPath = normalizeExistingDirPath(resolvedPath);
if (!createdPath) {
return {
ok: false,
code: 'new_session_cwd_create_failed',
resolvedPath,
message: '目录已创建,但当前进程无法访问,请检查权限。',
};
}
return { ok: true, path: createdPath, created: true };
} catch (err) {
return {
ok: false,
code: options.createMissing ? 'new_session_cwd_create_failed' : 'new_session_cwd_invalid',
resolvedPath,
message: `${options.createMissing ? '创建工作目录失败' : '解析工作目录失败'}: ${err.message}`,
};
}
}
function collectRecentSessionCwds(limit = 12) {
const results = [];
const seen = new Set();
const pushPath = (candidate) => {
const normalized = normalizeExistingDirPath(candidate);
if (!normalized || seen.has(normalized)) return;
seen.add(normalized);
results.push(normalized);
};
pushPath(getDefaultSessionCwd());
pushPath(process.cwd());
if (process.platform === 'win32') {
for (const letter of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') {
pushPath(`${letter}:\\`);
if (results.length >= limit) return results.slice(0, limit);
}
}
try {
const files = fs.readdirSync(SESSIONS_DIR)
.filter((name) => name.endsWith('.json'))
.map((name) => {
const fullPath = path.join(SESSIONS_DIR, name);
let updatedAt = 0;
try {
updatedAt = fs.statSync(fullPath).mtimeMs;
} catch {}
return { name, updatedAt };
})
.sort((a, b) => b.updatedAt - a.updatedAt);
for (const file of files) {
try {
const session = normalizeSession(JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, file.name), 'utf8')));
pushPath(session.cwd);
if (results.length >= limit) break;
} catch {}
}
} catch {}
return results.slice(0, limit);
}
function resolveDirectoryPickerTarget(requestedPath) {
const fallbackPath = getDefaultSessionCwd();
try {
const rawPath = String(requestedPath || '').trim();
const candidatePath = rawPath ? path.resolve(rawPath) : fallbackPath;
if (!fs.existsSync(candidatePath)) {
return { ok: false, statusCode: 404, message: '目标目录不存在' };
}
const realPath = fs.realpathSync(candidatePath);
const stat = fs.statSync(realPath);
if (!stat.isDirectory()) {
return { ok: false, statusCode: 400, message: '目标路径不是目录' };
}
const parentPath = path.dirname(realPath);
return {
ok: true,
realPath,
parentPath: parentPath === realPath ? '' : parentPath,
defaultPath: fallbackPath,
};
} catch (err) {
return { ok: false, statusCode: 500, message: `解析目录失败: ${err.message}` };
}
}
function getSessionBrowseContext(sessionId) {
const session = loadSession(sessionId);
if (!session) {
return { ok: false, statusCode: 404, message: '会话不存在' };
}
const rootCandidate = session.cwd || activeProcesses.get(sessionId)?.cwd || activeCodexAppTurns.get(sessionId)?.cwd || null;
if (!rootCandidate) {
return { ok: false, statusCode: 400, message: '当前会话没有可浏览的工作目录' };
}
try {
const resolvedRoot = path.resolve(String(rootCandidate));
if (!fs.existsSync(resolvedRoot)) {
return { ok: false, statusCode: 404, message: '工作目录不存在' };
}
const realRoot = fs.realpathSync(resolvedRoot);
const stat = fs.statSync(realRoot);
if (!stat.isDirectory()) {
return { ok: false, statusCode: 400, message: '工作目录不是目录' };
}
return { ok: true, session, rootDir: realRoot };
} catch (err) {
return { ok: false, statusCode: 500, message: `解析工作目录失败: ${err.message}` };
}
}
function resolveBrowseTarget(rootDir, requestedPath) {
try {
const candidatePath = requestedPath
? path.resolve(rootDir, String(requestedPath))
: rootDir;
if (!fs.existsSync(candidatePath)) {
return { ok: false, statusCode: 404, message: '目标路径不存在' };
}
const realPath = fs.realpathSync(candidatePath);
if (!isPathInside(rootDir, realPath)) {
return { ok: false, statusCode: 403, message: '目标路径超出允许范围' };
}
return {
ok: true,
realPath,
relativePath: normalizeRelativeBrowserPath(path.relative(rootDir, realPath)),
};
} catch (err) {
return { ok: false, statusCode: 500, message: `解析目标路径失败: ${err.message}` };
}
}
function isPreviewTextExtension(filePath) {
return TEXT_PREVIEW_EXTENSIONS.has(path.extname(filePath).toLowerCase());
}
function isProbablyTextBuffer(buffer) {
if (!buffer || buffer.length === 0) return true;
const sample = buffer.subarray(0, Math.min(buffer.length, 8192));
let suspicious = 0;
for (const byte of sample) {
if (byte === 0) return false;
if (byte < 7 || (byte > 13 && byte < 32)) suspicious++;
}
return suspicious / sample.length < 0.05;
}
function readFilePreviewBuffer(filePath, maxBytes) {
const previewSize = Math.max(0, Math.min(maxBytes, fs.statSync(filePath).size));
if (previewSize === 0) return Buffer.alloc(0);
const fd = fs.openSync(filePath, 'r');
try {
const buffer = Buffer.alloc(previewSize);
const read = fs.readSync(fd, buffer, 0, previewSize, 0);
return buffer.subarray(0, read);
} finally {
fs.closeSync(fd);
}
}
const COMPOSER_COMMANDS = [
{ name: '/clear', description: '清除当前会话', insertion: '/clear ' },
{ name: '/model', description: '查看/切换模型', insertion: '/model ' },
{ name: '/mode', description: '查看/切换权限模式', insertion: '/mode ' },
{ name: '/cost', description: '查看会话费用或 Token', insertion: '/cost ' },
{ name: '/compact', description: '压缩上下文', insertion: '/compact ' },
{ name: '/init', description: '生成/更新 Agent 指南文件', insertion: '/init ' },
{ name: '/help', description: '显示帮助', insertion: '/help ' },
];
const DEFAULT_COMPOSER_PROMPTS = [
{
name: 'plan',
title: '规划模式',
description: '先分析需求并给出实施计划,暂不修改文件。',
content: '请先分析需求、边界和风险,给出可执行的实施计划。暂时不要修改文件,等我确认后再落地。',
source: 'builtin',
},
{
name: 'review',
title: '代码审查',
description: '按代码审查视角优先指出问题。',
content: '请以代码审查视角检查当前上下文,优先指出 bug、行为回归、安全风险和缺失测试结论要可验证。',
source: 'builtin',
},
{
name: 'explain',
title: '解释代码',
description: '解释相关代码结构、调用链和取舍。',
content: '请解释相关代码的结构、关键调用链、设计取舍和可能的扩展点,尽量结合具体文件或函数说明。',
source: 'builtin',
},
];
function getCodexHomeDir() {
const explicit = String(process.env.CODEX_HOME || '').trim();
if (explicit) return path.resolve(explicit);
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
return homeDir ? path.join(homeDir, '.codex') : '';
}
function parseSimpleFrontmatter(text) {
const raw = String(text || '');
if (!raw.startsWith('---\n') && !raw.startsWith('---\r\n')) {
return { meta: {}, body: raw };
}
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
if (!match) return { meta: {}, body: raw };
const meta = {};
for (const line of match[1].split(/\r?\n/)) {
const m = line.match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/);
if (!m) continue;
let value = m[2].trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) {
value = value.slice(1, -1);
}
meta[m[1]] = value;
}
return { meta, body: raw.slice(match[0].length) };
}
function normalizeComposerKey(value) {
return String(value || '').trim().replace(/^[@$]/, '').toLowerCase();
}
function collectFilesByName(rootDir, fileNames, options = {}) {
const maxDepth = Number.isFinite(options.maxDepth) ? options.maxDepth : 4;
const maxFiles = Number.isFinite(options.maxFiles) ? options.maxFiles : 200;
const ignoreDirs = new Set(['node_modules', '.git', 'sessions', 'logs']);
const found = [];
function walk(dir, depth) {
if (found.length >= maxFiles || depth > maxDepth) return;
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
entries.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN', { numeric: true, sensitivity: 'base' }));
for (const entry of entries) {
if (found.length >= maxFiles) return;
const entryPath = path.join(dir, entry.name);
if (entry.isFile() && fileNames.has(entry.name)) {
found.push(entryPath);
} else if (entry.isDirectory() && !ignoreDirs.has(entry.name)) {
walk(entryPath, depth + 1);
}
}
}
try {
const realRoot = fs.realpathSync(rootDir);
if (fs.statSync(realRoot).isDirectory()) walk(realRoot, 0);
} catch {}
return found;
}
function composerSkillRoots() {
const roots = [];
const codexHome = getCodexHomeDir();
if (codexHome) roots.push(path.join(codexHome, 'skills'));
roots.push(path.join(__dirname, '.codex', 'skills'));
roots.push(path.join(__dirname, '.agents', 'skills'));
return roots;
}
function loadCodexSkills() {
const seen = new Set();
const skills = [];
for (const root of composerSkillRoots()) {
for (const skillPath of collectFilesByName(root, new Set(['SKILL.md']), { maxDepth: 5, maxFiles: 300 })) {
let text = '';
try { text = fs.readFileSync(skillPath, 'utf8'); } catch { continue; }
const { meta } = parseSimpleFrontmatter(text);
const dirName = path.basename(path.dirname(skillPath));
const name = String(meta.name || dirName || '').trim();
if (!name) continue;
const key = normalizeComposerKey(name);
if (!key || seen.has(key)) continue;
seen.add(key);
skills.push({
kind: 'skill',
name,
label: `$${name}`,
description: String(meta.description || 'Codex skill').trim(),
insertion: `$${name}`,
source: path.relative(process.cwd(), skillPath) || skillPath,
});
}
}
return skills;
}
function composerPromptRoots() {
const roots = [];
const codexHome = getCodexHomeDir();
if (codexHome) roots.push(path.join(codexHome, 'prompts'));
roots.push(path.join(__dirname, '.codex', 'prompts'));
roots.push(path.join(__dirname, '.agents', 'prompts'));
return roots;
}
function normalizePromptRecord(raw, source) {
if (!raw || typeof raw !== 'object') return null;
const name = String(raw.name || raw.id || '').trim().replace(/^@/, '');
const content = String(raw.content || raw.prompt || raw.text || '').trim();
if (!name || !content) return null;
return {
kind: 'prompt',
name,
title: String(raw.title || name).trim(),
description: String(raw.description || '').trim(),
content,
label: `@${name}`,
insertion: `@${name}`,
source,
};
}
function loadConfiguredPrompts() {
try {
if (!fs.existsSync(PROMPTS_CONFIG_PATH)) return [];
const raw = JSON.parse(fs.readFileSync(PROMPTS_CONFIG_PATH, 'utf8'));
const list = Array.isArray(raw) ? raw : Array.isArray(raw.prompts) ? raw.prompts : [];
return list.map((item) => normalizePromptRecord(item, 'config/prompts.json')).filter(Boolean);
} catch {
return [];
}
}
function loadPromptFiles() {
const prompts = [];
for (const root of composerPromptRoots()) {
const files = collectFilesByName(root, new Set(['prompt.md', 'PROMPT.md']), { maxDepth: 5, maxFiles: 200 });
try {
if (fs.existsSync(root)) {
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (entry.isFile() && /\.(md|txt)$/i.test(entry.name)) files.push(path.join(root, entry.name));
}
}
} catch {}
for (const filePath of files) {
let text = '';
try { text = fs.readFileSync(filePath, 'utf8'); } catch { continue; }
const { meta, body } = parseSimpleFrontmatter(text);
const fallbackName = path.basename(filePath).replace(/\.(md|txt)$/i, '');
const prompt = normalizePromptRecord({
name: meta.name || fallbackName,
title: meta.title || meta.name || fallbackName,
description: meta.description || '',
content: body,
}, path.relative(process.cwd(), filePath) || filePath);
if (prompt) prompts.push(prompt);
}
}
return prompts;
}
function loadComposerPrompts() {
const seen = new Set();
const prompts = [];
for (const prompt of [
...loadConfiguredPrompts(),
...loadPromptFiles(),
...DEFAULT_COMPOSER_PROMPTS.map((item) => normalizePromptRecord(item, item.source)),
]) {
if (!prompt) continue;
const key = normalizeComposerKey(prompt.name);
if (!key || seen.has(key)) continue;
seen.add(key);
prompts.push(prompt);
}
return prompts;
}
function getComposerPromptMap() {
const map = new Map();
for (const prompt of loadComposerPrompts()) {
map.set(normalizeComposerKey(prompt.name), prompt);
}
return map;
}
function filterComposerItems(items, query) {
const q = normalizeComposerKey(query);
const filtered = q
? items.filter((item) => {
const haystack = `${item.name || ''} ${item.label || ''} ${item.title || ''} ${item.description || ''}`.toLowerCase();
return haystack.includes(q);
})
: items;
return filtered.slice(0, COMPOSER_SUGGESTION_LIMIT);
}
function listComposerMcpTools() {
return CCWEB_MCP_TOOLS.map((tool) => {
const name = String(tool?.name || '').trim();
const label = `mcp:ccweb/${name}`;
return {
kind: 'mcp',
name,
label,
title: `ccweb/${name}`,
description: String(tool?.description || 'MCP 工具').trim(),
insertion: label,
appendSpace: true,
server: 'ccweb',
source: 'mcp:ccweb',
};
}).filter((item) => item.name);
}
function mergeComposerSuggestionGroups(...groups) {
const merged = [];
const seen = new Set();
for (const group of groups) {
for (const item of Array.isArray(group) ? group : []) {
const key = `${item.kind || ''}:${item.server || ''}:${item.name || item.label || item.insertion || ''}`;
if (!key || seen.has(key)) continue;
seen.add(key);
merged.push(item);
if (merged.length >= COMPOSER_SUGGESTION_LIMIT) return merged;
}
}
return merged;
}
function listComposerFileSuggestions(sessionId, query) {
const session = sessionId ? loadSession(sessionId) : null;
const rootCandidate = session?.cwd || getDefaultSessionCwd();
const rootDir = normalizeExistingDirPath(rootCandidate);
if (!rootDir) return [];
const raw = String(query || '').replace(/^@/, '').replace(/\\/g, '/');
const slashIndex = raw.lastIndexOf('/');
const dirPart = slashIndex >= 0 ? raw.slice(0, slashIndex) : '';
const base = slashIndex >= 0 ? raw.slice(slashIndex + 1) : raw;
const target = resolveBrowseTarget(rootDir, dirPart);
if (!target.ok) return [];
let stat;
try { stat = fs.statSync(target.realPath); } catch { return []; }
if (!stat.isDirectory()) return [];
let entries = [];
try { entries = fs.readdirSync(target.realPath, { withFileTypes: true }); } catch { return []; }
const q = base.toLowerCase();
const items = [];
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
if (q && !entry.name.toLowerCase().includes(q)) continue;
const rawEntryPath = path.join(target.realPath, entry.name);
try {
const lstat = fs.lstatSync(rawEntryPath);
const symlink = lstat.isSymbolicLink();
const realEntryPath = symlink ? fs.realpathSync(rawEntryPath) : rawEntryPath;
if (!isPathInside(rootDir, realEntryPath)) continue;
const finalStat = symlink ? fs.statSync(rawEntryPath) : lstat;
const kind = finalStat.isDirectory() ? 'directory' : finalStat.isFile() ? 'file' : null;
if (!kind) continue;
const rel = normalizeRelativeBrowserPath(target.relativePath ? `${target.relativePath}/${entry.name}` : entry.name);
items.push({
kind: 'file',
itemType: kind,
name: rel,
label: `@${rel}${kind === 'directory' ? '/' : ''}`,
description: kind === 'directory' ? '工作目录' : `${finalStat.size} bytes`,
insertion: `@${rel}${kind === 'directory' ? '/' : ''}`,
appendSpace: kind !== 'directory',
});
} catch {}
}
items.sort((a, b) => {
if (a.itemType !== b.itemType) return a.itemType === 'directory' ? -1 : 1;
return a.name.localeCompare(b.name, 'zh-CN', { numeric: true, sensitivity: 'base' });
});
return items.slice(0, COMPOSER_SUGGESTION_LIMIT);
}
function listComposerSuggestions(trigger, query, sessionId, agent) {
const mcpItems = filterComposerItems(listComposerMcpTools(), query);
if (trigger === '/') {
const commands = filterComposerItems(COMPOSER_COMMANDS.map((cmd) => ({
kind: 'command',
name: cmd.name,
label: cmd.name,
description: cmd.description,
insertion: cmd.insertion,
})), query.replace(/^\//, ''));
return mergeComposerSuggestionGroups(commands, mcpItems);
}
if (trigger === '$') {
const skills = isCodexLikeAgent(agent) ? filterComposerItems(loadCodexSkills(), query) : [];
return mergeComposerSuggestionGroups(mcpItems, skills);
}
if (trigger === '@') {
const prompts = filterComposerItems(loadComposerPrompts().map((prompt) => ({
kind: 'prompt',
name: prompt.name,
label: `@${prompt.name}`,
title: prompt.title,
description: prompt.description || 'Prompt 模板',
insertion: `@${prompt.name}`,
appendSpace: true,
})), query);
const files = listComposerFileSuggestions(sessionId, query);
return mergeComposerSuggestionGroups(prompts, mcpItems, files);
}
return [];
}
function handleComposerSuggestions(ws, msg) {
const trigger = ['/', '$', '@'].includes(msg.trigger) ? msg.trigger : '';
const query = String(msg.query || '').replace(/^[@$/]/, '').trim();
const requestId = String(msg.requestId || '');
if (!trigger) {
return wsSend(ws, { type: 'composer_suggestions', requestId, trigger, query, items: [] });
}
const sessionId = sanitizeId(msg.sessionId || '');
const agent = normalizeAgent(msg.agent);
const items = listComposerSuggestions(trigger, query, sessionId, agent);
return wsSend(ws, { type: 'composer_suggestions', requestId, trigger, query, items });
}
function cleanMentionToken(raw) {
return String(raw || '').replace(/[,。,;:!?)\]}]+$/u, '');
}
function resolveComposerFileContext(session, rawPath) {
const rootDir = normalizeExistingDirPath(session?.cwd || getDefaultSessionCwd());
if (!rootDir) return null;
const target = resolveBrowseTarget(rootDir, rawPath);
if (!target.ok) return null;
let stat;
try { stat = fs.statSync(target.realPath); } catch { return null; }
if (stat.isDirectory()) {
let names = [];
try {
names = fs.readdirSync(target.realPath, { withFileTypes: true })
.filter((entry) => entry.isDirectory() || entry.isFile())
.slice(0, 80)
.map((entry) => `${entry.isDirectory() ? '[dir]' : '[file]'} ${entry.name}`);
} catch {}
return {
kind: 'file',
type: 'directory',
path: target.relativePath || '.',
block: `----- BEGIN CC-WEB DIRECTORY: ${target.relativePath || '.'} -----\n${names.join('\n')}\n----- END CC-WEB DIRECTORY -----`,
};
}
if (!stat.isFile()) return null;
let buffer;
try {
buffer = readFilePreviewBuffer(target.realPath, COMPOSER_FILE_CONTEXT_MAX_BYTES);
} catch {
return null;
}
if (!isPreviewTextExtension(target.realPath) && !isProbablyTextBuffer(buffer)) {
return {
kind: 'file',
type: 'binary',
path: target.relativePath,
block: `----- CC-WEB FILE: ${target.relativePath} -----\n该文件不是可安全内联的文本文件,未展开内容。`,
};
}
const truncated = stat.size > buffer.length;
return {
kind: 'file',
type: 'file',
path: target.relativePath,
truncated,
block: `----- BEGIN CC-WEB FILE: ${target.relativePath}${truncated ? ' (truncated)' : ''} -----\n${buffer.toString('utf8')}\n----- END CC-WEB FILE -----`,
};
}
function resolveComposerDecorators(text, session, agent) {
const rawText = String(text || '');
if (!rawText.trim() || rawText.trimStart().startsWith('/')) {
return { runtimeText: rawText, mentions: [] };
}
const promptsByName = getComposerPromptMap();
const skillsByName = new Map(loadCodexSkills().map((skill) => [normalizeComposerKey(skill.name), skill]));
const mentions = [];
const promptBlocks = [];
const fileBlocks = [];
const seenPromptNames = new Set();
const seenFilePaths = new Set();
const seenSkillNames = new Set();
const re = /(^|\s)([@$])([^\s]+)/g;
let match;
while ((match = re.exec(rawText))) {
const trigger = match[2];
const token = cleanMentionToken(match[3]);
const key = normalizeComposerKey(token);
if (!key) continue;
if (trigger === '$' && isCodexLikeAgent(agent)) {
const skill = skillsByName.get(key);
if (skill && !seenSkillNames.has(key)) {
seenSkillNames.add(key);
mentions.push({ kind: 'skill', name: skill.name, label: `$${skill.name}`, source: skill.source || '' });
}
continue;
}
if (trigger !== '@') continue;
const prompt = promptsByName.get(key);
if (prompt && !seenPromptNames.has(key) && seenPromptNames.size < COMPOSER_MAX_PROMPT_MENTIONS) {
seenPromptNames.add(key);
mentions.push({ kind: 'prompt', name: prompt.name, label: `@${prompt.name}`, title: prompt.title || prompt.name, source: prompt.source || '' });
promptBlocks.push(`----- BEGIN CC-WEB PROMPT: ${prompt.name} -----\n${prompt.content}\n----- END CC-WEB PROMPT -----`);
continue;
}
if (seenFilePaths.size >= COMPOSER_MAX_FILE_MENTIONS) continue;
const fileContext = resolveComposerFileContext(session, token);
if (fileContext && !seenFilePaths.has(fileContext.path)) {
seenFilePaths.add(fileContext.path);
mentions.push({
kind: 'file',
name: fileContext.path,
label: `@${fileContext.path}`,
type: fileContext.type,
truncated: !!fileContext.truncated,
});
fileBlocks.push(fileContext.block);
}
}
if (promptBlocks.length === 0 && fileBlocks.length === 0) {
return { runtimeText: rawText, mentions };
}
const context = [
...promptBlocks,
...fileBlocks,
'----- BEGIN USER MESSAGE -----',
rawText,
'----- END USER MESSAGE -----',
].join('\n\n');
return { runtimeText: context, mentions };
}
function handleFileSystemListApi(req, res, url) {
const token = extractBearerToken(req);
if (!token || !activeTokens.has(token)) {
return jsonResponse(res, 401, { ok: false, message: 'Not authenticated' });
}
const sessionId = sanitizeId(url.searchParams.get('sessionId') || '');
if (!sessionId) {
return jsonResponse(res, 400, { ok: false, message: '缺少 sessionId' });
}
const browseContext = getSessionBrowseContext(sessionId);
if (!browseContext.ok) {
return jsonResponse(res, browseContext.statusCode, { ok: false, message: browseContext.message });
}
const target = resolveBrowseTarget(browseContext.rootDir, url.searchParams.get('path') || '');
if (!target.ok) {
return jsonResponse(res, target.statusCode, { ok: false, message: target.message });
}
let stat;
try {
stat = fs.statSync(target.realPath);
} catch (err) {
return jsonResponse(res, 500, { ok: false, message: `读取目录失败: ${err.message}` });
}
if (!stat.isDirectory()) {
return jsonResponse(res, 400, { ok: false, message: '目标路径不是目录' });
}
let dirEntries = [];
try {
dirEntries = fs.readdirSync(target.realPath, { withFileTypes: true });
} catch (err) {
return jsonResponse(res, 500, { ok: false, message: `读取目录失败: ${err.message}` });
}
const entries = [];
for (const entry of dirEntries) {
const rawEntryPath = path.join(target.realPath, entry.name);
try {
const lstat = fs.lstatSync(rawEntryPath);
const symlink = lstat.isSymbolicLink();
const resolvedEntryPath = symlink ? fs.realpathSync(rawEntryPath) : rawEntryPath;
if (!isPathInside(browseContext.rootDir, resolvedEntryPath)) continue;
const finalStat = symlink ? fs.statSync(rawEntryPath) : lstat;
const kind = finalStat.isDirectory() ? 'directory' : finalStat.isFile() ? 'file' : null;
if (!kind) continue;
const childRelativePath = normalizeRelativeBrowserPath(
target.relativePath ? `${target.relativePath}/${entry.name}` : entry.name
);
entries.push({
name: entry.name,
path: childRelativePath,
kind,
size: kind === 'file' ? finalStat.size : 0,
updatedAt: finalStat.mtime.toISOString(),
previewableHint: kind === 'file' && (isPreviewTextExtension(entry.name) || !path.extname(entry.name)),
symlink,
});
} catch {}
}
entries.sort((a, b) => {
if (a.kind !== b.kind) return a.kind === 'directory' ? -1 : 1;
return a.name.localeCompare(b.name, 'zh-CN', { numeric: true, sensitivity: 'base' });
});
const parentPath = target.relativePath
? normalizeRelativeBrowserPath(path.posix.dirname(target.relativePath))
: null;
return jsonResponse(res, 200, {
ok: true,
sessionId,
rootPath: browseContext.rootDir,
currentPath: target.relativePath,
currentDisplayPath: target.realPath,
parentPath: parentPath && parentPath !== '.' ? parentPath : '',
truncated: entries.length > FILE_BROWSER_MAX_LIST_ENTRIES,
entryLimit: FILE_BROWSER_MAX_LIST_ENTRIES,
entries: entries.slice(0, FILE_BROWSER_MAX_LIST_ENTRIES),
});
}
function handleFileSystemReadApi(req, res, url) {
const token = extractBearerToken(req);
if (!token || !activeTokens.has(token)) {
return jsonResponse(res, 401, { ok: false, message: 'Not authenticated' });
}
const sessionId = sanitizeId(url.searchParams.get('sessionId') || '');
if (!sessionId) {
return jsonResponse(res, 400, { ok: false, message: '缺少 sessionId' });
}
const browseContext = getSessionBrowseContext(sessionId);
if (!browseContext.ok) {
return jsonResponse(res, browseContext.statusCode, { ok: false, message: browseContext.message });
}
const target = resolveBrowseTarget(browseContext.rootDir, url.searchParams.get('path') || '');
if (!target.ok) {
return jsonResponse(res, target.statusCode, { ok: false, message: target.message });
}
let stat;
try {
stat = fs.statSync(target.realPath);
} catch (err) {
return jsonResponse(res, 500, { ok: false, message: `读取文件失败: ${err.message}` });
}
if (!stat.isFile()) {
return jsonResponse(res, 400, { ok: false, message: '目标路径不是文件' });
}
let previewBuffer;
try {
previewBuffer = readFilePreviewBuffer(target.realPath, FILE_BROWSER_MAX_PREVIEW_BYTES);
} catch (err) {
return jsonResponse(res, 500, { ok: false, message: `读取文件失败: ${err.message}` });
}
if (!isPreviewTextExtension(target.realPath) && !isProbablyTextBuffer(previewBuffer)) {
return jsonResponse(res, 415, { ok: false, message: '当前仅支持预览简单文本文件' });
}
return jsonResponse(res, 200, {
ok: true,
sessionId,
rootPath: browseContext.rootDir,
path: target.relativePath,
name: path.basename(target.realPath),
size: stat.size,
updatedAt: stat.mtime.toISOString(),
truncated: stat.size > FILE_BROWSER_MAX_PREVIEW_BYTES,
previewBytes: previewBuffer.length,
content: previewBuffer.toString('utf8'),
});
}
function handleDirectoryPickerListApi(req, res, url) {
const token = extractBearerToken(req);
if (!token || !activeTokens.has(token)) {
return jsonResponse(res, 401, { ok: false, message: 'Not authenticated' });
}
const target = resolveDirectoryPickerTarget(url.searchParams.get('path') || '');
if (!target.ok) {
return jsonResponse(res, target.statusCode, { ok: false, message: target.message });
}
let dirEntries = [];
try {
dirEntries = fs.readdirSync(target.realPath, { withFileTypes: true });
} catch (err) {
return jsonResponse(res, 500, { ok: false, message: `读取目录失败: ${err.message}` });
}
const entries = [];
for (const entry of dirEntries) {
const rawEntryPath = path.join(target.realPath, entry.name);
try {
const lstat = fs.lstatSync(rawEntryPath);
const symlink = lstat.isSymbolicLink();
const realEntryPath = symlink ? fs.realpathSync(rawEntryPath) : rawEntryPath;
const stat = symlink ? fs.statSync(rawEntryPath) : lstat;
if (!stat.isDirectory()) continue;
entries.push({
name: entry.name,
path: realEntryPath,
kind: 'directory',
updatedAt: stat.mtime.toISOString(),
symlink,
});
} catch {}
}
entries.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN', { numeric: true, sensitivity: 'base' }));
return jsonResponse(res, 200, {
ok: true,
defaultPath: target.defaultPath,
currentPath: target.realPath,
parentPath: target.parentPath,
truncated: entries.length > FILE_BROWSER_MAX_LIST_ENTRIES,
entryLimit: FILE_BROWSER_MAX_LIST_ENTRIES,
entries: entries.slice(0, FILE_BROWSER_MAX_LIST_ENTRIES),
});
}
const INITIAL_HISTORY_COUNT = 12;
const HISTORY_CHUNK_SIZE = 24;
function normalizeAgent(agent) {
return VALID_AGENTS.has(agent) ? agent : 'claude';
}
function isCodexLikeAgent(agent) {
const normalized = normalizeAgent(agent);
return normalized === 'codex' || normalized === 'codexapp';
}
function normalizeSession(session) {
if (!session || typeof session !== 'object') return 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;
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;
if (!Object.prototype.hasOwnProperty.call(session, 'totalCost')) session.totalCost = 0;
if (!Object.prototype.hasOwnProperty.call(session, 'totalUsage') || !session.totalUsage) {
session.totalUsage = { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 };
}
if (!Object.prototype.hasOwnProperty.call(session, 'messages')) session.messages = [];
if (Array.isArray(session.messages)) {
session.messages = session.messages.map((message) => {
if (!message || typeof message !== 'object') return message;
if (message.attachments) {
return { ...message, attachments: normalizeMessageAttachments(message.attachments) };
}
return message;
});
}
return session;
}
function getSessionAgent(session) {
return normalizeAgent(session?.agent);
}
function isClaudeSession(session) {
return getSessionAgent(session) === 'claude';
}
function isCodexSession(session) {
return getSessionAgent(session) === 'codex';
}
function isCodexAppSession(session) {
return getSessionAgent(session) === 'codexapp';
}
function isCodexLikeSession(session) {
const agent = getSessionAgent(session);
return agent === 'codex' || agent === 'codexapp';
}
function isSessionRunning(sessionId) {
return activeProcesses.has(sessionId) || activeCodexAppTurns.has(sessionId);
}
function getRuntimeSessionId(session) {
if (!session) return null;
const agent = getSessionAgent(session);
if (agent === 'codex') return session.codexThreadId || null;
if (agent === 'codexapp') return session.codexAppThreadId || null;
return session.claudeSessionId || null;
}
async function handleReloadMcpApi(req, res, rawSessionId) {
const token = extractBearerToken(req);
if (!token || !activeTokens.has(token)) {
return jsonResponse(res, 401, { ok: false, message: 'Not authenticated' });
}
const sessionId = sanitizeId(rawSessionId || '');
if (!sessionId) {
return jsonResponse(res, 400, { ok: false, code: 'missing_session_id', message: '缺少会话 ID' });
}
const session = loadSession(sessionId);
if (!session) {
return jsonResponse(res, 404, { ok: false, code: 'session_not_found', message: '会话不存在' });
}
if (!isCodexAppSession(session)) {
return jsonResponse(res, 400, {
ok: false,
code: 'reload_mcp_unsupported_agent',
message: '重载 MCP 仅支持 Codex App 会话。旧 Codex 会话请重启本地 Codex 后再继续。',
});
}
try {
const clientResult = getCodexAppClient();
if (clientResult.error) {
return jsonResponse(res, 500, {
ok: false,
code: 'codexapp_client_unavailable',
message: clientResult.error,
});
}
const client = clientResult.client;
await client.start();
const result = typeof client.reloadMcpServers === 'function'
? await client.reloadMcpServers()
: await client.request('config/mcpServer/reload', {}, 30000);
plog('INFO', 'codex_app_mcp_reload_requested', {
sessionId: sessionId.slice(0, 8),
threadId: getRuntimeSessionId(session) || null,
});
return jsonResponse(res, 200, {
ok: true,
sessionId,
threadId: getRuntimeSessionId(session) || null,
result: result || {},
});
} catch (err) {
const unsupported = err?.code === -32601 || /not found|unknown|unsupported|method/i.test(String(err?.message || ''));
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}`,
});
}
}
function setRuntimeSessionId(session, runtimeId) {
if (!session) return;
const agent = getSessionAgent(session);
if (agent === 'codex') {
session.codexThreadId = runtimeId || null;
} else if (agent === 'codexapp') {
session.codexAppThreadId = runtimeId || null;
} else {
session.claudeSessionId = runtimeId || null;
}
}
function clearRuntimeSessionId(session) {
setRuntimeSessionId(session, null);
}
function textByteLength(text) {
return Buffer.byteLength(String(text || ''), 'utf8');
}
function truncateTextValue(value, maxChars, marker = PERSIST_TRUNCATED_TEXT) {
const text = String(value || '');
if (!Number.isFinite(maxChars) || maxChars <= 0 || text.length <= maxChars) return text;
const suffix = `\n\n${marker}`;
const keep = Math.max(0, maxChars - suffix.length);
return `${text.slice(0, keep)}${suffix}`;
}
function sanitizePersistValue(value, options = {}, depth = 0, seen = new WeakSet()) {
const maxString = options.maxString || SESSION_MESSAGE_CONTENT_MAX_CHARS;
const maxDepth = options.maxDepth || 4;
const maxArray = options.maxArray || 80;
const maxKeys = options.maxKeys || 80;
if (value === null || value === undefined) return value;
if (typeof value === 'string') return truncateTextValue(value, maxString);
if (typeof value === 'number' || typeof value === 'boolean') return value;
if (typeof value === 'bigint') return String(value);
if (typeof value === 'function' || typeof value === 'symbol') return undefined;
if (value instanceof Date) return value.toISOString();
if (Buffer.isBuffer(value)) return `[Buffer ${value.length} bytes]`;
if (depth >= maxDepth) return '[Object truncated]';
if (typeof value !== 'object') return String(value);
if (seen.has(value)) return '[Circular]';
seen.add(value);
if (value instanceof Map) {
const output = {};
let index = 0;
for (const [key, item] of value.entries()) {
if (index >= maxKeys) {
output.__truncated = `已省略 ${value.size - index}`;
break;
}
output[String(key)] = sanitizePersistValue(item, options, depth + 1, seen);
index += 1;
}
seen.delete(value);
return output;
}
if (Array.isArray(value)) {
const limit = Math.min(value.length, maxArray);
const output = [];
for (let index = 0; index < limit; index += 1) {
output.push(sanitizePersistValue(value[index], options, depth + 1, seen));
}
if (value.length > limit) output.push({ __truncated: `已省略 ${value.length - limit}` });
seen.delete(value);
return output;
}
const output = {};
const keys = Object.keys(value);
const limit = Math.min(keys.length, maxKeys);
for (let index = 0; index < limit; index += 1) {
const key = keys[index];
const next = sanitizePersistValue(value[key], options, depth + 1, seen);
if (next !== undefined) output[key] = next;
}
if (keys.length > limit) output.__truncated = `已省略 ${keys.length - limit} 个字段`;
seen.delete(value);
return output;
}
function sanitizeToolCallsForPersist(toolCalls, limits = {}) {
const list = Array.isArray(toolCalls) ? toolCalls : [];
const maxCalls = limits.maxToolCalls || SESSION_MAX_TOOL_CALLS_PER_MESSAGE;
const selected = list.slice(0, maxCalls);
const sanitized = selected.map((toolCall) => {
const output = sanitizePersistValue(toolCall || {}, {
maxString: limits.toolResultMaxChars || SESSION_TOOL_RESULT_MAX_CHARS,
maxDepth: 5,
maxArray: 80,
maxKeys: 80,
});
if (!output || typeof output !== 'object' || Array.isArray(output)) return output;
if (Object.prototype.hasOwnProperty.call(toolCall || {}, 'input')) {
output.input = sanitizePersistValue(toolCall.input, {
maxString: limits.toolInputMaxChars || SESSION_TOOL_INPUT_MAX_CHARS,
maxDepth: 5,
maxArray: 80,
maxKeys: 80,
});
}
if (Object.prototype.hasOwnProperty.call(toolCall || {}, 'result')) {
output.result = sanitizePersistValue(toolCall.result, {
maxString: limits.toolResultMaxChars || SESSION_TOOL_RESULT_MAX_CHARS,
maxDepth: 5,
maxArray: 80,
maxKeys: 80,
});
}
return output;
});
if (list.length > maxCalls) {
sanitized.push({
id: 'ccweb-toolcalls-truncated',
name: 'cc-web',
kind: 'system',
done: true,
result: `工具调用过多,已省略 ${list.length - maxCalls} 条。`,
});
}
return sanitized;
}
function sanitizeMessageForPersist(message, limits = {}) {
if (!message || typeof message !== 'object') return message;
const output = {};
for (const [key, value] of Object.entries(message)) {
if (key === 'content') {
output.content = typeof value === 'string'
? truncateTextValue(value, limits.contentMaxChars || SESSION_MESSAGE_CONTENT_MAX_CHARS)
: sanitizePersistValue(value, {
maxString: limits.contentMaxChars || SESSION_MESSAGE_CONTENT_MAX_CHARS,
maxDepth: 6,
maxArray: 120,
maxKeys: 80,
});
continue;
}
if (key === 'toolCalls') {
output.toolCalls = sanitizeToolCallsForPersist(value, limits);
continue;
}
if (key === 'attachments') {
output.attachments = normalizeMessageAttachments(value);
continue;
}
output[key] = sanitizePersistValue(value, {
maxString: limits.metaMaxChars || 16 * 1024,
maxDepth: 5,
maxArray: 80,
maxKeys: 80,
});
}
return output;
}
function sanitizeMessagesForPersist(messages, limits = {}) {
const list = Array.isArray(messages) ? messages : [];
const maxMessages = limits.maxMessages || SESSION_PERSIST_MAX_MESSAGES;
const selected = list.length > maxMessages ? list.slice(-maxMessages) : list;
const output = selected.map((message) => sanitizeMessageForPersist(message, limits));
if (list.length > selected.length) {
output.unshift({
role: 'system',
content: `历史消息过多cc-web 已只保留最近 ${selected.length} 条用于本地展示,省略 ${list.length - selected.length} 条旧消息。`,
timestamp: new Date().toISOString(),
ccwebPersistenceNotice: true,
});
}
return output;
}
function sanitizeSessionForPersist(session, limits = {}) {
const output = {};
const skipKeys = new Set([
'ws',
'tailer',
'codexAppStateTimer',
'codexAppStateDirty',
'codexAppStateCleaned',
'toolOutputDeltas',
'agentMessageItems',
]);
for (const [key, value] of Object.entries(session || {})) {
if (skipKeys.has(key)) continue;
if (key === 'messages') {
output.messages = sanitizeMessagesForPersist(value, limits);
continue;
}
output[key] = sanitizePersistValue(value, {
maxString: limits.topLevelMaxChars || 16 * 1024,
maxDepth: 4,
maxArray: 100,
maxKeys: 100,
});
}
if (!Object.prototype.hasOwnProperty.call(output, 'messages')) output.messages = [];
return normalizeSession(output);
}
function buildSessionJsonForPersist(session) {
const attempts = [];
let maxMessages = SESSION_PERSIST_MAX_MESSAGES;
let contentMaxChars = SESSION_MESSAGE_CONTENT_MAX_CHARS;
let toolResultMaxChars = SESSION_TOOL_RESULT_MAX_CHARS;
for (let attempt = 0; attempt < 8; attempt += 1) {
const persisted = sanitizeSessionForPersist(session, {
maxMessages,
contentMaxChars,
toolResultMaxChars,
toolInputMaxChars: Math.min(SESSION_TOOL_INPUT_MAX_CHARS, toolResultMaxChars),
maxToolCalls: SESSION_MAX_TOOL_CALLS_PER_MESSAGE,
});
const json = JSON.stringify(persisted, null, 2);
const bytes = textByteLength(json);
attempts.push({ maxMessages, contentMaxChars, toolResultMaxChars, bytes });
if (bytes <= SESSION_MAX_JSON_BYTES) {
return {
json,
persisted,
guarded: attempt > 0 || (Array.isArray(session?.messages) && session.messages.length !== persisted.messages.length),
attempts,
};
}
maxMessages = Math.max(1, Math.floor(maxMessages / 2));
contentMaxChars = Math.max(2048, Math.floor(contentMaxChars / 2));
toolResultMaxChars = Math.max(1024, Math.floor(toolResultMaxChars / 2));
}
const fallback = sanitizeSessionForPersist({
...session,
messages: [{
role: 'system',
content: '当前会话历史过大cc-web 已跳过本地历史明细写入,仅保留会话元数据以保护服务稳定性。',
timestamp: new Date().toISOString(),
ccwebPersistenceNotice: true,
}],
}, {
maxMessages: 1,
contentMaxChars: 2048,
toolResultMaxChars: 1024,
toolInputMaxChars: 1024,
maxToolCalls: 1,
});
const json = JSON.stringify(fallback, null, 2);
attempts.push({ maxMessages: 1, contentMaxChars: 2048, toolResultMaxChars: 1024, bytes: textByteLength(json), fallback: true });
return { json, persisted: fallback, guarded: true, attempts };
}
function writeFileAtomicSync(filePath, content) {
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
try {
fs.writeFileSync(tmpPath, content);
fs.renameSync(tmpPath, filePath);
} finally {
try {
if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
} catch {}
}
}
function safeReadSessionJson(filePath, maxBytes, context = {}) {
const stat = fs.statSync(filePath);
if (stat.size > maxBytes) {
plog('WARN', 'session_json_load_skipped_oversized', {
sessionId: String(context.sessionId || '').slice(0, 8),
fileBytes: stat.size,
maxBytes,
});
return null;
}
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
function jsonStringFieldFromPreview(text, key) {
const pattern = new RegExp(`"${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);
return match ? match[1] === 'true' : false;
}
function readSessionPreview(filePath, stat) {
const headSize = Math.min(stat.size, SESSION_META_PREVIEW_BYTES);
const tailSize = Math.min(stat.size, SESSION_META_PREVIEW_BYTES);
const fd = fs.openSync(filePath, 'r');
try {
const head = Buffer.alloc(headSize);
fs.readSync(fd, head, 0, headSize, 0);
if (stat.size <= headSize) return head.toString('utf8');
const tail = Buffer.alloc(tailSize);
fs.readSync(fd, tail, 0, tailSize, Math.max(0, stat.size - tailSize));
return `${head.toString('utf8')}\n${tail.toString('utf8')}`;
} finally {
fs.closeSync(fd);
}
}
function loadSessionMetaFromFile(filePath) {
const fallbackId = path.basename(filePath, '.json');
try {
const stat = fs.statSync(filePath);
if (stat.size <= SESSION_META_FULL_PARSE_MAX_BYTES) {
const session = normalizeSession(JSON.parse(fs.readFileSync(filePath, 'utf8')));
const cwd = session.cwd || '';
return {
id: session.id || fallbackId,
title: session.title || 'Untitled',
updated: session.updated || session.created || stat.mtime.toISOString(),
created: session.created || null,
pinnedAt: session.pinnedAt || null,
hasUnread: !!session.hasUnread,
agent: getSessionAgent(session),
cwd,
projectName: cwd ? path.basename(cwd.replace(/[\\/]+$/, '')) : '',
fileBytes: stat.size,
oversized: stat.size > SESSION_LOAD_MAX_BYTES,
};
}
const preview = readSessionPreview(filePath, stat);
const cwd = jsonStringFieldFromPreview(preview, 'cwd') || '';
return {
id: jsonStringFieldFromPreview(preview, 'id') || fallbackId,
title: jsonStringFieldFromPreview(preview, 'title') || 'Untitled',
updated: jsonStringFieldFromPreview(preview, 'updated') || jsonStringFieldFromPreview(preview, 'updatedAt') || stat.mtime.toISOString(),
created: jsonStringFieldFromPreview(preview, 'created') || null,
pinnedAt: jsonStringFieldFromPreview(preview, 'pinnedAt') || null,
hasUnread: jsonBooleanFieldFromPreview(preview, 'hasUnread'),
agent: normalizeAgent(jsonStringFieldFromPreview(preview, 'agent')),
cwd,
projectName: cwd ? path.basename(cwd.replace(/[\\/]+$/, '')) : '',
fileBytes: stat.size,
oversized: stat.size > SESSION_LOAD_MAX_BYTES,
};
} catch (err) {
plog('WARN', 'session_meta_load_failed', {
sessionId: fallbackId.slice(0, 8),
error: err?.message || String(err || ''),
});
return null;
}
}
function loadSession(id) {
const normalizedId = sanitizeId(id || '');
if (!normalizedId) return null;
try {
const filePath = sessionPath(normalizedId);
if (!fs.existsSync(filePath)) return null;
return normalizeSession(safeReadSessionJson(filePath, SESSION_LOAD_MAX_BYTES, { sessionId: normalizedId }));
} catch (err) {
plog('WARN', 'session_load_failed', {
sessionId: normalizedId.slice(0, 8),
error: err?.message || String(err || ''),
});
return null;
}
}
function saveSession(session) {
if (!session?.id) return false;
normalizeSession(session);
const targetPath = sessionPath(session.id);
try {
const result = buildSessionJsonForPersist(session);
writeFileAtomicSync(targetPath, result.json);
if (result.guarded) {
const lastAttempt = result.attempts[result.attempts.length - 1] || {};
plog('WARN', 'session_save_guard_applied', {
sessionId: String(session.id || '').slice(0, 8),
fileBytes: lastAttempt.bytes || textByteLength(result.json),
maxBytes: SESSION_MAX_JSON_BYTES,
attempts: result.attempts,
});
}
return true;
} catch (err) {
plog('ERROR', 'session_save_failed', {
sessionId: String(session.id || '').slice(0, 8),
error: err?.message || String(err || ''),
});
return false;
}
}
function compareSessionsForList(a, b) {
const aPinned = a.pinnedAt ? new Date(a.pinnedAt).getTime() : 0;
const bPinned = b.pinnedAt ? new Date(b.pinnedAt).getTime() : 0;
if (aPinned !== bPinned) return bPinned - aPinned;
return new Date(b.updated || b.updatedAt || 0) - new Date(a.updated || a.updatedAt || 0);
}
function modelShortName(fullModel) {
if (!fullModel) return null;
const entry = Object.entries(MODEL_MAP).find(([, v]) => v === fullModel);
return entry ? entry[0] : null;
}
function sessionModelLabel(session) {
if (!session) return null;
if (!session.model) {
return isClaudeSession(session) ? null : getDefaultCodexModel();
}
return isClaudeSession(session) ? (modelShortName(session.model) || session.model) : session.model;
}
function splitHistoryMessages(messages, options = {}) {
const list = Array.isArray(messages) ? messages : [];
if (list.length <= INITIAL_HISTORY_COUNT) {
return { recentMessages: list, olderChunks: [], historyRemaining: 0, historyBuffered: list.length };
}
const prefetchChunks = Math.max(0, Math.min(
Number.isFinite(options.prefetchChunks) ? options.prefetchChunks : HISTORY_PREFETCH_CHUNKS,
HISTORY_MAX_CHUNKS_PER_LOAD,
));
const recentMessages = list.slice(-INITIAL_HISTORY_COUNT);
const older = list.slice(0, -INITIAL_HISTORY_COUNT);
const olderChunks = [];
let historyRemaining = older.length;
for (let end = older.length; end > 0 && olderChunks.length < prefetchChunks; end -= HISTORY_CHUNK_SIZE) {
const start = Math.max(0, end - HISTORY_CHUNK_SIZE);
olderChunks.push(older.slice(start, end));
historyRemaining = start;
}
const historyBuffered = recentMessages.length + olderChunks.reduce((total, chunk) => total + chunk.length, 0);
return { recentMessages, olderChunks, historyRemaining, historyBuffered };
}
const IS_WIN = process.platform === 'win32';
function isProcessRunning(pid) {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
function killProcess(pid, force = false) {
try {
if (IS_WIN) {
const args = ['/T', '/PID', String(pid)];
if (force) args.unshift('/F');
spawn('taskkill', args, { windowsHide: true, stdio: 'ignore' });
} else {
process.kill(pid, force ? 'SIGKILL' : 'SIGTERM');
}
} catch {}
}
function cleanRunDir(sessionId) {
const dir = runDir(sessionId);
try {
if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true });
} catch {}
}
function codexAppStatePath(sessionId) {
return path.join(runDir(sessionId), CODEX_APP_STATE_FILE);
}
function mapToPairs(value, options = {}) {
const maxEntries = options.maxEntries || CODEX_APP_STATE_MAX_MAP_ENTRIES;
const maxValueChars = options.maxValueChars || CODEX_APP_STATE_MAP_VALUE_MAX_CHARS;
const output = [];
if (value instanceof Map) {
for (const [key, item] of value.entries()) {
if (output.length >= maxEntries) break;
output.push([String(key), truncateTextValue(item, maxValueChars)]);
}
return output;
}
if (value && typeof value === 'object') {
for (const [key, item] of Object.entries(value)) {
if (output.length >= maxEntries) break;
output.push([String(key), truncateTextValue(item, maxValueChars)]);
}
return output;
}
return output;
}
function codexAppTurnKey(sessionId, state = {}) {
const threadId = state.threadId || '';
const turnId = state.turnId || '';
const startedAt = state.startedAt || '';
return `${sanitizeId(sessionId)}:${threadId}:${turnId}:${startedAt}`;
}
function serializeCodexAppEntry(sessionId, entry, limits = {}) {
return {
version: 1,
agent: 'codexapp',
sessionId: sanitizeId(sessionId),
cwd: entry.cwd || null,
threadId: entry.threadId || null,
turnId: entry.turnId || null,
turnStatus: entry.turnStatus || 'running',
startedAt: entry.startedAt || new Date().toISOString(),
updatedAt: new Date().toISOString(),
clientUserMessageId: entry.clientUserMessageId || null,
fullText: truncateTextValue(entry.fullText || '', limits.fullTextMaxChars || CODEX_APP_STATE_FULL_TEXT_MAX_CHARS),
toolCalls: sanitizeToolCallsForPersist(Array.isArray(entry.toolCalls) ? entry.toolCalls : [], {
maxToolCalls: limits.maxToolCalls || CODEX_APP_STATE_MAX_MAP_ENTRIES,
toolInputMaxChars: limits.toolInputMaxChars || SESSION_TOOL_INPUT_MAX_CHARS,
toolResultMaxChars: limits.toolResultMaxChars || SESSION_TOOL_RESULT_MAX_CHARS,
contentMaxChars: limits.fullTextMaxChars || CODEX_APP_STATE_FULL_TEXT_MAX_CHARS,
}),
toolOutputDeltas: mapToPairs(entry.toolOutputDeltas, {
maxEntries: limits.maxMapEntries || CODEX_APP_STATE_MAX_MAP_ENTRIES,
maxValueChars: limits.mapValueMaxChars || CODEX_APP_STATE_MAP_VALUE_MAX_CHARS,
}),
agentMessageItems: mapToPairs(entry.agentMessageItems, {
maxEntries: limits.maxMapEntries || CODEX_APP_STATE_MAX_MAP_ENTRIES,
maxValueChars: limits.mapValueMaxChars || CODEX_APP_STATE_MAP_VALUE_MAX_CHARS,
}),
lastUsage: entry.lastUsage || null,
lastError: entry.lastError || null,
userAborted: !!entry.userAborted,
};
}
function buildCodexAppStateJson(sessionId, entry) {
const attempts = [];
let fullTextMaxChars = CODEX_APP_STATE_FULL_TEXT_MAX_CHARS;
let toolResultMaxChars = SESSION_TOOL_RESULT_MAX_CHARS;
let mapValueMaxChars = CODEX_APP_STATE_MAP_VALUE_MAX_CHARS;
let maxToolCalls = CODEX_APP_STATE_MAX_MAP_ENTRIES;
for (let attempt = 0; attempt < 6; attempt += 1) {
const state = serializeCodexAppEntry(sessionId, entry, {
fullTextMaxChars,
toolResultMaxChars,
mapValueMaxChars,
maxToolCalls,
});
const json = JSON.stringify(state);
const bytes = textByteLength(json);
attempts.push({ fullTextMaxChars, toolResultMaxChars, mapValueMaxChars, maxToolCalls, bytes });
if (bytes <= CODEX_APP_STATE_MAX_BYTES) return { json, attempts };
fullTextMaxChars = Math.max(4096, Math.floor(fullTextMaxChars / 2));
toolResultMaxChars = Math.max(1024, Math.floor(toolResultMaxChars / 2));
mapValueMaxChars = Math.max(1024, Math.floor(mapValueMaxChars / 2));
maxToolCalls = Math.max(5, Math.floor(maxToolCalls / 2));
}
const fallback = serializeCodexAppEntry(sessionId, {
...entry,
fullText: truncateTextValue(entry.fullText || '', 2048),
toolCalls: [],
toolOutputDeltas: new Map(),
agentMessageItems: new Map(),
}, {
fullTextMaxChars: 2048,
toolResultMaxChars: 1024,
mapValueMaxChars: 1024,
maxToolCalls: 0,
});
fallback.stateGuarded = true;
const json = JSON.stringify(fallback);
attempts.push({ fallback: true, bytes: textByteLength(json) });
return { json, attempts };
}
function writeCodexAppTurnState(sessionId, entry) {
if (!entry || entry.codexAppStateCleaned) return;
try {
const dir = runDir(sessionId);
fs.mkdirSync(dir, { recursive: true });
const statePath = codexAppStatePath(sessionId);
const result = buildCodexAppStateJson(sessionId, entry);
writeFileAtomicSync(statePath, result.json);
if (result.attempts.length > 1) {
plog('WARN', 'codex_app_state_guard_applied', {
sessionId: String(sessionId || '').slice(0, 8),
attempts: result.attempts,
});
}
} catch (err) {
plog('WARN', 'codex_app_state_persist_failed', {
sessionId: String(sessionId || '').slice(0, 8),
error: err?.message || String(err || ''),
});
}
}
function persistCodexAppTurnState(sessionId, entry, options = {}) {
if (!sessionId || !entry || entry.codexAppStateCleaned) return;
const immediate = !!options.immediate;
if (immediate && entry.codexAppStateTimer) {
clearTimeout(entry.codexAppStateTimer);
entry.codexAppStateTimer = null;
}
if (immediate) {
entry.codexAppStateDirty = false;
writeCodexAppTurnState(sessionId, entry);
return;
}
entry.codexAppStateDirty = true;
if (entry.codexAppStateTimer) return;
entry.codexAppStateTimer = setTimeout(() => {
entry.codexAppStateTimer = null;
if (!entry.codexAppStateDirty) return;
entry.codexAppStateDirty = false;
writeCodexAppTurnState(sessionId, entry);
}, CODEX_APP_STATE_FLUSH_DELAY_MS);
if (typeof entry.codexAppStateTimer.unref === 'function') entry.codexAppStateTimer.unref();
}
function cleanupCodexAppTurnState(sessionId, entry) {
if (entry?.codexAppStateTimer) {
clearTimeout(entry.codexAppStateTimer);
entry.codexAppStateTimer = null;
}
if (entry) entry.codexAppStateCleaned = true;
cleanRunDir(sessionId);
}
function loadCodexAppTurnState(sessionId) {
try {
const statePath = codexAppStatePath(sessionId);
if (!fs.existsSync(statePath)) return null;
const stat = fs.statSync(statePath);
if (stat.size > CODEX_APP_STATE_LOAD_MAX_BYTES) {
plog('WARN', 'codex_app_state_load_skipped_oversized', {
sessionId: String(sessionId || '').slice(0, 8),
fileBytes: stat.size,
maxBytes: CODEX_APP_STATE_LOAD_MAX_BYTES,
});
return { __invalid: true, reason: 'too_large', fileBytes: stat.size, agent: 'codexapp' };
}
const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
if (!state || state.agent !== 'codexapp') return null;
return state;
} catch (err) {
plog('WARN', 'codex_app_state_load_failed', {
sessionId: String(sessionId || '').slice(0, 8),
error: err?.message || String(err || ''),
});
return null;
}
}
function appendCodexAppRecoveryNotice(sessionId, content) {
const session = loadSession(sessionId);
if (!session || !isCodexAppSession(session)) return false;
session.messages.push({
role: 'system',
content,
timestamp: new Date().toISOString(),
codexAppRecoveredPartial: true,
});
session.hasUnread = true;
session.updated = new Date().toISOString();
return saveSession(session);
}
function hasCodexAppTurnMessage(session, turnKey) {
return Array.isArray(session?.messages)
&& session.messages.some((message) => message?.codexAppTurnKey === turnKey);
}
function recoverCodexAppTurnState(sessionId) {
const state = loadCodexAppTurnState(sessionId);
if (state?.__invalid) {
appendCodexAppRecoveryNotice(
sessionId,
`Codex App 上次运行状态文件异常(${state.reason || 'invalid'}),已跳过恢复并清理运行目录,避免 cc-web 启动时占用过多内存。`,
);
cleanRunDir(sessionId);
return true;
}
if (!state) {
cleanRunDir(sessionId);
return false;
}
const session = loadSession(sessionId);
if (!session || !isCodexAppSession(session)) {
cleanRunDir(sessionId);
return true;
}
const fullText = truncateTextValue(state.fullText || '', CODEX_APP_STATE_FULL_TEXT_MAX_CHARS);
const toolCalls = sanitizeToolCallsForPersist(Array.isArray(state.toolCalls) ? state.toolCalls : [], {
maxToolCalls: CODEX_APP_STATE_MAX_MAP_ENTRIES,
toolInputMaxChars: SESSION_TOOL_INPUT_MAX_CHARS,
toolResultMaxChars: SESSION_TOOL_RESULT_MAX_CHARS,
contentMaxChars: CODEX_APP_STATE_FULL_TEXT_MAX_CHARS,
});
const hasRecoverableContent = fullText.trim() || toolCalls.length > 0;
const turnKey = codexAppTurnKey(sessionId, state);
let changed = false;
if (state.threadId) {
setRuntimeSessionId(session, state.threadId);
changed = true;
}
if (state.lastUsage) {
session.totalUsage = state.lastUsage;
changed = true;
}
if (hasRecoverableContent && !hasCodexAppTurnMessage(session, turnKey)) {
const interrupted = state.turnStatus !== 'completed';
session.messages.push({
role: 'assistant',
content: fullText,
toolCalls,
timestamp: state.updatedAt || new Date().toISOString(),
codexAppTurnKey: turnKey,
codexAppThreadId: state.threadId || null,
codexAppTurnId: state.turnId || null,
codexAppRecoveredPartial: interrupted,
interrupted,
});
if (interrupted) {
session.messages.push({
role: 'system',
content: 'Codex App 服务重启前的未完成输出已恢复,原运行任务已中断。',
timestamp: new Date().toISOString(),
codexAppTurnKey: `${turnKey}:notice`,
});
}
session.hasUnread = true;
changed = true;
plog('INFO', 'codex_app_state_recovered', {
sessionId: sessionId.slice(0, 8),
threadId: state.threadId || null,
turnId: state.turnId || null,
responseLen: fullText.length,
toolCallCount: toolCalls.length,
interrupted,
});
} else {
plog('INFO', 'codex_app_state_recovery_skipped', {
sessionId: sessionId.slice(0, 8),
threadId: state.threadId || null,
turnId: state.turnId || null,
duplicate: hasCodexAppTurnMessage(session, turnKey),
hasRecoverableContent: !!hasRecoverableContent,
});
}
if (changed) {
session.updated = new Date().toISOString();
saveSession(session);
}
cleanRunDir(sessionId);
return true;
}
function sendSessionList(ws) {
try {
const files = fs.readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.json'));
const sessions = [];
for (const f of files) {
const meta = loadSessionMetaFromFile(path.join(SESSIONS_DIR, f));
if (!meta) continue;
sessions.push({
id: meta.id,
title: meta.title || 'Untitled',
updated: meta.updated,
pinnedAt: meta.pinnedAt || null,
hasUnread: !!meta.hasUnread,
agent: normalizeAgent(meta.agent),
cwd: meta.cwd || '',
projectName: meta.projectName || '',
isRunning: isSessionRunning(meta.id),
oversized: !!meta.oversized,
fileBytes: meta.fileBytes || 0,
});
}
sessions.sort(compareSessionsForList);
wsSend(ws, { type: 'session_list', sessions });
} catch {
wsSend(ws, { type: 'session_list', sessions: [] });
}
}
function broadcastSessionList() {
if (!wss) return;
for (const client of wss.clients) {
if (client.readyState === 1) sendSessionList(client);
}
}
function findViewingSessionWs(sessionId) {
const normalizedId = sanitizeId(sessionId || '');
if (!normalizedId) return null;
for (const [client, viewedSessionId] of wsSessionMap.entries()) {
if (viewedSessionId === normalizedId && client?.readyState === 1) return client;
}
return null;
}
function getInternalMcpRequestToken(req) {
return String(req.headers['x-cc-web-mcp-token'] || '').trim() || extractBearerToken(req);
}
function mcpToolError(code, message, extra = {}) {
return { ok: false, code, message, ...extra };
}
function clampMcpLimit(value) {
const parsed = Number.parseInt(String(value || ''), 10);
if (!Number.isFinite(parsed)) return 50;
return Math.max(1, Math.min(100, parsed));
}
function listConversationSummaries(args = {}, sourceSessionId = '') {
const agentFilter = VALID_AGENTS.has(args.agent) ? args.agent : '';
const statusFilter = ['running', 'idle'].includes(args.status) ? args.status : 'all';
const limit = clampMcpLimit(args.limit);
const conversations = [];
try {
const files = fs.readdirSync(SESSIONS_DIR).filter((name) => name.endsWith('.json'));
for (const file of files) {
const meta = loadSessionMetaFromFile(path.join(SESSIONS_DIR, file));
if (!meta) continue;
const agent = normalizeAgent(meta.agent);
const running = isSessionRunning(meta.id);
const status = running ? 'running' : 'idle';
if (agentFilter && agent !== agentFilter) continue;
if (statusFilter !== 'all' && status !== statusFilter) continue;
conversations.push({
id: meta.id,
title: meta.title || 'Untitled',
agent,
status,
updatedAt: meta.updated || meta.created || null,
pinnedAt: meta.pinnedAt || null,
cwd: meta.cwd || '',
projectName: meta.projectName || '',
isCurrent: meta.id === sourceSessionId,
oversized: !!meta.oversized,
});
}
} catch {}
conversations.sort(compareSessionsForList);
return {
ok: true,
currentConversationId: sourceSessionId || null,
conversations: conversations.slice(0, limit),
};
}
function createMcpConversation(args = {}, sourceSessionId = '', sourceHopCount = 0) {
const sourceId = sanitizeId(sourceSessionId || '');
const normalizedHopCount = Math.max(0, Number.parseInt(String(sourceHopCount || 0), 10) || 0);
if (normalizedHopCount >= MCP_CREATE_CONVERSATION_MAX_HOP_COUNT) {
return mcpToolError('hop_limit_exceeded', '跨对话创建层级过深,已拒绝继续创建新对话。', {
hopCount: normalizedHopCount,
maxHopCount: MCP_CREATE_CONVERSATION_MAX_HOP_COUNT,
});
}
const sourceSession = sourceId ? loadSession(sourceId) : null;
if (sourceId && !sourceSession) {
return mcpToolError('source_not_found', '来源对话不存在。', { sourceConversationId: sourceId });
}
const initialMessage = typeof args.initialMessage === 'string'
? truncateTextValue(args.initialMessage.trim(), CROSS_CONVERSATION_MAX_CONTENT_CHARS)
: '';
const requestReply = args.requestReply === true || args.waitForReply === true;
if (requestReply && !initialMessage) {
return mcpToolError('reply_requires_initial_message', 'requestReply=true 时必须提供 initialMessage。');
}
if (initialMessage && !sourceId) {
return mcpToolError('missing_source_conversation', '发送 initialMessage 需要来源对话 ID。');
}
const now = new Date().toISOString();
const created = createPersistentConversationSession(args, {
sourceSession,
strict: true,
requireAbsoluteCwd: true,
createdFrom: sourceSession ? {
kind: 'mcp',
sourceSessionId: sourceSession.id,
sourceTitle: sourceSession.title || 'Untitled',
hopCount: normalizedHopCount + 1,
createdAt: now,
} : null,
});
if (!created.ok) return created;
const { session } = created;
let delivery = null;
if (initialMessage) {
delivery = sendCrossConversationMessage({
targetConversationId: session.id,
content: initialMessage,
}, sourceId, normalizedHopCount, { expectReply: requestReply });
if (!delivery?.ok) {
return mcpToolError(delivery?.code || 'initial_message_failed', delivery?.message || '创建对话后发送首条消息失败。', {
conversationId: session.id,
title: session.title,
agent: getSessionAgent(session),
cwd: session.cwd || '',
mode: session.permissionMode || 'yolo',
status: isSessionRunning(session.id) ? 'running' : 'idle',
});
}
}
broadcastSessionList();
return {
ok: true,
conversationId: session.id,
title: session.title || 'Untitled',
agent: getSessionAgent(session),
cwd: session.cwd || '',
mode: session.permissionMode || 'yolo',
status: isSessionRunning(session.id) ? 'running' : 'idle',
sourceConversationId: sourceId || null,
...(delivery ? {
messageId: delivery.messageId || null,
deliveryStatus: delivery.deliveryStatus || 'delivered',
...(delivery.requestId ? {
requestId: delivery.requestId,
replyStatus: delivery.status || 'waiting',
replyDelivery: delivery.replyDelivery || 'display_only',
sourceAutoRun: delivery.sourceAutoRun === true,
} : {}),
} : {}),
};
}
function buildCrossConversationRuntimeText(sourceSession, content) {
const sourceTitle = sourceSession?.title || 'Untitled';
const sourceId = sourceSession?.id || '';
return `来自「${sourceTitle}」对话ID: ${sourceId})的消息:\n\n${truncateTextValue(content, CROSS_CONVERSATION_MAX_CONTENT_CHARS)}`;
}
function buildCrossConversationReplyContent(targetSession, replyText) {
const targetTitle = targetSession?.title || 'Untitled';
return `线程「${targetTitle}」已返回消息:\n\n${truncateTextValue(replyText, CROSS_CONVERSATION_MAX_CONTENT_CHARS)}`;
}
function hasProcessedCrossConversationReply(session, requestId) {
const normalizedRequestId = String(requestId || '').trim();
if (!normalizedRequestId || !Array.isArray(session?.messages)) return false;
return session.messages.some((message) => (
message?.crossConversation?.replyToRequestId === normalizedRequestId &&
message?.crossConversation?.processed === true
));
}
function extractCrossConversationReplyText(content) {
if (!content) return '';
if (typeof content === 'string') return content.trim();
if (Array.isArray(content)) {
return content
.map((item) => {
if (!item) return '';
if (typeof item === 'string') return item;
if (typeof item.text === 'string') return item.text;
if (typeof item.content === 'string') return item.content;
return '';
})
.join('')
.trim();
}
if (typeof content.text === 'string') return content.text.trim();
if (typeof content.content === 'string') return content.content.trim();
return '';
}
function sendCrossConversationMessage(args = {}, sourceSessionId = '', sourceHopCount = 0, options = {}) {
const sourceId = sanitizeId(sourceSessionId || '');
const targetId = sanitizeId(args.targetConversationId || args.targetSessionId || args.conversationId || '');
const content = typeof args.content === 'string'
? truncateTextValue(args.content.trim(), CROSS_CONVERSATION_MAX_CONTENT_CHARS)
: '';
const expectReply = !!options.expectReply;
if (!sourceId) {
return mcpToolError('missing_source_conversation', '缺少来源对话 ID。');
}
if (!targetId) {
return mcpToolError('missing_target_conversation', '缺少目标对话 ID。');
}
if (!content) {
return mcpToolError('empty_content', '消息内容不能为空。');
}
const sourceSession = loadSession(sourceId);
if (!sourceSession) {
return mcpToolError('source_not_found', '来源对话不存在。', { sourceConversationId: sourceId });
}
const targetSession = loadSession(targetId);
if (!targetSession) {
return mcpToolError('target_not_found', '目标对话不存在。', { targetConversationId: targetId });
}
if (sourceId === targetId) {
return mcpToolError('same_conversation', '不能把消息发送给当前同一个对话。', { targetConversationId: targetId });
}
const normalizedHopCount = Math.max(0, Number.parseInt(String(sourceHopCount || 0), 10) || 0);
if (isSessionRunning(targetId)) {
return mcpToolError('target_running', '目标对话正在处理中,请稍后再发送。', { targetConversationId: targetId });
}
const now = new Date().toISOString();
const messageId = crypto.randomUUID();
const requestId = expectReply ? crypto.randomUUID() : null;
const crossConversation = {
messageId,
sourceSessionId: sourceSession.id,
sourceTitle: sourceSession.title || 'Untitled',
sentAt: now,
hopCount: normalizedHopCount + 1,
};
if (requestId) {
crossConversation.expectsReply = true;
crossConversation.replyRequestId = requestId;
pendingCrossConversationReplies.set(requestId, {
requestId,
messageId,
sourceConversationId: sourceId,
sourceTitle: sourceSession.title || 'Untitled',
targetConversationId: targetId,
targetTitle: targetSession.title || 'Untitled',
status: 'waiting',
createdAt: now,
hopCount: crossConversation.hopCount,
replyText: '',
completedAt: null,
returnedAt: null,
});
}
const targetWs = findViewingSessionWs(targetId);
const result = handleMessage(targetWs, {
text: content,
sessionId: targetSession.id,
mode: targetSession.permissionMode || 'yolo',
agent: getSessionAgent(targetSession),
}, {
crossConversation,
emitUserMessage: true,
runtimeText: buildCrossConversationRuntimeText(sourceSession, content),
mcpContext: { hopCount: crossConversation.hopCount },
});
if (!result?.ok) {
if (requestId) pendingCrossConversationReplies.delete(requestId);
return mcpToolError(result?.code || 'send_failed', result?.message || '跨对话消息发送失败。', {
sourceConversationId: sourceId,
targetConversationId: targetId,
});
}
broadcastSessionList();
return {
ok: true,
messageId,
deliveryStatus: 'delivered',
sourceConversationId: sourceId,
targetConversationId: targetId,
targetTitle: targetSession.title || 'Untitled',
...(requestId ? {
requestId,
status: 'waiting',
replyDelivery: 'display_only',
sourceAutoRun: false,
} : {}),
};
}
function requestCrossConversationReply(args = {}, sourceSessionId = '', sourceHopCount = 0) {
return sendCrossConversationMessage(args, sourceSessionId, sourceHopCount, { expectReply: true });
}
function deliverCrossConversationReply(requestId) {
const pending = pendingCrossConversationReplies.get(requestId);
if (!pending || pending.status !== 'ready') return false;
const sourceSession = loadSession(pending.sourceConversationId);
const targetSession = loadSession(pending.targetConversationId);
if (!sourceSession || !targetSession) {
pending.status = 'failed';
pending.lastError = sourceSession ? 'target_not_found' : 'source_not_found';
pendingCrossConversationReplies.delete(requestId);
return false;
}
if (isSessionRunning(sourceSession.id)) return false;
if (hasProcessedCrossConversationReply(sourceSession, requestId)) {
pending.status = 'returned';
pending.returnedAt = pending.returnedAt || new Date().toISOString();
pendingCrossConversationReplies.delete(requestId);
return true;
}
const now = new Date().toISOString();
const replyMessageId = crypto.randomUUID();
const replyContent = buildCrossConversationReplyContent(targetSession, pending.replyText);
const crossConversation = {
messageId: replyMessageId,
sourceSessionId: targetSession.id,
sourceTitle: targetSession.title || 'Untitled',
sentAt: now,
hopCount: Math.max(0, Number.parseInt(String(pending.hopCount || 0), 10) || 0) + 1,
reply: true,
replyToRequestId: requestId,
processed: true,
processedAt: now,
autoRun: false,
};
pending.status = 'delivering';
const replyMessage = {
role: 'assistant',
content: replyContent,
timestamp: now,
crossConversation,
ccwebDisplayOnly: true,
};
sourceSession.messages = Array.isArray(sourceSession.messages) ? sourceSession.messages : [];
sourceSession.messages.push(replyMessage);
sourceSession.updated = now;
const sourceWs = findViewingSessionWs(sourceSession.id);
if (!sourceWs) sourceSession.hasUnread = true;
saveSession(sourceSession);
if (sourceWs) {
wsSend(sourceWs, {
type: 'session_message',
sessionId: sourceSession.id,
message: replyMessage,
});
}
pending.status = 'returned';
pending.returnedAt = now;
pending.replyMessageId = replyMessageId;
pendingCrossConversationReplies.delete(requestId);
broadcastSessionList();
return true;
}
function flushPendingCrossConversationReplies(sourceSessionId) {
const sourceId = sanitizeId(sourceSessionId || '');
if (!sourceId || isSessionRunning(sourceId)) return;
for (const [requestId, pending] of pendingCrossConversationReplies.entries()) {
if (pending.sourceConversationId === sourceId && pending.status === 'ready') {
deliverCrossConversationReply(requestId);
}
}
}
function completeCrossConversationReply(requestId, entry = {}, targetSession = null) {
const normalizedRequestId = String(requestId || '').trim();
if (!normalizedRequestId) return false;
const pending = pendingCrossConversationReplies.get(normalizedRequestId);
if (!pending || pending.status !== 'waiting') return false;
let replyText = extractCrossConversationReplyText(entry.contentBlocks || entry.fullText);
if (!replyText && entry.lastError) {
replyText = `目标对话运行失败:${entry.lastError}`;
}
if (!replyText) {
replyText = '(目标对话没有返回可用文本。)';
}
pending.status = 'ready';
pending.replyText = truncateTextValue(replyText, CROSS_CONVERSATION_MAX_CONTENT_CHARS);
pending.completedAt = new Date().toISOString();
if (targetSession?.title) pending.targetTitle = targetSession.title;
return deliverCrossConversationReply(normalizedRequestId);
}
function callInternalMcpTool(tool, args, sourceSessionId, sourceHopCount) {
switch (tool) {
case 'ccweb_list_conversations':
return listConversationSummaries(args, sanitizeId(sourceSessionId || ''));
case 'ccweb_create_conversation':
return createMcpConversation(args, sourceSessionId, sourceHopCount);
case 'ccweb_send_message':
return sendCrossConversationMessage(args, sourceSessionId, sourceHopCount);
case 'ccweb_request_reply':
return requestCrossConversationReply(args, sourceSessionId, sourceHopCount);
default:
return mcpToolError('unknown_tool', `未知 MCP 工具: ${tool}`);
}
}
async function handleInternalMcpApi(req, res) {
const token = getInternalMcpRequestToken(req);
if (!token || token !== INTERNAL_MCP_TOKEN) {
return jsonResponse(res, 401, mcpToolError('unauthorized', 'MCP 内部接口未授权。'));
}
let payload;
try {
payload = await readJsonBody(req);
} catch (err) {
return jsonResponse(res, 400, mcpToolError('bad_request', err.message || '请求体无效。'));
}
const tool = String(payload.tool || '');
const args = payload.args && typeof payload.args === 'object' ? payload.args : {};
const sourceSessionId = sanitizeId(payload.sourceSessionId || '');
const sourceHopCount = Number.parseInt(String(payload.sourceHopCount || 0), 10) || 0;
const result = callInternalMcpTool(tool, args, sourceSessionId, sourceHopCount);
return jsonResponse(res, result.ok ? 200 : 400, result);
}
// === File Tailer ===
// Tails a file and calls onLine for each new complete line.
class FileTailer {
constructor(filePath, onLine) {
this.filePath = filePath;
this.onLine = onLine;
this.offset = 0;
this.buffer = '';
this.watcher = null;
this.interval = null;
this.stopped = false;
}
start() {
this.readNew();
try {
this.watcher = fs.watch(this.filePath, () => {
if (!this.stopped) this.readNew();
});
this.watcher.on('error', () => {});
} catch {}
// Backup poll every 500ms (fs.watch not always reliable on all systems)
this.interval = setInterval(() => {
if (!this.stopped) this.readNew();
}, 500);
}
readNew() {
try {
const stat = fs.statSync(this.filePath);
if (stat.size <= this.offset) return;
const unreadBytes = stat.size - this.offset;
if (unreadBytes > RUN_OUTPUT_TAILER_MAX_READ_BYTES) {
plog('WARN', 'runtime_output_tailer_skipped_oversized_gap', {
file: path.basename(this.filePath),
unreadBytes,
maxBytes: RUN_OUTPUT_TAILER_MAX_READ_BYTES,
});
this.offset = Math.max(0, stat.size - RUN_OUTPUT_TAILER_MAX_READ_BYTES);
this.buffer = '';
}
const readBytes = stat.size - this.offset;
if (readBytes <= 0) return;
const buf = Buffer.alloc(readBytes);
const fd = fs.openSync(this.filePath, 'r');
fs.readSync(fd, buf, 0, buf.length, this.offset);
fs.closeSync(fd);
this.offset = stat.size;
this.buffer += buf.toString();
const lines = this.buffer.split('\n');
this.buffer = lines.pop();
for (const line of lines) {
if (line.trim()) this.onLine(line);
}
} catch {}
}
stop() {
this.stopped = true;
if (this.watcher) { this.watcher.close(); this.watcher = null; }
if (this.interval) { clearInterval(this.interval); this.interval = null; }
}
}
// === Process Lifecycle ===
function firstMeaningfulLine(text) {
return String(text || '')
.split('\n')
.map((line) => line.trim())
.find(Boolean) || '';
}
function condenseRuntimeError(raw) {
const text = String(raw || '').trim();
if (!text) return '';
const lines = text.split('\n').map((line) => line.trim()).filter(Boolean);
const usageIndex = lines.findIndex((line) => /^Usage:/i.test(line));
if (usageIndex >= 0) return lines.slice(0, usageIndex).join(' ');
return lines.slice(0, 3).join(' ');
}
function formatRuntimeError(agent, raw, context = {}) {
const condensed = condenseRuntimeError(raw);
const exitInfo = typeof context.exitCode === 'number' ? `(退出码 ${context.exitCode}` : '';
if (!condensed) {
return agent === 'codex'
? `Codex 任务异常结束${exitInfo},但 CLI 没有返回更多错误信息。`
: `Claude 任务异常结束${exitInfo},但 CLI 没有返回更多错误信息。`;
}
if (agent === 'codex') {
if (/ENOENT|not found|No such file/i.test(condensed)) {
return '找不到 Codex CLI。请检查 Codex 设置里的 CLI 路径,或确认系统 PATH 中可直接运行 `codex`。';
}
if (/unexpected argument|unexpected option|Usage:\s*codex/i.test(raw || '')) {
return `Codex CLI 参数不兼容:${firstMeaningfulLine(condensed)}。建议检查当前 CLI 版本与 cc-web 的参数约定是否匹配。`;
}
if (/permission denied|EACCES|EPERM/i.test(condensed)) {
return 'Codex CLI 启动失败:当前环境没有足够权限执行该命令或访问目标目录。';
}
if (/authentication|unauthorized|forbidden|login|api key|credential/i.test(condensed)) {
return 'Codex 鉴权失败。请确认本机 Codex CLI 已完成登录,且当前凭据仍然有效。';
}
if (/rate limit|quota|billing|credits/i.test(condensed)) {
return 'Codex 请求被额度或速率限制拦截。请检查账号配额、计费状态或稍后重试。';
}
if (/network|timed out|timeout|ECONNRESET|ENOTFOUND|TLS|certificate|fetch failed/i.test(condensed)) {
return 'Codex 运行时网络请求失败。请检查当前网络、代理或证书环境后重试。';
}
if (/sandbox|approval|read-only|bypass-approvals/i.test(condensed)) {
return `Codex 当前的审批或沙箱设置阻止了这次执行:${firstMeaningfulLine(condensed)}`;
}
return `Codex 任务失败${exitInfo}${condensed}`;
}
if (/ENOENT|not found|No such file/i.test(condensed)) {
return '找不到 Claude CLI。请检查当前环境是否能直接运行 `claude`。';
}
if (/authentication|unauthorized|forbidden|api key|credential/i.test(condensed)) {
return 'Claude 鉴权失败。请确认本机 Claude CLI 已完成登录,且凭据仍然有效。';
}
return `Claude 任务失败${exitInfo}${condensed}`;
}
function compactStartMessage(agent) {
return agent === 'codex'
? '正在执行 Codex /compact 压缩上下文,请稍候…'
: '正在执行 Claude 原生 /compact 压缩上下文,请稍候…';
}
function compactDoneMessage(agent) {
return agent === 'codex'
? '上下文压缩完成。已执行 Codex /compact下次继续在同一会话发送即可。'
: '上下文压缩完成。已按 Claude Code 原生策略执行 /compact下次继续在同一会话发送即可。';
}
function initStartMessage(agent) {
return agent === 'codex' || agent === 'codexapp'
? '正在分析项目并生成 AGENTS.md ...'
: '正在分析项目并生成 CLAUDE.md ...';
}
function buildCodexInitPrompt(cwd) {
const targetPath = path.join(cwd || process.cwd(), 'AGENTS.md');
return [
'You are running cc-web\'s /init for a Codex session.',
'Analyze the current workspace and create or update AGENTS.md at the repository root.',
`The file path to write is: ${targetPath}`,
'Requirements:',
'- Actually write the file; do not stop after summarizing in chat.',
'- If AGENTS.md already exists, update it in place instead of creating a duplicate.',
'- Keep the document concise and practical for future coding agents working in this repo.',
'- Include the project purpose, key entry points, dev/test commands, important workflows, and repo-specific safety constraints.',
'- Prefer facts from the actual codebase over README claims when they differ.',
'- After editing the file, reply with a brief summary of what you wrote.',
].join('\n');
}
function compactAutoStartMessage(agent) {
return agent === 'codex'
? '检测到上下文达到上限,正在按 Codex /compact 自动压缩,然后继续当前任务…'
: '检测到上下文达到上限,正在按 Claude Code 原版策略自动执行 /compact然后继续当前任务…';
}
function compactAutoResumeMessage(agent) {
return agent === 'codex'
? '检测到上一条请求因上下文过大失败,现已按 Codex 压缩计划继续执行。'
: '检测到上一条请求因上下文过大失败,现已自动按压缩计划继续执行。';
}
function isContextLimitError(agent, raw) {
const text = String(raw || '');
if (!text) return false;
if (agent === 'claude') {
return /Request too large \(max 20MB\)/i.test(text);
}
return /context\s+(window|length)|maximum context length|context limit|token limit|too many tokens|input.*too long|prompt.*too long|request too large|please use\s*\/compact|use\s*\/compact|reduce (the )?(input|prompt|message)|exceed(?:ed|s).*(token|context)/i.test(text);
}
function handleProcessComplete(sessionId, exitCode, signal) {
const entry = activeProcesses.get(sessionId);
if (!entry) return;
const completeTime = new Date().toISOString();
const wsConnected = !!entry.ws;
const disconnectGap = entry.wsDisconnectTime
? ((new Date(completeTime) - new Date(entry.wsDisconnectTime)) / 1000).toFixed(1) + 's'
: null;
const pendingRetry = pendingCompactRetries.get(sessionId) || null;
let contextLimitExceeded = false;
// Read stderr for error clues
let stderrSnippet = '';
try {
const errPath = path.join(runDir(sessionId), 'error.log');
if (fs.existsSync(errPath)) {
const content = fs.readFileSync(errPath, 'utf8').trim();
if (content) stderrSnippet = content.slice(-500);
}
} catch {}
const rawCompletionError = entry.lastError || (
((typeof exitCode === 'number' && exitCode !== 0) || (!!signal && signal !== 'SIGTERM'))
? (stderrSnippet || null)
: null
);
contextLimitExceeded = isContextLimitError(entry.agent || 'claude', `${entry.fullText || ''}\n${stderrSnippet || ''}\n${rawCompletionError || ''}`);
const completionError = rawCompletionError ? formatRuntimeError(entry.agent || 'claude', rawCompletionError, { exitCode, signal }) : null;
if (!entry.lastError && rawCompletionError) entry.lastError = rawCompletionError;
plog(exitCode === 0 || exitCode === null ? 'INFO' : 'WARN', 'process_complete', {
sessionId: sessionId.slice(0, 8),
pid: entry.pid,
agent: entry.agent || 'claude',
exitCode,
signal,
wsConnected,
wsDisconnectTime: entry.wsDisconnectTime || null,
disconnectToDeathGap: disconnectGap,
responseLen: (entry.fullText || '').length,
toolCallCount: (entry.toolCalls || []).length,
cost: entry.lastCost,
usage: entry.lastUsage || null,
error: rawCompletionError,
stderr: stderrSnippet || null,
requestTooLarge: contextLimitExceeded,
});
// Final read
if (entry.tailer) {
entry.tailer.readNew();
entry.tailer.stop();
}
const pendingSlash = pendingSlashCommands.get(sessionId) || null;
if (pendingSlash) pendingSlashCommands.delete(sessionId);
// Save result to session
const session = loadSession(sessionId);
if (session && (entry.fullText || entry.contentBlocks)) {
session.messages.push({
role: 'assistant',
content: entry.contentBlocks || entry.fullText,
toolCalls: entry.toolCalls || [],
timestamp: new Date().toISOString(),
});
session.updated = new Date().toISOString();
if (!entry.ws) session.hasUnread = true;
saveSession(session);
}
if (pendingSlash?.kind === 'compact' && session) {
if (entry.lastCost) {
session.totalCost = Math.max(0, (session.totalCost || 0) - entry.lastCost);
}
session.updated = new Date().toISOString();
saveSession(session);
}
let shouldReturnForFollowup = false;
let shouldAutoCompact = false;
activeProcesses.delete(sessionId);
cleanRunDir(sessionId);
pendingSlashCommands.delete(sessionId);
if (entry.crossConversationReplyRequestId) {
completeCrossConversationReply(entry.crossConversationReplyRequestId, entry, session);
}
flushPendingCrossConversationReplies(sessionId);
// Notify client
if (entry.ws) {
if (pendingSlash?.kind === 'compact') {
const retry = pendingCompactRetries.get(sessionId);
const autoRetryRequested = !!(retry?.text && retry?.reason === 'auto');
if (autoRetryRequested) {
if (contextLimitExceeded) {
pendingCompactRetries.delete(sessionId);
wsSend(entry.ws, { type: 'system_message', sessionId, message: '已尝试执行 /compact但仍未成功解除上下文超限。请手动缩小输入范围后重试。' });
} else {
wsSend(entry.ws, { type: 'system_message', sessionId, message: compactDoneMessage(entry.agent || 'claude') });
wsSend(entry.ws, { type: 'system_message', sessionId, message: compactAutoResumeMessage(entry.agent || 'claude') });
shouldReturnForFollowup = true;
}
} else {
wsSend(entry.ws, { type: 'system_message', sessionId, message: compactDoneMessage(entry.agent || 'claude') });
}
}
if (contextLimitExceeded && !pendingSlash && session && getRuntimeSessionId(session)) {
pendingCompactRetries.set(sessionId, { text: pendingRetry?.text || '', mode: pendingRetry?.mode || session.permissionMode || 'yolo', reason: 'auto' });
wsSend(entry.ws, { type: 'system_message', sessionId, message: compactAutoStartMessage(entry.agent || 'claude') });
shouldAutoCompact = true;
}
if (completionError && !entry.errorSent && !shouldAutoCompact) {
entry.errorSent = true;
wsSend(entry.ws, { type: 'error', sessionId, message: completionError });
}
wsSend(entry.ws, { type: 'done', sessionId, costUsd: entry.lastCost || null });
sendSessionList(entry.ws);
// Push notification when trigger='always' (user online but still wants notification)
(() => {
const notifyCfg = loadNotifyConfig();
if (!notifyCfg.provider || notifyCfg.provider === 'off') return;
if ((notifyCfg.summary?.trigger || 'background') !== 'always') return;
const sess = loadSession(sessionId);
buildNotifyContent(entry, sess, completionError, contextLimitExceeded).then(({ title: ntitle, content }) => {
sendNotification(ntitle, content);
});
})();
} else {
// Process completed while browser was disconnected — notify all connected clients
const sess = loadSession(sessionId);
const title = sess?.title || 'Untitled';
for (const client of wss.clients) {
if (client.readyState === 1) {
wsSend(client, {
type: 'background_done',
sessionId,
title,
costUsd: entry.lastCost || null,
responseLen: (entry.fullText || '').length,
});
}
}
// Push notification (background task)
buildNotifyContent(entry, sess, completionError, contextLimitExceeded).then(({ title: ntitle, content }) => {
sendNotification(ntitle, content);
});
}
if (!shouldReturnForFollowup && !shouldAutoCompact && !contextLimitExceeded && pendingRetry && pendingRetry.text === (entry.fullText || '').trim()) {
pendingCompactRetries.delete(sessionId);
}
if (shouldReturnForFollowup && entry.ws && entry.ws.readyState === 1 && session) {
if (pendingSlash?.kind === 'compact') {
const retry = pendingCompactRetries.get(sessionId);
if (retry?.text) {
pendingCompactRetries.delete(sessionId);
handleMessage(entry.ws, { text: retry.text, sessionId, mode: retry.mode || session.permissionMode || 'yolo' });
}
return;
}
}
if (shouldAutoCompact && entry.ws && entry.ws.readyState === 1 && session) {
pendingSlashCommands.set(sessionId, { kind: 'compact' });
handleMessage(entry.ws, { text: '/compact', sessionId, mode: session.permissionMode || 'yolo' }, { hideInHistory: true });
return;
}
}
// Global PID monitor: detect process completion (especially after server restart)
setInterval(() => {
for (const [sessionId, entry] of activeProcesses) {
if (entry.pid && !isProcessRunning(entry.pid)) {
plog('INFO', 'pid_monitor_detected_exit', {
sessionId: sessionId.slice(0, 8),
pid: entry.pid,
wsConnected: !!entry.ws,
});
handleProcessComplete(sessionId, null, 'unknown (detected by monitor)');
}
}
}, 2000);
cleanupExpiredAttachments();
setInterval(cleanupExpiredAttachments, 6 * 60 * 60 * 1000);
// Recover processes that were running before server restart
function recoverProcesses() {
try {
const entries = fs.readdirSync(SESSIONS_DIR).filter(f => f.endsWith('-run') && fs.statSync(path.join(SESSIONS_DIR, f)).isDirectory());
if (entries.length === 0) return;
plog('INFO', 'recovery_start', { runDirs: entries.length });
for (const dirName of entries) {
const sessionId = dirName.replace('-run', '');
const dir = path.join(SESSIONS_DIR, dirName);
const pidPath = path.join(dir, 'pid');
const outputPath = path.join(dir, 'output.jsonl');
const session = loadSession(sessionId);
const agent = getSessionAgent(session);
if (fs.existsSync(codexAppStatePath(sessionId))) {
recoverCodexAppTurnState(sessionId);
continue;
}
if (!fs.existsSync(pidPath)) {
try { fs.rmSync(dir, { recursive: true }); } catch {}
continue;
}
const pid = parseInt(fs.readFileSync(pidPath, 'utf8'));
if (isProcessRunning(pid)) {
console.log(`[recovery] Re-attaching to session ${sessionId} (PID ${pid})`);
plog('INFO', 'recovery_alive', { sessionId: sessionId.slice(0, 8), pid, agent });
const entry = { pid, ws: null, agent, fullText: '', toolCalls: [], lastCost: null, lastUsage: null, lastError: null, errorSent: false, tailer: null };
activeProcesses.set(sessionId, entry);
if (fs.existsSync(outputPath)) {
entry.tailer = new FileTailer(outputPath, (line) => {
try {
const event = JSON.parse(line);
processRuntimeEvent(entry, event, sessionId);
} catch {}
});
entry.tailer.start();
}
} else {
// Process finished while server was down — read all output and save
console.log(`[recovery] Processing completed output for session ${sessionId}`);
plog('INFO', 'recovery_dead', { sessionId: sessionId.slice(0, 8), pid, agent });
if (fs.existsSync(outputPath)) {
const outputStat = fs.statSync(outputPath);
if (outputStat.size > RUN_OUTPUT_RECOVERY_MAX_BYTES) {
plog('WARN', 'recovery_output_skipped_oversized', {
sessionId: sessionId.slice(0, 8),
pid,
agent,
fileBytes: outputStat.size,
maxBytes: RUN_OUTPUT_RECOVERY_MAX_BYTES,
});
if (session) {
session.messages.push({
role: 'system',
content: '服务重启期间的运行输出文件过大cc-web 已跳过恢复完整输出,避免启动时占用过多内存。',
timestamp: new Date().toISOString(),
});
session.updated = new Date().toISOString();
saveSession(session);
}
try { fs.rmSync(dir, { recursive: true }); } catch {}
continue;
}
const tempEntry = { pid: 0, ws: null, agent, fullText: '', toolCalls: [], lastCost: null, lastUsage: null, lastError: null, errorSent: false, tailer: null };
const content = fs.readFileSync(outputPath, 'utf8');
for (const line of content.split('\n')) {
if (!line.trim()) continue;
try {
const event = JSON.parse(line);
processRuntimeEvent(tempEntry, event, sessionId);
} catch {}
}
if (session && tempEntry.fullText) {
session.messages.push({
role: 'assistant',
content: tempEntry.fullText,
toolCalls: tempEntry.toolCalls || [],
timestamp: new Date().toISOString(),
});
session.updated = new Date().toISOString();
saveSession(session);
}
}
try { fs.rmSync(dir, { recursive: true }); } catch {}
}
}
} catch (err) {
console.error('[recovery] Error:', err.message);
}
}
// === HTTP Static File Server ===
const server = http.createServer((req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
if (req.method === 'POST' && url.pathname === '/api/internal/mcp') {
return handleInternalMcpApi(req, res);
}
const reloadMcpMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/reload-mcp$/);
if (req.method === 'POST' && reloadMcpMatch) {
return handleReloadMcpApi(req, res, decodeURIComponent(reloadMcpMatch[1] || ''));
}
if (req.method === 'POST' && url.pathname === '/api/attachments') {
const token = extractBearerToken(req);
if (!token || !activeTokens.has(token)) {
return jsonResponse(res, 401, { ok: false, message: 'Not authenticated' });
}
const mime = String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
const rawName = decodeURIComponent(String(req.headers['x-filename'] || 'image'));
const filename = safeFilename(rawName);
if (!IMAGE_MIME_TYPES.has(mime)) {
return jsonResponse(res, 400, { ok: false, message: '仅支持 PNG/JPG/WEBP/GIF 图片' });
}
const chunks = [];
let total = 0;
let aborted = false;
req.on('data', (chunk) => {
total += chunk.length;
if (total > MAX_ATTACHMENT_SIZE) {
aborted = true;
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('end', () => {
if (aborted) {
return jsonResponse(res, 413, { ok: false, message: '图片大小不能超过 10MB' });
}
const buffer = Buffer.concat(chunks);
if (buffer.length === 0) {
return jsonResponse(res, 400, { ok: false, message: '图片内容为空' });
}
const id = crypto.randomUUID();
const ext = extFromMime(mime) || path.extname(filename) || '';
const dataPath = attachmentDataPath(id, ext);
const now = new Date();
const meta = {
id,
kind: 'image',
filename,
mime,
size: buffer.length,
createdAt: now.toISOString(),
expiresAt: new Date(now.getTime() + ATTACHMENT_TTL_MS).toISOString(),
path: dataPath,
};
try {
fs.writeFileSync(dataPath, buffer);
saveAttachmentMeta(meta);
return jsonResponse(res, 200, {
ok: true,
attachment: {
id,
kind: 'image',
filename,
mime,
size: buffer.length,
createdAt: meta.createdAt,
expiresAt: meta.expiresAt,
storageState: 'available',
},
});
} catch (err) {
try { if (fs.existsSync(dataPath)) fs.unlinkSync(dataPath); } catch {}
try { if (fs.existsSync(attachmentMetaPath(id))) fs.unlinkSync(attachmentMetaPath(id)); } catch {}
return jsonResponse(res, 500, { ok: false, message: `保存附件失败: ${err.message}` });
}
});
req.on('error', () => {
if (!res.headersSent) jsonResponse(res, 500, { ok: false, message: '上传过程中断' });
});
return;
}
if (url.pathname.startsWith('/api/attachments/')) {
const token = extractBearerToken(req) || String(url.searchParams.get('token') || '');
if (!token || !activeTokens.has(token)) {
return jsonResponse(res, 401, { ok: false, message: 'Not authenticated' });
}
const id = sanitizeId(url.pathname.split('/').pop() || '');
if (!id) {
return jsonResponse(res, 400, { ok: false, message: '缺少附件 ID' });
}
if (req.method === 'GET') {
const meta = loadAttachmentMeta(id);
const state = currentAttachmentState(meta);
if (state !== 'available' || !meta?.path || !fs.existsSync(meta.path)) {
return jsonResponse(res, 404, { ok: false, message: '附件不存在或已过期' });
}
try {
const stat = fs.statSync(meta.path);
res.writeHead(200, {
'Content-Type': meta.mime || 'application/octet-stream',
'Content-Length': stat.size,
'Content-Disposition': contentDispositionInline(meta.filename || 'image'),
'Cache-Control': 'private, no-store, max-age=0',
});
fs.createReadStream(meta.path).pipe(res);
} catch (err) {
return jsonResponse(res, 500, { ok: false, message: `读取附件失败: ${err.message}` });
}
return;
}
if (req.method === 'DELETE') {
removeAttachmentById(id);
return jsonResponse(res, 200, { ok: true });
}
}
if (req.method === 'GET' && url.pathname === '/api/fs/list') {
return handleFileSystemListApi(req, res, url);
}
if (req.method === 'GET' && url.pathname === '/api/fs/read') {
return handleFileSystemReadApi(req, res, url);
}
if (req.method === 'GET' && url.pathname === '/api/fs/directories') {
return handleDirectoryPickerListApi(req, res, url);
}
let filePath = path.join(PUBLIC_DIR, url.pathname === '/' ? 'index.html' : url.pathname);
filePath = path.resolve(filePath);
if (!filePath.startsWith(PUBLIC_DIR)) {
res.writeHead(403);
return res.end('Forbidden');
}
fs.readFile(filePath, (err, data) => {
if (err) {
res.writeHead(404);
return res.end('Not Found');
}
const ext = path.extname(filePath);
res.writeHead(200, {
'Content-Type': MIME_TYPES[ext] || 'application/octet-stream',
'Cache-Control': 'no-store, max-age=0',
});
res.end(data);
});
});
// === WebSocket Server ===
const wss = new WebSocketServer({ noServer: true });
server.on('upgrade', (req, socket, head) => {
let pathname = '';
try {
pathname = new URL(req.url || '/', 'http://localhost').pathname;
} catch {
pathname = '';
}
if (pathname !== '/ws') {
socket.write('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req);
});
});
wss.on('connection', (ws, req) => {
ws._req = req;
const clientIP = getClientIP(ws);
// Check if IP is banned
if (clientIP && bannedIPs.has(clientIP)) {
plog('WARN', 'banned_ip_rejected', { ip: clientIP });
wsSend(ws, { type: 'auth_result', success: false, banned: true });
ws.close();
return;
}
let authenticated = false;
let authToken = null;
const wsId = crypto.randomBytes(4).toString('hex'); // short id for log correlation
const wsConnectTime = new Date().toISOString();
plog('INFO', 'ws_connect', { wsId });
ws.on('message', (raw) => {
let msg;
try {
msg = JSON.parse(raw);
} catch {
return wsSend(ws, { type: 'error', message: 'Invalid JSON' });
}
if (msg.type === 'auth') {
// Check ban before processing auth
if (clientIP && bannedIPs.has(clientIP)) {
wsSend(ws, { type: 'auth_result', success: false, banned: true });
ws.close();
return;
}
if (msg.password === PASSWORD || (msg.token && activeTokens.has(msg.token))) {
authToken = msg.token && activeTokens.has(msg.token) ? msg.token : crypto.randomBytes(32).toString('hex');
activeTokens.add(authToken);
authenticated = true;
wsSend(ws, { type: 'auth_result', success: true, token: authToken, mustChangePassword: !!authConfig.mustChange });
sendSessionList(ws);
} else {
const justBanned = recordAuthFailure(clientIP);
wsSend(ws, { type: 'auth_result', success: false, banned: justBanned });
if (justBanned) ws.close();
}
return;
}
if (!authenticated) {
return wsSend(ws, { type: 'error', message: 'Not authenticated' });
}
switch (msg.type) {
case 'message':
if (msg.text && msg.text.trim().startsWith('/')) {
handleSlashCommand(ws, msg.text.trim(), msg.sessionId, msg.agent);
} else {
handleMessage(ws, msg);
}
break;
case 'composer_suggestions':
handleComposerSuggestions(ws, msg);
break;
case 'abort':
handleAbort(ws);
break;
case 'new_session':
handleNewSession(ws, msg);
break;
case 'load_session':
handleLoadSession(ws, msg.sessionId);
break;
case 'load_history_page':
handleLoadHistoryPage(ws, msg);
break;
case 'delete_session':
handleDeleteSession(ws, msg.sessionId);
break;
case 'rename_session':
handleRenameSession(ws, msg.sessionId, msg.title);
break;
case 'set_session_pinned':
handleSetSessionPinned(ws, msg.sessionId, !!msg.pinned);
break;
case 'set_mode':
handleSetMode(ws, msg.sessionId, msg.mode);
break;
case 'codex_app_user_input_response':
handleCodexAppUserInputResponse(ws, msg);
break;
case 'ccweb_mcp_child_agent_close':
handleCcwebMcpChildAgentClose(ws, msg);
break;
case 'list_sessions':
sendSessionList(ws);
break;
case 'detach_view':
handleDetachView(ws);
break;
case 'get_notify_config':
wsSend(ws, { type: 'notify_config', config: getNotifyConfigMasked() });
break;
case 'save_notify_config':
handleSaveNotifyConfig(ws, msg.config);
break;
case 'test_notify':
handleTestNotify(ws);
break;
case 'change_password':
handleChangePassword(ws, msg, authToken);
break;
case 'get_model_config':
wsSend(ws, { type: 'model_config', config: getModelConfigMasked() });
break;
case 'save_model_config':
handleSaveModelConfig(ws, msg.config);
break;
case 'get_codex_config':
wsSend(ws, { type: 'codex_config', config: getCodexConfigMasked() });
break;
case 'save_codex_config':
handleSaveCodexConfig(ws, msg.config);
break;
case 'fetch_models':
handleFetchModels(ws, msg);
break;
case 'check_update':
handleCheckUpdate(ws);
break;
case 'list_native_sessions':
handleListNativeSessions(ws);
break;
case 'import_native_session':
handleImportNativeSession(ws, msg);
break;
case 'list_codex_sessions':
handleListCodexSessions(ws);
break;
case 'import_codex_session':
handleImportCodexSession(ws, msg);
break;
case 'list_cwd_suggestions':
handleListCwdSuggestions(ws);
break;
default:
wsSend(ws, { type: 'error', message: `Unknown type: ${msg.type}` });
}
});
ws.on('close', () => handleDisconnect(ws, wsId));
ws.on('error', (err) => {
plog('WARN', 'ws_error', { wsId, error: err.message });
handleDisconnect(ws, wsId);
});
});
// === Notify Config Handlers ===
function handleSaveNotifyConfig(ws, newConfig) {
if (!newConfig || !newConfig.provider) {
return wsSend(ws, { type: 'error', message: '无效的通知配置' });
}
const current = loadNotifyConfig();
// Merge: only update fields that are not masked (contain ****)
const merged = { provider: newConfig.provider };
// pushplus
merged.pushplus = { token: (newConfig.pushplus?.token && !newConfig.pushplus.token.includes('****')) ? newConfig.pushplus.token : current.pushplus?.token || '' };
// telegram
merged.telegram = {
botToken: (newConfig.telegram?.botToken && !newConfig.telegram.botToken.includes('****')) ? newConfig.telegram.botToken : current.telegram?.botToken || '',
chatId: newConfig.telegram?.chatId !== undefined ? newConfig.telegram.chatId : current.telegram?.chatId || '',
};
// serverchan
merged.serverchan = { sendKey: (newConfig.serverchan?.sendKey && !newConfig.serverchan.sendKey.includes('****')) ? newConfig.serverchan.sendKey : current.serverchan?.sendKey || '' };
// feishu
merged.feishu = { webhook: (newConfig.feishu?.webhook && !newConfig.feishu.webhook.includes('****')) ? newConfig.feishu.webhook : current.feishu?.webhook || '' };
// qqbot
merged.qqbot = { qmsgKey: (newConfig.qqbot?.qmsgKey && !newConfig.qqbot.qmsgKey.includes('****')) ? newConfig.qqbot.qmsgKey : current.qqbot?.qmsgKey || '' };
// summary
const ns = newConfig.summary || {};
const cs = current.summary || {};
merged.summary = {
enabled: !!ns.enabled,
trigger: ['background', 'always'].includes(ns.trigger) ? ns.trigger : (cs.trigger || 'background'),
apiSource: ['claude', 'codex', 'custom'].includes(ns.apiSource) ? ns.apiSource : (cs.apiSource || 'claude'),
apiBase: ns.apiBase !== undefined ? ns.apiBase : (cs.apiBase || ''),
apiKey: (ns.apiKey && !ns.apiKey.includes('****')) ? ns.apiKey : (cs.apiKey || ''),
model: ns.model !== undefined ? ns.model : (cs.model || ''),
};
saveNotifyConfig(merged);
plog('INFO', 'notify_config_saved', { provider: merged.provider });
wsSend(ws, { type: 'notify_config', config: getNotifyConfigMasked() });
wsSend(ws, { type: 'system_message', message: '通知配置已保存' });
}
function handleTestNotify(ws) {
const config = loadNotifyConfig();
if (!config.provider || config.provider === 'off') {
return wsSend(ws, { type: 'notify_test_result', success: false, message: '通知已关闭,无法测试' });
}
sendNotification('CC-Web 测试通知', '这是一条测试消息,如果你收到了说明通知配置正确!').then((result) => {
wsSend(ws, { type: 'notify_test_result', success: result.ok, message: result.ok ? '测试消息已发送,请检查是否收到' : `发送失败: ${result.error || result.body || '未知错误'}` });
});
}
function handleChangePassword(ws, msg, currentToken) {
const { currentPassword, newPassword } = msg;
// Validate current password
if (currentPassword !== PASSWORD) {
return wsSend(ws, { type: 'password_changed', success: false, message: '当前密码错误' });
}
// Validate new password strength
const strength = validatePasswordStrength(newPassword);
if (!strength.valid) {
return wsSend(ws, { type: 'password_changed', success: false, message: strength.message });
}
// Save new password
authConfig = { password: newPassword, mustChange: false };
saveAuthConfig(authConfig);
PASSWORD = newPassword;
plog('INFO', 'password_changed', {});
// Clear all tokens (force all sessions to re-login)
activeTokens.clear();
// Generate new token for current connection
const newToken = crypto.randomBytes(32).toString('hex');
activeTokens.add(newToken);
wsSend(ws, { type: 'password_changed', success: true, token: newToken, message: '密码修改成功' });
}
// === Model Config Handler ===
function handleSaveModelConfig(ws, newConfig) {
if (!newConfig || !['local', 'custom'].includes(newConfig.mode)) {
return wsSend(ws, { type: 'error', message: '无效的模型配置' });
}
const current = loadModelConfig();
const merged = {
mode: newConfig.mode,
activeTemplate: newConfig.activeTemplate || '',
templates: [],
};
// Merge templates: keep existing secrets if masked
const newTemplates = Array.isArray(newConfig.templates) ? newConfig.templates : [];
const oldTemplates = Array.isArray(current.templates) ? current.templates : [];
for (const nt of newTemplates) {
if (!nt.name || !nt.name.trim()) continue;
const old = oldTemplates.find(t => t.name === nt.name);
merged.templates.push({
name: nt.name.trim(),
apiKey: (nt.apiKey && !nt.apiKey.includes('****')) ? nt.apiKey : (old?.apiKey || ''),
apiBase: nt.apiBase || '',
defaultModel: nt.defaultModel || '',
opusModel: nt.opusModel || '',
sonnetModel: nt.sonnetModel || '',
haikuModel: nt.haikuModel || '',
});
}
saveModelConfig(merged);
// Re-apply at runtime (mutate in-place to preserve agent-runtime closure reference)
MODEL_MAP.opus = 'claude-opus-4-6';
MODEL_MAP.sonnet = 'claude-sonnet-4-6';
MODEL_MAP.haiku = 'claude-haiku-4-5-20251001';
applyModelConfig();
// custom mode: write to ~/.claude/settings.json immediately on save
if (merged.mode === 'custom' && merged.activeTemplate) {
const tpl = merged.templates.find(t => t.name === merged.activeTemplate);
if (tpl) applyCustomTemplateToSettings(tpl);
}
// Remap ALL Claude sessions' model to current template values.
// Build a reverse map: modelName → tier, from ALL templates (not just old/new).
// This handles switches between any pair of templates regardless of name overlap.
const modelToTier = new Map();
for (const tpl of (merged.templates || [])) {
if (tpl.opusModel) modelToTier.set(tpl.opusModel, 'opus');
if (tpl.sonnetModel) modelToTier.set(tpl.sonnetModel, 'sonnet');
if (tpl.haikuModel) modelToTier.set(tpl.haikuModel, 'haiku');
}
try {
for (const file of fs.readdirSync(SESSIONS_DIR)) {
if (!file.endsWith('.json')) continue;
const sessionId = file.slice(0, -5);
try {
const session = loadSession(sessionId);
if (!session?.model || session.agent === 'codex' || session.agent === 'codexapp') continue;
const tier = modelToTier.get(session.model);
if (tier && MODEL_MAP[tier] !== session.model) {
session.model = MODEL_MAP[tier];
session.updated = new Date().toISOString();
saveSession(session);
}
} catch {}
}
} catch {}
plog('INFO', 'model_config_saved', { mode: merged.mode, activeTemplate: merged.activeTemplate });
wsSend(ws, { type: 'model_config', config: getModelConfigMasked() });
wsSend(ws, { type: 'system_message', message: '模型配置已保存' });
}
function handleSaveCodexConfig(ws, newConfig) {
if (!newConfig || typeof newConfig !== 'object') {
return wsSend(ws, { type: 'error', message: '无效的 Codex 配置' });
}
const current = loadCodexConfig();
const newProfiles = Array.isArray(newConfig.profiles) ? newConfig.profiles : [];
const oldProfiles = Array.isArray(current.profiles) ? current.profiles : [];
const mergedProfiles = [];
for (const profile of newProfiles) {
const name = String(profile?.name || '').trim();
if (!name) continue;
const old = oldProfiles.find((item) => item.name === name);
const rawApiKey = String(profile?.apiKey || '');
mergedProfiles.push({
name,
apiKey: rawApiKey && !rawApiKey.includes('****') ? rawApiKey : (old?.apiKey || ''),
apiBase: String(profile?.apiBase || '').trim(),
});
}
const requestedSearch = !!newConfig.enableSearch;
const merged = {
mode: newConfig.mode === 'custom' ? 'custom' : 'local',
activeProfile: String(newConfig.activeProfile || '').trim(),
profiles: mergedProfiles,
enableSearch: false,
supportsSearch: false,
storedEnableSearch: requestedSearch,
};
if (merged.mode === 'custom' && merged.profiles.length > 0 && !merged.profiles.some((profile) => profile.name === merged.activeProfile)) {
merged.activeProfile = merged.profiles[0].name;
}
saveCodexConfig(merged);
plog('INFO', 'codex_config_saved', {
mode: merged.mode,
activeProfile: merged.activeProfile || null,
profileCount: merged.profiles.length,
enableSearchRequested: requestedSearch,
enableSearchEffective: false,
});
wsSend(ws, { type: 'codex_config', config: getCodexConfigMasked() });
wsSend(ws, {
type: 'system_message',
message: requestedSearch
? 'Codex 配置已保存。当前 cc-web 的 Codex exec 路径暂未接入 Web Search已自动忽略该开关。'
: 'Codex 配置已保存',
});
}
// === Fetch Upstream Models ===
function handleFetchModels(ws, msg) {
const { apiBase, apiKey, modelsEndpoint } = msg;
if (!apiBase || !apiKey) {
return wsSend(ws, { type: 'fetch_models_result', success: false, message: '需要填写 API Base 和 API Key' });
}
// Build URL: apiBase + modelsEndpoint (default /v1/models)
let base = apiBase.replace(/\/+$/, '');
const endpoint = modelsEndpoint || '/v1/models';
const fullUrl = base + endpoint;
let parsed;
try { parsed = new URL(fullUrl); } catch {
return wsSend(ws, { type: 'fetch_models_result', success: false, message: '无效的 URL: ' + fullUrl });
}
// Resolve real apiKey (if masked, look up saved config by template name or apiBase)
let realKey = apiKey;
if (apiKey.includes('****')) {
const config = loadModelConfig();
const saved = (config.templates || []);
// Match by template name first, then by apiBase
const tpl = (msg.templateName && saved.find(t => t.name === msg.templateName))
|| saved.find(t => t.apiBase && t.apiBase.replace(/\/+$/, '') === base)
|| null;
if (tpl && tpl.apiKey && !tpl.apiKey.includes('****')) realKey = tpl.apiKey;
else return wsSend(ws, { type: 'fetch_models_result', success: false, message: 'API Key 已脱敏,请重新输入完整 Key' });
}
const mod = parsed.protocol === 'https:' ? require('https') : require('http');
const reqOptions = {
method: 'GET',
headers: { 'Authorization': `Bearer ${realKey}` },
timeout: 15000,
};
const req = mod.request(parsed, reqOptions, (res) => {
let body = '';
res.on('data', (chunk) => { body += chunk; });
res.on('end', () => {
if (res.statusCode !== 200) {
return wsSend(ws, { type: 'fetch_models_result', success: false, message: `HTTP ${res.statusCode}: ${body.slice(0, 200)}` });
}
try {
const json = JSON.parse(body);
const models = (json.data || json.models || []).map(m => typeof m === 'string' ? m : m.id || m.name || '').filter(Boolean).sort();
wsSend(ws, { type: 'fetch_models_result', success: true, models });
} catch (e) {
wsSend(ws, { type: 'fetch_models_result', success: false, message: '解析响应失败: ' + e.message });
}
});
});
req.on('error', (e) => {
wsSend(ws, { type: 'fetch_models_result', success: false, message: '请求失败: ' + e.message });
});
req.on('timeout', () => {
req.destroy();
wsSend(ws, { type: 'fetch_models_result', success: false, message: '请求超时 (15s)' });
});
req.end();
}
// === Slash Command Handler ===
function handleSlashCommand(ws, text, sessionId, fallbackAgent) {
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';
if (session && isCodexAppSession(session) && activeCodexAppTurns.has(sessionId)) {
wsSend(ws, { type: 'system_message', message: 'Codex App 运行中暂不支持 slash 指令,请等待完成或点击停止。' });
return;
}
switch (cmd) {
case '/clear': {
if (session) {
if (activeProcesses.has(sessionId)) {
const entry = activeProcesses.get(sessionId);
killProcess(entry.pid);
if (entry.tailer) entry.tailer.stop();
activeProcesses.delete(sessionId);
cleanRunDir(sessionId);
}
session.messages = [];
clearRuntimeSessionId(session);
session.updated = new Date().toISOString();
saveSession(session);
wsSend(ws, {
type: 'session_info',
sessionId: session.id,
messages: [],
title: session.title,
pinnedAt: session.pinnedAt || null,
mode: session.permissionMode || 'yolo',
model: sessionModelLabel(session),
agent: getSessionAgent(session),
cwd: session.cwd || null,
totalCost: session.totalCost || 0,
totalUsage: session.totalUsage || null,
});
}
wsSend(ws, { type: 'system_message', message: '会话已清除,上下文已重置。' });
break;
}
case '/model': {
const modelInput = parts[1];
if (codexLikeAgent) {
if (!modelInput) {
const current = session?.model || getDefaultCodexModel();
wsSend(ws, { type: 'system_message', message: `当前 ${agent === 'codexapp' ? 'Codex App' : 'Codex'} 模型: ${current}\n用法: /model <模型名>` });
} else {
if (session) {
session.model = modelInput;
session.updated = new Date().toISOString();
saveSession(session);
}
wsSend(ws, { type: 'model_changed', model: modelInput });
wsSend(ws, { type: 'system_message', message: `${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` });
} else {
const modelKey = modelInput.toLowerCase();
if (!MODEL_MAP[modelKey]) {
wsSend(ws, { type: 'system_message', message: `无效模型: ${modelInput}\n可选: opus, sonnet, haiku` });
} else {
const model = MODEL_MAP[modelKey];
if (session) {
session.model = model;
session.updated = new Date().toISOString();
saveSession(session);
}
wsSend(ws, { type: 'model_changed', model: modelKey });
wsSend(ws, { type: 'system_message', message: `模型已切换为: ${modelKey}` });
}
}
break;
}
case '/cost': {
if (codexLikeAgent) {
const usage = session?.totalUsage || { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 };
wsSend(ws, {
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)}` });
}
break;
}
case '/compact': {
if (!sessionId || !session) {
wsSend(ws, { type: 'system_message', message: '当前没有可压缩的会话。请先进入一个已进行过对话的会话后再执行 /compact。' });
break;
}
if (isSessionRunning(sessionId)) {
wsSend(ws, { type: 'system_message', message: '当前会话正在处理中,请先等待完成或点击停止,再执行 /compact。' });
break;
}
if (isCodexAppSession(session)) {
wsSend(ws, { type: 'system_message', message: 'Codex App 模式暂不支持 /compact请切换到旧 Codex 模式或等待后续接入。' });
break;
}
const runtimeId = getRuntimeSessionId(session);
if (!runtimeId) {
wsSend(ws, {
type: 'system_message',
message: agent === 'codex'
? '当前会话尚未建立 Codex 上下文,暂时无需压缩。'
: '当前会话尚未建立 Claude 上下文,暂时无需压缩。',
});
break;
}
wsSend(ws, { type: 'system_message', message: compactStartMessage(agent) });
pendingSlashCommands.set(session.id, { kind: 'compact' });
handleMessage(ws, { text: '/compact', sessionId: session.id, mode: session.permissionMode || 'yolo' }, { hideInHistory: true });
break;
}
case '/init': {
if (!sessionId || !session) {
wsSend(ws, { type: 'system_message', message: '请先进入一个会话后再执行 /init。' });
break;
}
if (isSessionRunning(sessionId)) {
wsSend(ws, { type: 'system_message', message: '当前会话正在处理中,请先等待完成或点击停止。' });
break;
}
wsSend(ws, { type: 'system_message', message: initStartMessage(agent) });
pendingSlashCommands.set(session.id, { kind: 'init' });
handleMessage(ws, {
text: codexLikeAgent ? buildCodexInitPrompt(session.cwd) : '/init',
sessionId: session.id,
mode: session.permissionMode || 'yolo',
}, { 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` });
}
break;
}
case '/help': {
const base = '可用指令:\n' +
'/clear — 清除当前会话(含上下文)\n' +
'/mode [模式] — 查看/切换权限模式default, plan, yolo\n' +
'/cost — 查看当前会话累计统计\n' +
'/help — 显示本帮助';
wsSend(ws, {
type: 'system_message',
message: codexLikeAgent
? base + `\n/model [名称] — 查看/切换 ${agent === 'codexapp' ? 'Codex App' : 'Codex'} 模型(自由输入)\n/init — 分析项目并生成/更新 AGENTS.md${agent === 'codexapp' ? '\n/compact — Codex App 模式暂不支持' : '\n/compact — 执行 Codex /compact 压缩上下文'}`
: base + '\n/model [名称] — 查看/切换模型opus, sonnet, haiku\n/compact — 执行 Claude 原生上下文压缩(保留压缩计划并可自动续跑)\n/init — 分析项目并生成/更新 CLAUDE.md',
});
break;
}
default:
wsSend(ws, { type: 'system_message', message: `未知指令: ${cmd}\n输入 /help 查看可用指令` });
}
}
// === Session Handlers ===
function normalizeConversationTitle(title, fallback = 'New Chat') {
const normalized = String(title || '').replace(/\s+/g, ' ').trim();
if (!normalized) return fallback;
return truncateTextValue(normalized, MCP_CONVERSATION_TITLE_MAX_CHARS, '...');
}
function resolveConversationAgent(rawAgent, fallbackAgent = 'claude', options = {}) {
const value = String(rawAgent || '').trim().toLowerCase();
if (value) {
if (VALID_AGENTS.has(value)) return { ok: true, agent: value };
if (options.strict) {
return mcpToolError('invalid_agent', 'Agent 必须是 claude、codex 或 codexapp。', { agent: value });
}
}
return { ok: true, agent: VALID_AGENTS.has(fallbackAgent) ? fallbackAgent : 'claude' };
}
function resolveConversationMode(rawMode, fallbackMode = 'yolo', options = {}) {
const value = String(rawMode || '').trim().toLowerCase();
if (value) {
if (VALID_PERMISSION_MODES.has(value)) return { ok: true, mode: value };
if (options.strict) {
return mcpToolError('invalid_mode', 'mode 必须是 default、plan 或 yolo。', { mode: value });
}
}
return { ok: true, mode: VALID_PERMISSION_MODES.has(fallbackMode) ? fallbackMode : 'yolo' };
}
function createPersistentConversationSession(args = {}, options = {}) {
const sourceSession = options.sourceSession || null;
const strict = !!options.strict;
const explicitCwd = typeof args.cwd === 'string' && args.cwd.trim();
const fallbackAgent = getSessionAgent(sourceSession) || options.defaultAgent || 'claude';
const fallbackMode = sourceSession?.permissionMode || options.defaultMode || 'yolo';
const agentResult = resolveConversationAgent(args.agent, fallbackAgent, { strict });
if (!agentResult.ok) return agentResult;
const modeResult = resolveConversationMode(args.mode, fallbackMode, { strict });
if (!modeResult.ok) return modeResult;
let cwdCandidate = explicitCwd ? String(args.cwd).trim() : '';
if (explicitCwd && options.requireAbsoluteCwd && !path.isAbsolute(cwdCandidate)) {
return mcpToolError('create_conversation_cwd_relative', 'cwd 必须是已存在的绝对路径。', { cwd: cwdCandidate });
}
if (!cwdCandidate && sourceSession?.cwd) {
cwdCandidate = normalizeExistingDirPath(sourceSession.cwd) || '';
}
const cwdResult = resolveSessionCwd(cwdCandidate || null, {
createMissing: !!(options.allowCreateCwd && args.createCwd),
});
if (!cwdResult.ok) {
return mcpToolError(cwdResult.code, cwdResult.message, { cwd: cwdResult.resolvedPath || cwdCandidate || null });
}
const now = new Date().toISOString();
const agent = agentResult.agent;
const session = {
id: crypto.randomUUID(),
title: normalizeConversationTitle(args.title),
created: now,
updated: now,
pinnedAt: null,
agent,
claudeSessionId: null,
codexThreadId: null,
codexAppThreadId: null,
// Codex/Codex App 读取 ~/.codex/config.toml 默认模型Claude 继续默认 opus 1M。
model: agent === 'codex' || agent === 'codexapp' ? getDefaultCodexModel() : MODEL_MAP.opus,
permissionMode: modeResult.mode,
totalCost: 0,
totalUsage: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 },
messages: [],
cwd: cwdResult.path || getDefaultSessionCwd(),
};
if (options.createdFrom) session.createdFrom = options.createdFrom;
if (!saveSession(session)) {
return mcpToolError('session_save_failed', '创建会话失败,请检查 sessions 目录写入权限。');
}
return { ok: true, session };
}
function buildSessionInfoPayload(session) {
return {
type: 'session_info',
sessionId: session.id,
messages: session.messages || [],
title: session.title,
pinnedAt: session.pinnedAt || null,
mode: session.permissionMode || 'yolo',
model: sessionModelLabel(session),
agent: getSessionAgent(session),
cwd: session.cwd,
totalCost: session.totalCost || 0,
totalUsage: session.totalUsage || { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 },
updated: session.updated,
hasUnread: false,
historyPending: false,
isRunning: false,
};
}
function handleNewSession(ws, msg) {
const result = createPersistentConversationSession(msg || {}, {
defaultAgent: normalizeAgent(msg?.agent),
defaultMode: 'yolo',
allowCreateCwd: true,
});
if (!result.ok) {
return wsSend(ws, {
type: 'error',
code: result.code,
cwd: result.cwd || null,
message: result.message,
});
}
const { session } = result;
detachWsFromActiveRuntimes(ws);
wsSessionMap.set(ws, session.id);
wsSend(ws, buildSessionInfoPayload(session));
sendSessionList(ws);
}
function handleLoadHistoryPage(ws, msg = {}) {
const sessionId = sanitizeId(msg.sessionId || '');
const session = loadSession(sessionId);
if (!session) {
return wsSend(ws, { type: 'error', message: 'Session not found' });
}
const list = Array.isArray(session.messages) ? session.messages : [];
const requestedBefore = Number.parseInt(String(msg.before || ''), 10);
const before = Number.isFinite(requestedBefore)
? Math.max(0, Math.min(list.length, requestedBefore))
: Math.max(0, list.length - INITIAL_HISTORY_COUNT);
const end = before;
const start = Math.max(0, end - HISTORY_CHUNK_SIZE);
wsSend(ws, {
type: 'session_history_chunk',
sessionId: session.id,
messages: list.slice(start, end),
remaining: 0,
historyCursor: start,
historyTruncated: start > 0,
});
}
function handleLoadSession(ws, sessionId) {
const session = loadSession(sessionId);
if (!session) {
return wsSend(ws, { type: 'error', message: 'Session not found' });
}
if (getSessionAgent(session) === 'claude' && !session.cwd && session.claudeSessionId) {
const localMeta = resolveClaudeSessionLocalMeta(session.claudeSessionId);
if (localMeta?.cwd) {
session.cwd = localMeta.cwd;
if (!session.importedFrom && localMeta.projectDir) session.importedFrom = localMeta.projectDir;
saveSession(session);
}
}
const { recentMessages, olderChunks, historyRemaining, historyBuffered } = splitHistoryMessages(session.messages);
const effectiveCwd = session.cwd || activeProcesses.get(sessionId)?.cwd || activeCodexAppTurns.get(sessionId)?.cwd || null;
// Detach ws from any previous session's process
detachWsFromActiveRuntimes(ws);
wsSessionMap.set(ws, sessionId);
// Read and clear unread flag
const hadUnread = !!session.hasUnread;
if (session.hasUnread) {
session.hasUnread = false;
saveSession(session);
}
wsSend(ws, {
type: 'session_info',
sessionId: session.id,
messages: recentMessages,
title: session.title,
pinnedAt: session.pinnedAt || null,
mode: session.permissionMode || 'yolo',
model: sessionModelLabel(session),
agent: getSessionAgent(session),
hasUnread: hadUnread,
cwd: effectiveCwd,
totalCost: session.totalCost || 0,
totalUsage: session.totalUsage || null,
historyTotal: session.messages.length,
historyBuffered,
historyCursor: historyRemaining,
historyTruncated: historyRemaining > 0,
historyPending: olderChunks.length > 0,
updated: session.updated,
isRunning: isSessionRunning(sessionId),
});
if (olderChunks.length > 0) {
olderChunks.forEach((chunk, index) => {
wsSend(ws, {
type: 'session_history_chunk',
sessionId: session.id,
messages: chunk,
remaining: Math.max(0, olderChunks.length - index - 1),
historyCursor: index === olderChunks.length - 1 ? historyRemaining : null,
historyTruncated: historyRemaining > 0,
});
});
}
// Resume streaming if process is still active
if (activeProcesses.has(sessionId)) {
const entry = activeProcesses.get(sessionId);
entry.ws = ws;
entry.wsDisconnectTime = null; // clear disconnect marker
plog('INFO', 'ws_resume_attach', {
sessionId: sessionId.slice(0, 8),
pid: entry.pid,
responseLen: (entry.fullText || '').length,
});
wsSend(ws, {
type: 'resume_generating',
sessionId,
text: truncateTextValue(entry.fullText || '', SESSION_MESSAGE_CONTENT_MAX_CHARS),
toolCalls: sanitizeToolCallsForPersist(entry.toolCalls || []),
});
} else if (activeCodexAppTurns.has(sessionId)) {
const entry = activeCodexAppTurns.get(sessionId);
entry.ws = ws;
entry.wsDisconnectTime = null;
plog('INFO', 'codex_app_ws_resume_attach', {
sessionId: sessionId.slice(0, 8),
threadId: entry.threadId || null,
turnId: entry.turnId || null,
responseLen: (entry.fullText || '').length,
});
wsSend(ws, {
type: 'resume_generating',
sessionId,
text: truncateTextValue(entry.fullText || '', SESSION_MESSAGE_CONTENT_MAX_CHARS),
toolCalls: sanitizeToolCallsForPersist(entry.toolCalls || []),
});
}
}
function sqlQuote(value) {
return `'${String(value).replace(/'/g, "''")}'`;
}
function deleteClaudeLocalSession(claudeSessionId) {
if (!claudeSessionId) return;
const projectsDir = path.join(process.env.HOME || process.env.USERPROFILE || '', '.claude', 'projects');
try {
for (const proj of fs.readdirSync(projectsDir)) {
const target = path.join(projectsDir, proj, `${claudeSessionId}.jsonl`);
if (fs.existsSync(target)) fs.unlinkSync(target);
}
} catch {}
}
function deleteCodexLocalSession(session) {
const threadId = session?.codexThreadId;
if (!threadId) return { removedFiles: 0, removedDbRows: false };
const rolloutPaths = new Set();
if (session.importedRolloutPath) rolloutPaths.add(path.resolve(session.importedRolloutPath));
try {
for (const filePath of getCodexRolloutFiles()) {
if (filePath.includes(threadId)) rolloutPaths.add(path.resolve(filePath));
}
} catch {}
let removedFiles = 0;
for (const filePath of rolloutPaths) {
try {
if (filePath.startsWith(CODEX_SESSIONS_DIR) && fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
removedFiles++;
}
} catch {}
}
let removedDbRows = false;
try {
const sqlitePath = spawnSync('sqlite3', ['-version'], { stdio: 'ignore' });
if (sqlitePath.status === 0) {
const quotedThreadId = sqlQuote(threadId);
const stateSql = [
'PRAGMA foreign_keys = ON;',
`DELETE FROM thread_dynamic_tools WHERE thread_id = ${quotedThreadId};`,
`DELETE FROM stage1_outputs WHERE thread_id = ${quotedThreadId};`,
`DELETE FROM logs WHERE thread_id = ${quotedThreadId};`,
`DELETE FROM threads WHERE id = ${quotedThreadId};`,
].join(' ');
const stateResult = spawnSync('sqlite3', [CODEX_STATE_DB_PATH, stateSql], { stdio: 'ignore' });
if (stateResult.status === 0) removedDbRows = true;
if (fs.existsSync(CODEX_LOG_DB_PATH)) {
spawnSync('sqlite3', [CODEX_LOG_DB_PATH, `DELETE FROM logs WHERE thread_id = ${quotedThreadId};`], { stdio: 'ignore' });
}
}
} catch {}
return { removedFiles, removedDbRows };
}
function handleDeleteSession(ws, sessionId) {
pendingSlashCommands.delete(sessionId);
pendingCompactRetries.delete(sessionId);
for (const [threadId, child] of ccwebMcpChildThreads.entries()) {
if (child.parentSessionId === sessionId) ccwebMcpChildThreads.delete(threadId);
}
if (activeProcesses.has(sessionId)) {
const entry = activeProcesses.get(sessionId);
try { killProcess(entry.pid); } catch {}
if (entry.tailer) entry.tailer.stop();
activeProcesses.delete(sessionId);
if (entry.ws) wsSend(entry.ws, { type: 'done', sessionId });
}
if (activeCodexAppTurns.has(sessionId)) {
const entry = activeCodexAppTurns.get(sessionId);
activeCodexAppTurns.delete(sessionId);
if (entry?.ws) wsSend(entry.ws, { type: 'done', sessionId });
if (entry?.threadId && entry?.turnId && codexAppClient?.isRunning()) {
codexAppClient.request('turn/interrupt', {
threadId: entry.threadId,
turnId: entry.turnId,
}, 30000).catch(() => {});
}
}
cleanRunDir(sessionId);
try {
const p = sessionPath(sessionId);
const session = loadSession(sessionId);
const sessionAgent = getSessionAgent(session);
for (const attachmentId of collectSessionAttachmentIds(session)) {
removeAttachmentById(attachmentId);
}
if (fs.existsSync(p)) fs.unlinkSync(p);
if (sessionAgent === 'codex') {
const result = deleteCodexLocalSession(session);
plog('INFO', 'codex_local_session_deleted', {
sessionId: sessionId.slice(0, 8),
threadId: session?.codexThreadId || null,
removedFiles: result.removedFiles,
removedDbRows: result.removedDbRows,
});
} else if (sessionAgent === 'claude') {
deleteClaudeLocalSession(session?.claudeSessionId || null);
}
sendSessionList(ws);
} catch {
wsSend(ws, { type: 'error', message: 'Failed to delete session' });
}
}
function handleRenameSession(ws, sessionId, title) {
if (!sessionId || !title) return;
const session = loadSession(sessionId);
if (session) {
session.title = String(title).slice(0, 100);
session.updated = new Date().toISOString();
saveSession(session);
sendSessionList(ws);
wsSend(ws, { type: 'session_renamed', sessionId, title: session.title });
}
}
function handleSetSessionPinned(ws, sessionId, pinned) {
const normalizedId = sanitizeId(sessionId || '');
if (!normalizedId) return wsSend(ws, { type: 'error', message: '缺少会话 ID' });
const session = loadSession(normalizedId);
if (!session) return wsSend(ws, { type: 'error', message: 'Session not found' });
session.pinnedAt = pinned ? new Date().toISOString() : null;
saveSession(session);
wsSend(ws, {
type: 'session_pinned',
sessionId: session.id,
pinnedAt: session.pinnedAt || null,
});
broadcastSessionList();
}
function handleSetMode(ws, sessionId, mode) {
const VALID_MODES = ['default', 'plan', 'yolo'];
if (!mode || !VALID_MODES.includes(mode)) return;
if (sessionId) {
const session = loadSession(sessionId);
if (session) {
session.permissionMode = mode;
// Same rule as /mode: don't clear runtime context on mode changes.
session.updated = new Date().toISOString();
saveSession(session);
}
}
wsSend(ws, { type: 'mode_changed', mode });
}
function handleDisconnect(ws, wsId) {
const affectedSessions = [];
for (const [sid, entry] of activeProcesses) {
if (entry.ws === ws) {
entry.ws = null;
entry.wsDisconnectTime = new Date().toISOString();
affectedSessions.push({ sessionId: sid.slice(0, 8), pid: entry.pid });
}
}
for (const [sid, entry] of activeCodexAppTurns) {
if (entry.ws === ws) {
entry.ws = null;
entry.wsDisconnectTime = new Date().toISOString();
affectedSessions.push({ sessionId: sid.slice(0, 8), threadId: entry.threadId || null, turnId: entry.turnId || null });
}
}
wsSessionMap.delete(ws);
plog('INFO', 'ws_disconnect', { wsId, activeProcessesAffected: affectedSessions });
}
function handleDetachView(ws) {
detachWsFromActiveRuntimes(ws, { markDisconnect: true });
wsSessionMap.delete(ws);
}
function handleAbort(ws) {
const sessionId = wsSessionMap.get(ws);
if (!sessionId) return;
if (handleCodexAppAbortSession(sessionId, ws)) return;
const entry = activeProcesses.get(sessionId);
if (!entry) return;
plog('INFO', 'user_abort', { sessionId: sessionId.slice(0, 8), pid: entry.pid });
killProcess(entry.pid);
setTimeout(() => {
killProcess(entry.pid, true);
}, 3000);
// handleProcessComplete will be triggered by the PID monitor
}
function closeCcwebMcpChildAgent(sessionId, childThreadId, options = {}) {
const normalizedSessionId = sanitizeId(sessionId || '');
const normalizedThreadId = String(childThreadId || '').trim();
if (!normalizedSessionId || !normalizedThreadId) {
return { ok: false, code: 'missing_child_agent', message: '缺少子代理线程 ID。' };
}
const child = ccwebMcpChildThreads.get(normalizedThreadId);
if (!child || child.parentSessionId !== normalizedSessionId) {
return { ok: false, code: 'child_agent_not_found', message: '未找到可关闭的 ccweb MCP 子代理。' };
}
const now = new Date().toISOString();
child.status = 'closed';
child.closedAt = now;
child.updatedAt = now;
child.closeReason = options.reason || 'manual';
ccwebMcpChildThreads.set(normalizedThreadId, child);
if (child.turnId && codexAppClient?.isRunning()) {
codexAppClient.request('turn/interrupt', {
threadId: child.threadId,
turnId: child.turnId,
}, 30000).catch((err) => {
plog('INFO', 'ccweb_mcp_child_interrupt_failed', {
sessionId: normalizedSessionId.slice(0, 8),
childThreadId: normalizedThreadId,
error: err?.message || String(err || ''),
});
});
}
sendCcwebMcpChildAgentUpdate(normalizedSessionId, child);
return { ok: true, child: ccwebMcpChildPublicState(child) };
}
function handleCcwebMcpChildAgentClose(ws, msg = {}) {
const sessionId = sanitizeId(msg.sessionId || wsSessionMap.get(ws) || '');
const result = closeCcwebMcpChildAgent(sessionId, msg.threadId || msg.childThreadId, { reason: 'manual' });
if (!result.ok) {
wsSend(ws, {
type: 'error',
sessionId,
code: result.code,
message: result.message,
transient: true,
autoDismissMs: 6000,
});
return;
}
wsSend(ws, {
type: 'system_message',
sessionId,
tone: 'info',
transient: true,
autoDismissMs: 4000,
message: `已关闭子代理 ${result.child.label || result.child.threadId}`,
});
}
// === Runtime Message Handler ===
function handleMessage(ws, msg, options = {}) {
const { text, sessionId, mode } = msg;
const { hideInHistory = false } = options;
const fail = (code, message) => {
wsSend(ws, { type: 'error', code, message });
return { ok: false, code, message };
};
const textValue = typeof text === 'string' ? text : '';
let runtimeTextValue = typeof options.runtimeText === 'string' ? options.runtimeText : textValue;
const attachments = Array.isArray(msg.attachments) ? msg.attachments.slice(0, MAX_MESSAGE_ATTACHMENTS) : [];
const normalizedText = textValue.trim();
let normalizedRuntimeText = runtimeTextValue.trim();
const resolvedAttachments = resolveMessageAttachments(attachments);
if (attachments.length > 0 && resolvedAttachments.length === 0) {
return fail('attachment_unavailable', '图片附件已过期或不可用,请重新上传后再发送。');
}
if (!normalizedText && resolvedAttachments.length === 0) {
return fail('empty_message', '消息内容不能为空。');
}
const savedAttachments = resolvedAttachments.map((attachment) => ({
id: attachment.id,
kind: 'image',
filename: attachment.filename,
mime: attachment.mime,
size: attachment.size,
createdAt: attachment.createdAt,
expiresAt: attachment.expiresAt,
storageState: attachment.storageState,
}));
if (sessionId && activeCodexAppTurns.has(sessionId)) {
return handleCodexAppSteerMessage(ws, msg, options);
}
if (sessionId && activeProcesses.has(sessionId)) {
return fail('session_running', '正在处理中,请先点击停止按钮。');
}
const derivedTitle = normalizedText
? textValue.slice(0, 60).replace(/\n/g, ' ')
: `图片: ${savedAttachments[0]?.filename || 'image'}`;
let session;
if (sessionId) session = loadSession(sessionId);
if (!session) {
const id = crypto.randomUUID();
const agent = normalizeAgent(msg.agent);
const resolvedCwd = getDefaultSessionCwd();
session = {
id,
title: derivedTitle,
created: new Date().toISOString(),
updated: new Date().toISOString(),
pinnedAt: null,
agent,
claudeSessionId: null,
codexThreadId: null,
codexAppThreadId: null,
model: agent === 'codex' || agent === 'codexapp' ? getDefaultCodexModel() : null,
permissionMode: mode || 'yolo',
totalCost: 0,
totalUsage: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 },
messages: [],
cwd: resolvedCwd,
};
}
normalizeSession(session);
const decoratorResolution = typeof options.runtimeText === 'string'
? { runtimeText: runtimeTextValue, mentions: [] }
: resolveComposerDecorators(textValue, session, getSessionAgent(session));
runtimeTextValue = decoratorResolution.runtimeText;
normalizedRuntimeText = runtimeTextValue.trim();
if (normalizedText.startsWith('/') && resolvedAttachments.length > 0) {
return fail('command_attachment_unsupported', '命令消息暂不支持同时附带图片。请先发送图片说明,再单独使用 /model 或 /mode。');
}
if (mode && ['default', 'plan', 'yolo'].includes(mode)) {
session.permissionMode = mode;
}
if (!hideInHistory && normalizedRuntimeText !== '/compact' && !isCodexAppSession(session) && getRuntimeSessionId(session)) {
const retryText = typeof options.runtimeText === 'string' ? normalizedRuntimeText : textValue;
pendingCompactRetries.set(session.id, { text: retryText, mode: session.permissionMode || 'yolo', reason: 'normal' });
}
if (session.title === 'New Chat' || session.title === 'Untitled') {
session.title = derivedTitle;
}
let persistedUserMessage = null;
if (!hideInHistory) {
persistedUserMessage = {
role: 'user',
content: textValue,
attachments: savedAttachments,
timestamp: new Date().toISOString(),
};
if (options.crossConversation) {
persistedUserMessage.crossConversation = options.crossConversation;
}
if (decoratorResolution.mentions.length > 0) {
persistedUserMessage.composerMentions = decoratorResolution.mentions;
}
session.messages.push(persistedUserMessage);
}
session.updated = new Date().toISOString();
saveSession(session);
const currentSessionId = session.id;
if (ws) {
detachWsFromActiveRuntimes(ws);
wsSessionMap.set(ws, currentSessionId);
}
if (!sessionId) {
wsSend(ws, {
type: 'session_info',
sessionId: currentSessionId,
messages: session.messages,
title: session.title,
pinnedAt: session.pinnedAt || null,
mode: session.permissionMode || 'yolo',
model: sessionModelLabel(session),
agent: getSessionAgent(session),
cwd: session.cwd || null,
totalCost: session.totalCost || 0,
totalUsage: session.totalUsage || null,
updated: session.updated,
hasUnread: false,
historyPending: false,
isRunning: false,
});
}
if (ws && options.emitUserMessage && persistedUserMessage) {
wsSend(ws, { type: 'session_message', sessionId: currentSessionId, message: persistedUserMessage });
}
sendSessionList(ws);
if (isCodexAppSession(session)) {
return handleCodexAppMessage(ws, session, runtimeTextValue, resolvedAttachments, {
mcpContext: options.mcpContext || {},
crossConversation: options.crossConversation || null,
});
}
const runtimeOptions = {
attachments: resolvedAttachments,
mcpContext: options.mcpContext || {},
};
const spawnSpec = isClaudeSession(session)
? buildClaudeSpawnSpec(session, runtimeOptions)
: buildCodexSpawnSpec(session, runtimeOptions);
if (spawnSpec?.error) {
return fail('runtime_config_error', spawnSpec.error);
}
// === Detached process with file-based I/O ===
const dir = runDir(currentSessionId);
fs.mkdirSync(dir, { recursive: true });
const inputPath = path.join(dir, 'input.txt');
const outputPath = path.join(dir, 'output.jsonl');
const errorPath = path.join(dir, 'error.log');
if (isClaudeSession(session) && resolvedAttachments.length > 0) {
const content = [];
if (runtimeTextValue) content.push({ type: 'text', text: runtimeTextValue });
for (const attachment of resolvedAttachments) {
const data = fs.readFileSync(attachment.path).toString('base64');
content.push({
type: 'image',
source: {
type: 'base64',
media_type: attachment.mime,
data,
},
});
}
fs.writeFileSync(inputPath, `${JSON.stringify({
type: 'user',
message: {
role: 'user',
content,
},
})}\n`);
} else {
fs.writeFileSync(inputPath, runtimeTextValue);
}
const inputFd = fs.openSync(inputPath, 'r');
const outputFd = fs.openSync(outputPath, 'w');
const errorFd = fs.openSync(errorPath, 'w');
let proc;
try {
proc = spawn(spawnSpec.command, spawnSpec.args, {
env: spawnSpec.env,
cwd: spawnSpec.cwd,
stdio: [inputFd, outputFd, errorFd],
detached: !IS_WIN,
windowsHide: true,
});
} catch (err) {
fs.closeSync(inputFd);
fs.closeSync(outputFd);
fs.closeSync(errorFd);
cleanRunDir(currentSessionId);
plog('ERROR', 'process_spawn_fail', { sessionId: currentSessionId.slice(0, 8), error: err.message });
const agent = getSessionAgent(session);
return fail('process_spawn_failed', formatRuntimeError(agent, err.message, { exitCode: null, signal: null }));
}
fs.closeSync(inputFd);
fs.closeSync(outputFd);
fs.closeSync(errorFd);
fs.writeFileSync(path.join(dir, 'pid'), String(proc.pid));
proc.unref(); // Process survives Node.js exit
plog('INFO', 'process_spawn', {
sessionId: currentSessionId.slice(0, 8),
pid: proc.pid,
agent: getSessionAgent(session),
mode: spawnSpec.mode,
model: session.model || 'default',
resume: spawnSpec.resume,
args: redactSpawnArgs(spawnSpec.args.join(' ')),
});
// Fast exit detection (while Node.js is running)
proc.on('exit', (code, signal) => {
plog('INFO', 'process_exit_event', {
sessionId: currentSessionId.slice(0, 8),
pid: proc.pid,
exitCode: code,
signal: signal,
});
// Small delay to ensure file is fully flushed
setTimeout(() => handleProcessComplete(currentSessionId, code, signal), 300);
});
const entry = {
pid: proc.pid,
ws,
agent: getSessionAgent(session),
cwd: spawnSpec.cwd,
fullText: '',
attachments: resolvedAttachments,
toolCalls: [],
lastCost: null,
lastUsage: null,
lastError: null,
errorSent: false,
crossConversationReplyRequestId: options.crossConversation?.replyRequestId || null,
tailer: null,
};
activeProcesses.set(currentSessionId, entry);
sendSessionList(ws);
// Tail the output file for real-time streaming
entry.tailer = new FileTailer(outputPath, (line) => {
try {
const event = JSON.parse(line);
processRuntimeEvent(entry, event, currentSessionId);
} catch {}
});
entry.tailer.start();
return { ok: true, sessionId: currentSessionId, pid: proc.pid };
}
function truncateObj(obj, maxLen) {
const s = JSON.stringify(obj);
if (s.length <= maxLen) return obj;
return s.slice(0, maxLen) + '...';
}
function safeJsonParse(input) {
if (input === null || input === undefined) return input;
if (typeof input !== 'string') return input;
const trimmed = input.trim();
if (!trimmed) return input;
if (!((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']')))) {
return input;
}
try {
return JSON.parse(trimmed);
} catch {
return input;
}
}
function sanitizeToolInput(toolName, input) {
const parsed = safeJsonParse(input);
if (toolName === 'AskUserQuestion') {
return parsed;
}
return truncateObj(parsed, 500);
}
function redactSpawnArgs(argsText) {
return String(argsText || '')
.replace(/CC_WEB_MCP_TOKEN[^\s,\]}]*/g, 'CC_WEB_MCP_TOKEN=****')
.replace(/mcp_servers\.ccweb\.env=\{[^}]*\}/g, 'mcp_servers.ccweb.env={****}');
}
const {
buildClaudeSpawnSpec,
buildCodexSpawnSpec,
processClaudeEvent,
processCodexEvent,
processRuntimeEvent,
} = createAgentRuntime({
processEnv: process.env,
CLAUDE_PATH,
CODEX_PATH,
MODEL_MAP,
loadModelConfig,
applyCustomTemplateToSettings,
getDefaultCodexModel,
loadCodexConfig,
prepareCodexCustomRuntime,
ccwebMcpServerPath: CCWEB_MCP_SERVER_PATH,
internalMcpUrl: `http://127.0.0.1:${PORT}/api/internal/mcp`,
internalMcpToken: INTERNAL_MCP_TOKEN,
nodePath: process.execPath,
wsSend,
truncateObj,
sanitizeToolInput,
loadSession,
saveSession,
setRuntimeSessionId,
getRuntimeSessionId,
});
const codexAppRuntime = createCodexAppRuntime({
wsSend,
loadSession,
saveSession,
truncateObj,
});
function detachWsFromActiveRuntimes(ws, options = {}) {
const disconnectTime = options.markDisconnect ? new Date().toISOString() : null;
for (const [, entry] of activeProcesses) {
if (entry.ws === ws) {
entry.ws = null;
if (disconnectTime) entry.wsDisconnectTime = disconnectTime;
}
}
for (const [, entry] of activeCodexAppTurns) {
if (entry.ws === ws) {
entry.ws = null;
if (disconnectTime) entry.wsDisconnectTime = disconnectTime;
}
}
}
function findCodexAppEntryByRuntime(params = {}) {
const threadId = params.threadId || params.thread?.id || null;
const turnId = params.turnId || params.turn?.id || null;
if (threadId) {
for (const [sessionId, entry] of activeCodexAppTurns) {
if (entry.threadId === threadId) return { sessionId, entry };
}
}
if (turnId) {
for (const [sessionId, entry] of activeCodexAppTurns) {
if (entry.turnId === turnId) return { sessionId, entry };
}
}
return null;
}
function parseMaybeJsonObject(value) {
if (value && typeof value === 'object' && !Array.isArray(value)) return value;
if (typeof value !== 'string') return null;
const text = value.trim();
if (!text || !text.startsWith('{')) return null;
try {
const parsed = JSON.parse(text);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
} catch {
return null;
}
}
function codexAppCollabToolName(value) {
const normalized = String(value || '').trim().toLowerCase().replace(/[\s_-]/g, '');
if (normalized === 'spawn' || normalized === 'spawnagent') return 'spawn_agent';
if (normalized === 'wait' || normalized === 'waitagent') return 'wait_agent';
if (normalized === 'sendinput' || normalized === 'sendmessage') return 'send_input';
if (normalized === 'resume' || normalized === 'resumeagent') return 'resume_agent';
if (normalized === 'close' || normalized === 'closeagent') return 'close_agent';
return normalized || '';
}
function ccwebMcpChildStatus(value, fallback = 'running') {
const normalized = String(value || '').trim().toLowerCase().replace(/[\s_-]/g, '');
if (!normalized) return fallback;
if (/^(closed|close|cleanup|cleaned)$/.test(normalized)) return 'closed';
if (/^(returned|completed|complete|done|success|succeeded|finished)$/.test(normalized)) return 'returned';
if (/^(failed|failure|error|errored)$/.test(normalized)) return 'failed';
if (/^(cancelled|canceled|aborted|interrupted)$/.test(normalized)) return 'closed';
if (/^(pending|pendinginit|queued|waiting|running|working|active|inprogress|started)$/.test(normalized)) return 'running';
return fallback;
}
function extractCcwebMcpStringArray(...values) {
for (const value of values) {
if (!Array.isArray(value)) continue;
const list = value.map((item) => String(item || '').trim()).filter(Boolean);
if (list.length > 0) return list;
}
return [];
}
function extractCcwebMcpAgentText(value) {
if (!value) return '';
if (typeof value === 'string') return value.trim();
if (typeof value.text === 'string') return value.text.trim();
if (typeof value.message === 'string') return value.message.trim();
if (typeof value.content === 'string') return value.content.trim();
if (Array.isArray(value.content)) {
return value.content.map((part) => {
if (typeof part === 'string') return part;
if (typeof part?.text === 'string') return part.text;
if (typeof part?.content === 'string') return part.content;
return '';
}).filter(Boolean).join('').trim();
}
return '';
}
function extractCcwebMcpChildCandidate(state = {}) {
if (!state || typeof state !== 'object') return '';
const direct = extractCcwebMcpAgentText(state);
if (direct) return direct;
for (const key of ['summary', 'lastMessage', 'finalMessage', 'final_message', 'output', 'result']) {
const value = state[key];
if (value === undefined || value === null) continue;
const text = extractCcwebMcpAgentText(value);
if (text) return text;
if (typeof value === 'object') {
try { return JSON.stringify(value); } catch {}
}
}
return '';
}
function ccwebMcpChildLabel(state = {}, fallbackThreadId = '') {
const label = state?.label || state?.title || state?.nickname || state?.name || state?.agent || state?.agentType || state?.agent_type || '';
return String(label || fallbackThreadId || '子代理').trim();
}
function ccwebMcpChildSummary(child = {}) {
const candidate = String(child.candidateResult || child.finalMessage || child.lastAssistantMessage || '').replace(/\s+/g, ' ').trim();
return candidate ? truncateTextValue(candidate, 180, '...') : '';
}
function ccwebMcpChildPublicState(child = {}) {
const candidateResult = child.finalMessage || child.candidateResult || '';
return {
threadId: child.threadId || '',
label: child.label || child.threadId || '子代理',
role: child.role || '',
status: child.status || 'running',
detail: ccwebMcpChildSummary(child),
candidateResult,
finalMessage: child.finalMessage || '',
spawnToolId: child.spawnToolId || '',
parentThreadId: child.parentThreadId || '',
createdAt: child.createdAt || null,
updatedAt: child.updatedAt || null,
returnedAt: child.returnedAt || null,
closedAt: child.closedAt || null,
};
}
function mergeCcwebMcpChildIntoTool(tool, child) {
if (!tool) return null;
const candidateResult = child.finalMessage || child.candidateResult || '';
const input = parseMaybeJsonObject(tool.input) || (tool.input && typeof tool.input === 'object' ? tool.input : {});
const result = parseMaybeJsonObject(tool.result) || (tool.result && typeof tool.result === 'object' ? tool.result : {});
const receiverThreadIds = Array.from(new Set([
...extractCcwebMcpStringArray(input.receiverThreadIds, input.receiver_thread_ids, input.targets),
...extractCcwebMcpStringArray(result.receiverThreadIds, result.receiver_thread_ids, result.targets),
child.threadId,
].filter(Boolean)));
const agentsStates = {
...(input.agentsStates && typeof input.agentsStates === 'object' ? input.agentsStates : {}),
...(input.agents_states && typeof input.agents_states === 'object' ? input.agents_states : {}),
...(result.agentsStates && typeof result.agentsStates === 'object' ? result.agentsStates : {}),
...(result.agents_states && typeof result.agents_states === 'object' ? result.agents_states : {}),
};
agentsStates[child.threadId] = {
...(agentsStates[child.threadId] && typeof agentsStates[child.threadId] === 'object' ? agentsStates[child.threadId] : {}),
name: child.label || agentsStates[child.threadId]?.name || child.threadId,
role: child.role || agentsStates[child.threadId]?.role || '',
status: child.status || 'running',
summary: ccwebMcpChildSummary(child),
candidateResult,
finalMessage: child.finalMessage || '',
closedAt: child.closedAt || null,
returnedAt: child.returnedAt || null,
};
const nextResult = {
...result,
status: child.status || result.status || null,
receiverThreadIds,
agentsStates,
};
tool.kind = tool.kind || 'collab_agent_tool_call';
tool.name = tool.name || 'CollabAgentToolCall';
tool.result = JSON.stringify(nextResult, null, 2);
return {
id: tool.id,
name: tool.name,
kind: tool.kind,
input: tool.input,
result: tool.result,
meta: tool.meta || null,
done: !!tool.done,
};
}
function updateCcwebMcpChildToolState(sessionId, child) {
const entry = activeCodexAppTurns.get(sessionId) || null;
let tool = entry?.toolCalls?.find((item) => item.id === child.spawnToolId) || null;
if (!tool) {
const session = loadSession(sessionId);
const messages = Array.isArray(session?.messages) ? session.messages : [];
for (let i = messages.length - 1; i >= 0 && !tool; i -= 1) {
const list = Array.isArray(messages[i]?.toolCalls) ? messages[i].toolCalls : [];
tool = list.find((item) => item.id === child.spawnToolId) || null;
}
}
return mergeCcwebMcpChildIntoTool(tool, child);
}
function updatePersistedCcwebMcpChildTool(sessionId, child) {
const session = loadSession(sessionId);
if (!session || !Array.isArray(session.messages)) return null;
let targetTool = null;
for (let i = session.messages.length - 1; i >= 0 && !targetTool; i -= 1) {
const list = Array.isArray(session.messages[i]?.toolCalls) ? session.messages[i].toolCalls : [];
targetTool = list.find((item) => item.id === child.spawnToolId) || null;
}
if (!mergeCcwebMcpChildIntoTool(targetTool, child)) return null;
session.updated = new Date().toISOString();
if (!findViewingSessionWs(sessionId)) session.hasUnread = true;
saveSession(session);
return targetTool;
}
function sendCcwebMcpChildAgentUpdate(sessionId, child) {
const activeTool = updateCcwebMcpChildToolState(sessionId, child);
const persistedTool = updatePersistedCcwebMcpChildTool(sessionId, child);
const tool = activeTool || (persistedTool ? {
id: persistedTool.id,
name: persistedTool.name,
kind: persistedTool.kind || 'collab_agent_tool_call',
input: persistedTool.input,
result: persistedTool.result,
meta: persistedTool.meta || null,
done: !!persistedTool.done,
} : null);
const payload = {
type: 'ccweb_mcp_child_agent_update',
sessionId,
toolUseId: child.spawnToolId || '',
child: ccwebMcpChildPublicState(child),
tool,
};
const targetWs = activeCodexAppTurns.get(sessionId)?.ws || findViewingSessionWs(sessionId);
if (targetWs) wsSend(targetWs, payload);
broadcastSessionList();
}
function syncCcwebMcpChildAgentsFromCollabItem(routed, item = {}) {
if (!routed?.sessionId || item?.type !== 'collabAgentToolCall') return;
const toolName = codexAppCollabToolName(item.tool || item.name);
const receiverThreadIds = extractCcwebMcpStringArray(item.receiverThreadIds, item.receiver_thread_ids, item.targets);
if (receiverThreadIds.length === 0) return;
const states = item.agentsStates && typeof item.agentsStates === 'object'
? item.agentsStates
: (item.agents_states && typeof item.agents_states === 'object' ? item.agents_states : {});
for (const threadId of receiverThreadIds) {
const state = states[threadId] && typeof states[threadId] === 'object' ? states[threadId] : {};
const existing = ccwebMcpChildThreads.get(threadId);
const now = new Date().toISOString();
const isSpawn = toolName === 'spawn_agent' || !existing;
const child = existing || {
threadId,
turnId: null,
parentSessionId: routed.sessionId,
parentThreadId: routed.entry?.threadId || item.senderThreadId || item.sender_thread_id || '',
spawnToolId: isSpawn ? item.id : '',
label: ccwebMcpChildLabel(state, threadId),
role: String(state.role || state.agent || state.agentType || state.agent_type || '').trim(),
lastAssistantMessage: '',
candidateResult: '',
finalMessage: '',
status: 'running',
summaryAttempts: 0,
createdAt: now,
updatedAt: now,
};
if (!child.spawnToolId && isSpawn) child.spawnToolId = item.id;
if (!child.spawnToolId && item.id) child.spawnToolId = item.id;
child.parentSessionId = child.parentSessionId || routed.sessionId;
child.parentThreadId = child.parentThreadId || routed.entry?.threadId || item.senderThreadId || item.sender_thread_id || '';
child.label = ccwebMcpChildLabel(state, child.label || threadId);
child.role = String(state.role || state.agent || state.agentType || state.agent_type || child.role || '').trim();
if (child.status !== 'closed') {
const candidate = extractCcwebMcpChildCandidate(state);
if (candidate) {
child.candidateResult = truncateTextValue(candidate, SESSION_MESSAGE_CONTENT_MAX_CHARS);
child.lastAssistantMessage = child.candidateResult;
}
const rawStatus = state.status || state.state || item.status;
const fallback = child.status || 'running';
const nextStatus = ccwebMcpChildStatus(rawStatus, fallback);
child.status = nextStatus === 'returned' && !child.candidateResult && !candidate ? fallback : nextStatus;
if (child.status === 'returned' && !child.returnedAt) child.returnedAt = now;
}
child.updatedAt = now;
ccwebMcpChildThreads.set(threadId, child);
sendCcwebMcpChildAgentUpdate(routed.sessionId, child);
}
}
function processCcwebMcpChildNotification(child, notification) {
const method = notification?.method || '';
const params = notification?.params || {};
const now = new Date().toISOString();
if (params.turnId && !child.turnId) child.turnId = params.turnId;
if (params.turn?.id && !child.turnId) child.turnId = params.turn.id;
if (child.status === 'closed' && method !== 'turn/completed') {
return { changed: false, done: false };
}
if (method === 'turn/started') {
child.status = 'running';
child.updatedAt = now;
return { changed: true, done: false };
}
if (method === 'item/agentMessage/delta') {
const itemId = String(params.itemId || 'agent-message');
if (!child.messageItems) child.messageItems = new Map();
const current = child.messageItems.get(itemId) || '';
const next = truncateTextValue(`${current}${String(params.delta || '')}`, SESSION_MESSAGE_CONTENT_MAX_CHARS);
child.messageItems.set(itemId, next);
child.lastAssistantMessage = next;
child.updatedAt = now;
return { changed: true, done: false };
}
if (method === 'item/completed') {
const item = params.item || {};
if (item.type === 'agentMessage') {
const text = extractCcwebMcpAgentText(item);
if (text) {
if (!child.messageItems) child.messageItems = new Map();
const finalMessage = truncateTextValue(text, SESSION_MESSAGE_CONTENT_MAX_CHARS);
child.messageItems.set(item.id || 'agent-message', finalMessage);
child.lastAssistantMessage = finalMessage;
child.finalMessage = finalMessage;
child.updatedAt = now;
return { changed: true, done: false };
}
}
return { changed: false, done: false };
}
if (method === 'turn/completed') {
if (child.status !== 'closed') {
const status = params.turn?.status || params.status || '';
child.status = ccwebMcpChildStatus(status, status && /fail|error/i.test(status) ? 'failed' : 'returned');
child.finalMessage = child.finalMessage || child.lastAssistantMessage || '';
child.candidateResult = child.finalMessage || child.candidateResult || '';
child.returnedAt = child.returnedAt || now;
}
child.updatedAt = now;
return { changed: true, done: true };
}
return { changed: false, done: false };
}
function findCodexAppRouteByRuntime(params = {}) {
const parent = findCodexAppEntryByRuntime(params);
if (parent) return { ...parent, role: 'parent' };
const threadId = params.threadId || params.thread?.id || null;
if (threadId && ccwebMcpChildThreads.has(threadId)) {
const child = ccwebMcpChildThreads.get(threadId);
return {
role: 'child',
sessionId: child.parentSessionId,
entry: activeCodexAppTurns.get(child.parentSessionId) || null,
child,
};
}
return null;
}
function handleCodexAppNotification(notification) {
const routed = findCodexAppRouteByRuntime(notification?.params || {});
if (!routed) {
plog('INFO', 'codex_app_notification_unrouted', {
method: notification?.method || '',
threadId: notification?.params?.threadId || notification?.params?.thread?.id || null,
turnId: notification?.params?.turnId || notification?.params?.turn?.id || null,
});
return;
}
if (routed.role === 'child') {
const result = processCcwebMcpChildNotification(routed.child, notification);
if (result.changed) sendCcwebMcpChildAgentUpdate(routed.sessionId, routed.child);
return;
}
const result = codexAppRuntime.processCodexAppNotification(routed.entry, notification, routed.sessionId);
const item = notification?.params?.item || null;
if (item?.type === 'collabAgentToolCall') {
syncCcwebMcpChildAgentsFromCollabItem(routed, item);
}
persistCodexAppTurnState(routed.sessionId, routed.entry, { immediate: !!result?.done });
if (result?.done) {
handleCodexAppTurnComplete(routed.sessionId);
}
}
function codexAppCommunicationDynamicTools() {
return [
{
name: 'ccweb_list_conversations',
namespace: 'ccweb',
description: '列出 cc-web 中可发送消息的对话。返回当前来源对话 ID、目标对话 ID、标题、Agent、状态和工作目录摘要。',
inputSchema: {
type: 'object',
properties: {
agent: {
type: 'string',
enum: ['claude', 'codex', 'codexapp'],
description: '可选。只返回指定 Agent 类型的对话。',
},
status: {
type: 'string',
enum: ['all', 'running', 'idle'],
description: '可选。按运行状态过滤,默认 all。',
},
limit: {
type: 'integer',
minimum: 1,
maximum: 100,
description: '可选。返回数量上限,默认 50。',
},
},
additionalProperties: false,
},
},
{
name: 'ccweb_send_message',
namespace: 'ccweb',
description: '向另一个 cc-web 对话发送消息。目标对话必须处于空闲状态;不能发送给当前对话。',
inputSchema: {
type: 'object',
required: ['targetConversationId', 'content'],
properties: {
targetConversationId: {
type: 'string',
description: '目标对话 ID。',
},
content: {
type: 'string',
description: '要发送给目标对话的消息内容。',
},
},
additionalProperties: false,
},
},
{
name: 'ccweb_create_conversation',
namespace: 'ccweb',
description: '创建新的 cc-web 持久对话。只用于需要长期追踪、后续继续对话或跨项目工作区管理的场景;一次性并行研究应使用子代能力。',
inputSchema: {
type: 'object',
properties: {
agent: {
type: 'string',
enum: ['claude', 'codex', 'codexapp'],
description: '可选。新对话 Agent默认继承来源对话。',
},
cwd: {
type: 'string',
description: '可选。新对话工作目录;指定时必须是已存在的绝对路径,默认继承来源对话 cwd。',
},
title: {
type: 'string',
maxLength: MCP_CONVERSATION_TITLE_MAX_CHARS,
description: '可选。新对话标题。',
},
mode: {
type: 'string',
enum: ['default', 'plan', 'yolo'],
description: '可选。权限模式,默认继承来源对话。',
},
initialMessage: {
type: 'string',
description: '可选。创建后立即发送到新对话的首条消息。',
},
requestReply: {
type: 'boolean',
description: '可选。若为 true新对话完成本轮输出后会把回复作为已处理的只读消息写回来源对话不会再次触发来源对话运行。',
},
},
additionalProperties: false,
},
},
{
name: 'ccweb_request_reply',
namespace: 'ccweb',
description: '向另一个 cc-web 对话发送消息,并在目标对话完成本轮输出后把回复作为已处理的只读消息写回当前对话;写回不会再次触发当前对话运行。',
inputSchema: {
type: 'object',
required: ['targetConversationId', 'content'],
properties: {
targetConversationId: {
type: 'string',
description: '目标对话 ID。',
},
content: {
type: 'string',
description: '要发送给目标对话的消息内容。',
},
},
additionalProperties: false,
},
},
];
}
function codexAppDynamicToolResponse(result) {
const payload = result && typeof result === 'object' ? result : { ok: true, result };
return {
success: payload.ok !== false,
contentItems: [{
type: 'inputText',
text: JSON.stringify(payload, null, 2),
}],
};
}
function handleCodexAppDynamicToolCall(routed, params = {}) {
const tool = String(params.tool || '');
const namespace = String(params.namespace || '');
if (namespace && namespace !== 'ccweb') return null;
if (
tool !== 'ccweb_list_conversations' &&
tool !== 'ccweb_create_conversation' &&
tool !== 'ccweb_send_message' &&
tool !== 'ccweb_request_reply'
) return null;
const sourceSessionId = routed?.sessionId || '';
const sourceHopCount = Number.parseInt(String(routed?.entry?.mcpContext?.hopCount || 0), 10) || 0;
const result = callInternalMcpTool(tool, params.arguments || {}, sourceSessionId, sourceHopCount);
return codexAppDynamicToolResponse(result);
}
function normalizeCodexAppUserInputAnswers(rawAnswers = {}) {
const answers = {};
for (const [id, value] of Object.entries(rawAnswers || {})) {
if (!id) continue;
if (Array.isArray(value)) {
answers[id] = { answers: value.map((item) => String(item || '')).filter(Boolean) };
continue;
}
if (value && typeof value === 'object' && Array.isArray(value.answers)) {
answers[id] = { answers: value.answers.map((item) => String(item || '')).filter(Boolean) };
continue;
}
const text = String(value || '').trim();
answers[id] = { answers: text ? [text] : [] };
}
return { answers };
}
function previewInlineText(text, maxLength = 36) {
const normalized = String(text || '').replace(/\s+/g, ' ').trim();
if (!normalized) return '空内容';
return normalized.length > maxLength ? `${normalized.slice(0, maxLength - 1)}...` : normalized;
}
function requestCodexAppUserInput(routed, params = {}) {
if (!routed?.entry?.ws) {
return Promise.resolve({ answers: {} });
}
const requestId = crypto.randomUUID();
return new Promise((resolve) => {
const timer = setTimeout(() => {
pendingCodexAppUserInputs.delete(requestId);
resolve({ answers: {} });
}, 10 * 60 * 1000);
pendingCodexAppUserInputs.set(requestId, {
sessionId: routed.sessionId,
itemId: params.itemId || '',
resolve,
timer,
});
wsSend(routed.entry.ws, {
type: 'codex_app_user_input_request',
sessionId: routed.sessionId,
requestId,
itemId: params.itemId || '',
questions: Array.isArray(params.questions) ? params.questions : [],
});
});
}
function handleCodexAppUserInputResponse(ws, msg = {}) {
const requestId = String(msg.requestId || '').trim();
const pending = pendingCodexAppUserInputs.get(requestId);
if (!pending) {
wsSend(ws, { type: 'error', code: 'codexapp_user_input_not_found', message: 'Codex App 引导输入请求不存在或已超时。' });
return;
}
pendingCodexAppUserInputs.delete(requestId);
clearTimeout(pending.timer);
const action = String(msg.action || 'submit').trim();
const isCancel = action === 'cancel';
if (!isCancel) {
wsSend(ws, {
type: 'system_message',
sessionId: pending.sessionId,
message: '已提交 Codex App 引导输入。',
});
}
pending.resolve(isCancel ? { answers: {} } : normalizeCodexAppUserInputAnswers(msg.answers || {}));
}
function resolvePendingCodexAppUserInputsForSession(sessionId) {
for (const [requestId, pending] of pendingCodexAppUserInputs) {
if (pending.sessionId !== sessionId) continue;
pendingCodexAppUserInputs.delete(requestId);
clearTimeout(pending.timer);
pending.resolve({ answers: {} });
}
}
function handleCodexAppServerRequest(request) {
const method = request?.method || '';
const params = request?.params || {};
const routed = findCodexAppEntryByRuntime(params);
const dynamicToolResponse = method === 'item/tool/call'
? handleCodexAppDynamicToolCall(routed, params)
: null;
if (!dynamicToolResponse && method !== 'item/tool/requestUserInput' && routed?.entry?.ws) {
wsSend(routed.entry.ws, {
type: 'system_message',
sessionId: routed.sessionId,
message: `Codex App 请求客户端处理 ${method},当前 cc-web 暂未接入交互式审批,已按保守策略拒绝。`,
});
}
switch (method) {
case 'item/commandExecution/requestApproval':
return { decision: 'cancel' };
case 'item/fileChange/requestApproval':
return { decision: 'cancel' };
case 'item/permissions/requestApproval':
return { permissions: {}, scope: 'turn' };
case 'item/tool/call':
if (dynamicToolResponse) return dynamicToolResponse;
return {
success: false,
contentItems: [{ type: 'inputText', text: 'cc-web 暂不支持执行 Codex App 动态客户端工具。' }],
};
case 'item/tool/requestUserInput':
return requestCodexAppUserInput(routed, params);
default:
throw new Error(`cc-web 暂不支持 Codex app-server 请求: ${method}`);
}
}
function handleCodexAppServerExit(signature, info = {}) {
if (signature && signature === codexAppClientSignature) {
codexAppClient = null;
codexAppClientSignature = '';
}
const stderr = String(info.stderr || '').trim();
const message = stderr || `Codex app-server 已退出: code=${info.code ?? 'null'} signal=${info.signal || 'null'}`;
const affected = Array.from(activeCodexAppTurns.keys());
plog('WARN', 'codex_app_server_exit', {
code: info.code ?? null,
signal: info.signal || null,
activeTurns: affected.length,
stderr: stderr.slice(-500) || null,
});
for (const sessionId of affected) {
handleCodexAppTurnFailure(sessionId, new Error(message));
}
}
function codexAppModelSettings(session) {
const raw = String(session?.model || getDefaultCodexModel() || '').trim();
const match = raw.match(/^(.*)\((low|medium|high|xhigh)\)\s*$/i);
if (!match) return { model: raw || null, effort: null };
return {
model: String(match[1] || '').trim() || null,
effort: String(match[2] || '').trim().toLowerCase(),
};
}
function codexAppPermissionParams(session) {
const mode = session?.permissionMode || 'yolo';
if (mode === 'plan') {
return { approvalPolicy: 'never', sandbox: 'read-only' };
}
if (mode === 'default') {
return { approvalPolicy: 'never', sandbox: 'workspace-write' };
}
return { approvalPolicy: 'never', sandbox: 'danger-full-access' };
}
function codexAppTurnPermissionParams(session) {
const mode = session?.permissionMode || 'yolo';
if (mode === 'plan') {
return { approvalPolicy: 'never', sandboxPolicy: { type: 'readOnly', networkAccess: false } };
}
if (mode === 'default') {
return {
approvalPolicy: 'never',
sandboxPolicy: {
type: 'workspaceWrite',
writableRoots: [],
networkAccess: false,
excludeTmpdirEnvVar: false,
excludeSlashTmp: false,
},
};
}
return { approvalPolicy: 'never', sandboxPolicy: { type: 'dangerFullAccess' } };
}
function codexAppCcwebMcpEnv(session, options = {}) {
if (!session?.id || !INTERNAL_MCP_TOKEN || !CCWEB_MCP_SERVER_PATH) return null;
const rawHopCount = Number.parseInt(String(options.mcpContext?.hopCount || 0), 10);
const hopCount = Number.isFinite(rawHopCount) ? Math.max(0, rawHopCount) : 0;
return {
CC_WEB_MCP_URL: `http://127.0.0.1:${PORT}/api/internal/mcp`,
CC_WEB_MCP_TOKEN: INTERNAL_MCP_TOKEN,
CC_WEB_SOURCE_SESSION_ID: session.id,
CC_WEB_CROSS_HOP_COUNT: String(hopCount),
};
}
function codexAppThreadConfig(session, options = {}) {
const ccwebMcpEnv = codexAppCcwebMcpEnv(session, options);
if (!ccwebMcpEnv) return {};
return {
'mcp_servers.ccweb': {
command: process.execPath,
args: [CCWEB_MCP_SERVER_PATH],
env: ccwebMcpEnv,
startup_timeout_sec: 10,
tool_timeout_sec: 60,
},
};
}
function codexAppCollaborationMode(session, modelSettings) {
const mode = (session?.permissionMode || 'yolo') === 'plan' ? 'plan' : 'default';
const settings = {
model: modelSettings.model || FALLBACK_CODEX_MODEL,
reasoning_effort: modelSettings.effort || null,
developer_instructions: CODEX_APP_COLLABORATION_INSTRUCTIONS,
};
return { mode, settings };
}
async function codexAppPostInitialize({ request, onLog } = {}) {
if (typeof request !== 'function') return;
try {
await request('experimentalFeature/enablement/set', { enablement: { goals: true } }, 30000);
if (typeof onLog === 'function') onLog('INFO', 'codex_app_goals_feature_enabled', {});
} catch (err) {
if (typeof onLog === 'function') {
onLog('INFO', 'codex_app_goals_feature_enable_failed', { error: err?.message || String(err || '') });
}
}
try {
const result = await request('collaborationMode/list', {}, 30000);
if (typeof onLog === 'function') onLog('INFO', 'codex_app_collaboration_modes', { result });
} catch (err) {
if (typeof onLog === 'function') {
onLog('INFO', 'codex_app_collaboration_mode_list_failed', { error: err?.message || String(err || '') });
}
}
}
function buildCodexAppClientSpec() {
const codexConfig = loadCodexConfig();
const runtimeConfig = prepareCodexCustomRuntime(codexConfig);
if (runtimeConfig?.error) return { error: runtimeConfig.error };
const env = { ...process.env };
delete env.CC_WEB_PASSWORD;
delete env.CLAUDECODE;
delete env.CLAUDE_CODE;
if (runtimeConfig?.mode === 'custom') {
env.CODEX_HOME = runtimeConfig.homeDir;
env.OPENAI_API_KEY = runtimeConfig.apiKey;
delete env.OPENAI_BASE_URL;
}
const args = ['app-server', '--stdio'];
const signature = JSON.stringify({
command: CODEX_PATH,
args,
codexConfig,
runtimeMode: runtimeConfig?.mode || 'local',
codeHome: env.CODEX_HOME || '',
apiKeyHash: runtimeConfig?.apiKey ? crypto.createHash('sha256').update(runtimeConfig.apiKey).digest('hex') : '',
worker: CODEX_APP_WORKER_ENABLED,
});
return {
command: CODEX_PATH,
args,
env,
cwd: process.env.HOME || process.env.USERPROFILE || process.cwd(),
signature,
};
}
function getCodexAppClient() {
const spec = buildCodexAppClientSpec();
if (spec?.error) return { error: spec.error };
if (codexAppClient && codexAppClientSignature !== spec.signature) {
if (activeCodexAppTurns.size > 0) {
return { error: 'Codex App 配置已变更,但仍有运行中的任务。请等待任务结束后再发送新消息。' };
}
codexAppClient.stop();
codexAppClient = null;
codexAppClientSignature = '';
}
if (codexAppClient && !codexAppClient.isRunning()) {
try { codexAppClient.stop(); } catch {}
codexAppClient = null;
codexAppClientSignature = '';
}
if (!codexAppClient || !codexAppClient.isRunning()) {
const signature = spec.signature;
const clientOptions = {
command: spec.command,
args: spec.args,
env: spec.env,
cwd: spec.cwd,
onNotification: handleCodexAppNotification,
onServerRequest: handleCodexAppServerRequest,
onExit: (info) => handleCodexAppServerExit(signature, info),
onLog: (level, event, data) => plog(level, event, data),
postInitialize: codexAppPostInitialize,
};
codexAppClient = CODEX_APP_WORKER_ENABLED
? createCodexAppWorkerClient(clientOptions)
: createCodexAppServerClient(clientOptions);
codexAppClientSignature = signature;
plog('INFO', 'codex_app_client_created', {
worker: CODEX_APP_WORKER_ENABLED,
command: path.basename(spec.command || ''),
});
}
return { client: codexAppClient };
}
function codexAppInputFromMessage(runtimeTextValue, attachments = []) {
const input = [];
const text = String(runtimeTextValue || '').trim();
if (text) input.push({ type: 'text', text });
for (const attachment of attachments) {
if (attachment?.path) input.push({ type: 'localImage', path: attachment.path, detail: 'auto' });
}
return input;
}
function codexAppThreadParams(session, options = {}) {
const modelSettings = codexAppModelSettings(session);
const config = codexAppThreadConfig(session, options);
const params = {
...codexAppPermissionParams(session),
cwd: session.cwd || getDefaultSessionCwd(),
model: modelSettings.model,
threadSource: 'user',
};
if (Object.keys(config).length > 0) params.config = config;
return params;
}
function codexAppTurnParams(session, input, threadId, clientUserMessageId = null) {
const modelSettings = codexAppModelSettings(session);
const collaborationMode = codexAppCollaborationMode(session, modelSettings);
const params = {
...codexAppTurnPermissionParams(session),
threadId,
input,
cwd: session.cwd || getDefaultSessionCwd(),
clientUserMessageId,
collaborationMode,
};
if (!collaborationMode) {
params.model = modelSettings.model;
if (modelSettings.effort) params.effort = modelSettings.effort;
}
return params;
}
function handleCodexAppMessage(ws, session, runtimeTextValue, resolvedAttachments, options = {}) {
const input = codexAppInputFromMessage(runtimeTextValue, resolvedAttachments);
if (input.length === 0) {
wsSend(ws, { type: 'error', code: 'empty_message', message: '消息内容不能为空。' });
return { ok: false, code: 'empty_message', message: '消息内容不能为空。' };
}
const entry = {
ws,
agent: 'codexapp',
cwd: session.cwd || getDefaultSessionCwd(),
threadId: getRuntimeSessionId(session),
turnId: null,
fullText: '',
toolCalls: [],
toolOutputDeltas: new Map(),
agentMessageItems: new Map(),
mcpContext: options.mcpContext || {},
lastUsage: null,
lastError: null,
errorSent: false,
crossConversationReplyRequestId: options.crossConversation?.replyRequestId || null,
clientUserMessageId: crypto.randomUUID(),
startedAt: new Date().toISOString(),
};
activeCodexAppTurns.set(session.id, entry);
persistCodexAppTurnState(session.id, entry, { immediate: true });
sendSessionList(ws);
startCodexAppTurn(session.id, input).catch((err) => {
handleCodexAppTurnFailure(session.id, err);
});
return { ok: true, sessionId: session.id };
}
async function startCodexAppTurn(sessionId, input) {
const session = loadSession(sessionId);
const entry = activeCodexAppTurns.get(sessionId);
if (!session || !entry) return;
const clientResult = getCodexAppClient();
if (clientResult.error) throw new Error(clientResult.error);
const client = clientResult.client;
await client.start();
let threadId = getRuntimeSessionId(session);
const threadParams = codexAppThreadParams(session, { mcpContext: entry.mcpContext || {} });
if (threadId) {
const resumed = await client.request('thread/resume', { ...threadParams, threadId }, 60000);
threadId = resumed?.thread?.id || threadId;
} else {
const started = await client.request('thread/start', { ...threadParams, sessionStartSource: 'startup' }, 60000);
threadId = started?.thread?.id || null;
}
if (!threadId) throw new Error('Codex app-server 未返回 threadId。');
entry.threadId = threadId;
setRuntimeSessionId(session, threadId);
session.updated = new Date().toISOString();
saveSession(session);
persistCodexAppTurnState(sessionId, entry, { immediate: true });
const turn = await client.request('turn/start', codexAppTurnParams(session, input, threadId, entry.clientUserMessageId), 60000);
if (turn?.turn?.id) entry.turnId = turn.turn.id;
persistCodexAppTurnState(sessionId, entry, { immediate: true });
}
function handleCodexAppTurnComplete(sessionId, options = {}) {
const entry = activeCodexAppTurns.get(sessionId);
if (!entry) return;
resolvePendingCodexAppUserInputsForSession(sessionId);
const explicitError = options.error || null;
const rawError = explicitError || entry.lastError || null;
const completionError = rawError && !entry.userAborted
? formatRuntimeError('codex', rawError, { exitCode: null, signal: null })
: null;
const session = loadSession(sessionId);
const turnKey = codexAppTurnKey(sessionId, entry);
const assistantContent = truncateTextValue(entry.fullText || '', SESSION_MESSAGE_CONTENT_MAX_CHARS);
const assistantToolCalls = sanitizeToolCallsForPersist(entry.toolCalls || [], {
maxToolCalls: SESSION_MAX_TOOL_CALLS_PER_MESSAGE,
toolInputMaxChars: SESSION_TOOL_INPUT_MAX_CHARS,
toolResultMaxChars: SESSION_TOOL_RESULT_MAX_CHARS,
contentMaxChars: SESSION_MESSAGE_CONTENT_MAX_CHARS,
});
if (session && (assistantContent.trim() || assistantToolCalls.length > 0) && !hasCodexAppTurnMessage(session, turnKey)) {
session.messages.push({
role: 'assistant',
content: assistantContent,
toolCalls: assistantToolCalls,
timestamp: new Date().toISOString(),
codexAppTurnKey: turnKey,
codexAppThreadId: entry.threadId || null,
codexAppTurnId: entry.turnId || null,
interrupted: !!options.interrupted,
});
session.updated = new Date().toISOString();
if (!entry.ws) session.hasUnread = true;
saveSession(session);
}
activeCodexAppTurns.delete(sessionId);
cleanupCodexAppTurnState(sessionId, entry);
if (entry.crossConversationReplyRequestId) {
completeCrossConversationReply(entry.crossConversationReplyRequestId, entry, session);
}
flushPendingCrossConversationReplies(sessionId);
plog(completionError ? 'WARN' : 'INFO', 'codex_app_turn_complete', {
sessionId: sessionId.slice(0, 8),
threadId: entry.threadId || null,
turnId: entry.turnId || null,
responseLen: (entry.fullText || '').length,
toolCallCount: (entry.toolCalls || []).length,
error: rawError || null,
});
if (entry.ws) {
if (completionError && !entry.errorSent) {
entry.errorSent = true;
wsSend(entry.ws, { type: 'error', sessionId, message: completionError });
}
wsSend(entry.ws, { type: 'done', sessionId, costUsd: null });
sendSessionList(entry.ws);
return;
}
if (wss && session) {
for (const client of wss.clients) {
if (client.readyState === 1) {
wsSend(client, {
type: 'background_done',
sessionId,
title: session.title || 'Untitled',
costUsd: null,
responseLen: (entry.fullText || '').length,
});
}
}
buildNotifyContent(entry, session, completionError, false).then(({ title, content }) => {
sendNotification(title, content);
});
}
}
function handleCodexAppTurnFailure(sessionId, err) {
const entry = activeCodexAppTurns.get(sessionId);
if (!entry) return;
entry.lastError = err?.message || String(err || 'Codex App 任务失败');
handleCodexAppTurnComplete(sessionId, { error: entry.lastError });
}
function handleCodexAppSteerMessage(ws, msg, options = {}) {
const sessionId = sanitizeId(msg?.sessionId || '');
const entry = activeCodexAppTurns.get(sessionId);
if (!entry) return null;
const clientMessageId = String(msg?.clientMessageId || '').trim();
const sendSteerStatus = (status, message) => {
const targetWs = entry.ws || ws;
if (!targetWs) return;
const payload = {
type: 'codex_app_steer_status',
sessionId,
status,
message,
};
if (clientMessageId) payload.clientMessageId = clientMessageId;
wsSend(targetWs, payload);
};
const fail = (code, message) => {
sendSteerStatus('failed', '插入失败');
wsSend(ws, { type: 'error', code, message });
return { ok: false, code, message };
};
const session = loadSession(sessionId);
if (!session || !isCodexAppSession(session)) {
return fail('session_not_found', 'Codex App 会话不存在。');
}
const textValue = typeof msg.text === 'string' ? msg.text : '';
const normalizedText = textValue.trim();
const attachments = Array.isArray(msg.attachments) ? msg.attachments : [];
if (!normalizedText) return fail('empty_message', '运行中插入内容不能为空。');
if (normalizedText.startsWith('/')) return fail('codexapp_running_slash_unsupported', 'Codex App 运行中暂不支持 slash 指令插入。');
if (attachments.length > 0) return fail('codexapp_running_attachment_unsupported', 'Codex App 运行中插入暂不支持图片附件。');
if (!entry.threadId || !entry.turnId) return fail('codexapp_turn_not_ready', 'Codex App 当前 turn 尚未准备好,请稍后再插入。');
if (!codexAppClient || !codexAppClient.isRunning()) return fail('codexapp_server_not_running', 'Codex app-server 未运行,无法插入。');
const decoratorResolution = typeof options.runtimeText === 'string'
? { runtimeText: options.runtimeText, mentions: [] }
: resolveComposerDecorators(textValue, session, getSessionAgent(session));
const runtimeTextValue = String(decoratorResolution.runtimeText || '').trim();
if (!runtimeTextValue) return fail('empty_message', '运行中插入内容不能为空。');
if (msg.mode && ['default', 'plan', 'yolo'].includes(msg.mode)) {
session.permissionMode = msg.mode;
}
const persistedUserMessage = {
role: 'user',
content: textValue,
attachments: [],
timestamp: new Date().toISOString(),
};
if (decoratorResolution.mentions.length > 0) {
persistedUserMessage.composerMentions = decoratorResolution.mentions;
}
session.messages.push(persistedUserMessage);
session.updated = new Date().toISOString();
saveSession(session);
if (ws) {
detachWsFromActiveRuntimes(ws);
entry.ws = ws;
entry.wsDisconnectTime = null;
wsSessionMap.set(ws, sessionId);
}
if (ws && options.emitUserMessage) {
wsSend(ws, { type: 'session_message', sessionId, message: persistedUserMessage });
}
sendSteerStatus('pending', '引导中...');
const input = codexAppInputFromMessage(runtimeTextValue, []);
const expectedTurnId = entry.turnId;
codexAppClient.request('turn/steer', {
threadId: entry.threadId,
expectedTurnId,
input,
clientUserMessageId: clientMessageId || crypto.randomUUID(),
}, 60000).then(() => {
sendSteerStatus('inserted', '已插入');
wsSend(entry.ws || ws, {
type: 'system_message',
sessionId,
tone: 'info',
transient: true,
autoDismissMs: 5000,
message: `已引导对话: ${previewInlineText(textValue)}`,
});
}).catch((err) => {
sendSteerStatus('failed', '插入失败');
wsSend(entry.ws || ws, {
type: 'error',
sessionId,
code: 'codexapp_steer_failed',
transient: true,
autoDismissMs: 7000,
message: formatRuntimeError('codex', err?.message || err, { exitCode: null, signal: null }),
});
});
sendSessionList(ws);
return { ok: true, sessionId };
}
function handleCodexAppAbortSession(sessionId, ws = null) {
const entry = activeCodexAppTurns.get(sessionId);
if (!entry) return false;
if (ws) entry.ws = ws;
entry.userAborted = true;
plog('INFO', 'codex_app_user_abort', {
sessionId: sessionId.slice(0, 8),
threadId: entry.threadId || null,
turnId: entry.turnId || null,
});
if (!entry.threadId || !entry.turnId || !codexAppClient || !codexAppClient.isRunning()) {
handleCodexAppTurnComplete(sessionId, { interrupted: true });
return true;
}
codexAppClient.request('turn/interrupt', {
threadId: entry.threadId,
turnId: entry.turnId,
}, 30000).then(() => {
setTimeout(() => {
if (activeCodexAppTurns.has(sessionId)) {
handleCodexAppTurnComplete(sessionId, { interrupted: true });
}
}, 2000);
}).catch((err) => {
handleCodexAppTurnFailure(sessionId, err);
});
return true;
}
// === Check Update ===
function handleCheckUpdate(ws) {
const localVersion = (() => {
try {
const cl = fs.readFileSync(path.join(__dirname, 'CHANGELOG.md'), 'utf8');
const m = cl.match(/##\s*v([\d.]+)/) || cl.match(/\*\*v([\d.]+)\*\*/);
if (m) return m[1];
} catch {}
try { return JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8')).version || 'unknown'; } catch {}
return 'unknown';
})();
const https = require('https');
const options = {
hostname: 'raw.githubusercontent.com',
path: '/ZgDaniel/cc-web/main/CHANGELOG.md',
headers: { 'User-Agent': 'cc-web-update-check' },
timeout: 10000,
};
const req = https.request(options, (res) => {
let body = '';
res.on('data', c => body += c);
res.on('end', () => {
if (res.statusCode !== 200) {
return wsSend(ws, { type: 'update_info', localVersion, error: `HTTP ${res.statusCode}` });
}
const m = body.match(/##\s*v([\d.]+)/) || body.match(/\*\*v([\d.]+)\*\*/);
const latest = m ? m[1] : null;
if (!latest) {
return wsSend(ws, { type: 'update_info', localVersion, error: '无法解析远端版本号' });
}
const hasUpdate = latest !== localVersion;
wsSend(ws, {
type: 'update_info',
localVersion,
latestVersion: latest,
hasUpdate,
releaseUrl: 'https://github.com/ZgDaniel/cc-web',
});
});
});
req.on('error', (e) => {
wsSend(ws, { type: 'update_info', localVersion, error: '网络请求失败: ' + e.message });
});
req.on('timeout', () => {
req.destroy();
wsSend(ws, { type: 'update_info', localVersion, error: '请求超时' });
});
req.end();
}
// === Native Session Import ===
const CLAUDE_PROJECTS_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '', '.claude', 'projects');
const CODEX_SESSIONS_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '', '.codex', 'sessions');
const CODEX_STATE_DB_PATH = path.join(process.env.HOME || process.env.USERPROFILE || '', '.codex', 'state_5.sqlite');
const CODEX_LOG_DB_PATH = path.join(process.env.HOME || process.env.USERPROFILE || '', '.codex', 'logs_1.sqlite');
function resolveClaudeSessionLocalMeta(claudeSessionId) {
if (!claudeSessionId) return null;
try {
const dirs = fs.readdirSync(CLAUDE_PROJECTS_DIR).filter((dir) => {
try { return fs.statSync(path.join(CLAUDE_PROJECTS_DIR, dir)).isDirectory(); } catch { return false; }
});
for (const dir of dirs) {
const filePath = path.join(CLAUDE_PROJECTS_DIR, dir, `${sanitizeId(claudeSessionId)}.jsonl`);
if (!fs.existsSync(filePath)) continue;
try {
const content = fs.readFileSync(filePath, 'utf8');
const lines = content.split('\n');
let cwd = null;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const entry = JSON.parse(trimmed);
if (entry.type === 'user' && entry.cwd) {
cwd = entry.cwd;
break;
}
} catch {}
}
return { cwd, projectDir: dir, filePath };
} catch {}
}
} catch {}
return null;
}
function parseJsonlToMessages(lines) {
const messages = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
let entry;
try { entry = JSON.parse(trimmed); } catch { continue; }
if (entry.type === 'user') {
const raw = entry.message?.content;
let content = '';
if (typeof raw === 'string') {
content = raw;
} else if (Array.isArray(raw)) {
// skip tool_result blocks, only take text blocks
content = raw
.filter(b => b.type === 'text')
.map(b => b.text || '')
.join('');
}
if (content.trim()) {
messages.push({ role: 'user', content, timestamp: entry.timestamp || null });
}
} else if (entry.type === 'assistant') {
const blocks = entry.message?.content;
if (!Array.isArray(blocks)) continue;
let content = '';
const toolCalls = [];
for (const b of blocks) {
if (b.type === 'text' && b.text) {
content += b.text;
} else if (b.type === 'tool_use') {
toolCalls.push({ name: b.name, id: b.id, input: b.input, done: true });
}
// skip thinking blocks
}
if (content.trim() || toolCalls.length > 0) {
messages.push({ role: 'assistant', content, toolCalls, timestamp: entry.timestamp || null });
}
}
// skip other types
}
return messages;
}
const {
parseCodexRolloutLines,
getCodexRolloutFiles,
getImportedCodexThreadIds,
parseCodexRolloutFile,
} = createCodexRolloutStore({
codexSessionsDir: CODEX_SESSIONS_DIR,
sessionsDir: SESSIONS_DIR,
normalizeSession,
sanitizeToolInput,
});
function getImportedSessionIds() {
const imported = new Set();
try {
for (const f of fs.readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.json'))) {
try {
const s = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, f), 'utf8'));
if (s.claudeSessionId) imported.add(s.claudeSessionId);
} catch {}
}
} catch {}
return imported;
}
function handleListNativeSessions(ws) {
const groups = [];
try {
const imported = getImportedSessionIds();
const dirs = fs.readdirSync(CLAUDE_PROJECTS_DIR).filter(d => {
try { return fs.statSync(path.join(CLAUDE_PROJECTS_DIR, d)).isDirectory(); } catch { return false; }
});
for (const dir of dirs) {
const dirPath = path.join(CLAUDE_PROJECTS_DIR, dir);
const sessionItems = [];
try {
const files = fs.readdirSync(dirPath).filter(f => f.endsWith('.jsonl'));
for (const f of files) {
const sessionId = f.replace('.jsonl', '');
const filePath = path.join(dirPath, f);
try {
const content = fs.readFileSync(filePath, 'utf8');
const lines = content.split('\n');
// Find first user message for title
let title = sessionId.slice(0, 20);
let cwd = null;
let updatedAt = null;
let lastTs = null;
for (const line of lines) {
const t = line.trim();
if (!t) continue;
try {
const e = JSON.parse(t);
if (e.timestamp) lastTs = e.timestamp;
if (e.type === 'user' && !cwd) {
cwd = e.cwd || null;
const raw = e.message?.content;
let text = '';
if (typeof raw === 'string') text = raw;
else if (Array.isArray(raw)) text = raw.filter(b => b.type === 'text').map(b => b.text || '').join('');
if (text.trim()) title = text.trim().slice(0, 80).replace(/\n/g, ' ');
}
} catch {}
}
updatedAt = lastTs;
sessionItems.push({ sessionId, title, cwd, updatedAt, alreadyImported: imported.has(sessionId) });
} catch {}
}
} catch {}
if (sessionItems.length > 0) {
sessionItems.sort((a, b) => {
if (!a.updatedAt) return 1;
if (!b.updatedAt) return -1;
return new Date(b.updatedAt) - new Date(a.updatedAt);
});
groups.push({ dir, sessions: sessionItems });
}
}
} catch {}
wsSend(ws, { type: 'native_sessions', groups });
}
function handleImportNativeSession(ws, msg) {
const { sessionId, projectDir } = msg;
if (!sessionId || !projectDir) {
return wsSend(ws, { type: 'error', message: '缺少 sessionId 或 projectDir' });
}
const filePath = path.join(CLAUDE_PROJECTS_DIR, String(projectDir), `${sanitizeId(sessionId)}.jsonl`);
if (!filePath.startsWith(CLAUDE_PROJECTS_DIR)) {
return wsSend(ws, { type: 'error', message: '非法路径' });
}
let content;
try { content = fs.readFileSync(filePath, 'utf8'); } catch {
return wsSend(ws, { type: 'error', message: '无法读取会话文件' });
}
const lines = content.split('\n');
const messages = parseJsonlToMessages(lines);
// Find or create cc-web session with this claudeSessionId
let existingSession = null;
try {
for (const f of fs.readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.json'))) {
try {
const s = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, f), 'utf8'));
if (s.claudeSessionId === sessionId) { existingSession = s; break; }
} catch {}
}
} catch {}
// Determine title and cwd from messages/raw
let title = sessionId.slice(0, 20);
let cwd = null;
for (const line of lines) {
const t = line.trim();
if (!t) continue;
try {
const e = JSON.parse(t);
if (e.type === 'user') {
if (!cwd) cwd = e.cwd || null;
const raw = e.message?.content;
let text = '';
if (typeof raw === 'string') text = raw;
else if (Array.isArray(raw)) text = raw.filter(b => b.type === 'text').map(b => b.text || '').join('');
if (text.trim()) { title = text.trim().slice(0, 60).replace(/\n/g, ' '); break; }
}
} catch {}
}
const id = existingSession ? existingSession.id : crypto.randomUUID();
const session = {
id,
title,
created: existingSession?.created || new Date().toISOString(),
updated: new Date().toISOString(),
pinnedAt: existingSession?.pinnedAt || null,
agent: 'claude',
claudeSessionId: sessionId,
codexThreadId: null,
importedFrom: projectDir,
model: existingSession?.model || null,
permissionMode: existingSession?.permissionMode || 'yolo',
totalCost: existingSession?.totalCost || 0,
totalUsage: existingSession?.totalUsage || { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 },
messages,
cwd: cwd || existingSession?.cwd || null,
};
saveSession(session);
wsSessionMap.set(ws, id);
wsSend(ws, {
type: 'session_info',
sessionId: id,
messages: session.messages,
title: session.title,
pinnedAt: session.pinnedAt || null,
mode: session.permissionMode,
model: sessionModelLabel(session),
agent: getSessionAgent(session),
cwd: session.cwd,
totalCost: session.totalCost || 0,
totalUsage: session.totalUsage || null,
updated: session.updated,
hasUnread: false,
historyPending: false,
isRunning: false,
});
sendSessionList(ws);
}
function handleListCodexSessions(ws) {
const imported = getImportedCodexThreadIds();
const items = [];
const seen = new Set();
for (const filePath of getCodexRolloutFiles()) {
const parsed = parseCodexRolloutFile(filePath);
if (!parsed?.meta?.threadId) continue;
if (seen.has(parsed.meta.threadId)) continue;
seen.add(parsed.meta.threadId);
const title = parsed.meta.title || parsed.meta.threadId.slice(0, 20);
items.push({
threadId: parsed.meta.threadId,
title,
cwd: parsed.meta.cwd || null,
updatedAt: parsed.meta.updatedAt || null,
cliVersion: parsed.meta.cliVersion || '',
source: parsed.meta.source || '',
rolloutPath: filePath,
alreadyImported: imported.has(parsed.meta.threadId),
});
}
wsSend(ws, { type: 'codex_sessions', sessions: items });
}
function handleImportCodexSession(ws, msg) {
const threadId = String(msg?.threadId || '').trim();
if (!threadId) {
return wsSend(ws, { type: 'error', message: '缺少 threadId' });
}
let parsed = null;
const requestedPath = msg?.rolloutPath ? path.resolve(String(msg.rolloutPath)) : '';
if (requestedPath && requestedPath.startsWith(CODEX_SESSIONS_DIR) && fs.existsSync(requestedPath)) {
parsed = parseCodexRolloutFile(requestedPath);
}
if (!parsed) {
for (const filePath of getCodexRolloutFiles()) {
const candidate = parseCodexRolloutFile(filePath);
if (candidate?.meta?.threadId === threadId) {
parsed = candidate;
break;
}
}
}
if (!parsed || parsed.meta.threadId !== threadId) {
return wsSend(ws, { type: 'error', message: '未找到对应的 Codex 会话文件' });
}
let existingSession = null;
try {
for (const f of fs.readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.json'))) {
try {
const s = normalizeSession(JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, f), 'utf8')));
if (s.codexThreadId === threadId) { existingSession = s; break; }
} catch {}
}
} catch {}
const id = existingSession ? existingSession.id : crypto.randomUUID();
const session = {
id,
title: parsed.meta.title || existingSession?.title || threadId.slice(0, 20),
created: existingSession?.created || new Date().toISOString(),
updated: new Date().toISOString(),
pinnedAt: existingSession?.pinnedAt || null,
agent: 'codex',
claudeSessionId: null,
codexThreadId: threadId,
importedFrom: 'codex',
importedRolloutPath: parsed.filePath,
model: existingSession?.model || null,
permissionMode: existingSession?.permissionMode || 'yolo',
totalCost: existingSession?.totalCost || 0,
totalUsage: parsed.totalUsage || existingSession?.totalUsage || { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 },
messages: parsed.messages,
cwd: parsed.meta.cwd || existingSession?.cwd || null,
};
saveSession(session);
wsSessionMap.set(ws, id);
wsSend(ws, {
type: 'session_info',
sessionId: id,
messages: session.messages,
title: session.title,
pinnedAt: session.pinnedAt || null,
mode: session.permissionMode,
model: sessionModelLabel(session),
agent: getSessionAgent(session),
cwd: session.cwd,
totalCost: session.totalCost || 0,
totalUsage: session.totalUsage || null,
updated: session.updated,
hasUnread: false,
historyPending: false,
isRunning: false,
});
sendSessionList(ws);
}
function handleListCwdSuggestions(ws) {
const defaultPath = getDefaultSessionCwd();
const paths = collectRecentSessionCwds(12).filter((candidate) => candidate !== defaultPath);
wsSend(ws, { type: 'cwd_suggestions', defaultPath, paths });
}
// === Startup ===
recoverProcesses();
// Periodic heartbeat: log active processes status every 60s
setInterval(() => {
if (activeProcesses.size === 0) return;
const procs = [];
for (const [sid, entry] of activeProcesses) {
const alive = isProcessRunning(entry.pid);
procs.push({
sessionId: sid.slice(0, 8),
pid: entry.pid,
alive,
wsConnected: !!entry.ws,
wsDisconnectTime: entry.wsDisconnectTime || null,
responseLen: (entry.fullText || '').length,
});
}
plog('INFO', 'heartbeat', { activeCount: procs.length, wsClients: wss.clients.size, processes: procs });
}, 60000);
plog('INFO', 'server_start', { port: PORT });
server.listen(PORT, '0.0.0.0', () => {
console.log(`CC-Web server listening on 0.0.0.0:${PORT}`);
});