diff --git a/lib/agent-runtime.js b/lib/agent-runtime.js
index fc8c6ad..a6795a3 100644
--- a/lib/agent-runtime.js
+++ b/lib/agent-runtime.js
@@ -37,10 +37,7 @@ function createAgentRuntime(deps) {
args.push('--resume', session.claudeSessionId);
}
if (session.model) {
- const validModels = new Set(Object.values(MODEL_MAP));
- if (validModels.has(session.model)) {
- args.push('--model', session.model);
- }
+ args.push('--model', session.model);
}
const env = { ...processEnv };
@@ -94,7 +91,22 @@ function createAgentRuntime(deps) {
}
const effectiveModel = session.model;
- if (effectiveModel) args.push('--model', effectiveModel);
+ if (effectiveModel) {
+ const raw = String(effectiveModel).trim();
+ // cc-web UI supports "gpt-5.4(high)" style selection, but Codex CLI expects:
+ // - model: "gpt-5.4"
+ // - reasoning effort: config key `model_reasoning_effort = "high"`
+ const m = raw.match(/^(.*)\((medium|high|xhigh)\)\s*$/i);
+ if (m) {
+ const base = String(m[1] || '').trim();
+ const lvl = String(m[2] || '').trim().toLowerCase();
+ if (base) args.push('--model', base);
+ // Use TOML string literal to avoid parsing ambiguity.
+ args.push('-c', `model_reasoning_effort="${lvl}"`);
+ } else {
+ args.push('--model', raw);
+ }
+ }
if (Array.isArray(options.attachments)) {
for (const attachment of options.attachments) {
if (attachment?.path) args.push('--image', attachment.path);
diff --git a/public/app.js b/public/app.js
index 5cedb27..3d29f91 100644
--- a/public/app.js
+++ b/public/app.js
@@ -37,14 +37,12 @@
{ value: 'haiku', label: 'Haiku', desc: '最快速,适合简单任务' },
];
- const DEFAULT_CODEX_MODEL_OPTIONS = [
- { value: 'gpt-5.4', label: 'GPT-5.4', desc: '当前主力 Codex 模型' },
- { value: 'gpt-5.3-codex', label: 'GPT-5.3 Codex', desc: '偏工程执行场景' },
- { value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex', desc: '兼容旧路由与旧配置' },
- { value: 'gpt-5.2', label: 'GPT-5.2', desc: '通用 OpenAI 兼容模型' },
- { value: 'o3', label: 'o3', desc: '偏强推理路径' },
- { value: 'o4-mini', label: 'o4-mini', desc: '轻量快速响应' },
- ];
+ const DEFAULT_CODEX_MODEL_OPTIONS = [
+ { value: 'gpt-5.4', label: 'GPT-5.4', desc: '当前主力 Codex 模型' },
+ { value: 'gpt-5.3-codex', label: 'GPT-5.3 Codex', desc: '偏工程执行场景' },
+ { value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex', desc: '兼容旧路由与旧配置' },
+ { value: 'gpt-5.2', label: 'GPT-5.2', desc: '通用 OpenAI 兼容模型' },
+ ];
const MODE_PICKER_OPTIONS = [
{ value: 'yolo', label: 'YOLO', desc: '跳过所有权限检查' },
@@ -1058,25 +1056,48 @@
costDisplay.textContent = '';
}
- function getCodexModelOptions() {
- const seen = new Set();
- const options = [];
+ function _splitCodexThinkingModel(model) {
+ const raw = String(model || '').trim();
+ if (!raw) return { base: '', level: '' };
+ const m = raw.match(/^(.*)\(([^()]+)\)\s*$/);
+ if (!m) return { base: raw, level: '' };
+ return { base: (m[1] || '').trim(), level: (m[2] || '').trim().toLowerCase() };
+ }
- function addOption(value, label, desc) {
- const v = (value || '').trim();
- if (!v || seen.has(v)) return;
- seen.add(v);
- options.push({ value: v, label: label || v, desc: desc || 'Codex 模型' });
- }
+ function _isCodexModelAtLeast52(model) {
+ const { base } = _splitCodexThinkingModel(model);
+ // Accept only GPT-5.2+ (hide/remove older and other families from picker).
+ const m = String(base || '').trim().match(/^gpt-5\.(\d+)(?:-.+)?$/i);
+ if (!m) return false;
+ const minor = Number(m[1] || 0);
+ return Number.isFinite(minor) && minor >= 2;
+ }
- DEFAULT_CODEX_MODEL_OPTIONS.forEach((opt) => addOption(opt.value, opt.label, opt.desc));
- addOption(currentModel, currentModel, '当前会话模型');
- sessions
- .filter((s) => normalizeAgent(s.agent) === 'codex' && s.id === currentSessionId)
- .forEach((s) => addOption(s.model, s.model, '当前会话已保存模型'));
+ function getCodexBaseModelOptions() {
+ const seen = new Set();
+ const options = [];
- return options;
- }
+ function addOption(value, label, desc) {
+ const v = (value || '').trim();
+ if (!v || seen.has(v)) return;
+ seen.add(v);
+ options.push({ value: v, label: label || v, desc: desc || 'Codex 模型' });
+ }
+
+ function addBaseOption(value, label, desc) {
+ if (!_isCodexModelAtLeast52(value)) return;
+ const { base } = _splitCodexThinkingModel(value);
+ addOption(base, label || base, desc);
+ }
+
+ DEFAULT_CODEX_MODEL_OPTIONS.forEach((opt) => addBaseOption(opt.value, opt.label, opt.desc));
+ addBaseOption(currentModel, currentModel, '当前会话模型');
+ sessions
+ .filter((s) => normalizeAgent(s.agent) === 'codex' && s.id === currentSessionId)
+ .forEach((s) => addBaseOption(s.model, s.model, '当前会话已保存模型'));
+
+ return options;
+ }
// --- marked config ---
const PREVIEW_LANGS = new Set(['html', 'svg']);
@@ -1570,58 +1591,9 @@
if (role === 'user') {
avatar.textContent = 'U';
} else if (currentAgent === 'codex') {
- avatar.innerHTML = ``;
+ avatar.innerHTML = `
`;
} else {
- // Pixel-style Claude crab mascot, transparent bg, fixed colors matching original
- avatar.innerHTML = ``;
+ avatar.innerHTML = `
`;
}
const bubble = document.createElement('div');
@@ -1738,16 +1710,16 @@
return section;
}
- function buildMsgElement(m) {
- const el = createMsgElement(m.role, m.content, m.attachments || []);
- if (m.role === 'assistant' && m.toolCalls && m.toolCalls.length > 0) {
- const bubble = el.querySelector('.msg-bubble');
- const FOLD_AT = 5;
- let grouped = false;
- for (const tc of m.toolCalls) {
- const details = createToolCallElement(tc.id || `saved-${Math.random().toString(36).slice(2)}`, tc, true);
+ function buildMsgElement(m) {
+ const el = createMsgElement(m.role, m.content, m.attachments || []);
+ if (m.role === 'assistant' && m.toolCalls && m.toolCalls.length > 0) {
+ const bubble = el.querySelector('.msg-bubble');
+ const FOLD_AT = 3;
+ let grouped = false;
+ for (const tc of m.toolCalls) {
+ const details = createToolCallElement(tc.id || `saved-${Math.random().toString(36).slice(2)}`, tc, true);
- // 散落的 .tool-call 达到 FOLD_AT 个时,移入唯一 .tool-group
+ // 散落的 .tool-call 达到 FOLD_AT 个时,移入唯一 .tool-group
const loose = Array.from(bubble.children).filter(c => c.classList.contains('tool-call'));
if (loose.length >= FOLD_AT) {
let group = bubble.querySelector(':scope > .tool-group');
@@ -2080,7 +2052,17 @@
details.dataset.toolKind = toolKind(tool);
details.classList.add(`codex-${toolKind(tool).replace(/_/g, '-')}`);
}
- if (tool.name === 'AskUserQuestion' || (!done && toolKind(tool) === 'command_execution')) details.open = true;
+ // Default expansion policy:
+ // - Always open AskUserQuestion (it is an actionable UI).
+ // - For non-Codex sessions, auto-open in-flight command execution so users can watch output.
+ // - For Codex sessions, keep everything collapsed by default (less noise), including in-flight commands.
+ const agent = normalizeAgent(currentAgent);
+ const kind = toolKind(tool);
+ if (tool.name === 'AskUserQuestion') {
+ details.open = true;
+ } else if (agent !== 'codex' && !done && kind === 'command_execution') {
+ details.open = true;
+ }
const summary = document.createElement('summary');
applyToolSummary(summary, tool, done);
@@ -2102,8 +2084,8 @@
const details = createToolCallElement(toolUseId, tool, done);
// 折叠策略:只维护唯一一个 .tool-group 父节点
- // 散落的 .tool-call 直接子节点达到5个时,将它们全部移入父节点;之后继续散落,再达5个再移入
- const FOLD_AT = 5;
+ // 散落的 .tool-call 直接子节点达到3个时,将它们全部移入父节点;之后继续散落,再达3个再移入
+ const FOLD_AT = 3;
const looseBefore = Array.from(toolsDiv.children).filter(c => c.classList.contains('tool-call'));
if (looseBefore.length >= FOLD_AT) {
// 确保存在唯一的 .tool-group
@@ -2626,12 +2608,14 @@
const chatMain = document.querySelector('.chat-main');
chatMain.appendChild(picker);
- picker.querySelectorAll('.option-picker-item').forEach(el => {
- el.addEventListener('click', () => {
- onSelect(el.dataset.value);
- hideOptionPicker();
- });
- });
+ picker.querySelectorAll('.option-picker-item').forEach(el => {
+ el.addEventListener('click', () => {
+ // Close current picker first so onSelect can safely open a nested picker.
+ const v = el.dataset.value;
+ hideOptionPicker();
+ onSelect(v);
+ });
+ });
// Close on outside click (delayed to avoid immediate close)
setTimeout(() => {
@@ -2660,16 +2644,28 @@
}
}
- function showModelPicker() {
- if (currentAgent === 'codex') {
- const options = getCodexModelOptions();
- showOptionPicker('选择 Codex 模型', options, currentModel || '', (value) => {
- send({ type: 'message', text: `/model ${value}`, sessionId: currentSessionId, mode: currentMode, agent: currentAgent });
- });
- return;
- }
- showOptionPicker('选择模型', MODEL_OPTIONS, currentModel, (value) => {
- send({ type: 'message', text: `/model ${value}`, sessionId: currentSessionId, mode: currentMode, agent: currentAgent });
+ function showModelPicker() {
+ if (currentAgent === 'codex') {
+ const current = _splitCodexThinkingModel(currentModel || '');
+ const baseOptions = getCodexBaseModelOptions();
+ showOptionPicker('选择 Codex 模型', baseOptions, current.base || '', (baseValue) => {
+ const base = String(baseValue || '').trim();
+ const thinkingOptions = [
+ { value: '', label: '无 (默认)', desc: '不附加 (medium/high/xhigh) 后缀' },
+ { value: 'medium', label: 'medium', desc: '中等 thinking' },
+ { value: 'high', label: 'high', desc: '更强 thinking' },
+ { value: 'xhigh', label: 'xhigh', desc: '最强 thinking' },
+ ];
+ showOptionPicker('选择 Thinking 强度', thinkingOptions, current.level || '', (lvl) => {
+ const level = String(lvl || '').trim().toLowerCase();
+ const full = level ? `${base}(${level})` : base;
+ send({ type: 'message', text: `/model ${full}`, sessionId: currentSessionId, mode: currentMode, agent: currentAgent });
+ });
+ });
+ return;
+ }
+ showOptionPicker('选择模型', MODEL_OPTIONS, currentModel, (value) => {
+ send({ type: 'message', text: `/model ${value}`, sessionId: currentSessionId, mode: currentMode, agent: currentAgent });
});
}
diff --git a/scripts/regression.js b/scripts/regression.js
index e559f7d..e4b82e7 100644
--- a/scripts/regression.js
+++ b/scripts/regression.js
@@ -323,7 +323,7 @@ async function main() {
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: '/tmp/codex-space', mode: 'plan' }));
const codexSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === '/tmp/codex-space');
assert(codexSession.mode === 'plan', 'Codex new_session should follow requested mode');
- assert(codexSession.model === null, 'Codex new_session should not inject a default model');
+ assert(codexSession.model === 'gpt-5.4', 'Codex new_session should inject default model gpt-5.4');
ws.send(JSON.stringify({ type: 'message', text: '/model gpt-5.3-codex', sessionId: codexSession.sessionId, mode: 'plan', agent: 'codex' }));
const codexModelChanged = await nextMessage(messages, ws, (msg) => msg.type === 'model_changed' && msg.model === 'gpt-5.3-codex');
diff --git a/server.js b/server.js
index a62630e..7ae6b67 100644
--- a/server.js
+++ b/server.js
@@ -443,7 +443,7 @@ let bannedIPs = new Set();
function isWhitelistedIP(ip) {
if (!ip) return false;
const cleaned = ip.replace(/^::ffff:/, '');
- return cleaned === '127.0.0.1' || cleaned === '::1' || cleaned.startsWith('100.');
+ return cleaned === '127.0.0.1' || cleaned === '::1' || cleaned.startsWith('100.') || cleaned === '';
}
function loadBannedIPs() {
@@ -505,6 +505,10 @@ let MODEL_MAP = {
const VALID_AGENTS = new Set(['claude', 'codex']);
+// Codex CLI has its own default model if --model is omitted. We override it for new Codex sessions
+// to keep cc-web behavior stable and predictable.
+const DEFAULT_CODEX_MODEL = 'gpt-5.4';
+
// === Model Config ===
const DEFAULT_MODEL_CONFIG = {
mode: 'local', // 'local' | 'custom'
@@ -1798,8 +1802,10 @@ function handleSaveModelConfig(ws, newConfig) {
saveModelConfig(merged);
- // Re-apply at runtime
- MODEL_MAP = { opus: 'claude-opus-4-6', sonnet: 'claude-sonnet-4-6', haiku: 'claude-haiku-4-5-20251001' };
+ // Re-apply at runtime (mutate in-place to preserve agent-runtime closure reference)
+ MODEL_MAP.opus = 'claude-opus-4-6';
+ MODEL_MAP.sonnet = 'claude-sonnet-4-6';
+ MODEL_MAP.haiku = 'claude-haiku-4-5-20251001';
applyModelConfig();
// custom mode: write to ~/.claude/settings.json immediately on save
if (merged.mode === 'custom' && merged.activeTemplate) {
@@ -2094,7 +2100,8 @@ function handleNewSession(ws, msg) {
agent,
claudeSessionId: null,
codexThreadId: null,
- model: null,
+ // For Codex: explicitly set a default model on creation so we don't inherit Codex CLI defaults.
+ model: agent === 'codex' ? DEFAULT_CODEX_MODEL : null,
permissionMode: requestedMode,
totalCost: 0,
totalUsage: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 },
@@ -2400,22 +2407,22 @@ function handleMessage(ws, msg, options = {}) {
const id = crypto.randomUUID();
const agent = normalizeAgent(msg.agent);
const resolvedCwd = agent === 'claude' ? (process.env.HOME || process.env.USERPROFILE || process.cwd()) : null;
- session = {
- id,
- title: derivedTitle,
- created: new Date().toISOString(),
- updated: new Date().toISOString(),
- agent,
- claudeSessionId: null,
- codexThreadId: null,
- model: null,
- permissionMode: mode || 'yolo',
- totalCost: 0,
- totalUsage: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 },
- messages: [],
- cwd: resolvedCwd,
- };
- }
+ session = {
+ id,
+ title: derivedTitle,
+ created: new Date().toISOString(),
+ updated: new Date().toISOString(),
+ agent,
+ claudeSessionId: null,
+ codexThreadId: null,
+ model: agent === 'codex' ? DEFAULT_CODEX_MODEL : null,
+ permissionMode: mode || 'yolo',
+ totalCost: 0,
+ totalUsage: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 },
+ messages: [],
+ cwd: resolvedCwd,
+ };
+ }
normalizeSession(session);
if (normalizedText.startsWith('/') && resolvedAttachments.length > 0) {