mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +02:00
feat(codex): add Codex (OpenAI CLI) run-mode foundation
Add Codex as a first-class session mode alongside Claude/Shell/OpenCode. - codex-cli-resolver: locate the `codex` binary and augment PATH (mirrors the OpenCode resolver) - SessionMode 'codex' + CodexConfig (model, resumeSessionId, dangerouslyBypass, renderMode); persisted in SessionState and threaded through CreateSession/ RespawnPane options - schema validation: CodexConfigSchema, CODEX_ env-var prefix allowlist, mode enums on create/quick-start, codexDangerouslyBypassApprovals setting - tmux launch: buildCodexCommand, setenv for OPENAI_API_KEY/CODEX_* (keeps secrets out of ps), truecolor COLORTERM, codex PATH resolution - session + routes: availability check (clear install hint), config passthrough, tmux-required guard; Codex skips Claude-only parsers (Ralph/respawn/token) - run-mode UI: "Run CX" selector option + dedicated Codex CLI settings tab with the bypass-approvals toggle; GET /api/codex/status Scope: foundation only. Codex terminal redraw handling and xterm snapshot/replay are intentionally excluded and tracked separately. Verification: tsc --noEmit, eslint, prettier --check, check:frontend-syntax all clean; full test:ci suite green (2712 passed, 0 failed); server boot smoke OK. Co-Authored-By: Saqeb Akhter <saqeb.akhter@gmail.com>
This commit is contained in:
co-authored by
Saqeb Akhter
parent
e1f611b8fb
commit
70378315da
@@ -14,6 +14,7 @@ import type {
|
|||||||
ClaudeMode,
|
ClaudeMode,
|
||||||
SessionMode,
|
SessionMode,
|
||||||
OpenCodeConfig,
|
OpenCodeConfig,
|
||||||
|
CodexConfig,
|
||||||
EffortLevel,
|
EffortLevel,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
||||||
@@ -62,6 +63,7 @@ export interface CreateSessionOptions {
|
|||||||
claudeMode?: ClaudeMode;
|
claudeMode?: ClaudeMode;
|
||||||
allowedTools?: string;
|
allowedTools?: string;
|
||||||
openCodeConfig?: OpenCodeConfig;
|
openCodeConfig?: OpenCodeConfig;
|
||||||
|
codexConfig?: CodexConfig;
|
||||||
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
|
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
/** Extra env vars exported before launching the CLI (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Ephemeral — not written to disk. */
|
/** 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;
|
claudeMode?: ClaudeMode;
|
||||||
allowedTools?: string;
|
allowedTools?: string;
|
||||||
openCodeConfig?: OpenCodeConfig;
|
openCodeConfig?: OpenCodeConfig;
|
||||||
|
codexConfig?: CodexConfig;
|
||||||
/** Resume a previous Claude conversation when respawning */
|
/** Resume a previous Claude conversation when respawning */
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
/** Extra env vars exported before launching the CLI (preserved across respawns). */
|
/** Extra env vars exported before launching the CLI (preserved across respawns). */
|
||||||
|
|||||||
+33
-6
@@ -46,6 +46,7 @@ import {
|
|||||||
type ClaudeMode,
|
type ClaudeMode,
|
||||||
type SessionMode,
|
type SessionMode,
|
||||||
type OpenCodeConfig,
|
type OpenCodeConfig,
|
||||||
|
type CodexConfig,
|
||||||
type EffortLevel,
|
type EffortLevel,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import type { TerminalMultiplexer, MuxSession } from './mux-interface.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) */
|
/** Pattern to split by newlines (CR or LF) */
|
||||||
const NEWLINE_SPLIT_PATTERN = /\r?\n/;
|
const NEWLINE_SPLIT_PATTERN = /\r?\n/;
|
||||||
|
|
||||||
|
/** True for external-CLI run modes (non-Claude) that use their own TUI and output format. */
|
||||||
|
function isExternalCliMode(mode: SessionMode): boolean {
|
||||||
|
return mode === 'opencode' || mode === 'codex';
|
||||||
|
}
|
||||||
|
|
||||||
// Note: Claude CLI PATH resolution moved to session-cli-builder.ts (buildClaudeEnv)
|
// 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). */
|
/** 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')
|
// OpenCode configuration (only for mode === 'opencode')
|
||||||
private _openCodeConfig: OpenCodeConfig | undefined;
|
private _openCodeConfig: OpenCodeConfig | undefined;
|
||||||
|
// Codex configuration (only for mode === 'codex')
|
||||||
|
private _codexConfig: CodexConfig | undefined;
|
||||||
private _resumeSessionId: string | undefined;
|
private _resumeSessionId: string | undefined;
|
||||||
|
|
||||||
// Ephemeral env overrides (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Exported by tmux
|
// Ephemeral env overrides (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Exported by tmux
|
||||||
@@ -381,6 +389,8 @@ export class Session extends EventEmitter {
|
|||||||
allowedTools?: string;
|
allowedTools?: string;
|
||||||
/** OpenCode configuration (only for mode === 'opencode') */
|
/** OpenCode configuration (only for mode === 'opencode') */
|
||||||
openCodeConfig?: OpenCodeConfig;
|
openCodeConfig?: OpenCodeConfig;
|
||||||
|
/** Codex configuration (only for mode === 'codex') */
|
||||||
|
codexConfig?: CodexConfig;
|
||||||
/** Resume a previous Claude conversation (used after server reboot) */
|
/** Resume a previous Claude conversation (used after server reboot) */
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
/** Extra env vars exported to the CLI at spawn time (no disk persistence) */
|
/** 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;
|
this._openCodeConfig = config.openCodeConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply Codex configuration
|
||||||
|
if (config.codexConfig) {
|
||||||
|
this._codexConfig = config.codexConfig;
|
||||||
|
}
|
||||||
|
|
||||||
// Apply env overrides (exported at spawn, not persisted to disk).
|
// 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,
|
// 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)
|
// which hard-locks /effort switching. Extract it into _effort (--settings soft default)
|
||||||
@@ -696,6 +711,11 @@ export class Session extends EventEmitter {
|
|||||||
return this._allowedTools;
|
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
|
// Note: _buildPermissionArgs removed — now using buildInteractiveArgs from session-cli-builder.ts
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -867,6 +887,7 @@ export class Session extends EventEmitter {
|
|||||||
cliAccountType: this._cliAccountType || undefined,
|
cliAccountType: this._cliAccountType || undefined,
|
||||||
cliLatestVersion: this._cliLatestVersion || undefined,
|
cliLatestVersion: this._cliLatestVersion || undefined,
|
||||||
openCodeConfig: this._openCodeConfig,
|
openCodeConfig: this._openCodeConfig,
|
||||||
|
codexConfig: this._codexConfig,
|
||||||
resumeSessionId: this._resumeSessionId,
|
resumeSessionId: this._resumeSessionId,
|
||||||
effort: this._effort,
|
effort: this._effort,
|
||||||
// envOverrides intentionally NOT on the public SessionState type — they must not
|
// envOverrides intentionally NOT on the public SessionState type — they must not
|
||||||
@@ -1045,7 +1066,7 @@ export class Session extends EventEmitter {
|
|||||||
|
|
||||||
this._resetBuffers();
|
this._resetBuffers();
|
||||||
|
|
||||||
const modeLabel = this.mode === 'opencode' ? 'OpenCode' : 'Claude';
|
const modeLabel = this.mode === 'opencode' ? 'OpenCode' : this.mode === 'codex' ? 'Codex' : 'Claude';
|
||||||
console.log(
|
console.log(
|
||||||
`[Session] Starting interactive ${modeLabel} session` + (this._useMux ? ` (with ${this._mux!.backend})` : '')
|
`[Session] Starting interactive ${modeLabel} session` + (this._useMux ? ` (with ${this._mux!.backend})` : '')
|
||||||
);
|
);
|
||||||
@@ -1063,6 +1084,7 @@ export class Session extends EventEmitter {
|
|||||||
claudeMode: this._claudeMode,
|
claudeMode: this._claudeMode,
|
||||||
allowedTools: this._allowedTools,
|
allowedTools: this._allowedTools,
|
||||||
openCodeConfig: this._openCodeConfig,
|
openCodeConfig: this._openCodeConfig,
|
||||||
|
codexConfig: this._codexConfig,
|
||||||
resumeSessionId: this._resumeSessionId,
|
resumeSessionId: this._resumeSessionId,
|
||||||
envOverrides: this._envOverrides,
|
envOverrides: this._envOverrides,
|
||||||
effort: this._effort,
|
effort: this._effort,
|
||||||
@@ -1077,6 +1099,7 @@ export class Session extends EventEmitter {
|
|||||||
claudeMode: this._claudeMode,
|
claudeMode: this._claudeMode,
|
||||||
allowedTools: this._allowedTools,
|
allowedTools: this._allowedTools,
|
||||||
openCodeConfig: this._openCodeConfig,
|
openCodeConfig: this._openCodeConfig,
|
||||||
|
codexConfig: this._codexConfig,
|
||||||
resumeSessionId: this._resumeSessionId,
|
resumeSessionId: this._resumeSessionId,
|
||||||
envOverrides: this._envOverrides,
|
envOverrides: this._envOverrides,
|
||||||
effort: this._effort,
|
effort: this._effort,
|
||||||
@@ -1090,8 +1113,8 @@ export class Session extends EventEmitter {
|
|||||||
// For NEW mux sessions: wait for readiness then clean buffer
|
// 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
|
// For RESTORED mux sessions: don't do anything - client will fetch buffer on tab switch
|
||||||
if (!isRestored) {
|
if (!isRestored) {
|
||||||
if (this.mode === 'opencode') {
|
if (isExternalCliMode(this.mode)) {
|
||||||
// OpenCode uses Bubble Tea TUI — no ❯ prompt to detect.
|
// External CLIs use custom TUIs — no ❯ prompt to detect.
|
||||||
// Wait for TUI to stabilize (output stops changing), then mark ready.
|
// 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.
|
// 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.
|
// 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') {
|
if (this.mode === 'opencode') {
|
||||||
throw new Error('OpenCode sessions require tmux. Direct PTY fallback is not supported.');
|
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 {
|
try {
|
||||||
// Pass --session-id to use the SAME ID as the Codeman session
|
// Pass --session-id to use the SAME ID as the Codeman session
|
||||||
// This ensures subagents can be directly matched to the correct tab
|
// 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.
|
* PTY data chunk. Receives accumulated raw data to process in one batch.
|
||||||
*/
|
*/
|
||||||
private _processExpensiveParsers(rawData: string): void {
|
private _processExpensiveParsers(rawData: string): void {
|
||||||
// Skip Claude-specific parsers for OpenCode sessions — Ralph tracker, BashToolParser,
|
// Skip Claude-specific parsers for external CLI sessions (Ralph tracker,
|
||||||
// token parsing, and CLI info parsing all depend on Claude's output format.
|
// BashToolParser, token + CLI-info parsing all depend on Claude's output format).
|
||||||
if (this.mode === 'opencode') return;
|
if (isExternalCliMode(this.mode)) return;
|
||||||
|
|
||||||
// Lazy ANSI strip: only compute cleanData when a consumer actually needs it.
|
// Lazy ANSI strip: only compute cleanData when a consumer actually needs it.
|
||||||
let _cleanData: string | null = null;
|
let _cleanData: string | null = null;
|
||||||
|
|||||||
+78
-2
@@ -39,10 +39,11 @@ import {
|
|||||||
type ClaudeMode,
|
type ClaudeMode,
|
||||||
type SessionMode,
|
type SessionMode,
|
||||||
type OpenCodeConfig,
|
type OpenCodeConfig,
|
||||||
|
type CodexConfig,
|
||||||
type EffortLevel,
|
type EffortLevel,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { buildEffortCliArgs } from './session-cli-builder.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 {
|
import type {
|
||||||
TerminalMultiplexer,
|
TerminalMultiplexer,
|
||||||
MuxSession,
|
MuxSession,
|
||||||
@@ -539,6 +540,32 @@ function buildOpenCodeCommand(config?: OpenCodeConfig): string {
|
|||||||
return parts.join(' ');
|
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.
|
* Build the spawn command for any session mode.
|
||||||
* Shared by createSession() and respawnPane() to avoid duplication.
|
* Shared by createSession() and respawnPane() to avoid duplication.
|
||||||
@@ -564,6 +591,7 @@ function buildSpawnCommand(options: {
|
|||||||
claudeMode?: ClaudeMode;
|
claudeMode?: ClaudeMode;
|
||||||
allowedTools?: string;
|
allowedTools?: string;
|
||||||
openCodeConfig?: OpenCodeConfig;
|
openCodeConfig?: OpenCodeConfig;
|
||||||
|
codexConfig?: CodexConfig;
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
effort?: EffortLevel;
|
effort?: EffortLevel;
|
||||||
}): string {
|
}): string {
|
||||||
@@ -588,6 +616,9 @@ function buildSpawnCommand(options: {
|
|||||||
if (options.mode === 'opencode') {
|
if (options.mode === 'opencode') {
|
||||||
return buildOpenCodeCommand(options.openCodeConfig);
|
return buildOpenCodeCommand(options.openCodeConfig);
|
||||||
}
|
}
|
||||||
|
if (options.mode === 'codex') {
|
||||||
|
return buildCodexCommand(options.codexConfig);
|
||||||
|
}
|
||||||
return '$SHELL';
|
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.
|
* Set OPENCODE_CONFIG_CONTENT on a tmux session via setenv.
|
||||||
* Uses tmux setenv to avoid shell metacharacter injection from user-supplied JSON.
|
* 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 = [
|
const exports = [
|
||||||
'export LANG=en_US.UTF-8',
|
'export LANG=en_US.UTF-8',
|
||||||
'export LC_ALL=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_MUX=1',
|
||||||
`export CODEMAN_SESSION_ID=${sessionId}`,
|
`export CODEMAN_SESSION_ID=${sessionId}`,
|
||||||
`export CODEMAN_MUX_NAME=${muxName}`,
|
`export CODEMAN_MUX_NAME=${muxName}`,
|
||||||
@@ -863,6 +918,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
const dir = resolveOpenCodeDir();
|
const dir = resolveOpenCodeDir();
|
||||||
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
|
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 };
|
return { pathExport: '', dir: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -877,6 +936,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
setOpenCodeConfigContent(tmuxCmd, muxName, openCodeConfig);
|
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.
|
* Creates a new tmux session wrapping Claude CLI or a shell.
|
||||||
* In test mode: creates an in-memory session only (no real tmux session).
|
* 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,
|
claudeMode,
|
||||||
allowedTools,
|
allowedTools,
|
||||||
openCodeConfig,
|
openCodeConfig,
|
||||||
|
codexConfig,
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
envOverrides,
|
envOverrides,
|
||||||
effort,
|
effort,
|
||||||
@@ -940,6 +1009,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
claudeMode,
|
claudeMode,
|
||||||
allowedTools,
|
allowedTools,
|
||||||
openCodeConfig,
|
openCodeConfig,
|
||||||
|
codexConfig,
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
effort,
|
effort,
|
||||||
});
|
});
|
||||||
@@ -986,6 +1056,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
// (not visible in ps output or tmux history, inherited by panes)
|
// (not visible in ps output or tmux history, inherited by panes)
|
||||||
if (mode === 'opencode') {
|
if (mode === 'opencode') {
|
||||||
this._configureOpenCode(muxName, openCodeConfig);
|
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
|
// 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,
|
claudeMode,
|
||||||
allowedTools,
|
allowedTools,
|
||||||
openCodeConfig,
|
openCodeConfig,
|
||||||
|
codexConfig,
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
envOverrides,
|
envOverrides,
|
||||||
effort,
|
effort,
|
||||||
@@ -1165,6 +1238,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
claudeMode,
|
claudeMode,
|
||||||
allowedTools,
|
allowedTools,
|
||||||
openCodeConfig,
|
openCodeConfig,
|
||||||
|
codexConfig,
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
effort,
|
effort,
|
||||||
});
|
});
|
||||||
@@ -1176,6 +1250,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
// For OpenCode: set sensitive env vars via tmux setenv before respawn
|
// For OpenCode: set sensitive env vars via tmux setenv before respawn
|
||||||
if (mode === 'opencode') {
|
if (mode === 'opencode') {
|
||||||
this._configureOpenCode(muxName, openCodeConfig);
|
this._configureOpenCode(muxName, openCodeConfig);
|
||||||
|
} else if (mode === 'codex') {
|
||||||
|
this._configureCodex(muxName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-apply user env overrides before respawn so the new shell inherits them.
|
// Re-apply user env overrides before respawn so the new shell inherits them.
|
||||||
|
|||||||
+19
-2
@@ -8,7 +8,7 @@
|
|||||||
* - SessionConfig — creation-time config (id, workingDir, createdAt)
|
* - SessionConfig — creation-time config (id, workingDir, createdAt)
|
||||||
* - SessionOutput — captured stdout/stderr/exitCode
|
* - SessionOutput — captured stdout/stderr/exitCode
|
||||||
* - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error'
|
* - 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')
|
* - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'normal' | 'allowedTools')
|
||||||
* - SessionColor — visual differentiation color
|
* - SessionColor — visual differentiation color
|
||||||
* - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession)
|
* - 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';
|
export type ClaudeMode = 'dangerously-skip-permissions' | 'normal' | 'allowedTools';
|
||||||
|
|
||||||
/** Session mode: which CLI backend a session runs */
|
/** 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).
|
* Valid Claude CLI effort levels (claude >= 2.1.154).
|
||||||
@@ -69,6 +69,21 @@ export interface OpenCodeConfig {
|
|||||||
configContent?: string;
|
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
|
* Configuration for creating a new session
|
||||||
*/
|
*/
|
||||||
@@ -158,6 +173,8 @@ export interface SessionState {
|
|||||||
cliLatestVersion?: string;
|
cliLatestVersion?: string;
|
||||||
/** OpenCode-specific configuration (only for mode === 'opencode') */
|
/** OpenCode-specific configuration (only for mode === 'opencode') */
|
||||||
openCodeConfig?: OpenCodeConfig;
|
openCodeConfig?: OpenCodeConfig;
|
||||||
|
/** Codex-specific configuration (only for mode === 'codex') */
|
||||||
|
codexConfig?: CodexConfig;
|
||||||
/** Claude conversation session ID to resume after reboot (set by restore script) */
|
/** Claude conversation session ID to resume after reboot (set by restore script) */
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -28,3 +28,4 @@ export { assertNever } from './type-safety.js';
|
|||||||
export { wrapWithNice } from './nice-wrapper.js';
|
export { wrapWithNice } from './nice-wrapper.js';
|
||||||
export { findClaudeDir, getAugmentedPath } from './claude-cli-resolver.js';
|
export { findClaudeDir, getAugmentedPath } from './claude-cli-resolver.js';
|
||||||
export { resolveOpenCodeDir } from './opencode-cli-resolver.js';
|
export { resolveOpenCodeDir } from './opencode-cli-resolver.js';
|
||||||
|
export { resolveCodexDir, isCodexAvailable } from './codex-cli-resolver.js';
|
||||||
|
|||||||
@@ -384,6 +384,9 @@
|
|||||||
<button class="run-mode-option" data-mode="opencode" onclick="app.setRunMode('opencode')">
|
<button class="run-mode-option" data-mode="opencode" onclick="app.setRunMode('opencode')">
|
||||||
<span class="run-mode-dot opencode"></span>OpenCode
|
<span class="run-mode-dot opencode"></span>OpenCode
|
||||||
</button>
|
</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-sep"></div>
|
||||||
<div class="run-mode-header">Recent Sessions</div>
|
<div class="run-mode-header">Recent Sessions</div>
|
||||||
<div class="run-mode-history" id="runModeHistory"></div>
|
<div class="run-mode-history" id="runModeHistory"></div>
|
||||||
@@ -895,6 +898,7 @@
|
|||||||
<div class="modal-tabs">
|
<div class="modal-tabs">
|
||||||
<button class="modal-tab-btn active" data-tab="settings-display">Display</button>
|
<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-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-models">Models</button>
|
||||||
<button class="modal-tab-btn" data-tab="settings-paths">Paths</button>
|
<button class="modal-tab-btn" data-tab="settings-paths">Paths</button>
|
||||||
<button class="modal-tab-btn" data-tab="settings-notifications">Notifications</button>
|
<button class="modal-tab-btn" data-tab="settings-notifications">Notifications</button>
|
||||||
@@ -1174,6 +1178,18 @@
|
|||||||
<span class="form-hint">Process priority (-20 to 19, higher = lower priority, default: 10)</span>
|
<span class="form-hint">Process priority (-20 to 19, higher = lower priority, default: 10)</span>
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Models Tab -->
|
||||||
<div class="modal-tab-content hidden" id="settings-models">
|
<div class="modal-tab-content hidden" id="settings-models">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
|
|||||||
@@ -157,6 +157,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (mode === 'opencode') {
|
if (mode === 'opencode') {
|
||||||
return this.runOpenCode();
|
return this.runOpenCode();
|
||||||
}
|
}
|
||||||
|
if (mode === 'codex') {
|
||||||
|
return this.runCodex();
|
||||||
|
}
|
||||||
return this.runClaude();
|
return this.runClaude();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -253,7 +256,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`;
|
gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`;
|
||||||
}
|
}
|
||||||
if (label) {
|
if (label) {
|
||||||
label.textContent = mode === 'opencode' ? 'Run OC' : 'Run';
|
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : 'Run';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -580,6 +583,51 @@ 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();
|
||||||
|
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');
|
||||||
|
|
||||||
|
if (data.sessionId) {
|
||||||
|
await this.selectSession(data.sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.terminal.focus();
|
||||||
|
} catch (err) {
|
||||||
|
this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// Session Options Modal
|
// Session Options Modal
|
||||||
@@ -592,7 +640,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this.editingSessionId = sessionId;
|
this.editingSessionId = sessionId;
|
||||||
|
|
||||||
// Reset to an appropriate tab — Summary for OpenCode (Respawn/Ralph are Claude-only)
|
// 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
|
// Update respawn status display and buttons
|
||||||
const respawnStatus = document.getElementById('sessionRespawnStatus');
|
const respawnStatus = document.getElementById('sessionRespawnStatus');
|
||||||
@@ -621,7 +669,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Hide Claude-specific options for OpenCode sessions
|
// 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]');
|
const claudeOnlyEls = document.querySelectorAll('[data-claude-only]');
|
||||||
claudeOnlyEls.forEach(el => { el.style.display = isOpenCode ? 'none' : ''; });
|
claudeOnlyEls.forEach(el => { el.style.display = isOpenCode ? 'none' : ''; });
|
||||||
|
|
||||||
@@ -1474,3 +1522,14 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
setTimeout(() => modal.classList.remove('from-mobile'), 300);
|
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';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -339,6 +339,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
claudeModeSelect.onchange = () => {
|
claudeModeSelect.onchange = () => {
|
||||||
allowedToolsRow.style.display = claudeModeSelect.value === 'allowedTools' ? '' : 'none';
|
allowedToolsRow.style.display = claudeModeSelect.value === 'allowedTools' ? '' : 'none';
|
||||||
};
|
};
|
||||||
|
// Codex CLI settings
|
||||||
|
document.getElementById('appSettingsCodexDangerouslyBypassApprovals').checked =
|
||||||
|
settings.codexDangerouslyBypassApprovals ?? false;
|
||||||
// Claude Permissions settings
|
// Claude Permissions settings
|
||||||
document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false;
|
document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false;
|
||||||
document.getElementById('appSettingsOpusContext1m').checked = settings.opusContext1mEnabled ?? false;
|
document.getElementById('appSettingsOpusContext1m').checked = settings.opusContext1mEnabled ?? false;
|
||||||
@@ -1340,6 +1343,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Claude CLI settings
|
// Claude CLI settings
|
||||||
claudeMode: document.getElementById('appSettingsClaudeMode').value,
|
claudeMode: document.getElementById('appSettingsClaudeMode').value,
|
||||||
allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(),
|
allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(),
|
||||||
|
// Codex CLI settings
|
||||||
|
codexDangerouslyBypassApprovals: document.getElementById('appSettingsCodexDangerouslyBypassApprovals').checked,
|
||||||
// Claude Permissions settings
|
// Claude Permissions settings
|
||||||
agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked,
|
agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked,
|
||||||
opusContext1mEnabled: document.getElementById('appSettingsOpusContext1m').checked,
|
opusContext1mEnabled: document.getElementById('appSettingsOpusContext1m').checked,
|
||||||
|
|||||||
@@ -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
|
// Pre-validate resumeSessionId: check that the conversation file actually exists
|
||||||
// in Claude's projects directory. If not, skip resume to avoid confusing
|
// in Claude's projects directory. If not, skip resume to avoid confusing
|
||||||
// "No conversation found" errors from Claude CLI.
|
// "No conversation found" errors from Claude CLI.
|
||||||
@@ -318,9 +329,11 @@ export function registerSessionRoutes(
|
|||||||
const model =
|
const model =
|
||||||
mode === 'opencode'
|
mode === 'opencode'
|
||||||
? body.openCodeConfig?.model
|
? body.openCodeConfig?.model
|
||||||
: mode !== 'shell'
|
: mode === 'codex'
|
||||||
? modelConfig?.defaultModel || undefined
|
? body.codexConfig?.model
|
||||||
: undefined;
|
: mode !== 'shell'
|
||||||
|
? modelConfig?.defaultModel || undefined
|
||||||
|
: undefined;
|
||||||
const claudeModeConfig = await ctx.getClaudeModeConfig();
|
const claudeModeConfig = await ctx.getClaudeModeConfig();
|
||||||
const session = new Session({
|
const session = new Session({
|
||||||
workingDir,
|
workingDir,
|
||||||
@@ -333,6 +346,7 @@ export function registerSessionRoutes(
|
|||||||
claudeMode: claudeModeConfig.claudeMode,
|
claudeMode: claudeModeConfig.claudeMode,
|
||||||
allowedTools: claudeModeConfig.allowedTools,
|
allowedTools: claudeModeConfig.allowedTools,
|
||||||
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
|
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
|
||||||
|
codexConfig: mode === 'codex' ? body.codexConfig : undefined,
|
||||||
resumeSessionId: validatedResumeId,
|
resumeSessionId: validatedResumeId,
|
||||||
envOverrides: body.envOverrides,
|
envOverrides: body.envOverrides,
|
||||||
effort: body.effort,
|
effort: body.effort,
|
||||||
@@ -1112,6 +1126,7 @@ export function registerSessionRoutes(
|
|||||||
caseName = 'testcase',
|
caseName = 'testcase',
|
||||||
mode = 'claude',
|
mode = 'claude',
|
||||||
openCodeConfig,
|
openCodeConfig,
|
||||||
|
codexConfig,
|
||||||
envOverrides,
|
envOverrides,
|
||||||
effort,
|
effort,
|
||||||
} = parseBody(QuickStartSchema, req.body);
|
} = 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.
|
// 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
|
// 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.
|
// external project directories are honoured by quick-start just like regular case routes.
|
||||||
@@ -1179,9 +1205,11 @@ export function registerSessionRoutes(
|
|||||||
const qsModel =
|
const qsModel =
|
||||||
mode === 'opencode'
|
mode === 'opencode'
|
||||||
? openCodeConfig?.model
|
? openCodeConfig?.model
|
||||||
: mode !== 'shell'
|
: mode === 'codex'
|
||||||
? qsModelConfig?.defaultModel || undefined
|
? codexConfig?.model
|
||||||
: undefined;
|
: mode !== 'shell'
|
||||||
|
? qsModelConfig?.defaultModel || undefined
|
||||||
|
: undefined;
|
||||||
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
|
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
|
||||||
const session = new Session({
|
const session = new Session({
|
||||||
workingDir: casePath,
|
workingDir: casePath,
|
||||||
@@ -1193,6 +1221,7 @@ export function registerSessionRoutes(
|
|||||||
claudeMode: qsClaudeModeConfig.claudeMode,
|
claudeMode: qsClaudeModeConfig.claudeMode,
|
||||||
allowedTools: qsClaudeModeConfig.allowedTools,
|
allowedTools: qsClaudeModeConfig.allowedTools,
|
||||||
openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
|
openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
|
||||||
|
codexConfig: mode === 'codex' ? codexConfig : undefined,
|
||||||
envOverrides,
|
envOverrides,
|
||||||
effort,
|
effort,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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)
|
// State & Lifecycle (cleanup, lifecycle log, stats)
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|||||||
+29
-4
@@ -46,7 +46,7 @@ const safePathSchema = z.string().max(1000).refine(isValidWorkingDir, {
|
|||||||
// ========== Env Var Allowlist ==========
|
// ========== Env Var Allowlist ==========
|
||||||
|
|
||||||
/** Allowlisted env var key prefixes */
|
/** 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) */
|
/** Env var keys that are always blocked (security-sensitive) */
|
||||||
const BLOCKED_ENV_KEYS = new Set([
|
const BLOCKED_ENV_KEYS = new Set([
|
||||||
@@ -76,7 +76,7 @@ const safeEnvOverridesSchema = z
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
message:
|
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();
|
.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({
|
export const CreateSessionSchema = z.object({
|
||||||
workingDir: safePathSchema.optional(),
|
workingDir: safePathSchema.optional(),
|
||||||
mode: z.enum(['claude', 'shell', 'opencode']).optional(),
|
mode: z.enum(['claude', 'shell', 'opencode', 'codex']).optional(),
|
||||||
name: z.string().max(100).optional(),
|
name: z.string().max(100).optional(),
|
||||||
envOverrides: safeEnvOverridesSchema,
|
envOverrides: safeEnvOverridesSchema,
|
||||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
/** 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. */
|
/** Model override to write to .claude/settings.local.json (e.g., "opus[1m]"). Empty string clears. */
|
||||||
modelOverride: z.string().max(50).optional(),
|
modelOverride: z.string().max(50).optional(),
|
||||||
openCodeConfig: OpenCodeConfigSchema,
|
openCodeConfig: OpenCodeConfigSchema,
|
||||||
|
codexConfig: CodexConfigSchema,
|
||||||
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
|
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
|
||||||
resumeSessionId: z
|
resumeSessionId: z
|
||||||
.string()
|
.string()
|
||||||
@@ -188,8 +210,9 @@ export const QuickStartSchema = z.object({
|
|||||||
.string()
|
.string()
|
||||||
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.')
|
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.')
|
||||||
.optional(),
|
.optional(),
|
||||||
mode: z.enum(['claude', 'shell', 'opencode']).optional(),
|
mode: z.enum(['claude', 'shell', 'opencode', 'codex']).optional(),
|
||||||
openCodeConfig: OpenCodeConfigSchema,
|
openCodeConfig: OpenCodeConfigSchema,
|
||||||
|
codexConfig: CodexConfigSchema,
|
||||||
envOverrides: safeEnvOverridesSchema,
|
envOverrides: safeEnvOverridesSchema,
|
||||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||||
effort: effortLevelSchema,
|
effort: effortLevelSchema,
|
||||||
@@ -300,6 +323,8 @@ export const SettingsUpdateSchema = z
|
|||||||
// Claude CLI settings
|
// Claude CLI settings
|
||||||
claudeMode: z.string().max(50).optional(),
|
claudeMode: z.string().max(50).optional(),
|
||||||
allowedTools: z.string().max(2000).optional(),
|
allowedTools: z.string().max(2000).optional(),
|
||||||
|
// Codex CLI settings
|
||||||
|
codexDangerouslyBypassApprovals: z.boolean().optional(),
|
||||||
// CPU priority
|
// CPU priority
|
||||||
nice: z
|
nice: z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* @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,
|
||||||
|
},
|
||||||
|
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 () => ({ available: true }) };
|
||||||
|
if (url === '/api/quick-start') return { json: async () => ({ success: true, 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 = () => ({});
|
||||||
|
app.selectSession = async () => {};
|
||||||
|
|
||||||
|
await app.runCodex();
|
||||||
|
|
||||||
|
expect(requests.find((req) => req.url === '/api/quick-start')?.body).toMatchObject({
|
||||||
|
caseName: 'codex-case',
|
||||||
|
mode: 'codex',
|
||||||
|
codexConfig: { dangerouslyBypassApprovals: true, renderMode: 'hybrid' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user