diff --git a/.planning/codex-app-web-search/findings.md b/.planning/codex-app-web-search/findings.md
new file mode 100644
index 0000000..44f7c2e
--- /dev/null
+++ b/.planning/codex-app-web-search/findings.md
@@ -0,0 +1,53 @@
+# Codex App Web Search 接入发现
+
+## 已确认
+
+- 用户观察正确:Codex App/app-server 本身会做原生自动上下文压缩。
+- 旧的“检测上下文超限 → `/compact` → 重放”属于 detached Claude/旧 Codex CLI 的客户端兜底,不等于 Codex App 没有自动压缩。
+- `lib/codex-app-runtime.js` 已识别 `webSearch` item,并映射为 `web_search` / `WebSearch` 展示事件。
+- 当前 Codex 配置把 `enableSearch` 与 `supportsSearch` 硬编码为 `false`,保存开启请求时也会忽略;该限制来自旧 `codex exec` 路径,需要收敛到 Codex App 原生能力。
+- OpenAI 官方页面在当前网络环境不可访问:developers 域返回 Forbidden,platform 域被 Cloudflare 拦截。
+- 按用户提示补用 `proxyd.picpi.top/{原始 URL}` 后,官方搜索页与 app-server 文档页仍返回上游 `Forbidden`;代理链路可达,但无法绕过官方站点的访问限制。
+- TODO CSV 计划审查已通过;审查员建议在确认 schema 后记录字段名、关闭语义与证据来源。
+
+## 待确认
+
+- hapi 是否使用相同字段;主线程全文检索暂未命中,等待只读研究子代理复核。
+
+## Codex 0.147.0 协议证据
+
+- 本机 `codex app-server generate-json-schema --experimental` 生成的 v2 schema 中,`Config.web_search` 引用 `WebSearchMode`。
+- `WebSearchMode` 的枚举值为 `disabled`、`cached`、`indexed`、`live`。
+- `ThreadStartParams.config` 和 `ThreadResumeParams.config` 都允许线程级 config;`TurnStartParams` 没有 Web Search 专用字段。
+- cc-web 的 `codexAppThreadParams()` 已把 `codexAppThreadConfig()` 同时用于 `thread/start` 与 `thread/resume`,因此正确接入点是 `codexAppThreadConfig()` 写入 `web_search`。
+- 生成的 resolved `Config` 同时存在两层:顶层 `web_search: WebSearchMode`,以及 `tools.web_search: WebSearchToolConfig`。前者选择 `disabled/cached/indexed/live` 模式,后者承载 `context_size/allowed_domains/location` 等工具配置;不能把二者混成同一字段。
+- CLI 解析实测:`web_search="live"`、`tools.web_search=true`、`tools.web_search=false` 都能加载;`tools.web_search="live"` 或 `"disabled"` 不合法。
+- app-server `config/read` 实测:顶层配置会解析为 `web_search: "live"`;`tools.web_search=true/false` 都归一到 `tools.web_search: null`,仅凭读取结果无法证明布尔输入的最终启停语义。
+- `codex --help` 明确 `--search` 是“Enable live web search”,但该开关只属于 Codex CLI 根命令,`codex app-server --search` 会被拒绝;app-server 应使用线程 config。
+- 官方 OpenAI 文档通过 `proxyd-accelerator` 成功获取:`web-search.md` 明确 live 用 `web_search = "live"`、关闭用 `web_search = "disabled"`;`config-reference.md` 明确 `tools.web_search` 只是可选的上下文大小、域名和位置配置。
+- 最终协议决策:cc-web 开关只传顶层 `web_search: enableSearch ? 'live' : 'disabled'`;当前产品没有域名/位置细节 UI,因此不传 `tools.web_search`。
+- app-server 的 `webSearch` thread item 已由 `lib/codex-app-runtime.js` 映射为前端 `web_search` 工具事件,展示链路已有基础。
+
+## 实现与回归落点
+
+- 服务端配置需把 `load/save/masked/handleSave` 的 `enableSearch` 改为真实布尔值,并将 `supportsSearch` 对 Codex App 暴露为 `true`;旧 Codex CLI 路径仍不读取该开关。
+- `codexAppThreadConfig()` 应无条件写入 `web_search: enableSearch ? 'live' : 'disabled'`,让 `thread/start` 与 `thread/resume` 都明确覆盖用户全局搜索默认值。
+- 设置面板已有 `.settings-toggle-row` / `.settings-switch` 组件,可新增“Web Search”开关,无需新增 CSS。
+- mock app-server 当前可在 `thread/start` / `thread/resume` 捕获 `params.config.web_search`;回归应分别覆盖开启后的 `live` 与保存关闭后的 `disabled`。
+- 现有配置回归仍断言“unsupported/ignore”,必须改为 `supportsSearch === true`、`enableSearch === true`,并在后续关闭保存中断言 `enableSearch === false`。
+- Web Search 事件映射已有实现,但目前回归未直接断言 `webSearch` item → `web_search` 工具事件,需要补 mock 事件或静态契约断言。
+
+## 最终实现结果
+
+- `server.js` 的 Codex 配置现在真实保存/读取 `enableSearch`,对 Codex App 暴露 `supportsSearch: true`,保存提示不再声称“Codex exec 暂未接入”。
+- `codexAppThreadConfig()` 每次组装线程参数时写入 `web_search: 'live' | 'disabled'`;同一配置同时覆盖 `thread/start` 和 `thread/resume`。
+- `public/app.js` 设置面板新增可读写的 Web Search switch,说明文案限定为当前 Codex App 会话。
+- mock app-server 暴露首次线程搜索模式与当前线程搜索模式,回归分别证明开启态 `live`、关闭后的 resume `disabled`,并证明未使用 `tools.web_search` 代替模式开关。
+- `scripts/regression.js` 新增 `webSearch` item 的 `WebSearch/web_search` tool_start/tool_end/persistence 断言。
+- 两次回归均通过:`timeout 60s node scripts/regression.js`、`timeout 60s npm run regression`。
+
+## 旧 Codex 模式盘点结论
+
+- 普通 UI 已默认 Codex App,Codex App 的 `/compact`、`/goal`、协作模式、MCP、附件、标题、自动容量重试已有实现。
+- 仍需后续单独处理的遗留:后端可新建 `agent='codex'`,旧 `codex exec` 运行链路完整存在,rollout 导入与 regression 仍覆盖旧 CLI。它们属于迁移/封口任务,不在本轮 Web Search 范围内。
+- 未知 app-server request 的“不支持”兜底应保留;运行中已知 slash 的策略需要产品决定,不应与本轮 Web Search 混改。
diff --git a/.planning/codex-app-web-search/progress.md b/.planning/codex-app-web-search/progress.md
new file mode 100644
index 0000000..5dfbb6b
--- /dev/null
+++ b/.planning/codex-app-web-search/progress.md
@@ -0,0 +1,18 @@
+# Codex App Web Search 接入进度
+
+- 2026-08-20T10:15:00+08:00:读取 `openai-docs`、`planning-with-files`、`todo-list-csv` 技能。
+- 2026-08-20T10:15:00+08:00:官方页面访问失败,记录降级原因。
+- 2026-08-20T10:15:00+08:00:确认 codebase-memory 项目 `home-cc-web` 索引状态为 ready。
+- 2026-08-20T10:15:00+08:00:确认工作区存在 Goal 生命周期并行改动,本轮必须增量编辑。
+- 2026-08-20T10:15:00+08:00:建立独立 scoped plan,未覆盖根目录 hooks 验证计划。
+- 2026-08-20T10:20:00+08:00:计划审查子代理通过,无阻塞问题。
+- 2026-08-20T10:20:00+08:00:使用用户点名的 `proxyd-accelerator` 重试官方页面,仍收到上游 Forbidden;继续以安装版 app-server schema 为直接证据。
+- 2026-08-20T10:25:00+08:00:由 Codex CLI 0.147.0 生成 experimental v2 schema,确认线程 config 字段为 `web_search`,枚举包含 `disabled/cached/indexed/live`,`thread/start` 与 `thread/resume` 均支持 config。
+- 2026-08-20T10:30:00+08:00:完成配置、设置 UI、mock 和 regression 落点梳理;确认可复用现有 settings switch 样式,且 Goal 生命周期改动与目标区域可增量合并。
+- 2026-08-20T10:40:00+08:00:发现 `Config.web_search` 与 `Config.tools.web_search` 双层语义,暂停实现代理锁定字段;已启动追加复核,避免把合法配置误当成实际可用搜索。
+- 2026-08-20T10:45:00+08:00:通过 `proxyd-accelerator` 成功读取 OpenAI 官方 Markdown 文档,确认 live/disabled 应使用顶层 `web_search`;通知实现代理继续。
+- 2026-08-20T10:45:00+08:00:只读旧模式盘点完成:除本轮 Web Search 外,主要遗留是后端仍可新建旧 `agent=codex`、完整 `codex exec` 路径和测试夹具保护;Codex App 原生 compact/Goal/协作/MCP/附件/重试已完成。
+- 2026-08-20T11:00:00+08:00:完成 Web Search 配置持久化、Codex App 线程级 `web_search` 注入、设置开关、mock 观测字段和回归断言;保留旧 exec 路径不启用搜索。
+- 2026-08-20T11:00:00+08:00:修正一次回归断言时机:live 断言放在开启态,disabled 断言放在配置关闭后的 thread/resume。
+- 2026-08-20T11:00:00+08:00:`timeout 60s node scripts/regression.js` 与 `timeout 60s npm run regression` 均通过;四个 JS 文件语法检查、`git diff --check` 均通过。
+- 2026-08-20T11:00:00+08:00:确认 Codex App 原生自动 compact 已存在,本轮未新增客户端“最终超限后 compact+重放”逻辑。
diff --git a/.planning/codex-app-web-search/task_plan.md b/.planning/codex-app-web-search/task_plan.md
new file mode 100644
index 0000000..f1c53fc
--- /dev/null
+++ b/.planning/codex-app-web-search/task_plan.md
@@ -0,0 +1,37 @@
+# Codex App Web Search 接入计划
+
+## 目标
+
+让设置中的 `enableSearch` 驱动 Codex App/app-server 原生 Web Search,保持旧 `codex exec` 路径禁用搜索,并用协议回归证明参数形状、关闭语义和事件展示均正确。
+
+## 验收标准
+
+- 开启 Web Search 后,Codex App 的官方协议字段收到明确启用值。
+- 关闭 Web Search 后,协议字段为禁用值或按官方语义省略,不影响普通 turn。
+- 设置保存与读取不再把 Codex App 搜索能力硬编码为不可用。
+- `webSearch` item 继续按 `web_search` 工具事件展示。
+- 不新增“上下文超限后客户端 compact + 重放”逻辑;文档结论明确区分上游原生自动压缩与 cc-web 额外失败兜底。
+- 回归、语法检查和差异检查通过,且不覆盖 Goal 生命周期的并行改动。
+
+## 阶段
+
+1. [完成] 确认 Codex App Web Search 官方协议与现有链路
+2. [完成] 编写 Web Search 配置与协议回归断言
+3. [完成] 接入 Codex App 原生 Web Search 参数
+4. [完成] 更新设置界面与配置保存语义
+5. [完成] 验证 Web Search 事件展示与自动 compact 现状
+6. [完成] 运行回归、语法与差异检查
+7. [完成] 汇总兼容性结论并清理跟踪文件
+
+## 决策
+
+- 以本机 Codex app-server 生成 schema 为参数形状的直接证据,并用 hapi 实现交叉核对。
+- Web Search 只接 Codex App 原生协议,不重新启用旧 `codex exec --search` 路径。
+- 原生自动 compact 已存在;客户端“最终超限后 compact 并重放”不是本轮实现目标。
+
+## 错误记录
+
+| 错误 | 尝试 | 处理 |
+|---|---:|---|
+| `developers.openai.com` 返回 Forbidden | 1 | 改查同为官方域名的 `platform.openai.com` |
+| `platform.openai.com` 被 Cloudflare 拦截 | 2 | 按 `openai-docs` 降级,读取官方文档参考并转用本机协议 schema 交叉验证 |
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 a67a814..7ec186e 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/public/app.js b/public/app.js
index e0170c0..43078d1 100644
--- a/public/app.js
+++ b/public/app.js
@@ -587,13 +587,14 @@
function updateGenerationControls() {
const noteActive = !!noteMode;
+ const runtimeBusy = isGenerating || currentSessionRunning;
const allowRuntimeInsert = isGenerating && isCodexAppAgent(currentAgent) && !noteActive;
const sendLabel = noteActive ? '记录笔记' : (allowRuntimeInsert ? '插入' : '发送');
if (sendBtn) {
sendBtn.classList.toggle('note-send', noteActive);
sendBtn.title = sendLabel;
sendBtn.setAttribute('aria-label', sendLabel);
- sendBtn.hidden = isGenerating ? !(noteActive || allowRuntimeInsert) : false;
+ sendBtn.hidden = runtimeBusy ? !(noteActive || allowRuntimeInsert) : false;
}
if (queueSendBtn) {
const queueAvailable = noteActive && supportsQueuedSend();
@@ -604,7 +605,7 @@
queueSendBtn.setAttribute('aria-label', queueLabel);
}
if (abortBtn) {
- abortBtn.hidden = !isGenerating;
+ abortBtn.hidden = !runtimeBusy;
}
}
@@ -5829,6 +5830,7 @@
}
}
updateCwdBadge();
+ updateGenerationControls();
if (!running) scheduleQueuedMessageDrain();
}
@@ -7311,11 +7313,11 @@
case 'done':
if (!isCurrentSessionEvent(msg)) {
if (msg.sessionId) {
- updateCachedSession(msg.sessionId, (snapshot) => { snapshot.isRunning = false; });
+ updateCachedSession(msg.sessionId, (snapshot) => { snapshot.isRunning = msg.goalActive === true; });
}
break;
}
- finishGenerating(msg.sessionId);
+ finishGenerating(msg.sessionId, { keepRunning: msg.goalActive === true });
break;
case 'system_message':
@@ -7610,8 +7612,9 @@
return true;
}
- function finishGenerating(sessionId) {
+ function finishGenerating(sessionId, options = {}) {
if (sessionId && currentSessionId && sessionId !== currentSessionId) return;
+ const keepRunning = options.keepRunning === true;
const hasPersistedAssistantMessage = !!(
pendingText
|| (Array.isArray(window.pendingContentBlocks) && window.pendingContentBlocks.length > 0)
@@ -7619,7 +7622,7 @@
isGenerating = false;
generatingSessionId = null;
updateNoteModeUI();
- setCurrentSessionRunningState(false);
+ setCurrentSessionRunningState(keepRunning);
msgInput.focus();
if (pendingText || (window.pendingContentBlocks && window.pendingContentBlocks.length > 0)) {
@@ -11207,6 +11210,7 @@
const runtimeInsert = isGenerating && isCodexAppAgent(currentAgent);
if ((!text && pendingAttachments.length === 0) || isBlockingSessionLoad()) return;
+ if (currentSessionRunning && !isGenerating) return;
if (isGenerating && !runtimeInsert) return;
hideCmdMenu();
hideOptionPicker();
@@ -12031,6 +12035,19 @@
+
+
容量失败重试
@@ -12087,6 +12104,7 @@
const closeBtn = panel.querySelector('.settings-close');
const codexModeSelect = panel.querySelector('#codex-mode');
const codexProfileArea = panel.querySelector('#codex-profile-area');
+ const codexSearchToggle = panel.querySelector('#codex-enable-search');
const codexRetryModeSelect = panel.querySelector('#codex-retry-mode');
const codexRetryIntervalInput = panel.querySelector('#codex-retry-interval');
const codexRetryAttemptsInput = panel.querySelector('#codex-retry-attempts');
@@ -12302,6 +12320,8 @@
codexModeSelect.value = currentCodexConfig.mode || 'local';
codexEditingProfiles = (currentCodexConfig.profiles || []).map((profile) => ({ ...profile }));
codexActiveProfile = currentCodexConfig.activeProfile || (codexEditingProfiles[0]?.name || '');
+ codexSearchToggle.checked = !!currentCodexConfig.enableSearch;
+ codexSearchToggle.disabled = currentCodexConfig.supportsSearch === false;
setCodexRetryConfig(currentCodexConfig.retry || codexRetryConfig);
renderCodexProfileArea();
};
@@ -12319,7 +12339,7 @@
mode: codexModeSelect.value,
activeProfile: codexActiveProfile,
profiles: codexEditingProfiles,
- enableSearch: false,
+ enableSearch: !!codexSearchToggle.checked,
retry: readCodexRetryConfig(),
};
send({ type: 'save_codex_config', config });
diff --git a/scripts/mock-codex-app-server.js b/scripts/mock-codex-app-server.js
index 4f0e79e..dfbc3e7 100755
--- a/scripts/mock-codex-app-server.js
+++ b/scripts/mock-codex-app-server.js
@@ -102,6 +102,7 @@ function ensureThread(threadId, params = {}) {
capacityRetryAttempts: new Map(),
reconnectRetryAttempts: new Map(),
goal: null,
+ threadStartWebSearchMode: params.config?.web_search || null,
});
}
const thread = threads.get(id);
@@ -537,9 +538,12 @@ function completeTurnWithoutTerminalNotification(thread, turnId, text) {
thread.steers = [];
}
-function completeGoalBackgroundTurn(thread, objective) {
+function completeGoalBackgroundTurn(thread, objective, turnNumber = 1, totalTurns = 1) {
const turnId = `goal-turn-${crypto.randomUUID()}`;
- const text = `Goal background output: ${objective}`;
+ const itemId = `goal-agent-msg-${turnNumber}`;
+ const text = turnNumber === 1
+ ? `Goal background output: ${objective}`
+ : `Goal continuation output ${turnNumber}: ${objective}`;
thread.activeTurnId = turnId;
send({
method: 'turn/started',
@@ -553,7 +557,7 @@ function completeGoalBackgroundTurn(thread, objective) {
params: {
threadId: thread.id,
turnId,
- itemId: 'goal-agent-msg',
+ itemId,
delta: text,
},
});
@@ -564,13 +568,24 @@ function completeGoalBackgroundTurn(thread, objective) {
turnId,
completedAtMs: Date.now(),
item: {
- id: 'goal-agent-msg',
+ id: itemId,
type: 'agentMessage',
text,
status: 'completed',
},
},
});
+ if (turnNumber >= totalTurns && thread.goal) {
+ thread.goal = {
+ ...thread.goal,
+ status: 'complete',
+ updatedAt: Date.now(),
+ };
+ send({
+ method: 'thread/goal/updated',
+ params: { threadId: thread.id, goal: thread.goal },
+ });
+ }
send({
method: 'thread/tokenUsage/updated',
params: {
@@ -591,6 +606,9 @@ function completeGoalBackgroundTurn(thread, objective) {
},
});
thread.activeTurnId = null;
+ if (turnNumber < totalTurns) {
+ setTimeout(() => completeGoalBackgroundTurn(thread, objective, turnNumber + 1, totalTurns), 120);
+ }
}
function requestClient(method, params, callback) {
@@ -667,6 +685,9 @@ function completeMcpToolTurn(thread, turnId) {
ok: true,
currentConversationId: env.CC_WEB_SOURCE_SESSION_ID || urlSourceSessionId,
sourceHopCount: env.CC_WEB_CROSS_HOP_COUNT || urlSourceHopCount,
+ threadStartWebSearchMode: thread.threadStartWebSearchMode || null,
+ threadConfigMethod: thread.lastThreadConfigMethod || null,
+ webSearchMode: thread.config?.web_search || null,
hasCcwebMcpConfig: Boolean(ccwebConfig),
hasProjectMcpConfig: Boolean(projectConfig),
ccwebType: ccwebConfig?.type || (ccwebConfig?.url ? 'streamable_http' : (ccwebConfig?.command ? 'stdio' : null)),
@@ -1092,19 +1113,42 @@ function handleRequest(message) {
}
if (method === 'thread/start') {
const thread = ensureThread(null, params);
+ thread.lastThreadConfigMethod = 'thread/start';
send({ id, result: { thread: threadPayload(thread), model: params.model || 'gpt-5.5', cwd: thread.cwd, modelProvider: 'mock', approvalPolicy: params.approvalPolicy || 'never', approvalsReviewer: 'user', sandbox: params.sandbox || 'danger-full-access' } });
return;
}
if (method === 'thread/resume') {
if (params.threadId && resumeMismatchThreads.delete(params.threadId)) {
const thread = ensureThread(null, params);
+ thread.lastThreadConfigMethod = 'thread/start';
send({ id, result: { thread: threadPayload(thread), model: params.model || 'gpt-5.5', cwd: thread.cwd, modelProvider: 'mock', approvalPolicy: params.approvalPolicy || 'never', approvalsReviewer: 'user', sandbox: params.sandbox || 'danger-full-access' } });
return;
}
const thread = ensureThread(params.threadId, params);
+ thread.lastThreadConfigMethod = 'thread/resume';
send({ id, result: { thread: threadPayload(thread), model: params.model || 'gpt-5.5', cwd: thread.cwd, modelProvider: 'mock', approvalPolicy: params.approvalPolicy || 'never', approvalsReviewer: 'user', sandbox: params.sandbox || 'danger-full-access' } });
return;
}
+ if (method === 'thread/compact/start') {
+ const thread = ensureThread(params.threadId, params);
+ const compactTurnId = `app-compact-${crypto.randomUUID()}`;
+ thread.lastCompactionTurnId = compactTurnId;
+ send({
+ method: 'thread/compacted',
+ params: {
+ threadId: thread.id,
+ turnId: compactTurnId,
+ },
+ });
+ send({
+ id,
+ result: {
+ threadId: thread.id,
+ turnId: compactTurnId,
+ },
+ });
+ return;
+ }
if (method === 'thread/goal/get') {
const thread = ensureThread(params.threadId, params);
send({ id, result: { goal: thread.goal } });
@@ -1130,10 +1174,11 @@ function handleRequest(message) {
params: { threadId: thread.id, goal: thread.goal },
});
setTimeout(() => {
- send({ id, result: { goal: thread.goal } });
- if (params.objective) {
- setTimeout(() => completeGoalBackgroundTurn(thread, objective), 50);
- }
+ send({ id, result: { goal: thread.goal } });
+ if (params.objective) {
+ const totalTurns = /improve benchmark coverage/i.test(objective) ? 2 : 1;
+ setTimeout(() => completeGoalBackgroundTurn(thread, objective, 1, totalTurns), 50);
+ }
}, 250);
return;
}
diff --git a/scripts/regression.js b/scripts/regression.js
index 562c482..b5e62dc 100644
--- a/scripts/regression.js
+++ b/scripts/regression.js
@@ -2444,6 +2444,47 @@ function assertCodexAppRuntimeSubAgentActivityContract() {
assert(reasoningEnd, 'Runtime reasoning item/completed should still emit tool_end');
assert(!Object.prototype.hasOwnProperty.call(reasoningEnd, 'input'), 'Runtime non-subAgentActivity reasoning tool_end should not gain input');
assert(!Object.prototype.hasOwnProperty.call(reasoningEnd, 'name'), 'Runtime non-subAgentActivity reasoning tool_end should not gain name');
+
+ const webSearchSent = [];
+ const webSearchRuntime = createCodexAppRuntime({
+ wsSend: (_ws, payload) => webSearchSent.push(payload),
+ loadSession: () => null,
+ saveSession: () => {},
+ });
+ const webSearchEntry = { ws: {}, toolCalls: [], fullText: '' };
+ webSearchRuntime.processCodexAppNotification(webSearchEntry, {
+ method: 'item/started',
+ params: {
+ item: {
+ id: 'runtime-web-search',
+ type: 'webSearch',
+ query: 'cc-web Web Search regression',
+ },
+ },
+ }, sessionId);
+ const webSearchStart = webSearchSent.find((msg) => msg.type === 'tool_start' && msg.toolUseId === 'runtime-web-search');
+ assert(webSearchStart, 'Runtime webSearch item/started should emit tool_start');
+ assert(webSearchStart.name === 'WebSearch', 'Runtime webSearch tool_start should map to WebSearch');
+ assert(webSearchStart.kind === 'web_search', 'Runtime webSearch tool_start should map to web_search kind');
+ assert(webSearchStart.input?.type === 'webSearch', 'Runtime webSearch input should preserve original item type');
+ assert(webSearchStart.input?.query === 'cc-web Web Search regression', 'Runtime webSearch input should preserve query');
+
+ webSearchRuntime.processCodexAppNotification(webSearchEntry, {
+ method: 'item/completed',
+ params: {
+ item: {
+ id: 'runtime-web-search',
+ type: 'webSearch',
+ query: 'cc-web Web Search regression',
+ results: [{ title: 'Result', url: 'https://example.com/result' }],
+ },
+ },
+ }, sessionId);
+ const webSearchEnd = webSearchSent.find((msg) => msg.type === 'tool_end' && msg.toolUseId === 'runtime-web-search');
+ assert(webSearchEnd, 'Runtime webSearch item/completed should emit tool_end');
+ assert(webSearchEnd.kind === 'web_search', 'Runtime webSearch tool_end should keep web_search kind');
+ assert(webSearchEntry.toolCalls[0]?.name === 'WebSearch', 'Runtime persisted webSearch tool should use WebSearch name');
+ assert(webSearchEntry.toolCalls[0]?.kind === 'web_search', 'Runtime persisted webSearch tool should use web_search kind');
}
function assertCodexAppTransientReconnectContract() {
@@ -2531,6 +2572,10 @@ function assertFrontendPrimaryCodexAppUiContract() {
assert(source.includes("localStorage.setItem('cc-web-agent', currentAgent);"), 'Frontend should overwrite stale cc-web-agent storage with the primary UI agent');
assert(source.includes('currentAgent = normalizeUiAgent(agent);'), 'setCurrentAgent should coerce ordinary UI agent changes back to Codex App');
assert(source.includes('return sessions.filter((s) => isPrimaryUiAgent(s.agent));'), 'Session list should only expose Codex App sessions in ordinary UI');
+ assert(source.includes('id="codex-enable-search"'), 'Codex settings should expose the Web Search toggle');
+ assert(source.includes('仅作用于当前 Codex App 会话的原生联网搜索'), 'Codex Web Search setting copy should scope the toggle to Codex App native search');
+ assert(source.includes('codexSearchToggle.checked = !!currentCodexConfig.enableSearch;'), 'Codex Web Search toggle should load the persisted setting');
+ assert(source.includes('enableSearch: !!codexSearchToggle.checked'), 'Codex Web Search save should send the real toggle state');
assert(
/function applySessionSnapshot\(snapshot[\s\S]*?!isPrimaryUiAgent\(snapshotAgent\)[\s\S]*?return false;[\s\S]*?return true;/.test(source),
'Frontend should reject legacy Claude/Codex snapshots from the current view'
@@ -4001,6 +4046,76 @@ function assertCodexAppStaleRunningRecoveryContract() {
);
}
+function assertCodexAppGoalLifecycleContract() {
+ const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
+ const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
+ const doneStart = frontendSource.indexOf("case 'done':");
+ const doneEnd = frontendSource.indexOf("case 'system_message':", doneStart);
+ const doneBlock = doneStart >= 0 && doneEnd > doneStart
+ ? frontendSource.slice(doneStart, doneEnd)
+ : '';
+ const finishBlock = extractFunctionSource(frontendSource, 'finishGenerating');
+ const controlsBlock = extractFunctionSource(frontendSource, 'updateGenerationControls');
+ const sendMessageBlock = extractFunctionSource(frontendSource, 'sendMessage');
+ const runningBlock = extractFunctionSource(serverSource, 'isSessionRunning');
+ const notificationBlock = extractFunctionSource(serverSource, 'handleCodexAppGoalNotification');
+ const completeBlock = extractFunctionSource(serverSource, 'handleCodexAppTurnComplete');
+ const pauseBlock = extractFunctionSource(serverSource, 'pauseCodexAppGoalForAbort');
+ const abortBlock = extractFunctionSource(serverSource, 'handleAbort');
+ const messageBlock = extractFunctionSource(serverSource, 'handleMessage');
+ const deleteBlock = extractFunctionSource(serverSource, 'handleDeleteSession');
+
+ assert(
+ doneBlock.includes('snapshot.isRunning = msg.goalActive === true')
+ && doneBlock.includes('finishGenerating(msg.sessionId, { keepRunning: msg.goalActive === true })'),
+ 'Frontend done handling should keep current and background Goal sessions running between turns'
+ );
+ assert(
+ finishBlock.includes('options = {}')
+ && finishBlock.includes('const keepRunning = options.keepRunning === true')
+ && finishBlock.includes('setCurrentSessionRunningState(keepRunning)'),
+ 'Frontend finishGenerating should close one bubble without ending an active Goal'
+ );
+ assert(
+ controlsBlock.includes('const runtimeBusy = isGenerating || currentSessionRunning')
+ && controlsBlock.includes('abortBtn.hidden = !runtimeBusy'),
+ 'Frontend should keep Stop available while an active Goal is between turns'
+ );
+ assert(
+ sendMessageBlock.includes('if (currentSessionRunning && !isGenerating) return;'),
+ 'Frontend should not submit an ordinary message into an active Goal turn gap'
+ );
+ assert(
+ runningBlock.includes('isCodexAppGoalActive(sessionId)')
+ && notificationBlock.includes("method !== 'thread/goal/updated'")
+ && notificationBlock.includes("method !== 'thread/goal/cleared'")
+ && notificationBlock.includes('updateCodexAppGoalState(matched.session, goal)'),
+ 'Server should maintain thread-level Goal state independently from active turns'
+ );
+ assert(
+ completeBlock.includes('const goalActive = isCodexAppGoalActive(sessionId)')
+ && completeBlock.includes("{ type: 'done', sessionId, costUsd: null, goalActive }")
+ && completeBlock.includes('if (goalActive)')
+ && completeBlock.includes('broadcastSessionList()'),
+ 'Server turn completion should report active Goal state without sending final background completion'
+ );
+ assert(
+ pauseBlock.includes("status: 'paused'")
+ && pauseBlock.includes("client.request('thread/goal/set'")
+ && abortBlock.includes('pauseCodexAppGoalForAbort(sessionId, ws)')
+ && abortBlock.includes('handleCodexAppAbortSession(sessionId, ws)'),
+ 'Stop should pause the Goal and interrupt any currently active turn'
+ );
+ assert(
+ messageBlock.includes('!codexAppGoalStates.has(sessionId)')
+ && messageBlock.includes('loadSession(sessionId)')
+ && messageBlock.includes('isCodexAppGoalActive(sessionId)')
+ && messageBlock.includes('Goal 仍在持续运行')
+ && deleteBlock.includes('codexAppGoalStates.delete(sessionId)'),
+ 'Server should block ordinary Goal-gap turns and clean Goal state on deletion'
+ );
+}
+
function assertRuntimeImageSendStaticContract() {
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
@@ -5954,6 +6069,11 @@ async function main() {
console.log('Codex App stale running regression checks passed.');
return;
}
+ if (regressionTarget === 'codexapp-goal-lifecycle') {
+ assertCodexAppGoalLifecycleContract();
+ console.log('Codex App Goal lifecycle regression checks passed.');
+ return;
+ }
if (regressionTarget === 'session-switch-race') {
assertSessionSwitchRaceContract();
console.log('Session switch race regression checks passed.');
@@ -6064,6 +6184,7 @@ async function main() {
assertCcwebMcpChildUpdateCoalescingContract();
assertTitleHistoryOutlineContract();
assertSessionSwitchResilienceContract();
+ assertCodexAppGoalLifecycleContract();
assertSessionSwitchRaceContract();
assertAdvancedSessionSearchContract();
await assertAdvancedSearchTimeOrderingContract();
@@ -6260,8 +6381,8 @@ async function main() {
assert(codexConfigMsg.config.mode === 'custom', 'Codex config mode save/load failed');
assert(codexConfigMsg.config.activeProfile === 'Regression Profile', 'Codex active profile save/load failed');
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.supportsSearch === true, 'Codex config should expose Codex App native search capability');
+ assert(codexConfigMsg.config.enableSearch === true, 'Codex config should persist enabled Web Search');
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');
@@ -7215,6 +7336,13 @@ 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: 'message', text: 'codexapp dynamic web search enabled prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
+ const codexAppEnabledSearchDynamicTool = await nextMessage(messages, ws, (msg) => msg.type === 'tool_end' && msg.sessionId === codexAppSession.sessionId && msg.toolUseId === 'mcp-ccweb-list');
+ assert(/"threadStartWebSearchMode": "live"/.test(codexAppEnabledSearchDynamicTool.result || ''), 'Codex App thread/start should pass web_search=live when Web Search is enabled');
+ assert(/"webSearchMode": "live"/.test(codexAppEnabledSearchDynamicTool.result || ''), 'Codex App thread config should keep web_search=live while Web Search is enabled');
+ assert(!/"webSearchToolConfig"/.test(codexAppEnabledSearchDynamicTool.result || ''), 'Codex App should use top-level web_search instead of tools.web_search as the mode switch');
+ await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
+
ws.send(JSON.stringify({
type: 'save_codex_config',
config: {
@@ -7229,6 +7357,7 @@ async function main() {
msg.type === 'codex_config' && msg.config?.activeProfile === 'Regression Profile Updated'
);
assert(codexAppChangedConfig.config.mode === 'custom', 'Codex App config-change regression should save custom mode');
+ assert(codexAppChangedConfig.config.enableSearch === false, 'Codex App config-change regression should persist disabled Web Search');
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) => (
@@ -7239,6 +7368,13 @@ async function main() {
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);
+ ws.send(JSON.stringify({ type: 'message', text: 'codexapp dynamic after web search disabled prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
+ const codexAppDisabledSearchDynamicTool = await nextMessage(messages, ws, (msg) => msg.type === 'tool_end' && msg.sessionId === codexAppSession.sessionId && msg.toolUseId === 'mcp-ccweb-list');
+ assert(/"threadConfigMethod": "thread\/resume"/.test(codexAppDisabledSearchDynamicTool.result || ''), 'Codex App existing thread should refresh config through thread/resume');
+ assert(/"webSearchMode": "disabled"/.test(codexAppDisabledSearchDynamicTool.result || ''), 'Codex App thread/resume should pass web_search=disabled after Web Search is disabled');
+ assert(!/"webSearchToolConfig"/.test(codexAppDisabledSearchDynamicTool.result || ''), 'Codex App should not use tools.web_search as the Web Search mode switch');
+ 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) => (
@@ -7346,13 +7482,45 @@ async function main() {
/Goal background output: improve benchmark coverage/.test(msg.text || '')
), 5000);
assert(/Goal background output/.test(codexAppGoalBackgroundDelta.text || ''), 'Codex App /goal background turn should stream through the active session');
- await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId, 5000);
+ const codexAppGoalFirstTurnDone = await nextMessage(messages, ws, (msg) => (
+ msg.type === 'done' &&
+ msg.sessionId === codexAppSession.sessionId &&
+ msg.goalActive === true
+ ), 5000);
+ assert(codexAppGoalFirstTurnDone.goalActive === true, 'Goal first turn completion should remain explicitly active');
+ const codexAppGoalStillRunningList = await nextMessage(messages, ws, (msg) => (
+ msg.type === 'session_list' &&
+ Array.isArray(msg.sessions) &&
+ msg.sessions.some((session) => session.id === codexAppSession.sessionId && session.isRunning)
+ ), 5000);
+ assert(codexAppGoalStillRunningList.sessions.some((session) => session.id === codexAppSession.sessionId && session.isRunning), 'Goal should stay running between continuation turns');
+ const codexAppGoalContinuationDelta = await nextMessage(messages, ws, (msg) => (
+ msg.type === 'text_delta' &&
+ msg.sessionId === codexAppSession.sessionId &&
+ /Goal continuation output 2: improve benchmark coverage/.test(msg.text || '')
+ ), 5000);
+ assert(/Goal continuation output 2/.test(codexAppGoalContinuationDelta.text || ''), 'Goal continuation turn should stream through the same session');
+ const codexAppGoalFinalDone = await nextMessage(messages, ws, (msg) => (
+ msg.type === 'done' &&
+ msg.sessionId === codexAppSession.sessionId &&
+ msg.goalActive !== true
+ ), 5000);
+ assert(codexAppGoalFinalDone.goalActive !== true, 'Goal terminal turn should emit a final done event');
const codexAppGoalIdleList = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_list' &&
Array.isArray(msg.sessions) &&
msg.sessions.some((session) => session.id === codexAppSession.sessionId && !session.isRunning)
), 5000);
- assert(codexAppGoalIdleList.sessions.some((session) => session.id === codexAppSession.sessionId && !session.isRunning), 'Codex App /goal RPC should clear running state after app-server responds');
+ assert(codexAppGoalIdleList.sessions.some((session) => session.id === codexAppSession.sessionId && !session.isRunning), 'Codex App Goal should become idle only after the Goal reaches a terminal status');
+ const storedCodexAppGoalSession = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
+ assert(
+ storedCodexAppGoalSession.messages.some((message) => message.role === 'assistant' && /Goal background output: improve benchmark coverage/.test(String(message.content || ''))),
+ 'Goal first turn assistant output should be persisted'
+ );
+ assert(
+ storedCodexAppGoalSession.messages.some((message) => message.role === 'assistant' && /Goal continuation output 2: improve benchmark coverage/.test(String(message.content || ''))),
+ 'Goal continuation assistant output should be persisted separately'
+ );
ws.send(JSON.stringify({
type: 'message',
text: '/goal improve benchmark coverage',
@@ -7372,7 +7540,7 @@ async function main() {
assert(!messages.some((msg) => msg.type === 'session_message' && msg.message?.id === codexAppGoalMessageId), 'Duplicate Goal command ids should not append another user bubble');
assert(!messages.some((msg) => msg.type === 'text_delta' && /Goal background output: improve benchmark coverage/.test(msg.text || '')), 'Duplicate Goal command ids should not trigger another Goal RPC background turn');
ws.send(JSON.stringify({ type: 'message', text: '/goal', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
- const codexAppGoalShow = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /Goal active/.test(msg.message || '') && /improve benchmark coverage/.test(msg.message || ''));
+ const codexAppGoalShow = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /Goal complete/.test(msg.message || '') && /improve benchmark coverage/.test(msg.message || ''));
assert(/improve benchmark coverage/.test(codexAppGoalShow.message || ''), 'Codex App /goal should show the current goal');
ws.send(JSON.stringify({ type: 'message', text: '/goal pause', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const codexAppGoalPause = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /Goal paused/.test(msg.message || ''));
@@ -7432,6 +7600,28 @@ async function main() {
assert(storedCodexApp.messages.some((message) => message.role === 'assistant' && /codexapp tool prompt/.test(String(message.content || ''))), 'Codex App assistant response should be persisted');
assert((storedCodexApp.totalUsage?.inputTokens || 0) > 0, 'Codex App token usage should be persisted');
+ ws.send(JSON.stringify({ type: 'message', text: '/compact', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
+ const codexAppCompactStart = await nextMessage(messages, ws, (msg) => (
+ msg.type === 'system_message' &&
+ msg.sessionId === codexAppSession.sessionId &&
+ /正在执行 Codex App 原生 \/compact/.test(msg.message || '')
+ ), 5000);
+ assert(/Codex App 原生 \/compact/.test(codexAppCompactStart.message || ''), 'Codex App /compact should announce native compaction');
+ const codexAppCompactRunning = await nextMessage(messages, ws, (msg) => (
+ msg.type === 'session_list' &&
+ msg.sessions?.some((session) => session.id === codexAppSession.sessionId && session.isRunning)
+ ), 5000);
+ assert(codexAppCompactRunning.sessions.some((session) => session.id === codexAppSession.sessionId && session.isRunning), 'Codex App /compact should mark the session running');
+ const codexAppCompactDone = await nextMessage(messages, ws, (msg) => (
+ msg.type === 'system_message' &&
+ msg.sessionId === codexAppSession.sessionId &&
+ /已执行 Codex App 原生 \/compact/.test(msg.message || '')
+ ), 10000);
+ assert(/上下文压缩完成/.test(codexAppCompactDone.message || ''), 'Codex App /compact should complete through app-server');
+ const storedCodexAppAfterCompact = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
+ assert(storedCodexAppAfterCompact.codexAppThreadId === codexAppThreadId, 'Codex App /compact should keep the same thread');
+ assert(!storedCodexAppAfterCompact.messages.some((message) => message.role === 'user' && message.content === '/compact'), 'Codex App /compact should not persist the slash command as a user message');
+
ws.send(JSON.stringify({ type: 'message', text: 'codexapp huge output prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const codexAppHugeTool = await nextMessage(messages, ws, (msg) => msg.type === 'tool_end' && msg.sessionId === codexAppSession.sessionId && msg.toolUseId === 'huge-tool');
assert((codexAppHugeTool.result || '').length <= 33000, 'Codex App huge tool result should be capped before sending to the browser');
diff --git a/server.js b/server.js
index c70244b..0a0593a 100644
--- a/server.js
+++ b/server.js
@@ -765,6 +765,12 @@ const activeCodexAppTurns = new Map();
// Active Codex app-server goal RPCs: sessionId -> { id, ws, action, cancelled }
const activeCodexAppGoalCommands = new Map();
+
+// Latest Codex app-server Goal state: sessionId -> normalized ThreadGoal
+const codexAppGoalStates = new Map();
+
+// Active Codex app-server compact RPCs: sessionId -> { id, ws, threadId, cancelled }
+const activeCodexAppCompactions = new Map();
// ccweb MCP child agents tracked from Codex App native collaboration mode:
// childThreadId -> { parentSessionId, parentThreadId, spawnToolId, ...state }
const ccwebMcpChildThreads = new Map();
@@ -863,7 +869,7 @@ const DEFAULT_CODEX_CONFIG = {
activeProfile: '',
profiles: [],
enableSearch: false,
- supportsSearch: false,
+ supportsSearch: true,
retry: {
mode: 'limited',
intervalSeconds: Math.max(1, Math.ceil(CODEX_TRANSIENT_RETRY_BASE_DELAY_MS / 1000)),
@@ -1308,9 +1314,8 @@ function loadCodexConfig() {
apiKey: String(profile?.apiKey || ''),
apiBase: String(profile?.apiBase || '').trim(),
})).filter((profile) => profile.name) : [],
- enableSearch: false,
- supportsSearch: false,
- storedEnableSearch: !!raw.enableSearch,
+ enableSearch: !!raw.enableSearch,
+ supportsSearch: true,
retry: normalizeCodexRetryConfig(raw.retry),
};
}
@@ -1327,7 +1332,7 @@ function saveCodexConfig(config) {
apiKey: String(profile?.apiKey || ''),
apiBase: String(profile?.apiBase || '').trim(),
})).filter((profile) => profile.name) : [],
- enableSearch: false,
+ enableSearch: !!config.enableSearch,
retry: normalizeCodexRetryConfig(config.retry),
}, null, 2));
}
@@ -1342,9 +1347,8 @@ function getCodexConfigMasked() {
apiKey: maskSecret(profile.apiKey),
apiBase: profile.apiBase || '',
})),
- enableSearch: false,
- supportsSearch: false,
- storedEnableSearch: !!config.storedEnableSearch,
+ enableSearch: !!config.enableSearch,
+ supportsSearch: true,
retry: normalizeCodexRetryConfig(config.retry),
};
}
@@ -3249,7 +3253,11 @@ function isCodexLikeSession(session) {
}
function isSessionRunning(sessionId) {
- return activeProcesses.has(sessionId) || activeCodexAppTurns.has(sessionId) || activeCodexAppGoalCommands.has(sessionId);
+ return activeProcesses.has(sessionId)
+ || activeCodexAppTurns.has(sessionId)
+ || activeCodexAppGoalCommands.has(sessionId)
+ || isCodexAppGoalActive(sessionId)
+ || activeCodexAppCompactions.has(sessionId);
}
function getRuntimeSessionId(session) {
@@ -3736,11 +3744,17 @@ async function handleReloadMcpApi(req, res, rawSessionId) {
function setRuntimeSessionId(session, runtimeId) {
if (!session) return;
const agent = getSessionAgent(session);
- if (agent === 'codex') {
- session.codexThreadId = runtimeId || null;
- } else if (agent === 'codexapp') {
- session.codexAppThreadId = runtimeId || null;
- } else {
+ if (agent === 'codex') {
+ session.codexThreadId = runtimeId || null;
+ } else if (agent === 'codexapp') {
+ const previousThreadId = normalizeCodexAppThreadId(session.codexAppThreadId);
+ const nextThreadId = normalizeCodexAppThreadId(runtimeId);
+ session.codexAppThreadId = runtimeId || null;
+ if (previousThreadId && previousThreadId !== nextThreadId) {
+ session.codexAppGoal = null;
+ if (session.id) codexAppGoalStates.delete(session.id);
+ }
+ } else {
session.claudeSessionId = runtimeId || null;
}
}
@@ -4250,8 +4264,9 @@ function loadSessionMetaFromFile(filePath) {
hasUnread: !!session.hasUnread,
agent: getSessionAgent(session),
cwd,
- projectName: cwd ? path.basename(cwd.replace(/[\\/]+$/, '')) : '',
- fileBytes: stat.size,
+ projectName: cwd ? path.basename(cwd.replace(/[\\/]+$/, '')) : '',
+ codexAppGoalActive: isCodexThreadGoalActive(session.codexAppGoal),
+ fileBytes: stat.size,
oversized: stat.size > SESSION_LOAD_MAX_BYTES,
};
}
@@ -4271,8 +4286,9 @@ function loadSessionMetaFromFile(filePath) {
hasUnread: previewBooleanField(previewFields, 'hasUnread'),
agent: normalizeAgent(previewStringField(previewFields, 'agent')),
cwd,
- projectName: cwd ? path.basename(cwd.replace(/[\\/]+$/, '')) : '',
- fileBytes: stat.size,
+ projectName: cwd ? path.basename(cwd.replace(/[\\/]+$/, '')) : '',
+ codexAppGoalActive: false,
+ fileBytes: stat.size,
oversized: stat.size > SESSION_LOAD_MAX_BYTES,
};
} catch (err) {
@@ -4290,8 +4306,9 @@ function loadSession(id) {
try {
const filePath = sessionPath(normalizedId);
if (!fs.existsSync(filePath)) return null;
- const session = normalizeSession(safeReadSessionJson(filePath, SESSION_LOAD_MAX_BYTES, { sessionId: normalizedId }));
- updateSessionRuntimeThreadIndex(session);
+ const session = normalizeSession(safeReadSessionJson(filePath, SESSION_LOAD_MAX_BYTES, { sessionId: normalizedId }));
+ syncCodexAppGoalStateFromSession(session);
+ updateSessionRuntimeThreadIndex(session);
return session;
} catch (err) {
plog('WARN', 'session_load_failed', {
@@ -5315,7 +5332,7 @@ function sendSessionList(ws) {
agent: normalizeAgent(meta.agent),
cwd: meta.cwd || '',
projectName: meta.projectName || '',
- isRunning: isSessionRunning(meta.id),
+ isRunning: isSessionRunning(meta.id) || meta.codexAppGoalActive === true,
waitingOnChildren: waitState.waitingOnChildren,
pendingReplyCount: waitState.pendingReplyCount,
readyReplyCount: waitState.readyReplyCount,
@@ -6687,12 +6704,14 @@ function formatRuntimeError(agent, raw, context = {}) {
}
function compactStartMessage(agent) {
+ if (agent === 'codexapp') return '正在执行 Codex App 原生 /compact 压缩上下文,请稍候…';
return agent === 'codex'
? '正在执行 Codex /compact 压缩上下文,请稍候…'
: '正在执行 Claude 原生 /compact 压缩上下文,请稍候…';
}
function compactDoneMessage(agent) {
+ if (agent === 'codexapp') return '上下文压缩完成。已执行 Codex App 原生 /compact,下次继续在同一会话发送即可。';
return agent === 'codex'
? '上下文压缩完成。已执行 Codex /compact,下次继续在同一会话发送即可。'
: '上下文压缩完成。已按 Claude Code 原生策略执行 /compact,下次继续在同一会话发送即可。';
@@ -7909,9 +7928,8 @@ function handleSaveCodexConfig(ws, newConfig) {
mode: newConfig.mode === 'custom' ? 'custom' : 'local',
activeProfile: String(newConfig.activeProfile || '').trim(),
profiles: mergedProfiles,
- enableSearch: false,
- supportsSearch: false,
- storedEnableSearch: requestedSearch,
+ enableSearch: requestedSearch,
+ supportsSearch: true,
retry,
};
if (merged.mode === 'custom' && merged.profiles.length > 0 && !merged.profiles.some((profile) => profile.name === merged.activeProfile)) {
@@ -7923,7 +7941,7 @@ function handleSaveCodexConfig(ws, newConfig) {
activeProfile: merged.activeProfile || null,
profileCount: merged.profiles.length,
enableSearchRequested: requestedSearch,
- enableSearchEffective: false,
+ enableSearchEffective: merged.enableSearch,
retryMode: retry.mode,
retryIntervalSeconds: retry.intervalSeconds,
retryMaxAttempts: retry.mode === 'limited' ? retry.maxAttempts : null,
@@ -7931,9 +7949,7 @@ function handleSaveCodexConfig(ws, newConfig) {
wsSend(ws, { type: 'codex_config', config: getCodexConfigMasked() });
wsSend(ws, {
type: 'system_message',
- message: requestedSearch
- ? 'Codex 配置已保存。当前 cc-web 的 Codex exec 路径暂未接入 Web Search,已自动忽略该开关。'
- : 'Codex 配置已保存',
+ message: 'Codex 配置已保存',
});
}
@@ -8050,6 +8066,63 @@ function normalizeCodexThreadGoal(goal, fallbackThreadId = '') {
};
}
+function codexGoalStatusKey(status) {
+ return goalString(status || 'active').toLowerCase().replace(/[\s_-]/g, '');
+}
+
+function isCodexThreadGoalActive(goal) {
+ return !!goal && codexGoalStatusKey(goal.status) === 'active';
+}
+
+function isCodexAppGoalActive(sessionId) {
+ return isCodexThreadGoalActive(codexAppGoalStates.get(sessionId));
+}
+
+function syncCodexAppGoalStateFromSession(session) {
+ const sessionId = sanitizeId(session?.id || '');
+ if (!sessionId || !isCodexAppSession(session)) return null;
+ const threadId = normalizeCodexAppThreadId(getRuntimeSessionId(session));
+ const goal = normalizeCodexThreadGoal(session.codexAppGoal, threadId || '');
+ if (!goal || (threadId && goal.threadId && goal.threadId !== threadId)) {
+ codexAppGoalStates.delete(sessionId);
+ return null;
+ }
+ codexAppGoalStates.set(sessionId, goal);
+ return goal;
+}
+
+function updateCodexAppGoalState(session, goal, options = {}) {
+ const sessionId = sanitizeId(session?.id || '');
+ if (!sessionId || !isCodexAppSession(session)) return null;
+ const threadId = normalizeCodexAppThreadId(getRuntimeSessionId(session));
+ const normalized = normalizeCodexThreadGoal(goal, threadId || '');
+ const previous = normalizeCodexThreadGoal(
+ codexAppGoalStates.get(sessionId) || session.codexAppGoal,
+ threadId || '',
+ );
+ if (
+ options.force !== true
+ && normalized
+ && previous
+ && normalized.threadId === previous.threadId
+ && normalized.updatedAt > 0
+ && previous.updatedAt > normalized.updatedAt
+ ) {
+ return previous;
+ }
+
+ if (normalized) {
+ session.codexAppGoal = normalized;
+ codexAppGoalStates.set(sessionId, normalized);
+ } else {
+ session.codexAppGoal = null;
+ codexAppGoalStates.delete(sessionId);
+ }
+ if (options.persist !== false) saveSession(session);
+ if (options.broadcast !== false) broadcastSessionList();
+ return normalized;
+}
+
function formatCodexGoalStatus(status) {
const normalized = String(status || 'active').trim();
const compact = normalized.toLowerCase().replace(/[\s_-]/g, '');
@@ -8133,6 +8206,122 @@ async function ensureCodexAppGoalThread(session) {
return { client, threadId };
}
+function isCurrentCodexAppCompaction(sessionId, entry) {
+ return !!entry
+ && activeCodexAppCompactions.get(sessionId)?.id === entry.id
+ && !entry.cancelled;
+}
+
+function finishCodexAppCompaction(sessionId, entry) {
+ if (!entry || activeCodexAppCompactions.get(sessionId)?.id !== entry.id) return false;
+ activeCodexAppCompactions.delete(sessionId);
+ broadcastSessionList();
+ return true;
+}
+
+function cancelCodexAppCompaction(sessionId, ws = null) {
+ const entry = activeCodexAppCompactions.get(sessionId);
+ if (!entry) return false;
+ entry.cancelled = true;
+ activeCodexAppCompactions.delete(sessionId);
+ const targetWs = ws || entry.ws || null;
+ if (targetWs) {
+ wsSend(targetWs, {
+ type: 'system_message',
+ sessionId,
+ message: '已取消 Codex App /compact 状态。底层 app-server 请求可能仍会自然返回,结果将被忽略。',
+ });
+ }
+ broadcastSessionList();
+ return true;
+}
+
+function isCodexCompactUnsupportedError(err) {
+ const detail = `${err?.code || ''} ${err?.message || err || ''}`;
+ return err?.code === -32601
+ || /compact.*unsupported|unsupported.*compact|method not found|unknown mock method|unsupported remote app-server request/i.test(detail);
+}
+
+async function handleCodexAppCompactSlashCommand(ws, session, source = {}) {
+ if (!session || !isCodexAppSession(session)) return;
+ const sessionId = session.id;
+ const sendCompactResponse = (targetWs, payload, options = {}) => {
+ if (!targetWs) return;
+ wsSend(targetWs, attachClientRequestId({
+ ...payload,
+ ...(options.preserveComposerDraft ? { preserveComposerDraft: true } : {}),
+ }, source));
+ };
+ const sendCompactSystemMessage = (targetWs, message, extra = {}, options = {}) => {
+ sendCompactResponse(targetWs, { type: 'system_message', message, ...extra }, options);
+ };
+
+ if (activeCodexAppCompactions.has(sessionId)) {
+ sendCompactSystemMessage(ws, 'Codex App /compact 正在执行,请稍候。', { sessionId }, { preserveComposerDraft: true });
+ return;
+ }
+
+ const runtimeThreadId = getRuntimeSessionId(session);
+ if (!runtimeThreadId) {
+ sendCompactSystemMessage(ws, '当前会话尚未建立 Codex App 上下文,暂时无需压缩。', { sessionId }, { preserveComposerDraft: true });
+ return;
+ }
+
+ const compactEntry = {
+ id: crypto.randomUUID(),
+ ws,
+ threadId: runtimeThreadId,
+ cancelled: false,
+ startedAt: new Date().toISOString(),
+ };
+ activeCodexAppCompactions.set(sessionId, compactEntry);
+ sendCompactSystemMessage(ws, compactStartMessage('codexapp'), { sessionId });
+ broadcastSessionList();
+
+ try {
+ const clientResult = getCodexAppClient({ excludeSessionId: sessionId });
+ if (clientResult.error) throw new Error(clientResult.error);
+ const client = clientResult.client;
+ await client.start();
+ if (!isCurrentCodexAppCompaction(sessionId, compactEntry)) return;
+
+ const threadParams = codexAppThreadParams(session);
+ const resumed = await client.request('thread/resume', {
+ ...threadParams,
+ threadId: runtimeThreadId,
+ }, 60000);
+ const threadId = resumed?.thread?.id || runtimeThreadId;
+ if (threadId !== runtimeThreadId) {
+ throw new Error(`Codex App 恢复到不同线程,已停止压缩(期望 ${String(runtimeThreadId).slice(0, 24)},实际 ${String(threadId).slice(0, 24)})。`);
+ }
+ const response = await client.request('thread/compact/start', { threadId }, 300000);
+ if (!isCurrentCodexAppCompaction(sessionId, compactEntry)) return;
+
+ session.updated = new Date().toISOString();
+ saveSession(session);
+ sendCompactSystemMessage(compactEntry.ws || ws, compactDoneMessage('codexapp'), { sessionId });
+ if (response?.threadId && response.threadId !== threadId) {
+ plog('WARN', 'codex_app_compact_thread_mismatch', {
+ sessionId: sessionId.slice(0, 8),
+ expectedThreadId: threadId,
+ actualThreadId: response.threadId,
+ });
+ }
+ } catch (err) {
+ if (isCurrentCodexAppCompaction(sessionId, compactEntry)) {
+ const message = isCodexCompactUnsupportedError(err)
+ ? '当前 Codex app-server 不支持 /compact,请升级 Codex 后重试。'
+ : `Codex App /compact 失败:${err?.message || err}`;
+ sendCompactSystemMessage(compactEntry.ws || ws, message, {
+ sessionId,
+ tone: 'danger',
+ }, { preserveComposerDraft: true });
+ }
+ } finally {
+ finishCodexAppCompaction(sessionId, compactEntry);
+ }
+}
+
function isCodexGoalUnsupportedError(err) {
const detail = `${err?.code || ''} ${err?.message || err || ''}`;
return err?.code === -32601
@@ -8256,6 +8445,7 @@ async function handleCodexAppGoalSlashCommand(ws, text, session, source = {}) {
const response = await client.request('thread/goal/get', { threadId }, 30000);
if (!isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) return;
const goal = normalizeCodexThreadGoal(response?.goal, threadId);
+ updateCodexAppGoalState(session, goal);
const targetWs = activeGoalCommand.ws || ws;
sendGoalSystemMessage(targetWs, goal ? formatCodexGoalUsage(goal) : '用法: /goal <目标描述>', { sessionId: session.id });
sendSessionList(targetWs);
@@ -8265,6 +8455,7 @@ async function handleCodexAppGoalSlashCommand(ws, text, session, source = {}) {
if (command.action === 'clear') {
const response = await client.request('thread/goal/clear', { threadId }, 30000);
if (!isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) return;
+ updateCodexAppGoalState(session, null);
const targetWs = activeGoalCommand.ws || ws;
sendGoalSystemMessage(targetWs, response?.cleared ? 'Goal cleared' : 'No goal to clear', {
sessionId: session.id,
@@ -8283,6 +8474,7 @@ async function handleCodexAppGoalSlashCommand(ws, text, session, source = {}) {
}, 30000);
if (!isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) return;
const goal = normalizeCodexThreadGoal(response?.goal, threadId);
+ updateCodexAppGoalState(session, goal);
const targetWs = activeGoalCommand.ws || ws;
sendGoalSystemMessage(targetWs, goal ? formatCodexGoalUsage(goal, { includeObjective: false }) : 'Goal updated', {
sessionId: session.id,
@@ -8341,6 +8533,10 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent, source = {}) {
sendSlashSystemMessage('Codex App Goal 正在同步,请稍候。', { sessionId }, { preserveComposerDraft: true });
return true;
}
+ if (session && isCodexAppSession(session) && activeCodexAppCompactions.has(sessionId)) {
+ sendSlashSystemMessage('Codex App /compact 正在执行,请稍候。', { sessionId }, { preserveComposerDraft: true });
+ return true;
+ }
switch (cmd) {
case '/clear': {
@@ -8452,7 +8648,9 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent, source = {}) {
break;
}
if (isCodexAppSession(session)) {
- sendSlashSystemMessage('Codex App 模式暂不支持 /compact,请切换到旧 Codex 模式或等待后续接入。', {}, { preserveComposerDraft: true });
+ handleCodexAppCompactSlashCommand(ws, session, source).catch((err) => {
+ sendSlashSystemMessage(`Codex App /compact 失败:${err?.message || err}`, { sessionId: session.id }, { preserveComposerDraft: true });
+ });
break;
}
const runtimeId = getRuntimeSessionId(session);
@@ -8531,7 +8729,7 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent, source = {}) {
sendSlashResponse({
type: 'system_message',
message: codexLikeAgent
- ? base + `\n/model [名称] — 查看/切换 ${agent === 'codexapp' ? 'Codex App' : 'Codex'} 模型(自由输入)${agent === 'codexapp' ? '\n/goal [目标] — 设置/查看持久目标;支持 pause/resume/clear' : ''}\n/init — 分析项目并生成/更新 AGENTS.md${agent === 'codexapp' ? '\n/compact — Codex App 模式暂不支持' : '\n/compact — 执行 Codex /compact 压缩上下文'}`
+ ? base + `\n/model [名称] — 查看/切换 ${agent === 'codexapp' ? 'Codex App' : 'Codex'} 模型(自由输入)${agent === 'codexapp' ? '\n/goal [目标] — 设置/查看持久目标;支持 pause/resume/clear' : ''}\n/init — 分析项目并生成/更新 AGENTS.md${agent === 'codexapp' ? '\n/compact — 执行 Codex App 原生上下文压缩' : '\n/compact — 执行 Codex /compact 压缩上下文'}`
: base + '\n/model [名称] — 查看/切换模型(opus, sonnet, haiku)\n/compact — 执行 Claude 原生上下文压缩(保留压缩计划并可自动续跑)\n/init — 分析项目并生成/更新 CLAUDE.md',
});
break;
@@ -9175,6 +9373,8 @@ function handleDeleteSession(ws, sessionId) {
pendingSlashCommands.delete(sessionId);
pendingCompactRetries.delete(sessionId);
cancelCodexCapacityRetry(sessionId);
+ cancelCodexAppCompaction(sessionId);
+ codexAppGoalStates.delete(sessionId);
removeSessionRuntimeThreadIndex(sessionId);
if (activeCodexAppGoalCommands.has(sessionId)) {
const entry = activeCodexAppGoalCommands.get(sessionId);
@@ -9290,6 +9490,12 @@ function handleDisconnect(ws, wsId) {
affectedSessions.push({ sessionId: sid.slice(0, 8), threadId: entry.threadId || null, turnId: entry.turnId || null });
}
}
+ for (const [sid, entry] of activeCodexAppCompactions) {
+ if (entry.ws === ws) {
+ entry.ws = null;
+ affectedSessions.push({ sessionId: sid.slice(0, 8), threadId: entry.threadId || null, compact: true });
+ }
+ }
wsSessionMap.delete(ws);
plog('INFO', 'ws_disconnect', { wsId, activeProcessesAffected: affectedSessions });
}
@@ -9305,6 +9511,7 @@ function bindAbortSessionToWs(sessionId, ws) {
activeProcesses.get(sessionId),
activeCodexAppTurns.get(sessionId),
activeCodexAppGoalCommands.get(sessionId),
+ activeCodexAppCompactions.get(sessionId),
].filter(Boolean);
if (entries.length > 0) detachWsFromActiveRuntimes(ws);
wsSessionMap.set(ws, sessionId);
@@ -9314,13 +9521,81 @@ function bindAbortSessionToWs(sessionId, ws) {
}
}
+function pauseCodexAppGoalForAbort(sessionId, ws = null) {
+ const session = loadSession(sessionId);
+ const threadId = normalizeCodexAppThreadId(getRuntimeSessionId(session));
+ const currentGoal = normalizeCodexThreadGoal(
+ codexAppGoalStates.get(sessionId) || session?.codexAppGoal,
+ threadId || '',
+ );
+ if (!session || !threadId || !isCodexThreadGoalActive(currentGoal)) return false;
+
+ const pausedGoal = {
+ ...currentGoal,
+ status: 'paused',
+ updatedAt: Math.max(Date.now(), Number(currentGoal.updatedAt || 0) + 1),
+ };
+ updateCodexAppGoalState(session, pausedGoal);
+ plog('INFO', 'codex_app_goal_pause_requested_by_abort', {
+ sessionId: sessionId.slice(0, 8),
+ threadId,
+ });
+
+ Promise.resolve().then(async () => {
+ const clientResult = getCodexAppClient();
+ if (clientResult.error) throw new Error(clientResult.error);
+ const client = clientResult.client;
+ await client.start();
+ const response = await client.request('thread/goal/set', {
+ threadId,
+ status: 'paused',
+ }, 30000);
+ const refreshedSession = loadSession(sessionId);
+ if (!refreshedSession) return;
+ const confirmedGoal = normalizeCodexThreadGoal(response?.goal, threadId);
+ if (confirmedGoal) updateCodexAppGoalState(refreshedSession, confirmedGoal);
+ if (ws) {
+ wsSend(ws, {
+ type: 'system_message',
+ sessionId,
+ tone: 'info',
+ transient: true,
+ autoDismissMs: 5000,
+ message: 'Goal paused',
+ });
+ }
+ }).catch((err) => {
+ const refreshedSession = loadSession(sessionId);
+ const latestGoal = codexAppGoalStates.get(sessionId);
+ if (
+ refreshedSession
+ && codexGoalStatusKey(latestGoal?.status) === 'paused'
+ && Number(latestGoal?.updatedAt || 0) === pausedGoal.updatedAt
+ ) {
+ updateCodexAppGoalState(refreshedSession, currentGoal, { force: true });
+ }
+ if (ws) {
+ wsSend(ws, {
+ type: 'error',
+ sessionId,
+ code: 'codexapp_goal_pause_failed',
+ message: `暂停 Goal 失败:${err?.message || err}`,
+ });
+ }
+ });
+ return true;
+}
+
function handleAbort(ws, msg = {}) {
const requestedSessionId = sanitizeId(msg?.sessionId || '');
const sessionId = requestedSessionId || wsSessionMap.get(ws);
if (!sessionId) return;
bindAbortSessionToWs(sessionId, ws);
- if (handleCodexAppAbortSession(sessionId, ws)) return;
+ const goalPauseStarted = pauseCodexAppGoalForAbort(sessionId, ws);
+ const turnAbortStarted = handleCodexAppAbortSession(sessionId, ws);
+ if (turnAbortStarted || goalPauseStarted) return;
if (cancelCodexAppGoalCommand(sessionId, ws)) return;
+ if (cancelCodexAppCompaction(sessionId, ws)) return;
const entry = activeProcesses.get(sessionId);
if (!entry) {
if (cancelCodexCapacityRetry(sessionId)) {
@@ -9453,6 +9728,15 @@ function handleMessage(ws, msg, options = {}) {
return fail('session_running', 'Codex App Goal 正在同步,请稍候。');
}
+ if (sessionId && activeCodexAppCompactions.has(sessionId)) {
+ return fail('session_running', 'Codex App /compact 正在执行,请稍候。');
+ }
+
+ if (sessionId && !codexAppGoalStates.has(sessionId)) loadSession(sessionId);
+ if (sessionId && isCodexAppGoalActive(sessionId)) {
+ return fail('session_running', 'Goal 仍在持续运行,请先点击停止按钮暂停 Goal。');
+ }
+
if (sessionId && activeProcesses.has(sessionId)) {
return fail('session_running', '正在处理中,请先点击停止按钮。');
}
@@ -9825,6 +10109,12 @@ function detachWsFromActiveRuntimes(ws, options = {}) {
if (disconnectTime) entry.wsDisconnectTime = disconnectTime;
}
}
+ for (const [, entry] of activeCodexAppCompactions) {
+ if (entry.ws === ws) {
+ entry.ws = null;
+ if (disconnectTime) entry.wsDisconnectTime = disconnectTime;
+ }
+ }
}
function codexAppRuntimeThreadId(params = {}) {
@@ -10614,7 +10904,47 @@ function shouldLogCodexAppUnroutedNotification(notification) {
return true;
}
+function handleCodexAppGoalNotification(notification) {
+ const method = String(notification?.method || '').trim();
+ if (method !== 'thread/goal/updated' && method !== 'thread/goal/cleared') return false;
+ const threadId = normalizeCodexAppThreadId(
+ notification?.params?.threadId || notification?.params?.thread?.id,
+ );
+ if (!threadId) return false;
+ const matched = findCodexAppSessionByThreadId(threadId);
+ if (!matched?.session) return false;
+
+ const goal = method === 'thread/goal/updated'
+ ? normalizeCodexThreadGoal(notification?.params?.goal, threadId)
+ : null;
+ if (method === 'thread/goal/updated' && !goal) return false;
+ updateCodexAppGoalState(matched.session, goal);
+ plog('INFO', method === 'thread/goal/updated' ? 'codex_app_goal_updated' : 'codex_app_goal_cleared', {
+ sessionId: matched.sessionId.slice(0, 8),
+ threadId,
+ status: goal?.status || null,
+ tokensUsed: goal?.tokensUsed || 0,
+ });
+ return true;
+}
+
function handleCodexAppNotification(notification) {
+ if (notification?.method === 'thread/compacted') {
+ const threadId = normalizeCodexAppThreadId(
+ notification?.params?.threadId
+ || notification?.params?.thread_id
+ || notification?.params?.thread?.id,
+ );
+ if (threadId) {
+ for (const entry of activeCodexAppCompactions.values()) {
+ if (entry.threadId === threadId) {
+ entry.compactedNotification = true;
+ return;
+ }
+ }
+ }
+ }
+ if (handleCodexAppGoalNotification(notification)) return;
const routed = findCodexAppRouteByRuntime(notification?.params || {}, notification?.method || '');
if (handleCodexAppMcpStartupStatusNotification(notification, routed)) return;
if (!routed) {
@@ -11187,7 +11517,9 @@ function codexAppCcwebMcpEnv(session, options = {}) {
}
function codexAppThreadConfig(session, options = {}) {
- const config = {};
+ const config = {
+ web_search: loadCodexConfig().enableSearch ? 'live' : 'disabled',
+ };
for (const item of listRuntimeMcpServerConfigs({ ...options, session, agent: 'codexapp' })) {
if (!item?.server || !item?.config) continue;
config[`mcp_servers.${item.server}`] = item.config;
@@ -11580,6 +11912,7 @@ function handleCodexAppTurnComplete(sessionId, options = {}) {
activeCodexAppTurns.delete(sessionId);
cleanupCodexAppTurnState(sessionId, entry);
+ const goalActive = isCodexAppGoalActive(sessionId);
dispatchTaskBoardLifecycle(sessionId, {
type: completionError
? TASK_BOARD_LIFECYCLE_EVENTS.TURN_FAILED
@@ -11590,7 +11923,7 @@ function handleCodexAppTurnComplete(sessionId, options = {}) {
outcome: completionError ? 'failed' : 'completed',
trackingEnabled: entry.taskTrackingEnabled === true,
});
- if (session && !completionError && !options.interrupted && !entry.userAborted) {
+ if (session && !goalActive && !completionError && !options.interrupted && !entry.userAborted) {
const toolEvidence = assistantToolCalls.length > 0
? truncateTextValue(JSON.stringify(assistantToolCalls.map((toolCall) => ({
name: toolCall?.name || '',
@@ -11619,6 +11952,7 @@ function handleCodexAppTurnComplete(sessionId, options = {}) {
responseLen: (entry.fullText || '').length,
toolCallCount: (entry.toolCalls || []).length,
error: rawError || null,
+ goalActive,
});
if (entry.ws) {
@@ -11626,11 +11960,16 @@ function handleCodexAppTurnComplete(sessionId, options = {}) {
entry.errorSent = true;
wsSend(entry.ws, { type: 'error', sessionId, message: completionError });
}
- wsSend(entry.ws, { type: 'done', sessionId, costUsd: null });
+ wsSend(entry.ws, { type: 'done', sessionId, costUsd: null, goalActive });
sendSessionList(entry.ws);
return;
}
+ if (goalActive) {
+ broadcastSessionList();
+ return;
+ }
+
if (wss && session) {
for (const client of wss.clients) {
if (client.readyState === 1) {