diff --git a/.changeset/codex-launch-defaults.md b/.changeset/codex-launch-defaults.md new file mode 100644 index 00000000..66f61efd --- /dev/null +++ b/.changeset/codex-launch-defaults.md @@ -0,0 +1,5 @@ +--- +'aicodeman': minor +--- + +Add synced Codex default model and reasoning effort controls to App Settings. Apply defaults to new local sessions (including WSL) while preserving explicit launch settings, custom endpoints, Docker and remote commands, and Codex configuration when defaults are empty. diff --git a/docs/wiki/Agent-CLIs.md b/docs/wiki/Agent-CLIs.md index 0b077926..09f0dee0 100644 --- a/docs/wiki/Agent-CLIs.md +++ b/docs/wiki/Agent-CLIs.md @@ -130,6 +130,14 @@ Integration detail: [`docs/opencode-integration.md`](https://github.com/Ark0N/Co ### Codex +App Settings has synced **Default Codex model** and **Default Codex reasoning effort** +controls. Enter a model ID supported by your Codex provider; available reasoning levels +depend on the model and CLI version. Empty defaults use Codex's own configuration. +The defaults apply when creating local sessions (including WSL), including HTTP API launches. +Explicit `codexConfig.model` / `codexConfig.reasoningEffort` values take precedence. +Custom model endpoints, Docker containers and remote host command overrides keep their own settings. +Changing a default affects new sessions and does not edit Codex configuration files. + Two behaviours that are deliberate and worth knowing: - **Predictive echo instead of buffered echo.** Codex's composer reacts to every keystroke, diff --git a/src/web/codex-launch-defaults.ts b/src/web/codex-launch-defaults.ts new file mode 100644 index 00000000..24f25b40 --- /dev/null +++ b/src/web/codex-launch-defaults.ts @@ -0,0 +1,22 @@ +import type { CodexConfig } from '../types.js'; +import { SettingsUpdateSchema } from './schemas.js'; +import { readJsonConfig, SETTINGS_PATH } from './route-helpers.js'; + +/** Resolve launch-only defaults without changing Codex's own configuration files. */ +export async function resolveCodexLaunchDefaults( + config: CodexConfig | undefined, + customEndpoint = false +): Promise { + if (customEndpoint) return config; + const settings = await readJsonConfig>(SETTINGS_PATH, 'Codex launch defaults', {}); + const model = SettingsUpdateSchema.shape.codexModel.safeParse(settings.codexModel); + const effort = SettingsUpdateSchema.shape.codexReasoningEffort.safeParse(settings.codexReasoningEffort); + const defaultModel = model.success ? model.data || undefined : undefined; + const defaultEffort = effort.success ? effort.data || undefined : undefined; + if (!defaultModel && !defaultEffort) return config; + return { + ...config, + model: config?.model ?? defaultModel, + reasoningEffort: config?.reasoningEffort ?? defaultEffort, + }; +} diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index c6a52d9e..1e0dd92a 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -45,6 +45,13 @@ // Exact English-source translations. Technical names, command examples, model // names, keyboard chords, and user-authored content intentionally stay unchanged. const ZH_CN = Object.freeze({ + 'Default Codex model': 'Codex 默认模型', + 'Default Codex reasoning effort': 'Codex 默认思考强度', + 'Use Codex configuration': '使用 Codex 配置', + 'Model ID for new local Codex sessions, including WSL. Leave empty to use Codex configuration.': + '新本地 Codex 会话(包括 WSL)使用的模型 ID。留空时使用 Codex 配置。', + 'Applies to new local sessions; supported levels depend on the model and Codex version. Custom endpoints, Docker and remote sessions keep their own settings.': + '应用于新本地会话;可用强度取决于模型和 Codex 版本。自定义端点、Docker 和远程会话保留自己的设置。', 'Skip to terminal': '跳转到终端', 'Go to main page': '返回主页', 'Session tabs': '会话标签页', diff --git a/src/web/public/index.html b/src/web/public/index.html index 9739c5a7..c5ba68fb 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2586,6 +2586,30 @@

Codex

synced
+
+
+ Default Codex model + Model ID for new local Codex sessions, including WSL. Leave empty to use Codex configuration. +
+ +
+
+
+ Default Codex reasoning effort + Applies to new local sessions; supported levels depend on the model and Codex version. Custom endpoints, Docker and remote sessions keep their own settings. +
+ +
Bypass approvals and sandbox diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 332cff77..bafe75b4 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -530,6 +530,8 @@ Object.assign(CodemanApp.prototype, { settings.codexDangerouslyBypassApprovals ?? false; document.getElementById('appSettingsCodexAnimations').checked = settings.codexAnimationsEnabled ?? false; + document.getElementById('appSettingsCodexModel').value = settings.codexModel ?? ''; + document.getElementById('appSettingsCodexReasoningEffort').value = settings.codexReasoningEffort ?? ''; this._applyCodexSettingsVisibility(); // Claude Permissions settings document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false; @@ -2584,6 +2586,8 @@ Object.assign(CodemanApp.prototype, { claudeMode: document.getElementById('appSettingsClaudeMode').value, allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(), // Codex CLI settings + codexModel: document.getElementById('appSettingsCodexModel').value.trim(), + codexReasoningEffort: document.getElementById('appSettingsCodexReasoningEffort').value, codexDangerouslyBypassApprovals: document.getElementById('appSettingsCodexDangerouslyBypassApprovals').checked, codexAnimationsEnabled: document.getElementById('appSettingsCodexAnimations').checked, // Claude Permissions settings diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 1292d00e..476b0cfd 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -115,6 +115,7 @@ import { clampEnvOverridesForOwner } from '../../session-env-clamp.js'; import { enabledClis, getCli } from '../../config/cli-registry/registry.js'; import type { NewlineSequence } from '../../config/cli-registry/types.js'; import { resolveCliLaunchError } from '../../utils/cli-launcher.js'; +import { resolveCodexLaunchDefaults } from '../codex-launch-defaults.js'; import { legacyConfigForMode } from '../../session-cli-registry-bridge.js'; import { isMultiUserMode } from '../../config/multiuser.js'; import { AUTH_COOKIE_NAME } from '../middleware/auth.js'; @@ -1117,6 +1118,8 @@ export function registerSessionRoutes( const globalNice = await ctx.getGlobalNiceConfig(); const modelConfig = await ctx.getModelConfig(); const mode = body.mode || 'claude'; + const launchCodexConfig = + mode === 'codex' && !remote ? await resolveCodexLaunchDefaults(body.codexConfig) : body.codexConfig; // Where a model override comes from is a capability, and the three answers are // genuinely different mechanisms: // 'flag' — the CLI takes --model, so read the value the caller sent @@ -1131,9 +1134,10 @@ export function registerSessionRoutes( const modelSource = getCli(mode)?.capabilities.model; const model = modelSource?.source === 'flag' - ? (legacyConfigForMode(mode, body as unknown as Record)?.[modelSource.param ?? 'model'] as - | string - | undefined) + ? (legacyConfigForMode(mode, { ...body, codexConfig: launchCodexConfig } as unknown as Record< + string, + unknown + >)?.[modelSource.param ?? 'model'] as string | undefined) : modelSource?.source === 'claude-settings-file' ? body.model || modelConfig?.defaultModel || undefined : undefined; @@ -1150,7 +1154,7 @@ export function registerSessionRoutes( deepSeekConfig: gatedDeepSeekConfig, } = await _clampExternalCliBypassForOwner( owner, - body.codexConfig, + launchCodexConfig, body.geminiConfig, body.antigravityConfig, body.piConfig, @@ -3851,13 +3855,17 @@ export function registerSessionRoutes( // Apply global Nice priority config and model config from settings const niceConfig = await ctx.getGlobalNiceConfig(); const qsModelConfig = await ctx.getModelConfig(); + const qsLaunchCodexConfig = + mode === 'codex' && !remote && !docker + ? await resolveCodexLaunchDefaults(codexConfig, !!customModel) + : codexConfig; // See the create path for why this is a capability rather than a mode ladder. const qsModelSource = getCli(mode)?.capabilities.model; const qsModel = qsModelSource?.source === 'flag' ? (legacyConfigForMode(mode, { openCodeConfig, - codexConfig, + codexConfig: qsLaunchCodexConfig, geminiConfig, antigravityConfig, piConfig, @@ -3879,7 +3887,7 @@ export function registerSessionRoutes( deepSeekConfig: qsGatedDeepSeekConfig, } = await _clampExternalCliBypassForOwner( owner, - codexConfig, + qsLaunchCodexConfig, geminiConfig, antigravityConfig, piConfig, diff --git a/src/web/schemas.ts b/src/web/schemas.ts index e8e59d5a..c1938cfb 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1484,6 +1484,12 @@ export const SettingsUpdateSchema = z claudeMode: z.string().max(50).optional(), allowedTools: z.string().max(2000).optional(), // Codex CLI settings + codexModel: z + .string() + .max(100) + .regex(/^[a-zA-Z0-9._\-/]*$/) + .optional(), + codexReasoningEffort: z.enum(['', ...CODEX_REASONING_EFFORTS]).optional(), codexDangerouslyBypassApprovals: z.boolean().optional(), codexAnimationsEnabled: z.boolean().optional(), // Terminal history and retention diff --git a/test/routes/session-routes-codex-defaults.test.ts b/test/routes/session-routes-codex-defaults.test.ts new file mode 100644 index 00000000..3b7fc479 --- /dev/null +++ b/test/routes/session-routes-codex-defaults.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdir, writeFile, rm } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { homedir } from 'node:os'; +import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; +import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; +import { registerSystemRoutes } from '../../src/web/routes/system-routes.js'; +import { CASES_DIR, SETTINGS_PATH } from '../../src/web/route-helpers.js'; +import { resolveCodexLaunchDefaults } from '../../src/web/codex-launch-defaults.js'; +import { buildCodexCommand } from '../../src/tmux-manager.js'; +import { Session } from '../../src/session.js'; +import { safeRmHomeTree } from '../mocks/index.js'; +import { getDataDir } from '../../src/config/instance.js'; + +vi.mock('../../src/utils/cli-launcher.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, resolveCliLaunchError: vi.fn().mockResolvedValue(null) }; +}); + +describe('Codex launch defaults', () => { + let harness: RouteTestHarness; + const workingDir = join(homedir(), 'codex-default-test'); + + beforeEach(async () => { + for (const name of ['docker-hosts.json', 'docker-cases.json', 'remote-hosts.json', 'remote-cases.json']) { + await rm(join(getDataDir(), name), { force: true }); + } + await mkdir(workingDir, { recursive: true }); + await mkdir(dirname(SETTINGS_PATH), { recursive: true }); + await writeFile(SETTINGS_PATH, JSON.stringify({ codexModel: 'gpt-6.1', codexReasoningEffort: 'high' })); + harness = await createRouteTestHarness(registerSessionRoutes); + vi.spyOn(Session.prototype, 'startInteractive').mockResolvedValue(undefined); + }); + + afterEach(async () => { + await harness.app.close(); + safeRmHomeTree(CASES_DIR); + safeRmHomeTree(workingDir); + vi.restoreAllMocks(); + }); + + async function createdConfig(url: string, overrides: Record = {}) { + const payload = + url === '/api/sessions' + ? { workingDir, mode: 'codex', ...overrides } + : { caseName: 'codex-default-test', mode: 'codex', ...overrides }; + const res = await harness.app.inject({ method: 'POST', url, payload }); + expect(res.statusCode).toBe(200); + expect(res.json().success, res.body).not.toBe(false); + const session = [...harness.ctx.sessions.values()].at(-1) as Session | undefined; + expect(session).toBeDefined(); + return session!.codexConfig; + } + + for (const url of ['/api/sessions', '/api/quick-start']) { + it(`applies persisted defaults at ${url} and emits the CLI options`, async () => { + const config = await createdConfig(url); + expect(config).toMatchObject({ model: 'gpt-6.1', reasoningEffort: 'high' }); + expect(buildCodexCommand(config)).toContain('--model gpt-6.1'); + expect(buildCodexCommand(config)).toContain('--config model_reasoning_effort=high'); + }); + + it(`preserves explicit model and effort at ${url}`, async () => { + expect(await createdConfig(url, { codexConfig: { model: 'gpt-other', reasoningEffort: 'low' } })).toMatchObject({ + model: 'gpt-other', + reasoningEffort: 'low', + }); + }); + } + + it('leaves empty defaults to Codex and ignores malformed persisted values', async () => { + for (const settings of [ + { codexModel: '', codexReasoningEffort: '' }, + { codexModel: 'bad;command', codexReasoningEffort: 'invalid' }, + ]) { + await writeFile(SETTINGS_PATH, JSON.stringify(settings)); + expect(await resolveCodexLaunchDefaults(undefined)).toBeUndefined(); + expect(buildCodexCommand(await resolveCodexLaunchDefaults(undefined))).toBe('codex'); + } + }); + + it('keeps defaults out of custom endpoint launches', async () => { + const config = { model: 'local-model', animations: false }; + expect(await resolveCodexLaunchDefaults(config, true)).toBe(config); + }); + + it('does not record unused defaults for a Docker quick-start', async () => { + await writeFile( + join(getDataDir(), 'docker-hosts.json'), + JSON.stringify([{ id: 'docker1', label: 'Docker', image: 'codeman/agent:base' }]) + ); + await writeFile( + join(getDataDir(), 'docker-cases.json'), + JSON.stringify([ + { + name: 'codex-default-test', + type: 'docker', + hostId: 'docker1', + hostWorkspacePath: workingDir, + container: 'codeman-default-test', + }, + ]) + ); + expect(await createdConfig('/api/quick-start')).toBeUndefined(); + const session = [...harness.ctx.sessions.values()].at(-1) as Session; + expect(session.docker?.containerName).toBe('codeman-default-test'); + expect(session.toState().model).toBeUndefined(); + }); + + it('does not record unused defaults for a remote attach', async () => { + await writeFile( + join(getDataDir(), 'remote-hosts.json'), + JSON.stringify([{ id: 'remote1', label: 'Remote', host: '10.0.0.5', username: 'dev' }]) + ); + expect( + await createdConfig('/api/sessions', { + workingDir: undefined, + attachRemoteSession: { hostId: 'remote1', remoteSessionName: 'codeman-existing' }, + }) + ).toBeUndefined(); + const session = [...harness.ctx.sessions.values()].at(-1) as Session; + expect(session.toState().remote?.hostId).toBe('remote1'); + expect(session.toState().model).toBeUndefined(); + }); + + it('does not record unused defaults for a remote quick-start', async () => { + await writeFile( + join(getDataDir(), 'remote-hosts.json'), + JSON.stringify([{ id: 'remote1', label: 'Remote', host: '10.0.0.5', username: 'dev' }]) + ); + await writeFile( + join(getDataDir(), 'remote-cases.json'), + JSON.stringify([{ name: 'codex-default-test', type: 'remote', hostId: 'remote1', remotePath: '/home/dev/work' }]) + ); + expect(await createdConfig('/api/quick-start')).toBeUndefined(); + const session = [...harness.ctx.sessions.values()].at(-1) as Session; + expect(session.toState().remote?.hostId).toBe('remote1'); + expect(session.toState().model).toBeUndefined(); + }); + + it('accepts, persists, clears and validates synced settings via HTTP', async () => { + const system = await createRouteTestHarness(registerSystemRoutes); + try { + const put = (payload: unknown) => system.app.inject({ method: 'PUT', url: '/api/settings', payload }); + expect((await put({ codexModel: 'gpt-6.1', codexReasoningEffort: 'xhigh' })).statusCode).toBe(200); + const read = await system.app.inject({ method: 'GET', url: '/api/settings' }); + expect(read.json()).toMatchObject({ codexModel: 'gpt-6.1', codexReasoningEffort: 'xhigh' }); + expect((await put({ codexReasoningEffort: 'bogus' })).statusCode).toBe(400); + expect((await put({ codexModel: 'bad;command' })).statusCode).toBe(400); + expect((await put({ codexModel: '', codexReasoningEffort: '' })).statusCode).toBe(200); + expect(await resolveCodexLaunchDefaults(undefined)).toBeUndefined(); + } finally { + await system.app.close(); + } + }); +});