diff --git a/lib/codex-app-runtime.js b/lib/codex-app-runtime.js index 3f8bf72..3b8175b 100644 --- a/lib/codex-app-runtime.js +++ b/lib/codex-app-runtime.js @@ -5,6 +5,24 @@ const CODEX_APP_ONCE_NOTICE_PATTERNS = [ /^Heads up: Long threads and multiple compactions/i, ]; +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 RUNTIME_FULL_TEXT_MAX_CHARS = readPositiveIntEnv('CC_WEB_CODEX_APP_RUNTIME_FULL_TEXT_MAX_CHARS', 256 * 1024, { min: 4096 }); +const RUNTIME_AGENT_ITEM_MAX_CHARS = readPositiveIntEnv('CC_WEB_CODEX_APP_RUNTIME_AGENT_ITEM_MAX_CHARS', 128 * 1024, { min: 4096 }); +const RUNTIME_TOOL_DELTA_MAX_CHARS = readPositiveIntEnv('CC_WEB_CODEX_APP_RUNTIME_TOOL_DELTA_MAX_CHARS', 64 * 1024, { min: 1024 }); +const RUNTIME_TOOL_RESULT_MAX_CHARS = readPositiveIntEnv('CC_WEB_CODEX_APP_RUNTIME_TOOL_RESULT_MAX_CHARS', 32 * 1024, { min: 1024 }); +const RUNTIME_TOOL_INPUT_MAX_CHARS = readPositiveIntEnv('CC_WEB_CODEX_APP_RUNTIME_TOOL_INPUT_MAX_CHARS', 16 * 1024, { min: 1024 }); +const RUNTIME_STREAM_DELTA_MAX_CHARS = readPositiveIntEnv('CC_WEB_CODEX_APP_STREAM_DELTA_MAX_CHARS', 16 * 1024, { min: 1024 }); +const RUNTIME_MAX_TOOL_CALLS = readPositiveIntEnv('CC_WEB_CODEX_APP_RUNTIME_MAX_TOOL_CALLS', 120, { min: 1, max: 1000 }); +const RUNTIME_TRUNCATED_HEAD = '[cc-web: 前文过长,已保留尾部以保护服务稳定性]\n'; +const RUNTIME_TRUNCATED_TAIL = '\n[cc-web: 内容过长,已截断以保护服务稳定性]'; + function createCodexAppRuntime(deps = {}) { const { wsSend, @@ -13,10 +31,87 @@ function createCodexAppRuntime(deps = {}) { truncateObj, } = deps; + function limitPreviewValue(value, options = {}, depth = 0, seen = new WeakSet()) { + const maxString = options.maxString || RUNTIME_TOOL_RESULT_MAX_CHARS; + const maxDepth = options.maxDepth || 4; + const maxArray = options.maxArray || 50; + const maxKeys = options.maxKeys || 60; + + if (value === null || value === undefined) return value; + if (typeof value === 'string') return truncateEnd(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 (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 (Array.isArray(value)) { + const output = []; + const limit = Math.min(value.length, maxArray); + for (let index = 0; index < limit; index += 1) { + output.push(limitPreviewValue(value[index], options, depth + 1, seen)); + } + if (value.length > limit) output.push({ __truncated: `omitted ${value.length - limit} items` }); + 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 = limitPreviewValue(value[key], options, depth + 1, seen); + if (next !== undefined) output[key] = next; + } + if (keys.length > limit) output.__truncated = `omitted ${keys.length - limit} fields`; + seen.delete(value); + return output; + } + + function safeStringifyPreview(value, maxLen = RUNTIME_TOOL_RESULT_MAX_CHARS, options = {}) { + if (typeof value === 'string') return truncateEnd(value, maxLen); + try { + const limited = limitPreviewValue(value, { + maxString: Math.min(maxLen, options.maxString || maxLen), + maxDepth: options.maxDepth || 4, + maxArray: options.maxArray || 50, + maxKeys: options.maxKeys || 60, + }); + return truncateEnd(JSON.stringify(limited, null, 2), maxLen); + } catch { + return truncateEnd(String(value), maxLen); + } + } + + function truncateEnd(value, maxLen) { + const text = String(value || ''); + if (!Number.isFinite(maxLen) || maxLen <= 0 || text.length <= maxLen) return text; + const keep = Math.max(0, maxLen - RUNTIME_TRUNCATED_TAIL.length); + return `${text.slice(0, keep)}${RUNTIME_TRUNCATED_TAIL}`; + } + + function keepTail(value, maxLen) { + const text = String(value || ''); + if (!Number.isFinite(maxLen) || maxLen <= 0 || text.length <= maxLen) return text; + const keep = Math.max(0, maxLen - RUNTIME_TRUNCATED_HEAD.length); + return `${RUNTIME_TRUNCATED_HEAD}${text.slice(-keep)}`; + } + + function appendCappedText(current, addition, maxLen) { + return keepTail(`${String(current || '')}${String(addition || '')}`, maxLen); + } + + function capStreamDelta(text) { + return truncateEnd(text, RUNTIME_STREAM_DELTA_MAX_CHARS); + } + function truncate(value, maxLen) { - if (typeof truncateObj === 'function') return truncateObj(value, maxLen); - const text = typeof value === 'string' ? value : JSON.stringify(value); - return text.length > maxLen ? `${text.slice(0, maxLen)}...` : value; + if (typeof value === 'string') return truncateEnd(value, maxLen); + return safeStringifyPreview(value, maxLen, { maxString: maxLen }); } const shownOnceNoticeKeys = new Set(); @@ -112,28 +207,51 @@ function createCodexAppRuntime(deps = {}) { if (!item) return null; switch (item.type) { case 'commandExecution': - return { command: item.command || '' }; + return { command: truncateEnd(item.command || '', RUNTIME_TOOL_INPUT_MAX_CHARS) }; case 'mcpToolCall': return { - server: item.server || '', - tool: item.tool || '', - arguments: item.arguments ?? null, + server: truncateEnd(item.server || '', 256), + tool: truncateEnd(item.tool || '', 256), + arguments: limitPreviewValue(item.arguments ?? null, { + maxString: RUNTIME_TOOL_INPUT_MAX_CHARS, + maxDepth: 5, + maxArray: 80, + maxKeys: 80, + }), }; case 'fileChange': - return { changes: item.changes || [] }; + return { changes: limitPreviewValue(item.changes || [], { maxString: RUNTIME_TOOL_INPUT_MAX_CHARS, maxDepth: 5 }) }; case 'reasoning': - return { content: item.content || [], summary: item.summary || [] }; + return { + content: limitPreviewValue(item.content || [], { maxString: RUNTIME_TOOL_INPUT_MAX_CHARS, maxDepth: 4 }), + summary: limitPreviewValue(item.summary || [], { maxString: RUNTIME_TOOL_INPUT_MAX_CHARS, maxDepth: 4 }), + }; case 'dynamicToolCall': - return { tool: item.tool || '', namespace: item.namespace || null, arguments: item.arguments ?? null }; + return { + tool: truncateEnd(item.tool || '', 256), + namespace: truncateEnd(item.namespace || '', 256) || null, + arguments: limitPreviewValue(item.arguments ?? null, { maxString: RUNTIME_TOOL_INPUT_MAX_CHARS, maxDepth: 5 }), + }; case 'collabAgentToolCall': return { - tool: item.tool || '', - prompt: item.prompt || null, - receiverThreadIds: item.receiverThreadIds || [], - agentsStates: item.agentsStates || {}, + tool: truncateEnd(item.tool || '', 256), + prompt: truncateEnd(item.prompt || '', RUNTIME_TOOL_INPUT_MAX_CHARS) || null, + receiverThreadIds: limitPreviewValue(item.receiverThreadIds || [], { maxString: 512, maxDepth: 3 }), + agentsStates: limitPreviewValue(item.agentsStates || {}, { maxString: RUNTIME_TOOL_INPUT_MAX_CHARS, maxDepth: 5 }), + }; + case 'imageGeneration': + return { + prompt: truncateEnd(item.prompt || item.query || '', RUNTIME_TOOL_INPUT_MAX_CHARS), + size: item.size || null, + quality: item.quality || null, }; default: - return truncate(item, 500); + return limitPreviewValue(item, { + maxString: Math.min(500, RUNTIME_TOOL_INPUT_MAX_CHARS), + maxDepth: 4, + maxArray: 30, + maxKeys: 40, + }); } } @@ -196,47 +314,49 @@ function createCodexAppRuntime(deps = {}) { if (!result) return ''; if (Array.isArray(result.content)) { const text = result.content.map((part) => { - if (typeof part?.text === 'string') return part.text; - try { - return JSON.stringify(part); - } catch { - return String(part); - } + if (typeof part?.text === 'string') return truncateEnd(part.text, RUNTIME_TOOL_RESULT_MAX_CHARS); + return safeStringifyPreview(part, RUNTIME_TOOL_RESULT_MAX_CHARS); }).filter(Boolean).join('\n'); - if (text) return text; - } - try { - return JSON.stringify(result, null, 2); - } catch { - return String(result); + if (text) return truncateEnd(text, RUNTIME_TOOL_RESULT_MAX_CHARS); } + return safeStringifyPreview(result, RUNTIME_TOOL_RESULT_MAX_CHARS); } function itemResult(item) { if (!item) return ''; switch (item.type) { case 'commandExecution': - return item.aggregatedOutput || ''; + return truncateEnd(item.aggregatedOutput || '', RUNTIME_TOOL_RESULT_MAX_CHARS); case 'mcpToolCall': return item.error?.message || stringifyMcpResult(item.result); case 'fileChange': - return JSON.stringify(item.changes || [], null, 2); + return safeStringifyPreview(item.changes || [], RUNTIME_TOOL_RESULT_MAX_CHARS); case 'reasoning': - return reasoningTextFromItem(item); + return truncateEnd(reasoningTextFromItem(item), RUNTIME_TOOL_RESULT_MAX_CHARS); case 'dynamicToolCall': - return JSON.stringify({ + return safeStringifyPreview({ success: item.success ?? null, contentItems: item.contentItems || null, - }, null, 2); + }, RUNTIME_TOOL_RESULT_MAX_CHARS); case 'collabAgentToolCall': - return JSON.stringify({ + return safeStringifyPreview({ status: item.status || null, receiverThreadIds: item.receiverThreadIds || [], agentsStates: item.agentsStates || {}, - }, null, 2); + }, RUNTIME_TOOL_RESULT_MAX_CHARS); + case 'imageGeneration': + return safeStringifyPreview({ + status: item.status || null, + images: Array.isArray(item.images) ? item.images.map((image) => ({ + path: image.path || image.filePath || null, + mime: image.mime || image.mimeType || null, + size: image.size || null, + })) : null, + outputPath: item.outputPath || item.path || null, + }, RUNTIME_TOOL_RESULT_MAX_CHARS); default: - if (typeof item.text === 'string') return item.text; - return JSON.stringify(truncate(item, 1200)); + if (typeof item.text === 'string') return truncateEnd(item.text, RUNTIME_TOOL_RESULT_MAX_CHARS); + return safeStringifyPreview(item, Math.min(1200, RUNTIME_TOOL_RESULT_MAX_CHARS)); } } @@ -252,6 +372,31 @@ function createCodexAppRuntime(deps = {}) { return toolCall; } + if (entry.toolCalls.length >= RUNTIME_MAX_TOOL_CALLS) { + let overflowTool = entry.toolCalls.find((tool) => tool.id === 'ccweb-toolcalls-overflow'); + if (!overflowTool) { + overflowTool = { + name: 'cc-web', + id: 'ccweb-toolcalls-overflow', + kind: 'system', + meta: { kind: 'system', title: 'Tool Calls', subtitle: 'too many tool calls', status: 'inProgress' }, + input: null, + done: false, + result: '工具调用数量过多,后续工具调用已折叠显示。', + }; + entry.toolCalls.push(overflowTool); + sendRuntime(entry, sessionId, { + type: 'tool_start', + name: overflowTool.name, + toolUseId: overflowTool.id, + input: overflowTool.input, + kind: overflowTool.kind, + meta: overflowTool.meta, + }); + } + return overflowTool; + } + toolCall = { name: itemName(item), id: item.id, @@ -278,9 +423,10 @@ function createCodexAppRuntime(deps = {}) { if (!entry.agentMessageItems) entry.agentMessageItems = new Map(); const currentItemText = entry.agentMessageItems.get(itemId) || ''; const separator = agentMessageSeparator(entry, itemId, nextText); - entry.agentMessageItems.set(itemId, currentItemText + nextText); - entry.fullText = (entry.fullText || '') + separator + nextText; - return separator + nextText; + const appended = separator + nextText; + entry.agentMessageItems.set(itemId, appendCappedText(currentItemText, nextText, RUNTIME_AGENT_ITEM_MAX_CHARS)); + entry.fullText = appendCappedText(entry.fullText || '', appended, RUNTIME_FULL_TEXT_MAX_CHARS); + return capStreamDelta(appended); } function appendAgentCompletedText(entry, item) { @@ -290,15 +436,16 @@ function createCodexAppRuntime(deps = {}) { const currentItemText = entry.agentMessageItems.get(item.id) || ''; if (currentItemText && text.startsWith(currentItemText)) { const remainder = text.slice(currentItemText.length); - entry.agentMessageItems.set(item.id, text); - entry.fullText = (entry.fullText || '') + remainder; - return remainder; + entry.agentMessageItems.set(item.id, keepTail(text, RUNTIME_AGENT_ITEM_MAX_CHARS)); + entry.fullText = appendCappedText(entry.fullText || '', remainder, RUNTIME_FULL_TEXT_MAX_CHARS); + return capStreamDelta(remainder); } if (currentItemText === text) return ''; const separator = agentMessageSeparator(entry, item.id, text); - entry.agentMessageItems.set(item.id, text); - entry.fullText = (entry.fullText || '') + separator + text; - return separator + text; + const appended = separator + text; + entry.agentMessageItems.set(item.id, keepTail(text, RUNTIME_AGENT_ITEM_MAX_CHARS)); + entry.fullText = appendCappedText(entry.fullText || '', appended, RUNTIME_FULL_TEXT_MAX_CHARS); + return capStreamDelta(appended); } function agentMessageSeparator(entry, itemId, nextText) { @@ -312,6 +459,11 @@ function createCodexAppRuntime(deps = {}) { function updateToolResult(entry, sessionId, itemId, result, done = false, patch = {}) { if (!itemId) return; let toolCall = entry.toolCalls.find((tool) => tool.id === itemId); + if (!toolCall) { + const targetItemId = entry.toolCalls.length >= RUNTIME_MAX_TOOL_CALLS ? 'ccweb-toolcalls-overflow' : itemId; + toolCall = entry.toolCalls.find((tool) => tool.id === targetItemId); + itemId = targetItemId; + } if (!toolCall) { toolCall = { name: patch.name || 'CodexAppItem', @@ -334,15 +486,23 @@ function createCodexAppRuntime(deps = {}) { if (patch.name) toolCall.name = patch.name; if (patch.kind) toolCall.kind = patch.kind; if (patch.meta) toolCall.meta = patch.meta; - if (patch.input !== undefined) toolCall.input = patch.input; + if (patch.input !== undefined) { + toolCall.input = limitPreviewValue(patch.input, { + maxString: RUNTIME_TOOL_INPUT_MAX_CHARS, + maxDepth: 5, + maxArray: 80, + maxKeys: 80, + }); + } + const safeResult = truncateEnd(result, RUNTIME_TOOL_RESULT_MAX_CHARS); toolCall.done = done; - toolCall.result = result; + toolCall.result = safeResult; sendRuntime(entry, sessionId, { type: done ? 'tool_end' : 'tool_update', toolUseId: itemId, name: toolCall.name, input: toolCall.input, - result, + result: safeResult, kind: toolCall.kind, meta: toolCall.meta, }); @@ -393,7 +553,7 @@ function createCodexAppRuntime(deps = {}) { case 'item/commandExecution/outputDelta': { const itemId = params.itemId; const current = entry.toolOutputDeltas?.get(itemId) || ''; - const next = current + String(params.delta || ''); + const next = appendCappedText(current, params.delta || '', RUNTIME_TOOL_DELTA_MAX_CHARS); if (!entry.toolOutputDeltas) entry.toolOutputDeltas = new Map(); entry.toolOutputDeltas.set(itemId, next); updateToolResult(entry, sessionId, itemId, next, false, { @@ -432,7 +592,7 @@ function createCodexAppRuntime(deps = {}) { const delta = String(params.delta || ''); if (!itemId || !delta) return { done: false }; const current = entry.toolOutputDeltas?.get(itemId) || ''; - const next = current + delta; + const next = appendCappedText(current, delta, RUNTIME_TOOL_DELTA_MAX_CHARS); if (!entry.toolOutputDeltas) entry.toolOutputDeltas = new Map(); entry.toolOutputDeltas.set(itemId, next); updateToolResult(entry, sessionId, itemId, next, false, { @@ -452,7 +612,7 @@ function createCodexAppRuntime(deps = {}) { } if (item.type === 'userMessage') return { done: false }; if (item.type === 'reasoning') { - const result = (itemResult(item) || entry.toolOutputDeltas?.get(item.id) || '').slice(0, 4000); + const result = truncateEnd(itemResult(item) || entry.toolOutputDeltas?.get(item.id) || '', RUNTIME_TOOL_RESULT_MAX_CHARS); if (!result.trim()) return { done: false }; const toolCall = ensureToolCall(entry, item, sessionId); if (!toolCall) return { done: false }; @@ -470,7 +630,7 @@ function createCodexAppRuntime(deps = {}) { } const toolCall = ensureToolCall(entry, item, sessionId); if (!toolCall) return { done: false }; - const result = itemResult(item).slice(0, 4000); + const result = truncateEnd(itemResult(item), RUNTIME_TOOL_RESULT_MAX_CHARS); toolCall.done = true; toolCall.result = result; toolCall.meta = itemMeta(item) || toolCall.meta; diff --git a/lib/codex-app-worker-client.js b/lib/codex-app-worker-client.js new file mode 100644 index 0000000..ba5cb4c --- /dev/null +++ b/lib/codex-app-worker-client.js @@ -0,0 +1,206 @@ +'use strict'; + +const path = require('path'); +const { fork } = require('child_process'); + +function createCodexAppWorkerClient(options = {}) { + const workerPath = options.workerPath || path.join(__dirname, 'codex-app-worker.js'); + const onNotification = typeof options.onNotification === 'function' ? options.onNotification : () => {}; + const onServerRequest = typeof options.onServerRequest === 'function' ? options.onServerRequest : null; + const onExit = typeof options.onExit === 'function' ? options.onExit : () => {}; + const onLog = typeof options.onLog === 'function' ? options.onLog : () => {}; + + let worker = null; + let nextId = 1; + let configured = false; + let workerExited = false; + let appServerRunning = false; + const pending = new Map(); + + function rejectAllPending(err) { + for (const [, item] of pending) { + clearTimeout(item.timer); + item.reject(err); + } + pending.clear(); + } + + function specPayload() { + return { + command: options.command, + args: Array.isArray(options.args) ? options.args : [], + env: options.env || process.env, + cwd: options.cwd || process.cwd(), + clientInfo: options.clientInfo || null, + }; + } + + function ensureWorker() { + if (worker && !workerExited) return worker; + workerExited = false; + configured = false; + appServerRunning = false; + worker = fork(workerPath, [], { + cwd: options.cwd || process.cwd(), + env: process.env, + stdio: ['ignore', 'ignore', 'ignore', 'ipc'], + }); + + worker.on('message', (message = {}) => { + if (Object.prototype.hasOwnProperty.call(message, 'id')) { + const item = pending.get(message.id); + if (!item) return; + pending.delete(message.id); + clearTimeout(item.timer); + if (message.error) { + const err = new Error(message.error.message || 'Codex App worker 请求失败。'); + err.code = message.error.code; + err.data = message.error.data; + item.reject(err); + } else { + item.resolve(message.result || {}); + } + return; + } + + if (message.type === 'notification') { + onNotification(message.notification); + return; + } + if (message.type === 'serverRequest') { + handleServerRequest(message); + return; + } + if (message.type === 'exit') { + appServerRunning = false; + onExit(message.info || {}); + return; + } + if (message.type === 'log') { + onLog(message.level || 'INFO', message.event || 'codex_app_worker_log', message.data || {}); + } + }); + + worker.on('exit', (code, signal) => { + workerExited = true; + configured = false; + appServerRunning = false; + rejectAllPending(new Error(`Codex App worker 已退出: code=${code ?? 'null'} signal=${signal || 'null'}`)); + onExit({ code, signal, stderr: 'Codex App worker process exited' }); + }); + + worker.on('error', (err) => { + workerExited = true; + configured = false; + appServerRunning = false; + rejectAllPending(err); + onExit({ code: null, signal: null, stderr: err.message }); + }); + + return worker; + } + + function sendWorker(type, payload = {}, timeoutMs = 300000) { + const proc = ensureWorker(); + const id = nextId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error(`Codex App worker 请求超时: ${type}`)); + }, timeoutMs); + pending.set(id, { resolve, reject, timer, type }); + try { + proc.send({ id, type, ...payload }); + } catch (err) { + clearTimeout(timer); + pending.delete(id); + reject(err); + } + }); + } + + function sendWorkerNotification(type, payload = {}) { + const proc = ensureWorker(); + proc.send({ type, ...payload }); + } + + function handleServerRequest(message) { + const requestId = message.requestId; + if (!requestId) return; + if (!onServerRequest) { + sendWorkerNotification('serverRequestResult', { + requestId, + error: { code: -32601, message: 'cc-web 暂不支持 Codex app-server 请求。' }, + }); + return; + } + Promise.resolve() + .then(() => onServerRequest(message.request || {})) + .then((result) => { + sendWorkerNotification('serverRequestResult', { requestId, result: result || {} }); + }) + .catch((err) => { + sendWorkerNotification('serverRequestResult', { + requestId, + error: { code: -32603, message: err?.message || 'cc-web 处理 Codex App worker 请求失败。' }, + }); + }); + } + + async function configureIfNeeded() { + if (configured) return; + await sendWorker('configure', { spec: specPayload() }, 30000); + configured = true; + } + + async function start() { + await configureIfNeeded(); + const result = await sendWorker('start', {}, 30000); + appServerRunning = true; + return result; + } + + async function request(method, params = {}, timeoutMs = 300000) { + await configureIfNeeded(); + return sendWorker('request', { method, params, timeoutMs }, timeoutMs + 1000); + } + + function notification(method, params = {}) { + sendWorkerNotification('notification', { method, params }); + } + + async function reloadMcpServers() { + await configureIfNeeded(); + return sendWorker('reloadMcpServers', {}, 30000); + } + + function stop() { + appServerRunning = false; + configured = false; + if (worker && !workerExited) { + try { worker.send({ type: 'stop' }); } catch {} + setTimeout(() => { + try { + if (worker && !worker.killed) worker.kill('SIGKILL'); + } catch {} + }, 3000); + } + rejectAllPending(new Error('Codex App worker 已停止。')); + } + + function isRunning() { + return !!worker && !workerExited && appServerRunning; + } + + return { + start, + stop, + request, + notification, + reloadMcpServers, + isRunning, + pid: () => worker?.pid || null, + }; +} + +module.exports = { createCodexAppWorkerClient }; diff --git a/lib/codex-app-worker.js b/lib/codex-app-worker.js new file mode 100644 index 0000000..30f6356 --- /dev/null +++ b/lib/codex-app-worker.js @@ -0,0 +1,165 @@ +'use strict'; + +const { createCodexAppServerClient } = require('./codex-app-server-client'); + +let client = null; +let currentSpec = null; +let nextParentRequestId = 1; +const pendingParentRequests = new Map(); + +function send(message) { + if (typeof process.send === 'function') { + process.send(message); + } +} + +function serializeError(err) { + return { + code: err?.code || -32603, + message: err?.message || String(err || 'Codex App worker error'), + data: err?.data || null, + }; +} + +function reply(id, result, error) { + if (!id) return; + if (error) { + send({ id, error: serializeError(error) }); + } else { + send({ id, result: result || {} }); + } +} + +function requestParent(request, timeoutMs = 300000) { + const requestId = String(nextParentRequestId++); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pendingParentRequests.delete(requestId); + reject(new Error(`Codex App worker 等待主进程处理请求超时: ${request.method || ''}`)); + }, timeoutMs); + pendingParentRequests.set(requestId, { resolve, reject, timer }); + send({ type: 'serverRequest', requestId, request }); + }); +} + +async function postInitialize({ 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_worker_goals_feature_enabled', {}); + } catch (err) { + if (typeof onLog === 'function') { + onLog('INFO', 'codex_app_worker_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_worker_collaboration_modes', { result }); + } catch (err) { + if (typeof onLog === 'function') { + onLog('INFO', 'codex_app_worker_collaboration_mode_list_failed', { error: err?.message || String(err || '') }); + } + } +} + +function configure(spec = {}) { + const nextSpec = { + command: spec.command || 'codex', + args: Array.isArray(spec.args) && spec.args.length > 0 ? spec.args : ['app-server', '--stdio'], + env: spec.env || process.env, + cwd: spec.cwd || process.cwd(), + clientInfo: spec.clientInfo || undefined, + }; + + const nextSignature = JSON.stringify({ + command: nextSpec.command, + args: nextSpec.args, + cwd: nextSpec.cwd, + envCodeHome: nextSpec.env.CODEX_HOME || '', + }); + const currentSignature = currentSpec ? JSON.stringify({ + command: currentSpec.command, + args: currentSpec.args, + cwd: currentSpec.cwd, + envCodeHome: currentSpec.env.CODEX_HOME || '', + }) : ''; + + if (client && nextSignature === currentSignature) { + currentSpec = nextSpec; + return; + } + + if (client) { + try { client.stop(); } catch {} + client = null; + } + currentSpec = nextSpec; + client = createCodexAppServerClient({ + command: nextSpec.command, + args: nextSpec.args, + env: nextSpec.env, + cwd: nextSpec.cwd, + clientInfo: nextSpec.clientInfo, + onNotification: (notification) => send({ type: 'notification', notification }), + onServerRequest: (request) => requestParent(request), + onExit: (info) => send({ type: 'exit', info }), + onLog: (level, event, data) => send({ type: 'log', level, event, data }), + postInitialize, + }); +} + +process.on('message', (message = {}) => { + if (message.type === 'serverRequestResult') { + const item = pendingParentRequests.get(String(message.requestId || '')); + if (!item) return; + pendingParentRequests.delete(String(message.requestId || '')); + clearTimeout(item.timer); + if (message.error) { + const err = new Error(message.error.message || '主进程处理 Codex App 请求失败。'); + err.code = message.error.code; + err.data = message.error.data; + item.reject(err); + } else { + item.resolve(message.result || {}); + } + return; + } + + Promise.resolve() + .then(async () => { + switch (message.type) { + case 'configure': + configure(message.spec || {}); + return {}; + case 'start': + if (!client) configure(currentSpec || {}); + return client.start(); + case 'request': + if (!client) configure(currentSpec || {}); + return client.request(message.method, message.params || {}, message.timeoutMs || 300000); + case 'notification': + if (!client) configure(currentSpec || {}); + client.notification(message.method, message.params || {}); + return {}; + case 'reloadMcpServers': + if (!client) configure(currentSpec || {}); + return client.reloadMcpServers(); + case 'stop': + if (client) client.stop(); + process.exit(0); + return {}; + default: + throw new Error(`未知 Codex App worker 消息: ${message.type}`); + } + }) + .then((result) => reply(message.id, result)) + .catch((err) => reply(message.id, null, err)); +}); + +process.on('disconnect', () => { + if (client) { + try { client.stop(); } catch {} + } + process.exit(0); +}); diff --git a/scripts/mock-codex-app-server.js b/scripts/mock-codex-app-server.js index a7de6fd..b72665e 100755 --- a/scripts/mock-codex-app-server.js +++ b/scripts/mock-codex-app-server.js @@ -162,6 +162,49 @@ function completeTurn(thread, turnId, text, status = 'completed') { }); } + if (/huge output/i.test(text)) { + const hugeOutput = `huge-output-start\n${'0123456789abcdef'.repeat(30000)}\nhuge-output-end\n`; + send({ + method: 'item/started', + params: { + threadId: thread.id, + turnId, + startedAtMs: Date.now(), + item: { + id: 'huge-tool', + type: 'commandExecution', + command: '/bin/bash -lc huge-output', + status: 'inProgress', + }, + }, + }); + send({ + method: 'item/commandExecution/outputDelta', + params: { + threadId: thread.id, + turnId, + itemId: 'huge-tool', + delta: hugeOutput, + }, + }); + send({ + method: 'item/completed', + params: { + threadId: thread.id, + turnId, + completedAtMs: Date.now(), + item: { + id: 'huge-tool', + type: 'commandExecution', + command: '/bin/bash -lc huge-output', + aggregatedOutput: hugeOutput, + exitCode: 0, + status: 'completed', + }, + }, + }); + } + if (/subagent|collab/i.test(text)) { send({ method: 'item/started', diff --git a/scripts/regression.js b/scripts/regression.js index 9fc8cbb..47434e2 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -859,6 +859,19 @@ async function main() { assert(storedCodexApp.messages.some((message) => message.role === 'assistant' && /codexapp tool prompt/.test(String(message.content || ''))), 'Codex App assistant response should be persisted'); assert((storedCodexApp.totalUsage?.inputTokens || 0) > 0, 'Codex App token usage should be persisted'); + ws.send(JSON.stringify({ type: 'message', text: 'codexapp huge output prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' })); + const codexAppHugeTool = await nextMessage(messages, ws, (msg) => msg.type === 'tool_end' && msg.sessionId === codexAppSession.sessionId && msg.toolUseId === 'huge-tool'); + assert((codexAppHugeTool.result || '').length <= 33000, 'Codex App huge tool result should be capped before sending to the browser'); + assert(/内容过长|huge-output-start/.test(codexAppHugeTool.result || ''), 'Codex App huge tool result should keep a clear truncated preview'); + await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId); + storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8')); + const persistedHugeTool = storedCodexApp.messages + .flatMap((message) => Array.isArray(message.toolCalls) ? message.toolCalls : []) + .find((tool) => tool.id === 'huge-tool'); + assert(persistedHugeTool, 'Codex App huge tool call should be persisted as a preview'); + assert(String(persistedHugeTool.result || '').length <= 33000, 'Persisted Codex App huge tool result should be capped'); + assert(fs.statSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`)).size < 1024 * 1024, 'Codex App huge output should not inflate session JSON beyond 1MB'); + const reloadMcpResult = await postAuthedJson(port, token, `/api/sessions/${codexAppSession.sessionId}/reload-mcp`); assert(reloadMcpResult.sessionId === codexAppSession.sessionId, 'Codex App MCP reload should return the target session id'); assert(reloadMcpResult.result?.reloaded === true, 'Codex App MCP reload should call app-server config/mcpServer/reload'); @@ -1098,6 +1111,56 @@ async function main() { } finally { await restartedRecoveryServer.stop(); } + + const oversizedRecoverySessionId = 'oversized-recovery-session'; + const oversizedRecoveryStateDir = path.join(sessionsDir, `${oversizedRecoverySessionId}-run`); + const oversizedRecoveryStatePath = path.join(oversizedRecoveryStateDir, 'codexapp-state.json'); + fs.writeFileSync(path.join(sessionsDir, `${oversizedRecoverySessionId}.json`), JSON.stringify({ + id: oversizedRecoverySessionId, + title: 'Oversized Recovery', + created: new Date().toISOString(), + updated: new Date().toISOString(), + pinnedAt: null, + agent: 'codexapp', + claudeSessionId: null, + codexThreadId: null, + codexAppThreadId: 'oversized-thread', + model: 'gpt-5.5(xhigh)', + permissionMode: 'yolo', + totalCost: 0, + totalUsage: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }, + messages: [], + cwd: homeDir, + }, null, 2)); + mkdirp(oversizedRecoveryStateDir); + fs.writeFileSync(oversizedRecoveryStatePath, JSON.stringify({ + version: 1, + agent: 'codexapp', + sessionId: oversizedRecoverySessionId, + threadId: 'oversized-thread', + turnId: 'oversized-turn', + turnStatus: 'running', + fullText: 'x'.repeat(5 * 1024 * 1024), + toolCalls: [], + })); + assert(fs.statSync(oversizedRecoveryStatePath).size > 4 * 1024 * 1024, 'Oversized recovery fixture should exceed the state load guard'); + + const oversizedRecoveryServer = await startServer(recoveryEnv); + try { + const { ws, messages } = await connectWs(recoveryPort, password); + await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((session) => session.id === oversizedRecoverySessionId)); + assert(!fs.existsSync(oversizedRecoveryStateDir), 'Oversized Codex App recovery state directory should be cleaned without parsing the state'); + ws.send(JSON.stringify({ type: 'load_session', sessionId: oversizedRecoverySessionId })); + const oversizedSessionInfo = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.sessionId === oversizedRecoverySessionId); + assert((oversizedSessionInfo.messages || []).some((message) => ( + message.role === 'system' && + /状态文件异常/.test(String(message.content || '')) && + /跳过恢复/.test(String(message.content || '')) + )), 'Oversized Codex App recovery should add a system notice'); + ws.close(); + } finally { + await oversizedRecoveryServer.stop(); + } } main().catch((err) => { diff --git a/server.js b/server.js index 250782b..a057e10 100644 --- a/server.js +++ b/server.js @@ -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,