Compare commits

...
Author SHA1 Message Date
arkonandClaude Fable 5 0be3d09603 chore: version packages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:48:32 +02:00
arkonandClaude Fable 5 96035ffa1f feat(settings): Claude Model picker for new sessions; Fable 5 in model dropdowns
App Settings → Claude CLI gains a Claude Model select (claudeModel setting)
that pins the model for new Claude sessions via the case's
.claude/settings.local.json, taking precedence over the 1M Opus toggle.
Fable 5 added to the orchestrator default/phase model dropdowns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:47:05 +02:00
arkonandClaude Opus 4.8 7dd7614760 docs+test: document Codex run mode in CLAUDE.md; make tests immune to CODEMAN_GESTURE
CLAUDE.md: tech-stack, envOverrides, and prefix-discipline sections now
cover the Codex (OpenAI CLI) run mode merged in PR #114 (SessionMode
'codex', codex-cli-resolver, CODEX_* allowlist).

test/setup.ts: strip CODEMAN_GESTURE like the auth vars — when the
shell exports it, renderIndexHtml injects the gesture-availability
flag and test/server-index-title.test.ts byte-identity assertions fail
(1 spurious failure in an otherwise-green local test:ci sweep).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:23:57 +02:00
Ark0N 9c986b2869 Merge pull request #114 from aakhter/pr/cod-34-codex
feat(codex): add Codex (OpenAI CLI) run-mode foundation
2026-06-10 16:22:52 +02:00
arkonandClaude Opus 4.8 8c7a9781fa fix(codex): review fixes — envelope handling, mode guards, UI parity
Blocker: runCodex read raw response shapes, but the global
preSerialization hook (server.ts) wraps every payload in the
{ success, data } envelope — status.available was always undefined, so
the UI unconditionally printed "Codex CLI not found" and could never
start a session; the created session was also never auto-selected
(data.sessionId vs data.data.sessionId). Fixed both reads to match
runOpenCode, and updated the test mocks to the real wire shape (plus a
selectSession assertion) so envelope drift fails the test.

Guard parity: export isExternalCliMode() from session.ts and use it in
the ralph-config guard, all three respawn guards, and the six restore/
setup guards in server.ts that previously only excluded 'opencode' —
codex sessions could otherwise get a Ralph tracker or respawn
controller attached (idle detection is Claude-specific and output-
silence respawn cycling would misfire on a quiet codex TUI).

UI parity: cx tab badge, "Kill Tmux & Codex" dialog title, and the
missing CSS (.run-mode-dot.codex, .tab-mode.codex, .mode-codex button
colors — purple) so the Codex menu dot is no longer invisible. Removed
the dead object-literal runMode getter that Object.assign flattens
(superseded by the defineProperty accessor this PR adds).

Verified end-to-end on an isolated instance with a stub codex binary:
10/10 Playwright checks (menu/dot/label/button styling, session
created + auto-selected, cx badge, TUI output streamed, ralph+respawn
guards reject codex) and --dangerously-bypass-approvals-and-sandbox
+ --model observed on the spawned command line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:16:20 +02:00
Aamer AkhterandSaqeb Akhter 70378315da 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>
2026-06-10 09:34:52 -04:00
arkonandClaude Opus 4.8 f8b2a2a347 docs: document the response-viewer (eye) button visibility toggle in CLAUDE.md
CLAUDE.md audit against current tree: all commands, counts, and
architecture claims verified accurate; the only drift was the new
showResponseViewer toggle (8a995cb) and its hidden-by-default flip
(dd44976), now covered in the Frontend section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:52:18 +02:00
arkonandClaude Opus 4.8 dd449765de feat(ui): hide the response-viewer (eye) header button by default
Flip both showResponseViewer fallbacks to false and ship the template
with the hidden marker class so fresh installs never flash the button
before settings apply (enabling it via App Settings -> Display ->
Response Viewer still works live and survives reload).

Verified via Playwright on a fresh instance: 5/5 — hidden + unchecked
by default on desktop, enable shows live + persists, marker class
mirrors the per-device setting on mobile.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 12:16:47 +02:00
arkonandClaude Opus 4.8 8a995cb9e3 feat(ui): toggle to hide the response-viewer (eye) header button
The eye button (View last response) was the only header control with
no visibility setting. Add App Settings -> Display -> "Response
Viewer" (showResponseViewer, default on, per-device like the other
header toggles; added to the displayKeys no-cross-device-sync set
along with showLifecycleLog, which was missing from it).

