Stabilize ccweb codex app runtime

This commit is contained in:
shiyue
2026-06-16 09:09:23 +08:00
parent 0f4a1c27fe
commit 2e119fd7e3
6 changed files with 1361 additions and 124 deletions

744
server.js
View File

@@ -6,6 +6,7 @@ const { spawn, spawnSync } = require('child_process');
const { WebSocketServer } = require('ws');
const { createAgentRuntime } = require('./lib/agent-runtime');
const { createCodexAppServerClient } = require('./lib/codex-app-server-client');
const { createCodexAppWorkerClient } = require('./lib/codex-app-worker-client');
const { createCodexAppRuntime } = require('./lib/codex-app-runtime');
const { createCodexRolloutStore } = require('./lib/codex-rollouts');
@@ -18,6 +19,14 @@ if (fs.existsSync(envPath)) {
}
}
function readPositiveIntEnv(name, fallback, options = {}) {
const raw = Number.parseInt(String(process.env[name] || ''), 10);
const min = Number.isFinite(options.min) ? options.min : 1;
const max = Number.isFinite(options.max) ? options.max : Number.MAX_SAFE_INTEGER;
if (!Number.isFinite(raw) || raw <= 0) return fallback;
return Math.max(min, Math.min(max, raw));
}
const PORT = parseInt(process.env.PORT) || 8002;
const CLAUDE_PATH = process.env.CLAUDE_PATH || 'claude';
const CODEX_PATH = process.env.CODEX_PATH || 'codex';
@@ -37,6 +46,28 @@ const COMPOSER_SUGGESTION_LIMIT = 20;
const COMPOSER_FILE_CONTEXT_MAX_BYTES = 60 * 1024;
const COMPOSER_MAX_FILE_MENTIONS = 4;
const COMPOSER_MAX_PROMPT_MENTIONS = 4;
const SESSION_MAX_JSON_BYTES = readPositiveIntEnv('CC_WEB_SESSION_MAX_JSON_BYTES', 10 * 1024 * 1024, { min: 512 * 1024 });
const SESSION_LOAD_MAX_BYTES = readPositiveIntEnv('CC_WEB_SESSION_LOAD_MAX_BYTES', 32 * 1024 * 1024, { min: SESSION_MAX_JSON_BYTES });
const SESSION_META_FULL_PARSE_MAX_BYTES = readPositiveIntEnv('CC_WEB_SESSION_META_FULL_PARSE_MAX_BYTES', 512 * 1024, { min: 64 * 1024 });
const SESSION_META_PREVIEW_BYTES = readPositiveIntEnv('CC_WEB_SESSION_META_PREVIEW_BYTES', 128 * 1024, { min: 16 * 1024 });
const SESSION_PERSIST_MAX_MESSAGES = readPositiveIntEnv('CC_WEB_SESSION_PERSIST_MAX_MESSAGES', 180, { min: 20, max: 2000 });
const SESSION_MESSAGE_CONTENT_MAX_CHARS = readPositiveIntEnv('CC_WEB_SESSION_MESSAGE_CONTENT_MAX_CHARS', 96 * 1024, { min: 4096 });
const SESSION_TOOL_INPUT_MAX_CHARS = readPositiveIntEnv('CC_WEB_SESSION_TOOL_INPUT_MAX_CHARS', 16 * 1024, { min: 1024 });
const SESSION_TOOL_RESULT_MAX_CHARS = readPositiveIntEnv('CC_WEB_SESSION_TOOL_RESULT_MAX_CHARS', 32 * 1024, { min: 1024 });
const SESSION_MAX_TOOL_CALLS_PER_MESSAGE = readPositiveIntEnv('CC_WEB_SESSION_MAX_TOOL_CALLS_PER_MESSAGE', 80, { min: 1, max: 1000 });
const HISTORY_PREFETCH_CHUNKS = readPositiveIntEnv('CC_WEB_HISTORY_PREFETCH_CHUNKS', 3, { min: 0, max: 20 });
const HISTORY_MAX_CHUNKS_PER_LOAD = readPositiveIntEnv('CC_WEB_HISTORY_MAX_CHUNKS_PER_LOAD', 8, { min: 1, max: 100 });
const CODEX_APP_STATE_MAX_BYTES = readPositiveIntEnv('CC_WEB_CODEX_APP_STATE_MAX_BYTES', 2 * 1024 * 1024, { min: 128 * 1024 });
const CODEX_APP_STATE_LOAD_MAX_BYTES = readPositiveIntEnv('CC_WEB_CODEX_APP_STATE_LOAD_MAX_BYTES', 4 * 1024 * 1024, { min: CODEX_APP_STATE_MAX_BYTES });
const CODEX_APP_STATE_FULL_TEXT_MAX_CHARS = readPositiveIntEnv('CC_WEB_CODEX_APP_STATE_FULL_TEXT_MAX_CHARS', 192 * 1024, { min: 4096 });
const CODEX_APP_STATE_MAP_VALUE_MAX_CHARS = readPositiveIntEnv('CC_WEB_CODEX_APP_STATE_MAP_VALUE_MAX_CHARS', 16 * 1024, { min: 1024 });
const CODEX_APP_STATE_MAX_MAP_ENTRIES = readPositiveIntEnv('CC_WEB_CODEX_APP_STATE_MAX_MAP_ENTRIES', 80, { min: 1, max: 1000 });
const RUN_OUTPUT_RECOVERY_MAX_BYTES = readPositiveIntEnv('CC_WEB_RUN_OUTPUT_RECOVERY_MAX_BYTES', 16 * 1024 * 1024, { min: 1024 * 1024 });
const RUN_OUTPUT_TAILER_MAX_READ_BYTES = readPositiveIntEnv('CC_WEB_RUN_OUTPUT_TAILER_MAX_READ_BYTES', 2 * 1024 * 1024, { min: 64 * 1024 });
const CROSS_CONVERSATION_MAX_CONTENT_CHARS = readPositiveIntEnv('CC_WEB_CROSS_CONVERSATION_MAX_CONTENT_CHARS', 64 * 1024, { min: 1024 });
const CODEX_APP_WORKER_DISABLED = /^(0|false|no|off)$/i.test(String(process.env.CC_WEB_CODEX_APP_WORKER || ''));
const CODEX_APP_WORKER_ENABLED = !CODEX_APP_WORKER_DISABLED;
const PERSIST_TRUNCATED_TEXT = '[cc-web: 内容过大,已截断以保护服务稳定性]';
const IMAGE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
const TEXT_PREVIEW_EXTENSIONS = new Set([
'.txt', '.md', '.markdown', '.json', '.jsonl', '.js', '.jsx', '.ts', '.tsx',
@@ -2096,17 +2127,389 @@ function clearRuntimeSessionId(session) {
setRuntimeSessionId(session, null);
}
function loadSession(id) {
function textByteLength(text) {
return Buffer.byteLength(String(text || ''), 'utf8');
}
function truncateTextValue(value, maxChars, marker = PERSIST_TRUNCATED_TEXT) {
const text = String(value || '');
if (!Number.isFinite(maxChars) || maxChars <= 0 || text.length <= maxChars) return text;
const suffix = `\n\n${marker}`;
const keep = Math.max(0, maxChars - suffix.length);
return `${text.slice(0, keep)}${suffix}`;
}
function sanitizePersistValue(value, options = {}, depth = 0, seen = new WeakSet()) {
const maxString = options.maxString || SESSION_MESSAGE_CONTENT_MAX_CHARS;
const maxDepth = options.maxDepth || 4;
const maxArray = options.maxArray || 80;
const maxKeys = options.maxKeys || 80;
if (value === null || value === undefined) return value;
if (typeof value === 'string') return truncateTextValue(value, maxString);
if (typeof value === 'number' || typeof value === 'boolean') return value;
if (typeof value === 'bigint') return String(value);
if (typeof value === 'function' || typeof value === 'symbol') return undefined;
if (value instanceof Date) return value.toISOString();
if (Buffer.isBuffer(value)) return `[Buffer ${value.length} bytes]`;
if (depth >= maxDepth) return '[Object truncated]';
if (typeof value !== 'object') return String(value);
if (seen.has(value)) return '[Circular]';
seen.add(value);
if (value instanceof Map) {
const output = {};
let index = 0;
for (const [key, item] of value.entries()) {
if (index >= maxKeys) {
output.__truncated = `已省略 ${value.size - index}`;
break;
}
output[String(key)] = sanitizePersistValue(item, options, depth + 1, seen);
index += 1;
}
seen.delete(value);
return output;
}
if (Array.isArray(value)) {
const limit = Math.min(value.length, maxArray);
const output = [];
for (let index = 0; index < limit; index += 1) {
output.push(sanitizePersistValue(value[index], options, depth + 1, seen));
}
if (value.length > limit) output.push({ __truncated: `已省略 ${value.length - limit}` });
seen.delete(value);
return output;
}
const output = {};
const keys = Object.keys(value);
const limit = Math.min(keys.length, maxKeys);
for (let index = 0; index < limit; index += 1) {
const key = keys[index];
const next = sanitizePersistValue(value[key], options, depth + 1, seen);
if (next !== undefined) output[key] = next;
}
if (keys.length > limit) output.__truncated = `已省略 ${keys.length - limit} 个字段`;
seen.delete(value);
return output;
}
function sanitizeToolCallsForPersist(toolCalls, limits = {}) {
const list = Array.isArray(toolCalls) ? toolCalls : [];
const maxCalls = limits.maxToolCalls || SESSION_MAX_TOOL_CALLS_PER_MESSAGE;
const selected = list.slice(0, maxCalls);
const sanitized = selected.map((toolCall) => {
const output = sanitizePersistValue(toolCall || {}, {
maxString: limits.toolResultMaxChars || SESSION_TOOL_RESULT_MAX_CHARS,
maxDepth: 5,
maxArray: 80,
maxKeys: 80,
});
if (!output || typeof output !== 'object' || Array.isArray(output)) return output;
if (Object.prototype.hasOwnProperty.call(toolCall || {}, 'input')) {
output.input = sanitizePersistValue(toolCall.input, {
maxString: limits.toolInputMaxChars || SESSION_TOOL_INPUT_MAX_CHARS,
maxDepth: 5,
maxArray: 80,
maxKeys: 80,
});
}
if (Object.prototype.hasOwnProperty.call(toolCall || {}, 'result')) {
output.result = sanitizePersistValue(toolCall.result, {
maxString: limits.toolResultMaxChars || SESSION_TOOL_RESULT_MAX_CHARS,
maxDepth: 5,
maxArray: 80,
maxKeys: 80,
});
}
return output;
});
if (list.length > maxCalls) {
sanitized.push({
id: 'ccweb-toolcalls-truncated',
name: 'cc-web',
kind: 'system',
done: true,
result: `工具调用过多,已省略 ${list.length - maxCalls} 条。`,
});
}
return sanitized;
}
function sanitizeMessageForPersist(message, limits = {}) {
if (!message || typeof message !== 'object') return message;
const output = {};
for (const [key, value] of Object.entries(message)) {
if (key === 'content') {
output.content = typeof value === 'string'
? truncateTextValue(value, limits.contentMaxChars || SESSION_MESSAGE_CONTENT_MAX_CHARS)
: sanitizePersistValue(value, {
maxString: limits.contentMaxChars || SESSION_MESSAGE_CONTENT_MAX_CHARS,
maxDepth: 6,
maxArray: 120,
maxKeys: 80,
});
continue;
}
if (key === 'toolCalls') {
output.toolCalls = sanitizeToolCallsForPersist(value, limits);
continue;
}
if (key === 'attachments') {
output.attachments = normalizeMessageAttachments(value);
continue;
}
output[key] = sanitizePersistValue(value, {
maxString: limits.metaMaxChars || 16 * 1024,
maxDepth: 5,
maxArray: 80,
maxKeys: 80,
});
}
return output;
}
function sanitizeMessagesForPersist(messages, limits = {}) {
const list = Array.isArray(messages) ? messages : [];
const maxMessages = limits.maxMessages || SESSION_PERSIST_MAX_MESSAGES;
const selected = list.length > maxMessages ? list.slice(-maxMessages) : list;
const output = selected.map((message) => sanitizeMessageForPersist(message, limits));
if (list.length > selected.length) {
output.unshift({
role: 'system',
content: `历史消息过多cc-web 已只保留最近 ${selected.length} 条用于本地展示,省略 ${list.length - selected.length} 条旧消息。`,
timestamp: new Date().toISOString(),
ccwebPersistenceNotice: true,
});
}
return output;
}
function sanitizeSessionForPersist(session, limits = {}) {
const output = {};
const skipKeys = new Set([
'ws',
'tailer',
'codexAppStateTimer',
'codexAppStateDirty',
'codexAppStateCleaned',
'toolOutputDeltas',
'agentMessageItems',
]);
for (const [key, value] of Object.entries(session || {})) {
if (skipKeys.has(key)) continue;
if (key === 'messages') {
output.messages = sanitizeMessagesForPersist(value, limits);
continue;
}
output[key] = sanitizePersistValue(value, {
maxString: limits.topLevelMaxChars || 16 * 1024,
maxDepth: 4,
maxArray: 100,
maxKeys: 100,
});
}
if (!Object.prototype.hasOwnProperty.call(output, 'messages')) output.messages = [];
return normalizeSession(output);
}
function buildSessionJsonForPersist(session) {
const attempts = [];
let maxMessages = SESSION_PERSIST_MAX_MESSAGES;
let contentMaxChars = SESSION_MESSAGE_CONTENT_MAX_CHARS;
let toolResultMaxChars = SESSION_TOOL_RESULT_MAX_CHARS;
for (let attempt = 0; attempt < 8; attempt += 1) {
const persisted = sanitizeSessionForPersist(session, {
maxMessages,
contentMaxChars,
toolResultMaxChars,
toolInputMaxChars: Math.min(SESSION_TOOL_INPUT_MAX_CHARS, toolResultMaxChars),
maxToolCalls: SESSION_MAX_TOOL_CALLS_PER_MESSAGE,
});
const json = JSON.stringify(persisted, null, 2);
const bytes = textByteLength(json);
attempts.push({ maxMessages, contentMaxChars, toolResultMaxChars, bytes });
if (bytes <= SESSION_MAX_JSON_BYTES) {
return {
json,
persisted,
guarded: attempt > 0 || (Array.isArray(session?.messages) && session.messages.length !== persisted.messages.length),
attempts,
};
}
maxMessages = Math.max(1, Math.floor(maxMessages / 2));
contentMaxChars = Math.max(2048, Math.floor(contentMaxChars / 2));
toolResultMaxChars = Math.max(1024, Math.floor(toolResultMaxChars / 2));
}
const fallback = sanitizeSessionForPersist({
...session,
messages: [{
role: 'system',
content: '当前会话历史过大cc-web 已跳过本地历史明细写入,仅保留会话元数据以保护服务稳定性。',
timestamp: new Date().toISOString(),
ccwebPersistenceNotice: true,
}],
}, {
maxMessages: 1,
contentMaxChars: 2048,
toolResultMaxChars: 1024,
toolInputMaxChars: 1024,
maxToolCalls: 1,
});
const json = JSON.stringify(fallback, null, 2);
attempts.push({ maxMessages: 1, contentMaxChars: 2048, toolResultMaxChars: 1024, bytes: textByteLength(json), fallback: true });
return { json, persisted: fallback, guarded: true, attempts };
}
function writeFileAtomicSync(filePath, content) {
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
try {
return normalizeSession(JSON.parse(fs.readFileSync(sessionPath(id), 'utf8')));
fs.writeFileSync(tmpPath, content);
fs.renameSync(tmpPath, filePath);
} finally {
try {
if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
} catch {}
}
}
function safeReadSessionJson(filePath, maxBytes, context = {}) {
const stat = fs.statSync(filePath);
if (stat.size > maxBytes) {
plog('WARN', 'session_json_load_skipped_oversized', {
sessionId: String(context.sessionId || '').slice(0, 8),
fileBytes: stat.size,
maxBytes,
});
return null;
}
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
function jsonStringFieldFromPreview(text, key) {
const pattern = new RegExp(`"${key}"\\s*:\\s*("(?:(?:\\\\.)|[^"\\\\])*"|null)`);
const match = pattern.exec(text);
if (!match) return null;
if (match[1] === 'null') return null;
try {
return JSON.parse(match[1]);
} catch {
return null;
}
}
function jsonBooleanFieldFromPreview(text, key) {
const pattern = new RegExp(`"${key}"\\s*:\\s*(true|false)`);
const match = pattern.exec(text);
return match ? match[1] === 'true' : false;
}
function readSessionPreview(filePath, stat) {
const headSize = Math.min(stat.size, SESSION_META_PREVIEW_BYTES);
const tailSize = Math.min(stat.size, SESSION_META_PREVIEW_BYTES);
const fd = fs.openSync(filePath, 'r');
try {
const head = Buffer.alloc(headSize);
fs.readSync(fd, head, 0, headSize, 0);
if (stat.size <= headSize) return head.toString('utf8');
const tail = Buffer.alloc(tailSize);
fs.readSync(fd, tail, 0, tailSize, Math.max(0, stat.size - tailSize));
return `${head.toString('utf8')}\n${tail.toString('utf8')}`;
} finally {
fs.closeSync(fd);
}
}
function loadSessionMetaFromFile(filePath) {
const fallbackId = path.basename(filePath, '.json');
try {
const stat = fs.statSync(filePath);
if (stat.size <= SESSION_META_FULL_PARSE_MAX_BYTES) {
const session = normalizeSession(JSON.parse(fs.readFileSync(filePath, 'utf8')));
const cwd = session.cwd || '';
return {
id: session.id || fallbackId,
title: session.title || 'Untitled',
updated: session.updated || session.created || stat.mtime.toISOString(),
created: session.created || null,
pinnedAt: session.pinnedAt || null,
hasUnread: !!session.hasUnread,
agent: getSessionAgent(session),
cwd,
projectName: cwd ? path.basename(cwd.replace(/[\\/]+$/, '')) : '',
fileBytes: stat.size,
oversized: stat.size > SESSION_LOAD_MAX_BYTES,
};
}
const preview = readSessionPreview(filePath, stat);
const cwd = jsonStringFieldFromPreview(preview, 'cwd') || '';
return {
id: jsonStringFieldFromPreview(preview, 'id') || fallbackId,
title: jsonStringFieldFromPreview(preview, 'title') || 'Untitled',
updated: jsonStringFieldFromPreview(preview, 'updated') || jsonStringFieldFromPreview(preview, 'updatedAt') || stat.mtime.toISOString(),
created: jsonStringFieldFromPreview(preview, 'created') || null,
pinnedAt: jsonStringFieldFromPreview(preview, 'pinnedAt') || null,
hasUnread: jsonBooleanFieldFromPreview(preview, 'hasUnread'),
agent: normalizeAgent(jsonStringFieldFromPreview(preview, 'agent')),
cwd,
projectName: cwd ? path.basename(cwd.replace(/[\\/]+$/, '')) : '',
fileBytes: stat.size,
oversized: stat.size > SESSION_LOAD_MAX_BYTES,
};
} catch (err) {
plog('WARN', 'session_meta_load_failed', {
sessionId: fallbackId.slice(0, 8),
error: err?.message || String(err || ''),
});
return null;
}
}
function loadSession(id) {
const normalizedId = sanitizeId(id || '');
if (!normalizedId) return null;
try {
const filePath = sessionPath(normalizedId);
if (!fs.existsSync(filePath)) return null;
return normalizeSession(safeReadSessionJson(filePath, SESSION_LOAD_MAX_BYTES, { sessionId: normalizedId }));
} catch (err) {
plog('WARN', 'session_load_failed', {
sessionId: normalizedId.slice(0, 8),
error: err?.message || String(err || ''),
});
return null;
}
}
function saveSession(session) {
if (!session?.id) return false;
normalizeSession(session);
fs.writeFileSync(sessionPath(session.id), JSON.stringify(session, null, 2));
const targetPath = sessionPath(session.id);
try {
const result = buildSessionJsonForPersist(session);
writeFileAtomicSync(targetPath, result.json);
if (result.guarded) {
const lastAttempt = result.attempts[result.attempts.length - 1] || {};
plog('WARN', 'session_save_guard_applied', {
sessionId: String(session.id || '').slice(0, 8),
fileBytes: lastAttempt.bytes || textByteLength(result.json),
maxBytes: SESSION_MAX_JSON_BYTES,
attempts: result.attempts,
});
}
return true;
} catch (err) {
plog('ERROR', 'session_save_failed', {
sessionId: String(session.id || '').slice(0, 8),
error: err?.message || String(err || ''),
});
return false;
}
}
function compareSessionsForList(a, b) {
@@ -2130,19 +2533,26 @@ function sessionModelLabel(session) {
return isClaudeSession(session) ? (modelShortName(session.model) || session.model) : session.model;
}
function splitHistoryMessages(messages) {
function splitHistoryMessages(messages, options = {}) {
const list = Array.isArray(messages) ? messages : [];
if (list.length <= INITIAL_HISTORY_COUNT) {
return { recentMessages: list, olderChunks: [] };
return { recentMessages: list, olderChunks: [], historyRemaining: 0, historyBuffered: list.length };
}
const prefetchChunks = Math.max(0, Math.min(
Number.isFinite(options.prefetchChunks) ? options.prefetchChunks : HISTORY_PREFETCH_CHUNKS,
HISTORY_MAX_CHUNKS_PER_LOAD,
));
const recentMessages = list.slice(-INITIAL_HISTORY_COUNT);
const older = list.slice(0, -INITIAL_HISTORY_COUNT);
const olderChunks = [];
for (let end = older.length; end > 0; end -= HISTORY_CHUNK_SIZE) {
let historyRemaining = older.length;
for (let end = older.length; end > 0 && olderChunks.length < prefetchChunks; end -= HISTORY_CHUNK_SIZE) {
const start = Math.max(0, end - HISTORY_CHUNK_SIZE);
olderChunks.push(older.slice(start, end));
historyRemaining = start;
}
return { recentMessages, olderChunks };
const historyBuffered = recentMessages.length + olderChunks.reduce((total, chunk) => total + chunk.length, 0);
return { recentMessages, olderChunks, historyRemaining, historyBuffered };
}
const IS_WIN = process.platform === 'win32';
@@ -2179,14 +2589,25 @@ function codexAppStatePath(sessionId) {
return path.join(runDir(sessionId), CODEX_APP_STATE_FILE);
}
function mapToPairs(value) {
function mapToPairs(value, options = {}) {
const maxEntries = options.maxEntries || CODEX_APP_STATE_MAX_MAP_ENTRIES;
const maxValueChars = options.maxValueChars || CODEX_APP_STATE_MAP_VALUE_MAX_CHARS;
const output = [];
if (value instanceof Map) {
return Array.from(value.entries()).map(([key, item]) => [String(key), String(item || '')]);
for (const [key, item] of value.entries()) {
if (output.length >= maxEntries) break;
output.push([String(key), truncateTextValue(item, maxValueChars)]);
}
return output;
}
if (value && typeof value === 'object') {
return Object.entries(value).map(([key, item]) => [String(key), String(item || '')]);
for (const [key, item] of Object.entries(value)) {
if (output.length >= maxEntries) break;
output.push([String(key), truncateTextValue(item, maxValueChars)]);
}
return output;
}
return [];
return output;
}
function codexAppTurnKey(sessionId, state = {}) {
@@ -2196,7 +2617,7 @@ function codexAppTurnKey(sessionId, state = {}) {
return `${sanitizeId(sessionId)}:${threadId}:${turnId}:${startedAt}`;
}
function serializeCodexAppEntry(sessionId, entry) {
function serializeCodexAppEntry(sessionId, entry, limits = {}) {
return {
version: 1,
agent: 'codexapp',
@@ -2208,25 +2629,83 @@ function serializeCodexAppEntry(sessionId, entry) {
startedAt: entry.startedAt || new Date().toISOString(),
updatedAt: new Date().toISOString(),
clientUserMessageId: entry.clientUserMessageId || null,
fullText: entry.fullText || '',
toolCalls: Array.isArray(entry.toolCalls) ? entry.toolCalls : [],
toolOutputDeltas: mapToPairs(entry.toolOutputDeltas),
agentMessageItems: mapToPairs(entry.agentMessageItems),
fullText: truncateTextValue(entry.fullText || '', limits.fullTextMaxChars || CODEX_APP_STATE_FULL_TEXT_MAX_CHARS),
toolCalls: sanitizeToolCallsForPersist(Array.isArray(entry.toolCalls) ? entry.toolCalls : [], {
maxToolCalls: limits.maxToolCalls || CODEX_APP_STATE_MAX_MAP_ENTRIES,
toolInputMaxChars: limits.toolInputMaxChars || SESSION_TOOL_INPUT_MAX_CHARS,
toolResultMaxChars: limits.toolResultMaxChars || SESSION_TOOL_RESULT_MAX_CHARS,
contentMaxChars: limits.fullTextMaxChars || CODEX_APP_STATE_FULL_TEXT_MAX_CHARS,
}),
toolOutputDeltas: mapToPairs(entry.toolOutputDeltas, {
maxEntries: limits.maxMapEntries || CODEX_APP_STATE_MAX_MAP_ENTRIES,
maxValueChars: limits.mapValueMaxChars || CODEX_APP_STATE_MAP_VALUE_MAX_CHARS,
}),
agentMessageItems: mapToPairs(entry.agentMessageItems, {
maxEntries: limits.maxMapEntries || CODEX_APP_STATE_MAX_MAP_ENTRIES,
maxValueChars: limits.mapValueMaxChars || CODEX_APP_STATE_MAP_VALUE_MAX_CHARS,
}),
lastUsage: entry.lastUsage || null,
lastError: entry.lastError || null,
userAborted: !!entry.userAborted,
};
}
function buildCodexAppStateJson(sessionId, entry) {
const attempts = [];
let fullTextMaxChars = CODEX_APP_STATE_FULL_TEXT_MAX_CHARS;
let toolResultMaxChars = SESSION_TOOL_RESULT_MAX_CHARS;
let mapValueMaxChars = CODEX_APP_STATE_MAP_VALUE_MAX_CHARS;
let maxToolCalls = CODEX_APP_STATE_MAX_MAP_ENTRIES;
for (let attempt = 0; attempt < 6; attempt += 1) {
const state = serializeCodexAppEntry(sessionId, entry, {
fullTextMaxChars,
toolResultMaxChars,
mapValueMaxChars,
maxToolCalls,
});
const json = JSON.stringify(state);
const bytes = textByteLength(json);
attempts.push({ fullTextMaxChars, toolResultMaxChars, mapValueMaxChars, maxToolCalls, bytes });
if (bytes <= CODEX_APP_STATE_MAX_BYTES) return { json, attempts };
fullTextMaxChars = Math.max(4096, Math.floor(fullTextMaxChars / 2));
toolResultMaxChars = Math.max(1024, Math.floor(toolResultMaxChars / 2));
mapValueMaxChars = Math.max(1024, Math.floor(mapValueMaxChars / 2));
maxToolCalls = Math.max(5, Math.floor(maxToolCalls / 2));
}
const fallback = serializeCodexAppEntry(sessionId, {
...entry,
fullText: truncateTextValue(entry.fullText || '', 2048),
toolCalls: [],
toolOutputDeltas: new Map(),
agentMessageItems: new Map(),
}, {
fullTextMaxChars: 2048,
toolResultMaxChars: 1024,
mapValueMaxChars: 1024,
maxToolCalls: 0,
});
fallback.stateGuarded = true;
const json = JSON.stringify(fallback);
attempts.push({ fallback: true, bytes: textByteLength(json) });
return { json, attempts };
}
function writeCodexAppTurnState(sessionId, entry) {
if (!entry || entry.codexAppStateCleaned) return;
try {
const dir = runDir(sessionId);
fs.mkdirSync(dir, { recursive: true });
const statePath = codexAppStatePath(sessionId);
const tmpPath = `${statePath}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tmpPath, JSON.stringify(serializeCodexAppEntry(sessionId, entry)));
fs.renameSync(tmpPath, statePath);
const result = buildCodexAppStateJson(sessionId, entry);
writeFileAtomicSync(statePath, result.json);
if (result.attempts.length > 1) {
plog('WARN', 'codex_app_state_guard_applied', {
sessionId: String(sessionId || '').slice(0, 8),
attempts: result.attempts,
});
}
} catch (err) {
plog('WARN', 'codex_app_state_persist_failed', {
sessionId: String(sessionId || '').slice(0, 8),
@@ -2271,6 +2750,15 @@ function loadCodexAppTurnState(sessionId) {
try {
const statePath = codexAppStatePath(sessionId);
if (!fs.existsSync(statePath)) return null;
const stat = fs.statSync(statePath);
if (stat.size > CODEX_APP_STATE_LOAD_MAX_BYTES) {
plog('WARN', 'codex_app_state_load_skipped_oversized', {
sessionId: String(sessionId || '').slice(0, 8),
fileBytes: stat.size,
maxBytes: CODEX_APP_STATE_LOAD_MAX_BYTES,
});
return { __invalid: true, reason: 'too_large', fileBytes: stat.size, agent: 'codexapp' };
}
const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
if (!state || state.agent !== 'codexapp') return null;
return state;
@@ -2283,6 +2771,20 @@ function loadCodexAppTurnState(sessionId) {
}
}
function appendCodexAppRecoveryNotice(sessionId, content) {
const session = loadSession(sessionId);
if (!session || !isCodexAppSession(session)) return false;
session.messages.push({
role: 'system',
content,
timestamp: new Date().toISOString(),
codexAppRecoveredPartial: true,
});
session.hasUnread = true;
session.updated = new Date().toISOString();
return saveSession(session);
}
function hasCodexAppTurnMessage(session, turnKey) {
return Array.isArray(session?.messages)
&& session.messages.some((message) => message?.codexAppTurnKey === turnKey);
@@ -2290,6 +2792,14 @@ function hasCodexAppTurnMessage(session, turnKey) {
function recoverCodexAppTurnState(sessionId) {
const state = loadCodexAppTurnState(sessionId);
if (state?.__invalid) {
appendCodexAppRecoveryNotice(
sessionId,
`Codex App 上次运行状态文件异常(${state.reason || 'invalid'}),已跳过恢复并清理运行目录,避免 cc-web 启动时占用过多内存。`,
);
cleanRunDir(sessionId);
return true;
}
if (!state) {
cleanRunDir(sessionId);
return false;
@@ -2301,8 +2811,13 @@ function recoverCodexAppTurnState(sessionId) {
return true;
}
const fullText = String(state.fullText || '');
const toolCalls = Array.isArray(state.toolCalls) ? state.toolCalls : [];
const fullText = truncateTextValue(state.fullText || '', CODEX_APP_STATE_FULL_TEXT_MAX_CHARS);
const toolCalls = sanitizeToolCallsForPersist(Array.isArray(state.toolCalls) ? state.toolCalls : [], {
maxToolCalls: CODEX_APP_STATE_MAX_MAP_ENTRIES,
toolInputMaxChars: SESSION_TOOL_INPUT_MAX_CHARS,
toolResultMaxChars: SESSION_TOOL_RESULT_MAX_CHARS,
contentMaxChars: CODEX_APP_STATE_FULL_TEXT_MAX_CHARS,
});
const hasRecoverableContent = fullText.trim() || toolCalls.length > 0;
const turnKey = codexAppTurnKey(sessionId, state);
let changed = false;
@@ -2370,21 +2885,21 @@ function sendSessionList(ws) {
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 {}
const meta = loadSessionMetaFromFile(path.join(SESSIONS_DIR, f));
if (!meta) continue;
sessions.push({
id: meta.id,
title: meta.title || 'Untitled',
updated: meta.updated,
pinnedAt: meta.pinnedAt || null,
hasUnread: !!meta.hasUnread,
agent: normalizeAgent(meta.agent),
cwd: meta.cwd || '',
projectName: meta.projectName || '',
isRunning: isSessionRunning(meta.id),
oversized: !!meta.oversized,
fileBytes: meta.fileBytes || 0,
});
}
sessions.sort(compareSessionsForList);
wsSend(ws, { type: 'session_list', sessions });
@@ -2432,26 +2947,25 @@ function listConversationSummaries(args = {}, sourceSessionId = '') {
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 {}
const meta = loadSessionMetaFromFile(path.join(SESSIONS_DIR, file));
if (!meta) continue;
const agent = normalizeAgent(meta.agent);
const running = isSessionRunning(meta.id);
const status = running ? 'running' : 'idle';
if (agentFilter && agent !== agentFilter) continue;
if (statusFilter !== 'all' && status !== statusFilter) continue;
conversations.push({
id: meta.id,
title: meta.title || 'Untitled',
agent,
status,
updatedAt: meta.updated || meta.created || null,
pinnedAt: meta.pinnedAt || null,
cwd: meta.cwd || '',
projectName: meta.projectName || '',
isCurrent: meta.id === sourceSessionId,
oversized: !!meta.oversized,
});
}
} catch {}
@@ -2466,12 +2980,12 @@ function listConversationSummaries(args = {}, sourceSessionId = '') {
function buildCrossConversationRuntimeText(sourceSession, content) {
const sourceTitle = sourceSession?.title || 'Untitled';
const sourceId = sourceSession?.id || '';
return `来自「${sourceTitle}」对话ID: ${sourceId})的消息:\n\n${content}`;
return `来自「${sourceTitle}」对话ID: ${sourceId})的消息:\n\n${truncateTextValue(content, CROSS_CONVERSATION_MAX_CONTENT_CHARS)}`;
}
function buildCrossConversationReplyContent(targetSession, replyText) {
const targetTitle = targetSession?.title || 'Untitled';
return `线程「${targetTitle}」已返回消息:\n\n${replyText}`;
return `线程「${targetTitle}」已返回消息:\n\n${truncateTextValue(replyText, CROSS_CONVERSATION_MAX_CONTENT_CHARS)}`;
}
function extractCrossConversationReplyText(content) {
@@ -2497,7 +3011,9 @@ function extractCrossConversationReplyText(content) {
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 content = typeof args.content === 'string'
? truncateTextValue(args.content.trim(), CROSS_CONVERSATION_MAX_CONTENT_CHARS)
: '';
const expectReply = !!options.expectReply;
if (!sourceId) {
@@ -2676,7 +3192,7 @@ function completeCrossConversationReply(requestId, entry = {}, targetSession = n
}
pending.status = 'ready';
pending.replyText = replyText;
pending.replyText = truncateTextValue(replyText, CROSS_CONVERSATION_MAX_CONTENT_CHARS);
pending.completedAt = new Date().toISOString();
if (targetSession?.title) pending.targetTitle = targetSession.title;
return deliverCrossConversationReply(normalizedRequestId);
@@ -2747,7 +3263,19 @@ class FileTailer {
try {
const stat = fs.statSync(this.filePath);
if (stat.size <= this.offset) return;
const buf = Buffer.alloc(stat.size - this.offset);
const unreadBytes = stat.size - this.offset;
if (unreadBytes > RUN_OUTPUT_TAILER_MAX_READ_BYTES) {
plog('WARN', 'runtime_output_tailer_skipped_oversized_gap', {
file: path.basename(this.filePath),
unreadBytes,
maxBytes: RUN_OUTPUT_TAILER_MAX_READ_BYTES,
});
this.offset = Math.max(0, stat.size - RUN_OUTPUT_TAILER_MAX_READ_BYTES);
this.buffer = '';
}
const readBytes = stat.size - this.offset;
if (readBytes <= 0) return;
const buf = Buffer.alloc(readBytes);
const fd = fs.openSync(this.filePath, 'r');
fs.readSync(fd, buf, 0, buf.length, this.offset);
fs.closeSync(fd);
@@ -3124,6 +3652,27 @@ function recoverProcesses() {
console.log(`[recovery] Processing completed output for session ${sessionId}`);
plog('INFO', 'recovery_dead', { sessionId: sessionId.slice(0, 8), pid, agent });
if (fs.existsSync(outputPath)) {
const outputStat = fs.statSync(outputPath);
if (outputStat.size > RUN_OUTPUT_RECOVERY_MAX_BYTES) {
plog('WARN', 'recovery_output_skipped_oversized', {
sessionId: sessionId.slice(0, 8),
pid,
agent,
fileBytes: outputStat.size,
maxBytes: RUN_OUTPUT_RECOVERY_MAX_BYTES,
});
if (session) {
session.messages.push({
role: 'system',
content: '服务重启期间的运行输出文件过大cc-web 已跳过恢复完整输出,避免启动时占用过多内存。',
timestamp: new Date().toISOString(),
});
session.updated = new Date().toISOString();
saveSession(session);
}
try { fs.rmSync(dir, { recursive: true }); } catch {}
continue;
}
const tempEntry = { pid: 0, ws: null, agent, fullText: '', toolCalls: [], lastCost: null, lastUsage: null, lastError: null, errorSent: false, tailer: null };
const content = fs.readFileSync(outputPath, 'utf8');
for (const line of content.split('\n')) {
@@ -3401,6 +3950,9 @@ wss.on('connection', (ws, req) => {
case 'load_session':
handleLoadSession(ws, msg.sessionId);
break;
case 'load_history_page':
handleLoadHistoryPage(ws, msg);
break;
case 'delete_session':
handleDeleteSession(ws, msg.sessionId);
break;
@@ -3987,6 +4539,29 @@ function handleNewSession(ws, msg) {
sendSessionList(ws);
}
function handleLoadHistoryPage(ws, msg = {}) {
const sessionId = sanitizeId(msg.sessionId || '');
const session = loadSession(sessionId);
if (!session) {
return wsSend(ws, { type: 'error', message: 'Session not found' });
}
const list = Array.isArray(session.messages) ? session.messages : [];
const requestedBefore = Number.parseInt(String(msg.before || ''), 10);
const before = Number.isFinite(requestedBefore)
? Math.max(0, Math.min(list.length, requestedBefore))
: Math.max(0, list.length - INITIAL_HISTORY_COUNT);
const end = before;
const start = Math.max(0, end - HISTORY_CHUNK_SIZE);
wsSend(ws, {
type: 'session_history_chunk',
sessionId: session.id,
messages: list.slice(start, end),
remaining: 0,
historyCursor: start,
historyTruncated: start > 0,
});
}
function handleLoadSession(ws, sessionId) {
const session = loadSession(sessionId);
if (!session) {
@@ -4000,7 +4575,7 @@ function handleLoadSession(ws, sessionId) {
saveSession(session);
}
}
const { recentMessages, olderChunks } = splitHistoryMessages(session.messages);
const { recentMessages, olderChunks, historyRemaining, historyBuffered } = splitHistoryMessages(session.messages);
const effectiveCwd = session.cwd || activeProcesses.get(sessionId)?.cwd || activeCodexAppTurns.get(sessionId)?.cwd || null;
// Detach ws from any previous session's process
@@ -4029,7 +4604,9 @@ function handleLoadSession(ws, sessionId) {
totalCost: session.totalCost || 0,
totalUsage: session.totalUsage || null,
historyTotal: session.messages.length,
historyBuffered: recentMessages.length,
historyBuffered,
historyCursor: historyRemaining,
historyTruncated: historyRemaining > 0,
historyPending: olderChunks.length > 0,
updated: session.updated,
isRunning: isSessionRunning(sessionId),
@@ -4042,6 +4619,8 @@ function handleLoadSession(ws, sessionId) {
sessionId: session.id,
messages: chunk,
remaining: Math.max(0, olderChunks.length - index - 1),
historyCursor: index === olderChunks.length - 1 ? historyRemaining : null,
historyTruncated: historyRemaining > 0,
});
});
}
@@ -4059,8 +4638,8 @@ function handleLoadSession(ws, sessionId) {
wsSend(ws, {
type: 'resume_generating',
sessionId,
text: entry.fullText || '',
toolCalls: entry.toolCalls || [],
text: truncateTextValue(entry.fullText || '', SESSION_MESSAGE_CONTENT_MAX_CHARS),
toolCalls: sanitizeToolCallsForPersist(entry.toolCalls || []),
});
} else if (activeCodexAppTurns.has(sessionId)) {
const entry = activeCodexAppTurns.get(sessionId);
@@ -4075,8 +4654,8 @@ function handleLoadSession(ws, sessionId) {
wsSend(ws, {
type: 'resume_generating',
sessionId,
text: entry.fullText || '',
toolCalls: entry.toolCalls || [],
text: truncateTextValue(entry.fullText || '', SESSION_MESSAGE_CONTENT_MAX_CHARS),
toolCalls: sanitizeToolCallsForPersist(entry.toolCalls || []),
});
}
}
@@ -5024,6 +5603,7 @@ function buildCodexAppClientSpec() {
runtimeMode: runtimeConfig?.mode || 'local',
codeHome: env.CODEX_HOME || '',
apiKeyHash: runtimeConfig?.apiKey ? crypto.createHash('sha256').update(runtimeConfig.apiKey).digest('hex') : '',
worker: CODEX_APP_WORKER_ENABLED,
});
return {
@@ -5048,9 +5628,15 @@ function getCodexAppClient() {
codexAppClientSignature = '';
}
if (codexAppClient && !codexAppClient.isRunning()) {
try { codexAppClient.stop(); } catch {}
codexAppClient = null;
codexAppClientSignature = '';
}
if (!codexAppClient || !codexAppClient.isRunning()) {
const signature = spec.signature;
codexAppClient = createCodexAppServerClient({
const clientOptions = {
command: spec.command,
args: spec.args,
env: spec.env,
@@ -5060,8 +5646,15 @@ function getCodexAppClient() {
onExit: (info) => handleCodexAppServerExit(signature, info),
onLog: (level, event, data) => plog(level, event, data),
postInitialize: codexAppPostInitialize,
});
};
codexAppClient = CODEX_APP_WORKER_ENABLED
? createCodexAppWorkerClient(clientOptions)
: createCodexAppServerClient(clientOptions);
codexAppClientSignature = signature;
plog('INFO', 'codex_app_client_created', {
worker: CODEX_APP_WORKER_ENABLED,
command: path.basename(spec.command || ''),
});
}
return { client: codexAppClient };
@@ -5189,11 +5782,18 @@ function handleCodexAppTurnComplete(sessionId, options = {}) {
const session = loadSession(sessionId);
const turnKey = codexAppTurnKey(sessionId, entry);
if (session && ((entry.fullText || '').trim() || (entry.toolCalls || []).length > 0) && !hasCodexAppTurnMessage(session, turnKey)) {
const assistantContent = truncateTextValue(entry.fullText || '', SESSION_MESSAGE_CONTENT_MAX_CHARS);
const assistantToolCalls = sanitizeToolCallsForPersist(entry.toolCalls || [], {
maxToolCalls: SESSION_MAX_TOOL_CALLS_PER_MESSAGE,
toolInputMaxChars: SESSION_TOOL_INPUT_MAX_CHARS,
toolResultMaxChars: SESSION_TOOL_RESULT_MAX_CHARS,
contentMaxChars: SESSION_MESSAGE_CONTENT_MAX_CHARS,
});
if (session && (assistantContent.trim() || assistantToolCalls.length > 0) && !hasCodexAppTurnMessage(session, turnKey)) {
session.messages.push({
role: 'assistant',
content: entry.fullText || '',
toolCalls: entry.toolCalls || [],
content: assistantContent,
toolCalls: assistantToolCalls,
timestamp: new Date().toISOString(),
codexAppTurnKey: turnKey,
codexAppThreadId: entry.threadId || null,