Files
cc-web/server.js
cc-dan b64d5ec029 feat: v1.2.5 — UI improvements and session management fixes
- Fix session delete to scan all claude project dirs (not just first match)
- Batch async rendering for message history with stale render guard
- Add custom draggable scrollbar for chat area
- Fix AskUserQuestion card rendered at bottom instead of top
- Fix bubble split (msg-text + msg-tools) to prevent tool UI overwrite
- Add delete confirmation dialog with warm theme styling
- Support multiline display in user messages
- Apply model config to settings.json immediately on save
2026-03-10 15:19:47 +00:00

1538 lines
52 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { spawn } = require('child_process');
const { WebSocketServer } = require('ws');
// 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 SESSIONS_DIR = path.join(__dirname, 'sessions');
const PUBLIC_DIR = path.join(__dirname, 'public');
const LOGS_DIR = path.join(__dirname, 'logs');
const NOTIFY_CONFIG_PATH = path.join(__dirname, 'config', 'notify.json');
const AUTH_CONFIG_PATH = path.join(__dirname, 'config', 'auth.json');
const MODEL_CONFIG_PATH = path.join(__dirname, 'config', 'model.json');
fs.mkdirSync(SESSIONS_DIR, { recursive: true });
fs.mkdirSync(LOGS_DIR, { recursive: true });
fs.mkdirSync(path.dirname(NOTIFY_CONFIG_PATH), { 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 ===
function loadNotifyConfig() {
try {
if (fs.existsSync(NOTIFY_CONFIG_PATH)) {
return JSON.parse(fs.readFileSync(NOTIFY_CONFIG_PATH, 'utf8'));
}
} 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: '' },
};
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();
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) },
};
}
function sendNotification(title, content) {
const config = loadNotifyConfig();
if (!config.provider || config.provider === 'off') return Promise.resolve({ ok: true, skipped: true });
const https = require('https');
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, 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${content}` });
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: content });
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${content}` } });
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${content}`)}`;
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();
// 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();
// Track which session each ws is viewing: ws -> sessionId
const wsSessionMap = new Map();
// Default fallback MODEL_MAP (overridden by model config at runtime)
let MODEL_MAP = {
opus: 'claude-opus-4-6',
sonnet: 'claude-sonnet-4-6',
haiku: 'claude-haiku-4-5-20251001',
};
// === 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)
};
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 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 || '',
})),
};
}
// 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 = {};
if (env.ANTHROPIC_DEFAULT_OPUS_MODEL) map.opus = env.ANTHROPIC_DEFAULT_OPUS_MODEL;
if (env.ANTHROPIC_DEFAULT_SONNET_MODEL) map.sonnet = env.ANTHROPIC_DEFAULT_SONNET_MODEL;
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;
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'];
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;
try { fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2)); } 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;
if (tpl.sonnetModel) MODEL_MAP.sonnet = tpl.sonnetModel;
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 loadSession(id) {
try {
return JSON.parse(fs.readFileSync(sessionPath(id), 'utf8'));
} catch {
return null;
}
}
function saveSession(session) {
fs.writeFileSync(sessionPath(session.id), JSON.stringify(session, null, 2));
}
function modelShortName(fullModel) {
if (!fullModel) return null;
const entry = Object.entries(MODEL_MAP).find(([, v]) => v === fullModel);
return entry ? entry[0] : null;
}
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 = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, f), 'utf8'));
sessions.push({ id: s.id, title: s.title || 'Untitled', updated: s.updated, hasUnread: !!s.hasUnread });
} catch {}
}
sessions.sort((a, b) => new Date(b.updated) - new Date(a.updated));
wsSend(ws, { type: 'session_list', sessions });
} catch {
wsSend(ws, { type: 'session_list', sessions: [] });
}
}
// === 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 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 requestTooLarge = 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 {}
requestTooLarge = /Request too large \(max 20MB\)/i.test(entry.fullText || '') || /Request too large \(max 20MB\)/i.test(stderrSnippet || '');
plog(exitCode === 0 || exitCode === null ? 'INFO' : 'WARN', 'process_complete', {
sessionId: sessionId.slice(0, 8),
pid: entry.pid,
exitCode,
signal,
wsConnected,
wsDisconnectTime: entry.wsDisconnectTime || null,
disconnectToDeathGap: disconnectGap,
responseLen: (entry.fullText || '').length,
toolCallCount: (entry.toolCalls || []).length,
cost: entry.lastCost,
stderr: stderrSnippet || null,
requestTooLarge,
});
// 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) {
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);
}
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;
// Notify client
if (entry.ws) {
if (pendingSlash?.kind === 'compact') {
const retry = pendingCompactRetries.get(sessionId);
if (retry?.reason === 'auto') {
wsSend(entry.ws, { type: 'system_message', message: '上下文压缩完成。已按 Claude Code 原生策略执行 /compact下次继续在同一会话发送即可。' });
pendingCompactRetries.delete(sessionId);
} else if (retry?.text) {
if (requestTooLarge) {
pendingCompactRetries.delete(sessionId);
wsSend(entry.ws, { type: 'system_message', message: '已尝试执行 /compact但仍未成功解除上下文超限。请手动缩小输入范围后重试。' });
} else {
wsSend(entry.ws, { type: 'system_message', message: '检测到上一条请求因上下文过大失败,现已自动按压缩计划继续执行。' });
shouldReturnForFollowup = true;
pendingCompactRetries.delete(sessionId);
}
} else {
wsSend(entry.ws, { type: 'system_message', message: '上下文压缩完成。已按 Claude Code 原生策略执行 /compact下次继续在同一会话发送即可。' });
}
}
if (requestTooLarge && !pendingSlash && session && session.claudeSessionId) {
pendingCompactRetries.set(sessionId, { text: pendingRetry?.text || '', mode: pendingRetry?.mode || session.permissionMode || 'yolo', reason: 'auto' });
wsSend(entry.ws, { type: 'system_message', message: '检测到上下文达到上限,正在按 Claude Code 原版策略自动执行 /compact然后继续当前任务…' });
shouldReturnForFollowup = true;
}
wsSend(entry.ws, { type: 'done', sessionId, costUsd: entry.lastCost || null });
sendSessionList(entry.ws);
} else {
// Process completed while browser was disconnected — notify all connected clients
const session = loadSession(sessionId);
const title = session?.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
const cost = entry.lastCost ? `$${entry.lastCost.toFixed(4)}` : '';
const respLen = (entry.fullText || '').length;
sendNotification(
`CC-Web 任务完成`,
`会话: ${title}\n字数: ${respLen}\n费用: ${cost}`
);
}
activeProcesses.delete(sessionId);
cleanRunDir(sessionId);
pendingSlashCommands.delete(sessionId);
if (!shouldReturnForFollowup && !requestTooLarge && 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 (requestTooLarge && !pendingSlash && session.claudeSessionId) {
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);
// 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');
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 });
const entry = { pid, ws: null, fullText: '', toolCalls: [], lastCost: null, tailer: null };
activeProcesses.set(sessionId, entry);
if (fs.existsSync(outputPath)) {
entry.tailer = new FileTailer(outputPath, (line) => {
try {
const event = JSON.parse(line);
processClaudeEvent(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 });
if (fs.existsSync(outputPath)) {
const tempEntry = { pid: 0, ws: null, fullText: '', toolCalls: [], lastCost: null, 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);
processClaudeEvent(tempEntry, event, sessionId);
} catch {}
}
const session = loadSession(sessionId);
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}`);
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-cache',
});
res.end(data);
});
});
// === WebSocket Server ===
const wss = new WebSocketServer({ server });
wss.on('connection', (ws) => {
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') {
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 {
wsSend(ws, { type: 'auth_result', success: false });
}
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);
} else {
handleMessage(ws, msg);
}
break;
case 'abort':
handleAbort(ws);
break;
case 'new_session':
handleNewSession(ws);
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_mode':
handleSetMode(ws, msg.sessionId, msg.mode);
break;
case 'list_sessions':
sendSessionList(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;
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 || '' };
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
MODEL_MAP = { opus: 'claude-opus-4-6', sonnet: 'claude-sonnet-4-6', 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);
}
plog('INFO', 'model_config_saved', { mode: merged.mode, activeTemplate: merged.activeTemplate });
wsSend(ws, { type: 'model_config', config: getModelConfigMasked() });
wsSend(ws, { type: 'system_message', message: '模型配置已保存' });
}
// === Slash Command Handler ===
function handleSlashCommand(ws, text, sessionId) {
const parts = text.split(/\s+/);
const cmd = parts[0].toLowerCase();
let session = sessionId ? loadSession(sessionId) : null;
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 = [];
session.claudeSessionId = null;
session.updated = new Date().toISOString();
saveSession(session);
wsSend(ws, { type: 'session_info', sessionId: session.id, messages: [], title: session.title });
}
wsSend(ws, { type: 'system_message', message: '会话已清除,上下文已重置。' });
break;
}
case '/model': {
const modelInput = parts[1];
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': {
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 (activeProcesses.has(sessionId)) {
wsSend(ws, { type: 'system_message', message: '当前会话正在处理中,请先等待完成或点击停止,再执行 /compact。' });
break;
}
if (!session.claudeSessionId) {
wsSend(ws, { type: 'system_message', message: '当前会话尚未建立 Claude 上下文,暂时无需压缩。' });
break;
}
wsSend(ws, { type: 'system_message', message: '正在执行 Claude 原生 /compact 压缩上下文,请稍候…' });
pendingSlashCommands.set(session.id, { kind: 'compact' });
handleMessage(ws, { text: '/compact', 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;
session.claudeSessionId = null;
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': {
wsSend(ws, {
type: 'system_message',
message: '可用指令:\n' +
'/clear — 清除当前会话(含上下文)\n' +
'/model [名称] — 查看/切换模型opus, sonnet, haiku\n' +
'/mode [模式] — 查看/切换权限模式default, plan, yolo\n' +
'/cost — 查看当前会话累计费用\n' +
'/compact — 执行 Claude 原生上下文压缩(保留压缩计划并可自动续跑)\n' +
'/help — 显示本帮助',
});
break;
}
default:
wsSend(ws, { type: 'system_message', message: `未知指令: ${cmd}\n输入 /help 查看可用指令` });
}
}
// === Session Handlers ===
function handleNewSession(ws) {
const id = crypto.randomUUID();
const session = {
id,
title: 'New Chat',
created: new Date().toISOString(),
updated: new Date().toISOString(),
claudeSessionId: null,
model: null,
permissionMode: 'yolo',
totalCost: 0,
messages: [],
};
saveSession(session);
wsSessionMap.set(ws, id);
wsSend(ws, { type: 'session_info', sessionId: id, messages: [], title: session.title, mode: session.permissionMode, model: null });
sendSessionList(ws);
}
function handleLoadSession(ws, sessionId) {
const session = loadSession(sessionId);
if (!session) {
return wsSend(ws, { type: 'error', message: 'Session not found' });
}
// Detach ws from any previous session's process
for (const [, entry] of activeProcesses) {
if (entry.ws === ws) entry.ws = null;
}
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: session.messages,
title: session.title,
mode: session.permissionMode || 'yolo',
model: modelShortName(session.model),
hasUnread: hadUnread,
});
// 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 || [],
});
}
}
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 });
}
cleanRunDir(sessionId);
try {
const p = sessionPath(sessionId);
// Read claudeSessionId before deleting the file
let claudeSessionId = null;
try {
const session = loadSession(sessionId);
claudeSessionId = session?.claudeSessionId || null;
} catch {}
if (fs.existsSync(p)) fs.unlinkSync(p);
// Sync-delete the corresponding Claude native session .jsonl
if (claudeSessionId) {
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 {}
}
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 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;
session.claudeSessionId = null;
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 });
}
}
wsSessionMap.delete(ws);
plog('INFO', 'ws_disconnect', { wsId, activeProcessesAffected: affectedSessions });
}
function handleAbort(ws) {
const sessionId = wsSessionMap.get(ws);
if (!sessionId) 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
}
// === Claude Message Handler ===
function handleMessage(ws, msg, options = {}) {
const { text, sessionId, mode } = msg;
const { hideInHistory = false } = options;
if (!text || !text.trim()) return;
const normalizedText = text.trim();
if (sessionId && activeProcesses.has(sessionId)) {
return wsSend(ws, { type: 'error', message: '正在处理中,请先点击停止按钮。' });
}
let session;
if (sessionId) session = loadSession(sessionId);
if (!session) {
const id = crypto.randomUUID();
session = {
id,
title: text.slice(0, 60).replace(/\n/g, ' '),
created: new Date().toISOString(),
updated: new Date().toISOString(),
claudeSessionId: null,
model: null,
permissionMode: mode || 'yolo',
totalCost: 0,
messages: [],
};
}
if (mode && ['default', 'plan', 'yolo'].includes(mode)) {
session.permissionMode = mode;
}
if (!hideInHistory && normalizedText !== '/compact' && session.claudeSessionId) {
pendingCompactRetries.set(session.id, { text: normalizedText, mode: session.permissionMode || 'yolo', reason: 'normal' });
}
if (session.title === 'New Chat' || session.title === 'Untitled') {
session.title = text.slice(0, 60).replace(/\n/g, ' ');
}
if (!hideInHistory) {
session.messages.push({ role: 'user', content: text, timestamp: new Date().toISOString() });
}
session.updated = new Date().toISOString();
saveSession(session);
const currentSessionId = session.id;
for (const [, entry] of activeProcesses) {
if (entry.ws === ws) entry.ws = null;
}
wsSessionMap.set(ws, currentSessionId);
if (!sessionId) {
wsSend(ws, { type: 'session_info', sessionId: currentSessionId, messages: session.messages, title: session.title, mode: session.permissionMode || 'yolo', model: modelShortName(session.model) });
}
sendSessionList(ws);
// Build claude args
const args = ['-p', '--output-format', 'stream-json', '--verbose'];
const permMode = session.permissionMode || 'yolo';
switch (permMode) {
case 'yolo':
args.push('--dangerously-skip-permissions');
break;
case 'plan':
args.push('--permission-mode', 'plan');
break;
case 'default':
break;
}
if (session.claudeSessionId) {
args.push('--resume', session.claudeSessionId);
}
if (session.model) {
// Only pass --model if it's a known valid model name in MODEL_MAP
const validModels = new Set(Object.values(MODEL_MAP));
if (validModels.has(session.model)) {
args.push('--model', session.model);
}
}
const env = { ...process.env };
delete env.CLAUDECODE;
delete env.CLAUDE_CODE;
delete env.CC_WEB_PASSWORD;
// Strip all ANTHROPIC_* from env — claude CLI reads ~/.claude/settings.json which takes priority
for (const k of Object.keys(env)) {
if (k.startsWith('ANTHROPIC_')) delete env[k];
}
// custom mode: patch ~/.claude/settings.json env section with template credentials
{
const modelCfg = loadModelConfig();
if (modelCfg.mode === 'custom' && modelCfg.activeTemplate) {
const tpl = (modelCfg.templates || []).find(t => t.name === modelCfg.activeTemplate);
if (tpl) applyCustomTemplateToSettings(tpl);
}
}
// === 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');
fs.writeFileSync(inputPath, text);
const inputFd = fs.openSync(inputPath, 'r');
const outputFd = fs.openSync(outputPath, 'w');
const errorFd = fs.openSync(errorPath, 'w');
let proc;
try {
proc = spawn(CLAUDE_PATH, args, {
env,
cwd: process.env.HOME || process.env.USERPROFILE || process.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 });
return wsSend(ws, { type: 'error', message: `启动 Claude 失败: ${err.message}` });
}
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,
mode: permMode,
model: session.model || 'default',
resume: !!session.claudeSessionId,
args: 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, fullText: '', toolCalls: [], lastCost: null, tailer: null };
activeProcesses.set(currentSessionId, entry);
// Tail the output file for real-time streaming
entry.tailer = new FileTailer(outputPath, (line) => {
try {
const event = JSON.parse(line);
processClaudeEvent(entry, event, currentSessionId);
} catch {}
});
entry.tailer.start();
}
// === Claude Event Processing ===
function processClaudeEvent(entry, event, sessionId) {
if (!event || !event.type) return;
switch (event.type) {
case 'system':
if (event.session_id) {
const session = loadSession(sessionId);
if (session) {
session.claudeSessionId = event.session_id;
saveSession(session);
}
}
break;
case 'assistant': {
const content = event.message?.content;
if (!Array.isArray(content)) break;
for (const block of content) {
if (block.type === 'text' && block.text) {
entry.fullText += block.text;
wsSend(entry.ws, { type: 'text_delta', text: block.text });
} else if (block.type === 'tool_use') {
const toolInput = sanitizeToolInput(block.name, block.input);
const tc = { name: block.name, id: block.id, input: toolInput, done: false };
entry.toolCalls.push(tc);
wsSend(entry.ws, { type: 'tool_start', name: block.name, toolUseId: block.id, input: tc.input });
} else if (block.type === 'tool_result') {
const resultText = typeof block.content === 'string'
? block.content
: Array.isArray(block.content)
? block.content.map(c => c.text || '').join('\n')
: JSON.stringify(block.content);
const tc = entry.toolCalls.find(t => t.id === block.tool_use_id);
if (tc) { tc.done = true; tc.result = resultText.slice(0, 2000); }
wsSend(entry.ws, { type: 'tool_end', toolUseId: block.tool_use_id, result: resultText.slice(0, 2000) });
}
}
if (event.session_id) {
const session = loadSession(sessionId);
if (session && !session.claudeSessionId) {
session.claudeSessionId = event.session_id;
saveSession(session);
}
}
break;
}
case 'result': {
const session = loadSession(sessionId);
if (session) {
if (event.session_id) session.claudeSessionId = event.session_id;
if (event.total_cost_usd) session.totalCost = (session.totalCost || 0) + event.total_cost_usd;
saveSession(session);
}
entry.lastCost = event.total_cost_usd || null;
if (entry.ws && event.total_cost_usd !== undefined) {
wsSend(entry.ws, { type: 'cost', costUsd: session?.totalCost || 0 });
}
break;
}
}
}
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);
}
// === 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, '127.0.0.1', () => {
console.log(`CC-Web server listening on 127.0.0.1:${PORT}`);
});