chore: rebuild CentOS7 release package
This commit is contained in:
90
.codex/skills/cc-web-centos7-release/SKILL.md
Normal file
90
.codex/skills/cc-web-centos7-release/SKILL.md
Normal file
@@ -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
|
||||
```
|
||||
4
.codex/skills/cc-web-centos7-release/agents/openai.yaml
Normal file
4
.codex/skills/cc-web-centos7-release/agents/openai.yaml
Normal file
@@ -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."
|
||||
Binary file not shown.
@@ -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:
|
||||
|
||||
251
public/app.js
251
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 = `
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M6 3v12"></path>
|
||||
<circle cx="6" cy="18" r="3"></circle>
|
||||
<circle cx="18" cy="6" r="3"></circle>
|
||||
<path d="M6 9h6a6 6 0 0 0 6-6"></path>
|
||||
<path d="M15 18h6"></path>
|
||||
<path d="M18 15v6"></path>
|
||||
</svg>
|
||||
`;
|
||||
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 @@
|
||||
</select>
|
||||
</div>
|
||||
<div id="codex-profile-area"></div>
|
||||
|
||||
<div class="settings-divider"></div>
|
||||
<div class="settings-section-title">容量失败重试</div>
|
||||
<div class="settings-field">
|
||||
<label>自动重试</label>
|
||||
<select class="settings-select" id="codex-retry-mode">
|
||||
<option value="limited">按次数重试</option>
|
||||
<option value="forever">一直重试</option>
|
||||
<option value="off">关闭</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-retry-grid">
|
||||
<div class="settings-field">
|
||||
<label>间隔(秒)</label>
|
||||
<input type="number" id="codex-retry-interval" min="1" max="3600" step="1" inputmode="numeric">
|
||||
</div>
|
||||
<div class="settings-field" id="codex-retry-attempts-field">
|
||||
<label>重试次数</label>
|
||||
<input type="number" id="codex-retry-attempts" min="1" max="1000" step="1" inputmode="numeric">
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-inline-note" id="codex-retry-note">
|
||||
仅对没有产生文本或工具调用的 Codex 容量/过载失败生效,避免重复执行已有副作用的任务。
|
||||
</div>
|
||||
|
||||
<div class="settings-actions">
|
||||
<button class="btn-save" id="codex-save-btn">保存 Codex 配置</button>
|
||||
</div>
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
document.documentElement.dataset.dividerTime = dividerTime;
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="style.css?v=20260624-icon-refresh">
|
||||
<link rel="stylesheet" href="style.css?v=20260625-branch-bubble">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -169,6 +169,6 @@
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.1/marked.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/mermaid/10.9.1/mermaid.min.js"></script>
|
||||
<script src="app.js?v=20260624-icon-refresh"></script>
|
||||
<script src="app.js?v=20260625-branch-bubble"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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 === */
|
||||
|
||||
@@ -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: [] } };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
368
server.js
368
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',
|
||||
|
||||
Reference in New Issue
Block a user