feat: complete Codex App capabilities and rebuild release
This commit is contained in:
407
server.js
407
server.js
@@ -765,6 +765,12 @@ const activeCodexAppTurns = new Map();
|
||||
|
||||
// Active Codex app-server goal RPCs: sessionId -> { id, ws, action, cancelled }
|
||||
const activeCodexAppGoalCommands = new Map();
|
||||
|
||||
// Latest Codex app-server Goal state: sessionId -> normalized ThreadGoal
|
||||
const codexAppGoalStates = new Map();
|
||||
|
||||
// Active Codex app-server compact RPCs: sessionId -> { id, ws, threadId, cancelled }
|
||||
const activeCodexAppCompactions = new Map();
|
||||
// ccweb MCP child agents tracked from Codex App native collaboration mode:
|
||||
// childThreadId -> { parentSessionId, parentThreadId, spawnToolId, ...state }
|
||||
const ccwebMcpChildThreads = new Map();
|
||||
@@ -863,7 +869,7 @@ const DEFAULT_CODEX_CONFIG = {
|
||||
activeProfile: '',
|
||||
profiles: [],
|
||||
enableSearch: false,
|
||||
supportsSearch: false,
|
||||
supportsSearch: true,
|
||||
retry: {
|
||||
mode: 'limited',
|
||||
intervalSeconds: Math.max(1, Math.ceil(CODEX_TRANSIENT_RETRY_BASE_DELAY_MS / 1000)),
|
||||
@@ -1308,9 +1314,8 @@ function loadCodexConfig() {
|
||||
apiKey: String(profile?.apiKey || ''),
|
||||
apiBase: String(profile?.apiBase || '').trim(),
|
||||
})).filter((profile) => profile.name) : [],
|
||||
enableSearch: false,
|
||||
supportsSearch: false,
|
||||
storedEnableSearch: !!raw.enableSearch,
|
||||
enableSearch: !!raw.enableSearch,
|
||||
supportsSearch: true,
|
||||
retry: normalizeCodexRetryConfig(raw.retry),
|
||||
};
|
||||
}
|
||||
@@ -1327,7 +1332,7 @@ function saveCodexConfig(config) {
|
||||
apiKey: String(profile?.apiKey || ''),
|
||||
apiBase: String(profile?.apiBase || '').trim(),
|
||||
})).filter((profile) => profile.name) : [],
|
||||
enableSearch: false,
|
||||
enableSearch: !!config.enableSearch,
|
||||
retry: normalizeCodexRetryConfig(config.retry),
|
||||
}, null, 2));
|
||||
}
|
||||
@@ -1342,9 +1347,8 @@ function getCodexConfigMasked() {
|
||||
apiKey: maskSecret(profile.apiKey),
|
||||
apiBase: profile.apiBase || '',
|
||||
})),
|
||||
enableSearch: false,
|
||||
supportsSearch: false,
|
||||
storedEnableSearch: !!config.storedEnableSearch,
|
||||
enableSearch: !!config.enableSearch,
|
||||
supportsSearch: true,
|
||||
retry: normalizeCodexRetryConfig(config.retry),
|
||||
};
|
||||
}
|
||||
@@ -3249,7 +3253,11 @@ function isCodexLikeSession(session) {
|
||||
}
|
||||
|
||||
function isSessionRunning(sessionId) {
|
||||
return activeProcesses.has(sessionId) || activeCodexAppTurns.has(sessionId) || activeCodexAppGoalCommands.has(sessionId);
|
||||
return activeProcesses.has(sessionId)
|
||||
|| activeCodexAppTurns.has(sessionId)
|
||||
|| activeCodexAppGoalCommands.has(sessionId)
|
||||
|| isCodexAppGoalActive(sessionId)
|
||||
|| activeCodexAppCompactions.has(sessionId);
|
||||
}
|
||||
|
||||
function getRuntimeSessionId(session) {
|
||||
@@ -3736,11 +3744,17 @@ async function handleReloadMcpApi(req, res, rawSessionId) {
|
||||
function setRuntimeSessionId(session, runtimeId) {
|
||||
if (!session) return;
|
||||
const agent = getSessionAgent(session);
|
||||
if (agent === 'codex') {
|
||||
session.codexThreadId = runtimeId || null;
|
||||
} else if (agent === 'codexapp') {
|
||||
session.codexAppThreadId = runtimeId || null;
|
||||
} else {
|
||||
if (agent === 'codex') {
|
||||
session.codexThreadId = runtimeId || null;
|
||||
} else if (agent === 'codexapp') {
|
||||
const previousThreadId = normalizeCodexAppThreadId(session.codexAppThreadId);
|
||||
const nextThreadId = normalizeCodexAppThreadId(runtimeId);
|
||||
session.codexAppThreadId = runtimeId || null;
|
||||
if (previousThreadId && previousThreadId !== nextThreadId) {
|
||||
session.codexAppGoal = null;
|
||||
if (session.id) codexAppGoalStates.delete(session.id);
|
||||
}
|
||||
} else {
|
||||
session.claudeSessionId = runtimeId || null;
|
||||
}
|
||||
}
|
||||
@@ -4250,8 +4264,9 @@ function loadSessionMetaFromFile(filePath) {
|
||||
hasUnread: !!session.hasUnread,
|
||||
agent: getSessionAgent(session),
|
||||
cwd,
|
||||
projectName: cwd ? path.basename(cwd.replace(/[\\/]+$/, '')) : '',
|
||||
fileBytes: stat.size,
|
||||
projectName: cwd ? path.basename(cwd.replace(/[\\/]+$/, '')) : '',
|
||||
codexAppGoalActive: isCodexThreadGoalActive(session.codexAppGoal),
|
||||
fileBytes: stat.size,
|
||||
oversized: stat.size > SESSION_LOAD_MAX_BYTES,
|
||||
};
|
||||
}
|
||||
@@ -4271,8 +4286,9 @@ function loadSessionMetaFromFile(filePath) {
|
||||
hasUnread: previewBooleanField(previewFields, 'hasUnread'),
|
||||
agent: normalizeAgent(previewStringField(previewFields, 'agent')),
|
||||
cwd,
|
||||
projectName: cwd ? path.basename(cwd.replace(/[\\/]+$/, '')) : '',
|
||||
fileBytes: stat.size,
|
||||
projectName: cwd ? path.basename(cwd.replace(/[\\/]+$/, '')) : '',
|
||||
codexAppGoalActive: false,
|
||||
fileBytes: stat.size,
|
||||
oversized: stat.size > SESSION_LOAD_MAX_BYTES,
|
||||
};
|
||||
} catch (err) {
|
||||
@@ -4290,8 +4306,9 @@ function loadSession(id) {
|
||||
try {
|
||||
const filePath = sessionPath(normalizedId);
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
const session = normalizeSession(safeReadSessionJson(filePath, SESSION_LOAD_MAX_BYTES, { sessionId: normalizedId }));
|
||||
updateSessionRuntimeThreadIndex(session);
|
||||
const session = normalizeSession(safeReadSessionJson(filePath, SESSION_LOAD_MAX_BYTES, { sessionId: normalizedId }));
|
||||
syncCodexAppGoalStateFromSession(session);
|
||||
updateSessionRuntimeThreadIndex(session);
|
||||
return session;
|
||||
} catch (err) {
|
||||
plog('WARN', 'session_load_failed', {
|
||||
@@ -5315,7 +5332,7 @@ function sendSessionList(ws) {
|
||||
agent: normalizeAgent(meta.agent),
|
||||
cwd: meta.cwd || '',
|
||||
projectName: meta.projectName || '',
|
||||
isRunning: isSessionRunning(meta.id),
|
||||
isRunning: isSessionRunning(meta.id) || meta.codexAppGoalActive === true,
|
||||
waitingOnChildren: waitState.waitingOnChildren,
|
||||
pendingReplyCount: waitState.pendingReplyCount,
|
||||
readyReplyCount: waitState.readyReplyCount,
|
||||
@@ -6687,12 +6704,14 @@ function formatRuntimeError(agent, raw, context = {}) {
|
||||
}
|
||||
|
||||
function compactStartMessage(agent) {
|
||||
if (agent === 'codexapp') return '正在执行 Codex App 原生 /compact 压缩上下文,请稍候…';
|
||||
return agent === 'codex'
|
||||
? '正在执行 Codex /compact 压缩上下文,请稍候…'
|
||||
: '正在执行 Claude 原生 /compact 压缩上下文,请稍候…';
|
||||
}
|
||||
|
||||
function compactDoneMessage(agent) {
|
||||
if (agent === 'codexapp') return '上下文压缩完成。已执行 Codex App 原生 /compact,下次继续在同一会话发送即可。';
|
||||
return agent === 'codex'
|
||||
? '上下文压缩完成。已执行 Codex /compact,下次继续在同一会话发送即可。'
|
||||
: '上下文压缩完成。已按 Claude Code 原生策略执行 /compact,下次继续在同一会话发送即可。';
|
||||
@@ -7909,9 +7928,8 @@ function handleSaveCodexConfig(ws, newConfig) {
|
||||
mode: newConfig.mode === 'custom' ? 'custom' : 'local',
|
||||
activeProfile: String(newConfig.activeProfile || '').trim(),
|
||||
profiles: mergedProfiles,
|
||||
enableSearch: false,
|
||||
supportsSearch: false,
|
||||
storedEnableSearch: requestedSearch,
|
||||
enableSearch: requestedSearch,
|
||||
supportsSearch: true,
|
||||
retry,
|
||||
};
|
||||
if (merged.mode === 'custom' && merged.profiles.length > 0 && !merged.profiles.some((profile) => profile.name === merged.activeProfile)) {
|
||||
@@ -7923,7 +7941,7 @@ function handleSaveCodexConfig(ws, newConfig) {
|
||||
activeProfile: merged.activeProfile || null,
|
||||
profileCount: merged.profiles.length,
|
||||
enableSearchRequested: requestedSearch,
|
||||
enableSearchEffective: false,
|
||||
enableSearchEffective: merged.enableSearch,
|
||||
retryMode: retry.mode,
|
||||
retryIntervalSeconds: retry.intervalSeconds,
|
||||
retryMaxAttempts: retry.mode === 'limited' ? retry.maxAttempts : null,
|
||||
@@ -7931,9 +7949,7 @@ function handleSaveCodexConfig(ws, newConfig) {
|
||||
wsSend(ws, { type: 'codex_config', config: getCodexConfigMasked() });
|
||||
wsSend(ws, {
|
||||
type: 'system_message',
|
||||
message: requestedSearch
|
||||
? 'Codex 配置已保存。当前 cc-web 的 Codex exec 路径暂未接入 Web Search,已自动忽略该开关。'
|
||||
: 'Codex 配置已保存',
|
||||
message: 'Codex 配置已保存',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8050,6 +8066,63 @@ function normalizeCodexThreadGoal(goal, fallbackThreadId = '') {
|
||||
};
|
||||
}
|
||||
|
||||
function codexGoalStatusKey(status) {
|
||||
return goalString(status || 'active').toLowerCase().replace(/[\s_-]/g, '');
|
||||
}
|
||||
|
||||
function isCodexThreadGoalActive(goal) {
|
||||
return !!goal && codexGoalStatusKey(goal.status) === 'active';
|
||||
}
|
||||
|
||||
function isCodexAppGoalActive(sessionId) {
|
||||
return isCodexThreadGoalActive(codexAppGoalStates.get(sessionId));
|
||||
}
|
||||
|
||||
function syncCodexAppGoalStateFromSession(session) {
|
||||
const sessionId = sanitizeId(session?.id || '');
|
||||
if (!sessionId || !isCodexAppSession(session)) return null;
|
||||
const threadId = normalizeCodexAppThreadId(getRuntimeSessionId(session));
|
||||
const goal = normalizeCodexThreadGoal(session.codexAppGoal, threadId || '');
|
||||
if (!goal || (threadId && goal.threadId && goal.threadId !== threadId)) {
|
||||
codexAppGoalStates.delete(sessionId);
|
||||
return null;
|
||||
}
|
||||
codexAppGoalStates.set(sessionId, goal);
|
||||
return goal;
|
||||
}
|
||||
|
||||
function updateCodexAppGoalState(session, goal, options = {}) {
|
||||
const sessionId = sanitizeId(session?.id || '');
|
||||
if (!sessionId || !isCodexAppSession(session)) return null;
|
||||
const threadId = normalizeCodexAppThreadId(getRuntimeSessionId(session));
|
||||
const normalized = normalizeCodexThreadGoal(goal, threadId || '');
|
||||
const previous = normalizeCodexThreadGoal(
|
||||
codexAppGoalStates.get(sessionId) || session.codexAppGoal,
|
||||
threadId || '',
|
||||
);
|
||||
if (
|
||||
options.force !== true
|
||||
&& normalized
|
||||
&& previous
|
||||
&& normalized.threadId === previous.threadId
|
||||
&& normalized.updatedAt > 0
|
||||
&& previous.updatedAt > normalized.updatedAt
|
||||
) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
if (normalized) {
|
||||
session.codexAppGoal = normalized;
|
||||
codexAppGoalStates.set(sessionId, normalized);
|
||||
} else {
|
||||
session.codexAppGoal = null;
|
||||
codexAppGoalStates.delete(sessionId);
|
||||
}
|
||||
if (options.persist !== false) saveSession(session);
|
||||
if (options.broadcast !== false) broadcastSessionList();
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function formatCodexGoalStatus(status) {
|
||||
const normalized = String(status || 'active').trim();
|
||||
const compact = normalized.toLowerCase().replace(/[\s_-]/g, '');
|
||||
@@ -8133,6 +8206,122 @@ async function ensureCodexAppGoalThread(session) {
|
||||
return { client, threadId };
|
||||
}
|
||||
|
||||
function isCurrentCodexAppCompaction(sessionId, entry) {
|
||||
return !!entry
|
||||
&& activeCodexAppCompactions.get(sessionId)?.id === entry.id
|
||||
&& !entry.cancelled;
|
||||
}
|
||||
|
||||
function finishCodexAppCompaction(sessionId, entry) {
|
||||
if (!entry || activeCodexAppCompactions.get(sessionId)?.id !== entry.id) return false;
|
||||
activeCodexAppCompactions.delete(sessionId);
|
||||
broadcastSessionList();
|
||||
return true;
|
||||
}
|
||||
|
||||
function cancelCodexAppCompaction(sessionId, ws = null) {
|
||||
const entry = activeCodexAppCompactions.get(sessionId);
|
||||
if (!entry) return false;
|
||||
entry.cancelled = true;
|
||||
activeCodexAppCompactions.delete(sessionId);
|
||||
const targetWs = ws || entry.ws || null;
|
||||
if (targetWs) {
|
||||
wsSend(targetWs, {
|
||||
type: 'system_message',
|
||||
sessionId,
|
||||
message: '已取消 Codex App /compact 状态。底层 app-server 请求可能仍会自然返回,结果将被忽略。',
|
||||
});
|
||||
}
|
||||
broadcastSessionList();
|
||||
return true;
|
||||
}
|
||||
|
||||
function isCodexCompactUnsupportedError(err) {
|
||||
const detail = `${err?.code || ''} ${err?.message || err || ''}`;
|
||||
return err?.code === -32601
|
||||
|| /compact.*unsupported|unsupported.*compact|method not found|unknown mock method|unsupported remote app-server request/i.test(detail);
|
||||
}
|
||||
|
||||
async function handleCodexAppCompactSlashCommand(ws, session, source = {}) {
|
||||
if (!session || !isCodexAppSession(session)) return;
|
||||
const sessionId = session.id;
|
||||
const sendCompactResponse = (targetWs, payload, options = {}) => {
|
||||
if (!targetWs) return;
|
||||
wsSend(targetWs, attachClientRequestId({
|
||||
...payload,
|
||||
...(options.preserveComposerDraft ? { preserveComposerDraft: true } : {}),
|
||||
}, source));
|
||||
};
|
||||
const sendCompactSystemMessage = (targetWs, message, extra = {}, options = {}) => {
|
||||
sendCompactResponse(targetWs, { type: 'system_message', message, ...extra }, options);
|
||||
};
|
||||
|
||||
if (activeCodexAppCompactions.has(sessionId)) {
|
||||
sendCompactSystemMessage(ws, 'Codex App /compact 正在执行,请稍候。', { sessionId }, { preserveComposerDraft: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const runtimeThreadId = getRuntimeSessionId(session);
|
||||
if (!runtimeThreadId) {
|
||||
sendCompactSystemMessage(ws, '当前会话尚未建立 Codex App 上下文,暂时无需压缩。', { sessionId }, { preserveComposerDraft: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const compactEntry = {
|
||||
id: crypto.randomUUID(),
|
||||
ws,
|
||||
threadId: runtimeThreadId,
|
||||
cancelled: false,
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
activeCodexAppCompactions.set(sessionId, compactEntry);
|
||||
sendCompactSystemMessage(ws, compactStartMessage('codexapp'), { sessionId });
|
||||
broadcastSessionList();
|
||||
|
||||
try {
|
||||
const clientResult = getCodexAppClient({ excludeSessionId: sessionId });
|
||||
if (clientResult.error) throw new Error(clientResult.error);
|
||||
const client = clientResult.client;
|
||||
await client.start();
|
||||
if (!isCurrentCodexAppCompaction(sessionId, compactEntry)) return;
|
||||
|
||||
const threadParams = codexAppThreadParams(session);
|
||||
const resumed = await client.request('thread/resume', {
|
||||
...threadParams,
|
||||
threadId: runtimeThreadId,
|
||||
}, 60000);
|
||||
const threadId = resumed?.thread?.id || runtimeThreadId;
|
||||
if (threadId !== runtimeThreadId) {
|
||||
throw new Error(`Codex App 恢复到不同线程,已停止压缩(期望 ${String(runtimeThreadId).slice(0, 24)},实际 ${String(threadId).slice(0, 24)})。`);
|
||||
}
|
||||
const response = await client.request('thread/compact/start', { threadId }, 300000);
|
||||
if (!isCurrentCodexAppCompaction(sessionId, compactEntry)) return;
|
||||
|
||||
session.updated = new Date().toISOString();
|
||||
saveSession(session);
|
||||
sendCompactSystemMessage(compactEntry.ws || ws, compactDoneMessage('codexapp'), { sessionId });
|
||||
if (response?.threadId && response.threadId !== threadId) {
|
||||
plog('WARN', 'codex_app_compact_thread_mismatch', {
|
||||
sessionId: sessionId.slice(0, 8),
|
||||
expectedThreadId: threadId,
|
||||
actualThreadId: response.threadId,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (isCurrentCodexAppCompaction(sessionId, compactEntry)) {
|
||||
const message = isCodexCompactUnsupportedError(err)
|
||||
? '当前 Codex app-server 不支持 /compact,请升级 Codex 后重试。'
|
||||
: `Codex App /compact 失败:${err?.message || err}`;
|
||||
sendCompactSystemMessage(compactEntry.ws || ws, message, {
|
||||
sessionId,
|
||||
tone: 'danger',
|
||||
}, { preserveComposerDraft: true });
|
||||
}
|
||||
} finally {
|
||||
finishCodexAppCompaction(sessionId, compactEntry);
|
||||
}
|
||||
}
|
||||
|
||||
function isCodexGoalUnsupportedError(err) {
|
||||
const detail = `${err?.code || ''} ${err?.message || err || ''}`;
|
||||
return err?.code === -32601
|
||||
@@ -8256,6 +8445,7 @@ async function handleCodexAppGoalSlashCommand(ws, text, session, source = {}) {
|
||||
const response = await client.request('thread/goal/get', { threadId }, 30000);
|
||||
if (!isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) return;
|
||||
const goal = normalizeCodexThreadGoal(response?.goal, threadId);
|
||||
updateCodexAppGoalState(session, goal);
|
||||
const targetWs = activeGoalCommand.ws || ws;
|
||||
sendGoalSystemMessage(targetWs, goal ? formatCodexGoalUsage(goal) : '用法: /goal <目标描述>', { sessionId: session.id });
|
||||
sendSessionList(targetWs);
|
||||
@@ -8265,6 +8455,7 @@ async function handleCodexAppGoalSlashCommand(ws, text, session, source = {}) {
|
||||
if (command.action === 'clear') {
|
||||
const response = await client.request('thread/goal/clear', { threadId }, 30000);
|
||||
if (!isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) return;
|
||||
updateCodexAppGoalState(session, null);
|
||||
const targetWs = activeGoalCommand.ws || ws;
|
||||
sendGoalSystemMessage(targetWs, response?.cleared ? 'Goal cleared' : 'No goal to clear', {
|
||||
sessionId: session.id,
|
||||
@@ -8283,6 +8474,7 @@ async function handleCodexAppGoalSlashCommand(ws, text, session, source = {}) {
|
||||
}, 30000);
|
||||
if (!isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) return;
|
||||
const goal = normalizeCodexThreadGoal(response?.goal, threadId);
|
||||
updateCodexAppGoalState(session, goal);
|
||||
const targetWs = activeGoalCommand.ws || ws;
|
||||
sendGoalSystemMessage(targetWs, goal ? formatCodexGoalUsage(goal, { includeObjective: false }) : 'Goal updated', {
|
||||
sessionId: session.id,
|
||||
@@ -8341,6 +8533,10 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent, source = {}) {
|
||||
sendSlashSystemMessage('Codex App Goal 正在同步,请稍候。', { sessionId }, { preserveComposerDraft: true });
|
||||
return true;
|
||||
}
|
||||
if (session && isCodexAppSession(session) && activeCodexAppCompactions.has(sessionId)) {
|
||||
sendSlashSystemMessage('Codex App /compact 正在执行,请稍候。', { sessionId }, { preserveComposerDraft: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
switch (cmd) {
|
||||
case '/clear': {
|
||||
@@ -8452,7 +8648,9 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent, source = {}) {
|
||||
break;
|
||||
}
|
||||
if (isCodexAppSession(session)) {
|
||||
sendSlashSystemMessage('Codex App 模式暂不支持 /compact,请切换到旧 Codex 模式或等待后续接入。', {}, { preserveComposerDraft: true });
|
||||
handleCodexAppCompactSlashCommand(ws, session, source).catch((err) => {
|
||||
sendSlashSystemMessage(`Codex App /compact 失败:${err?.message || err}`, { sessionId: session.id }, { preserveComposerDraft: true });
|
||||
});
|
||||
break;
|
||||
}
|
||||
const runtimeId = getRuntimeSessionId(session);
|
||||
@@ -8531,7 +8729,7 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent, source = {}) {
|
||||
sendSlashResponse({
|
||||
type: 'system_message',
|
||||
message: codexLikeAgent
|
||||
? base + `\n/model [名称] — 查看/切换 ${agent === 'codexapp' ? 'Codex App' : 'Codex'} 模型(自由输入)${agent === 'codexapp' ? '\n/goal [目标] — 设置/查看持久目标;支持 pause/resume/clear' : ''}\n/init — 分析项目并生成/更新 AGENTS.md${agent === 'codexapp' ? '\n/compact — Codex App 模式暂不支持' : '\n/compact — 执行 Codex /compact 压缩上下文'}`
|
||||
? base + `\n/model [名称] — 查看/切换 ${agent === 'codexapp' ? 'Codex App' : 'Codex'} 模型(自由输入)${agent === 'codexapp' ? '\n/goal [目标] — 设置/查看持久目标;支持 pause/resume/clear' : ''}\n/init — 分析项目并生成/更新 AGENTS.md${agent === 'codexapp' ? '\n/compact — 执行 Codex App 原生上下文压缩' : '\n/compact — 执行 Codex /compact 压缩上下文'}`
|
||||
: base + '\n/model [名称] — 查看/切换模型(opus, sonnet, haiku)\n/compact — 执行 Claude 原生上下文压缩(保留压缩计划并可自动续跑)\n/init — 分析项目并生成/更新 CLAUDE.md',
|
||||
});
|
||||
break;
|
||||
@@ -9175,6 +9373,8 @@ function handleDeleteSession(ws, sessionId) {
|
||||
pendingSlashCommands.delete(sessionId);
|
||||
pendingCompactRetries.delete(sessionId);
|
||||
cancelCodexCapacityRetry(sessionId);
|
||||
cancelCodexAppCompaction(sessionId);
|
||||
codexAppGoalStates.delete(sessionId);
|
||||
removeSessionRuntimeThreadIndex(sessionId);
|
||||
if (activeCodexAppGoalCommands.has(sessionId)) {
|
||||
const entry = activeCodexAppGoalCommands.get(sessionId);
|
||||
@@ -9290,6 +9490,12 @@ function handleDisconnect(ws, wsId) {
|
||||
affectedSessions.push({ sessionId: sid.slice(0, 8), threadId: entry.threadId || null, turnId: entry.turnId || null });
|
||||
}
|
||||
}
|
||||
for (const [sid, entry] of activeCodexAppCompactions) {
|
||||
if (entry.ws === ws) {
|
||||
entry.ws = null;
|
||||
affectedSessions.push({ sessionId: sid.slice(0, 8), threadId: entry.threadId || null, compact: true });
|
||||
}
|
||||
}
|
||||
wsSessionMap.delete(ws);
|
||||
plog('INFO', 'ws_disconnect', { wsId, activeProcessesAffected: affectedSessions });
|
||||
}
|
||||
@@ -9305,6 +9511,7 @@ function bindAbortSessionToWs(sessionId, ws) {
|
||||
activeProcesses.get(sessionId),
|
||||
activeCodexAppTurns.get(sessionId),
|
||||
activeCodexAppGoalCommands.get(sessionId),
|
||||
activeCodexAppCompactions.get(sessionId),
|
||||
].filter(Boolean);
|
||||
if (entries.length > 0) detachWsFromActiveRuntimes(ws);
|
||||
wsSessionMap.set(ws, sessionId);
|
||||
@@ -9314,13 +9521,81 @@ function bindAbortSessionToWs(sessionId, ws) {
|
||||
}
|
||||
}
|
||||
|
||||
function pauseCodexAppGoalForAbort(sessionId, ws = null) {
|
||||
const session = loadSession(sessionId);
|
||||
const threadId = normalizeCodexAppThreadId(getRuntimeSessionId(session));
|
||||
const currentGoal = normalizeCodexThreadGoal(
|
||||
codexAppGoalStates.get(sessionId) || session?.codexAppGoal,
|
||||
threadId || '',
|
||||
);
|
||||
if (!session || !threadId || !isCodexThreadGoalActive(currentGoal)) return false;
|
||||
|
||||
const pausedGoal = {
|
||||
...currentGoal,
|
||||
status: 'paused',
|
||||
updatedAt: Math.max(Date.now(), Number(currentGoal.updatedAt || 0) + 1),
|
||||
};
|
||||
updateCodexAppGoalState(session, pausedGoal);
|
||||
plog('INFO', 'codex_app_goal_pause_requested_by_abort', {
|
||||
sessionId: sessionId.slice(0, 8),
|
||||
threadId,
|
||||
});
|
||||
|
||||
Promise.resolve().then(async () => {
|
||||
const clientResult = getCodexAppClient();
|
||||
if (clientResult.error) throw new Error(clientResult.error);
|
||||
const client = clientResult.client;
|
||||
await client.start();
|
||||
const response = await client.request('thread/goal/set', {
|
||||
threadId,
|
||||
status: 'paused',
|
||||
}, 30000);
|
||||
const refreshedSession = loadSession(sessionId);
|
||||
if (!refreshedSession) return;
|
||||
const confirmedGoal = normalizeCodexThreadGoal(response?.goal, threadId);
|
||||
if (confirmedGoal) updateCodexAppGoalState(refreshedSession, confirmedGoal);
|
||||
if (ws) {
|
||||
wsSend(ws, {
|
||||
type: 'system_message',
|
||||
sessionId,
|
||||
tone: 'info',
|
||||
transient: true,
|
||||
autoDismissMs: 5000,
|
||||
message: 'Goal paused',
|
||||
});
|
||||
}
|
||||
}).catch((err) => {
|
||||
const refreshedSession = loadSession(sessionId);
|
||||
const latestGoal = codexAppGoalStates.get(sessionId);
|
||||
if (
|
||||
refreshedSession
|
||||
&& codexGoalStatusKey(latestGoal?.status) === 'paused'
|
||||
&& Number(latestGoal?.updatedAt || 0) === pausedGoal.updatedAt
|
||||
) {
|
||||
updateCodexAppGoalState(refreshedSession, currentGoal, { force: true });
|
||||
}
|
||||
if (ws) {
|
||||
wsSend(ws, {
|
||||
type: 'error',
|
||||
sessionId,
|
||||
code: 'codexapp_goal_pause_failed',
|
||||
message: `暂停 Goal 失败:${err?.message || err}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleAbort(ws, msg = {}) {
|
||||
const requestedSessionId = sanitizeId(msg?.sessionId || '');
|
||||
const sessionId = requestedSessionId || wsSessionMap.get(ws);
|
||||
if (!sessionId) return;
|
||||
bindAbortSessionToWs(sessionId, ws);
|
||||
if (handleCodexAppAbortSession(sessionId, ws)) return;
|
||||
const goalPauseStarted = pauseCodexAppGoalForAbort(sessionId, ws);
|
||||
const turnAbortStarted = handleCodexAppAbortSession(sessionId, ws);
|
||||
if (turnAbortStarted || goalPauseStarted) return;
|
||||
if (cancelCodexAppGoalCommand(sessionId, ws)) return;
|
||||
if (cancelCodexAppCompaction(sessionId, ws)) return;
|
||||
const entry = activeProcesses.get(sessionId);
|
||||
if (!entry) {
|
||||
if (cancelCodexCapacityRetry(sessionId)) {
|
||||
@@ -9453,6 +9728,15 @@ function handleMessage(ws, msg, options = {}) {
|
||||
return fail('session_running', 'Codex App Goal 正在同步,请稍候。');
|
||||
}
|
||||
|
||||
if (sessionId && activeCodexAppCompactions.has(sessionId)) {
|
||||
return fail('session_running', 'Codex App /compact 正在执行,请稍候。');
|
||||
}
|
||||
|
||||
if (sessionId && !codexAppGoalStates.has(sessionId)) loadSession(sessionId);
|
||||
if (sessionId && isCodexAppGoalActive(sessionId)) {
|
||||
return fail('session_running', 'Goal 仍在持续运行,请先点击停止按钮暂停 Goal。');
|
||||
}
|
||||
|
||||
if (sessionId && activeProcesses.has(sessionId)) {
|
||||
return fail('session_running', '正在处理中,请先点击停止按钮。');
|
||||
}
|
||||
@@ -9825,6 +10109,12 @@ function detachWsFromActiveRuntimes(ws, options = {}) {
|
||||
if (disconnectTime) entry.wsDisconnectTime = disconnectTime;
|
||||
}
|
||||
}
|
||||
for (const [, entry] of activeCodexAppCompactions) {
|
||||
if (entry.ws === ws) {
|
||||
entry.ws = null;
|
||||
if (disconnectTime) entry.wsDisconnectTime = disconnectTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function codexAppRuntimeThreadId(params = {}) {
|
||||
@@ -10614,7 +10904,47 @@ function shouldLogCodexAppUnroutedNotification(notification) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleCodexAppGoalNotification(notification) {
|
||||
const method = String(notification?.method || '').trim();
|
||||
if (method !== 'thread/goal/updated' && method !== 'thread/goal/cleared') return false;
|
||||
const threadId = normalizeCodexAppThreadId(
|
||||
notification?.params?.threadId || notification?.params?.thread?.id,
|
||||
);
|
||||
if (!threadId) return false;
|
||||
const matched = findCodexAppSessionByThreadId(threadId);
|
||||
if (!matched?.session) return false;
|
||||
|
||||
const goal = method === 'thread/goal/updated'
|
||||
? normalizeCodexThreadGoal(notification?.params?.goal, threadId)
|
||||
: null;
|
||||
if (method === 'thread/goal/updated' && !goal) return false;
|
||||
updateCodexAppGoalState(matched.session, goal);
|
||||
plog('INFO', method === 'thread/goal/updated' ? 'codex_app_goal_updated' : 'codex_app_goal_cleared', {
|
||||
sessionId: matched.sessionId.slice(0, 8),
|
||||
threadId,
|
||||
status: goal?.status || null,
|
||||
tokensUsed: goal?.tokensUsed || 0,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleCodexAppNotification(notification) {
|
||||
if (notification?.method === 'thread/compacted') {
|
||||
const threadId = normalizeCodexAppThreadId(
|
||||
notification?.params?.threadId
|
||||
|| notification?.params?.thread_id
|
||||
|| notification?.params?.thread?.id,
|
||||
);
|
||||
if (threadId) {
|
||||
for (const entry of activeCodexAppCompactions.values()) {
|
||||
if (entry.threadId === threadId) {
|
||||
entry.compactedNotification = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (handleCodexAppGoalNotification(notification)) return;
|
||||
const routed = findCodexAppRouteByRuntime(notification?.params || {}, notification?.method || '');
|
||||
if (handleCodexAppMcpStartupStatusNotification(notification, routed)) return;
|
||||
if (!routed) {
|
||||
@@ -11187,7 +11517,9 @@ function codexAppCcwebMcpEnv(session, options = {}) {
|
||||
}
|
||||
|
||||
function codexAppThreadConfig(session, options = {}) {
|
||||
const config = {};
|
||||
const config = {
|
||||
web_search: loadCodexConfig().enableSearch ? 'live' : 'disabled',
|
||||
};
|
||||
for (const item of listRuntimeMcpServerConfigs({ ...options, session, agent: 'codexapp' })) {
|
||||
if (!item?.server || !item?.config) continue;
|
||||
config[`mcp_servers.${item.server}`] = item.config;
|
||||
@@ -11580,6 +11912,7 @@ function handleCodexAppTurnComplete(sessionId, options = {}) {
|
||||
|
||||
activeCodexAppTurns.delete(sessionId);
|
||||
cleanupCodexAppTurnState(sessionId, entry);
|
||||
const goalActive = isCodexAppGoalActive(sessionId);
|
||||
dispatchTaskBoardLifecycle(sessionId, {
|
||||
type: completionError
|
||||
? TASK_BOARD_LIFECYCLE_EVENTS.TURN_FAILED
|
||||
@@ -11590,7 +11923,7 @@ function handleCodexAppTurnComplete(sessionId, options = {}) {
|
||||
outcome: completionError ? 'failed' : 'completed',
|
||||
trackingEnabled: entry.taskTrackingEnabled === true,
|
||||
});
|
||||
if (session && !completionError && !options.interrupted && !entry.userAborted) {
|
||||
if (session && !goalActive && !completionError && !options.interrupted && !entry.userAborted) {
|
||||
const toolEvidence = assistantToolCalls.length > 0
|
||||
? truncateTextValue(JSON.stringify(assistantToolCalls.map((toolCall) => ({
|
||||
name: toolCall?.name || '',
|
||||
@@ -11619,6 +11952,7 @@ function handleCodexAppTurnComplete(sessionId, options = {}) {
|
||||
responseLen: (entry.fullText || '').length,
|
||||
toolCallCount: (entry.toolCalls || []).length,
|
||||
error: rawError || null,
|
||||
goalActive,
|
||||
});
|
||||
|
||||
if (entry.ws) {
|
||||
@@ -11626,11 +11960,16 @@ function handleCodexAppTurnComplete(sessionId, options = {}) {
|
||||
entry.errorSent = true;
|
||||
wsSend(entry.ws, { type: 'error', sessionId, message: completionError });
|
||||
}
|
||||
wsSend(entry.ws, { type: 'done', sessionId, costUsd: null });
|
||||
wsSend(entry.ws, { type: 'done', sessionId, costUsd: null, goalActive });
|
||||
sendSessionList(entry.ws);
|
||||
return;
|
||||
}
|
||||
|
||||
if (goalActive) {
|
||||
broadcastSessionList();
|
||||
return;
|
||||
}
|
||||
|
||||
if (wss && session) {
|
||||
for (const client of wss.clients) {
|
||||
if (client.readyState === 1) {
|
||||
|
||||
Reference in New Issue
Block a user