Merge pull request #114 from aakhter/pr/cod-34-codex

feat(codex): add Codex (OpenAI CLI) run-mode foundation
This commit is contained in:
Ark0N
2026-06-10 16:22:52 +02:00
committed by GitHub
18 changed files with 555 additions and 54 deletions
+3
View File
@@ -14,6 +14,7 @@ import type {
ClaudeMode,
SessionMode,
OpenCodeConfig,
CodexConfig,
EffortLevel,
} from './types.js';
@@ -62,6 +63,7 @@ export interface CreateSessionOptions {
claudeMode?: ClaudeMode;
allowedTools?: string;
openCodeConfig?: OpenCodeConfig;
codexConfig?: CodexConfig;
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
resumeSessionId?: string;
/** Extra env vars exported before launching the CLI (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Ephemeral — not written to disk. */
@@ -80,6 +82,7 @@ export interface RespawnPaneOptions {
claudeMode?: ClaudeMode;
allowedTools?: string;
openCodeConfig?: OpenCodeConfig;
codexConfig?: CodexConfig;
/** Resume a previous Claude conversation when respawning */
resumeSessionId?: string;
/** Extra env vars exported before launching the CLI (preserved across respawns). */
+33 -6
View File
@@ -46,6 +46,7 @@ import {
type ClaudeMode,
type SessionMode,
type OpenCodeConfig,
type CodexConfig,
type EffortLevel,
} from './types.js';
import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
@@ -123,6 +124,11 @@ const CTRL_L_PATTERN = /\x0c/g;
/** Pattern to split by newlines (CR or LF) */
const NEWLINE_SPLIT_PATTERN = /\r?\n/;
/** True for external-CLI run modes (non-Claude) that use their own TUI and output format. */
export function isExternalCliMode(mode: SessionMode): boolean {
return mode === 'opencode' || mode === 'codex';
}
// Note: Claude CLI PATH resolution moved to session-cli-builder.ts (buildClaudeEnv)
/** PTY fallback geometry when tmux can't be queried (matches pre-#80 hardcoded values). */
@@ -313,6 +319,8 @@ export class Session extends EventEmitter {
// OpenCode configuration (only for mode === 'opencode')
private _openCodeConfig: OpenCodeConfig | undefined;
// Codex configuration (only for mode === 'codex')
private _codexConfig: CodexConfig | undefined;
private _resumeSessionId: string | undefined;
// Ephemeral env overrides (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Exported by tmux
@@ -381,6 +389,8 @@ export class Session extends EventEmitter {
allowedTools?: string;
/** OpenCode configuration (only for mode === 'opencode') */
openCodeConfig?: OpenCodeConfig;
/** Codex configuration (only for mode === 'codex') */
codexConfig?: CodexConfig;
/** Resume a previous Claude conversation (used after server reboot) */
resumeSessionId?: string;
/** Extra env vars exported to the CLI at spawn time (no disk persistence) */
@@ -434,6 +444,11 @@ export class Session extends EventEmitter {
this._openCodeConfig = config.openCodeConfig;
}
// Apply Codex configuration
if (config.codexConfig) {
this._codexConfig = config.codexConfig;
}
// Apply env overrides (exported at spawn, not persisted to disk).
// Legacy migration: pre-0.7.2 carried effort as the CLAUDE_CODE_EFFORT_LEVEL env var,
// which hard-locks /effort switching. Extract it into _effort (--settings soft default)
@@ -696,6 +711,11 @@ export class Session extends EventEmitter {
return this._allowedTools;
}
/** Codex CLI configuration for this session. */
get codexConfig(): CodexConfig | undefined {
return this._codexConfig;
}
// Note: _buildPermissionArgs removed — now using buildInteractiveArgs from session-cli-builder.ts
/**
@@ -867,6 +887,7 @@ export class Session extends EventEmitter {
cliAccountType: this._cliAccountType || undefined,
cliLatestVersion: this._cliLatestVersion || undefined,
openCodeConfig: this._openCodeConfig,
codexConfig: this._codexConfig,
resumeSessionId: this._resumeSessionId,
effort: this._effort,
// envOverrides intentionally NOT on the public SessionState type — they must not
@@ -1045,7 +1066,7 @@ export class Session extends EventEmitter {
this._resetBuffers();
const modeLabel = this.mode === 'opencode' ? 'OpenCode' : 'Claude';
const modeLabel = this.mode === 'opencode' ? 'OpenCode' : this.mode === 'codex' ? 'Codex' : 'Claude';
console.log(
`[Session] Starting interactive ${modeLabel} session` + (this._useMux ? ` (with ${this._mux!.backend})` : '')
);
@@ -1063,6 +1084,7 @@ export class Session extends EventEmitter {
claudeMode: this._claudeMode,
allowedTools: this._allowedTools,
openCodeConfig: this._openCodeConfig,
codexConfig: this._codexConfig,
resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides,
effort: this._effort,
@@ -1077,6 +1099,7 @@ export class Session extends EventEmitter {
claudeMode: this._claudeMode,
allowedTools: this._allowedTools,
openCodeConfig: this._openCodeConfig,
codexConfig: this._codexConfig,
resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides,
effort: this._effort,
@@ -1090,8 +1113,8 @@ export class Session extends EventEmitter {
// For NEW mux sessions: wait for readiness then clean buffer
// For RESTORED mux sessions: don't do anything - client will fetch buffer on tab switch
if (!isRestored) {
if (this.mode === 'opencode') {
// OpenCode uses Bubble Tea TUI — no ❯ prompt to detect.
if (isExternalCliMode(this.mode)) {
// External CLIs use custom TUIs — no ❯ prompt to detect.
// Wait for TUI to stabilize (output stops changing), then mark ready.
// Don't clear the buffer — the TUI's initial render IS the useful content.
// Emit needsRefresh so the client fetches the full buffer once the TUI has rendered.
@@ -1146,6 +1169,10 @@ export class Session extends EventEmitter {
if (this.mode === 'opencode') {
throw new Error('OpenCode sessions require tmux. Direct PTY fallback is not supported.');
}
// Codex sessions require tmux for OPENAI_API_KEY injection via setenv
if (this.mode === 'codex') {
throw new Error('Codex sessions require tmux. Direct PTY fallback is not supported.');
}
try {
// Pass --session-id to use the SAME ID as the Codeman session
// This ensures subagents can be directly matched to the correct tab
@@ -1305,9 +1332,9 @@ export class Session extends EventEmitter {
* PTY data chunk. Receives accumulated raw data to process in one batch.
*/
private _processExpensiveParsers(rawData: string): void {
// Skip Claude-specific parsers for OpenCode sessions — Ralph tracker, BashToolParser,
// token parsing, and CLI info parsing all depend on Claude's output format.
if (this.mode === 'opencode') return;
// Skip Claude-specific parsers for external CLI sessions (Ralph tracker,
// BashToolParser, token + CLI-info parsing all depend on Claude's output format).
if (isExternalCliMode(this.mode)) return;
// Lazy ANSI strip: only compute cleanData when a consumer actually needs it.
let _cleanData: string | null = null;
+78 -2
View File
@@ -39,10 +39,11 @@ import {
type ClaudeMode,
type SessionMode,
type OpenCodeConfig,
type CodexConfig,
type EffortLevel,
} from './types.js';
import { buildEffortCliArgs } from './session-cli-builder.js';
import { wrapWithNice, SAFE_PATH_PATTERN, findClaudeDir, resolveOpenCodeDir } from './utils/index.js';
import { wrapWithNice, SAFE_PATH_PATTERN, findClaudeDir, resolveOpenCodeDir, resolveCodexDir } from './utils/index.js';
import type {
TerminalMultiplexer,
MuxSession,
@@ -539,6 +540,32 @@ function buildOpenCodeCommand(config?: OpenCodeConfig): string {
return parts.join(' ');
}
/**
* Build the codex CLI command with appropriate flags.
*
* Codeman launches Codex's native TUI and handles replay/scrollback by
* stripping destructive terminal sequences before xterm.js sees them.
*/
export function buildCodexCommand(config?: CodexConfig): string {
const parts = ['codex'];
if (config?.dangerouslyBypassApprovals) {
parts.push('--dangerously-bypass-approvals-and-sandbox');
}
if (config?.model) {
const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined;
if (safeModel) parts.push('--model', safeModel);
}
if (config?.resumeSessionId) {
const safeId = /^[a-zA-Z0-9_-]+$/.test(config.resumeSessionId) ? config.resumeSessionId : undefined;
if (safeId) parts.push('resume', safeId);
}
return parts.join(' ');
}
/**
* Build the spawn command for any session mode.
* Shared by createSession() and respawnPane() to avoid duplication.
@@ -564,6 +591,7 @@ function buildSpawnCommand(options: {
claudeMode?: ClaudeMode;
allowedTools?: string;
openCodeConfig?: OpenCodeConfig;
codexConfig?: CodexConfig;
resumeSessionId?: string;
effort?: EffortLevel;
}): string {
@@ -588,6 +616,9 @@ function buildSpawnCommand(options: {
if (options.mode === 'opencode') {
return buildOpenCodeCommand(options.openCodeConfig);
}
if (options.mode === 'codex') {
return buildCodexCommand(options.codexConfig);
}
return '$SHELL';
}
@@ -615,6 +646,29 @@ function setOpenCodeEnvVars(tmuxCmd: string, muxName: string): void {
}
}
/**
* Set sensitive environment variables for Codex on a tmux session via setenv.
* Codex (OpenAI CLI) needs OPENAI_API_KEY; we also forward CODEX_* keys.
*/
function setCodexEnvVars(tmuxCmd: string, muxName: string): void {
const sensitiveVars = ['OPENAI_API_KEY', 'CODEX_API_KEY', 'CODEX_HOME'];
for (const key of sensitiveVars) {
const val = process.env[key];
if (val) {
const escaped = val.replace(/'/g, "'\\''");
try {
execSync(`${tmuxCmd} setenv -t '${muxName}' ${key} '${escaped}'`, {
encoding: 'utf8',
timeout: EXEC_TIMEOUT_MS,
stdio: ['pipe', 'pipe', 'pipe'],
});
} catch {
/* Non-critical — key may not be needed */
}
}
}
}
/**
* Set OPENCODE_CONFIG_CONTENT on a tmux session via setenv.
* Uses tmux setenv to avoid shell metacharacter injection from user-supplied JSON.
@@ -797,7 +851,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const exports = [
'export LANG=en_US.UTF-8',
'export LC_ALL=en_US.UTF-8',
'unset COLORTERM',
mode === 'codex' ? 'export COLORTERM=truecolor' : 'unset COLORTERM',
...(mode === 'codex' ? ['unset NO_COLOR'] : []),
'export CODEMAN_MUX=1',
`export CODEMAN_SESSION_ID=${sessionId}`,
`export CODEMAN_MUX_NAME=${muxName}`,
@@ -863,6 +918,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const dir = resolveOpenCodeDir();
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
}
if (mode === 'codex') {
const dir = resolveCodexDir();
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
}
return { pathExport: '', dir: null };
}
@@ -877,6 +936,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
setOpenCodeConfigContent(tmuxCmd, muxName, openCodeConfig);
}
/**
* Configure Codex-specific environment on a tmux session.
* Sets OPENAI_API_KEY (and related keys) via tmux setenv so secrets don't
* appear in the bash command line.
*/
private _configureCodex(muxName: string): void {
setCodexEnvVars(this.tmux(), muxName);
}
/**
* Creates a new tmux session wrapping Claude CLI or a shell.
* In test mode: creates an in-memory session only (no real tmux session).
@@ -892,6 +960,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
claudeMode,
allowedTools,
openCodeConfig,
codexConfig,
resumeSessionId,
envOverrides,
effort,
@@ -940,6 +1009,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
claudeMode,
allowedTools,
openCodeConfig,
codexConfig,
resumeSessionId,
effort,
});
@@ -986,6 +1056,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// (not visible in ps output or tmux history, inherited by panes)
if (mode === 'opencode') {
this._configureOpenCode(muxName, openCodeConfig);
} else if (mode === 'codex') {
this._configureCodex(muxName);
}
// Apply user-supplied env overrides (e.g., CLAUDE_CODE_EFFORT_LEVEL) via tmux setenv
@@ -1143,6 +1215,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
claudeMode,
allowedTools,
openCodeConfig,
codexConfig,
resumeSessionId,
envOverrides,
effort,
@@ -1165,6 +1238,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
claudeMode,
allowedTools,
openCodeConfig,
codexConfig,
resumeSessionId,
effort,
});
@@ -1176,6 +1250,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// For OpenCode: set sensitive env vars via tmux setenv before respawn
if (mode === 'opencode') {
this._configureOpenCode(muxName, openCodeConfig);
} else if (mode === 'codex') {
this._configureCodex(muxName);
}
// Re-apply user env overrides before respawn so the new shell inherits them.
+19 -2
View File
@@ -8,7 +8,7 @@
* - SessionConfig — creation-time config (id, workingDir, createdAt)
* - SessionOutput — captured stdout/stderr/exitCode
* - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error'
* - SessionMode — 'claude' | 'shell' | 'opencode' (which CLI backend)
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' (which CLI backend)
* - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'normal' | 'allowedTools')
* - SessionColor — visual differentiation color
* - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession)
@@ -38,7 +38,7 @@ export type SessionStatus = 'idle' | 'busy' | 'stopped' | 'error';
export type ClaudeMode = 'dangerously-skip-permissions' | 'normal' | 'allowedTools';
/** Session mode: which CLI backend a session runs */
export type SessionMode = 'claude' | 'shell' | 'opencode';
export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex';
/**
* Valid Claude CLI effort levels (claude >= 2.1.154).
@@ -69,6 +69,21 @@ export interface OpenCodeConfig {
configContent?: string;
}
/** Codex (OpenAI CLI) browser rendering strategy. Hybrid TUI is the only supported mode. */
export type CodexRenderMode = 'hybrid';
/** Codex (OpenAI CLI) session configuration */
export interface CodexConfig {
/** Model identifier (e.g., "gpt-5", "o4-mini"). Passed via --model. */
model?: string;
/** Resume a previous codex conversation by session id (passed via --resume) */
resumeSessionId?: string;
/** Bypass approval prompts (passes --dangerously-bypass-approvals-and-sandbox) */
dangerouslyBypassApprovals?: boolean;
/** Browser rendering strategy for Codex sessions. Hybrid TUI is the only supported mode. */
renderMode?: CodexRenderMode;
}
/**
* Configuration for creating a new session
*/
@@ -158,6 +173,8 @@ export interface SessionState {
cliLatestVersion?: string;
/** OpenCode-specific configuration (only for mode === 'opencode') */
openCodeConfig?: OpenCodeConfig;
/** Codex-specific configuration (only for mode === 'codex') */
codexConfig?: CodexConfig;
/** Claude conversation session ID to resume after reboot (set by restore script) */
resumeSessionId?: string;
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
+69
View File
@@ -0,0 +1,69 @@
/**
* @fileoverview Resolve the Codex (OpenAI) CLI binary across common install paths.
*
* Mirrors opencode-cli-resolver.ts pattern. Finds the `codex` binary
* and provides an augmented PATH string for tmux sessions.
*
* @module utils/codex-cli-resolver
*/
import { execSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { homedir } from 'node:os';
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
/** Common directories where the Codex CLI binary may be installed */
const CODEX_SEARCH_DIRS = [
join(homedir(), '.codex', 'bin'), // Default install location
join(homedir(), '.local', 'bin'), // Alternative install location
'/usr/local/bin', // Homebrew / system
join(homedir(), '.bun', 'bin'), // Bun global
join(homedir(), '.npm-global', 'bin'), // npm global
join(homedir(), 'bin'), // User bin
];
/** Cached directory containing the codex binary (empty string = searched but not found) */
let _codexDir: string | null = null;
/**
* Finds the directory containing the `codex` binary.
* Checks `which codex` first, then falls back to common install locations.
* Result is cached for subsequent calls.
*
* @returns Directory path, or null if not found
*/
export function resolveCodexDir(): string | null {
if (_codexDir !== null) return _codexDir || null;
// Try `which` first (respects current PATH)
try {
const result = execSync('which codex', {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
if (result && existsSync(result)) {
_codexDir = dirname(result);
return _codexDir;
}
} catch {
// Codex not in PATH, will check common locations
}
for (const dir of CODEX_SEARCH_DIRS) {
if (existsSync(join(dir, 'codex'))) {
_codexDir = dir;
return _codexDir;
}
}
_codexDir = ''; // mark as searched, not found
return null;
}
/**
* Check if Codex CLI is available on the system.
*/
export function isCodexAvailable(): boolean {
return resolveCodexDir() !== null;
}
+1
View File
@@ -28,3 +28,4 @@ export { assertNever } from './type-safety.js';
export { wrapWithNice } from './nice-wrapper.js';
export { findClaudeDir, getAugmentedPath } from './claude-cli-resolver.js';
export { resolveOpenCodeDir } from './opencode-cli-resolver.js';
export { resolveCodexDir, isCodexAvailable } from './codex-cli-resolver.js';
+4 -2
View File
@@ -2572,7 +2572,7 @@ class CodemanApp {
<span class="tab-status ${status}" aria-hidden="true"></span>
<span class="tab-info">
<span class="tab-name-row">
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : ''}
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : ''}
<span class="tab-name" data-session-id="${id}">${(() => { const p = parseSessionPrefix(name); return p && p.suffix ? '<span class="tab-prefix">' + escapeHtml(p.prefix) + '</span><span class="tab-suffix">: ' + escapeHtml(p.suffix) + '</span>' : escapeHtml(name); })()}</span>
<span class="tab-detached-badge" aria-hidden="true">detached</span>
</span>
@@ -3374,7 +3374,9 @@ class CodemanApp {
if (killTitle) {
killTitle.textContent = session.mode === 'opencode'
? 'Kill Tmux & OpenCode'
: 'Kill Tmux & Claude Code';
: session.mode === 'codex'
? 'Kill Tmux & Codex'
: 'Kill Tmux & Claude Code';
}
document.getElementById('closeConfirmModal').classList.add('active');
+16
View File
@@ -384,6 +384,9 @@
<button class="run-mode-option" data-mode="opencode" onclick="app.setRunMode('opencode')">
<span class="run-mode-dot opencode"></span>OpenCode
</button>
<button class="run-mode-option" data-mode="codex" onclick="app.setRunMode('codex')">
<span class="run-mode-dot codex"></span>Codex
</button>
<div class="run-mode-sep"></div>
<div class="run-mode-header">Recent Sessions</div>
<div class="run-mode-history" id="runModeHistory"></div>
@@ -895,6 +898,7 @@
<div class="modal-tabs">
<button class="modal-tab-btn active" data-tab="settings-display">Display</button>
<button class="modal-tab-btn" data-tab="settings-claude">Claude CLI</button>
<button class="modal-tab-btn" data-tab="settings-codex">Codex CLI</button>
<button class="modal-tab-btn" data-tab="settings-models">Models</button>
<button class="modal-tab-btn" data-tab="settings-paths">Paths</button>
<button class="modal-tab-btn" data-tab="settings-notifications">Notifications</button>
@@ -1181,6 +1185,18 @@
<span class="form-hint">Process priority (-20 to 19, higher = lower priority, default: 10)</span>
</div>
</div>
<!-- Codex CLI Tab -->
<div class="modal-tab-content hidden" id="settings-codex">
<div class="form-section-header">Codex CLI</div>
<div class="form-row form-row-switch">
<label>Bypass Approvals and Sandbox</label>
<label class="switch">
<input type="checkbox" id="appSettingsCodexDangerouslyBypassApprovals">
<span class="slider"></span>
</label>
<span class="form-hint">Start new Codex sessions with --dangerously-bypass-approvals-and-sandbox</span>
</div>
</div>
<!-- Models Tab -->
<div class="modal-tab-content hidden" id="settings-models">
<div class="form-row">
+68 -6
View File
@@ -151,17 +151,21 @@ Object.assign(CodemanApp.prototype, {
return this.run();
},
/** Run using the selected mode (Claude Code or OpenCode) */
/** Run using the selected mode (Claude Code, OpenCode, or Codex) */
async run() {
const mode = this._runMode || 'claude';
if (mode === 'opencode') {
return this.runOpenCode();
}
if (mode === 'codex') {
return this.runCodex();
}
return this.runClaude();
},
/** Get/set the run mode, persisted in localStorage */
get runMode() { return this._runMode || 'claude'; },
// Note: `runMode` is an accessor defined via Object.defineProperty at the bottom of
// this file — an object-literal getter here would be flattened to a static value by
// Object.assign (it copies values, not accessor descriptors).
setRunMode(mode) {
this._runMode = mode;
@@ -253,7 +257,7 @@ Object.assign(CodemanApp.prototype, {
gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`;
}
if (label) {
label.textContent = mode === 'opencode' ? 'Run OC' : 'Run';
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : 'Run';
}
},
@@ -580,6 +584,53 @@ Object.assign(CodemanApp.prototype, {
}
},
async runCodex() {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
this.terminal.clear();
this.terminal.writeln(`\x1b[1;32m Starting Codex session in ${caseName}...\x1b[0m`);
this.terminal.writeln('');
this.terminal.focus();
try {
const statusRes = await fetch('/api/codex/status');
const status = (await statusRes.json()).data;
if (!status.available) {
this.terminal.writeln('\x1b[1;31m Codex CLI not found.\x1b[0m');
this.terminal.writeln('\x1b[90m Install with: npm install -g @openai/codex\x1b[0m');
return;
}
const globalSettings = this.loadAppSettingsFromStorage();
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), globalSettings);
const res = await fetch('/api/quick-start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
caseName,
mode: 'codex',
codexConfig: {
dangerouslyBypassApprovals: globalSettings.codexDangerouslyBypassApprovals ?? false,
renderMode: 'hybrid',
},
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
})
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to start Codex');
// Switch to the new session (don't pre-set activeSessionId — selectSession
// early-returns when IDs match, skipping buffer load and sendResize)
if (data.data.sessionId) {
await this.selectSession(data.data.sessionId);
}
this.terminal.focus();
} catch (err) {
this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`);
}
},
// ═══════════════════════════════════════════════════════════════
// Session Options Modal
@@ -592,7 +643,7 @@ Object.assign(CodemanApp.prototype, {
this.editingSessionId = sessionId;
// Reset to an appropriate tab — Summary for OpenCode (Respawn/Ralph are Claude-only)
this.switchOptionsTab(session.mode === 'opencode' ? 'summary' : 'respawn');
this.switchOptionsTab(session.mode === 'opencode' || session.mode === 'codex' ? 'summary' : 'respawn');
// Update respawn status display and buttons
const respawnStatus = document.getElementById('sessionRespawnStatus');
@@ -621,7 +672,7 @@ Object.assign(CodemanApp.prototype, {
}
// Hide Claude-specific options for OpenCode sessions
const isOpenCode = session.mode === 'opencode';
const isOpenCode = session.mode === 'opencode' || session.mode === 'codex';
const claudeOnlyEls = document.querySelectorAll('[data-claude-only]');
claudeOnlyEls.forEach(el => { el.style.display = isOpenCode ? 'none' : ''; });
@@ -1474,3 +1525,14 @@ Object.assign(CodemanApp.prototype, {
setTimeout(() => modal.classList.remove('from-mobile'), 300);
},
});
Object.defineProperty(CodemanApp.prototype, 'runMode', {
configurable: true,
enumerable: true,
get() {
return this._runMode || 'claude';
},
set(mode) {
this._runMode = mode === 'opencode' || mode === 'codex' || mode === 'claude' ? mode : 'claude';
},
});
+5
View File
@@ -340,6 +340,9 @@ Object.assign(CodemanApp.prototype, {
claudeModeSelect.onchange = () => {
allowedToolsRow.style.display = claudeModeSelect.value === 'allowedTools' ? '' : 'none';
};
// Codex CLI settings
document.getElementById('appSettingsCodexDangerouslyBypassApprovals').checked =
settings.codexDangerouslyBypassApprovals ?? false;
// Claude Permissions settings
document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false;
document.getElementById('appSettingsOpusContext1m').checked = settings.opusContext1mEnabled ?? false;
@@ -1342,6 +1345,8 @@ Object.assign(CodemanApp.prototype, {
// Claude CLI settings
claudeMode: document.getElementById('appSettingsClaudeMode').value,
allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(),
// Codex CLI settings
codexDangerouslyBypassApprovals: document.getElementById('appSettingsCodexDangerouslyBypassApprovals').checked,
// Claude Permissions settings
agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked,
opusContext1mEnabled: document.getElementById('appSettingsOpusContext1m').checked,
+22
View File
@@ -1064,6 +1064,11 @@ body.solo-mode .btn-lifecycle-log {
color: #10b981;
}
.session-tab .tab-mode.codex {
background: rgba(168, 85, 247, 0.2);
color: #a855f7;
}
/* Timer Banner - Compact */
.timer-banner {
display: flex;
@@ -2745,6 +2750,22 @@ body.solo-mode .btn-lifecycle-log {
color: #a7f3d0;
}
/* Codex mode colors */
.btn-toolbar.btn-run.mode-codex,
.btn-toolbar.btn-run-gear.mode-codex {
background: linear-gradient(135deg, #2a0a3e 0%, #350b4d 50%, #400d5e 100%);
border-color: rgba(168, 85, 247, 0.5);
color: #d8b4fe;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.btn-toolbar.btn-run.mode-codex:hover,
.btn-toolbar.btn-run-gear.mode-codex:hover {
background: linear-gradient(135deg, #400d5e 0%, #581c87 50%, #6b21a8 100%);
box-shadow: 0 0 12px rgba(168, 85, 247, 0.35), 0 2px 8px rgba(168, 85, 247, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.08);
border-color: rgba(192, 132, 252, 0.6);
color: #e9d5ff;
}
/* Dropdown menu */
.run-mode-menu {
display: none;
@@ -2797,6 +2818,7 @@ body.solo-mode .btn-lifecycle-log {
}
.run-mode-dot.claude { background: #3b82f6; }
.run-mode-dot.opencode { background: #10b981; }
.run-mode-dot.codex { background: #a855f7; }
.run-mode-sep {
height: 1px;
+7 -4
View File
@@ -9,7 +9,7 @@ import { join, dirname, resolve, relative, isAbsolute } from 'node:path';
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import fs from 'node:fs/promises';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
import { Session } from '../../session.js';
import { Session, isExternalCliMode } from '../../session.js';
import { RespawnController } from '../../respawn-controller.js';
import { RalphConfigSchema, FixPlanImportSchema, RalphPromptWriteSchema, RalphLoopStartSchema } from '../schemas.js';
import { SseEvent } from '../sse-events.js';
@@ -44,9 +44,12 @@ export function registerRalphRoutes(
};
const session = findSessionOrFail(ctx, id);
// Ralph tracker is not supported for opencode sessions
if (session.mode === 'opencode') {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Ralph tracker is not supported for opencode sessions');
// Ralph tracker is not supported for external-CLI sessions (opencode/codex)
if (isExternalCliMode(session.mode)) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
`Ralph tracker is not supported for ${session.mode} sessions`
);
}
// Handle reset first (before other config)
+10 -9
View File
@@ -11,6 +11,7 @@ import { SseEvent } from '../sse-events.js';
import { findSessionOrFail, autoConfigureRalph, parseBody } from '../route-helpers.js';
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
import { getLifecycleLog } from '../../session-lifecycle-log.js';
import { isExternalCliMode } from '../../session.js';
import {
AI_CHECK_MODEL,
AI_IDLE_CHECK_MAX_CONTEXT,
@@ -88,9 +89,9 @@ export function registerRespawnRoutes(
}
const session = findSessionOrFail(ctx, id);
// Respawn is not supported for opencode sessions
if (session.mode === 'opencode') {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Respawn is not supported for opencode sessions');
// Respawn is not supported for external-CLI sessions (opencode/codex)
if (isExternalCliMode(session.mode)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Respawn is not supported for ${session.mode} sessions`);
}
// Create or get existing controller
@@ -231,9 +232,9 @@ export function registerRespawnRoutes(
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
}
// Respawn is not supported for opencode sessions
if (session.mode === 'opencode') {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Respawn is not supported for opencode sessions');
// Respawn is not supported for external-CLI sessions (opencode/codex)
if (isExternalCliMode(session.mode)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Respawn is not supported for ${session.mode} sessions`);
}
try {
@@ -296,9 +297,9 @@ export function registerRespawnRoutes(
const body = reResult.data as { config?: Partial<RespawnConfig>; durationMinutes?: number };
const session = findSessionOrFail(ctx, id);
// Respawn is not supported for opencode sessions
if (session.mode === 'opencode') {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Respawn is not supported for opencode sessions');
// Respawn is not supported for external-CLI sessions (opencode/codex)
if (isExternalCliMode(session.mode)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Respawn is not supported for ${session.mode} sessions`);
}
// Check if session is running (has a PID)
+35 -6
View File
@@ -282,6 +282,17 @@ export function registerSessionRoutes(
}
}
// Check Codex availability if requested
if (body.mode === 'codex') {
const { isCodexAvailable } = await import('../../utils/codex-cli-resolver.js');
if (!isCodexAvailable()) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
'Codex CLI not found. Install with: npm install -g @openai/codex'
);
}
}
// Pre-validate resumeSessionId: check that the conversation file actually exists
// in Claude's projects directory. If not, skip resume to avoid confusing
// "No conversation found" errors from Claude CLI.
@@ -318,9 +329,11 @@ export function registerSessionRoutes(
const model =
mode === 'opencode'
? body.openCodeConfig?.model
: mode !== 'shell'
? modelConfig?.defaultModel || undefined
: undefined;
: mode === 'codex'
? body.codexConfig?.model
: mode !== 'shell'
? modelConfig?.defaultModel || undefined
: undefined;
const claudeModeConfig = await ctx.getClaudeModeConfig();
const session = new Session({
workingDir,
@@ -333,6 +346,7 @@ export function registerSessionRoutes(
claudeMode: claudeModeConfig.claudeMode,
allowedTools: claudeModeConfig.allowedTools,
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
codexConfig: mode === 'codex' ? body.codexConfig : undefined,
resumeSessionId: validatedResumeId,
envOverrides: body.envOverrides,
effort: body.effort,
@@ -1112,6 +1126,7 @@ export function registerSessionRoutes(
caseName = 'testcase',
mode = 'claude',
openCodeConfig,
codexConfig,
envOverrides,
effort,
} = parseBody(QuickStartSchema, req.body);
@@ -1127,6 +1142,17 @@ export function registerSessionRoutes(
}
}
// Check Codex availability if requested
if (mode === 'codex') {
const { isCodexAvailable } = await import('../../utils/codex-cli-resolver.js');
if (!isCodexAvailable()) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
'Codex CLI not found. Install with: npm install -g @openai/codex'
);
}
}
// Resolve case path: check linked-cases registry first, then fall back to CASES_DIR.
// This mirrors the behaviour of resolveCasePath() in case-routes so that linked
// external project directories are honoured by quick-start just like regular case routes.
@@ -1179,9 +1205,11 @@ export function registerSessionRoutes(
const qsModel =
mode === 'opencode'
? openCodeConfig?.model
: mode !== 'shell'
? qsModelConfig?.defaultModel || undefined
: undefined;
: mode === 'codex'
? codexConfig?.model
: mode !== 'shell'
? qsModelConfig?.defaultModel || undefined
: undefined;
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
const session = new Session({
workingDir: casePath,
@@ -1193,6 +1221,7 @@ export function registerSessionRoutes(
claudeMode: qsClaudeModeConfig.claudeMode,
allowedTools: qsClaudeModeConfig.allowedTools,
openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
codexConfig: mode === 'codex' ? codexConfig : undefined,
envOverrides,
effort,
});
+8
View File
@@ -341,6 +341,14 @@ export function registerSystemRoutes(
};
});
app.get('/api/codex/status', async () => {
const { isCodexAvailable, resolveCodexDir } = await import('../../utils/codex-cli-resolver.js');
return {
available: isCodexAvailable(),
path: resolveCodexDir(),
};
});
// ═══════════════════════════════════════════════════════════════
// State & Lifecycle (cleanup, lifecycle log, stats)
// ═══════════════════════════════════════════════════════════════
+29 -4
View File
@@ -46,7 +46,7 @@ const safePathSchema = z.string().max(1000).refine(isValidWorkingDir, {
// ========== Env Var Allowlist ==========
/** Allowlisted env var key prefixes */
const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_', 'OPENCODE_'];
const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_', 'OPENCODE_', 'CODEX_'];
/** Env var keys that are always blocked (security-sensitive) */
const BLOCKED_ENV_KEYS = new Set([
@@ -76,7 +76,7 @@ const safeEnvOverridesSchema = z
},
{
message:
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_* and OPENCODE_* keys are allowed.',
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, and CODEX_* keys are allowed.',
}
);
@@ -128,9 +128,30 @@ const OpenCodeConfigSchema = z
})
.optional();
/** Schema for Codex (OpenAI CLI)-specific configuration */
const CodexConfigSchema = z
.object({
model: z
.string()
.max(100)
.regex(/^[a-zA-Z0-9._\-/]+$/)
.optional(),
resumeSessionId: z
.string()
.max(100)
.regex(/^[a-zA-Z0-9_-]+$/)
.optional(),
dangerouslyBypassApprovals: z.boolean().optional(),
renderMode: z
.enum(['scrollback', 'hybrid'])
.optional()
.transform(() => 'hybrid' as const),
})
.optional();
export const CreateSessionSchema = z.object({
workingDir: safePathSchema.optional(),
mode: z.enum(['claude', 'shell', 'opencode']).optional(),
mode: z.enum(['claude', 'shell', 'opencode', 'codex']).optional(),
name: z.string().max(100).optional(),
envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
@@ -138,6 +159,7 @@ export const CreateSessionSchema = z.object({
/** Model override to write to .claude/settings.local.json (e.g., "opus[1m]"). Empty string clears. */
modelOverride: z.string().max(50).optional(),
openCodeConfig: OpenCodeConfigSchema,
codexConfig: CodexConfigSchema,
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
resumeSessionId: z
.string()
@@ -188,8 +210,9 @@ export const QuickStartSchema = z.object({
.string()
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.')
.optional(),
mode: z.enum(['claude', 'shell', 'opencode']).optional(),
mode: z.enum(['claude', 'shell', 'opencode', 'codex']).optional(),
openCodeConfig: OpenCodeConfigSchema,
codexConfig: CodexConfigSchema,
envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort: effortLevelSchema,
@@ -301,6 +324,8 @@ export const SettingsUpdateSchema = z
// Claude CLI settings
claudeMode: z.string().max(50).optional(),
allowedTools: z.string().max(2000).optional(),
// Codex CLI settings
codexDangerouslyBypassApprovals: z.boolean().optional(),
// CPU priority
nice: z
.object({
+13 -13
View File
@@ -42,7 +42,7 @@ import { execSync } from 'node:child_process';
import { hostname as getHostname } from 'node:os';
import { dataPath } from '../config/instance.js';
import { EventEmitter } from 'node:events';
import { Session, type BackgroundTask } from '../session.js';
import { Session, isExternalCliMode, type BackgroundTask } from '../session.js';
import type { ClaudeMode, SessionState } from '../types.js';
import { RespawnController, RespawnConfig } from '../respawn-controller.js';
import type { TerminalMultiplexer } from '../mux-interface.js';
@@ -1189,8 +1189,8 @@ export class WebServer extends EventEmitter {
this.runSummaryTrackers.set(session.id, summaryTracker);
summaryTracker.recordSessionStarted(session.mode, session.workingDir);
// Set working directory for Ralph tracker to auto-load @fix_plan.md (not supported for opencode sessions)
if (session.mode !== 'opencode') {
// Set working directory for Ralph tracker to auto-load @fix_plan.md (not supported for external CLIs)
if (!isExternalCliMode(session.mode)) {
session.ralphTracker.setWorkingDir(session.workingDir);
}
@@ -2016,8 +2016,8 @@ export class WebServer extends EventEmitter {
);
}
}
// Ralph / Todo tracker (not supported for opencode sessions)
if (session.mode !== 'opencode') {
// Ralph / Todo tracker (not supported for external-CLI sessions)
if (!isExternalCliMode(session.mode)) {
if (savedState.ralphAutoEnableDisabled) {
session.ralphTracker.disableAutoEnable();
console.log(`[Server] Restored Ralph auto-enable disabled for session ${session.id}`);
@@ -2046,8 +2046,8 @@ export class WebServer extends EventEmitter {
if (savedState.flickerFilterEnabled !== undefined) {
session.flickerFilterEnabled = savedState.flickerFilterEnabled;
}
// Respawn controller (not supported for opencode sessions)
if (session.mode !== 'opencode' && savedState.respawnEnabled && savedState.respawnConfig) {
// Respawn controller (not supported for external-CLI sessions)
if (!isExternalCliMode(session.mode) && savedState.respawnEnabled && savedState.respawnConfig) {
try {
this.restoreRespawnController(session, savedState.respawnConfig, 'state.json');
} catch (err) {
@@ -2056,9 +2056,9 @@ export class WebServer extends EventEmitter {
}
}
// Fallback: restore respawn from mux-sessions.json if state.json didn't have it (not supported for opencode)
// Fallback: restore respawn from mux-sessions.json if state.json didn't have it (not supported for external CLIs)
if (
session.mode !== 'opencode' &&
!isExternalCliMode(session.mode) &&
!this.respawnControllers.has(session.id) &&
muxSession.respawnConfig?.enabled
) {
@@ -2073,9 +2073,9 @@ export class WebServer extends EventEmitter {
}
// Fallback: restore Ralph state from state-inner.json if not already set and not explicitly disabled
// Ralph tracker is not supported for opencode sessions
// Ralph tracker is not supported for external-CLI sessions
if (
session.mode !== 'opencode' &&
!isExternalCliMode(session.mode) &&
!session.ralphTracker.enabled &&
!session.ralphTracker.autoEnableDisabled
) {
@@ -2086,9 +2086,9 @@ export class WebServer extends EventEmitter {
}
}
// Fallback: auto-detect completion phrase from CLAUDE.md (not supported for opencode)
// Fallback: auto-detect completion phrase from CLAUDE.md (not supported for external CLIs)
if (
session.mode !== 'opencode' &&
!isExternalCliMode(session.mode) &&
session.ralphTracker.enabled &&
!session.ralphTracker.loopState.completionPhrase
) {
+135
View File
@@ -0,0 +1,135 @@
/**
* @fileoverview Unit tests for the Codex run-mode UI surface in session-ui.js /
* settings-ui.js / index.html. Loads the browser modules into a vm sandbox (no
* real DOM) and exercises run-mode selection + Codex quick-start wiring.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
function loadRunModeHarness() {
const elements: Record<string, any> = {};
const storage = new Map<string, string>();
const CodemanApp = function CodemanApp(this: any) {};
const context = vm.createContext({
CodemanApp,
VoiceInput: {},
localStorage: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
},
document: {
getElementById: (id: string) => elements[id] ?? null,
},
console,
});
const settingsUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/settings-ui.js'), 'utf8');
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
vm.runInContext(settingsUi, context, { filename: 'settings-ui.js' });
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
const runModeMenu = { classList: { remove: () => {} } };
const gearBtn = { className: '' };
const runBtn = { className: '', nextElementSibling: gearBtn };
const runBtnLabel = { textContent: '' };
elements.runModeMenu = runModeMenu;
elements.runBtn = runBtn;
elements.runBtnLabel = runBtnLabel;
const app = new (CodemanApp as any)();
app.loadAppSettingsFromStorage = () => ({});
app.saveAppSettingsToStorage = () => {};
app._apiPut = () => Promise.resolve();
return { app, storage, runBtnLabel };
}
describe('run mode UI', () => {
it('updates the visible mode when selecting Claude after server sync set Codex', async () => {
const { app, storage, runBtnLabel } = loadRunModeHarness();
storage.set('codeman_runMode', 'claude');
await app.loadAppSettingsFromServer(Promise.resolve({ runMode: 'codex' }));
expect(app.runMode).toBe('codex');
expect(runBtnLabel.textContent).toBe('Run CX');
app.setRunMode('claude');
expect(app.runMode).toBe('claude');
expect(runBtnLabel.textContent).toBe('Run');
});
});
describe('Codex quick start settings', () => {
it('renders Codex CLI settings in a dedicated app settings tab', () => {
const html = readFileSync(resolve(import.meta.dirname, '../src/web/public/index.html'), 'utf8');
expect(html).toContain('data-tab="settings-codex">Codex CLI</button>');
const claudeTab = html.match(
/<div class="modal-tab-content hidden" id="settings-claude">([\s\S]*?)<!-- Codex CLI Tab -->/
);
expect(claudeTab?.[1]).not.toContain('appSettingsCodexDangerouslyBypassApprovals');
const codexTab = html.match(
/<div class="modal-tab-content hidden" id="settings-codex">([\s\S]*?)<\/div>\s*<!-- Models Tab -->/
);
expect(codexTab?.[1]).toContain('appSettingsCodexDangerouslyBypassApprovals');
expect(codexTab?.[1]).not.toContain('appSettingsCodexRenderMode');
});
it('passes global Codex settings into quick-start config for new sessions', async () => {
const elements: Record<string, any> = {
quickStartCase: { value: 'codex-case' },
};
const requests: Array<{ url: string; body?: any }> = [];
const CodemanApp = function CodemanApp(this: any) {};
const context = vm.createContext({
CodemanApp,
localStorage: {
getItem: () => null,
setItem: () => {},
},
document: {
getElementById: (id: string) => elements[id] ?? null,
},
// Mock responses use the real wire shape: the global preSerialization hook in
// server.ts wraps route payloads into the { success, data } envelope.
fetch: async (url: string, init?: { body?: string }) => {
requests.push({ url, body: init?.body ? JSON.parse(init.body) : undefined });
if (url === '/api/codex/status') return { json: async () => ({ success: true, data: { available: true } }) };
if (url === '/api/quick-start') return { json: async () => ({ success: true, data: { sessionId: 'sess-1' } }) };
throw new Error(`unexpected fetch: ${url}`);
},
console,
});
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
const app = new (CodemanApp as any)();
app.terminal = { clear: () => {}, writeln: () => {}, focus: () => {} };
app.loadAppSettingsFromStorage = () => ({
codexDangerouslyBypassApprovals: true,
});
app.getCaseSettings = () => ({});
app.buildEnvOverrides = () => ({});
const selected: string[] = [];
app.selectSession = async (id: string) => {
selected.push(id);
};
await app.runCodex();
expect(requests.find((req) => req.url === '/api/quick-start')?.body).toMatchObject({
caseName: 'codex-case',
mode: 'codex',
codexConfig: { dangerouslyBypassApprovals: true, renderMode: 'hybrid' },
});
expect(selected).toEqual(['sess-1']);
});
});