fix(cli-registry): codex launch defaults as registry data, ultra footer, schema doc defaults

- Codex footer model detection (c28): the modelDetect.screenLine effort
  alternation is now built from CODEX_REASONING_EFFORTS plus 'default', so
  'ultra' (offered by the codexReasoningEffort App Setting and codex's own
  /model picker) is read and the launch enum and the footer reader cannot
  drift again. Still one capture group, 125 characters, no new quantifier.
  New session-display-model case loops every effort level, ultra included.

- No CLI-id branching for launch defaults (c27): the two mode === 'codex'
  branches the synced codex model/effort defaults added to the create and
  quick-start routes are replaced by a registry capability,
  capabilities.launchDefaults (launch param -> settings key, values from a
  closed enum), declared on the codex entry only. The resolver moved from
  web/codex-launch-defaults.ts to web/launch-defaults.ts as
  applyLaunchDefaults(mode, configs, customEndpoint), filling the entry's
  legacyConfigField object through legacyConfigAliases, still re-validating
  with SettingsUpdateSchema and never overwriting a caller's value. The
  route exclusions are unchanged (create: not remote; quick-start: not
  remote, not Docker, not a custom model endpoint), and quick-start still
  derives the session model from a bag without ompConfig, as before.
  schema.ts refuses an undeclared param, an unknown settings key, an empty
  map, and launchDefaults on an entry with no legacyConfigField.

- The no-id-branching guard now carries an exact occurrence count per
  allowlisted key, so a new copy of an already approved expression fails
  instead of riding the old approval, with a synthetic anti-vacuity case.

- SettingsUpdateSchema JSDoc (c21/c29): 'classic' is the tabArrangement
  default and 'compact' the headerStatsStyle default, matching the
  resolvers and the pre-paint script; state/case/ledger are marked opt-in.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-09 09:35:56 +02:00
