Stabilize ccweb codex app runtime
This commit is contained in:
@@ -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;
|
||||
|
||||
206
lib/codex-app-worker-client.js
Normal file
206
lib/codex-app-worker-client.js
Normal file
@@ -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 };
|
||||
165
lib/codex-app-worker.js
Normal file
165
lib/codex-app-worker.js
Normal file
@@ -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);
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
744
server.js
744
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,
|
||||
|
||||
Reference in New Issue
Block a user