Hiding uses a marker class with higher specificity — the base rule is
display:inline-flex !important, so an inline style cannot override it.

Verified via Playwright on a fresh instance: 8/8 — default visible,
hides live on save, persists across reload, server schema accepts the
key, re-enable restores, mobile storage isolated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 12:10:31 +02:00
23 changed files with 639 additions and 67 deletions
+14
View File
@@ -1,5 +1,19 @@
# aicodeman
## 0.9.9
### Patch Changes
- Codex (OpenAI CLI) run mode, Claude Model picker, and response-viewer button now opt-in.
**Codex (OpenAI CLI) run mode** (#114): new `codex` session mode alongside Claude Code and OpenCode. Sessions launch the Codex CLI via tmux with secrets injected through `tmux setenv` (`OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` — never on the command line). Supports `--model`, `resume <id>`, and `--dangerously-bypass-approvals-and-sandbox` via the `codexConfig` payload or the new App Settings → Codex CLI tab (`codexDangerouslyBypassApprovals`). Availability surfaced at `GET /api/codex/status` with an install hint when the binary is missing. Frontend gets a "Run CX" run-mode option; Respawn/Ralph options stay Claude-only (session options open on the Summary tab for external-CLI sessions). `CODEX_*` env prefix added to the env-override allowlist.
**Claude Model picker**: App Settings → Claude CLI gains a "Claude Model" select (`claudeModel` setting) that pins the model for new Claude sessions via the case's `.claude/settings.local.json` — e.g. Fable 5 (1M context), Fable 5, Opus (1M), Opus, Sonnet, Haiku. It takes precedence over the legacy 1M Opus Context toggle. Fable 5 also added to the orchestrator default/phase model dropdowns.
**Response-viewer (eye) header button is now hidden by default** — existing users who relied on it can re-enable it under App Settings → Display → Response Viewer (`showResponseViewer`, per-device setting). A new Display toggle controls its visibility.
Also: tests made immune to a set `CODEMAN_GESTURE` env var; CLAUDE.md documents the Codex run mode and the eye-button toggle.
## 0.9.8
### Patch Changes
+11 -7
View File
@@ -56,13 +56,13 @@ When user says "COM":
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
**Version**: 0.9.8 (must match `package.json`)
**Version**: 0.9.9 (must match `package.json`)
## Project Overview
Codeman is a Claude Code session manager with web interface and autonomous Ralph Loop. Spawns Claude CLI via PTY, streams via SSE, supports respawn cycling for 24+ hour autonomous runs.
**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, node-pty, xterm.js. Supports both Claude Code and OpenCode AI CLIs via pluggable CLI resolvers.
**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, node-pty, xterm.js. Supports Claude Code, OpenCode, and Codex (OpenAI) CLIs via pluggable CLI resolvers (`SessionMode = 'claude' | 'shell' | 'opencode' | 'codex'`).
**TypeScript Strictness** (see `tsconfig.json`): `noUnusedLocals`, `noUnusedParameters`, `noImplicitReturns`, `noImplicitOverride`, `noFallthroughCasesInSwitch`, `allowUnreachableCode: false`, `allowUnusedLabels: false`.
@@ -100,9 +100,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
- **ESM only** — Never `require()`, use `await import()`. `tsx` masks CJS/ESM issues in dev but production breaks
- **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly. Both `aicodeman` and `codeman` bin aliases are installed (`package.json` `bin`)
- **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically)
- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` env vars** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `<case>/.claude/settings.local.json` — that's the old path and creates UI/disk drift
- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` env vars** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `<case>/.claude/settings.local.json` — that's the old path and creates UI/disk drift
- **Effort is NOT an env var** — never carry effort as `CLAUDE_CODE_EFFORT_LEVEL`: the env var hard-locks effort and blocks in-session `/effort` switching (incl. ultracode). It flows as the dedicated `effort` payload field → `Session._effort` → `claude --effort <level>` for regular levels incl. `max` (the settings `effortLevel` key is `enum(["low","medium","high","xhigh"]).catch(undefined)` — `max` gets SILENTLY dropped there), or `claude --settings '{"ultracode":true}'` for ultracode (rejected by `--effort`). Both are soft defaults the user can override anytime. Legacy env-var entries are auto-migrated by the Session constructor and unset from tmux sessions in `applyEnvOverrides()`. See `buildEffortCliArgs()` in `session-cli-builder.ts`, tests in `test/effort-injection.test.ts`
- **Dual-CLI prefix discipline** — Codeman supports both Claude Code and OpenCode (`claude-cli-resolver.ts` / `opencode-cli-resolver.ts`); env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*`) and the allowlist in `schemas.ts` enforces this. When adding settings, decide which CLI(s) it applies to and gate the env export accordingly — don't blindly forward both prefixes. See `docs/opencode-integration.md` for the OpenCode resolver design
- **Multi-CLI prefix discipline** — Codeman supports Claude Code, OpenCode, and Codex (`claude-cli-resolver.ts` / `opencode-cli-resolver.ts` / `codex-cli-resolver.ts`); env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*`) and the allowlist in `schemas.ts` enforces this. When adding settings, decide which CLI(s) it applies to and gate the env export accordingly — don't blindly forward all prefixes. See `docs/opencode-integration.md` for the OpenCode resolver design
- **Zod `.optional()` rejects `null`** — accepts `undefined` only. When the frontend builds a request body with `JSON.stringify`, an explicit `null` field is preserved on the wire and fails validation with `INVALID_INPUT`. Convert `null` → `undefined` before stringifying (e.g. `field: value ?? undefined`), or declare the schema `.nullish()`. Real bugs caused: 0.6.4 (`durationMinutes` for ∞ respawn), and the same shape pattern hit `opusContext1mEnabled` in 0.6.3
- **`xterm-zerolag-input` is single-source — edit the package, then rebuild the bundle** — the local-echo overlay source lives ONLY in `packages/xterm-zerolag-input/src/` (`zerolag-input-addon.ts`; also published to npm as a standalone library — see README "Published Packages"). It is bundled (esbuild → IIFE, with appended `window.LocalEchoOverlay` aliases) into the **gitignored** `src/web/public/vendor/xterm-zerolag-input.js` by `scripts/postinstall.js` (for dev/`tsx`) and into `dist/.../vendor/` by `scripts/build.mjs:50` (for prod). `app.js` only **consumes** it via `new LocalEchoOverlay(terminal)` — there is NO inline copy to keep in sync. So: change behavior in the package source, then re-run the bundle step (`npm install` reruns postinstall; `npm run build` for prod); **never hand-edit `app.js` for overlay behavior or commit the gitignored vendor bundle**. A public-API break in the package still warrants a separate `xterm-zerolag-input` version bump in the changeset. Always test on mobile after touching it. See `docs/local-echo-overlay-plan.md`.
- **Default bind is loopback-only; non-loopback without a password starts but warns** — since COD-29 (PR #107) the web server defaults to `--host 127.0.0.1` (was `0.0.0.0`). As of **0.9.0** binding a non-loopback host (`--host`/`-H`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` **no longer refuses to start — it starts and prints a loud warning** listing the fixes (set `CODEMAN_PASSWORD`, bind loopback + tunnel/`tailscale serve`, or `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` to acknowledge → terser note). Host classification is `isLoopbackBindHost()` in `network-auth-policy.ts`; the warn-vs-start logic is in `server.ts` `start()`; flags wired in `cli.ts`. ⚠️ Operational note: the production systemd unit runs `node dist/index.js web --https` with no `--host`, so it binds **localhost only** — reach it remotely via `tailscale serve`/tunnel to `127.0.0.1`, or add `Environment=CODEMAN_HOST=0.0.0.0` + `Environment=CODEMAN_PASSWORD=…` to `~/.config/systemd/user/codeman-web.service`. A loopback bind is reachable through a same-host tunnel (cloudflared/tailscale → `127.0.0.1`) but NOT by a browser hitting the box's LAN IP. Auth user defaults to `admin`. **Full model: `docs/security-architecture.md`.**
@@ -138,7 +138,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Config**: `src/config/` — 10 files, no barrel (`index.ts`) exists; import from the specific file.
**Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap`, `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`, `KeyedDebouncer`. Also: `claude-cli-resolver`/`opencode-cli-resolver` (CLI path resolution), `string-similarity` (fuzzy matching), `regex-patterns` (ANSI/token/spinner patterns), `assertNever` (exhaustive checks), `token-validation` (auth tokens), `nice-wrapper` (process priority).
**Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap`, `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`, `KeyedDebouncer`. Also: `claude-cli-resolver`/`opencode-cli-resolver`/`codex-cli-resolver` (CLI path resolution), `string-similarity` (fuzzy matching), `regex-patterns` (ANSI/token/spinner patterns), `assertNever` (exhaustive checks), `token-validation` (auth tokens), `nice-wrapper` (process priority).
### Data Flow
@@ -155,6 +155,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Orchestrator**: State machine that turns a user goal into a phased plan and drives it to completion: `idle → planning → approval → executing → verifying → (replanning) → completed/failed`. `OrchestratorLoop` (engine) delegates plan generation to `orchestrator-planner` and per-phase verification gates to `orchestrator-verifier`, executing phases via team agents/`task-queue`. State persists under the `orchestrator` key in `state.json`. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See `docs/orchestrator-loop-architecture.md`.
**External CLI modes (OpenCode, Codex)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). Both modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv`, never on the spawn command line: OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars` in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume <id>`, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode); tmux exports `COLORTERM=truecolor` + unsets `NO_COLOR` (other modes unset `COLORTERM`); availability via `GET /api/codex/status` — session/quick-start routes fail with `OPERATION_FAILED` and an install hint (`npm install -g @openai/codex`) when the binary is missing. Frontend: run-mode dropdown → `runCodex()` in `session-ui.js` ("Run CX" label), App Settings → Codex CLI tab; Respawn/Ralph options are Claude-only, so session options open on the Summary tab for external CLI sessions. Tests: `test/run-mode-ui.test.ts` (vm-sandbox harness, no real DOM).
**Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`. See `src/hooks-config.ts`.
**Agent Teams**: `TeamWatcher` polls `~/.claude/teams/`, matches to sessions via `leadSessionId`. Teammates are in-process threads appearing as subagents. Enable: `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`. See `docs/agent-teams/`.
@@ -173,6 +175,8 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Multi-monitor button** (header, top-right; the notification bell it sits beside stays hidden — notifications live in Settings → Notifications). `app.launchMultiMonitor()` (in `panels-ui.js`) POSTs `/api/system/span-displays`, which spawns `scripts/span-codeman.sh` — a fresh, maximized browser `--app` window sized to the union of all displays (macOS; needs "Displays have separate Spaces" OFF). Supports the gesture layer's in-page floating session panels dragging across the physical monitor seam. **Opt-in:** hidden by default; enable under App Settings → Display → **Header Displays** ("Multi-monitor Button", `showMultiMonitorButton`). The button carries a `btn-multimonitor--hidden` class in the template; `renderIndexHtml` strips that class at render when the setting is on (a unique class token, not a brittle match on the aria-label/style copy), and `applyHeaderVisibilitySettings()` toggles the same class live on save. Solo (detached) windows hide it via `body.solo-mode`.
**Response-viewer (eye) button** (header) is likewise **hidden by default** — enable under App Settings → Display → **Response Viewer** (`showResponseViewer`). Purely client-side (no `renderIndexHtml` step): the template ships with `btn-response-viewer-header--hidden` and `applyHeaderVisibilitySettings()` (settings-ui.js) toggles it after settings load. Hiding must go through that marker class — the base rule is `display:inline-flex !important`, so an inline style can't override it. `showResponseViewer` is in the `displayKeys` per-device set (settings-ui.js), so it does NOT sync across devices.
**Gesture control** (the camera hand-tracking overlay) is **opt-in, default OFF**, under App Settings → Display → **Input** (`gestureControlEnabled`). `CODEMAN_GESTURE=1` makes the feature *available* on the instance (CSP widening + `/gesture/` assets) and sets `window.__codemanGestureAvailable` (the Input section only shows when set); the overlay bundle is injected by `renderIndexHtml` **only when the setting is enabled**, so that method is `async` and reads `settings.json` via `readSettings(true)` — the `true` forces a **fresh** read (bypassing the 2s `_settingsCache`), because a post-save reload happens within that TTL and the cached value would otherwise render the pre-toggle state. Toggling the setting reloads the page (the bundle is render-injected).
**Gesture-control source lives in-repo** at `packages/gesture-control/` (workspace package `codeman-gesture-control`, was the standalone `Ark0N/codeman-gesture-control` repo). The transport-agnostic core is `src/gesture/*` (MediaPipe GestureRecognizer → One-Euro-filtered cursor → pinch state machine); `src/codeman/entry.ts` is the Codeman *consumer* that maps grab/drag/drop onto real `.session-tab`/toolbar buttons and is the bundle entry. **Edit there, then run `npm run build:gesture`** (`scripts/build-gesture-bundle.mjs` → esbuild bundles `entry.ts`, MediaPipe JS included, into `src/web/public/gesture/gesture-codeman.js`) and **commit the regenerated bundle** — the committed bundle is what dev/`tsx` serves (no bundler at runtime), and `scripts/build.mjs` reruns the same step so prod always reflects current source. The MediaPipe **wasm + model** are NOT bundled — loaded at runtime from same-origin `/gesture/wasm` + `/gesture/gesture_recognizer.task`, fetched by `scripts/fetch-gesture-assets.mjs` (gitignored, see Gotchas). `entry.ts` mounts `window.__codemanGesture = new GestureBridge()` idempotently at module-eval. A standalone vite playground (`npm run dev` in the package — fake tabs, no Codeman) lets you iterate on gesture *feel* in isolation. ⚠️ Keep `MP_VERSION` in `fetch-gesture-assets.mjs` in sync with `@mediapipe/tasks-vision` in `packages/gesture-control/package.json`.
@@ -196,7 +200,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
| **Rate limit** | 10 failed auth/IP → 429 (15min decay). QR has separate limiter |
| **Hook bypass** | `/api/hook-event` exempt from auth (localhost-only, schema-validated) |
| **Env vars** | `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks), `CODEMAN_ALLOWED_HOSTS` (extra Host/Origin allowlist entries for reverse proxies, comma-separated; bare `.suffix` matches subdomains) |
| **Validation** | Zod schemas, path allowlist regex, `CLAUDE_CODE_*` env prefix allowlist |
| **Validation** | Zod schemas, path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`) |
| **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS |
### SSE Event Registry
@@ -205,7 +209,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
### API Routes
~134 handlers across 15 route files in `src/web/routes/`: system (40, incl. self-update `check`/`status`/`POST /api/system/update` + `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`), sessions (28), orchestrator (10), cases (9), ralph (9), plan (8), respawn (7), files (6), mux (5), push (4), scheduled (4), teams (2), hooks (1), clipboard (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
~135 handlers across 15 route files in `src/web/routes/`: system (41, incl. self-update `check`/`status`/`POST /api/system/update`, `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`, and `GET /api/codex/status`), sessions (28), orchestrator (10), cases (9), ralph (9), plan (8), respawn (7), files (6), mux (5), push (4), scheduled (4), teams (2), hooks (1), clipboard (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
**HTTP contract** (stable since 0.9.x, see `docs/versioning-policy.md`): responses use the `ApiResponse<T>` envelope — `{ success: true, data? }` or `{ success: false, error, errorCode }` (`src/types/api.ts`). `/api/v1/*` is a versioned alias of `/api/*` (URL rewrite in `server.ts`).
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "0.9.8",
"version": "0.9.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "0.9.8",
"version": "0.9.9",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "0.9.8",
"version": "0.9.9",
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+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');
+43 -2
View File
@@ -110,7 +110,7 @@
<span class="stat-value" id="statMem">--</span>
</div>
</div>
<button class="btn-icon-header btn-response-viewer-header" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
<button class="btn-icon-header btn-response-viewer-header btn-response-viewer-header--hidden" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications" style="display:none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
@@ -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>
@@ -985,6 +989,13 @@
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Show the response viewer (eye) button in header">
<span class="settings-item-label">Response Viewer</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsShowResponseViewer">
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Show the multi-monitor button in the header (opens Codeman spanned across all displays)">
<span class="settings-item-label">Multi-monitor Button</span>
<label class="switch switch-sm">
@@ -1137,13 +1148,26 @@
</label>
<span class="form-hint">Enable experimental Agent Teams for all new Claude sessions (disabled by default)</span>
</div>
<div class="form-row">
<label>Claude Model</label>
<select id="appSettingsClaudeModel" class="form-select">
<option value="">Default (CLI setting)</option>
<option value="claude-fable-5[1m]">Fable 5 (1M context)</option>
<option value="claude-fable-5">Fable 5</option>
<option value="opus[1m]">Opus (1M context)</option>
<option value="opus">Opus</option>
<option value="sonnet">Sonnet</option>
<option value="haiku">Haiku</option>
</select>
<span class="form-hint">Model for new Claude sessions (pinned via the case's .claude/settings.local.json) — takes precedence over the 1M Opus toggle below</span>
</div>
<div class="form-row form-row-switch">
<label>1M Opus Context</label>
<label class="switch">
<input type="checkbox" id="appSettingsOpusContext1m">
<span class="slider"></span>
</label>
<span class="form-hint">Use 1M token context window (model: opus[1m]) for all new sessions</span>
<span class="form-hint">Use 1M token context window (model: opus[1m]) for all new sessions — ignored when a Claude Model is selected above</span>
</div>
<div class="form-row">
<label>Thinking Effort</label>
@@ -1174,12 +1198,25 @@
<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">
<label>Default Model</label>
<select id="appSettingsDefaultModel" class="form-select">
<option value="">Default (CLI default)</option>
<option value="claude-fable-5">Fable 5 (Most powerful)</option>
<option value="opus">Opus (Most capable)</option>
<option value="sonnet">Sonnet (Balanced)</option>
<option value="haiku">Haiku (Fast & cheap)</option>
@@ -1203,6 +1240,7 @@
<option value="haiku">Haiku</option>
<option value="sonnet">Sonnet</option>
<option value="opus">Opus</option>
<option value="claude-fable-5">Fable 5</option>
</select>
<span class="form-hint">Quick searches, codebase exploration</span>
</div>
@@ -1213,6 +1251,7 @@
<option value="haiku">Haiku</option>
<option value="sonnet">Sonnet</option>
<option value="opus">Opus</option>
<option value="claude-fable-5">Fable 5</option>
</select>
<span class="form-hint">Code writing, feature implementation</span>
</div>
@@ -1223,6 +1262,7 @@
<option value="haiku">Haiku</option>
<option value="sonnet">Sonnet</option>
<option value="opus">Opus</option>
<option value="claude-fable-5">Fable 5</option>
</select>
<span class="form-hint">Writing and running tests</span>
</div>
@@ -1233,6 +1273,7 @@
<option value="haiku">Haiku</option>
<option value="sonnet">Sonnet</option>
<option value="opus">Opus</option>
<option value="claude-fable-5">Fable 5</option>
</select>
<span class="form-hint">Code review, quality checks</span>
</div>
+71 -7
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';
}
},
@@ -350,8 +354,10 @@ Object.assign(CodemanApp.prototype, {
const envOverrides = this.buildEnvOverrides(caseSettings, globalSettings);
const hasEnvOverrides = Object.keys(envOverrides).length > 0;
const effort = this.getEffortSetting(globalSettings);
// Explicit Claude Model choice (App Settings) wins over the legacy 1M Opus
// toggles; both flow as `modelOverride` → the case's .claude/settings.local.json
const useOpus1m = caseSettings.opusContext1m || globalSettings.opusContext1mEnabled;
const modelOverride = useOpus1m ? 'opus[1m]' : '';
const modelOverride = globalSettings.claudeModel || (useOpus1m ? 'opus[1m]' : '');
// Step 1: Create all sessions in parallel
this.terminal.writeln(`\x1b[90m Creating ${tabCount} session(s)...\x1b[0m`);
@@ -580,6 +586,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 +645,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 +674,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 +1527,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';
},
});
+18
View File
@@ -308,6 +308,7 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsShowTokenCount').checked = settings.showTokenCount ?? defaults.showTokenCount ?? true;
document.getElementById('appSettingsShowCost').checked = settings.showCost ?? defaults.showCost ?? false;
document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? true;
document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false;
document.getElementById('appSettingsShowMonitor').checked = settings.showMonitor ?? defaults.showMonitor ?? false;
document.getElementById('appSettingsShowProjectInsights').checked = settings.showProjectInsights ?? defaults.showProjectInsights ?? false;
document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? defaults.showFileBrowser ?? false;
@@ -339,8 +340,12 @@ 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('appSettingsClaudeModel').value = settings.claudeModel ?? '';
document.getElementById('appSettingsOpusContext1m').checked = settings.opusContext1mEnabled ?? false;
document.getElementById('appSettingsThinkingEffort').value = settings.thinkingEffort ?? '';
// CPU Priority settings
@@ -1323,6 +1328,7 @@ Object.assign(CodemanApp.prototype, {
showTokenCount: document.getElementById('appSettingsShowTokenCount').checked,
showCost: document.getElementById('appSettingsShowCost').checked,
showLifecycleLog: document.getElementById('appSettingsShowLifecycleLog').checked,
showResponseViewer: document.getElementById('appSettingsShowResponseViewer').checked,
showMonitor: document.getElementById('appSettingsShowMonitor').checked,
showProjectInsights: document.getElementById('appSettingsShowProjectInsights').checked,
showFileBrowser: document.getElementById('appSettingsShowFileBrowser').checked,
@@ -1340,8 +1346,11 @@ 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,
claudeModel: document.getElementById('appSettingsClaudeModel').value,
opusContext1mEnabled: document.getElementById('appSettingsOpusContext1m').checked,
thinkingEffort: document.getElementById('appSettingsThinkingEffort').value,
// CPU Priority settings
@@ -1670,6 +1679,14 @@ Object.assign(CodemanApp.prototype, {
lifecycleBtn.style.display = showLifecycleLog ? '' : 'none';
}
// Hide the response viewer (eye) button when setting is disabled.
// Marker class, not inline style — the base rule is display:inline-flex !important.
const showResponseViewer = settings.showResponseViewer ?? defaults.showResponseViewer ?? false;
const responseViewerBtn = document.querySelector('.btn-response-viewer-header');
if (responseViewerBtn) {
responseViewerBtn.classList.toggle('btn-response-viewer-header--hidden', !showResponseViewer);
}
// Multi-monitor button — hidden by default (App Settings → Display → "Header
// Displays"). The server renders the correct initial state on every reload;
// this handles a live toggle from a settings save (no reload). Toggle the
@@ -1937,6 +1954,7 @@ Object.assign(CodemanApp.prototype, {
// are NOT display keys — they control server-side behavior and must sync from server.
const displayKeys = new Set([
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
'showLifecycleLog', 'showResponseViewer',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
]);
+28
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;
@@ -8115,6 +8137,12 @@ kbd {
display: inline-flex !important;
}
/* "Response Viewer" header toggle (App Settings → Display) — must out-specify
the inline-flex !important above */
.btn-response-viewer-header.btn-response-viewer-header--hidden {
display: none !important;
}
.response-viewer {
display: none;
position: fixed;
+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)
// ═══════════════════════════════════════════════════════════════
+32 -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,
@@ -282,6 +305,8 @@ export const SettingsUpdateSchema = z
tunnelEnabled: z.boolean().optional(),
tabTwoRows: z.boolean().optional(),
agentTeamsEnabled: z.boolean().optional(),
/** Model for new Claude sessions (e.g. "claude-fable-5[1m]", "opus[1m]"); takes precedence over opusContext1mEnabled */
claudeModel: z.string().max(50).optional(),
opusContext1mEnabled: z.boolean().optional(),
thinkingEffort: z.string().max(20).optional(),
// UI visibility
@@ -290,6 +315,7 @@ export const SettingsUpdateSchema = z
showTokenCount: z.boolean().optional(),
showCost: z.boolean().optional(),
showLifecycleLog: z.boolean().optional(),
showResponseViewer: z.boolean().optional(),
showMonitor: z.boolean().optional(),
showProjectInsights: z.boolean().optional(),
showFileBrowser: z.boolean().optional(),
@@ -300,6 +326,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']);
});
});
+4
View File
@@ -14,6 +14,10 @@ import { afterEach, vi } from 'vitest';
delete process.env.CODEMAN_PASSWORD;
delete process.env.CODEMAN_USERNAME;
// Gesture availability changes renderIndexHtml output (injects the
// __codemanGestureAvailable flag), breaking byte-identity assertions
// (test/server-index-title.test.ts) when the shell exports CODEMAN_GESTURE=1.
delete process.env.CODEMAN_GESTURE;
afterEach(() => {
vi.clearAllMocks();