parent 82c87f56d1
commit ecd577157b
12 changed files with 456 additions and 135 deletions
+36 -1
View File
@@ -15,7 +15,7 @@
import { z } from 'zod';
import { compileVersionRegex, countCaptureGroups, TOKEN_PATTERNS } from './patterns.js';
import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js';
import type { McpConfigFormat, ModelConfigResolverName } from './types.js';
import type { LaunchDefaultSettingKey, McpConfigFormat, ModelConfigResolverName } from './types.js';
/** A bare CLI id: lowercase, starts with a letter, at most 24 chars. Also used as a CSS/URL token. */
const cliId = z
@@ -409,6 +409,17 @@ const capabilitiesSchema = z
'rejectWords has nothing to filter without a screenLine'
)
.optional(),
// Launch param -> synced App Settings key. The values are a closed enum, like
// configResolver: a clis.json override names one of the settings this build knows
// how to validate, never an arbitrary key. Params are checked against the declared
// ones in the superRefine below.
launchDefaults: z
.record(
z.string(),
z.enum(['codexModel', 'codexReasoningEffort'] as const satisfies readonly LaunchDefaultSettingKey[])
)
.refine((v) => Object.keys(v).length >= 1 && Object.keys(v).length <= 8, 'launchDefaults takes 1 to 8 params')
.optional(),
privilegedParams: z
.array(
z
@@ -627,6 +638,30 @@ export const CliEntrySchema = z
}
});
// Same silent-no-op class again: a launch default for a param the entry never declared
// would be filled into the config object and then read by nothing. And without a
// `legacyConfigField` the entry's params are read off the request body itself, where a
// filled `model` would be a different field (claude's per-session one), so refuse it.
const { launchDefaults } = entry.capabilities;
if (launchDefaults !== undefined) {
if (entry.launch.legacyConfigField === undefined) {
ctx.addIssue({
code: 'custom',
message: 'launchDefaults needs launch.legacyConfigField to fill',
path: ['capabilities', 'launchDefaults'],
});
}
for (const param of Object.keys(launchDefaults)) {
if (!declaredParams.has(param)) {
ctx.addIssue({
code: 'custom',
message: `launchDefaults param "${param}" is not a declared launch param`,
path: ['capabilities', 'launchDefaults', param],
});
}
}
}
const { setenvProfile } = entry.env;
if (setenvProfile !== undefined && !isKnownSetenvProfile(setenvProfile)) {
ctx.addIssue({
+10 -1
View File
@@ -671,6 +671,11 @@ const CODEX: CliEntry = {
// popup or a bare line of prose does not have that shape. A footer without an effort
// word (a model with no reasoning setting) is not read, and the session keeps its
// last known or launch model.
// The effort words are built from CODEX_REASONING_EFFORTS, the same list the
// `reasoningEffort` launch param above admits, plus `default` (what codex prints when
// no effort is configured). A hand-kept copy once left out `ultra`, so a session at
// that level never named its model. Every word is plain letters, so the join adds no
// quantifier and only a few characters to the 200-character compileVersionRegex cap.
// ⚠️ It is not always the LAST row. 0.162.0 (measured 2026-10-09) adds a hint row
// under it at rest, ` ← for agents · ? for shortcuts` or ` ? for shortcuts`, and
// drops it again while a prompt is being typed. With a one-row window the footer was
@@ -681,9 +686,13 @@ const CODEX: CliEntry = {
// and the `›` composer, and a forged footer-shaped transcript line is not followed by
// an indented row, so it is not read.
modelDetect: {
screenLine: String.raw`(?:^|\n) {2}([A-Za-z0-9][\w.:/@+-]{0,79}) (?:none|minimal|low|medium|high|xhigh|max|default) · [^\n]*(?:\n {2}[^\n]*)?$`,
screenLine: String.raw`(?:^|\n) {2}([A-Za-z0-9][\w.:/@+-]{0,79}) (?:${[...CODEX_REASONING_EFFORTS, 'default'].join('|')}) · [^\n]*(?:\n {2}[^\n]*)?$`,
screenLines: 2,
},
// App Settings → Codex model / reasoning effort (synced), filled into a LOCAL launch's
// codexConfig wherever the caller left the field unset. Launch-only: nothing writes
// codex's own config.toml. Read by applyLaunchDefaults() in src/web/launch-defaults.ts.
launchDefaults: { model: 'codexModel', reasoningEffort: 'codexReasoningEffort' },
// Two columns, like claude's, measured on a live 0.154.0 answer: the `•`/`›`/`⚠`
// markers sit in the gutter, prose continuations sit at 2, and a nested YAML block
// the model wrote rendered at 2/4/6/8 for its own 0/2/4/6. Replayed at 100, 120,
+22
View File
@@ -96,6 +96,14 @@ export type NewlineSequence = 'line-feed' | 'esc-enter';
/** The config readers `capabilities.modelDetect.configResolver` may name (src/model-config-resolvers.ts). */
export type ModelConfigResolverName = 'deepseek-route';
/**
* The synced App Settings keys `capabilities.launchDefaults` may name (src/web/launch-defaults.ts).
* A closed list rather than any settings key, so a clis.json override cannot feed an
* arbitrary setting onto a command line; each name must also be a `SettingsUpdateSchema`
* key, which the resolver's typing enforces.
*/
export type LaunchDefaultSettingKey = 'codexModel' | 'codexReasoningEffort';
/** The MCP config dialects `src/mcp-sync.ts` has an adapter for. */
export type McpConfigFormat = 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json' | 'antigravity-json';
@@ -529,6 +537,20 @@ export interface CliCapabilities {
rejectWords?: string[];
configResolver?: ModelConfigResolverName;
};
/**
* Synced App Settings that seed this CLI's launch params when the caller left them unset,
* keyed by LAUNCH PARAM name (`{ model: 'codexModel' }`), never the legacy wire name; the
* resolver translates through `launch.legacyConfigAliases` like every other `param`.
*
* Filled into the entry's `launch.legacyConfigField` object at create time by
* `applyLaunchDefaults()` (src/web/launch-defaults.ts), which re-validates each value
* with `SettingsUpdateSchema` and never overwrites a value the caller sent. Which
* launches get it is the CALLER's decision (local ones only: never remote, Docker or a
* custom model endpoint). `schema.ts` refuses an undeclared param, and an entry without
* a `legacyConfigField`, whose params would otherwise be read off the request body itself.
* Absent = no launch defaults.
*/
launchDefaults?: Record<string, LaunchDefaultSettingKey>;
/**
* Params a non-granted multi-user owner may not set freely, and what they are forced to.
* Data-driven so a CUSTOM CLI's bypass flag is clampable exactly like codex's.
-35
View File
@@ -1,35 +0,0 @@
/**
* @fileoverview Launch-time defaults for Codex sessions.
*
* Resolves the synced App Settings `codexModel` / `codexReasoningEffort` into the
* `codexConfig` a launch uses, filling ONLY the fields the caller left unset.
* Persisted values are re-validated with `SettingsUpdateSchema`, so a hand-edited
* settings.json can never smuggle an unchecked value onto the codex command line.
*
* Scope is the caller's decision: the create and quick-start routes apply it to
* local launches only, never to remote, Docker or custom-endpoint launches.
* Nothing here writes Codex's own config files.
*/
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<CodexConfig | undefined> {
if (customEndpoint) return config;
const settings = await readJsonConfig<Record<string, unknown>>(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,
};
}
+49
View File
@@ -0,0 +1,49 @@
/**
* @fileoverview Launch-time defaults from synced App Settings, driven by registry data.
*
* A CLI entry declares `capabilities.launchDefaults` (launch param -> settings key; today
* only codex, `{ model: 'codexModel', reasoningEffort: 'codexReasoningEffort' }`), and
* `applyLaunchDefaults()` fills those settings into the entry's `launch.legacyConfigField`
* object, setting ONLY the fields the caller left unset. Persisted values are re-validated
* with `SettingsUpdateSchema`, so a hand-edited settings.json can never smuggle an
* unchecked value onto the command line.
*
* Scope is the caller's decision: the create and quick-start routes apply it to local
* launches only, never to remote, Docker or custom-endpoint launches. Nothing here writes
* a CLI's own config files.
*/
import { getCli } from '../config/cli-registry/registry.js';
import { SettingsUpdateSchema } from './schemas.js';
import { readJsonConfig, SETTINGS_PATH } from './route-helpers.js';
/**
* Return `configs` with the launch defaults of `mode`'s registry entry filled into its
* legacy config object (e.g. `codexConfig`). Every other field of `configs` is passed
* through untouched, and `configs` itself comes back unchanged (same object) when the entry
* declares no defaults, `customEndpoint` is set, or no setting names a value.
*/
export async function applyLaunchDefaults<T extends object>(
mode: string,
configs: T,
customEndpoint = false
): Promise<T> {
const entry = getCli(mode);
const declared = entry?.capabilities.launchDefaults;
const field = entry?.launch.legacyConfigField;
if (customEndpoint || !declared || field === undefined) return configs;
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'CLI launch defaults', {});
const aliases = entry.launch.legacyConfigAliases ?? {};
const current = (configs as Record<string, unknown>)[field] as Record<string, unknown> | undefined;
const defaults: Record<string, unknown> = {};
for (const [param, settingKey] of Object.entries(declared)) {
const parsed = SettingsUpdateSchema.shape[settingKey].safeParse(settings[settingKey]);
// '' is the settings' "leave it to the CLI" value, the same as unset.
const value = parsed.success ? parsed.data || undefined : undefined;
const wireKey = aliases[param] ?? param;
if (value !== undefined && (current?.[wireKey] ?? undefined) === undefined) defaults[wireKey] = value;
}
if (Object.keys(defaults).length === 0) return configs;
return { ...configs, [field]: { ...current, ...defaults } };
}
+45 -36
View File
@@ -116,7 +116,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 { applyLaunchDefaults } from '../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';
@@ -1150,8 +1150,10 @@ 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;
// Synced App Settings launch defaults (capabilities.launchDefaults, codex's model and
// effort today) fill the CLI's own config object where the caller left it unset.
// Local launches only: a remote attach runs whatever the remote pane already runs.
const launchBody = remote ? body : await applyLaunchDefaults(mode, body);
// 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
@@ -1166,10 +1168,9 @@ export function registerSessionRoutes(
const modelSource = getCli(mode)?.capabilities.model;
const model =
modelSource?.source === 'flag'
? (legacyConfigForMode(mode, { ...body, codexConfig: launchCodexConfig } as unknown as Record<
string,
unknown
>)?.[modelSource.param ?? 'model'] as string | undefined)
? (legacyConfigForMode(mode, launchBody as unknown as Record<string, unknown>)?.[
modelSource.param ?? 'model'
] as string | undefined)
: modelSource?.source === 'claude-settings-file'
? body.model || modelConfig?.defaultModel || undefined
: undefined;
@@ -1186,12 +1187,12 @@ export function registerSessionRoutes(
deepSeekConfig: gatedDeepSeekConfig,
} = await _clampExternalCliBypassForOwner(
owner,
launchCodexConfig,
body.geminiConfig,
body.antigravityConfig,
body.piConfig,
body.grokConfig,
body.deepSeekConfig
launchBody.codexConfig,
launchBody.geminiConfig,
launchBody.antigravityConfig,
launchBody.piConfig,
launchBody.grokConfig,
launchBody.deepSeekConfig
);
const terminalHistoryConfig = await ctx.getTerminalHistoryConfig();
const session = new Session({
@@ -1204,14 +1205,14 @@ export function registerSessionRoutes(
model,
claudeMode: effectiveClaudeMode,
allowedTools: claudeModeConfig.allowedTools,
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
openCodeConfig: mode === 'opencode' ? launchBody.openCodeConfig : undefined,
codexConfig: mode === 'codex' ? gatedCodexConfig : undefined,
geminiConfig: mode === 'gemini' ? gatedGeminiConfig : undefined,
antigravityConfig: mode === 'antigravity' ? gatedAntigravityConfig : undefined,
piConfig: mode === 'pi' ? gatedPiConfig : undefined,
grokConfig: mode === 'grok' ? gatedGrokConfig : undefined,
deepSeekConfig: mode === 'deepseek' ? gatedDeepSeekConfig : undefined,
ompConfig: resolveOmpConfigForCreate(mode, workingDir, body.ompConfig),
ompConfig: resolveOmpConfigForCreate(mode, workingDir, launchBody.ompConfig),
resumeSessionId: validatedResumeId,
envOverrides: await clampEnvOverridesForOwner(owner, body.envOverrides),
effort: body.effort,
@@ -3875,23 +3876,31 @@ 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;
// Synced App Settings launch defaults, as on the create path: local launches only, so
// never a remote or Docker case, and never a custom model endpoint launch.
const qsRequestConfigs = {
openCodeConfig,
codexConfig,
geminiConfig,
antigravityConfig,
piConfig,
grokConfig,
deepSeekConfig,
ompConfig,
};
const qsLaunchConfigs =
remote || docker ? qsRequestConfigs : await applyLaunchDefaults(mode, qsRequestConfigs, !!customModel);
// ⚠️ The model is read from a bag WITHOUT ompConfig, as it always was here: quick-start
// has never taken omp's session model from ompConfig (the create path does). Kept as
// found rather than changed in passing.
const { ompConfig: qsLaunchOmpConfig, ...qsModelConfigs } = qsLaunchConfigs;
// 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: qsLaunchCodexConfig,
geminiConfig,
antigravityConfig,
piConfig,
grokConfig,
deepSeekConfig,
} as unknown as Record<string, unknown>)?.[qsModelSource.param ?? 'model'] as string | undefined)
? (legacyConfigForMode(mode, qsModelConfigs as unknown as Record<string, unknown>)?.[
qsModelSource.param ?? 'model'
] as string | undefined)
: qsModelSource?.source === 'claude-settings-file'
? qsModelConfig?.defaultModel || undefined
: undefined;
@@ -3907,16 +3916,16 @@ export function registerSessionRoutes(
deepSeekConfig: qsGatedDeepSeekConfig,
} = await _clampExternalCliBypassForOwner(
owner,
qsLaunchCodexConfig,
geminiConfig,
antigravityConfig,
piConfig,
grokConfig,
deepSeekConfig
qsLaunchConfigs.codexConfig,
qsLaunchConfigs.geminiConfig,
qsLaunchConfigs.antigravityConfig,
qsLaunchConfigs.piConfig,
qsLaunchConfigs.grokConfig,
qsLaunchConfigs.deepSeekConfig
);
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
const qsGatedEnvOverrides = await clampEnvOverridesForOwner(owner, envOverrides);
const qsResolvedOmpConfig = resolveOmpConfigForCreate(mode, resolvedCasePath, ompConfig);
const qsResolvedOmpConfig = resolveOmpConfigForCreate(mode, resolvedCasePath, qsLaunchOmpConfig);
// Custom Model Endpoint Profiles, applied AT CREATE TIME (docs/custom-model-endpoints-plan.md)
// rather than via the dedicated restart-in-place route (POST /api/sessions/:id/custom-
@@ -4073,7 +4082,7 @@ export function registerSessionRoutes(
claudeMode: qsEffectiveClaudeMode,
allowedTools: qsClaudeModeConfig.allowedTools,
owner,
openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
openCodeConfig: mode === 'opencode' ? qsLaunchConfigs.openCodeConfig : undefined,
codexConfig: mode === 'codex' ? qsGatedCodexConfig : undefined,
geminiConfig: mode === 'gemini' ? qsGatedGeminiConfig : undefined,
antigravityConfig: mode === 'antigravity' ? qsGatedAntigravityConfig : undefined,
+7 -6
View File
@@ -1406,14 +1406,14 @@ export const SettingsUpdateSchema = z
/**
* Tab layout, the arrangement of the tab list (Discussion #426). Display key
* (per-device).
* 'classic' = one flat list in tab order, as before. The default.
* 'state' = a row per state in the header strip (needs you, waiting,
* working, idle; option C), sections in the flat side rail and
* the sidebar. The default.
* the sidebar. Opt-in.
* 'case' = one cluster per case (option A): a labelled box in the strip,
* a section in the side rail and the sidebar.
* a section in the side rail and the sidebar. Opt-in.
* 'ledger' = the flat list on an aligned column grid with a status bar
* per cell (option B). Header strip on desktop only.
* 'classic' = one flat list in tab order, as before.
* per cell (option B). Header strip on desktop only. Opt-in.
*/
tabArrangement: z.enum(['state', 'case', 'ledger', 'classic']).optional(),
/**
@@ -1457,8 +1457,9 @@ export const SettingsUpdateSchema = z
* How the header draws its WS / CPU / MEM / plan-usage cluster. Display key
* (per-device), desktop only (the cluster is hidden below 768px).
* 'classic' = the bars and the 5H · 7D chip, as before
* 'compact' = two pills (WS/CPU/MEM, the plan windows), a ring beside every value
* 'tiles' = label over value with a bar underneath, no icons. The default.
* 'compact' = two pills (WS/CPU/MEM, the plan windows), a ring beside every value.
* The default.
* 'tiles' = label over value with a bar underneath, no icons
*/
headerStatsStyle: z.enum(['classic', 'compact', 'tiles']).optional(),
showTokenCount: z.boolean().optional(),