diff --git a/.codex/skills/cc-web-centos7-release/SKILL.md b/.codex/skills/cc-web-centos7-release/SKILL.md new file mode 100644 index 0000000..8dd4d86 --- /dev/null +++ b/.codex/skills/cc-web-centos7-release/SKILL.md @@ -0,0 +1,90 @@ +--- +name: cc-web-centos7-release +description: Rebuild and verify the cc-web CentOS 7 compatible Bun baseline single executable release package. Use when the user asks to 打包, 重新打包, build single-exe, CentOS 7 发布包, dist-exe/cc-web-bun-linux-x64-baseline.tar.gz, or asks why Bun/BUN_BIN was needed for this project. +--- + +# cc-web CentOS 7 Release + +## Core Rules + +- Build the CentOS 7 release with `scripts/build-single-exe.js`. +- The default target must stay `bun-linux-x64-baseline`. +- The release archive is `dist-exe/cc-web-bun-linux-x64-baseline.tar.gz`. +- Do not switch this project back to Docker for CentOS 7 compatibility. +- Do not install NodeSource Node.js 22 on CentOS 7. CentOS 7 has glibc 2.17, while current NodeSource Node.js 22 packages require newer glibc/libstdc++ symbols. +- Do not bundle Claude/Codex CLI into the release package. cc-web must call host CLIs at runtime through `CLAUDE_PATH`, `CODEX_PATH`, or `PATH`. + +## Build Workflow + +1. Check whether `bun` is available: + +```bash +command -v bun +``` + +2. If `bun` is not in `PATH`, reuse an existing local Bun binary before downloading anything: + +```bash +find /home /tmp -type f -name bun -perm -111 2>/dev/null | head -50 +``` + +Prefer a baseline binary path like: + +```text +/tmp/ccweb-bun.*/node_modules/@oven/bun-linux-x64-baseline/bin/bun +``` + +3. Build with `BUN_BIN` when using a local/temporary Bun: + +```bash +BUN_BIN=/tmp/ccweb-bun.rhfNgd/node_modules/@oven/bun-linux-x64-baseline/bin/bun npm run build:single-exe +``` + +If `bun` is already in `PATH`, this is enough: + +```bash +npm run build:single-exe +``` + +4. After adding or changing `.codex/skills`, `.agents/skills`, public assets, runtime assets, or server code, rebuild again. The build copies runtime assets into `dist-exe/bun-linux-x64-baseline/` before creating the tarball. + +## Verification + +Run lightweight checks before committing: + +```bash +node --check server.js +node --check lib/codex-app-runtime.js +node --check scripts/mock-codex-app-server.js +node --check scripts/mock-codex.js +node --check scripts/regression.js +node --check public/app.js +./dist-exe/bun-linux-x64-baseline/cc-web --ccweb-mcp-server +tar -tzf dist-exe/cc-web-bun-linux-x64-baseline.tar.gz | head +``` + +For the MCP smoke test, send one JSON-RPC `initialize` request on stdin and expect a valid JSON response. Do not leave the process running. + +## CentOS 7 Run Command + +On the target machine, unpack the archive and run the binary from the release directory: + +```bash +tar -xzf cc-web-bun-linux-x64-baseline.tar.gz +cd bun-linux-x64-baseline +chmod +x cc-web +PORT=8002 CC_WEB_PASSWORD='请改成强密码' ./cc-web +``` + +For background execution without PM2: + +```bash +nohup env PORT=8002 CC_WEB_PASSWORD='请改成强密码' ./cc-web > logs/cc-web.out 2>&1 & +``` + +For host CLI paths: + +```bash +export CLAUDE_PATH=/usr/local/bin/claude +export CODEX_PATH=/usr/local/bin/codex +``` diff --git a/.codex/skills/cc-web-centos7-release/agents/openai.yaml b/.codex/skills/cc-web-centos7-release/agents/openai.yaml new file mode 100644 index 0000000..e9d8a73 --- /dev/null +++ b/.codex/skills/cc-web-centos7-release/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "CC Web CentOS7 Release" + short_description: "固化 cc-web CentOS 7 单文件打包流程" + default_prompt: "Use $cc-web-centos7-release to rebuild the CentOS 7 Bun baseline single-exe release package." 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 d7c1b6a..6d2ffce 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/lib/codex-app-runtime.js b/lib/codex-app-runtime.js index 3b8175b..ddaef99 100644 --- a/lib/codex-app-runtime.js +++ b/lib/codex-app-runtime.js @@ -131,6 +131,28 @@ function createCodexAppRuntime(deps = {}) { return true; } + function codexAppErrorMessage(value) { + if (!value) return ''; + if (typeof value === 'string') return value; + if (typeof value !== 'object') return String(value); + + const parts = []; + const directMessage = value.message || value.title || value.detail || value.reason; + if (directMessage) parts.push(String(directMessage)); + + const error = value.error && typeof value.error === 'object' ? value.error : null; + if (error) { + if (error.message) parts.push(String(error.message)); + if (error.code) parts.push(String(error.code)); + if (error.type) parts.push(String(error.type)); + } + + if (value.code) parts.push(String(value.code)); + if (value.type) parts.push(String(value.type)); + if (parts.length > 0) return [...new Set(parts)].join(' '); + return safeStringifyPreview(value, 2000, { maxDepth: 3, maxArray: 10, maxKeys: 20 }); + } + function sendRuntime(entry, sessionId, payload) { wsSend(entry.ws, { ...payload, sessionId }); } @@ -652,7 +674,7 @@ function createCodexAppRuntime(deps = {}) { if (params.turn?.id) entry.turnId = params.turn.id; entry.turnStatus = params.turn?.status || 'completed'; if (params.turn?.status === 'failed') { - entry.lastError = params.turn?.error?.message || 'Codex App 任务失败'; + entry.lastError = codexAppErrorMessage(params.turn?.error) || 'Codex App 任务失败'; } return { done: true }; } @@ -662,14 +684,16 @@ function createCodexAppRuntime(deps = {}) { case 'guardianWarning': case 'configWarning': case 'deprecationNotice': { - const message = params.message || params.title || ''; + const message = method === 'error' + ? codexAppErrorMessage(params) + : (params.message || params.title || ''); if (message) { if (method === 'error') entry.lastError = message; if (method === 'error' || shouldShowRuntimeNotice(method, message)) { sendRuntime(entry, sessionId, { type: 'system_message', message }); } } - return { done: false }; + return { done: method === 'error' }; } default: diff --git a/public/app.js b/public/app.js index 4f5ed20..a37df2c 100644 --- a/public/app.js +++ b/public/app.js @@ -2,7 +2,7 @@ (function () { 'use strict'; - const ASSET_VERSION = '20260624-icon-refresh'; + const ASSET_VERSION = '20260625-branch-bubble'; const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`; const RENDER_DEBOUNCE = 100; const COMPOSER_SUGGESTION_DEBOUNCE = 120; @@ -11,10 +11,13 @@ const CROSS_CONVERSATION_REPLY_COLLAPSE_STORAGE_KEY = 'cc-web-collapsed-cross-replies'; const CROSS_CONVERSATION_REPLY_COLLAPSE_LIMIT = 500; const ASSISTANT_LAST_SECTION_BUTTON_CLASS = 'msg-last-section-btn'; + const ASSISTANT_BRANCH_BUTTON_CLASS = 'msg-branch-btn'; const ASSISTANT_LAST_SECTION_FOCUS_CLASS = 'msg-last-section-focus'; const ASSISTANT_LAST_SECTION_SCROLL_OFFSET = 72; const ASSISTANT_LAST_SECTION_SKIP_SELECTOR = [ `.${ASSISTANT_LAST_SECTION_BUTTON_CLASS}`, + `.${ASSISTANT_BRANCH_BUTTON_CLASS}`, + '.msg-action-row', '.msg-tools', '.tool-call', '.tool-group', @@ -190,6 +193,7 @@ const attachmentPreviewCache = new Map(); let loginPasswordValue = ''; // store login password for force-change flow let currentCwd = null; + let currentSessionMessageCount = 0; let currentSessionRunning = false; let fileBrowserState = null; let directoryPickerState = null; @@ -1406,17 +1410,74 @@ return button; } + function getMessageActionRow(bubble) { + if (!bubble) return null; + let row = bubble.querySelector(':scope > .msg-action-row'); + if (!row) { + row = document.createElement('div'); + row.className = 'msg-action-row'; + bubble.appendChild(row); + } + return row; + } + function syncAssistantLastSectionButton(messageEl) { if (!messageEl?.classList?.contains('assistant')) return; const bubble = messageEl.querySelector(':scope > .msg-bubble'); if (!bubble) return; - let button = bubble.querySelector(`:scope > .${ASSISTANT_LAST_SECTION_BUTTON_CLASS}`); + let button = bubble.querySelector(`:scope .${ASSISTANT_LAST_SECTION_BUTTON_CLASS}`); const hasTarget = !!getAssistantLastSectionTarget(bubble); if (!button && !hasTarget) return; if (!button) button = createAssistantLastSectionButton(); button.hidden = !hasTarget; button.disabled = !hasTarget; - bubble.appendChild(button); + getMessageActionRow(bubble)?.appendChild(button); + } + + function createAssistantBranchButton(messageIndex) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = ASSISTANT_BRANCH_BUTTON_CLASS; + button.title = '从这里分支新会话'; + button.setAttribute('aria-label', '从这里分支新会话'); + button.dataset.messageIndex = String(messageIndex); + button.innerHTML = ` + + `; + button.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + const index = Number.parseInt(button.dataset.messageIndex || '', 10); + branchFromAssistantMessage(index); + }); + return button; + } + + function syncAssistantBranchButton(messageEl, messageIndex) { + if (!messageEl?.classList?.contains('assistant')) return; + if (!currentSessionId || !Number.isFinite(messageIndex) || messageIndex < 0) return; + const bubble = messageEl.querySelector(':scope > .msg-bubble'); + if (!bubble) return; + let button = bubble.querySelector(`:scope .${ASSISTANT_BRANCH_BUTTON_CLASS}`); + if (!button) button = createAssistantBranchButton(messageIndex); + button.dataset.messageIndex = String(messageIndex); + getMessageActionRow(bubble)?.appendChild(button); + } + + function markSessionMessageElement(messageEl, messageIndex) { + if (!messageEl || !Number.isFinite(messageIndex) || messageIndex < 0) return; + messageEl.dataset.sessionMessage = 'true'; + messageEl.dataset.messageIndex = String(messageIndex); + if (messageEl.classList.contains('assistant')) { + syncAssistantBranchButton(messageEl, messageIndex); + } } function updateSessionIdBadge() { @@ -1534,10 +1595,17 @@ function normalizeSessionSnapshot(payload, options = {}) { const sessionId = payload.sessionId || payload.id || ''; + const messages = cloneMessages(payload.messages || []); + const historyTotal = Number.isFinite(Number(payload.historyTotal)) + ? Math.max(0, Number(payload.historyTotal)) + : messages.length; + const historyBaseIndex = Number.isFinite(Number(payload.historyBaseIndex)) + ? Math.max(0, Number(payload.historyBaseIndex)) + : Math.max(0, historyTotal - messages.length); return { sessionId, id: sessionId, - messages: cloneMessages(payload.messages || []), + messages, title: payload.title || '新会话', mode: payload.mode || 'yolo', model: payload.model || '', @@ -1558,6 +1626,8 @@ waitingReplyCount: Number(payload.waitingReplyCount || 0), failedReplyCount: Number(payload.failedReplyCount || 0), pendingReplies: Array.isArray(payload.pendingReplies) ? deepClone(payload.pendingReplies) : [], + historyTotal, + historyBaseIndex, historyPending: !!payload.historyPending, complete: options.complete !== undefined ? !!options.complete : !payload.historyPending, }; @@ -3332,6 +3402,7 @@ closeFileBrowser(); currentSessionId = null; loadedHistorySessionId = null; + currentSessionMessageCount = 0; clearSessionLoading(); setCurrentSessionRunningState(false); currentCwd = null; @@ -3375,6 +3446,11 @@ } currentSessionId = snapshot.sessionId; loadedHistorySessionId = snapshot.sessionId; + currentSessionMessageCount = Math.max( + snapshot.historyTotal || 0, + snapshot.historyBaseIndex + (snapshot.messages || []).length, + (snapshot.messages || []).length, + ); setLastSessionForAgent(snapshot.agent, currentSessionId); chatTitle.textContent = snapshot.title || '新会话'; updateSessionIdBadge(); @@ -3393,7 +3469,10 @@ } currentModel = snapshot.model || ''; if (!preserveStreaming) { - renderMessages(snapshot.messages || [], { immediate: !!options.immediate }); + renderMessages(snapshot.messages || [], { + immediate: !!options.immediate, + baseIndex: snapshot.historyBaseIndex || 0, + }); if (snapshot.isRunning && snapshot.sessionId === currentSessionId) { startGenerating(snapshot.sessionId); } @@ -4231,6 +4310,7 @@ prependHistoryMessages(msg.messages || [], { preserveScroll: !blocking, skipScrollbar: blocking, + baseIndex: Number.isFinite(Number(msg.historyBaseIndex)) ? Number(msg.historyBaseIndex) : 0, }); if (!msg.remaining) { finalizeLoadedSession(msg.sessionId); @@ -4266,11 +4346,13 @@ } } if (msg.sessionId === currentSessionId && msg.message) { + const messageIndex = currentSessionMessageCount; + currentSessionMessageCount += 1; collectClosedCollabAgentIds([msg.message]).forEach((id) => closedCollabAgentIds.add(id)); const welcome = messagesDiv.querySelector('.welcome-msg'); if (welcome) welcome.remove(); const shouldFollow = !(currentSessionRunning || isGenerating) || isNearBottom(); - messagesDiv.appendChild(buildMsgElement(msg.message)); + messagesDiv.appendChild(buildMsgElement(msg.message, messageIndex)); followOutputIfNeeded(shouldFollow); setCurrentSessionRunningState(!!getSessionMeta(currentSessionId)?.isRunning); } @@ -4494,6 +4576,10 @@ cwd: request.cwd, agent: request.agent, mode: request.mode, + model: request.model, + title: request.title, + branchSourceSessionId: request.branchSourceSessionId, + branchMessageIndex: request.branchMessageIndex, createCwd: true, requestId: request.requestId, }); @@ -4503,6 +4589,10 @@ agent: request.agent, cwd: request.rawCwd || request.cwd, mode: request.mode, + model: request.model, + title: request.title, + branchSourceSessionId: request.branchSourceSessionId, + branchMessageIndex: request.branchMessageIndex, }); }, }); @@ -4632,6 +4722,10 @@ function finishGenerating(sessionId) { if (sessionId && currentSessionId && sessionId !== currentSessionId) return; + const hasPersistedAssistantMessage = !!( + pendingText + || (Array.isArray(window.pendingContentBlocks) && window.pendingContentBlocks.length > 0) + ); isGenerating = false; generatingSessionId = null; updateNoteModeUI(); @@ -4674,6 +4768,11 @@ } } streamEl.removeAttribute('id'); + if (hasPersistedAssistantMessage && currentSessionId) { + const messageIndex = currentSessionMessageCount; + currentSessionMessageCount += 1; + markSessionMessageElement(streamEl, messageIndex); + } syncAssistantLastSectionButton(streamEl); } @@ -5769,7 +5868,7 @@ return fallbackMessages.length > 0 ? fallbackMessages[fallbackMessages.length - 1] : null; } - function buildMsgElement(m) { + function buildMsgElement(m, messageIndex = null) { const el = createMsgElement(m.role, m.content, m.attachments || [], m); if (m.role === 'assistant' && m.toolCalls && m.toolCalls.length > 0) { const bubble = el.querySelector('.msg-bubble'); @@ -5820,12 +5919,16 @@ } } } + if (Number.isFinite(messageIndex)) { + markSessionMessageElement(el, messageIndex); + } return el; } function renderMessages(messages, options = {}) { renderEpoch++; const epoch = renderEpoch; + const baseIndex = Number.isFinite(Number(options.baseIndex)) ? Number(options.baseIndex) : 0; closedCollabAgentIds = collectClosedCollabAgentIds(messages); messagesDiv.innerHTML = ''; clearUserMessageIndex(); @@ -5838,7 +5941,7 @@ } if (options.immediate) { const frag = document.createDocumentFragment(); - messages.forEach((message) => frag.appendChild(buildMsgElement(message))); + messages.forEach((message, index) => frag.appendChild(buildMsgElement(message, baseIndex + index))); messagesDiv.appendChild(frag); updateUserOutlinePanel(); renderPendingNotes({ scroll: false }); @@ -5861,7 +5964,7 @@ // Render first batch immediately const frag0 = document.createDocumentFragment(); - for (let i = batches[0][0]; i < batches[0][1]; i++) frag0.appendChild(buildMsgElement(messages[i])); + for (let i = batches[0][0]; i < batches[0][1]; i++) frag0.appendChild(buildMsgElement(messages[i], baseIndex + i)); messagesDiv.appendChild(frag0); updateUserOutlinePanel(); renderPendingNotes({ scroll: false }); @@ -5878,7 +5981,7 @@ const prevHeight = messagesDiv.scrollHeight; const prevScrollTop = messagesDiv.scrollTop; const frag = document.createDocumentFragment(); - for (let i = start; i < end; i++) frag.appendChild(buildMsgElement(messages[i])); + for (let i = start; i < end; i++) frag.appendChild(buildMsgElement(messages[i], baseIndex + i)); messagesDiv.insertBefore(frag, messagesDiv.firstChild); updateUserOutlinePanel(); // Compensate scrollTop so visible area stays unchanged @@ -5890,13 +5993,14 @@ function prependHistoryMessages(messages, options = {}) { if (!Array.isArray(messages) || messages.length === 0) return; + const baseIndex = Number.isFinite(Number(options.baseIndex)) ? Number(options.baseIndex) : 0; collectClosedCollabAgentIds(messages).forEach((id) => closedCollabAgentIds.add(id)); const preserveScroll = options.preserveScroll !== false; const skipScrollbar = options.skipScrollbar === true; const welcome = messagesDiv.querySelector('.welcome-msg'); if (welcome) welcome.remove(); const frag = document.createDocumentFragment(); - messages.forEach((m) => frag.appendChild(buildMsgElement(m))); + messages.forEach((m, index) => frag.appendChild(buildMsgElement(m, baseIndex + index))); if (!preserveScroll) { messagesDiv.insertBefore(frag, messagesDiv.firstChild); updateUserOutlinePanel(); @@ -7210,6 +7314,11 @@ const messageId = createLocalId('user'); const element = createMsgElement('user', text, attachments, { messageId }); messagesDiv.appendChild(element); + if (currentSessionId) { + const messageIndex = currentSessionMessageCount; + currentSessionMessageCount += 1; + markSessionMessageElement(element, messageIndex); + } registerUserMessage(messageId, element, text); updateUserOutlinePanel(); scrollToBottom(); @@ -7259,6 +7368,11 @@ } else { messagesDiv.appendChild(element); } + if (currentSessionId) { + const messageIndex = currentSessionMessageCount; + currentSessionMessageCount += 1; + markSessionMessageElement(element, messageIndex); + } registerUserMessage(messageId, element, text); updateUserOutlinePanel(); if (shouldFollow) { @@ -7886,6 +8000,31 @@
+ +
+
容量失败重试
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ 仅对没有产生文本或工具调用的 Codex 容量/过载失败生效,避免重复执行已有副作用的任务。 +
+
@@ -7918,6 +8057,11 @@ const closeBtn = panel.querySelector('.settings-close'); const codexModeSelect = panel.querySelector('#codex-mode'); const codexProfileArea = panel.querySelector('#codex-profile-area'); + const codexRetryModeSelect = panel.querySelector('#codex-retry-mode'); + const codexRetryIntervalInput = panel.querySelector('#codex-retry-interval'); + const codexRetryAttemptsInput = panel.querySelector('#codex-retry-attempts'); + const codexRetryAttemptsField = panel.querySelector('#codex-retry-attempts-field'); + const codexRetryNote = panel.querySelector('#codex-retry-note'); const codexStatus = panel.querySelector('#codex-status'); const codexSaveBtn = panel.querySelector('#codex-save-btn'); @@ -7928,6 +8072,7 @@ let currentCodexConfig = null; let codexEditingProfiles = []; let codexActiveProfile = ''; + let codexRetryConfig = { mode: 'limited', intervalSeconds: 2, maxAttempts: 3 }; let _onUpdateInfo = null; function showCodexStatus(msg, type) { @@ -7935,6 +8080,42 @@ codexStatus.className = 'settings-status ' + (type || ''); } + function normalizeCodexRetryConfig(raw = {}) { + const mode = ['off', 'limited', 'forever'].includes(raw.mode) ? raw.mode : 'limited'; + const intervalSeconds = Math.max(1, Math.min(3600, Number.parseInt(String(raw.intervalSeconds || ''), 10) || 2)); + const maxAttempts = Math.max(1, Math.min(1000, Number.parseInt(String(raw.maxAttempts || ''), 10) || 3)); + return { mode, intervalSeconds, maxAttempts }; + } + + function syncCodexRetryInputs() { + const mode = codexRetryModeSelect.value; + const disabled = mode === 'off'; + codexRetryIntervalInput.disabled = disabled; + codexRetryAttemptsInput.disabled = disabled || mode === 'forever'; + codexRetryAttemptsField.classList.toggle('settings-field-disabled', disabled || mode === 'forever'); + codexRetryNote.textContent = mode === 'off' + ? '已关闭自动重试;容量/过载失败会直接显示错误。' + : mode === 'forever' + ? '会一直按固定间隔重试;仍只在没有文本或工具调用时触发。' + : '按指定次数重试;仍只在没有文本或工具调用时触发,避免重复执行副作用。'; + } + + function setCodexRetryConfig(config) { + codexRetryConfig = normalizeCodexRetryConfig(config); + codexRetryModeSelect.value = codexRetryConfig.mode; + codexRetryIntervalInput.value = String(codexRetryConfig.intervalSeconds); + codexRetryAttemptsInput.value = String(codexRetryConfig.maxAttempts); + syncCodexRetryInputs(); + } + + function readCodexRetryConfig() { + return normalizeCodexRetryConfig({ + mode: codexRetryModeSelect.value, + intervalSeconds: codexRetryIntervalInput.value, + maxAttempts: codexRetryAttemptsInput.value, + }); + } + function renderCodexProfileArea() { const mode = codexModeSelect.value; if (mode === 'local') { @@ -8091,10 +8272,13 @@ codexModeSelect.value = currentCodexConfig.mode || 'local'; codexEditingProfiles = (currentCodexConfig.profiles || []).map((profile) => ({ ...profile })); codexActiveProfile = currentCodexConfig.activeProfile || (codexEditingProfiles[0]?.name || ''); + setCodexRetryConfig(currentCodexConfig.retry || codexRetryConfig); renderCodexProfileArea(); }; codexModeSelect.addEventListener('change', renderCodexProfileArea); + codexRetryModeSelect.addEventListener('change', syncCodexRetryInputs); + setCodexRetryConfig(codexRetryConfig); codexSaveBtn.addEventListener('click', () => { if (codexModeSelect.value === 'custom' && codexEditingProfiles.length === 0) { @@ -8106,6 +8290,7 @@ activeProfile: codexActiveProfile, profiles: codexEditingProfiles, enableSearch: false, + retry: readCodexRetryConfig(), }; send({ type: 'save_codex_config', config }); showCodexStatus('已保存', 'success'); @@ -8680,21 +8865,59 @@ try { localStorage.setItem(RECENT_CWD_KEY, JSON.stringify(list)); } catch {} } + function branchFromAssistantMessage(messageIndex) { + if (!currentSessionId) { + appendError('当前没有可分支的会话。', { transient: true, autoDismissMs: 4000 }); + return; + } + if (!Number.isFinite(messageIndex) || messageIndex < 0) { + appendError('无法定位分支消息,请刷新会话后重试。', { transient: true, autoDismissMs: 4000 }); + return; + } + const sourceTitle = (chatTitle.textContent || '新会话').trim() || '新会话'; + const cwd = currentCwd || getSessionEffectiveCwd(currentSessionId) || null; + requestNewSession({ + cwd, + rawCwd: cwd || '', + agent: currentAgent, + mode: currentMode, + model: currentModel || '', + title: `${sourceTitle} 的分支`, + branchSourceSessionId: currentSessionId, + branchMessageIndex: messageIndex, + }); + } + function requestNewSession(options = {}) { const cwd = options.cwd || null; const rawCwd = options.rawCwd !== undefined ? options.rawCwd : (cwd || ''); const agent = normalizeAgent(options.agent || currentAgent); const mode = ['default', 'plan', 'yolo'].includes(options.mode) ? options.mode : currentMode; + const model = typeof options.model === 'string' ? options.model.trim() : ''; + const title = typeof options.title === 'string' ? options.title.trim() : ''; + const branchSourceSessionId = String(options.branchSourceSessionId || '').trim(); + const branchMessageIndex = Number.isFinite(Number(options.branchMessageIndex)) + ? Number(options.branchMessageIndex) + : null; const requestId = createSessionSwitchRequestId('new'); pendingNewSessionRequest = { cwd, rawCwd, agent, mode, + model, + title, + branchSourceSessionId, + branchMessageIndex, requestId, }; if (cwd) saveRecentCwd(cwd); - send({ type: 'new_session', cwd, agent, mode, requestId }); + const payload = { type: 'new_session', cwd, agent, mode, requestId }; + if (model) payload.model = model; + if (title) payload.title = title; + if (branchSourceSessionId) payload.branchSourceSessionId = branchSourceSessionId; + if (branchMessageIndex !== null) payload.branchMessageIndex = branchMessageIndex; + send(payload); } // --- New Session Modal --- @@ -8828,6 +9051,10 @@ rawCwd, agent: targetAgent, mode: requestedMode, + model: options.model || '', + title: options.title || '', + branchSourceSessionId: options.branchSourceSessionId || '', + branchMessageIndex: options.branchMessageIndex, }); } diff --git a/public/index.html b/public/index.html index f68fe4e..5108e27 100644 --- a/public/index.html +++ b/public/index.html @@ -20,7 +20,7 @@ document.documentElement.dataset.dividerTime = dividerTime; })(); - + @@ -169,6 +169,6 @@ - + diff --git a/public/style.css b/public/style.css index 709f3d2..ce309fa 100644 --- a/public/style.css +++ b/public/style.css @@ -2376,11 +2376,22 @@ body.session-loading-active { border-bottom-left-radius: 4px; color: var(--text-primary); } -.msg-last-section-btn { +.msg-action-row { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 6px; + margin-top: 8px; +} +.msg-action-row:empty { + display: none; +} +.msg-last-section-btn, +.msg-branch-btn { appearance: none; width: 28px; height: 28px; - margin: 8px 0 0 auto; + margin: 0; padding: 0; display: flex; align-items: center; @@ -2393,21 +2404,25 @@ body.session-loading-active { opacity: 0.72; transition: opacity 0.15s ease, background 0.15s ease, border-color 0.15s ease, color 0.15s ease, transform 0.15s ease; } -.msg-last-section-btn[hidden] { +.msg-last-section-btn[hidden], +.msg-branch-btn[hidden] { display: none; } -.msg-last-section-btn svg { +.msg-last-section-btn svg, +.msg-branch-btn svg { display: block; flex-shrink: 0; } -.msg-last-section-btn:hover { +.msg-last-section-btn:hover, +.msg-branch-btn:hover { opacity: 1; background: var(--bg-tertiary); border-color: var(--accent); color: var(--accent); transform: translateY(-1px); } -.msg-last-section-btn:focus-visible { +.msg-last-section-btn:focus-visible, +.msg-branch-btn:focus-visible { opacity: 1; outline: 2px solid rgba(91, 126, 161, 0.28); outline-offset: 2px; @@ -4167,6 +4182,19 @@ html[data-divider-time='hide'] .msg-bubble .agent-message-divider span { } .settings-field input:focus, .settings-select:focus { border-color: var(--accent); } +.settings-field input:disabled, +.settings-select:disabled { + cursor: not-allowed; + opacity: 0.55; +} +.settings-field-disabled { + opacity: 0.65; +} +.settings-retry-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 12px; +} .settings-select { -webkit-appearance: none; appearance: none; @@ -4219,6 +4247,7 @@ html[data-divider-time='hide'] .msg-bubble .agent-message-divider span { .settings-panel { width: 95%; padding: 20px 16px; } .settings-nav-card { padding: 13px 14px; } .settings-back { width: 32px; height: 32px; } + .settings-retry-grid { grid-template-columns: 1fr; } } /* === Force Change Password Overlay === */ diff --git a/scripts/mock-codex-app-server.js b/scripts/mock-codex-app-server.js index 056f615..ff49bdf 100755 --- a/scripts/mock-codex-app-server.js +++ b/scripts/mock-codex-app-server.js @@ -89,6 +89,8 @@ function ensureThread(threadId, params = {}) { activeTurnId: null, timer: null, steers: [], + capacityRetryAttempts: new Map(), + reconnectRetryAttempts: new Map(), goal: null, }); } @@ -463,6 +465,60 @@ function completeMcpToolTurn(thread, turnId) { completeTurn(thread, turnId, `mcp result: ${JSON.stringify(payload)}`); } +function emitCapacityError(thread, turnId) { + send({ + method: 'error', + params: { + threadId: thread.id, + turnId, + type: 'error', + error: { + type: 'service_unavailable_error', + code: 'server_is_overloaded', + message: 'Our servers are currently overloaded. Please try again later.', + param: null, + }, + sequence_number: 2, + }, + }); + thread.activeTurnId = null; +} + +function emitPartialCapacityOutput(thread, turnId) { + send({ + method: 'item/agentMessage/delta', + params: { + threadId: thread.id, + turnId, + itemId: 'agent-msg', + delta: 'partial capacity output before retry', + }, + }); + send({ + method: 'item/started', + params: { + threadId: thread.id, + turnId, + startedAtMs: Date.now(), + item: { + id: 'capacity-tool', + type: 'commandExecution', + command: '/bin/bash -lc echo capacity', + status: 'inProgress', + }, + }, + }); + send({ + method: 'item/commandExecution/outputDelta', + params: { + threadId: thread.id, + turnId, + itemId: 'capacity-tool', + delta: 'capacity tool output\n', + }, + }); +} + function completeGuidedInputTurn(thread, turnId) { requestClient('item/tool/requestUserInput', { threadId: thread.id, @@ -566,6 +622,34 @@ function startTurn(params) { } } + if (/codexapp capacity retry/i.test(text)) { + const attempts = (thread.capacityRetryAttempts.get(text) || 0) + 1; + thread.capacityRetryAttempts.set(text, attempts); + if (attempts <= 2) { + if (attempts === 2) emitPartialCapacityOutput(thread, turnId); + emitCapacityError(thread, turnId); + return { turn: { id: turnId, status: 'running', items: [] } }; + } + } + + if (/codexapp reconnect retry/i.test(text)) { + const attempts = (thread.reconnectRetryAttempts.get(text) || 0) + 1; + thread.reconnectRetryAttempts.set(text, attempts); + if (attempts === 1) { + emitPartialCapacityOutput(thread, turnId); + send({ + method: 'error', + params: { + threadId: thread.id, + turnId, + message: 'Reconnecting... 1/5', + }, + }); + thread.activeTurnId = null; + return { turn: { id: turnId, status: 'running', items: [] } }; + } + } + if (/collaboration/i.test(text)) { completeTurn(thread, turnId, `collaboration mode: ${collaborationSummary(params)}`); return { turn: { id: turnId, status: 'running', items: [] } }; diff --git a/scripts/mock-codex.js b/scripts/mock-codex.js index 926ea7a..e35eb32 100755 --- a/scripts/mock-codex.js +++ b/scripts/mock-codex.js @@ -87,6 +87,20 @@ function sleep(ms) { process.exit(1); } + if (input === 'trigger codex capacity retry' && !state.capacityRetried) { + state.capacityRetried = true; + fs.writeFileSync(statePath, JSON.stringify(state)); + process.stdout.write(`${JSON.stringify({ + type: 'turn.failed', + error: { + type: 'service_unavailable_error', + code: 'server_is_overloaded', + message: 'Our servers are currently overloaded. Please try again later.', + }, + })}\n`); + process.exit(1); + } + if (input === 'slow cross-session prompt') { await sleep(800); } diff --git a/scripts/regression.js b/scripts/regression.js index 93c9908..d9f597b 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -621,6 +621,7 @@ async function main() { HOME: homeDir, CLAUDE_PATH: MOCK_CLAUDE, CODEX_PATH: MOCK_CODEX_APP_SERVER, + CC_WEB_CODEX_TRANSIENT_RETRY_BASE_DELAY_MS: '100', }, async () => { await assertWsUpgradeRejected(port, '/not-ws'); @@ -663,6 +664,7 @@ async function main() { activeProfile: 'Regression Profile', profiles: [{ name: 'Regression Profile', apiKey: 'sk-regression', apiBase: 'https://example.com/v1' }], enableSearch: true, + retry: { mode: 'limited', intervalSeconds: 1, maxAttempts: 2 }, }, })); const codexConfigMsg = await nextMessage(messages, ws, (msg) => msg.type === 'codex_config'); @@ -671,6 +673,9 @@ async function main() { assert(Array.isArray(codexConfigMsg.config.profiles) && codexConfigMsg.config.profiles[0]?.apiKey.includes('****'), 'Codex profile API key should be masked'); assert(codexConfigMsg.config.supportsSearch === false, 'Codex config should expose unsupported search capability'); assert(codexConfigMsg.config.enableSearch === false, 'Codex config should ignore unsupported search toggle'); + assert(codexConfigMsg.config.retry?.mode === 'limited', 'Codex retry mode should round-trip'); + assert(codexConfigMsg.config.retry?.intervalSeconds === 1, 'Codex retry interval should round-trip'); + assert(codexConfigMsg.config.retry?.maxAttempts === 2, 'Codex retry max attempts should round-trip'); const codexInitCwd = path.join(tempRoot, 'codex-space'); mkdirp(codexInitCwd); @@ -1201,6 +1206,15 @@ async function main() { assert(lastSpawn.includes('-s read-only'), 'Codex plan mode should set sandbox read-only'); assert(lastSpawn.includes('-s read-only resume'), 'Codex resume in plan mode must place -s before resume subcommand'); + ws.send(JSON.stringify({ type: 'message', text: 'trigger codex capacity retry', sessionId: firstMessageSession.sessionId, mode: 'plan', agent: 'codex' })); + const capacityRetryNotice = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /自动重试/.test(msg.message || '') && msg.sessionId === firstMessageSession.sessionId, 10000); + assert(/Codex 服务暂时繁忙/.test(capacityRetryNotice.message || ''), 'Codex transient capacity failure should announce automatic retry'); + await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === firstMessageSession.sessionId, 20000); + const storedAfterCapacityRetry = JSON.parse(fs.readFileSync(codexSessionPath, 'utf8')); + const capacityRetryUsers = storedAfterCapacityRetry.messages.filter((message) => message.role === 'user' && message.content === 'trigger codex capacity retry'); + assert(capacityRetryUsers.length === 1, 'Codex transient retry should not duplicate the user message'); + assert(storedAfterCapacityRetry.messages.some((message) => message.role === 'assistant' && /trigger codex capacity retry/.test(String(message.content || ''))), 'Codex transient retry should persist the successful assistant response'); + const runtimeToml = fs.readFileSync(path.join(configDir, 'codex-runtime-home', 'config.toml'), 'utf8'); assert(runtimeToml.includes('preferred_auth_method = "apikey"'), 'Codex custom profile should write isolated runtime auth mode'); assert(runtimeToml.includes('base_url = "https://example.com/v1"'), 'Codex custom profile should write isolated runtime base_url'); @@ -1265,6 +1279,64 @@ async function main() { assert(/"hasTopLevelEffort":false/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration turn should not duplicate effort at top level'); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId); + ws.send(JSON.stringify({ + type: 'save_codex_config', + config: { + mode: 'custom', + activeProfile: 'Regression Profile Updated', + profiles: [{ name: 'Regression Profile Updated', apiKey: 'sk-regression-updated', apiBase: 'https://updated.example.com/v1' }], + enableSearch: false, + retry: { mode: 'limited', intervalSeconds: 1, maxAttempts: 2 }, + }, + })); + const codexAppChangedConfig = await nextMessage(messages, ws, (msg) => + msg.type === 'codex_config' && msg.config?.activeProfile === 'Regression Profile Updated' + ); + assert(codexAppChangedConfig.config.mode === 'custom', 'Codex App config-change regression should save custom mode'); + + ws.send(JSON.stringify({ type: 'message', text: 'codexapp after config change prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' })); + const codexAppAfterConfigChange = await nextMessage(messages, ws, (msg) => ( + msg.type === 'text_delta' && + msg.sessionId === codexAppSession.sessionId && + /codexapp after config change prompt/.test(msg.text || '') + )); + assert(/codexapp after config change prompt/.test(codexAppAfterConfigChange.text || ''), 'Codex App should not reject a new turn after config signature changes'); + await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId); + + const codexAppRetryText = 'codexapp capacity retry prompt'; + ws.send(JSON.stringify({ type: 'message', text: codexAppRetryText, sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' })); + const codexAppCapacityRetryNotice = await nextMessage(messages, ws, (msg) => ( + msg.type === 'system_message' && + msg.sessionId === codexAppSession.sessionId && + /自动重试/.test(msg.message || '') + ), 10000); + assert(/Codex 服务暂时繁忙/.test(codexAppCapacityRetryNotice.message || ''), 'Codex App transient capacity failure should announce automatic retry'); + const codexAppPartialCapacityRetryNotice = await nextMessage(messages, ws, (msg) => ( + msg.type === 'system_message' && + msg.sessionId === codexAppSession.sessionId && + /自动重试/.test(msg.message || '') + ), 10000); + assert(/第 2\/2 次/.test(codexAppPartialCapacityRetryNotice.message || ''), 'Codex App transient retry should continue after partial output'); + await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId, 20000); + const storedCodexAppAfterCapacityRetry = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8')); + const codexAppCapacityRetryUsers = storedCodexAppAfterCapacityRetry.messages.filter((message) => message.role === 'user' && message.content === codexAppRetryText); + assert(codexAppCapacityRetryUsers.length === 1, 'Codex App transient retry should not duplicate the user message'); + assert(storedCodexAppAfterCapacityRetry.messages.some((message) => message.role === 'assistant' && /codexapp capacity retry prompt/.test(String(message.content || ''))), 'Codex App transient retry should persist the successful assistant response'); + + const codexAppReconnectRetryText = 'codexapp reconnect retry prompt'; + ws.send(JSON.stringify({ type: 'message', text: codexAppReconnectRetryText, sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' })); + const codexAppReconnectRetryNotice = await nextMessage(messages, ws, (msg) => ( + msg.type === 'system_message' && + msg.sessionId === codexAppSession.sessionId && + /自动重试/.test(msg.message || '') + ), 10000); + assert(/Codex 服务暂时繁忙/.test(codexAppReconnectRetryNotice.message || ''), 'Codex App reconnect failure should announce automatic retry'); + await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId, 20000); + const storedCodexAppAfterReconnectRetry = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8')); + const codexAppReconnectRetryUsers = storedCodexAppAfterReconnectRetry.messages.filter((message) => message.role === 'user' && message.content === codexAppReconnectRetryText); + assert(codexAppReconnectRetryUsers.length === 1, 'Codex App reconnect retry should not duplicate the user message'); + assert(storedCodexAppAfterReconnectRetry.messages.some((message) => message.role === 'assistant' && /codexapp reconnect retry prompt/.test(String(message.content || ''))), 'Codex App reconnect retry should persist the successful assistant response'); + ws.send(JSON.stringify({ type: 'message', text: '/goal improve benchmark coverage', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' })); 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'); diff --git a/server.js b/server.js index bf7892c..1511bf2 100644 --- a/server.js +++ b/server.js @@ -99,6 +99,8 @@ const RUN_OUTPUT_RECOVERY_MAX_BYTES = readPositiveIntEnv('CC_WEB_RUN_OUTPUT_RECO 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 MCP_CREATE_CONVERSATION_MAX_HOP_COUNT = readPositiveIntEnv('CC_WEB_MCP_CREATE_CONVERSATION_MAX_HOP_COUNT', 3, { min: 1, max: 20 }); +const CODEX_TRANSIENT_RETRY_MAX_ATTEMPTS = readPositiveIntEnv('CC_WEB_CODEX_TRANSIENT_RETRY_MAX_ATTEMPTS', 3, { min: 1, max: 10 }); +const CODEX_TRANSIENT_RETRY_BASE_DELAY_MS = readPositiveIntEnv('CC_WEB_CODEX_TRANSIENT_RETRY_BASE_DELAY_MS', 2000, { min: 100, max: 60000 }); const MAX_CODEX_GOAL_OBJECTIVE_CHARS = 4000; 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; @@ -632,6 +634,9 @@ const pendingSlashCommands = new Map(); // Pending compact retry metadata: sessionId -> { text: string, mode: string, reason: string } const pendingCompactRetries = new Map(); +// Pending Codex transient retry metadata: sessionId -> { text, runtimeText, mode, attempts, timer } +const pendingCodexCapacityRetries = new Map(); + // Active processes: sessionId -> { pid, ws, fullText, toolCalls, lastCost, tailer } const activeProcesses = new Map(); @@ -699,6 +704,11 @@ const DEFAULT_CODEX_CONFIG = { profiles: [], enableSearch: false, supportsSearch: false, + retry: { + mode: 'limited', + intervalSeconds: Math.max(1, Math.ceil(CODEX_TRANSIENT_RETRY_BASE_DELAY_MS / 1000)), + maxAttempts: CODEX_TRANSIENT_RETRY_MAX_ATTEMPTS, + }, }; function stripTomlInlineComment(value) { @@ -1009,6 +1019,20 @@ function saveModelConfig(config) { fs.writeFileSync(MODEL_CONFIG_PATH, JSON.stringify(config, null, 2)); } +function normalizeCodexRetryConfig(raw = {}) { + const defaults = DEFAULT_CODEX_CONFIG.retry; + const mode = ['off', 'limited', 'forever'].includes(raw?.mode) ? raw.mode : defaults.mode; + const rawInterval = Number.parseInt(String(raw?.intervalSeconds ?? raw?.interval ?? ''), 10); + const intervalSeconds = Number.isFinite(rawInterval) + ? Math.max(1, Math.min(3600, rawInterval)) + : defaults.intervalSeconds; + const rawMaxAttempts = Number.parseInt(String(raw?.maxAttempts ?? raw?.attempts ?? ''), 10); + const maxAttempts = Number.isFinite(rawMaxAttempts) + ? Math.max(1, Math.min(1000, rawMaxAttempts)) + : defaults.maxAttempts; + return { mode, intervalSeconds, maxAttempts }; +} + function loadCodexConfig() { try { if (fs.existsSync(CODEX_CONFIG_PATH)) { @@ -1024,6 +1048,7 @@ function loadCodexConfig() { enableSearch: false, supportsSearch: false, storedEnableSearch: !!raw.enableSearch, + retry: normalizeCodexRetryConfig(raw.retry), }; } } catch {} @@ -1040,6 +1065,7 @@ function saveCodexConfig(config) { apiBase: String(profile?.apiBase || '').trim(), })).filter((profile) => profile.name) : [], enableSearch: false, + retry: normalizeCodexRetryConfig(config.retry), }, null, 2)); } @@ -1056,6 +1082,7 @@ function getCodexConfigMasked() { enableSearch: false, supportsSearch: false, storedEnableSearch: !!config.storedEnableSearch, + retry: normalizeCodexRetryConfig(config.retry), }; } @@ -4561,6 +4588,12 @@ function formatRuntimeError(agent, raw, context = {}) { if (/rate limit|quota|billing|credits/i.test(condensed)) { return 'Codex 请求被额度或速率限制拦截。请检查账号配额、计费状态或稍后重试。'; } + if (isCodexTransientCapacityError(condensed)) { + return 'Codex 服务暂时繁忙或所选模型容量不足。cc-web 已自动重试,仍未成功;请稍后再试或临时切换模型。'; + } + if (isCodexTransientConnectionError(condensed)) { + return 'Codex App 连接暂时中断。cc-web 已自动重试,仍未成功;请稍后再试或检查网络代理。'; + } if (/network|timed out|timeout|ECONNRESET|ENOTFOUND|TLS|certificate|fetch failed/i.test(condensed)) { return 'Codex 运行时网络请求失败。请检查当前网络、代理或证书环境后重试。'; } @@ -4634,6 +4667,144 @@ function isContextLimitError(agent, raw) { return /context\s+(window|length)|maximum context length|context limit|token limit|too many tokens|input.*too long|prompt.*too long|request too large|please use\s*\/compact|use\s*\/compact|reduce (the )?(input|prompt|message)|exceed(?:ed|s).*(token|context)/i.test(text); } +function isCodexTransientCapacityError(raw) { + const text = String(raw || ''); + if (!text) return false; + return /server_is_overloaded|service_unavailable_error|ServiceUnavailableError|servers?\s+(?:are\s+)?(?:currently\s+)?overloaded|server\s+is\s+overloaded|model\s+is\s+at\s+capacity|selected model is at capacity|model.*overloaded|503\b.*(?:overloaded|unavailable)|temporarily unavailable|please try again later/i.test(text); +} + +function isCodexTransientConnectionError(raw) { + const text = String(raw || ''); + if (!text) return false; + return /reconnecting(?:\.\.\.)?\s*\d+\/\d+|connection\s+(?:lost|closed|reset|refused|interrupted)|disconnect(?:ed|ion)|ECONNRESET|ECONNREFUSED|EPIPE|ETIMEDOUT|ENOTFOUND|fetch failed|network.*(?:error|failed)|TLS connection.*terminated/i.test(text); +} + +function isCodexTransientRetryableError(raw) { + return isCodexTransientCapacityError(raw) || isCodexTransientConnectionError(raw); +} + +function hasRuntimeOutput(entry) { + if ((entry.fullText || '').trim()) return true; + if (Array.isArray(entry.contentBlocks) && entry.contentBlocks.length > 0) return true; + return Array.isArray(entry.toolCalls) && entry.toolCalls.length > 0; +} + +function getCodexRetryConfig() { + return normalizeCodexRetryConfig(loadCodexConfig().retry); +} + +function codexTransientRetryDelayMs(config) { + return Math.max(0, (config?.intervalSeconds || DEFAULT_CODEX_CONFIG.retry.intervalSeconds) * 1000); +} + +function cancelCodexCapacityRetry(sessionId) { + const retry = pendingCodexCapacityRetries.get(sessionId); + if (!retry) return false; + if (retry.timer) clearTimeout(retry.timer); + pendingCodexCapacityRetries.delete(sessionId); + return true; +} + +function shouldRetryCodexTransientFailure(entry, rawError, context = {}) { + if (!['codex', 'codexapp'].includes(entry.agent || 'claude')) return false; + if (!rawError || !isCodexTransientRetryableError(rawError)) return false; + if (getCodexRetryConfig().mode === 'off') return false; + if (context.contextLimitExceeded || context.pendingSlash) return false; + if (entry.crossConversationReplyRequestId) return false; + if ((entry.agent || 'claude') !== 'codexapp' && hasRuntimeOutput(entry)) return false; + return !!(entry.retryRequest?.text || entry.retryRequest?.runtimeText); +} + +function scheduleCodexCapacityRetry(sessionId, entry, rawError) { + const retryRequest = entry.retryRequest || {}; + const previous = pendingCodexCapacityRetries.get(sessionId) || null; + const retryConfig = getCodexRetryConfig(); + if (retryConfig.mode === 'off') { + cancelCodexCapacityRetry(sessionId); + return false; + } + const attempts = (previous?.attempts || 0) + 1; + if (retryConfig.mode === 'limited' && attempts > retryConfig.maxAttempts) { + cancelCodexCapacityRetry(sessionId); + return false; + } + + const delayMs = codexTransientRetryDelayMs(retryConfig); + if (previous?.timer) clearTimeout(previous.timer); + + const retry = { + text: retryRequest.text || retryRequest.runtimeText || '', + runtimeText: retryRequest.runtimeText || retryRequest.text || '', + mode: retryRequest.mode || 'yolo', + agent: retryRequest.agent || entry.agent || 'codex', + attachments: Array.isArray(retryRequest.attachments) ? retryRequest.attachments : [], + mcpContext: retryRequest.mcpContext || {}, + attempts, + retryMode: retryConfig.mode, + timer: null, + ws: entry.ws || null, + }; + + retry.timer = setTimeout(() => { + const latest = pendingCodexCapacityRetries.get(sessionId); + if (!latest || latest.timer !== retry.timer) return; + latest.timer = null; + + const session = loadSession(sessionId); + if (!session) { + pendingCodexCapacityRetries.delete(sessionId); + return; + } + if (activeProcesses.has(sessionId) || activeCodexAppTurns.has(sessionId)) { + plog('WARN', 'codex_capacity_retry_skipped_busy', { + sessionId: sessionId.slice(0, 8), + attempt: latest.attempts, + }); + return; + } + + const ws = latest.ws && latest.ws.readyState === 1 ? latest.ws : null; + plog('INFO', 'codex_capacity_retry_start', { + sessionId: sessionId.slice(0, 8), + attempt: latest.attempts, + }); + handleMessage(ws, { + type: 'message', + text: latest.text, + sessionId, + mode: latest.mode, + agent: latest.agent || 'codex', + attachments: latest.attachments, + }, { + hideInHistory: true, + runtimeText: latest.runtimeText, + mcpContext: latest.mcpContext, + skipPendingCrossConversationFlush: true, + }); + }, delayMs); + + pendingCodexCapacityRetries.set(sessionId, retry); + plog('WARN', 'codex_capacity_retry_scheduled', { + sessionId: sessionId.slice(0, 8), + attempt: attempts, + maxAttempts: retryConfig.mode === 'limited' ? retryConfig.maxAttempts : null, + retryMode: retryConfig.mode, + delayMs, + error: String(rawError || '').slice(0, 300), + }); + if (entry.ws) { + const attemptText = retryConfig.mode === 'forever' + ? `第 ${attempts} 次` + : `第 ${attempts}/${retryConfig.maxAttempts} 次`; + wsSend(entry.ws, { + type: 'system_message', + sessionId, + message: `Codex 服务暂时繁忙,${retryConfig.intervalSeconds} 秒后自动重试(${attemptText})。`, + }); + } + return true; +} + function handleProcessComplete(sessionId, exitCode, signal) { const entry = activeProcesses.get(sessionId); if (!entry) return; @@ -4699,6 +4870,19 @@ function handleProcessComplete(sessionId, exitCode, signal) { // Save result to session const session = loadSession(sessionId); + if (session && shouldRetryCodexTransientFailure(entry, rawCompletionError, { contextLimitExceeded, pendingSlash })) { + activeProcesses.delete(sessionId); + cleanRunDir(sessionId); + pendingSlashCommands.delete(sessionId); + if (scheduleCodexCapacityRetry(sessionId, entry, rawCompletionError)) { + return; + } + } + + if (!shouldRetryCodexTransientFailure(entry, rawCompletionError, { contextLimitExceeded, pendingSlash })) { + cancelCodexCapacityRetry(sessionId); + } + if (session && (entry.fullText || entry.contentBlocks)) { session.messages.push({ role: 'assistant', @@ -5468,6 +5652,7 @@ function handleSaveCodexConfig(ws, newConfig) { }); } const requestedSearch = !!newConfig.enableSearch; + const retry = normalizeCodexRetryConfig(newConfig.retry); const merged = { mode: newConfig.mode === 'custom' ? 'custom' : 'local', activeProfile: String(newConfig.activeProfile || '').trim(), @@ -5475,6 +5660,7 @@ function handleSaveCodexConfig(ws, newConfig) { enableSearch: false, supportsSearch: false, storedEnableSearch: requestedSearch, + retry, }; if (merged.mode === 'custom' && merged.profiles.length > 0 && !merged.profiles.some((profile) => profile.name === merged.activeProfile)) { merged.activeProfile = merged.profiles[0].name; @@ -5486,6 +5672,9 @@ function handleSaveCodexConfig(ws, newConfig) { profileCount: merged.profiles.length, enableSearchRequested: requestedSearch, enableSearchEffective: false, + retryMode: retry.mode, + retryIntervalSeconds: retry.intervalSeconds, + retryMaxAttempts: retry.mode === 'limited' ? retry.maxAttempts : null, }); wsSend(ws, { type: 'codex_config', config: getCodexConfigMasked() }); wsSend(ws, { @@ -5740,6 +5929,7 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) { switch (cmd) { case '/clear': { if (session) { + cancelCodexCapacityRetry(sessionId); if (activeProcesses.has(sessionId)) { const entry = activeProcesses.get(sessionId); killProcess(entry.pid); @@ -5951,6 +6141,56 @@ function resolveConversationMode(rawMode, fallbackMode = 'yolo', options = {}) { return { ok: true, mode: VALID_PERMISSION_MODES.has(fallbackMode) ? fallbackMode : 'yolo' }; } +function resolveConversationModel(rawModel, agent, sourceSession = null) { + const value = String(rawModel || '').trim(); + if (value) { + if (agent === 'codex' || agent === 'codexapp') return value; + return MODEL_MAP[value.toLowerCase()] || value; + } + if (sourceSession?.model) return sourceSession.model; + return agent === 'codex' || agent === 'codexapp' ? getDefaultCodexModel() : MODEL_MAP.opus; +} + +function buildBranchSessionTitle(sourceSession) { + const sourceTitle = normalizeConversationTitle(sourceSession?.title, '新会话'); + return normalizeConversationTitle(`${sourceTitle} 的分支`); +} + +function resolveBranchSource(args = {}) { + const sourceSessionId = sanitizeId(args.branchSourceSessionId || args.sourceSessionId || ''); + if (!sourceSessionId) return { ok: true, sourceSession: null }; + + const sourceSession = loadSession(sourceSessionId); + if (!sourceSession) { + return mcpToolError('branch_source_not_found', '来源会话不存在,无法创建分支。', { sourceSessionId }); + } + + const sourceMessages = Array.isArray(sourceSession.messages) ? sourceSession.messages : []; + if (sourceMessages.length === 0) { + return mcpToolError('branch_source_empty', '来源会话没有可复制的上下文。', { sourceSessionId }); + } + + const parsedIndex = Number.parseInt(String(args.branchMessageIndex ?? ''), 10); + const sourceMessageIndex = Number.isFinite(parsedIndex) + ? Math.max(0, Math.min(sourceMessages.length - 1, parsedIndex)) + : sourceMessages.length - 1; + const createdAt = new Date().toISOString(); + + return { + ok: true, + sourceSession, + initialMessages: sourceMessages.slice(0, sourceMessageIndex + 1), + createdFrom: { + kind: 'branch', + sourceSessionId: sourceSession.id, + sourceTitle: sourceSession.title || 'Untitled', + sourceMessageIndex, + createdAt, + }, + defaultTitle: buildBranchSessionTitle(sourceSession), + }; +} + function createPersistentConversationSession(args = {}, options = {}) { const sourceSession = options.sourceSession || null; const strict = !!options.strict; @@ -5981,6 +6221,9 @@ function createPersistentConversationSession(args = {}, options = {}) { const now = new Date().toISOString(); const agent = agentResult.agent; + const initialMessages = Array.isArray(options.initialMessages) + ? sanitizeMessagesForPersist(options.initialMessages) + : []; const session = { id: crypto.randomUUID(), title: normalizeConversationTitle(args.title), @@ -5991,12 +6234,16 @@ function createPersistentConversationSession(args = {}, options = {}) { claudeSessionId: null, codexThreadId: null, codexAppThreadId: null, - // Codex/Codex App 读取 ~/.codex/config.toml 默认模型;Claude 继续默认 opus 1M。 - model: agent === 'codex' || agent === 'codexapp' ? getDefaultCodexModel() : MODEL_MAP.opus, + // Codex/Codex App 默认读取 ~/.codex/config.toml;分支会话优先继承来源模型。 + model: resolveConversationModel( + args.model, + agent, + options.inheritSourceModel === true ? sourceSession : null, + ), permissionMode: modeResult.mode, totalCost: 0, totalUsage: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }, - messages: [], + messages: initialMessages, cwd: cwdResult.path || getDefaultSessionCwd(), }; if (options.createdFrom) session.createdFrom = options.createdFrom; @@ -6009,10 +6256,11 @@ function createPersistentConversationSession(args = {}, options = {}) { function buildSessionInfoPayload(session) { const waitState = crossConversationWaitState(session.id); + const messages = session.messages || []; return { type: 'session_info', sessionId: session.id, - messages: session.messages || [], + messages, title: session.title, pinnedAt: session.pinnedAt || null, mode: session.permissionMode || 'yolo', @@ -6022,6 +6270,8 @@ function buildSessionInfoPayload(session) { totalCost: session.totalCost || 0, totalUsage: session.totalUsage || { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }, updated: session.updated, + historyTotal: messages.length, + historyBaseIndex: 0, hasUnread: false, historyPending: false, isRunning: false, @@ -6040,10 +6290,29 @@ function attachClientRequestId(payload, source = {}) { } function handleNewSession(ws, msg) { - const result = createPersistentConversationSession(msg || {}, { - defaultAgent: normalizeAgent(msg?.agent), + const request = msg || {}; + const branch = resolveBranchSource(request); + if (!branch.ok) { + return wsSend(ws, { + type: 'error', + code: branch.code, + message: branch.message, + }); + } + + const createArgs = { ...request }; + if (branch.defaultTitle && !String(createArgs.title || '').trim()) { + createArgs.title = branch.defaultTitle; + } + + const result = createPersistentConversationSession(createArgs, { + defaultAgent: normalizeAgent(request.agent), defaultMode: 'yolo', allowCreateCwd: true, + sourceSession: branch.sourceSession || null, + initialMessages: branch.initialMessages || null, + createdFrom: branch.createdFrom || null, + inheritSourceModel: !!branch.sourceSession, }); if (!result.ok) { return wsSend(ws, { @@ -6079,6 +6348,7 @@ function handleLoadHistoryPage(ws, msg = {}) { messages: list.slice(start, end), remaining: 0, historyCursor: start, + historyBaseIndex: start, historyTruncated: start > 0, }); } @@ -6132,6 +6402,7 @@ function handleLoadSession(ws, msg) { historyTotal: refreshedSession.messages.length, historyBuffered, historyCursor: historyRemaining, + historyBaseIndex: Math.max(0, refreshedSession.messages.length - recentMessages.length), historyTruncated: historyRemaining > 0, historyPending: olderChunks.length > 0, updated: refreshedSession.updated, @@ -6145,15 +6416,19 @@ function handleLoadSession(ws, msg) { }, msg)); if (olderChunks.length > 0) { + let chunkEnd = Math.max(0, refreshedSession.messages.length - recentMessages.length); olderChunks.forEach((chunk, index) => { + const chunkStart = Math.max(0, chunkEnd - chunk.length); wsSend(ws, { type: 'session_history_chunk', sessionId: refreshedSession.id, messages: chunk, remaining: Math.max(0, olderChunks.length - index - 1), historyCursor: index === olderChunks.length - 1 ? historyRemaining : null, + historyBaseIndex: chunkStart, historyTruncated: historyRemaining > 0, }); + chunkEnd = chunkStart; }); } @@ -6256,6 +6531,7 @@ function deleteCodexLocalSession(session) { function handleDeleteSession(ws, sessionId) { pendingSlashCommands.delete(sessionId); pendingCompactRetries.delete(sessionId); + cancelCodexCapacityRetry(sessionId); deleteCrossConversationRepliesForSession(sessionId); for (const [threadId, child] of ccwebMcpChildThreads.entries()) { if (child.parentSessionId === sessionId) ccwebMcpChildThreads.delete(threadId); @@ -6376,9 +6652,17 @@ function handleAbort(ws) { if (!sessionId) return; if (handleCodexAppAbortSession(sessionId, ws)) return; const entry = activeProcesses.get(sessionId); - if (!entry) return; + if (!entry) { + if (cancelCodexCapacityRetry(sessionId)) { + wsSend(ws, { type: 'system_message', sessionId, message: '已取消 Codex 自动重试。' }); + wsSend(ws, { type: 'done', sessionId }); + sendSessionList(ws); + } + return; + } plog('INFO', 'user_abort', { sessionId: sessionId.slice(0, 8), pid: entry.pid }); + cancelCodexCapacityRetry(sessionId); killProcess(entry.pid); setTimeout(() => { killProcess(entry.pid, true); @@ -6465,6 +6749,9 @@ function handleMessage(ws, msg, options = {}) { if (!normalizedText && resolvedAttachments.length === 0) { return fail('empty_message', '消息内容不能为空。'); } + if (sessionId && !hideInHistory) { + cancelCodexCapacityRetry(sessionId); + } const savedAttachments = resolvedAttachments.map((attachment) => ({ id: attachment.id, @@ -6695,6 +6982,13 @@ function handleMessage(ws, msg, options = {}) { lastError: null, errorSent: false, crossConversationReplyRequestId: options.crossConversation?.replyRequestId || null, + retryRequest: { + text: textValue, + runtimeText: runtimeTextValue, + mode: session.permissionMode || 'yolo', + attachments: savedAttachments, + mcpContext: options.mcpContext || {}, + }, tailer: null, }; activeProcesses.set(currentSessionId, entry); @@ -7666,6 +7960,14 @@ function handleCodexAppServerRequest(request) { } function handleCodexAppServerExit(signature, info = {}) { + if (signature && signature !== codexAppClientSignature) { + plog('INFO', 'codex_app_server_exit_stale', { + code: info.code ?? null, + signal: info.signal || null, + activeTurns: activeCodexAppTurns.size, + }); + return; + } if (signature && signature === codexAppClientSignature) { codexAppClient = null; codexAppClientSignature = ''; @@ -7821,13 +8123,27 @@ function buildCodexAppClientSpec() { }; } -function getCodexAppClient() { +function getCodexAppClient(options = {}) { const spec = buildCodexAppClientSpec(); if (spec?.error) return { error: spec.error }; if (codexAppClient && codexAppClientSignature !== spec.signature) { - if (activeCodexAppTurns.size > 0) { - return { error: 'Codex App 配置已变更,但仍有运行中的任务。请等待任务结束后再发送新消息。' }; + const excludeSessionId = sanitizeId(options.excludeSessionId || ''); + const blockingSessionIds = Array.from(activeCodexAppTurns.keys()) + .filter((sessionId) => !excludeSessionId || sessionId !== excludeSessionId); + if (blockingSessionIds.length > 0) { + if (codexAppClient.isRunning()) { + plog('WARN', 'codex_app_config_changed_reusing_active_client', { + activeTurns: activeCodexAppTurns.size, + blockingTurns: blockingSessionIds.length, + excludeSessionId: excludeSessionId ? excludeSessionId.slice(0, 8) : null, + }); + return { client: codexAppClient, staleConfig: true }; + } + const message = 'Codex App 配置已变更,旧 app-server 已不可用,已结束残留运行任务。请重试。'; + for (const sessionId of blockingSessionIds) { + handleCodexAppTurnFailure(sessionId, new Error(message)); + } } codexAppClient.stop(); codexAppClient = null; @@ -7920,6 +8236,16 @@ function handleCodexAppMessage(ws, session, runtimeTextValue, resolvedAttachment return { ok: false, code: 'empty_message', message: '消息内容不能为空。' }; } + const retryAttachments = resolvedAttachments.map((attachment) => ({ + id: attachment.id, + kind: 'image', + filename: attachment.filename, + mime: attachment.mime, + size: attachment.size, + createdAt: attachment.createdAt, + expiresAt: attachment.expiresAt, + storageState: attachment.storageState, + })); const entry = { ws, agent: 'codexapp', @@ -7935,6 +8261,14 @@ function handleCodexAppMessage(ws, session, runtimeTextValue, resolvedAttachment lastError: null, errorSent: false, crossConversationReplyRequestId: options.crossConversation?.replyRequestId || null, + retryRequest: { + text: runtimeTextValue, + runtimeText: runtimeTextValue, + mode: session.permissionMode || 'yolo', + agent: 'codexapp', + attachments: retryAttachments, + mcpContext: options.mcpContext || {}, + }, clientUserMessageId: crypto.randomUUID(), startedAt: new Date().toISOString(), }; @@ -7954,7 +8288,7 @@ async function startCodexAppTurn(sessionId, input) { const entry = activeCodexAppTurns.get(sessionId); if (!session || !entry) return; - const clientResult = getCodexAppClient(); + const clientResult = getCodexAppClient({ excludeSessionId: sessionId }); if (clientResult.error) throw new Error(clientResult.error); const client = clientResult.client; await client.start(); @@ -8002,6 +8336,18 @@ function handleCodexAppTurnComplete(sessionId, options = {}) { toolResultMaxChars: SESSION_TOOL_RESULT_MAX_CHARS, contentMaxChars: SESSION_MESSAGE_CONTENT_MAX_CHARS, }); + + if (session && shouldRetryCodexTransientFailure(entry, rawError)) { + activeCodexAppTurns.delete(sessionId); + cleanupCodexAppTurnState(sessionId, entry); + if (scheduleCodexCapacityRetry(sessionId, entry, rawError)) { + sendSessionList(entry.ws); + return; + } + } else { + cancelCodexCapacityRetry(sessionId); + } + if (session && (assistantContent.trim() || assistantToolCalls.length > 0) && !hasCodexAppTurnMessage(session, turnKey)) { session.messages.push({ role: 'assistant',