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