diff --git a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz index aadd690..0e2c3e1 100644 Binary files a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz and b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz differ diff --git a/scripts/mock-codex-app-server.js b/scripts/mock-codex-app-server.js index 62b3fd8..000ded0 100755 --- a/scripts/mock-codex-app-server.js +++ b/scripts/mock-codex-app-server.js @@ -872,7 +872,9 @@ function handleRequest(message) { method: 'thread/goal/updated', params: { threadId: thread.id, goal: thread.goal }, }); - send({ id, result: { goal: thread.goal } }); + setTimeout(() => { + send({ id, result: { goal: thread.goal } }); + }, 250); return; } if (method === 'thread/goal/clear') { diff --git a/scripts/regression.js b/scripts/regression.js index 8cd7277..7f21c5c 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -1500,8 +1500,22 @@ async function main() { assert(!storedCodexAppAfterRetryMismatch.messages.some((message) => message.role === 'assistant' && /codexapp retry thread mismatch prompt/.test(String(message.content || ''))), 'Codex App retry mismatch should not persist a successful assistant response on the wrong thread'); ws.send(JSON.stringify({ type: 'message', text: '/goal improve benchmark coverage', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' })); + const codexAppGoalSyncing = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /正在同步 Goal/.test(msg.message || ''), 5000); + assert(/正在同步 Goal/.test(codexAppGoalSyncing.message || ''), 'Codex App /goal should immediately show a syncing notice'); + const codexAppGoalRunningList = await nextMessage(messages, ws, (msg) => ( + msg.type === 'session_list' && + Array.isArray(msg.sessions) && + msg.sessions.some((session) => session.id === codexAppSession.sessionId && session.isRunning) + ), 5000); + assert(codexAppGoalRunningList.sessions.some((session) => session.id === codexAppSession.sessionId && session.isRunning), 'Codex App /goal RPC should mark the session running while waiting for app-server'); const codexAppGoalSet = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /Goal active/.test(msg.message || '') && /improve benchmark coverage/.test(msg.message || '')); assert(/Goal active/.test(codexAppGoalSet.message || ''), 'Codex App /goal should set an active goal'); + const codexAppGoalIdleList = await nextMessage(messages, ws, (msg) => ( + msg.type === 'session_list' && + Array.isArray(msg.sessions) && + msg.sessions.some((session) => session.id === codexAppSession.sessionId && !session.isRunning) + ), 5000); + assert(codexAppGoalIdleList.sessions.some((session) => session.id === codexAppSession.sessionId && !session.isRunning), 'Codex App /goal RPC should clear running state after app-server responds'); ws.send(JSON.stringify({ type: 'message', text: '/goal', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' })); const codexAppGoalShow = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /Goal active/.test(msg.message || '') && /improve benchmark coverage/.test(msg.message || '')); assert(/improve benchmark coverage/.test(codexAppGoalShow.message || ''), 'Codex App /goal should show the current goal'); diff --git a/server.js b/server.js index 5b7ee01..4604e21 100644 --- a/server.js +++ b/server.js @@ -662,6 +662,9 @@ const activeProcesses = new Map(); // Active Codex app-server turns: sessionId -> { ws, threadId, turnId, fullText, toolCalls } const activeCodexAppTurns = new Map(); + +// Active Codex app-server goal RPCs: sessionId -> { id, ws, action, cancelled } +const activeCodexAppGoalCommands = new Map(); // ccweb MCP child agents tracked from Codex App native collaboration mode: // childThreadId -> { parentSessionId, parentThreadId, spawnToolId, ...state } const ccwebMcpChildThreads = new Map(); @@ -2906,7 +2909,7 @@ function isCodexLikeSession(session) { } function isSessionRunning(sessionId) { - return activeProcesses.has(sessionId) || activeCodexAppTurns.has(sessionId); + return activeProcesses.has(sessionId) || activeCodexAppTurns.has(sessionId) || activeCodexAppGoalCommands.has(sessionId); } function getRuntimeSessionId(session) { @@ -6725,6 +6728,34 @@ function isCodexGoalUnsupportedError(err) { || /goals feature is disabled|unsupported remote app-server request|method not found|unknown mock method/i.test(detail); } +function isCurrentCodexAppGoalCommand(sessionId, entry) { + return !!entry && activeCodexAppGoalCommands.get(sessionId)?.id === entry.id && !entry.cancelled; +} + +function finishCodexAppGoalCommand(sessionId, entry) { + if (!entry || activeCodexAppGoalCommands.get(sessionId)?.id !== entry.id) return false; + activeCodexAppGoalCommands.delete(sessionId); + broadcastSessionList(); + return true; +} + +function cancelCodexAppGoalCommand(sessionId, ws = null) { + const entry = activeCodexAppGoalCommands.get(sessionId); + if (!entry) return false; + entry.cancelled = true; + activeCodexAppGoalCommands.delete(sessionId); + const targetWs = ws || entry.ws || null; + if (targetWs) { + wsSend(targetWs, { + type: 'system_message', + sessionId, + message: '已取消 Goal 同步状态。底层 Codex app-server 请求可能仍会自然返回,结果将被忽略。', + }); + } + broadcastSessionList(); + return true; +} + async function handleCodexAppGoalSlashCommand(ws, text, session) { const command = parseCodexGoalCommand(text); if (!command) return; @@ -6741,29 +6772,49 @@ async function handleCodexAppGoalSlashCommand(ws, text, session) { wsSend(ws, { type: 'system_message', sessionId: session.id, message: command.error }); return; } + if (activeCodexAppGoalCommands.has(session.id)) { + wsSend(ws, { type: 'system_message', sessionId: session.id, message: 'Codex App Goal 正在同步,请稍候。' }); + return; + } + + const activeGoalCommand = { + id: crypto.randomUUID(), + ws, + action: command.action, + cancelled: false, + startedAt: new Date().toISOString(), + }; + activeCodexAppGoalCommands.set(session.id, activeGoalCommand); + wsSend(ws, { type: 'system_message', sessionId: session.id, message: '正在同步 Goal...' }); + broadcastSessionList(); try { const { client, threadId } = await ensureCodexAppGoalThread(session); + if (!isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) return; if (command.action === 'show') { const response = await client.request('thread/goal/get', { threadId }, 30000); + if (!isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) return; const goal = normalizeCodexThreadGoal(response?.goal, threadId); - wsSend(ws, { + const targetWs = activeGoalCommand.ws || ws; + wsSend(targetWs, { type: 'system_message', sessionId: session.id, message: goal ? formatCodexGoalUsage(goal) : '用法: /goal <目标描述>', }); - sendSessionList(ws); + sendSessionList(targetWs); return; } if (command.action === 'clear') { const response = await client.request('thread/goal/clear', { threadId }, 30000); - wsSend(ws, { + if (!isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) return; + const targetWs = activeGoalCommand.ws || ws; + wsSend(targetWs, { type: 'system_message', sessionId: session.id, message: response?.cleared ? 'Goal cleared' : 'No goal to clear', }); - sendSessionList(ws); + sendSessionList(targetWs); return; } @@ -6772,18 +6823,24 @@ async function handleCodexAppGoalSlashCommand(ws, text, session) { ...(command.action === 'set' ? { objective: command.objective } : {}), status: command.action === 'pause' ? 'paused' : 'active', }, 30000); + if (!isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) return; const goal = normalizeCodexThreadGoal(response?.goal, threadId); - wsSend(ws, { + const targetWs = activeGoalCommand.ws || ws; + wsSend(targetWs, { type: 'system_message', sessionId: session.id, message: goal ? formatCodexGoalUsage(goal) : 'Goal updated', }); - sendSessionList(ws); + sendSessionList(targetWs); } catch (err) { const message = isCodexGoalUnsupportedError(err) ? '当前 Codex app-server 不支持 /goal,请升级 Codex 或启用 goals feature。' : `Goal failed: ${err?.message || err}`; - wsSend(ws, { type: 'system_message', sessionId: session.id, message }); + if (isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) { + wsSend(activeGoalCommand.ws || ws, { type: 'system_message', sessionId: session.id, message }); + } + } finally { + finishCodexAppGoalCommand(session.id, activeGoalCommand); } } @@ -6799,6 +6856,10 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) { wsSend(ws, { type: 'system_message', message: 'Codex App 运行中暂不支持 slash 指令,请等待完成或点击停止。' }); return; } + if (session && isCodexAppSession(session) && activeCodexAppGoalCommands.has(sessionId)) { + wsSend(ws, { type: 'system_message', sessionId, message: 'Codex App Goal 正在同步,请稍候。' }); + return; + } switch (cmd) { case '/clear': { @@ -7338,6 +7399,11 @@ function handleLoadSession(ws, msg) { text: truncateTextValue(entry.fullText || '', SESSION_MESSAGE_CONTENT_MAX_CHARS), toolCalls: sanitizeToolCallsForPersist(entry.toolCalls || []), }); + } else if (activeCodexAppGoalCommands.has(sessionId)) { + const entry = activeCodexAppGoalCommands.get(sessionId); + entry.ws = ws; + entry.wsDisconnectTime = null; + wsSend(ws, { type: 'system_message', sessionId, message: '正在同步 Goal...' }); } } @@ -7406,6 +7472,11 @@ function handleDeleteSession(ws, sessionId) { pendingSlashCommands.delete(sessionId); pendingCompactRetries.delete(sessionId); cancelCodexCapacityRetry(sessionId); + if (activeCodexAppGoalCommands.has(sessionId)) { + const entry = activeCodexAppGoalCommands.get(sessionId); + entry.cancelled = true; + activeCodexAppGoalCommands.delete(sessionId); + } deleteCrossConversationRepliesForSession(sessionId); for (const [threadId, child] of ccwebMcpChildThreads.entries()) { if (child.parentSessionId === sessionId) ccwebMcpChildThreads.delete(threadId); @@ -7525,6 +7596,7 @@ function handleAbort(ws) { const sessionId = wsSessionMap.get(ws); if (!sessionId) return; if (handleCodexAppAbortSession(sessionId, ws)) return; + if (cancelCodexAppGoalCommand(sessionId, ws)) return; const entry = activeProcesses.get(sessionId); if (!entry) { if (cancelCodexCapacityRetry(sessionId)) { @@ -7642,6 +7714,10 @@ function handleMessage(ws, msg, options = {}) { return handleCodexAppSteerMessage(ws, msg, options); } + if (sessionId && activeCodexAppGoalCommands.has(sessionId)) { + return fail('session_running', 'Codex App Goal 正在同步,请稍候。'); + } + if (sessionId && activeProcesses.has(sessionId)) { return fail('session_running', '正在处理中,请先点击停止按钮。'); } @@ -7968,6 +8044,12 @@ function detachWsFromActiveRuntimes(ws, options = {}) { if (disconnectTime) entry.wsDisconnectTime = disconnectTime; } } + for (const [, entry] of activeCodexAppGoalCommands) { + if (entry.ws === ws) { + entry.ws = null; + if (disconnectTime) entry.wsDisconnectTime = disconnectTime; + } + } } function findCodexAppEntryByRuntime(params = {}) {