diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 848ae8f1..936be89a 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -37,7 +37,7 @@ Pure helpers unit-tested in `test/base-path.test.ts` + `test/webview-proxy.test. ### External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP) -**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok' || 'deepseek'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). ⚠️ **Work detection left this gate in #385** and is now per-CLI `capabilities.workDetect` data (`promptGlyph` + `workingLine`), because gating it on the mode left every Codex session reporting `idle` for its entire life; a CLI declaring neither falls back to Claude's pair, which is logic-identical to the pre-registry behaviour. All seven modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume `, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode ` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP export `COLORTERM=truecolor` and unset `NO_COLOR`; `opencode` unsets `COLORTERM`. **Terminal colour env** under Session launch modes covers Claude and says which panes those declarations actually reach. Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation ` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume ` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fullscreen alt-screen TUI with mouse support — the opencode case, not the Ink case). Env allowlist: `GROK_*` plus the vendor namespace `XAI_*` (`XAI_API_KEY` is grok's documented headless auth var — the same narrow-vendor-namespace reasoning as `GOOGLE_*` for gemini). Docker cred seeding is per-file (`auth.json`, `config.toml`, `pager.toml` from `~/.grok` — the dir also holds `sessions/`, `memory/`, and the ~160MB binary under `downloads/`). Grok tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`. +**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok' || 'deepseek'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). ⚠️ **Work detection left this gate in #385** and is now per-CLI `capabilities.workDetect` data (`promptGlyph` + `workingLine`), because gating it on the mode left every Codex session reporting `idle` for its entire life; a CLI declaring neither falls back to Claude's pair, which is logic-identical to the pre-registry behaviour. All seven modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `--config model_reasoning_effort=` from `reasoningEffort`, `--config tui.animations=` from `animations`, `resume `, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode ` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP export `COLORTERM=truecolor` and unset `NO_COLOR`; `opencode` unsets `COLORTERM`. **Terminal colour env** under Session launch modes covers Claude and says which panes those declarations actually reach. Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation ` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume ` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fullscreen alt-screen TUI with mouse support — the opencode case, not the Ink case). Env allowlist: `GROK_*` plus the vendor namespace `XAI_*` (`XAI_API_KEY` is grok's documented headless auth var — the same narrow-vendor-namespace reasoning as `GOOGLE_*` for gemini). Docker cred seeding is per-file (`auth.json`, `config.toml`, `pager.toml` from `~/.grok` — the dir also holds `sessions/`, `memory/`, and the ~160MB binary under `downloads/`). Grok tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`. **DeepSeek Harness (`dsh`) specifics** — the mode that breaks three of the assumptions the six above share, so read this before changing anything about it. diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index 465da26a..87651161 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -11,6 +11,7 @@ */ import type { CliEntry } from './types.js'; +import { CODEX_REASONING_EFFORTS } from '../../types/session.js'; const HOME_DIRS = { local: '~/.local/bin', @@ -532,6 +533,7 @@ const CODEX: CliEntry = { bypassApprovals: { type: 'bool' }, animations: { type: 'bool' }, model: { type: 'token', pattern: 'model' }, + reasoningEffort: { type: 'enum', values: [...CODEX_REASONING_EFFORTS] }, resumeId: { type: 'token', pattern: 'id' }, }, variants: [ @@ -543,6 +545,14 @@ const CODEX: CliEntry = { { flag: '--config', value: 'tui.animations=true', when: { param: 'animations', is: true } }, { flag: '--config', value: 'tui.animations=false', when: { param: 'animations', is: false } }, { flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } }, + // One literal per level: an argv token cannot splice a value into a literal, and + // `model_reasoning_effort=` is a single `--config` value. The enum above is + // what admits a level, so an unknown one emits nothing. + ...CODEX_REASONING_EFFORTS.map((level) => ({ + flag: '--config', + value: `model_reasoning_effort=${level}`, + when: { param: 'reasoningEffort', is: level }, + })), { lit: 'resume', when: { param: 'resumeId', state: 'set' } }, { valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } }, ], diff --git a/src/types/session.ts b/src/types/session.ts index 49249aff..1b1ae9b7 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -377,6 +377,16 @@ export function isEffortLevel(value: string | undefined): value is EffortLevel { return value !== undefined && (EFFORT_LEVELS as readonly string[]).includes(value); } +/** + * Reasoning effort levels codex accepts as `model_reasoning_effort` (codex-cli 0.154.0). + * Which of them a given model honours is codex's business; Codeman only keeps the value + * to a known word, since it lands in the launch argv. + */ +export const CODEX_REASONING_EFFORTS = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'] as const; + +/** Codex reasoning effort for a session, passed as `--config model_reasoning_effort=` */ +export type CodexReasoningEffort = (typeof CODEX_REASONING_EFFORTS)[number]; + /** OpenCode session configuration */ export interface OpenCodeConfig { /** Model identifier (e.g., "anthropic/claude-sonnet-4-5", "openai/gpt-5.2", "ollama/codellama") */ @@ -398,6 +408,8 @@ export type CodexRenderMode = 'hybrid'; export interface CodexConfig { /** Model identifier (e.g., "gpt-5", "o4-mini"). Passed via --model. */ model?: string; + /** Reasoning effort for this session. Passed via --config model_reasoning_effort=. */ + reasoningEffort?: CodexReasoningEffort; /** Resume a previous codex conversation by session id (passed via --resume) */ resumeSessionId?: string; /** Bypass approval prompts (passes --dangerously-bypass-approvals-and-sandbox) */ diff --git a/src/web/schemas.ts b/src/web/schemas.ts index c12a83a9..e73d7e85 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -18,6 +18,7 @@ import { MIN_TERMINAL_SCROLLBACK_LINES, } from '../config/terminal-history.js'; import { MAX_EDITABLE_BYTES } from '../config/file-editing.js'; +import { CODEX_REASONING_EFFORTS } from '../types/session.js'; import { MIN_MATCH_LENGTH, MAX_MATCH_LENGTH } from '../config/agent-wait.js'; import { MAX_WAKE_MACS } from '../config/remote-wake-limits.js'; import { MAX_INPUT_LENGTH } from '../config/terminal-limits.js'; @@ -298,6 +299,7 @@ const CodexConfigSchema = z .max(100) .regex(/^[a-zA-Z0-9._\-/]+$/) .optional(), + reasoningEffort: z.enum(CODEX_REASONING_EFFORTS).optional(), resumeSessionId: z .string() .max(100) diff --git a/test/cli-registry-spawn-golden.test.ts b/test/cli-registry-spawn-golden.test.ts index 72efee78..3883018c 100644 --- a/test/cli-registry-spawn-golden.test.ts +++ b/test/cli-registry-spawn-golden.test.ts @@ -24,6 +24,7 @@ import { describe, it, expect } from 'vitest'; import { getCli } from '../src/config/cli-registry/registry.js'; import { buildSpawnCommandFromRegistry, type SpawnBridgeOptions } from '../src/session-cli-registry-bridge.js'; +import { CODEX_REASONING_EFFORTS } from '../src/types/session.js'; /** A fixed session id, so `--session-id` is stable across runs. */ const SID = '0f9c2b14-1111-2222-3333-444455556666'; @@ -131,6 +132,18 @@ describe('codex', () => { it('resumes with a POSITIONAL subcommand, not a flag', () => { expect(cx({ model: 'gpt-5', resumeSessionId: 'roll_42' })).toBe('codex --model gpt-5 resume roll_42'); }); + + it('sends reasoning effort as one model_reasoning_effort config value, for every level', () => { + for (const level of CODEX_REASONING_EFFORTS) { + expect(cx({ reasoningEffort: level })).toBe(`codex --config model_reasoning_effort=${level}`); + } + }); + + it('keeps reasoning effort ahead of the resume subcommand', () => { + expect(cx({ model: 'gpt-5', reasoningEffort: 'high', resumeSessionId: 'roll_42' })).toBe( + 'codex --model gpt-5 --config model_reasoning_effort=high resume roll_42' + ); + }); }); describe('gemini', () => { diff --git a/test/codex-reasoning-effort-schema.test.ts b/test/codex-reasoning-effort-schema.test.ts new file mode 100644 index 00000000..d018a485 --- /dev/null +++ b/test/codex-reasoning-effort-schema.test.ts @@ -0,0 +1,41 @@ +/** + * @fileoverview `codexConfig.reasoningEffort` on the create routes. + * + * The level becomes part of a `--config model_reasoning_effort=` launch token, so the + * schema admits only the words codex knows; anything else fails the request rather than + * reaching the argv. + */ + +import { describe, it, expect } from 'vitest'; +import { CreateSessionSchema, QuickStartSchema } from '../src/web/schemas.js'; +import { CODEX_REASONING_EFFORTS } from '../src/types/session.js'; + +describe('codexConfig.reasoningEffort', () => { + it('accepts every level codex knows on both create routes', () => { + for (const level of CODEX_REASONING_EFFORTS) { + const created = CreateSessionSchema.parse({ + workingDir: '/tmp', + mode: 'codex', + codexConfig: { reasoningEffort: level }, + }); + expect(created.codexConfig?.reasoningEffort).toBe(level); + const quick = QuickStartSchema.parse({ + caseName: 'work', + mode: 'codex', + codexConfig: { reasoningEffort: level }, + }); + expect(quick.codexConfig?.reasoningEffort).toBe(level); + } + }); + + it('rejects a level codex does not know, and anything shaped like shell, on both create routes', () => { + for (const reasoningEffort of ['bogus', 'HIGH', 'high; rm -rf /', '']) { + expect(() => + CreateSessionSchema.parse({ workingDir: '/tmp', mode: 'codex', codexConfig: { reasoningEffort } }) + ).toThrow(); + expect(() => + QuickStartSchema.parse({ caseName: 'work', mode: 'codex', codexConfig: { reasoningEffort } }) + ).toThrow(); + } + }); +}); diff --git a/test/routes/external-cli-bypass-clamp.test.ts b/test/routes/external-cli-bypass-clamp.test.ts index 226f7fda..22cf06ec 100644 --- a/test/routes/external-cli-bypass-clamp.test.ts +++ b/test/routes/external-cli-bypass-clamp.test.ts @@ -104,6 +104,20 @@ describe('clampExternalCliBypassForOwner — multi-user mode', () => { expect(out.grokConfig).toEqual({ alwaysApprove: false, model: 'grok-4.5' }); }); + it("keeps a non-granted owner's codex reasoning effort while forcing bypass off", async () => { + // The clamp rewrites one field and must carry the rest; a clamp rebuilt from named + // fields would drop the effort here without a word. + const out = await _clampExternalCliBypassForOwner( + 'peon', + { dangerouslyBypassApprovals: true, reasoningEffort: 'xhigh' }, + undefined, + undefined, + undefined, + undefined + ); + expect(out.codexConfig).toEqual({ dangerouslyBypassApprovals: false, reasoningEffort: 'xhigh' }); + }); + it('leaves codex/antigravity/grok absent when nothing was sent (they already spawn safe)', async () => { const out = await _clampExternalCliBypassForOwner('peon', undefined, undefined, undefined, undefined, undefined); expect(out.codexConfig).toBeUndefined();