diff --git a/src/mux-interface.ts b/src/mux-interface.ts index 724ee729..1eddd1da 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -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). */ diff --git a/src/session.ts b/src/session.ts index b50f22ac..d0657d4f 100644 --- a/src/session.ts +++ b/src/session.ts @@ -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; diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 2fb70169..5a31d37e 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -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. diff --git a/src/types/session.ts b/src/types/session.ts index 94ee0ea3..bda7d0d5 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -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) */ diff --git a/src/utils/codex-cli-resolver.ts b/src/utils/codex-cli-resolver.ts new file mode 100644 index 00000000..e85fb75c --- /dev/null +++ b/src/utils/codex-cli-resolver.ts @@ -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; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 89d8d89d..d6e76fc5 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -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'; diff --git a/src/web/public/app.js b/src/web/public/app.js index 729ef861..f5babd80 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -2572,7 +2572,7 @@ class CodemanApp { - ${mode === 'shell' ? '' : mode === 'opencode' ? '' : ''} + ${mode === 'shell' ? '' : mode === 'opencode' ? '' : mode === 'codex' ? '' : ''} ${(() => { const p = parseSessionPrefix(name); return p && p.suffix ? '' + escapeHtml(p.prefix) + ': ' + escapeHtml(p.suffix) + '' : escapeHtml(name); })()} @@ -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'); diff --git a/src/web/public/index.html b/src/web/public/index.html index a5dce01d..0c96a5a2 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -384,6 +384,9 @@ +
Recent Sessions
@@ -895,6 +898,7 @@ + +