const http = require('http'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const os = require('os'); 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'); const CCWEB_MCP_SERVER_INFO = { name: 'ccweb', version: '1.0.0' }; if (process.argv.includes('--ccweb-mcp-server')) { require('./lib/ccweb-mcp-server').runStdioServer(); return; } if (process.argv.includes('--codex-app-worker')) { require('./lib/codex-app-worker'); return; } function resolveAppDir() { const explicit = String(process.env.CC_WEB_APP_DIR || '').trim(); if (explicit) return path.resolve(explicit); const execDir = process.execPath ? path.dirname(process.execPath) : ''; if (process.versions?.bun && execDir && fs.existsSync(path.join(execDir, 'public'))) { return execDir; } return __dirname; } const APP_DIR = resolveAppDir(); const IS_BUN_SINGLE_EXECUTABLE = !!process.versions?.bun && process.execPath && !/^bun(?:\.exe)?$/i.test(path.basename(process.execPath)); const CCWEB_MCP_SERVER_ARG = '--ccweb-mcp-server'; const CODEX_APP_WORKER_ARG = '--codex-app-worker'; function ccwebMcpServerCommandSpec() { if (IS_BUN_SINGLE_EXECUTABLE) { return { command: process.execPath, args: [CCWEB_MCP_SERVER_ARG] }; } return { command: process.execPath, args: [path.join(APP_DIR, 'server.js'), CCWEB_MCP_SERVER_ARG], }; } // Load .env const envPath = path.join(APP_DIR, '.env'); if (fs.existsSync(envPath)) { 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)); } function normalizeCodexAppCcwebMcpTransport(value) { const raw = String(value || '').trim().toLowerCase(); if (raw === 'stdio') return 'stdio'; return 'streamable_http'; } 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(APP_DIR, 'config'); const SESSIONS_DIR = process.env.CC_WEB_SESSIONS_DIR || path.join(APP_DIR, 'sessions'); const PUBLIC_DIR = process.env.CC_WEB_PUBLIC_DIR || path.join(APP_DIR, 'public'); const LOGS_DIR = process.env.CC_WEB_LOGS_DIR || path.join(APP_DIR, 'logs'); 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 COMPOSER_SKILL_METADATA_MAX_BYTES = 64 * 1024; const COMPOSER_SKILL_DEFAULT_PROMPT_PREVIEW_CHARS = 180; const COMPOSER_SKILL_TEXT_MAX_CHARS = 240; 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_TRANSIENT_RETRY_MAX_ATTEMPTS = readPositiveIntEnv('CC_WEB_CODEX_TRANSIENT_RETRY_MAX_ATTEMPTS', 3, { min: 1, max: 10 }); const CODEX_TRANSIENT_RETRY_BASE_DELAY_MS = readPositiveIntEnv('CC_WEB_CODEX_TRANSIENT_RETRY_BASE_DELAY_MS', 2000, { min: 100, max: 60000 }); const MAX_CODEX_GOAL_OBJECTIVE_CHARS = 4000; const CODEX_APP_CCWEB_MCP_BEARER_TOKEN_ENV = 'CC_WEB_CODEX_APP_MCP_TOKEN'; const CODEX_APP_CCWEB_MCP_TRANSPORT = normalizeCodexAppCcwebMcpTransport(process.env.CC_WEB_CODEX_APP_CCWEB_MCP_TRANSPORT); 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 CODEX_APP_PROCESS_ENV_STRIP_KEYS = [ 'CC_WEB_MCP_URL', 'CC_WEB_MCP_TOKEN', CODEX_APP_CCWEB_MCP_BEARER_TOKEN_ENV, 'CC_WEB_SOURCE_SESSION_ID', 'CC_WEB_CROSS_HOP_COUNT', 'CODEX_THREAD_ID', 'CODEX_CI', ]; const PROCESS_CLEAN_PATH_FALLBACK = [ path.join(os.homedir(), '.local/bin'), '/usr/local/sbin', '/usr/local/bin', '/usr/sbin', '/usr/bin', '/sbin', '/bin', ].join(path.delimiter); function isCodexInjectedPathEntry(value) { const normalized = String(value || '').replace(/\\/g, '/'); if (!normalized) return true; if (normalized.includes('/.codex/tmp/arg0')) return true; return normalized.includes('/node_modules/@openai/codex/') && /\/(?:codex-path|path)$/.test(normalized); } function cleanProcessPathValue(value) { const override = String(process.env.CC_WEB_PROCESS_CLEAN_PATH || '').trim(); const source = override || value || PROCESS_CLEAN_PATH_FALLBACK; const fallbackEntries = PROCESS_CLEAN_PATH_FALLBACK.split(path.delimiter).filter(Boolean); const output = []; const seen = new Set(); const addEntry = (entry) => { const text = String(entry || '').trim(); if (!text || seen.has(text) || isCodexInjectedPathEntry(text)) return; seen.add(text); output.push(text); }; String(source).split(path.delimiter).forEach(addEntry); fallbackEntries.forEach(addEntry); return output.length > 0 ? output.join(path.delimiter) : PROCESS_CLEAN_PATH_FALLBACK; } 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'); const CROSS_CONVERSATION_REPLIES_PATH = path.join(CONFIG_DIR, 'cross-conversation-replies.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="," 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(); // Pending Codex transient retry metadata: sessionId -> { text, runtimeText, mode, attempts, timer } const pendingCodexCapacityRetries = 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(); // Active Codex app-server goal RPCs: sessionId -> { id, ws, action, cancelled } const activeCodexAppGoalCommands = new Map(); // ccweb MCP child agents tracked from Codex App native collaboration mode: // childThreadId -> { parentSessionId, parentThreadId, spawnToolId, ...state } const ccwebMcpChildThreads = new Map(); const CODEX_APP_MCP_STARTUP_STATUS_METHOD = 'mcpServer/startupStatus/updated'; const CODEX_APP_MCP_DEFAULT_SERVER = 'ccweb'; const CODEX_APP_MCP_RELOAD_STATUS_WAIT_MS = 1200; const CODEX_APP_MCP_RELOAD_TRACK_MS = 15000; const codexAppMcpStartupStatusByServer = new Map(); // sessionId -> { threadId, requestedAt, expiresAt, reloadRequestId } const pendingCodexAppMcpReloads = new Map(); // sessionId -> Set<{ requestedAt, timer, resolve }> const codexAppMcpStatusWaiters = new Map(); // 等待目标对话完成后回传给来源对话的跨对话请求:requestId -> metadata const pendingCrossConversationReplies = new Map(); // Pending Codex app-server guided input requests: requestId -> { sessionId, resolve, timer } const pendingCodexAppUserInputs = new Map(); // Pending Codex app-server approval requests: requestId -> { sessionId, method, params, resolve, timer } const pendingCodexAppApprovals = 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; const MCP_PROMPT_TITLE_MAX_CHARS = 160; const MCP_PROMPT_DESCRIPTION_MAX_CHARS = 2000; const MCP_PROMPT_QUESTION_MAX_COUNT = 10; const MCP_PROMPT_OPTION_MAX_COUNT = 8; const MCP_PROMPT_QUESTION_MAX_CHARS = 4000; const MCP_PROMPT_OPTION_MAX_CHARS = 1000; const MCP_PROMPT_ANSWER_MAX_CHARS = 4000; const MCP_PROMPT_RESPONSE_MAX_CHARS = 20000; // Codex 默认模型优先读取 ~/.codex/config.toml,缺失时再回退到旧默认值。 const FALLBACK_CODEX_MODEL = 'gpt-5.4'; 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.', '- When calling wait_agent, always pass timeout_ms explicitly. Use 300000ms for normal waits, and use a longer value when the child task is expected to run longer.', '- If wait_agent returns without a final child-agent status, keep waiting on the same target agents in additional wait_agent rounds until they return, the user interrupts, or the result is no longer needed.', ].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, retry: { mode: 'limited', intervalSeconds: Math.max(1, Math.ceil(CODEX_TRANSIENT_RETRY_BASE_DELAY_MS / 1000)), maxAttempts: CODEX_TRANSIENT_RETRY_MAX_ATTEMPTS, }, }; 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 splitTomlTopLevelList(value) { const parts = []; let current = ''; let quote = ''; let escaped = false; let depth = 0; for (const ch of String(value || '')) { if (quote) { current += ch; if (escaped) { escaped = false; } else if (ch === '\\' && quote === '"') { escaped = true; } else if (ch === quote) { quote = ''; } continue; } if (ch === '"' || ch === '\'') { quote = ch; current += ch; continue; } if (ch === '[' || ch === '{') depth += 1; if (ch === ']' || ch === '}') depth = Math.max(0, depth - 1); if (ch === ',' && depth === 0) { if (current.trim()) parts.push(current.trim()); current = ''; continue; } current += ch; } if (current.trim()) parts.push(current.trim()); return parts; } function parseTomlValue(value) { const raw = stripTomlInlineComment(value); if (!raw) return ''; if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith('\'') && raw.endsWith('\''))) { return parseTomlStringValue(raw); } if (raw === 'true') return true; if (raw === 'false') return false; if (/^[+-]?\d+(?:\.\d+)?$/.test(raw)) return Number(raw); if (raw.startsWith('[') && raw.endsWith(']')) { const inner = raw.slice(1, -1).trim(); if (!inner) return []; return splitTomlTopLevelList(inner).map((item) => parseTomlValue(item)); } if (raw.startsWith('{') && raw.endsWith('}')) { const inner = raw.slice(1, -1).trim(); const parsed = {}; if (!inner) return parsed; for (const item of splitTomlTopLevelList(inner)) { const eqIndex = item.indexOf('='); if (eqIndex <= 0) continue; const key = String(item.slice(0, eqIndex)).trim().replace(/^["']|["']$/g, ''); if (!key) continue; parsed[key] = parseTomlValue(item.slice(eqIndex + 1)); } return parsed; } return raw; } function parseTomlBareKeyPath(pathText) { const parts = []; let current = ''; let quote = ''; let escaped = false; for (const ch of String(pathText || '').trim()) { if (quote) { if (escaped) { current += ch; escaped = false; continue; } if (ch === '\\' && quote === '"') { escaped = true; continue; } if (ch === quote) { quote = ''; continue; } current += ch; continue; } if (ch === '"' || ch === '\'') { quote = ch; continue; } if (ch === '.') { if (current.trim()) parts.push(current.trim()); current = ''; continue; } current += ch; } if (current.trim()) parts.push(current.trim()); return parts.filter(Boolean); } function commandExistsForMcp(command) { const raw = String(command || '').trim(); if (!raw) return false; const hasPathSeparator = raw.includes('/') || raw.includes('\\'); const candidates = hasPathSeparator ? [path.resolve(raw)] : String(process.env.PATH || '').split(path.delimiter).filter(Boolean).map((dir) => path.join(dir, raw)); for (const candidate of candidates) { try { const stat = fs.statSync(candidate); if (!stat.isFile()) continue; fs.accessSync(candidate, fs.constants.X_OK); return true; } catch {} } return false; } function getProjectCodexConfigTomlPath(cwd) { const start = normalizeExistingDirPath(cwd); if (!start) return ''; const projectRoot = findNearestProjectRoot(start) || start; let dir = start; const seen = new Set(); while (dir && !seen.has(dir)) { seen.add(dir); const configPath = path.join(dir, '.codex', 'config.toml'); try { if (fs.statSync(configPath).isFile()) return configPath; } catch {} if (dir === projectRoot) break; const parent = path.dirname(dir); if (!parent || parent === dir) break; dir = parent; } return ''; } function parseCodexMcpServerConfigToml(text, source) { const servers = []; let current = null; for (const line of String(text || '').split(/\r?\n/)) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/); if (sectionMatch) { const parts = parseTomlBareKeyPath(sectionMatch[1]); current = parts[0] === 'mcp_servers' && parts[1] ? { name: String(parts[1]).trim(), config: {} } : null; if (current?.name) servers.push(current); continue; } if (!current) continue; const eqIndex = trimmed.indexOf('='); if (eqIndex <= 0) continue; const key = String(trimmed.slice(0, eqIndex)).trim(); if (!key) continue; current.config[key] = parseTomlValue(trimmed.slice(eqIndex + 1)); } return servers .map((server) => normalizeCodexMcpServerConfig(server.name, server.config, source)) .filter(Boolean); } function normalizeCodexMcpServerConfig(name, rawConfig, source) { const server = normalizeMcpServerName(name); if (!isLikelyMcpServerName(server) || !rawConfig || typeof rawConfig !== 'object') return null; if (rawConfig.enabled === false) return null; const type = String(rawConfig.type || (rawConfig.url ? 'streamable_http' : 'stdio')).trim(); const config = { type }; if (type === 'stdio') { const command = String(rawConfig.command || '').trim(); if (!command || !commandExistsForMcp(command)) return null; config.command = command; if (Array.isArray(rawConfig.args)) config.args = rawConfig.args.map((item) => String(item)); if (rawConfig.env && typeof rawConfig.env === 'object' && !Array.isArray(rawConfig.env)) config.env = rawConfig.env; if (Array.isArray(rawConfig.env_vars)) config.env_vars = rawConfig.env_vars.map((item) => String(item)); } else { const url = normalizeSkillMcpUrl(rawConfig.url || rawConfig.server_url || ''); if (!url) return null; config.url = url; const bearerTokenEnvVar = String(rawConfig.bearer_token_env_var || rawConfig.bearerTokenEnvVar || '').trim(); if (bearerTokenEnvVar) config.bearer_token_env_var = bearerTokenEnvVar; } for (const key of ['startup_timeout_sec', 'tool_timeout_sec']) { if (Number.isFinite(rawConfig[key])) config[key] = rawConfig[key]; } return { server, name: server, source, type, config, description: `MCP server: ${server}`, }; } function loadProjectCodexMcpServerConfigs(cwd) { try { const configPath = getProjectCodexConfigTomlPath(cwd); if (!configPath || !fs.existsSync(configPath)) return []; return parseCodexMcpServerConfigToml(fs.readFileSync(configPath, 'utf8'), path.relative(process.cwd(), configPath) || configPath); } catch { return []; } } 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 normalizeCodexRetryConfig(raw = {}) { const defaults = DEFAULT_CODEX_CONFIG.retry; const mode = ['off', 'limited', 'forever'].includes(raw?.mode) ? raw.mode : defaults.mode; const rawInterval = Number.parseInt(String(raw?.intervalSeconds ?? raw?.interval ?? ''), 10); const intervalSeconds = Number.isFinite(rawInterval) ? Math.max(1, Math.min(3600, rawInterval)) : defaults.intervalSeconds; const rawMaxAttempts = Number.parseInt(String(raw?.maxAttempts ?? raw?.attempts ?? ''), 10); const maxAttempts = Number.isFinite(rawMaxAttempts) ? Math.max(1, Math.min(1000, rawMaxAttempts)) : defaults.maxAttempts; return { mode, intervalSeconds, maxAttempts }; } 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, retry: normalizeCodexRetryConfig(raw.retry), }; } } 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, retry: normalizeCodexRetryConfig(config.retry), }, 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, retry: normalizeCodexRetryConfig(config.retry), }; } 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: '/goal', description: '设置/查看/暂停/恢复/清除 Codex App 持久目标', insertion: '/goal ' }, { 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 stripSimpleYamlComment(line) { const value = String(line || ''); let quote = ''; let escaped = false; for (let i = 0; i < value.length; i += 1) { const ch = value[i]; if (quote) { if (escaped) { escaped = false; continue; } if (ch === '\\' && quote === '"') { escaped = true; continue; } if (ch === quote) quote = ''; continue; } if (ch === '"' || ch === '\'') { quote = ch; continue; } if (ch === '#' && (i === 0 || /\s/.test(value[i - 1]))) { return value.slice(0, i).trimEnd(); } } return value; } function parseSimpleYamlScalar(rawValue) { const value = stripSimpleYamlComment(rawValue).trim(); if (!value) return ''; if (value === 'true') return true; if (value === 'false') return false; if (value === 'null' || value === '~') return null; if (value.startsWith('"') && value.endsWith('"')) { try { return JSON.parse(value); } catch { return value.slice(1, -1); } } if (value.startsWith('\'') && value.endsWith('\'')) { return value.slice(1, -1).replace(/''/g, '\''); } return value; } function parseSimpleYamlKeyValue(text) { const value = String(text || '').trim(); if (!value) return null; let quote = ''; let escaped = false; for (let i = 0; i < value.length; i += 1) { const ch = value[i]; if (quote) { if (escaped) { escaped = false; continue; } if (ch === '\\' && quote === '"') { escaped = true; continue; } if (ch === quote) quote = ''; continue; } if (ch === '"' || ch === '\'') { quote = ch; continue; } if (ch !== ':') continue; const key = value.slice(0, i).trim(); if (!/^[A-Za-z0-9_-]+$/.test(key)) return null; return { key, value: parseSimpleYamlScalar(value.slice(i + 1)) }; } return null; } function parseOpenAiSkillYaml(text) { const parsed = { interface: {}, policy: {}, dependencies: { tools: [] } }; let section = ''; let dependencyList = ''; let currentTool = null; for (const rawLine of String(text || '').split(/\r?\n/)) { const line = stripSimpleYamlComment(rawLine); if (!line.trim()) continue; const indent = line.match(/^\s*/)?.[0]?.length || 0; const trimmed = line.trim(); if (indent === 0) { const sectionName = trimmed.endsWith(':') ? trimmed.slice(0, -1).trim() : ''; if (['interface', 'policy', 'dependencies'].includes(sectionName)) { section = sectionName; dependencyList = ''; currentTool = null; } else { section = ''; } continue; } if (!section) continue; if (section === 'dependencies') { if (indent === 2 && trimmed === 'tools:') { dependencyList = 'tools'; currentTool = null; continue; } if (dependencyList === 'tools' && indent >= 4 && trimmed.startsWith('-')) { currentTool = {}; parsed.dependencies.tools.push(currentTool); const rest = trimmed.slice(1).trim(); const item = parseSimpleYamlKeyValue(rest); if (item) currentTool[item.key] = item.value; continue; } if (dependencyList === 'tools' && currentTool && indent >= 6) { const item = parseSimpleYamlKeyValue(trimmed); if (item) currentTool[item.key] = item.value; } continue; } if (indent >= 2) { const item = parseSimpleYamlKeyValue(trimmed); if (item) parsed[section][item.key] = item.value; } } return parsed; } function normalizeComposerTextValue(value, maxChars = COMPOSER_SKILL_TEXT_MAX_CHARS) { const text = String(value || '').trim().replace(/\s+/g, ' '); return text ? truncateTextValue(text, maxChars, '...') : ''; } function normalizeSkillBrandColor(value) { const color = String(value || '').trim(); return /^#[0-9A-Fa-f]{3}(?:[0-9A-Fa-f]{3})?$/.test(color) ? color : ''; } function normalizeSkillIconUrl(value) { const url = String(value || '').trim(); if (/^https?:\/\//i.test(url)) return url; if (/^data:image\/(?:png|jpe?g|gif|webp|svg\+xml);base64,[A-Za-z0-9+/=]+$/i.test(url) && url.length <= 12000) { return url; } return ''; } 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 collectFilesByExtension(rootDir, extensions, 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 normalizedExtensions = new Set( [...extensions].map((ext) => String(ext || '').trim().toLowerCase()).filter(Boolean) ); 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() && normalizedExtensions.has(path.extname(entry.name).toLowerCase())) { 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 hasProjectRootMarker(dir) { return ['.git', '.hg', '.sl'].some((marker) => { try { return fs.existsSync(path.join(dir, marker)); } catch { return false; } }); } function findNearestProjectRoot(startDir) { const start = normalizeExistingDirPath(startDir); if (!start) return null; let dir = start; const seen = new Set(); while (dir && !seen.has(dir)) { seen.add(dir); if (hasProjectRootMarker(dir)) return dir; const parent = path.dirname(dir); if (!parent || parent === dir) break; dir = parent; } return start; } function composerProjectSkillRoots(cwd) { const start = normalizeExistingDirPath(cwd); const projectRoot = findNearestProjectRoot(start); if (!start || !projectRoot) return []; const roots = []; let dir = start; const seen = new Set(); while (dir && !seen.has(dir)) { seen.add(dir); roots.push(path.join(dir, '.agents', 'skills')); roots.push(path.join(dir, '.codex', 'skills')); if (dir === projectRoot) break; const parent = path.dirname(dir); if (!parent || parent === dir) break; dir = parent; } return roots; } function composerSkillRoots(options = {}) { const roots = []; const session = options.session || (options.sessionId ? loadSession(options.sessionId) : null); roots.push(...composerProjectSkillRoots(options.cwd || session?.cwd)); const codexHome = getCodexHomeDir(); if (codexHome) roots.push(path.join(codexHome, 'skills')); const homeDir = normalizeExistingDirPath(process.env.HOME || process.env.USERPROFILE || ''); if (homeDir) roots.push(path.join(homeDir, '.agents', 'skills')); roots.push(path.join(APP_DIR, '.codex', 'skills')); roots.push(path.join(APP_DIR, '.agents', 'skills')); roots.push('/etc/codex/skills'); const seen = new Set(); return roots.filter((root) => { const key = path.resolve(String(root || '')); if (!key || seen.has(key)) return false; seen.add(key); return true; }); } function normalizeSkillMcpUrl(value) { const raw = String(value || '').trim(); if (!raw) return ''; try { const parsed = new URL(raw); return ['http:', 'https:'].includes(parsed.protocol) ? parsed.toString() : ''; } catch { return ''; } } function normalizeSkillMcpTool(rawTool) { if (!rawTool || typeof rawTool !== 'object') return null; const type = String(rawTool.type || '').trim().toLowerCase(); const value = normalizeMcpServerName(rawTool.value || rawTool.name || rawTool.server); if (type !== 'mcp' || !isLikelyMcpServerName(value)) return null; return { type: 'mcp', value, name: value, server: value, description: normalizeComposerTextValue(rawTool.description || `MCP server: ${value}`), transport: normalizeComposerTextValue(rawTool.transport, 80), url: normalizeSkillMcpUrl(rawTool.url), }; } function loadOpenAiSkillMetadata(skillPath) { const skillDir = path.dirname(skillPath); const metadataPath = path.join(skillDir, 'agents', 'openai.yaml'); try { const stat = fs.statSync(metadataPath); if (!stat.isFile() || stat.size > COMPOSER_SKILL_METADATA_MAX_BYTES) return null; const parsed = parseOpenAiSkillYaml(fs.readFileSync(metadataPath, 'utf8')); const iface = parsed.interface || {}; const policy = parsed.policy || {}; const tools = Array.isArray(parsed.dependencies?.tools) ? parsed.dependencies.tools : []; const mcpTools = tools.map((tool) => normalizeSkillMcpTool(tool)).filter(Boolean); return { metadataSource: path.relative(process.cwd(), metadataPath) || metadataPath, displayName: normalizeComposerTextValue(iface.display_name, 100), shortDescription: normalizeComposerTextValue(iface.short_description, COMPOSER_SKILL_TEXT_MAX_CHARS), iconSmall: normalizeSkillIconUrl(iface.icon_small), iconLarge: normalizeSkillIconUrl(iface.icon_large), brandColor: normalizeSkillBrandColor(iface.brand_color), defaultPromptPreview: normalizeComposerTextValue(iface.default_prompt, COMPOSER_SKILL_DEFAULT_PROMPT_PREVIEW_CHARS), policy: { allowImplicitInvocation: policy.allow_implicit_invocation !== false, }, dependencies: { tools: mcpTools }, }; } catch { return null; } } function loadCodexSkills(options = {}) { const seen = new Set(); const skills = []; for (const root of composerSkillRoots(options)) { 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); const metadata = loadOpenAiSkillMetadata(skillPath); const title = metadata?.displayName || name; const description = metadata?.shortDescription || String(meta.description || 'Codex skill').trim(); skills.push({ kind: 'skill', name, label: `$${name}`, title, description, insertion: `$${name}`, source: path.relative(process.cwd(), skillPath) || skillPath, metadataSource: metadata?.metadataSource || '', iconSmall: metadata?.iconSmall || '', iconLarge: metadata?.iconLarge || '', brandColor: metadata?.brandColor || '', defaultPromptPreview: metadata?.defaultPromptPreview || '', policy: metadata?.policy || { allowImplicitInvocation: true }, dependencies: metadata?.dependencies || { tools: [] }, }); } } return skills; } function composerPromptRoots() { const roots = []; const codexHome = getCodexHomeDir(); if (codexHome) roots.push(path.join(codexHome, 'prompts')); roots.push(path.join(APP_DIR, '.codex', 'prompts')); roots.push(path.join(APP_DIR, '.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 = Array.from(new Set([ ...collectFilesByName(root, new Set(['prompt.md', 'PROMPT.md']), { maxDepth: 5, maxFiles: 300 }), ...collectFilesByExtension(root, new Set(['.md', '.txt']), { maxDepth: 5, maxFiles: 300 }), ])); for (const filePath of files) { let text = ''; try { text = fs.readFileSync(filePath, 'utf8'); } catch { continue; } const { meta, body } = parseSimpleFrontmatter(text); const baseName = path.basename(filePath).replace(/\.(md|txt)$/i, ''); const relName = normalizeRelativeBrowserPath(path.relative(root, filePath)).replace(/\.(md|txt)$/i, ''); const fallbackName = /^prompt$/i.test(baseName) ? path.basename(path.dirname(filePath)) : relName; 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 normalizeMcpServerName(value) { return String(value || '').trim().replace(/^mcp:/, '').replace(/^mcp__/, '').replace(/__.*$/, ''); } function isLikelyMcpServerName(value) { const name = normalizeMcpServerName(value); if (!name || name.length > 80) return false; if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(name)) return false; return !new Set([ 'content', 'text', 'input', 'result', 'status', 'server', 'tool', 'mcp', 'true', 'false', 'null', 'undefined', 'currentConversationId', ]).has(name); } function mcpServerSuggestion(name, options = {}) { const server = normalizeMcpServerName(name); if (!isLikelyMcpServerName(server)) return null; return { kind: 'mcp', name: server, label: `mcp:${server}`, title: `${server} MCP`, description: options.description || `MCP server: ${server}`, insertion: `mcp:${server}`, appendSpace: true, server, source: options.source || 'mcp', itemType: 'server', state: options.state || 'configured', dependencyOf: options.dependencyOf || '', transport: options.transport || '', url: options.url || '', }; } function summarizeSkillDependencies(skill) { const tools = Array.isArray(skill?.dependencies?.tools) ? skill.dependencies.tools : []; return tools .filter((tool) => tool?.type === 'mcp' && isLikelyMcpServerName(tool.value || tool.name || tool.server)) .map((tool) => ({ type: 'mcp', value: normalizeMcpServerName(tool.value || tool.name || tool.server), name: normalizeMcpServerName(tool.value || tool.name || tool.server), description: normalizeComposerTextValue(tool.description || ''), transport: normalizeComposerTextValue(tool.transport || '', 80), url: normalizeSkillMcpUrl(tool.url || ''), state: 'declared', })); } function summarizeSkillForMention(skill, configuredMcpNames = new Set()) { const dependencies = summarizeSkillDependencies(skill).map((dependency) => ({ ...dependency, state: configuredMcpNames.has(normalizeMcpServerName(dependency.value)) ? 'configured' : 'declared', })); return { kind: 'skill', name: skill.name, label: `$${skill.name}`, title: skill.title || skill.name, description: skill.description || '', source: skill.source || '', metadataSource: skill.metadataSource || '', iconSmall: skill.iconSmall || '', iconLarge: skill.iconLarge || '', brandColor: skill.brandColor || '', defaultPromptPreview: skill.defaultPromptPreview || '', dependencies, }; } function buildCcwebMcpRuntimeConfig(session, options = {}) { if (!session?.id || !INTERNAL_MCP_TOKEN) return null; const rawHopCount = Number.parseInt(String(options.mcpContext?.hopCount || 0), 10); const hopCount = Number.isFinite(rawHopCount) ? Math.max(0, rawHopCount) : 0; if (normalizeAgent(options.agent || session?.agent || 'codex') === 'codexapp' && CODEX_APP_CCWEB_MCP_TRANSPORT !== 'stdio') { const params = new URLSearchParams({ sourceSessionId: session.id, sourceHopCount: String(hopCount), }); return { server: 'ccweb', name: 'ccweb', source: 'builtin', type: 'streamable_http', description: 'ccweb 内置共享 MCP server,可用于跨会话协作。', config: { type: 'streamable_http', url: `http://127.0.0.1:${PORT}/api/internal/mcp/stream?${params.toString()}`, bearer_token_env_var: CODEX_APP_CCWEB_MCP_BEARER_TOKEN_ENV, startup_timeout_sec: 10, tool_timeout_sec: 60, }, }; } const env = codexAppCcwebMcpEnv(session, { ...options, mcpContext: { hopCount } }); if (!env) return null; const commandSpec = ccwebMcpServerCommandSpec(); return { server: 'ccweb', name: 'ccweb', source: 'builtin', type: 'stdio', description: 'ccweb 内置 MCP server,可用于跨会话协作。', config: { command: commandSpec.command, args: commandSpec.args, env, startup_timeout_sec: 10, tool_timeout_sec: 60, }, }; } function listRuntimeMcpServerConfigs(options = {}) { const session = options.session || (options.sessionId ? loadSession(options.sessionId) : null); const agent = normalizeAgent(options.agent || session?.agent || 'codex'); if (!isCodexLikeAgent(agent)) return []; const configs = []; const seen = new Set(); const push = (config) => { const server = normalizeMcpServerName(config?.server || config?.name); if (!server || seen.has(server)) return; seen.add(server); configs.push({ ...config, server, name: server }); }; for (const config of loadProjectCodexMcpServerConfigs(session?.cwd || options.cwd || getDefaultSessionCwd())) { push({ ...config, source: 'project-config' }); } push(buildCcwebMcpRuntimeConfig(session, options)); return configs; } function listComposerMcpItems(options = {}) { const items = []; const seen = new Set(); const push = (item) => { if (!item?.server && !item?.name) return; const key = `${item.kind || ''}:${item.server || ''}:${item.name || item.label || item.insertion || ''}`; if (seen.has(key)) return; seen.add(key); items.push(item); }; for (const config of listRuntimeMcpServerConfigs(typeof options === 'string' ? { sessionId: options } : options)) { push(mcpServerSuggestion(config.server, { source: config.source || 'runtime', description: config.description || `MCP server: ${config.server}`, transport: config.type || '', url: config.config?.url || '', })); if (config.server === 'ccweb') { for (const tool of CCWEB_MCP_TOOLS) { const name = String(tool?.name || '').trim(); const label = `mcp:ccweb/${name}`; push({ kind: 'mcp', name, label, title: `ccweb/${name}`, description: String(tool?.description || 'MCP 工具').trim(), insertion: label, appendSpace: true, server: 'ccweb', source: 'mcp:ccweb', itemType: 'tool', action: '', }); } } } return items; } 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, session = null) { const skillItems = isCodexLikeAgent(agent) ? loadCodexSkills({ session }) : []; if (trigger === '/') { const mcpItems = filterComposerItems(listComposerMcpItems({ sessionId, session, agent }), query); const promptUserMcpItems = mcpItems.filter((item) => item.kind === 'mcp' && item.server === 'ccweb' && item.name === 'ccweb_prompt_user'); const otherMcpItems = mcpItems.filter((item) => !(item.kind === 'mcp' && item.server === 'ccweb' && item.name === 'ccweb_prompt_user')); const commands = filterComposerItems(COMPOSER_COMMANDS.map((cmd) => ({ kind: 'command', name: cmd.name, label: cmd.name, description: cmd.description, insertion: cmd.insertion, })), query.replace(/^\//, '')); return mergeComposerSuggestionGroups(commands, promptUserMcpItems, otherMcpItems); } if (trigger === '$') { const skills = filterComposerItems(skillItems, query); return mergeComposerSuggestionGroups(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(files, prompts); } 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 session = sessionId ? loadSession(sessionId) : null; const items = listComposerSuggestions(trigger, query, sessionId, agent, session); 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({ session }).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 configuredMcpNames = new Set(listComposerMcpItems({ sessionId: session?.id, agent, skills: [...skillsByName.values()] }) .filter((item) => item.kind === 'mcp' && item.itemType === 'server' && item.state !== 'declared') .map((item) => normalizeMcpServerName(item.server || item.name))); 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(summarizeSkillForMention(skill, configuredMcpNames)); } 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) || activeCodexAppGoalCommands.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; } function mcpStatusObject(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : null; } function firstPresentValue(...values) { for (const value of values) { if (value === undefined || value === null) continue; if (typeof value === 'string' && !value.trim()) continue; return value; } return null; } function redactMcpStatusText(text) { return String(text || '') .replace(/\b(Bearer)\s+[A-Za-z0-9._~+/=-]+/gi, '$1 [redacted]') .replace(/\b((?:[A-Z0-9_]*_)?(?:TOKEN|API_KEY|SECRET|PASSWORD|AUTHORIZATION))\b\s*[:=]\s*["']?[^"',\s}]+/gi, '$1=[redacted]') .replace(/("(?:[^"]*(?:token|api[_-]?key|secret|password|authorization)[^"]*)"\s*:\s*)"[^"]*"/gi, '$1"[redacted]"'); } function safeMcpStatusString(value, maxChars = 500) { if (value === undefined || value === null) return ''; let text = ''; if (typeof value === 'string') { text = value; } else if (typeof value === 'number' || typeof value === 'boolean') { text = String(value); } else if (mcpStatusObject(value)) { text = firstPresentValue(value.message, value.error, value.reason, value.detail); if (text === null) { try { text = JSON.stringify(value); } catch { text = String(value); } } } else { text = String(value); } const redacted = redactMcpStatusText(text).trim(); if (!redacted) return ''; return redacted.length > maxChars ? `${redacted.slice(0, maxChars)}...` : redacted; } function normalizeCodexAppMcpStartupStatus(value) { const raw = safeMcpStatusString(value, 80).toLowerCase(); if (!raw) return 'unknown'; if (/^(ready|running|ok|success|succeeded|started|available)$/.test(raw)) return 'ready'; if (/^(starting|pending|loading|initializing|launching|connecting)$/.test(raw)) return 'starting'; if (/^(failed|failure|error|errored|crashed)$/.test(raw)) return 'failed'; if (/^(cancelled|canceled|disabled|stopped)$/.test(raw)) return 'cancelled'; return raw; } function codexAppMcpStatusKey(name) { return safeMcpStatusString(name || CODEX_APP_MCP_DEFAULT_SERVER, 120).toLowerCase() || CODEX_APP_MCP_DEFAULT_SERVER; } function publicCodexAppMcpStatusRecord(record = {}) { const name = safeMcpStatusString(record.name || record.server || CODEX_APP_MCP_DEFAULT_SERVER, 120) || CODEX_APP_MCP_DEFAULT_SERVER; const status = normalizeCodexAppMcpStartupStatus(record.status || record.state || 'unknown'); return { server: name, name, status, rawStatus: safeMcpStatusString(record.rawStatus || record.status || status, 80) || status, message: safeMcpStatusString(record.message || record.error || '', 500), threadId: safeMcpStatusString(record.threadId || '', 160) || null, updatedAt: safeMcpStatusString(record.updatedAt || new Date().toISOString(), 80), source: safeMcpStatusString(record.source || 'notification', 40) || 'notification', }; } function parseCodexAppMcpStartupStatus(params = {}) { const direct = mcpStatusObject(params) || {}; const statusObject = mcpStatusObject(direct.status) || mcpStatusObject(direct.startupStatus) || mcpStatusObject(direct.serverStatus) || {}; const serverObject = mcpStatusObject(direct.server) || mcpStatusObject(direct.mcpServer) || mcpStatusObject(statusObject.server) || {}; const name = safeMcpStatusString(firstPresentValue( direct.name, direct.serverName, direct.mcpServerName, typeof direct.server === 'string' ? direct.server : null, typeof direct.mcpServer === 'string' ? direct.mcpServer : null, serverObject.name, serverObject.server, statusObject.name, statusObject.server, statusObject.serverName ), 120); const rawStatus = firstPresentValue( typeof direct.status === 'string' ? direct.status : null, direct.state, direct.startupState, statusObject.status, statusObject.state, statusObject.startupState, direct.ready === true ? 'ready' : null, direct.ok === false ? 'failed' : null ); const threadId = safeMcpStatusString(firstPresentValue( direct.threadId, direct.thread?.id, statusObject.threadId, statusObject.thread?.id ), 160) || null; if (!name && rawStatus === null && !threadId) return null; const status = normalizeCodexAppMcpStartupStatus(rawStatus); return publicCodexAppMcpStatusRecord({ name: name || CODEX_APP_MCP_DEFAULT_SERVER, status, rawStatus: rawStatus || status, message: firstPresentValue( direct.message, direct.error, direct.reason, direct.detail, statusObject.message, statusObject.error, statusObject.reason, statusObject.detail ), threadId, updatedAt: new Date().toISOString(), source: 'notification', }); } function ensureCodexAppMcpStartupState(session) { if (!session || typeof session !== 'object') return null; if (!mcpStatusObject(session.codexAppMcpStartupStatus)) { session.codexAppMcpStartupStatus = {}; } const state = session.codexAppMcpStartupStatus; if (!mcpStatusObject(state.servers)) state.servers = {}; return state; } function findCodexAppMcpStatusRecord(state, serverName = CODEX_APP_MCP_DEFAULT_SERVER) { const servers = mcpStatusObject(state?.servers) || {}; const key = codexAppMcpStatusKey(serverName); if (servers[key]) return publicCodexAppMcpStatusRecord(servers[key]); return Object.values(servers) .map((record) => publicCodexAppMcpStatusRecord(record)) .find((record) => codexAppMcpStatusKey(record.name) === key) || null; } function buildCodexAppMcpStatusSummary(session, options = {}) { const state = mcpStatusObject(session?.codexAppMcpStartupStatus) || {}; const serversObject = mcpStatusObject(state.servers) || {}; const servers = Object.values(serversObject).map((record) => publicCodexAppMcpStatusRecord(record)); let current = findCodexAppMcpStatusRecord(state, options.serverName || CODEX_APP_MCP_DEFAULT_SERVER); const reloadRequestedAt = safeMcpStatusString(options.reloadRequestedAt || state.reloadRequestedAt || '', 80) || null; if (!current) { const threadId = safeMcpStatusString(state.threadId || getRuntimeSessionId(session) || '', 160) || null; current = publicCodexAppMcpStatusRecord({ name: options.serverName || CODEX_APP_MCP_DEFAULT_SERVER, status: reloadRequestedAt ? 'pending' : 'unknown', rawStatus: reloadRequestedAt ? 'pending' : 'unknown', message: reloadRequestedAt ? '已请求重载,等待 app-server 上报启动状态' : '尚未收到 app-server 启动状态', threadId, updatedAt: reloadRequestedAt || new Date().toISOString(), source: reloadRequestedAt ? 'pending' : 'unknown', }); } return { ...current, reloadRequestedAt, reloadRequestId: safeMcpStatusString(state.reloadRequestId || '', 80) || null, hasStartupStatus: current.source === 'notification', servers, }; } function markCodexAppMcpReloadPending(session, sessionId) { const requestedAt = new Date().toISOString(); const threadId = getRuntimeSessionId(session) || null; const reloadRequestId = crypto.randomUUID(); const state = ensureCodexAppMcpStartupState(session); if (!state) return { requestedAt, summary: null }; state.reloadRequestedAt = requestedAt; state.reloadRequestId = reloadRequestId; state.updatedAt = requestedAt; state.threadId = threadId; state.servers[codexAppMcpStatusKey(CODEX_APP_MCP_DEFAULT_SERVER)] = publicCodexAppMcpStatusRecord({ name: CODEX_APP_MCP_DEFAULT_SERVER, status: 'pending', rawStatus: 'pending', message: '已请求重载,等待 app-server 上报启动状态', threadId, updatedAt: requestedAt, source: 'pending', }); pendingCodexAppMcpReloads.set(sessionId, { threadId, requestedAt, expiresAt: Date.now() + CODEX_APP_MCP_RELOAD_TRACK_MS, reloadRequestId, }); saveSession(session); return { requestedAt, summary: buildCodexAppMcpStatusSummary(session, { reloadRequestedAt: requestedAt }), }; } function cleanupExpiredCodexAppMcpReloads() { const now = Date.now(); for (const [sessionId, pending] of pendingCodexAppMcpReloads.entries()) { if ((pending.expiresAt || 0) <= now) pendingCodexAppMcpReloads.delete(sessionId); } } function isFinalCodexAppMcpStatus(status) { return status === 'ready' || status === 'failed' || status === 'cancelled'; } function isFreshCodexAppMcpSummary(summary, requestedAt) { if (!summary || summary.source !== 'notification') return false; if (!requestedAt) return true; const updated = Date.parse(summary.updatedAt || ''); const requested = Date.parse(requestedAt || ''); if (!Number.isFinite(updated) || !Number.isFinite(requested)) return true; return updated >= requested; } function resolveCodexAppMcpStatusWaiters(sessionId, summary) { const waiters = codexAppMcpStatusWaiters.get(sessionId); if (!waiters || waiters.size === 0) return; for (const waiter of Array.from(waiters)) { if (!isFreshCodexAppMcpSummary(summary, waiter.requestedAt) || !isFinalCodexAppMcpStatus(summary.status)) continue; clearTimeout(waiter.timer); waiters.delete(waiter); waiter.resolve(summary); } if (waiters.size === 0) codexAppMcpStatusWaiters.delete(sessionId); } function waitForCodexAppMcpStatusAfterReload(sessionId, requestedAt, timeoutMs = CODEX_APP_MCP_RELOAD_STATUS_WAIT_MS) { const current = buildCodexAppMcpStatusSummary(loadSession(sessionId), { reloadRequestedAt: requestedAt }); if (isFreshCodexAppMcpSummary(current, requestedAt) && isFinalCodexAppMcpStatus(current.status)) { return Promise.resolve(current); } return new Promise((resolve) => { const waiter = { requestedAt, resolve, timer: setTimeout(() => { const waiters = codexAppMcpStatusWaiters.get(sessionId); if (waiters) { waiters.delete(waiter); if (waiters.size === 0) codexAppMcpStatusWaiters.delete(sessionId); } resolve(buildCodexAppMcpStatusSummary(loadSession(sessionId), { reloadRequestedAt: requestedAt })); }, timeoutMs), }; let waiters = codexAppMcpStatusWaiters.get(sessionId); if (!waiters) { waiters = new Set(); codexAppMcpStatusWaiters.set(sessionId, waiters); } waiters.add(waiter); }); } function updateCodexAppSessionMcpStatus(sessionId, statusRecord) { const normalizedId = sanitizeId(sessionId || ''); if (!normalizedId) return null; const session = loadSession(normalizedId); if (!session || !isCodexAppSession(session)) return null; const state = ensureCodexAppMcpStartupState(session); if (!state) return null; const record = publicCodexAppMcpStatusRecord({ ...statusRecord, threadId: statusRecord.threadId || state.threadId || getRuntimeSessionId(session) || null, source: 'notification', }); const key = codexAppMcpStatusKey(record.name); state.servers[key] = record; state.updatedAt = record.updatedAt; state.threadId = record.threadId || state.threadId || null; if (key === codexAppMcpStatusKey(CODEX_APP_MCP_DEFAULT_SERVER) && isFinalCodexAppMcpStatus(record.status)) { pendingCodexAppMcpReloads.delete(normalizedId); } saveSession(session); const summary = buildCodexAppMcpStatusSummary(session); resolveCodexAppMcpStatusWaiters(normalizedId, summary); sendSessionEventToViewers(normalizedId, { type: 'mcp_startup_status', sessionId: normalizedId, status: summary, mcpStatus: summary, }); return summary; } function markCodexAppMcpReloadFailed(sessionId, message) { return updateCodexAppSessionMcpStatus(sessionId, { name: CODEX_APP_MCP_DEFAULT_SERVER, status: 'failed', rawStatus: 'failed', message, updatedAt: new Date().toISOString(), source: 'notification', }); } function codexAppMcpStatusTargetSessionIds(statusRecord, routed) { cleanupExpiredCodexAppMcpReloads(); const targetSessionIds = new Set(); if (routed?.sessionId) targetSessionIds.add(routed.sessionId); for (const [sessionId, pending] of pendingCodexAppMcpReloads.entries()) { if (statusRecord.threadId && pending.threadId && statusRecord.threadId !== pending.threadId) continue; targetSessionIds.add(sessionId); } return targetSessionIds; } function handleCodexAppMcpStartupStatusNotification(notification, routed) { if (notification?.method !== CODEX_APP_MCP_STARTUP_STATUS_METHOD) return false; const statusRecord = parseCodexAppMcpStartupStatus(notification.params || {}); if (!statusRecord) { plog('WARN', 'codex_app_mcp_startup_status_unparsed', { method: notification.method }); return true; } codexAppMcpStartupStatusByServer.set(codexAppMcpStatusKey(statusRecord.name), statusRecord); const targetSessionIds = codexAppMcpStatusTargetSessionIds(statusRecord, routed); for (const sessionId of targetSessionIds) { updateCodexAppSessionMcpStatus(sessionId, statusRecord); } plog('INFO', 'codex_app_mcp_startup_status_updated', { server: statusRecord.name, status: statusRecord.status, threadId: statusRecord.threadId, targetSessions: targetSessionIds.size, }); return true; } async function handleReloadMcpApi(req, res, rawSessionId) { const token = extractBearerToken(req); if (!token || !activeTokens.has(token)) { 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 后再继续。', }); } let reloadRequestedAt = null; 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 pendingMcp = markCodexAppMcpReloadPending(session, sessionId); reloadRequestedAt = pendingMcp.requestedAt; const result = typeof client.reloadMcpServers === 'function' ? await client.reloadMcpServers() : await client.request('config/mcpServer/reload', {}, 30000); const mcpStatus = await waitForCodexAppMcpStatusAfterReload(sessionId, reloadRequestedAt); plog('INFO', 'codex_app_mcp_reload_requested', { sessionId: sessionId.slice(0, 8), threadId: getRuntimeSessionId(session) || null, status: mcpStatus?.status || pendingMcp.summary?.status || 'pending', }); return jsonResponse(res, 200, { ok: true, sessionId, threadId: getRuntimeSessionId(session) || null, result: result || {}, mcpStatus: mcpStatus || pendingMcp.summary || buildCodexAppMcpStatusSummary(loadSession(sessionId), { reloadRequestedAt }), }); } catch (err) { const unsupported = err?.code === -32601 || /not found|unknown|unsupported|method/i.test(String(err?.message || '')); const message = unsupported ? `当前 Codex app-server 不支持重载 MCP,请重启 Codex App。${err?.message ? `(${err.message})` : ''}` : `重载 MCP 失败: ${err?.message || err}`; const mcpStatus = reloadRequestedAt ? markCodexAppMcpReloadFailed(sessionId, message) : buildCodexAppMcpStatusSummary(session); return jsonResponse(res, unsupported ? 501 : 500, { ok: false, code: unsupported ? 'codexapp_reload_mcp_unsupported' : 'codexapp_reload_mcp_failed', message, mcpStatus, }); } } 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 normalizeCrossConversationReplyState(raw = {}) { const requestId = String(raw.requestId || '').trim(); if (!requestId) return null; const status = ['waiting', 'ready', 'delivering', 'returned', 'failed'].includes(raw.status) ? raw.status : 'waiting'; const sourceConversationId = sanitizeId(raw.sourceConversationId || raw.sourceSessionId || ''); const targetConversationId = sanitizeId(raw.targetConversationId || raw.targetSessionId || raw.conversationId || ''); if (!sourceConversationId || !targetConversationId) return null; return { requestId, messageId: String(raw.messageId || '').trim() || null, sourceConversationId, sourceTitle: String(raw.sourceTitle || '').trim() || 'Untitled', targetConversationId, targetTitle: String(raw.targetTitle || '').trim() || 'Untitled', status, createdAt: String(raw.createdAt || '').trim() || new Date().toISOString(), hopCount: Math.max(0, Number.parseInt(String(raw.hopCount || 0), 10) || 0), sourceAutoRun: raw.sourceAutoRun !== false, replyText: truncateTextValue(String(raw.replyText || ''), CROSS_CONVERSATION_MAX_CONTENT_CHARS), completedAt: raw.completedAt || null, returnedAt: raw.returnedAt || null, replyMessageId: raw.replyMessageId || null, lastError: raw.lastError || null, }; } function serializeCrossConversationReplies() { const replies = []; for (const pending of pendingCrossConversationReplies.values()) { const normalized = normalizeCrossConversationReplyState(pending); if (!normalized) continue; if (normalized.status === 'returned') continue; replies.push(normalized); } return { version: 1, updatedAt: new Date().toISOString(), replies, }; } function saveCrossConversationReplies() { try { fs.mkdirSync(CONFIG_DIR, { recursive: true }); writeFileAtomicSync(CROSS_CONVERSATION_REPLIES_PATH, JSON.stringify(serializeCrossConversationReplies(), null, 2)); } catch (err) { plog('WARN', 'cross_conversation_replies_save_failed', { error: err?.message || String(err || ''), }); } } function loadCrossConversationReplies() { try { if (!fs.existsSync(CROSS_CONVERSATION_REPLIES_PATH)) return; const parsed = JSON.parse(fs.readFileSync(CROSS_CONVERSATION_REPLIES_PATH, 'utf8')); const list = Array.isArray(parsed?.replies) ? parsed.replies : []; for (const item of list) { const normalized = normalizeCrossConversationReplyState(item); if (!normalized || normalized.status === 'returned') continue; pendingCrossConversationReplies.set(normalized.requestId, normalized); } } catch (err) { plog('WARN', 'cross_conversation_replies_load_failed', { error: err?.message || String(err || ''), }); } } function setPendingCrossConversationReply(requestId, pending) { const normalized = normalizeCrossConversationReplyState({ ...pending, requestId }); if (!normalized) return null; pendingCrossConversationReplies.set(normalized.requestId, normalized); saveCrossConversationReplies(); return normalized; } function updatePendingCrossConversationReply(requestId, updater) { const existing = pendingCrossConversationReplies.get(String(requestId || '').trim()); if (!existing) return null; const draft = { ...existing }; updater(draft); const normalized = normalizeCrossConversationReplyState(draft); if (!normalized) { pendingCrossConversationReplies.delete(existing.requestId); saveCrossConversationReplies(); return null; } pendingCrossConversationReplies.set(normalized.requestId, normalized); saveCrossConversationReplies(); return normalized; } function deletePendingCrossConversationReply(requestId) { const normalizedRequestId = String(requestId || '').trim(); if (!normalizedRequestId) return false; const deleted = pendingCrossConversationReplies.delete(normalizedRequestId); if (deleted) saveCrossConversationReplies(); return deleted; } function deleteCrossConversationRepliesForSession(sessionId) { const normalizedId = sanitizeId(sessionId || ''); if (!normalizedId) return 0; let deleted = 0; for (const [requestId, pending] of pendingCrossConversationReplies.entries()) { if (pending.sourceConversationId === normalizedId || pending.targetConversationId === normalizedId) { pendingCrossConversationReplies.delete(requestId); deleted += 1; } } if (deleted > 0) saveCrossConversationReplies(); return deleted; } function crossConversationReplySummary(pending = {}) { return { requestId: pending.requestId, status: pending.status, sourceConversationId: pending.sourceConversationId, sourceTitle: pending.sourceTitle || 'Untitled', targetConversationId: pending.targetConversationId, targetTitle: pending.targetTitle || 'Untitled', createdAt: pending.createdAt || null, completedAt: pending.completedAt || null, returnedAt: pending.returnedAt || null, replyMessageId: pending.replyMessageId || null, sourceAutoRun: pending.sourceAutoRun !== false, preview: truncateTextValue(pending.replyText || '', 240), }; } function listCrossConversationRepliesForSource(sourceSessionId, options = {}) { const sourceId = sanitizeId(sourceSessionId || ''); if (!sourceId) return []; const statuses = Array.isArray(options.statuses) && options.statuses.length > 0 ? new Set(options.statuses) : null; const output = []; for (const pending of pendingCrossConversationReplies.values()) { if (pending.sourceConversationId !== sourceId) continue; if (statuses && !statuses.has(pending.status)) continue; output.push(crossConversationReplySummary(pending)); } output.sort((a, b) => new Date(a.completedAt || a.createdAt || 0) - new Date(b.completedAt || b.createdAt || 0)); return output; } function crossConversationWaitState(sessionId) { const replies = listCrossConversationRepliesForSource(sessionId, { statuses: ['waiting', 'ready', 'delivering', 'failed'], }); const readyReplies = replies.filter((reply) => reply.status === 'ready'); const waitingReplies = replies.filter((reply) => reply.status === 'waiting' || reply.status === 'delivering'); const failedReplies = replies.filter((reply) => reply.status === 'failed'); return { waitingOnChildren: replies.length > 0, pendingReplyCount: replies.length, readyReplyCount: readyReplies.length, waitingReplyCount: waitingReplies.length, failedReplyCount: failedReplies.length, pendingReplies: replies, }; } function findCrossConversationReplyInTargetSession(targetSession, requestId) { const normalizedRequestId = String(requestId || '').trim(); const messages = Array.isArray(targetSession?.messages) ? targetSession.messages : []; if (!normalizedRequestId || messages.length === 0) return ''; const requestIndex = messages.findIndex((message) => ( message?.crossConversation?.replyRequestId === normalizedRequestId )); if (requestIndex < 0) return ''; for (let index = messages.length - 1; index > requestIndex; index -= 1) { const message = messages[index]; if (message?.role !== 'assistant') continue; if (message?.ccwebDisplayOnly) continue; const text = extractCrossConversationReplyText(message.content); if (text) return text; } return ''; } function reconcilePendingCrossConversationReplies() { let changed = false; for (const pending of pendingCrossConversationReplies.values()) { if (pending.status !== 'waiting') continue; if (isSessionRunning(pending.targetConversationId)) continue; const targetSession = loadSession(pending.targetConversationId); const replyText = findCrossConversationReplyInTargetSession(targetSession, pending.requestId); if (!replyText) continue; pending.status = 'ready'; pending.replyText = truncateTextValue(replyText, CROSS_CONVERSATION_MAX_CONTENT_CHARS); pending.completedAt = pending.completedAt || new Date().toISOString(); if (targetSession?.title) pending.targetTitle = targetSession.title; changed = true; } if (changed) saveCrossConversationReplies(); for (const pending of pendingCrossConversationReplies.values()) { if (pending.status === 'ready') deliverCrossConversationReply(pending.requestId); } return changed; } 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; const waitState = crossConversationWaitState(meta.id); 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), waitingOnChildren: waitState.waitingOnChildren, pendingReplyCount: waitState.pendingReplyCount, readyReplyCount: waitState.readyReplyCount, waitingReplyCount: waitState.waitingReplyCount, failedReplyCount: waitState.failedReplyCount, 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 sendSessionEventToViewers(sessionId, payload) { const normalizedId = sanitizeId(sessionId || ''); if (!normalizedId) return 0; let sent = 0; for (const [client, viewedSessionId] of wsSessionMap.entries()) { if (viewedSessionId !== normalizedId || client?.readyState !== 1) continue; wsSend(client, payload); sent += 1; } return sent; } function getInternalMcpRequestToken(req) { return String(req.headers['x-cc-web-mcp-token'] || '').trim() || extractBearerToken(req); } 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 = '') { reconcilePendingCrossConversationReplies(); 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; const waitState = crossConversationWaitState(meta.id); 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, waitingOnChildren: waitState.waitingOnChildren, pendingReplyCount: waitState.pendingReplyCount, readyReplyCount: waitState.readyReplyCount, waitingReplyCount: waitState.waitingReplyCount, }); } } catch {} conversations.sort(compareSessionsForList); const sourceWaitState = crossConversationWaitState(sourceSessionId); return { ok: true, currentConversationId: sourceSessionId || null, waitingOnChildren: sourceWaitState.waitingOnChildren, pendingReplyCount: sourceWaitState.pendingReplyCount, readyReplyCount: sourceWaitState.readyReplyCount, waitingReplyCount: sourceWaitState.waitingReplyCount, pendingReplies: sourceWaitState.pendingReplies, conversations: conversations.slice(0, limit), }; } function normalizeMcpPromptText(value, maxChars) { if (value === null || value === undefined) return ''; return truncateTextValue(String(value).trim(), maxChars, '...'); } function uniqueMcpPromptId(value, fallback, used) { const base = normalizeMcpPromptText(value, 80) .replace(/\s+/g, '_') .replace(/[^\w.-]/g, '') || fallback; let id = base; let index = 2; while (used.has(id)) { id = `${base}_${index}`; index += 1; } used.add(id); return id; } function normalizeMcpPromptOption(rawOption, index, usedIds) { const raw = rawOption && typeof rawOption === 'object' ? rawOption : {}; const label = normalizeMcpPromptText(raw.label ?? raw.title ?? raw.value ?? raw.answerText, 240); const answerText = normalizeMcpPromptText(raw.answerText ?? raw.answer ?? label, MCP_PROMPT_ANSWER_MAX_CHARS); if (!label && !answerText) return null; const id = uniqueMcpPromptId(raw.id ?? raw.value ?? label, `option_${index + 1}`, usedIds); return { id, label: label || answerText.slice(0, 80) || `选项 ${index + 1}`, description: normalizeMcpPromptText(raw.description ?? raw.desc, MCP_PROMPT_OPTION_MAX_CHARS), answerText, recommended: raw.recommended === true, }; } function normalizeMcpPromptQuestion(rawQuestion, index, usedIds) { const raw = rawQuestion && typeof rawQuestion === 'object' ? rawQuestion : {}; const title = normalizeMcpPromptText(raw.title ?? raw.header, MCP_PROMPT_TITLE_MAX_CHARS); const question = normalizeMcpPromptText(raw.question ?? raw.prompt ?? raw.text, MCP_PROMPT_QUESTION_MAX_CHARS); if (!title && !question) return null; const id = uniqueMcpPromptId(raw.id, `question_${index + 1}`, usedIds); const rawOptions = Array.isArray(raw.options) ? raw.options : []; const optionIds = new Set(); const options = rawOptions .slice(0, MCP_PROMPT_OPTION_MAX_COUNT) .map((option, optionIndex) => normalizeMcpPromptOption(option, optionIndex, optionIds)) .filter(Boolean); const mode = String(raw.selectionMode || raw.mode || '').trim(); const selectionMode = mode === 'multi' || mode === 'multiple' ? 'multi' : mode === 'none' || options.length === 0 ? 'none' : 'single'; return { id, title: title || `问题 ${index + 1}`, question, required: raw.required !== false, selectionMode, answerPlaceholder: normalizeMcpPromptText(raw.answerPlaceholder ?? raw.placeholder, 240), defaultAnswer: normalizeMcpPromptText(raw.defaultAnswer ?? raw.answerText, MCP_PROMPT_ANSWER_MAX_CHARS), options, }; } function normalizeCcwebPromptUserArgs(args = {}) { const rawQuestions = Array.isArray(args.questions) ? args.questions : []; const usedQuestionIds = new Set(); const questions = rawQuestions .slice(0, MCP_PROMPT_QUESTION_MAX_COUNT) .map((question, index) => normalizeMcpPromptQuestion(question, index, usedQuestionIds)) .filter(Boolean); if (questions.length === 0) { return mcpToolError('missing_questions', 'ccweb_prompt_user 需要至少一个有效问题。'); } return { ok: true, title: normalizeMcpPromptText(args.title, MCP_PROMPT_TITLE_MAX_CHARS) || '需要用户确认', description: normalizeMcpPromptText(args.description ?? args.instructions, MCP_PROMPT_DESCRIPTION_MAX_CHARS), questions, }; } function createCcwebPromptUser(args = {}, sourceSessionId = '') { const sourceId = sanitizeId(sourceSessionId || ''); if (!sourceId) return mcpToolError('missing_source_conversation', '缺少来源对话 ID。'); const session = loadSession(sourceId); if (!session) return mcpToolError('source_not_found', '来源对话不存在。', { sourceConversationId: sourceId }); const normalized = normalizeCcwebPromptUserArgs(args); if (!normalized.ok) return normalized; const now = new Date().toISOString(); const promptId = crypto.randomUUID(); const prompt = { id: promptId, status: 'pending', title: normalized.title, description: normalized.description, questions: normalized.questions, answers: {}, createdAt: now, submittedAt: null, }; const message = { role: 'assistant', content: '', timestamp: now, ccwebPrompt: prompt, }; session.messages = Array.isArray(session.messages) ? session.messages : []; session.messages.push(message); session.updated = now; if (!findViewingSessionWs(sourceId)) session.hasUnread = true; saveSession(session); sendSessionEventToViewers(sourceId, { type: 'session_message', sessionId: sourceId, message, }); broadcastSessionList(); return { ok: true, promptId, status: 'rendered', sourceConversationId: sourceId, questionCount: normalized.questions.length, message: '已在 ccweb 前台展示问题,等待用户提交。', }; } function findCcwebPromptMessage(session, promptId) { const normalizedPromptId = String(promptId || '').trim(); if (!normalizedPromptId || !Array.isArray(session?.messages)) return null; for (let index = session.messages.length - 1; index >= 0; index -= 1) { const message = session.messages[index]; if (message?.ccwebPrompt?.id === normalizedPromptId) { return { message, index, prompt: message.ccwebPrompt }; } } return null; } function removeCcwebPromptMessage(session, promptId) { const found = findCcwebPromptMessage(session, promptId); if (!found || !Array.isArray(session?.messages)) return null; session.messages.splice(found.index, 1); return found; } function selectedPromptOptionIds(rawAnswer, question) { const raw = rawAnswer && typeof rawAnswer === 'object' ? rawAnswer : {}; const source = Array.isArray(raw.selectedOptionIds) ? raw.selectedOptionIds : Array.isArray(raw.selectedOptions) ? raw.selectedOptions : Array.isArray(raw.optionIds) ? raw.optionIds : raw.selectedOptionId || raw.selectedOption || raw.optionId ? [raw.selectedOptionId || raw.selectedOption || raw.optionId] : []; const valid = new Set((question.options || []).map((option) => option.id)); const ids = source.map((item) => String(item || '').trim()).filter((item) => valid.has(item)); if (question.selectionMode !== 'multi') return ids.slice(0, 1); return [...new Set(ids)]; } function normalizeCcwebPromptUserAnswers(prompt, rawAnswers = {}) { const raw = rawAnswers && typeof rawAnswers === 'object' ? rawAnswers : {}; const answers = {}; const answerList = []; for (const question of prompt.questions || []) { const rawAnswer = raw[question.id] && typeof raw[question.id] === 'object' ? raw[question.id] : {}; const selectedOptionIds = question.selectionMode === 'none' ? [] : selectedPromptOptionIds(rawAnswer, question); const selectedOptions = selectedOptionIds .map((id) => (question.options || []).find((option) => option.id === id)) .filter(Boolean); let answerText = normalizeMcpPromptText( rawAnswer.answerText ?? rawAnswer.answer ?? rawAnswer.text ?? '', MCP_PROMPT_ANSWER_MAX_CHARS ); if (!answerText && selectedOptions.length > 0) { answerText = selectedOptions.map((option) => option.answerText || option.label).filter(Boolean).join('\n'); } if (!answerText && question.defaultAnswer) answerText = question.defaultAnswer; if (question.required && !answerText) { return mcpToolError('missing_answer', `问题「${question.title || question.id}」需要填写答案。`, { promptId: prompt.id, questionId: question.id, }); } const answer = { questionId: question.id, selectedOptionIds, selectedOptionLabels: selectedOptions.map((option) => option.label), answerText, }; answers[question.id] = answer; answerList.push({ question, answer }); } return { ok: true, answers, answerList }; } function buildCcwebPromptUserResponseText(prompt, answerList) { const lines = ['我已回答 ccweb 提示的问题:']; if (prompt.title) { lines.push('', `表单:${prompt.title}`); } answerList.forEach(({ question, answer }, index) => { lines.push('', `${index + 1}. ${question.title || question.id}`); if (question.question) lines.push(`问题:${question.question}`); if (answer.selectedOptionLabels.length > 0) { lines.push(`选择:${answer.selectedOptionLabels.join(',')}`); } lines.push(`答案:${answer.answerText || '(空)'}`); }); return truncateTextValue(lines.join('\n'), MCP_PROMPT_RESPONSE_MAX_CHARS); } function createMcpConversation(args = {}, sourceSessionId = '', sourceHopCount = 0) { const sourceId = sanitizeId(sourceSessionId || ''); const normalizedHopCount = Math.max(0, Number.parseInt(String(sourceHopCount || 0), 10) || 0); 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 createArgs = { ...args }; delete createArgs.agent; const created = createPersistentConversationSession(createArgs, { sourceSession, strict: true, requireAbsoluteCwd: true, inheritSourceMode: false, defaultMode: 'yolo', 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 buildCrossConversationReplyAutoRunText(targetSession, replyText) { const targetTitle = targetSession?.title || 'Untitled'; const body = truncateTextValue(replyText, CROSS_CONVERSATION_MAX_CONTENT_CHARS); return `子对话「${targetTitle}」已返回。请基于以下返回继续当前任务:\n\n${body}`; } function startCrossConversationReplyAutoRun(sourceSession, targetSession, replyText, crossConversation) { if (!sourceSession?.id) return false; const runtimeText = buildCrossConversationReplyAutoRunText(targetSession, replyText); const sourceWs = findViewingSessionWs(sourceSession.id); const result = handleMessage(sourceWs, { text: runtimeText, sessionId: sourceSession.id, mode: sourceSession.permissionMode || 'yolo', agent: getSessionAgent(sourceSession), }, { hideInHistory: true, runtimeText, crossConversation, mcpContext: { hopCount: crossConversation?.hopCount || 0 }, skipPendingCrossConversationFlush: true, }); if (!result?.ok) { plog('WARN', 'cross_conversation_reply_autorun_failed', { sourceSessionId: sourceSession.id.slice(0, 8), targetSessionId: targetSession?.id ? targetSession.id.slice(0, 8) : null, requestId: crossConversation?.replyToRequestId || null, code: result?.code || null, message: result?.message || null, }); return false; } return true; } 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; const sourceAutoRun = expectReply && options.sourceAutoRun !== false; 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; setPendingCrossConversationReply(requestId, { requestId, messageId, sourceConversationId: sourceId, sourceTitle: sourceSession.title || 'Untitled', targetConversationId: targetId, targetTitle: targetSession.title || 'Untitled', status: 'waiting', createdAt: now, hopCount: crossConversation.hopCount, sourceAutoRun, 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) deletePendingCrossConversationReply(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: sourceAutoRun ? 'auto_run' : 'display_only', sourceAutoRun, } : {}), }; } function requestCrossConversationReply(args = {}, sourceSessionId = '', sourceHopCount = 0) { return sendCrossConversationMessage(args, sourceSessionId, sourceHopCount, { expectReply: true }); } function listPendingCrossConversationReplies(args = {}, sourceSessionId = '') { reconcilePendingCrossConversationReplies(); const sourceId = sanitizeId(sourceSessionId || ''); if (!sourceId) return mcpToolError('missing_source_conversation', '缺少来源对话 ID。'); const status = String(args.status || 'all').trim(); const statuses = status && status !== 'all' ? [status] : ['waiting', 'ready', 'delivering', 'failed']; const validStatuses = statuses.filter((item) => ['waiting', 'ready', 'delivering', 'returned', 'failed'].includes(item)); const replies = listCrossConversationRepliesForSource(sourceId, { statuses: validStatuses.length > 0 ? validStatuses : ['waiting', 'ready', 'delivering', 'failed'], }); return { ok: true, sourceConversationId: sourceId, waitingOnChildren: replies.length > 0, pendingReplyCount: replies.length, readyReplyCount: replies.filter((reply) => reply.status === 'ready').length, replies, }; } function getPendingCrossConversationReply(args = {}, sourceSessionId = '') { reconcilePendingCrossConversationReplies(); const sourceId = sanitizeId(sourceSessionId || ''); const requestId = String(args.requestId || '').trim(); if (!sourceId) return mcpToolError('missing_source_conversation', '缺少来源对话 ID。'); if (!requestId) return mcpToolError('missing_request_id', '缺少 requestId。'); const pending = pendingCrossConversationReplies.get(requestId); if (!pending || pending.sourceConversationId !== sourceId) { const sourceSession = loadSession(sourceId); if (hasProcessedCrossConversationReply(sourceSession, requestId)) { const message = sourceSession.messages.find((item) => item?.crossConversation?.replyToRequestId === requestId); return { ok: true, requestId, status: 'returned', returned: true, replyText: extractCrossConversationReplyText(message?.content || ''), message: message || null, }; } return mcpToolError('reply_not_found', '未找到该跨对话等待回复。', { requestId }); } return { ok: true, ...crossConversationReplySummary(pending), returned: pending.status === 'returned', replyText: pending.replyText || '', }; } 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) { updatePendingCrossConversationReply(requestId, (draft) => { draft.status = 'failed'; draft.lastError = sourceSession ? 'target_not_found' : 'source_not_found'; }); broadcastSessionList(); return false; } if (isSessionRunning(sourceSession.id)) { broadcastSessionList(); return false; } if (hasProcessedCrossConversationReply(sourceSession, requestId)) { updatePendingCrossConversationReply(requestId, (draft) => { draft.status = 'returned'; draft.returnedAt = draft.returnedAt || new Date().toISOString(); }); deletePendingCrossConversationReply(requestId); return true; } const now = new Date().toISOString(); const replyMessageId = crypto.randomUUID(); const replyContent = buildCrossConversationReplyContent(targetSession, pending.replyText); const sourceAutoRun = pending.sourceAutoRun !== false; 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: sourceAutoRun, }; updatePendingCrossConversationReply(requestId, (draft) => { draft.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, }); } updatePendingCrossConversationReply(requestId, (draft) => { draft.status = 'returned'; draft.returnedAt = now; draft.replyMessageId = replyMessageId; }); deletePendingCrossConversationReply(requestId); broadcastSessionList(); if (sourceAutoRun) { startCrossConversationReplyAutoRun(sourceSession, targetSession, pending.replyText, crossConversation); } 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 = '(目标对话没有返回可用文本。)'; } updatePendingCrossConversationReply(normalizedRequestId, (draft) => { draft.status = 'ready'; draft.replyText = truncateTextValue(replyText, CROSS_CONVERSATION_MAX_CONTENT_CHARS); draft.completedAt = new Date().toISOString(); if (targetSession?.title) draft.targetTitle = targetSession.title; }); broadcastSessionList(); 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_list_pending_replies': return listPendingCrossConversationReplies(args, sourceSessionId); case 'ccweb_get_pending_reply': return getPendingCrossConversationReply(args, sourceSessionId); case 'ccweb_request_reply': return requestCrossConversationReply(args, sourceSessionId, sourceHopCount); case 'ccweb_prompt_user': return createCcwebPromptUser(args, sourceSessionId); default: return mcpToolError('unknown_tool', `未知 MCP 工具: ${tool}`); } } function mcpJsonRpcResult(id, result) { return { jsonrpc: '2.0', id, result }; } function mcpJsonRpcError(id, code, message, data) { const error = { code, message }; if (data !== undefined) error.data = data; return { jsonrpc: '2.0', id, error }; } function mcpToolResponse(payload) { const text = JSON.stringify(payload, null, 2); return { content: [{ type: 'text', text }], structuredContent: payload, isError: !payload?.ok, }; } function handleMcpJsonRpcMessage(message, context = {}) { const hasId = Object.prototype.hasOwnProperty.call(message || {}, 'id'); if (!hasId) return null; const id = message.id; const method = String(message?.method || ''); try { switch (method) { case 'initialize': return mcpJsonRpcResult(id, { protocolVersion: message.params?.protocolVersion || '2024-11-05', capabilities: { tools: {} }, serverInfo: CCWEB_MCP_SERVER_INFO, }); case 'ping': return mcpJsonRpcResult(id, {}); case 'tools/list': return mcpJsonRpcResult(id, { tools: CCWEB_MCP_TOOLS }); case 'tools/call': { const name = String(message.params?.name || ''); const args = message.params?.arguments || {}; if (!CCWEB_MCP_TOOLS.some((tool) => tool.name === name)) { return mcpJsonRpcResult(id, mcpToolResponse(mcpToolError('unknown_tool', `未知工具: ${name}`))); } const payload = callInternalMcpTool( name, args, context.sourceSessionId || '', context.sourceHopCount || 0, ); return mcpJsonRpcResult(id, mcpToolResponse(payload)); } case 'resources/list': return mcpJsonRpcResult(id, { resources: [] }); case 'prompts/list': return mcpJsonRpcResult(id, { prompts: [] }); default: return mcpJsonRpcError(id, -32601, `Method not found: ${method}`); } } catch (err) { return mcpJsonRpcError(id, -32603, err?.message || 'Internal error'); } } async function handleSharedMcpHttpApi(req, res, url) { if (req.method !== 'POST') { res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-cache', Allow: 'POST', }); res.end(JSON.stringify(mcpJsonRpcError(null, -32600, 'Only POST is supported.'))); return; } const token = getInternalMcpRequestToken(req); if (!token || token !== INTERNAL_MCP_TOKEN) { return jsonResponse(res, 401, mcpJsonRpcError(null, -32001, 'MCP 内部接口未授权。')); } let payload; try { payload = await readJsonBody(req); } catch (err) { return jsonResponse(res, 400, mcpJsonRpcError(null, -32700, err?.message || '请求体无效。')); } const context = { sourceSessionId: sanitizeId(url.searchParams.get('sourceSessionId') || ''), sourceHopCount: Number.parseInt(String(url.searchParams.get('sourceHopCount') || 0), 10) || 0, }; const messages = Array.isArray(payload) ? payload : [payload]; const responses = messages .map((message) => handleMcpJsonRpcMessage(message, context)) .filter(Boolean); if (responses.length === 0) { res.writeHead(202, { 'Cache-Control': 'no-cache' }); res.end(); return; } return jsonResponse(res, 200, Array.isArray(payload) ? responses : responses[0]); } 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 (isCodexTransientCapacityError(condensed)) { return 'Codex 服务暂时繁忙或所选模型容量不足。cc-web 已自动重试,仍未成功;请稍后再试或临时切换模型。'; } if (isCodexTransientConnectionError(condensed)) { return 'Codex App 连接暂时中断。cc-web 已自动重试,仍未成功;请稍后再试或检查网络代理。'; } 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 isCodexTransientCapacityError(raw) { const text = String(raw || ''); if (!text) return false; return /server_is_overloaded|service_unavailable_error|ServiceUnavailableError|servers?\s+(?:are\s+)?(?:currently\s+)?overloaded|server\s+is\s+overloaded|model\s+is\s+at\s+capacity|selected model is at capacity|model.*overloaded|503\b.*(?:overloaded|unavailable)|temporarily unavailable|please try again later/i.test(text); } function isCodexTransientConnectionError(raw) { const text = String(raw || ''); if (!text) return false; return /reconnecting(?:\.\.\.)?\s*\d+\/\d+|connection\s+(?:lost|closed|reset|refused|interrupted)|disconnect(?:ed|ion)|ECONNRESET|ECONNREFUSED|EPIPE|ETIMEDOUT|ENOTFOUND|fetch failed|network.*(?:error|failed)|TLS connection.*terminated/i.test(text); } function isCodexTransientRetryableError(raw) { return isCodexTransientCapacityError(raw) || isCodexTransientConnectionError(raw); } function hasRuntimeOutput(entry) { if ((entry.fullText || '').trim()) return true; if (Array.isArray(entry.contentBlocks) && entry.contentBlocks.length > 0) return true; return Array.isArray(entry.toolCalls) && entry.toolCalls.length > 0; } function retryTailText(value, maxChars) { const text = String(value || '').trim(); if (!text || text.length <= maxChars) return text; const marker = '[cc-web: 前文过长,下面仅保留尾部]\n'; return `${marker}${text.slice(Math.max(0, text.length - Math.max(0, maxChars - marker.length)))}`; } function retryPreviewValue(value, maxChars) { if (value === null || value === undefined) return ''; if (typeof value === 'string') return retryTailText(value, maxChars); try { return retryTailText(JSON.stringify(sanitizePersistValue(value, { maxString: maxChars, maxDepth: 4, maxArray: 20, maxKeys: 40, }), null, 2), maxChars); } catch { return retryTailText(String(value), maxChars); } } function buildCodexRetryToolSummary(toolCalls) { const list = Array.isArray(toolCalls) ? toolCalls.filter(Boolean).slice(-8) : []; if (list.length === 0) return ''; return list.map((tool, index) => { const name = tool.name || tool.kind || tool.id || `tool-${index + 1}`; const status = tool.done ? 'done' : (tool.status || tool.meta?.status || 'inProgress'); const lines = [`${index + 1}. ${name} (${status})`]; if (tool.input !== undefined && tool.input !== null) { lines.push(` input: ${retryPreviewValue(tool.input, 1200)}`); } if (tool.result !== undefined && tool.result !== null) { lines.push(` result: ${retryPreviewValue(tool.result, 2400)}`); } return lines.join('\n'); }).join('\n'); } function shouldUseCodexAppContinuationRetry(entry) { return (entry?.agent || '') === 'codexapp' && !!(entry.turnId || hasRuntimeOutput(entry)); } function buildCodexAppContinuationRetryText(entry, retryRequest, rawError) { const original = retryPreviewValue( retryRequest.originalRuntimeText || retryRequest.runtimeText || retryRequest.originalText || retryRequest.text || '', 5000, ); const partialText = retryPreviewValue(entry.fullText || '', 7000); const toolSummary = buildCodexRetryToolSummary(entry.toolCalls || []); const errorText = retryPreviewValue(rawError || entry.lastError || '', 1200); const parts = [ '继续上一轮被临时服务或网络错误中断的 Codex App 任务。', '不要从头重做,不要重复已经完成的命令、工具调用或文件修改;请基于现有线程上下文和下面 cc-web 已观察到的中断前状态继续执行。不要在回复中复述这段内部重试说明。', ]; if (original) { parts.push(`原始用户请求(仅用于理解目标,不要当成新请求从头执行):\n${original}`); } if (partialText) { parts.push(`cc-web 已观察到的中断前助手输出(尾部):\n${partialText}`); } if (toolSummary) { parts.push(`cc-web 已观察到的工具/执行摘要:\n${toolSummary}`); } if (errorText) { parts.push(`中断原因:\n${errorText}`); } parts.push('请从中断处继续完成剩余工作。'); return parts.filter(Boolean).join('\n\n'); } function getCodexRetryConfig() { return normalizeCodexRetryConfig(loadCodexConfig().retry); } function codexTransientRetryDelayMs(config) { return Math.max(0, (config?.intervalSeconds || DEFAULT_CODEX_CONFIG.retry.intervalSeconds) * 1000); } function cancelCodexCapacityRetry(sessionId) { const retry = pendingCodexCapacityRetries.get(sessionId); if (!retry) return false; if (retry.timer) clearTimeout(retry.timer); pendingCodexCapacityRetries.delete(sessionId); return true; } function shouldRetryCodexTransientFailure(entry, rawError, context = {}) { if (!['codex', 'codexapp'].includes(entry.agent || 'claude')) return false; if (!rawError || !isCodexTransientRetryableError(rawError)) return false; if (getCodexRetryConfig().mode === 'off') return false; if (context.contextLimitExceeded || context.pendingSlash) return false; if (entry.crossConversationReplyRequestId) return false; if ((entry.agent || 'claude') !== 'codexapp' && hasRuntimeOutput(entry)) return false; return !!(entry.retryRequest?.text || entry.retryRequest?.runtimeText); } function scheduleCodexCapacityRetry(sessionId, entry, rawError) { const retryRequest = entry.retryRequest || {}; const previous = pendingCodexCapacityRetries.get(sessionId) || null; const retryConfig = getCodexRetryConfig(); if (retryConfig.mode === 'off') { cancelCodexCapacityRetry(sessionId); return false; } const attempts = (previous?.attempts || entry.codexRetry?.attempt || 0) + 1; if (retryConfig.mode === 'limited' && attempts > retryConfig.maxAttempts) { cancelCodexCapacityRetry(sessionId); return false; } const delayMs = codexTransientRetryDelayMs(retryConfig); if (previous?.timer) clearTimeout(previous.timer); const expectedThreadId = retryRequest.expectedThreadId || entry.expectedThreadId || entry.codexRetry?.expectedThreadId || entry.threadId || previous?.expectedThreadId || null; const originalText = retryRequest.originalText || retryRequest.text || retryRequest.runtimeText || ''; const originalRuntimeText = retryRequest.originalRuntimeText || retryRequest.runtimeText || retryRequest.text || ''; const useContinuationRetry = shouldUseCodexAppContinuationRetry(entry); const continuationText = useContinuationRetry ? buildCodexAppContinuationRetryText(entry, retryRequest, rawError) : ''; const retryRuntimeText = continuationText || originalRuntimeText || originalText; const retryText = retryRuntimeText || originalText; const retry = { text: retryText, runtimeText: retryRuntimeText, originalText, originalRuntimeText, mode: retryRequest.mode || 'yolo', agent: retryRequest.agent || entry.agent || 'codex', attachments: useContinuationRetry ? [] : (Array.isArray(retryRequest.attachments) ? retryRequest.attachments : []), mcpContext: retryRequest.mcpContext || {}, expectedThreadId, useContinuationRetry, attempts, retryMode: retryConfig.mode, timer: null, ws: entry.ws || null, }; retry.timer = setTimeout(() => { const latest = pendingCodexCapacityRetries.get(sessionId); if (!latest || latest.timer !== retry.timer) return; latest.timer = null; const session = loadSession(sessionId); if (!session) { pendingCodexCapacityRetries.delete(sessionId); return; } if (activeProcesses.has(sessionId) || activeCodexAppTurns.has(sessionId)) { pendingCodexCapacityRetries.delete(sessionId); plog('WARN', 'codex_capacity_retry_skipped_busy', { sessionId: sessionId.slice(0, 8), attempt: latest.attempts, }); if (latest.ws && latest.ws.readyState === 1) sendSessionList(latest.ws); return; } pendingCodexCapacityRetries.delete(sessionId); const ws = latest.ws && latest.ws.readyState === 1 ? latest.ws : null; plog('INFO', 'codex_capacity_retry_start', { sessionId: sessionId.slice(0, 8), attempt: latest.attempts, expectedThreadId: latest.expectedThreadId ? String(latest.expectedThreadId).slice(0, 24) : null, continuation: !!latest.useContinuationRetry, }); handleMessage(ws, { type: 'message', text: latest.text, sessionId, mode: latest.mode, agent: latest.agent || 'codex', attachments: latest.attachments, }, { hideInHistory: true, runtimeText: latest.runtimeText, mcpContext: latest.mcpContext, codexRetry: latest.agent === 'codexapp' ? { isAutoRetry: true, attempt: latest.attempts, retryMode: latest.retryMode, expectedThreadId: latest.expectedThreadId || null, originalText: latest.originalText || latest.text || '', originalRuntimeText: latest.originalRuntimeText || latest.runtimeText || '', useContinuationRetry: !!latest.useContinuationRetry, } : null, skipPendingCrossConversationFlush: true, }); }, delayMs); pendingCodexCapacityRetries.set(sessionId, retry); plog('WARN', 'codex_capacity_retry_scheduled', { sessionId: sessionId.slice(0, 8), attempt: attempts, maxAttempts: retryConfig.mode === 'limited' ? retryConfig.maxAttempts : null, retryMode: retryConfig.mode, delayMs, expectedThreadId: expectedThreadId ? String(expectedThreadId).slice(0, 24) : null, continuation: useContinuationRetry, error: String(rawError || '').slice(0, 300), }); if (entry.ws) { const attemptText = retryConfig.mode === 'forever' ? `第 ${attempts} 次` : `第 ${attempts}/${retryConfig.maxAttempts} 次`; const continuationText = useContinuationRetry ? ',将从中断处继续' : ''; wsSend(entry.ws, { type: 'system_message', sessionId, message: `Codex 服务暂时繁忙,${retryConfig.intervalSeconds} 秒后自动重试(${attemptText}${continuationText})。`, }); } return true; } function handleProcessComplete(sessionId, exitCode, signal) { const entry = activeProcesses.get(sessionId); if (!entry) return; if (entry.pidMonitorCompleteTimer) { clearTimeout(entry.pidMonitorCompleteTimer); entry.pidMonitorCompleteTimer = null; } 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; // 先读完剩余 JSONL,再判定错误类型,避免退出监控早于 tailer 造成漏判。 if (entry.tailer) { entry.tailer.readNew(); entry.tailer.stop(); } // 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, }); const pendingSlash = pendingSlashCommands.get(sessionId) || null; if (pendingSlash) pendingSlashCommands.delete(sessionId); // Save result to session const session = loadSession(sessionId); if (session && shouldRetryCodexTransientFailure(entry, rawCompletionError, { contextLimitExceeded, pendingSlash })) { activeProcesses.delete(sessionId); cleanRunDir(sessionId); pendingSlashCommands.delete(sessionId); if (scheduleCodexCapacityRetry(sessionId, entry, rawCompletionError)) { return; } } if (!shouldRetryCodexTransientFailure(entry, rawCompletionError, { contextLimitExceeded, pendingSlash })) { cancelCodexCapacityRetry(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)) { if (entry.pidMonitorCompleteTimer) continue; plog('INFO', 'pid_monitor_detected_exit', { sessionId: sessionId.slice(0, 8), pid: entry.pid, wsConnected: !!entry.ws, }); entry.pidMonitorCompleteTimer = setTimeout(() => { const latest = activeProcesses.get(sessionId); if (!latest || latest.pid !== entry.pid) return; latest.pidMonitorCompleteTimer = null; handleProcessComplete(sessionId, null, 'unknown (detected by monitor)'); }, 1000); } } }, 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 (url.pathname === '/api/internal/mcp/stream') { return handleSharedMcpHttpApi(req, res, url); } 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 }); const WS_HEARTBEAT_INTERVAL_MS = 30000; 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(); ws.isAlive = true; ws._ccWebId = wsId; plog('INFO', 'ws_connect', { wsId }); ws.on('pong', () => { ws.isAlive = true; }); 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); 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_prompt_user_response': handleCcwebPromptUserResponse(ws, msg); break; case 'ccweb_prompt_user_dismiss': handleCcwebPromptUserDismiss(ws, msg); break; case 'codex_app_approval_response': handleCodexAppApprovalResponse(ws, msg); break; 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, msg); 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); }); }); // WebSocket 心跳:避免反向代理因空闲连接关闭 /ws。 const wsHeartbeatTimer = setInterval(() => { for (const client of wss.clients) { if (client.isAlive === false) { client.terminate(); continue; } client.isAlive = false; if (client.readyState === 1) { try { client.ping(); } catch (err) { plog('WARN', 'ws_ping_failed', { wsId: client._ccWebId || null, error: err.message }); client.terminate(); } } } }, WS_HEARTBEAT_INTERVAL_MS); if (typeof wsHeartbeatTimer.unref === 'function') wsHeartbeatTimer.unref(); // === 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 retry = normalizeCodexRetryConfig(newConfig.retry); const merged = { mode: newConfig.mode === 'custom' ? 'custom' : 'local', activeProfile: String(newConfig.activeProfile || '').trim(), profiles: mergedProfiles, enableSearch: false, supportsSearch: false, storedEnableSearch: requestedSearch, retry, }; 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, retryMode: retry.mode, retryIntervalSeconds: retry.intervalSeconds, retryMaxAttempts: retry.mode === 'limited' ? retry.maxAttempts : null, }); 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(); } function parseCodexGoalCommand(text) { const match = /^\s*\/goal(?:\s+([\s\S]*))?$/i.exec(String(text || '')); if (!match) return null; const rest = String(match[1] || '').trim(); if (!rest) return { action: 'show' }; const lower = rest.toLowerCase(); if (lower === 'clear') return { action: 'clear' }; if (lower === 'pause') return { action: 'pause' }; if (lower === 'resume') return { action: 'resume' }; if ([...rest].length > MAX_CODEX_GOAL_OBJECTIVE_CHARS) { return { action: 'set', error: `Goal 目标最多 ${MAX_CODEX_GOAL_OBJECTIVE_CHARS} 个字符。`, }; } return { action: 'set', objective: rest }; } function goalNumber(value) { if (typeof value === 'number' && Number.isFinite(value)) return value; if (typeof value === 'string' && value.trim()) { const parsed = Number(value); if (Number.isFinite(parsed)) return parsed; } return null; } function goalString(value) { if (value === null || value === undefined) return ''; return String(value).trim(); } function normalizeCodexThreadGoal(goal, fallbackThreadId = '') { if (!goal || typeof goal !== 'object') return null; const threadId = goalString(goal.threadId || goal.thread_id || fallbackThreadId); const objective = goalString(goal.objective); const rawStatus = goalString(goal.status) || 'active'; return { ...goal, threadId, objective, status: rawStatus, tokenBudget: goalNumber(goal.tokenBudget ?? goal.token_budget), tokensUsed: goalNumber(goal.tokensUsed ?? goal.tokens_used) || 0, timeUsedSeconds: goalNumber(goal.timeUsedSeconds ?? goal.time_used_seconds) || 0, createdAt: goalNumber(goal.createdAt ?? goal.created_at) || 0, updatedAt: goalNumber(goal.updatedAt ?? goal.updated_at) || 0, }; } function formatCodexGoalStatus(status) { const normalized = String(status || 'active').trim(); const compact = normalized.toLowerCase().replace(/[\s_-]/g, ''); if (compact === 'budgetlimited') return 'budget limited'; if (compact === 'complete' || compact === 'completed') return 'complete'; if (compact === 'paused') return 'paused'; if (compact === 'active') return 'active'; if (compact === 'blocked') return 'blocked'; return normalized || 'updated'; } function formatCodexGoalUsage(goal) { const normalized = normalizeCodexThreadGoal(goal); if (!normalized) return 'Goal updated'; const parts = [`Goal ${formatCodexGoalStatus(normalized.status)}`]; if (normalized.tokenBudget !== null) { parts.push(`${normalized.tokensUsed}/${normalized.tokenBudget} tokens`); } else if (normalized.tokensUsed > 0) { parts.push(`${normalized.tokensUsed} tokens`); } const objective = normalized.objective ? `\n目标: ${normalized.objective}` : ''; return `${parts.join(' · ')}${objective}`; } async function ensureCodexAppGoalThread(session) { 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); 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。'); setRuntimeSessionId(session, threadId); session.updated = new Date().toISOString(); saveSession(session); return { client, threadId }; } function isCodexGoalUnsupportedError(err) { const detail = `${err?.code || ''} ${err?.message || err || ''}`; return err?.code === -32601 || /goals feature is disabled|unsupported remote app-server request|method not found|unknown mock method/i.test(detail); } function isCurrentCodexAppGoalCommand(sessionId, entry) { return !!entry && activeCodexAppGoalCommands.get(sessionId)?.id === entry.id && !entry.cancelled; } function finishCodexAppGoalCommand(sessionId, entry) { if (!entry || activeCodexAppGoalCommands.get(sessionId)?.id !== entry.id) return false; activeCodexAppGoalCommands.delete(sessionId); broadcastSessionList(); return true; } function cancelCodexAppGoalCommand(sessionId, ws = null) { const entry = activeCodexAppGoalCommands.get(sessionId); if (!entry) return false; entry.cancelled = true; activeCodexAppGoalCommands.delete(sessionId); const targetWs = ws || entry.ws || null; if (targetWs) { wsSend(targetWs, { type: 'system_message', sessionId, message: '已取消 Goal 同步状态。底层 Codex app-server 请求可能仍会自然返回,结果将被忽略。', }); } broadcastSessionList(); return true; } async function handleCodexAppGoalSlashCommand(ws, text, session) { const command = parseCodexGoalCommand(text); if (!command) return; if (!session) { wsSend(ws, { type: 'system_message', message: '请先进入一个 Codex App 会话后再执行 /goal。' }); return; } if (!isCodexAppSession(session)) { wsSend(ws, { type: 'system_message', message: '当前 /goal 仅支持 Codex App 会话。旧 Codex/Claude 会话没有 app-server goal RPC。' }); return; } if (command.error) { wsSend(ws, { type: 'system_message', sessionId: session.id, message: command.error }); return; } if (activeCodexAppGoalCommands.has(session.id)) { wsSend(ws, { type: 'system_message', sessionId: session.id, message: 'Codex App Goal 正在同步,请稍候。' }); return; } const activeGoalCommand = { id: crypto.randomUUID(), ws, action: command.action, cancelled: false, startedAt: new Date().toISOString(), }; activeCodexAppGoalCommands.set(session.id, activeGoalCommand); wsSend(ws, { type: 'system_message', sessionId: session.id, message: '正在同步 Goal...' }); broadcastSessionList(); try { const { client, threadId } = await ensureCodexAppGoalThread(session); if (!isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) return; if (command.action === 'show') { const response = await client.request('thread/goal/get', { threadId }, 30000); if (!isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) return; const goal = normalizeCodexThreadGoal(response?.goal, threadId); const targetWs = activeGoalCommand.ws || ws; wsSend(targetWs, { type: 'system_message', sessionId: session.id, message: goal ? formatCodexGoalUsage(goal) : '用法: /goal <目标描述>', }); sendSessionList(targetWs); return; } if (command.action === 'clear') { const response = await client.request('thread/goal/clear', { threadId }, 30000); if (!isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) return; const targetWs = activeGoalCommand.ws || ws; wsSend(targetWs, { type: 'system_message', sessionId: session.id, message: response?.cleared ? 'Goal cleared' : 'No goal to clear', }); sendSessionList(targetWs); return; } const response = await client.request('thread/goal/set', { threadId, ...(command.action === 'set' ? { objective: command.objective } : {}), status: command.action === 'pause' ? 'paused' : 'active', }, 30000); if (!isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) return; const goal = normalizeCodexThreadGoal(response?.goal, threadId); const targetWs = activeGoalCommand.ws || ws; wsSend(targetWs, { type: 'system_message', sessionId: session.id, message: goal ? formatCodexGoalUsage(goal) : 'Goal updated', }); sendSessionList(targetWs); } catch (err) { const message = isCodexGoalUnsupportedError(err) ? '当前 Codex app-server 不支持 /goal,请升级 Codex 或启用 goals feature。' : `Goal failed: ${err?.message || err}`; if (isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) { wsSend(activeGoalCommand.ws || ws, { type: 'system_message', sessionId: session.id, message }); } } finally { finishCodexAppGoalCommand(session.id, activeGoalCommand); } } // === 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; } if (session && isCodexAppSession(session) && activeCodexAppGoalCommands.has(sessionId)) { wsSend(ws, { type: 'system_message', sessionId, message: 'Codex App Goal 正在同步,请稍候。' }); return; } switch (cmd) { case '/clear': { if (session) { cancelCodexCapacityRetry(sessionId); 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, attachClientRequestId({ 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, }, { sessionId })); } 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 '/goal': { handleCodexAppGoalSlashCommand(ws, text, session).catch((err) => { wsSend(ws, { type: 'system_message', sessionId: session?.id, message: `Goal failed: ${err?.message || err}`, }); }); 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'} 模型(自由输入)${agent === 'codexapp' ? '\n/goal [目标] — 设置/查看持久目标;支持 pause/resume/clear' : ''}\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 resolveConversationModel(rawModel, agent, sourceSession = null) { const value = String(rawModel || '').trim(); if (value) { if (agent === 'codex' || agent === 'codexapp') return value; return MODEL_MAP[value.toLowerCase()] || value; } if (sourceSession?.model) return sourceSession.model; return agent === 'codex' || agent === 'codexapp' ? getDefaultCodexModel() : MODEL_MAP.opus; } function buildBranchSessionTitle(sourceSession) { const sourceTitle = normalizeConversationTitle(sourceSession?.title, '新会话'); return normalizeConversationTitle(`${sourceTitle} 的分支`); } function resolveBranchSource(args = {}) { const sourceSessionId = sanitizeId(args.branchSourceSessionId || args.sourceSessionId || ''); if (!sourceSessionId) return { ok: true, sourceSession: null }; const sourceSession = loadSession(sourceSessionId); if (!sourceSession) { return mcpToolError('branch_source_not_found', '来源会话不存在,无法创建分支。', { sourceSessionId }); } const sourceMessages = Array.isArray(sourceSession.messages) ? sourceSession.messages : []; if (sourceMessages.length === 0) { return mcpToolError('branch_source_empty', '来源会话没有可复制的上下文。', { sourceSessionId }); } const parsedIndex = Number.parseInt(String(args.branchMessageIndex ?? ''), 10); const sourceMessageIndex = Number.isFinite(parsedIndex) ? Math.max(0, Math.min(sourceMessages.length - 1, parsedIndex)) : sourceMessages.length - 1; const createdAt = new Date().toISOString(); return { ok: true, sourceSession, initialMessages: sourceMessages.slice(0, sourceMessageIndex + 1), createdFrom: { kind: 'branch', sourceSessionId: sourceSession.id, sourceTitle: sourceSession.title || 'Untitled', sourceMessageIndex, createdAt, }, defaultTitle: buildBranchSessionTitle(sourceSession), }; } 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 = options.inheritSourceMode === false ? (options.defaultMode || 'yolo') : (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 initialMessages = Array.isArray(options.initialMessages) ? sanitizeMessagesForPersist(options.initialMessages) : []; 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;分支会话优先继承来源模型。 model: resolveConversationModel( args.model, agent, options.inheritSourceModel === true ? sourceSession : null, ), permissionMode: modeResult.mode, totalCost: 0, totalUsage: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }, messages: initialMessages, 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) { const waitState = crossConversationWaitState(session.id); const messages = session.messages || []; return { 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, totalCost: session.totalCost || 0, totalUsage: session.totalUsage || { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }, updated: session.updated, historyTotal: messages.length, historyBaseIndex: 0, hasUnread: false, historyPending: false, isRunning: false, waitingOnChildren: waitState.waitingOnChildren, pendingReplyCount: waitState.pendingReplyCount, readyReplyCount: waitState.readyReplyCount, waitingReplyCount: waitState.waitingReplyCount, failedReplyCount: waitState.failedReplyCount, pendingReplies: waitState.pendingReplies, }; } function attachClientRequestId(payload, source = {}) { const requestId = String(source?.requestId || '').trim(); return requestId ? { ...payload, requestId } : payload; } function handleNewSession(ws, msg) { const request = msg || {}; const branch = resolveBranchSource(request); if (!branch.ok) { return wsSend(ws, { type: 'error', code: branch.code, message: branch.message, }); } const createArgs = { ...request }; if (branch.defaultTitle && !String(createArgs.title || '').trim()) { createArgs.title = branch.defaultTitle; } const result = createPersistentConversationSession(createArgs, { defaultAgent: normalizeAgent(request.agent), defaultMode: 'yolo', allowCreateCwd: true, sourceSession: branch.sourceSession || null, initialMessages: branch.initialMessages || null, createdFrom: branch.createdFrom || null, inheritSourceModel: !!branch.sourceSession, }); 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, attachClientRequestId(buildSessionInfoPayload(session), msg)); 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, historyBaseIndex: start, historyTruncated: start > 0, }); } function handleLoadSession(ws, msg) { const sessionId = sanitizeId(typeof msg === 'string' ? msg : msg?.sessionId); reconcilePendingCrossConversationReplies(); const session = loadSession(sessionId); if (!session) { return wsSend(ws, { type: 'error', message: 'Session not found' }); } flushPendingCrossConversationReplies(sessionId); const refreshedSession = loadSession(sessionId) || session; if (getSessionAgent(refreshedSession) === 'claude' && !refreshedSession.cwd && refreshedSession.claudeSessionId) { const localMeta = resolveClaudeSessionLocalMeta(refreshedSession.claudeSessionId); if (localMeta?.cwd) { refreshedSession.cwd = localMeta.cwd; if (!refreshedSession.importedFrom && localMeta.projectDir) refreshedSession.importedFrom = localMeta.projectDir; saveSession(refreshedSession); } } const { recentMessages, olderChunks, historyRemaining, historyBuffered } = splitHistoryMessages(refreshedSession.messages); const effectiveCwd = refreshedSession.cwd || activeProcesses.get(sessionId)?.cwd || activeCodexAppTurns.get(sessionId)?.cwd || null; const waitState = crossConversationWaitState(sessionId); // Detach ws from any previous session's process detachWsFromActiveRuntimes(ws); wsSessionMap.set(ws, sessionId); // Read and clear unread flag const hadUnread = !!refreshedSession.hasUnread; if (refreshedSession.hasUnread) { refreshedSession.hasUnread = false; saveSession(refreshedSession); } wsSend(ws, attachClientRequestId({ type: 'session_info', sessionId: refreshedSession.id, messages: recentMessages, title: refreshedSession.title, pinnedAt: refreshedSession.pinnedAt || null, mode: refreshedSession.permissionMode || 'yolo', model: sessionModelLabel(refreshedSession), agent: getSessionAgent(refreshedSession), hasUnread: hadUnread, cwd: effectiveCwd, totalCost: session.totalCost || 0, totalUsage: session.totalUsage || null, historyTotal: refreshedSession.messages.length, historyBuffered, historyCursor: historyRemaining, historyBaseIndex: Math.max(0, refreshedSession.messages.length - recentMessages.length), historyTruncated: historyRemaining > 0, historyPending: olderChunks.length > 0, updated: refreshedSession.updated, isRunning: isSessionRunning(sessionId), waitingOnChildren: waitState.waitingOnChildren, pendingReplyCount: waitState.pendingReplyCount, readyReplyCount: waitState.readyReplyCount, waitingReplyCount: waitState.waitingReplyCount, failedReplyCount: waitState.failedReplyCount, pendingReplies: waitState.pendingReplies, }, msg)); if (olderChunks.length > 0) { let chunkEnd = Math.max(0, refreshedSession.messages.length - recentMessages.length); olderChunks.forEach((chunk, index) => { const chunkStart = Math.max(0, chunkEnd - chunk.length); wsSend(ws, { type: 'session_history_chunk', sessionId: refreshedSession.id, messages: chunk, remaining: Math.max(0, olderChunks.length - index - 1), historyCursor: index === olderChunks.length - 1 ? historyRemaining : null, historyBaseIndex: chunkStart, historyTruncated: historyRemaining > 0, }); chunkEnd = chunkStart; }); } // 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 || []), }); } else if (activeCodexAppGoalCommands.has(sessionId)) { const entry = activeCodexAppGoalCommands.get(sessionId); entry.ws = ws; entry.wsDisconnectTime = null; wsSend(ws, { type: 'system_message', sessionId, message: '正在同步 Goal...' }); } } 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); cancelCodexCapacityRetry(sessionId); if (activeCodexAppGoalCommands.has(sessionId)) { const entry = activeCodexAppGoalCommands.get(sessionId); entry.cancelled = true; activeCodexAppGoalCommands.delete(sessionId); } deleteCrossConversationRepliesForSession(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; if (cancelCodexAppGoalCommand(sessionId, ws)) return; const entry = activeProcesses.get(sessionId); if (!entry) { if (cancelCodexCapacityRetry(sessionId)) { wsSend(ws, { type: 'system_message', sessionId, message: '已取消 Codex 自动重试。' }); wsSend(ws, { type: 'done', sessionId }); sendSessionList(ws); } return; } plog('INFO', 'user_abort', { sessionId: sessionId.slice(0, 8), pid: entry.pid }); cancelCodexCapacityRetry(sessionId); 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', '消息内容不能为空。'); } if (sessionId && !hideInHistory) { cancelCodexCapacityRetry(sessionId); } 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 && activeCodexAppGoalCommands.has(sessionId)) { return fail('session_running', 'Codex App Goal 正在同步,请稍候。'); } 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) { if (!options.skipPendingCrossConversationFlush) { reconcilePendingCrossConversationReplies(); flushPendingCrossConversationReplies(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, buildSessionInfoPayload(session)); } 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, codexRetry: options.codexRetry || null, }); } const runtimeOptions = { attachments: resolvedAttachments, mcpContext: options.mcpContext || {}, projectMcpConfigs: isCodexSession(session) ? loadProjectCodexMcpServerConfigs(session.cwd || getDefaultSessionCwd()) : [], }; 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, retryRequest: { text: textValue, runtimeText: runtimeTextValue, mode: session.permissionMode || 'yolo', attachments: savedAttachments, mcpContext: options.mcpContext || {}, }, 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={****}') .replace(/mcp_servers\.[^\s]+\.env=\{[^}]*\}/g, (match) => match.replace(/\{[^}]*\}/, '{****}')); } const { buildClaudeSpawnSpec, buildCodexSpawnSpec, processClaudeEvent, processCodexEvent, processRuntimeEvent, } = createAgentRuntime({ processEnv: process.env, CLAUDE_PATH, CODEX_PATH, MODEL_MAP, loadModelConfig, applyCustomTemplateToSettings, getDefaultCodexModel, loadCodexConfig, prepareCodexCustomRuntime, ccwebMcpServerArg: CCWEB_MCP_SERVER_ARG, ccwebMcpServerArgs: ccwebMcpServerCommandSpec().args, internalMcpUrl: `http://127.0.0.1:${PORT}/api/internal/mcp`, internalMcpToken: INTERNAL_MCP_TOKEN, nodePath: process.execPath, 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; } } for (const [, entry] of activeCodexAppGoalCommands) { 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 (handleCodexAppMcpStartupStatusNotification(notification, routed)) return; 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_list_pending_replies', namespace: 'ccweb', description: '列出当前来源对话等待中的跨对话回复,包括已完成但尚未处理的子对话返回摘要。', inputSchema: { type: 'object', properties: { status: { type: 'string', enum: ['all', 'waiting', 'ready', 'delivering', 'returned', 'failed'], description: '可选。按回复状态过滤,默认 all。', }, }, additionalProperties: false, }, }, { name: 'ccweb_get_pending_reply', namespace: 'ccweb', description: '读取指定 requestId 的跨对话回复状态和正文;用于判断是否继续追问指定子对话。', inputSchema: { type: 'object', required: ['requestId'], properties: { requestId: { type: 'string', description: '等待回复 requestId。', }, }, additionalProperties: false, }, }, { name: 'ccweb_create_conversation', namespace: 'ccweb', description: '创建新的 cc-web 持久对话。Agent 固定继承来源对话,不作为参数指定;只用于需要长期追踪、后续继续对话或跨项目工作区管理的场景;一次性并行研究应使用子代能力。', inputSchema: { type: 'object', properties: { cwd: { type: 'string', description: '可选。新对话工作目录;指定时必须是已存在的绝对路径,默认继承来源对话 cwd。', }, title: { type: 'string', maxLength: MCP_CONVERSATION_TITLE_MAX_CHARS, description: '可选。新对话标题。', }, mode: { type: 'string', enum: ['default', 'plan', 'yolo'], description: '可选。权限模式,默认 yolo;只有显式传 default/plan/yolo 时才使用指定模式。', }, 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_list_pending_replies' && tool !== 'ccweb_get_pending_reply' && 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 handleCcwebPromptUserResponse(ws, msg = {}) { const sessionId = sanitizeId(msg.sessionId || wsSessionMap.get(ws) || ''); const promptId = String(msg.promptId || '').trim(); const fail = (code, message, extra = {}) => { wsSend(ws, { type: 'error', sessionId, code, message, ...extra }); return { ok: false, code, message, ...extra }; }; if (!sessionId) return fail('missing_session_id', '缺少会话 ID。'); if (!promptId) return fail('missing_prompt_id', '缺少 promptId。'); if (activeProcesses.has(sessionId) && !activeCodexAppTurns.has(sessionId)) { return fail('session_running', '当前会话正在运行,暂不能提交 ccweb 表单答案。'); } const session = loadSession(sessionId); if (!session) return fail('session_not_found', '会话不存在。'); const found = findCcwebPromptMessage(session, promptId); if (!found) return fail('prompt_not_found', 'ccweb 表单不存在或已过期。', { promptId }); if (found.prompt.status && found.prompt.status !== 'pending') { return fail('prompt_already_completed', 'ccweb 表单已经提交。', { promptId, status: found.prompt.status }); } const normalized = normalizeCcwebPromptUserAnswers(found.prompt, msg.answers || {}); if (!normalized.ok) return fail(normalized.code || 'bad_answers', normalized.message || '答案无效。', normalized); const now = new Date().toISOString(); const submittedPrompt = { ...found.prompt, status: 'submitted', answers: normalized.answers, submittedAt: now, submitMessageId: crypto.randomUUID(), }; removeCcwebPromptMessage(session, promptId); session.updated = now; saveSession(session); sendSessionEventToViewers(sessionId, { type: 'ccweb_prompt_user_remove', sessionId, promptId, prompt: submittedPrompt, }); const responseText = buildCcwebPromptUserResponseText(submittedPrompt, normalized.answerList); const result = handleMessage(ws, { type: 'message', text: responseText, sessionId, mode: session.permissionMode || 'yolo', agent: getSessionAgent(session), clientMessageId: submittedPrompt.submitMessageId, }, { emitUserMessage: true, runtimeText: responseText, skipPendingCrossConversationFlush: true, }); if (!result?.ok) { return fail(result?.code || 'submit_failed', result?.message || '提交 ccweb 表单答案失败。', { promptId }); } return { ok: true, sessionId, promptId }; } function handleCcwebPromptUserDismiss(ws, msg = {}) { const sessionId = sanitizeId(msg.sessionId || wsSessionMap.get(ws) || ''); const promptId = String(msg.promptId || '').trim(); const fail = (code, message, extra = {}) => { wsSend(ws, { type: 'error', sessionId, code, message, ...extra }); return { ok: false, code, message, ...extra }; }; if (!sessionId) return fail('missing_session_id', '缺少会话 ID。'); if (!promptId) return fail('missing_prompt_id', '缺少 promptId。'); const session = loadSession(sessionId); if (!session) return fail('session_not_found', '会话不存在。'); const found = removeCcwebPromptMessage(session, promptId); if (!found) return fail('prompt_not_found', 'ccweb 表单不存在或已被清理。', { promptId }); const now = new Date().toISOString(); const dismissedPrompt = { ...found.prompt, status: 'dismissed', dismissedAt: now, }; session.updated = now; saveSession(session); sendSessionEventToViewers(sessionId, { type: 'ccweb_prompt_user_remove', sessionId, promptId, prompt: dismissedPrompt, reason: 'dismissed', }); broadcastSessionList(); return { ok: true, promptId, status: 'dismissed' }; } function codexAppRecord(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; } function codexAppString(value) { return typeof value === 'string' && value.trim() ? value.trim() : ''; } function codexAppApprovalToolName(params = {}) { const record = codexAppRecord(params); return codexAppString(record.toolName) || codexAppString(record.tool_name) || codexAppString(record.tool) || codexAppString(record.name) || codexAppString(record.permission) || 'CodexTool'; } function codexAppApprovalPreview(method, params = {}) { const record = codexAppRecord(params); const itemId = codexAppString(record.itemId) || codexAppString(record.item_id); const reason = codexAppString(record.reason) || codexAppString(record.message); const cwd = codexAppString(record.cwd); if (method === 'item/commandExecution/requestApproval') { const command = record.command ?? record.cmd ?? ''; return { itemId, approvalType: 'command', title: 'Codex App 请求执行命令', reason, summary: codexAppString(command) || reason || cwd, payload: truncateObj({ command, cwd, reason }, 4000), allowSessionScope: true, }; } if (method === 'item/fileChange/requestApproval') { const grantRoot = codexAppString(record.grantRoot) || codexAppString(record.path); return { itemId, approvalType: 'file_change', title: 'Codex App 请求修改文件', reason, summary: grantRoot || reason || cwd, payload: truncateObj({ grantRoot, cwd, reason, changes: record.changes || null }, 4000), allowSessionScope: true, }; } if (method === 'item/permissions/requestApproval') { return { itemId, approvalType: 'permissions', title: 'Codex App 请求提升权限', reason, summary: reason || cwd || '权限配置请求', payload: truncateObj({ cwd, permissions: record.permissions || {} }, 4000), allowSessionScope: true, }; } const toolName = codexAppApprovalToolName(record); const input = record.input ?? record.arguments ?? record.params ?? record; return { itemId, approvalType: 'tool', title: `Codex App 请求调用 ${toolName}`, reason, summary: reason || toolName, payload: truncateObj(input, 4000), allowSessionScope: true, toolName, }; } function codexAppApprovalDecisionFromAction(action) { switch (String(action || '').trim()) { case 'approve': return 'approved'; case 'approve_session': return 'approved_for_session'; case 'deny': return 'denied'; default: return 'abort'; } } function codexAppDecisionResponse(decision) { switch (decision) { case 'approved': return { decision: 'accept' }; case 'approved_for_session': return { decision: 'acceptForSession' }; case 'denied': return { decision: 'decline' }; default: return { decision: 'cancel' }; } } function codexAppPermissionsResponse(params = {}, decision = 'abort') { if (decision === 'approved' || decision === 'approved_for_session') { return { permissions: codexAppRecord(params).permissions || {}, scope: decision === 'approved_for_session' ? 'session' : 'turn', }; } return { permissions: { network: null, fileSystem: null, }, scope: 'turn', }; } function codexAppApprovalResponse(method, params = {}, action = 'cancel') { const decision = codexAppApprovalDecisionFromAction(action); if (method === 'item/permissions/requestApproval') { return codexAppPermissionsResponse(params, decision); } return codexAppDecisionResponse(decision); } function requestCodexAppApproval(routed, method, params = {}) { const targetWs = routed?.entry?.ws || findViewingSessionWs(routed?.sessionId); if (!routed?.sessionId || !targetWs) { return Promise.resolve(codexAppApprovalResponse(method, params, 'cancel')); } const requestId = crypto.randomUUID(); const preview = codexAppApprovalPreview(method, params); return new Promise((resolve) => { const timer = setTimeout(() => { pendingCodexAppApprovals.delete(requestId); resolve(codexAppApprovalResponse(method, params, 'cancel')); }, 10 * 60 * 1000); pendingCodexAppApprovals.set(requestId, { sessionId: routed.sessionId, method, params, resolve, timer, }); wsSend(targetWs, { type: 'codex_app_approval_request', sessionId: routed.sessionId, requestId, method, ...preview, }); }); } function handleCodexAppApprovalResponse(ws, msg = {}) { const requestId = String(msg.requestId || '').trim(); const pending = pendingCodexAppApprovals.get(requestId); if (!pending) { wsSend(ws, { type: 'error', code: 'codexapp_approval_not_found', message: 'Codex App 审批请求不存在或已超时。' }); return; } pendingCodexAppApprovals.delete(requestId); clearTimeout(pending.timer); const action = String(msg.action || 'cancel').trim(); const result = codexAppApprovalResponse(pending.method, pending.params, action); const approved = action === 'approve' || action === 'approve_session'; const message = approved ? (action === 'approve_session' ? '已批准 Codex App 本会话执行。' : '已批准 Codex App 本次执行。') : (action === 'deny' ? '已拒绝 Codex App 执行请求。' : '已取消 Codex App 审批请求。'); wsSend(ws, { type: 'system_message', sessionId: pending.sessionId, tone: approved ? 'info' : 'warning', transient: true, autoDismissMs: 5000, message, }); pending.resolve(result); } function resolvePendingCodexAppApprovalsForSession(sessionId) { for (const [requestId, pending] of pendingCodexAppApprovals) { if (pending.sessionId !== sessionId) continue; pendingCodexAppApprovals.delete(requestId); clearTimeout(pending.timer); pending.resolve(codexAppApprovalResponse(pending.method, pending.params, 'cancel')); } } 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; const isApprovalRequest = method === 'item/commandExecution/requestApproval' || method === 'item/fileChange/requestApproval' || method === 'item/permissions/requestApproval' || method === 'item/tool/requestApproval'; if (!dynamicToolResponse && !isApprovalRequest && 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': case 'item/fileChange/requestApproval': case 'item/permissions/requestApproval': case 'item/tool/requestApproval': return requestCodexAppApproval(routed, method, params); 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) { plog('INFO', 'codex_app_server_exit_stale', { code: info.code ?? null, signal: info.signal || null, activeTurns: activeCodexAppTurns.size, }); return; } 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: 'on-request', 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: 'on-request', 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) 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 config = {}; for (const item of listRuntimeMcpServerConfigs({ ...options, session, agent: 'codexapp' })) { if (!item?.server || !item?.config) continue; config[`mcp_servers.${item.server}`] = item.config; } return config; } 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 }; const strippedEnvKeys = []; for (const key of CODEX_APP_PROCESS_ENV_STRIP_KEYS) { if (Object.prototype.hasOwnProperty.call(env, key)) strippedEnvKeys.push(key); delete env[key]; } env.PATH = cleanProcessPathValue(env.PATH); delete env.CC_WEB_PASSWORD; delete env.CLAUDECODE; delete env.CLAUDE_CODE; if (CODEX_APP_CCWEB_MCP_TRANSPORT !== 'stdio') { env[CODEX_APP_CCWEB_MCP_BEARER_TOKEN_ENV] = INTERNAL_MCP_TOKEN; } 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, ccwebMcpTransport: CODEX_APP_CCWEB_MCP_TRANSPORT, internalMcpTokenHash: crypto.createHash('sha256').update(INTERNAL_MCP_TOKEN).digest('hex'), }); return { command: CODEX_PATH, args, env, cwd: process.env.HOME || process.env.USERPROFILE || process.cwd(), signature, strippedEnvKeys, }; } function getCodexAppClient(options = {}) { const spec = buildCodexAppClientSpec(); if (spec?.error) return { error: spec.error }; if (codexAppClient && codexAppClientSignature !== spec.signature) { const excludeSessionId = sanitizeId(options.excludeSessionId || ''); const blockingSessionIds = Array.from(activeCodexAppTurns.keys()) .filter((sessionId) => !excludeSessionId || sessionId !== excludeSessionId); if (blockingSessionIds.length > 0) { if (codexAppClient.isRunning()) { plog('WARN', 'codex_app_config_changed_reusing_active_client', { activeTurns: activeCodexAppTurns.size, blockingTurns: blockingSessionIds.length, excludeSessionId: excludeSessionId ? excludeSessionId.slice(0, 8) : null, }); return { client: codexAppClient, staleConfig: true }; } const message = 'Codex App 配置已变更,旧 app-server 已不可用,已结束残留运行任务。请重试。'; for (const sessionId of blockingSessionIds) { handleCodexAppTurnFailure(sessionId, new Error(message)); } } 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, }; if (IS_BUN_SINGLE_EXECUTABLE) { clientOptions.workerCommand = process.execPath; clientOptions.workerArgs = [CODEX_APP_WORKER_ARG]; } codexAppClient = CODEX_APP_WORKER_ENABLED ? createCodexAppWorkerClient(clientOptions) : createCodexAppServerClient(clientOptions); codexAppClientSignature = signature; plog('INFO', 'codex_app_client_created', { worker: CODEX_APP_WORKER_ENABLED, workerLauncher: IS_BUN_SINGLE_EXECUTABLE ? 'single-executable' : 'source-file', command: path.basename(spec.command || ''), strippedEnvKeys: spec.strippedEnvKeys || [], }); } 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 codexRetry = options.codexRetry && typeof options.codexRetry === 'object' ? { isAutoRetry: !!options.codexRetry.isAutoRetry, attempt: Number.isFinite(Number(options.codexRetry.attempt)) ? Number(options.codexRetry.attempt) : null, retryMode: options.codexRetry.retryMode || null, expectedThreadId: options.codexRetry.expectedThreadId || null, originalText: options.codexRetry.originalText || null, originalRuntimeText: options.codexRetry.originalRuntimeText || null, useContinuationRetry: !!options.codexRetry.useContinuationRetry, } : null; const currentThreadId = getRuntimeSessionId(session); const expectedThreadId = codexRetry?.expectedThreadId || currentThreadId || null; const retryAttachments = 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, })); const entry = { ws, agent: 'codexapp', cwd: session.cwd || getDefaultSessionCwd(), threadId: expectedThreadId, expectedThreadId, turnId: null, fullText: '', toolCalls: [], toolOutputDeltas: new Map(), agentMessageItems: new Map(), mcpContext: options.mcpContext || {}, codexRetry, lastUsage: null, lastError: null, errorSent: false, crossConversationReplyRequestId: options.crossConversation?.replyRequestId || null, retryRequest: { text: codexRetry?.originalText || runtimeTextValue, runtimeText: codexRetry?.originalRuntimeText || runtimeTextValue, originalText: codexRetry?.originalText || runtimeTextValue, originalRuntimeText: codexRetry?.originalRuntimeText || runtimeTextValue, lastRetryText: runtimeTextValue, mode: session.permissionMode || 'yolo', agent: 'codexapp', attachments: retryAttachments, mcpContext: options.mcpContext || {}, expectedThreadId, }, 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({ excludeSessionId: sessionId }); if (clientResult.error) throw new Error(clientResult.error); const client = clientResult.client; await client.start(); const currentThreadId = getRuntimeSessionId(session); const expectedThreadId = entry.expectedThreadId || entry.codexRetry?.expectedThreadId || entry.retryRequest?.expectedThreadId || entry.threadId || currentThreadId || null; let threadId = expectedThreadId || currentThreadId; const threadParams = codexAppThreadParams(session, { mcpContext: entry.mcpContext || {} }); if (threadId) { const requestedThreadId = threadId; const resumed = await client.request('thread/resume', { ...threadParams, threadId: requestedThreadId }, 60000); const resumedThreadId = resumed?.thread?.id || requestedThreadId; if (expectedThreadId && resumedThreadId !== expectedThreadId) { const expectedShort = String(expectedThreadId).slice(0, 24); const actualShort = String(resumedThreadId).slice(0, 24); plog('WARN', 'codex_app_thread_resume_mismatch', { sessionId: sessionId.slice(0, 8), expectedThreadId: expectedShort, actualThreadId: actualShort, autoRetry: !!entry.codexRetry?.isAutoRetry, retryAttempt: entry.codexRetry?.attempt || null, }); const prefix = entry.codexRetry?.isAutoRetry ? 'Codex App 自动重试' : 'Codex App'; throw new Error(`${prefix}恢复到不同线程,已停止以避免上下文丢失(期望 ${expectedShort},实际 ${actualShort})。`); } threadId = resumedThreadId; } 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); resolvePendingCodexAppApprovalsForSession(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 && shouldRetryCodexTransientFailure(entry, rawError)) { activeCodexAppTurns.delete(sessionId); cleanupCodexAppTurnState(sessionId, entry); if (scheduleCodexCapacityRetry(sessionId, entry, rawError)) { sendSessionList(entry.ws); return; } } else { cancelCodexCapacityRetry(sessionId); } 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(APP_DIR, '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(APP_DIR, '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, attachClientRequestId({ 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, }, msg)); sendSessionList(ws); } function resolveCodexImportAgent(value) { return value === 'codexapp' ? 'codexapp' : 'codex'; } function codexImportSourceLabel(source) { if (!source) return ''; if (typeof source === 'string') return source; if (source && typeof source === 'object') { if (source.subagent?.thread_spawn) return 'subagent'; if (source.type) return String(source.type); } return ''; } function extractCcwebSourceConversation(title) { const match = String(title || '').match(/^来自「([^」]+)」对话(ID:\s*([0-9a-fA-F-]{36}))的消息:/); if (!match) return null; return { id: match[2].toLowerCase(), title: match[1], }; } function codexImportDedupeKey(item) { if (item.sourceConversationId) return `ccweb-source:${item.sourceConversationId}`; return `thread:${item.threadId}`; } function codexImportItemTime(item) { const time = new Date(item?.updatedAt || 0).getTime(); return Number.isFinite(time) ? time : 0; } function preferCodexImportItem(next, current) { const nextTime = codexImportItemTime(next); const currentTime = codexImportItemTime(current); if (nextTime !== currentTime) return nextTime > currentTime ? next : current; return String(next.rolloutPath || '') > String(current.rolloutPath || '') ? next : current; } function handleListCodexSessions(ws, msg = {}) { const importAgent = resolveCodexImportAgent(msg?.agent); const imported = getImportedCodexThreadIds(importAgent); const itemsByKey = new Map(); 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); const sourceConversation = parsed.meta.sourceConversationId ? { id: parsed.meta.sourceConversationId, title: parsed.meta.sourceConversationTitle || '', } : extractCcwebSourceConversation(title); const item = { threadId: parsed.meta.threadId, title, cwd: parsed.meta.cwd || null, updatedAt: parsed.meta.updatedAt || null, cliVersion: parsed.meta.cliVersion || '', source: codexImportSourceLabel(parsed.meta.source), sourceConversationId: sourceConversation?.id || null, sourceConversationTitle: sourceConversation?.title || '', duplicateCount: 1, rolloutPath: filePath, agent: importAgent, alreadyImported: imported.has(parsed.meta.threadId), }; const dedupeKey = codexImportDedupeKey(item); const current = itemsByKey.get(dedupeKey); if (!current) { itemsByKey.set(dedupeKey, item); continue; } const preferred = preferCodexImportItem(item, current); preferred.duplicateCount = (current.duplicateCount || 1) + 1; itemsByKey.set(dedupeKey, preferred); } const items = Array.from(itemsByKey.values()).sort((a, b) => { const timeDiff = codexImportItemTime(b) - codexImportItemTime(a); if (timeDiff) return timeDiff; return String(b.rolloutPath || '').localeCompare(String(a.rolloutPath || '')); }); wsSend(ws, { type: 'codex_sessions', sessions: items }); } function handleImportCodexSession(ws, msg) { const threadId = String(msg?.threadId || '').trim(); const importAgent = resolveCodexImportAgent(msg?.agent); 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'))); const runtimeThreadId = importAgent === 'codexapp' ? s.codexAppThreadId : s.codexThreadId; if (runtimeThreadId === 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: importAgent, claudeSessionId: null, codexThreadId: importAgent === 'codex' ? threadId : null, codexAppThreadId: importAgent === 'codexapp' ? threadId : null, importedFrom: importAgent, 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, attachClientRequestId({ 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, }, msg)); sendSessionList(ws); } function handleListCwdSuggestions(ws) { const defaultPath = getDefaultSessionCwd(); const paths = collectRecentSessionCwds(12).filter((candidate) => candidate !== defaultPath); wsSend(ws, { type: 'cwd_suggestions', defaultPath, paths }); } // === Startup === loadCrossConversationReplies(); recoverProcesses(); reconcilePendingCrossConversationReplies(); // 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}`); });