mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-01 13:09:42 +02:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fad32eeaab | ||
|
|
c1458d8ab8 | ||
|
|
0be3d09603 | ||
|
|
96035ffa1f | ||
|
|
7dd7614760 | ||
|
|
9c986b2869 | ||
|
|
8c7a9781fa | ||
|
|
70378315da | ||
|
|
f8b2a2a347 | ||
|
|
dd449765de | ||
|
|
8a995cb9e3 |
@@ -1,5 +1,29 @@
|
||||
# aicodeman
|
||||
|
||||
## 0.9.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Self-update now restarts automatically on headless Macs supervised by a system LaunchDaemon.
|
||||
|
||||
New `launchd-daemon` supervisor kind: when Codeman runs under a bootstrapped, KeepAlive system-level LaunchDaemon (`/Library/LaunchDaemons/com.codeman.web.plist` — the right setup for headless Macs, where LaunchAgents never start because there is no GUI login), the updater no longer ends with "Update staged — restart Codeman to apply". It restarts rootlessly: the update script kills the server PID (passed via `--server-pid`) and launchd respawns it on the freshly built `dist/`. Detection is conservative — the daemon must be bootstrapped in the system domain AND have `KeepAlive` enabled.
|
||||
|
||||
Also fixed: a lingering "restart Codeman to apply" status. After a manual restart of a staged update, boot reconciliation now flips `completed-needs-manual-restart` to `completed` once the running version matches the staged target, so the Updates tab stops showing the stale instruction.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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.10 (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,13 +155,15 @@ 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/`.
|
||||
|
||||
**Circuit breaker**: Prevents respawn thrashing. States: `CLOSED` → `HALF_OPEN` → `OPEN`. Reset: `/api/sessions/:id/ralph-circuit-breaker/reset`.
|
||||
|
||||
**Self-update** (App Settings → Updates): in-app updater for **git-clone installs** supervised by systemd/launchd. The update restarts the very process running it, so the real work runs in a DETACHED `scripts/self-update.sh` (`git checkout <release tag> && npm install && npm run build && restart`) that outlives the restart; it writes progress to `dataPath('update-status.json')`, which the browser polls across the connection drop. Channel = latest `codeman@X.Y.Z` release tag; dirty trees are auto-stashed. `src/web/self-update.ts` splits PURE helpers (semver/tag parsing, reconcile decision — unit-tested) from IO wrappers (`getInstallInfo`/`checkForUpdate`/`startUpdate`/`reconcileUpdateOnBoot`). Routes: `GET /api/system/update/check`, `POST /api/system/update`, `GET /api/system/update/status`. Types: `src/types/update.ts`. npm installs report as non-updatable.
|
||||
**Self-update** (App Settings → Updates): in-app updater for **git-clone installs** supervised by systemd/launchd. Supervisors: `systemd` (user unit), `launchd` (GUI LaunchAgent, gui-domain kickstart), `launchd-daemon` (KeepAlive system LaunchDaemon on headless Macs — restarts rootlessly by killing the server PID and letting launchd respawn it; detected only when the daemon is bootstrapped AND KeepAlive), else `none` → "restart manually" message; on next boot a manual-restart status auto-completes when the running version matches the target. The update restarts the very process running it, so the real work runs in a DETACHED `scripts/self-update.sh` (`git checkout <release tag> && npm install && npm run build && restart`) that outlives the restart; it writes progress to `dataPath('update-status.json')`, which the browser polls across the connection drop. Channel = latest `codeman@X.Y.Z` release tag; dirty trees are auto-stashed. `src/web/self-update.ts` splits PURE helpers (semver/tag parsing, reconcile decision — unit-tested) from IO wrappers (`getInstallInfo`/`checkForUpdate`/`startUpdate`/`reconcileUpdateOnBoot`). Routes: `GET /api/system/update/check`, `POST /api/system/update`, `GET /api/system/update/status`. Types: `src/types/update.ts`. npm installs report as non-updatable.
|
||||
|
||||
**Port interfaces**: Routes declare dependencies via port interfaces (`src/web/ports/`). Routes use intersection types (e.g., `SessionPort & EventPort`).
|
||||
|
||||
@@ -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`).
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "0.9.8",
|
||||
"version": "0.9.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "0.9.8",
|
||||
"version": "0.9.10",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "0.9.8",
|
||||
"version": "0.9.10",
|
||||
"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",
|
||||
|
||||
@@ -31,6 +31,7 @@ export PUPPETEER_SKIP_DOWNLOAD="${PUPPETEER_SKIP_DOWNLOAD:-1}"
|
||||
REPO=""
|
||||
TAG=""
|
||||
SUPERVISOR="none"
|
||||
SERVER_PID=""
|
||||
STATUS_FILE=""
|
||||
UPDATE_ID=""
|
||||
FROM_VERSION=""
|
||||
@@ -50,6 +51,7 @@ while [[ $# -gt 0 ]]; do
|
||||
--node) NODE="$2"; shift 2 ;;
|
||||
--log) LOG="$2"; shift 2 ;;
|
||||
--prev-sha) PREV_SHA="$2"; shift 2 ;;
|
||||
--server-pid) SERVER_PID="$2"; shift 2 ;;
|
||||
--stash) DO_STASH=1; shift ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
@@ -198,6 +200,19 @@ case "$SUPERVISOR" in
|
||||
|| fail "Build succeeded but launchd restart failed" "launchctl"
|
||||
}
|
||||
;;
|
||||
launchd-daemon)
|
||||
# System-level KeepAlive LaunchDaemon (headless Mac): kickstarting the system
|
||||
# domain needs root, but we don't need it — kill the server and launchd
|
||||
# respawns it on the new dist/ within ThrottleInterval seconds.
|
||||
if [[ -n "$SERVER_PID" ]] && kill "$SERVER_PID" 2>/dev/null; then
|
||||
: # respawn is launchd's job from here
|
||||
else
|
||||
MANUAL_CMD="sudo launchctl kickstart -k system/com.codeman.web"
|
||||
write_status "completed-needs-manual-restart" "Update staged — restart Codeman to apply v$TO_VERSION."
|
||||
echo "[self-update] launchd-daemon: could not signal server pid '$SERVER_PID' — manual restart required"
|
||||
exit 0
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
MANUAL_CMD="pkill -f 'codeman.*web'; codeman web &"
|
||||
write_status "completed-needs-manual-restart" "Update staged — restart Codeman to apply v$TO_VERSION."
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
+67
-431
@@ -1,363 +1,84 @@
|
||||
# CLAUDE.md - Project Configuration
|
||||
# CLAUDE.md
|
||||
|
||||
## Setup
|
||||
Copy these files to your new project:
|
||||
- `CLAUDE.md` → project root
|
||||
- `.claude/settings.json` → `.claude/settings.json`
|
||||
<!--
|
||||
Generated by Codeman on [DATE]. This file is loaded into context at the
|
||||
start of every Claude Code session in this project.
|
||||
|
||||
Then update the Project Overview section below.
|
||||
Keep it short (target: under 200 lines). For each line ask: "would removing
|
||||
this cause Claude to make mistakes?" If not, cut it. Don't document what
|
||||
Claude can infer from the code itself (file layout, standard conventions,
|
||||
APIs) — bloat causes Claude to ignore the rules that matter.
|
||||
|
||||
---
|
||||
HTML comments like this one are stripped before loading, so fill-in notes
|
||||
cost no context. If this file grows too big, split into path-scoped rules
|
||||
in .claude/rules/*.md or import other files with @path/to/file syntax.
|
||||
-->
|
||||
|
||||
This file guides Claude Code when working in this repository.
|
||||
|
||||
## Project
|
||||
|
||||
## Project Overview
|
||||
<!-- Update this section with project-specific details -->
|
||||
- **Project Name**: [PROJECT_NAME]
|
||||
- **Description**: [PROJECT_DESCRIPTION]
|
||||
- **Tech Stack**: [TECHNOLOGIES_USED]
|
||||
- **Last Updated**: [DATE]
|
||||
|
||||
---
|
||||
## Commands
|
||||
|
||||
<!-- List the exact commands Claude can't guess — fill in as the project
|
||||
takes shape, then delete this comment:
|
||||
|
||||
| Task | Command |
|
||||
|------|---------|
|
||||
| Dev server | `npm run dev` |
|
||||
| Test (single file) | `npm test -- test/<file>.test.ts` |
|
||||
| Lint | `npm run lint` |
|
||||
| Build | `npm run build` |
|
||||
-->
|
||||
|
||||
## Code Style
|
||||
|
||||
<!-- Only rules that differ from language/framework defaults, one line each:
|
||||
- Use 2-space indentation
|
||||
- ES modules only — never require()
|
||||
-->
|
||||
|
||||
## Workflow
|
||||
|
||||
- Full permissions are granted: read, write, edit, and execute without asking.
|
||||
- Commit after every meaningful change; never batch unrelated work.
|
||||
- Use conventional commits (`feat:` `fix:` `docs:` `refactor:` `test:` `chore:`); the message says what changed and why.
|
||||
- Run the tests and linter before declaring any task done.
|
||||
- Keep README and docs in sync with code changes.
|
||||
|
||||
## Codeman Environment
|
||||
|
||||
This session is managed by **Codeman** and runs within a tmux session.
|
||||
This session is managed by Codeman and runs inside tmux (`CODEMAN_MUX=1` confirms it).
|
||||
|
||||
**Important**: Check for `CODEMAN_MUX=1` environment variable to confirm.
|
||||
- Do NOT attempt to kill your own tmux session
|
||||
- The session persists across disconnects - your work is safe
|
||||
- Token usage, costs, and background tasks are tracked externally
|
||||
- NEVER kill your own session: no `tmux kill-session`, `pkill tmux`, or `pkill claude`.
|
||||
- The session persists across disconnects — your work is safe.
|
||||
- Hooks may auto-format or validate after writes; unexpected tool behavior usually means a hook ran. Keep working.
|
||||
|
||||
---
|
||||
## Ralph Loop (Codeman Autonomous Mode)
|
||||
|
||||
## Work Principles
|
||||
Start: `/ralph-loop:ralph-loop` · Cancel: `/ralph-loop:cancel-ralph` · Help: `/ralph-loop:help`
|
||||
|
||||
### Autonomy
|
||||
Full permissions granted. Act decisively without asking - read, write, edit, execute freely.
|
||||
You are in a Ralph loop when the prompt contains a completion phrase (e.g. `<promise>COMPLETE</promise>`). While looping:
|
||||
|
||||
### Git Discipline
|
||||
- **Commit after every meaningful change** - never batch unrelated work
|
||||
- Use conventional commits: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`
|
||||
- Commit message = what changed + why (not how)
|
||||
- Work incrementally: one sub-task at a time — implement, verify, commit, move on.
|
||||
- Fix failing tests and lint errors before starting the next task.
|
||||
- Check `git log --oneline -10` / `git diff HEAD~1` before retrying an approach that already failed.
|
||||
- Keep testing to ~20% of total effort; prioritize implementation. Don't refactor working code or add unrequested
|
||||
features as busy work.
|
||||
- If the prompt sets a minimum duration (e.g. "work for 4 hours"), record the start time (`date +%s`) and, when the
|
||||
primary tasks finish early, keep generating useful related work (edge cases, coverage, docs, cleanup, hardening)
|
||||
until the minimum time is reached.
|
||||
|
||||
### Documentation
|
||||
- Update README.md when adding features or changing setup
|
||||
- Update this file's session log after work sessions
|
||||
- Keep docs in sync with code changes
|
||||
Output the completion phrase ONLY when every requirement is verifiably done: all tests pass, lint is clean, the build
|
||||
succeeds, and changes are committed. Never output it early — and never withhold it for busy work once everything is
|
||||
genuinely done.
|
||||
|
||||
### Thinking
|
||||
Extended thinking is enabled. Use deep reasoning for complex architectural decisions, difficult bugs, and multi-file changes.
|
||||
### RALPH_STATUS block (required)
|
||||
|
||||
### Task Tracking (TodoWrite)
|
||||
**ALWAYS use TodoWrite** to track tasks. This is non-negotiable for anything beyond trivial single-step work.
|
||||
|
||||
**When to use TodoWrite:**
|
||||
- Multi-step tasks (3+ steps)
|
||||
- Bug fixes requiring investigation
|
||||
- Feature implementations
|
||||
- Any work where progress tracking helps
|
||||
- When the user provides multiple requests
|
||||
|
||||
**How to use it:**
|
||||
1. **Before starting**: Break down the work into discrete todos
|
||||
2. **During work**: Mark each todo `in_progress` before starting, `completed` when done
|
||||
3. **One at a time**: Only ONE todo should be `in_progress` at any moment
|
||||
4. **Immediately**: Mark todos complete the moment they're done - don't batch
|
||||
|
||||
**Why this matters:**
|
||||
- Gives the user visibility into your progress
|
||||
- Prevents forgetting tasks mid-work
|
||||
- Creates accountability checkpoints
|
||||
- Makes complex work manageable
|
||||
|
||||
**Example workflow:**
|
||||
```
|
||||
User: "Add user authentication with JWT"
|
||||
|
||||
→ TodoWrite:
|
||||
- [ ] Research existing auth patterns in codebase
|
||||
- [ ] Implement JWT token generation
|
||||
- [ ] Add login endpoint
|
||||
- [ ] Add token validation middleware
|
||||
- [ ] Add protected route example
|
||||
- [ ] Write tests
|
||||
|
||||
→ Mark "Research existing auth patterns" as in_progress
|
||||
→ Do the research
|
||||
→ Mark as completed, mark next as in_progress
|
||||
→ Continue until all done
|
||||
```
|
||||
|
||||
**Anti-patterns to avoid:**
|
||||
- Starting work without creating todos first
|
||||
- Having multiple todos `in_progress` simultaneously
|
||||
- Batching completions at the end
|
||||
- Skipping TodoWrite for "simple" multi-step tasks
|
||||
|
||||
---
|
||||
|
||||
## When to Use Agents
|
||||
|
||||
**Explore agent**: Codebase investigation, finding files, understanding architecture
|
||||
```
|
||||
"Use explore agent to find all authentication-related code"
|
||||
```
|
||||
|
||||
**Parallel agents**: Independent tasks that don't conflict
|
||||
```
|
||||
"Research auth, database, and API modules in parallel using separate agents"
|
||||
```
|
||||
|
||||
**Background execution**: Long-running operations (tests, builds)
|
||||
```
|
||||
"Run the test suite in the background while I continue"
|
||||
```
|
||||
|
||||
**Sequential chaining**: When second task depends on first
|
||||
```
|
||||
"Use code-reviewer to find issues, then use fixer to resolve them"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Planning Mode (Automatic)
|
||||
|
||||
**Automatically enter planning mode** when ANY of these conditions apply:
|
||||
- Multi-file changes (3+ files affected)
|
||||
- Architectural decisions
|
||||
- Unclear or evolving requirements
|
||||
- Risk mitigation on core systems
|
||||
- New feature implementation
|
||||
- Refactoring existing functionality
|
||||
|
||||
**Do NOT ask** whether to enter planning mode - just enter it when conditions are met.
|
||||
|
||||
Planning mode flow: read-only exploration → create plan → get approval → execute.
|
||||
|
||||
**Skip planning mode** only for:
|
||||
- Single-file bug fixes
|
||||
- Typo corrections
|
||||
- Simple config changes
|
||||
- Tasks with explicit step-by-step instructions from user
|
||||
|
||||
---
|
||||
|
||||
## Ralph Wiggum Loop (Autonomous Work Mode)
|
||||
|
||||
Ralph loops enable persistent, autonomous work on large tasks. When active, you continue iterating until completion criteria are met or the loop is cancelled.
|
||||
|
||||
### Starting a Ralph Loop
|
||||
- Start: `/ralph-loop:ralph-loop`
|
||||
- Cancel: `/ralph-loop:cancel-ralph`
|
||||
- Help: `/ralph-loop:help`
|
||||
|
||||
### Time-Aware Loops
|
||||
|
||||
When the user specifies a **minimum duration** (e.g., "optimize for 8 hours", "work on this for 2 hours"), the loop becomes time-aware:
|
||||
|
||||
**At loop start:**
|
||||
```bash
|
||||
# Record start time
|
||||
date +%s > /tmp/ralph_start_time
|
||||
echo "Loop started at $(date)"
|
||||
```
|
||||
|
||||
**Check elapsed time periodically:**
|
||||
```bash
|
||||
START=$(cat /tmp/ralph_start_time)
|
||||
NOW=$(date +%s)
|
||||
ELAPSED_HOURS=$(echo "scale=2; ($NOW - $START) / 3600" | bc)
|
||||
echo "Elapsed: $ELAPSED_HOURS hours"
|
||||
```
|
||||
|
||||
**Time-aware behavior:**
|
||||
1. Complete all primary tasks from the user's prompt
|
||||
2. After primary tasks done, check elapsed time
|
||||
3. If minimum duration NOT reached:
|
||||
- **Do NOT output completion phrase**
|
||||
- Self-generate additional related tasks
|
||||
- Continue working until minimum time elapsed
|
||||
4. Only output completion phrase when:
|
||||
- ALL primary tasks complete AND
|
||||
- Minimum duration reached (or exceeded)
|
||||
|
||||
**Self-generating additional tasks when time remains:**
|
||||
- Code optimization (performance, readability, DRY)
|
||||
- Test coverage improvements
|
||||
- Edge case handling
|
||||
- Error message improvements
|
||||
- Documentation gaps
|
||||
- Security hardening
|
||||
- Accessibility improvements
|
||||
- Code cleanup and dead code removal
|
||||
- Dependency updates
|
||||
- Type safety improvements
|
||||
|
||||
**Example time-aware prompt:**
|
||||
```
|
||||
"Optimize the API endpoints for the next 4 hours. Focus on performance first,
|
||||
then code quality. Minimum runtime: 4 hours."
|
||||
Completion phrase: <promise>TIME_COMPLETE</promise>
|
||||
```
|
||||
|
||||
**Time-aware loop behavior:**
|
||||
```
|
||||
[Start loop, record timestamp]
|
||||
[Complete primary optimization tasks - 2 hours elapsed]
|
||||
[Check time: 2/4 hours - NOT done yet]
|
||||
[Self-generate: "Add caching to database queries"]
|
||||
[Self-generate: "Optimize N+1 queries"]
|
||||
[Self-generate: "Add request batching"]
|
||||
[Continue working... 4.5 hours elapsed]
|
||||
[Check time: 4.5/4 hours - minimum reached]
|
||||
[All tasks complete, tests pass]
|
||||
<promise>TIME_COMPLETE</promise>
|
||||
```
|
||||
|
||||
### How You Know You're in a Ralph Loop
|
||||
|
||||
The user started the loop with a prompt containing:
|
||||
- Clear task requirements
|
||||
- A **completion phrase** (e.g., `<promise>COMPLETE</promise>`)
|
||||
- **Optional: minimum duration** (e.g., "for the next 4 hours")
|
||||
- Iteration limits (handled by the system)
|
||||
|
||||
Your job: Keep working until ALL requirements are verifiably done AND minimum time reached (if specified), then output the exact completion phrase.
|
||||
|
||||
### Core Behaviors During Ralph Loop
|
||||
|
||||
**1. Work Incrementally**
|
||||
- Complete one sub-task at a time
|
||||
- Verify it works before moving to the next
|
||||
- Don't try to do everything in one pass
|
||||
|
||||
**2. Commit Frequently**
|
||||
- Commit after each meaningful completion
|
||||
- Creates recovery points if something breaks
|
||||
- Shows progress in git history
|
||||
```
|
||||
git add . && git commit -m "feat(auth): add token refresh endpoint"
|
||||
```
|
||||
|
||||
**3. Self-Correct Relentlessly**
|
||||
```
|
||||
Loop:
|
||||
1. Implement/fix
|
||||
2. Run tests
|
||||
3. If tests fail → read error, fix, go to 1
|
||||
4. Run linter
|
||||
5. If lint errors → fix, go to 1
|
||||
6. Commit
|
||||
7. Continue to next task
|
||||
```
|
||||
|
||||
**4. Track Progress**
|
||||
Update the session log in this file as you complete tasks:
|
||||
```markdown
|
||||
| Date | Tasks Completed | Files Changed | Notes |
|
||||
|------|-----------------|---------------|-------|
|
||||
| YYYY-MM-DD | Add auth endpoint | auth.ts, routes.ts | Tests passing |
|
||||
```
|
||||
|
||||
**5. Use Git History When Stuck**
|
||||
If something isn't working:
|
||||
```bash
|
||||
git log --oneline -10
|
||||
git diff HEAD~1
|
||||
```
|
||||
See what you already tried. Don't repeat failed approaches.
|
||||
|
||||
**6. Completion Phrase = Contract**
|
||||
Only output the completion phrase (e.g., `<promise>COMPLETE</promise>`) when:
|
||||
- ALL requirements from the original prompt are done
|
||||
- ALL tests pass
|
||||
- ALL linting passes
|
||||
- Changes are committed
|
||||
|
||||
**Never output the completion phrase early.** The loop only ends when you say it's done.
|
||||
|
||||
### What Makes Good Completion Criteria
|
||||
|
||||
The user should provide criteria that are:
|
||||
- **Verifiable**: Tests pass, lint clean, build succeeds
|
||||
- **Measurable**: "5 endpoints", "all files in src/", "zero errors"
|
||||
- **Binary**: Done or not done, no ambiguity
|
||||
|
||||
If the original prompt has vague criteria, ask clarifying questions before starting heavy work.
|
||||
|
||||
### Self-Correction Pattern (Include in Your Work)
|
||||
|
||||
```
|
||||
FOR EACH TASK:
|
||||
1. Implement the change
|
||||
2. Run tests (npm test, pytest, go test, cargo test, etc.)
|
||||
- If fail → read error, fix, retry
|
||||
3. Run linter (npm run lint, ruff, golangci-lint, etc.)
|
||||
- If fail → fix, go to step 2
|
||||
4. Verify manually if needed
|
||||
5. Commit with descriptive message
|
||||
6. Update session log
|
||||
7. Move to next task
|
||||
|
||||
WHEN ALL TASKS DONE:
|
||||
1. Run full test suite
|
||||
2. Run full lint
|
||||
3. Verify build succeeds
|
||||
4. Review all changes: git diff main
|
||||
5. Only then output completion phrase
|
||||
```
|
||||
|
||||
### Example: How to Think During Ralph Loop
|
||||
|
||||
**Original prompt**: "Add CRUD endpoints for todos with validation"
|
||||
|
||||
**Your approach**:
|
||||
```
|
||||
Task breakdown:
|
||||
- [ ] GET /todos (list)
|
||||
- [ ] POST /todos (create with validation)
|
||||
- [ ] GET /todos/:id (single)
|
||||
- [ ] PUT /todos/:id (update with validation)
|
||||
- [ ] DELETE /todos/:id
|
||||
- [ ] Tests for all endpoints
|
||||
|
||||
Starting with GET /todos...
|
||||
[implement]
|
||||
[test - passes]
|
||||
[commit: "feat(todos): add GET /todos endpoint"]
|
||||
[update session log]
|
||||
|
||||
Moving to POST /todos...
|
||||
[implement]
|
||||
[test - fails: validation not working]
|
||||
[fix validation]
|
||||
[test - passes]
|
||||
[commit: "feat(todos): add POST /todos with validation"]
|
||||
[update session log]
|
||||
|
||||
...continue until all done...
|
||||
|
||||
Final verification:
|
||||
[npm test - all pass]
|
||||
[npm run lint - clean]
|
||||
[npm run build - succeeds]
|
||||
|
||||
<promise>COMPLETE</promise>
|
||||
```
|
||||
|
||||
### When to NOT Output Completion Phrase
|
||||
|
||||
- Tests are failing (even one)
|
||||
- Lint errors exist
|
||||
- Build is broken
|
||||
- You skipped a requirement
|
||||
- You're unsure if something works
|
||||
- **Minimum duration not reached** (for time-aware loops)
|
||||
|
||||
Instead: Fix the issue, verify, then complete. For time-aware loops: generate more tasks and keep improving until minimum time elapsed.
|
||||
|
||||
### RALPH_STATUS Block (Required During Ralph Loop)
|
||||
|
||||
At the **END of every response** during a Ralph Loop, output this structured status block:
|
||||
End EVERY response during a Ralph loop with exactly this block — Codeman parses it to track the loop:
|
||||
|
||||
```
|
||||
---RALPH_STATUS---
|
||||
@@ -367,95 +88,10 @@ FILES_MODIFIED: <number>
|
||||
TESTS_STATUS: PASSING | FAILING | NOT_RUN
|
||||
WORK_TYPE: IMPLEMENTATION | TESTING | DOCUMENTATION | REFACTORING
|
||||
EXIT_SIGNAL: false | true
|
||||
RECOMMENDATION: <one line summary of what to do next>
|
||||
RECOMMENDATION: <one line: what to do next>
|
||||
---END_RALPH_STATUS---
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- Output this block at the end of **every** response, no exceptions
|
||||
- Set `EXIT_SIGNAL` to `true` ONLY when ALL tasks are verifiably done
|
||||
- Set `STATUS` to `BLOCKED` when you need human intervention
|
||||
- Do NOT continue with busy work when `EXIT_SIGNAL` should be `true`
|
||||
- Do NOT forget the status block — it is required for loop tracking
|
||||
|
||||
### Testing Limits
|
||||
|
||||
- **LIMIT testing to ~20% of total effort** per loop
|
||||
- PRIORITIZE: Implementation > Documentation > Tests
|
||||
- Only write tests for NEW functionality
|
||||
- Do NOT refactor existing tests unless broken
|
||||
- Do NOT run tests repeatedly without implementing new features
|
||||
|
||||
### Exit Scenarios (When to Set EXIT_SIGNAL)
|
||||
|
||||
| Scenario | STATUS | EXIT_SIGNAL | Action |
|
||||
|----------|--------|-------------|--------|
|
||||
| All tasks completed, tests pass | COMPLETE | true | Output completion phrase |
|
||||
| No work remaining, specs done | COMPLETE | true | Output completion phrase |
|
||||
| Making normal progress | IN_PROGRESS | false | Continue to next task |
|
||||
| Test-only loop (no implementation) | IN_PROGRESS | false | Warn and shift to implementation |
|
||||
| Stuck on same error repeatedly | BLOCKED | false | Describe blocker, request help |
|
||||
| Needs human decision/intervention | BLOCKED | false | Describe what's needed |
|
||||
|
||||
**Anti-patterns to avoid:**
|
||||
- Setting `EXIT_SIGNAL: true` when tests are failing
|
||||
- Continuing to work when all tasks are genuinely done (busy work)
|
||||
- Running the same failing test repeatedly without changing approach
|
||||
- Adding features not in the original specifications
|
||||
- Refactoring working code instead of completing assigned tasks
|
||||
|
||||
---
|
||||
|
||||
## Code Standards
|
||||
|
||||
### Before Writing
|
||||
- Read existing code in the area you're modifying
|
||||
- Follow existing patterns and conventions
|
||||
- Check for similar implementations to reference
|
||||
|
||||
### During Implementation
|
||||
- Keep changes focused and minimal
|
||||
- Don't over-engineer
|
||||
- Write tests for new functionality
|
||||
|
||||
### After Implementation
|
||||
- Run tests
|
||||
- Update docs if needed
|
||||
- Commit with descriptive message
|
||||
|
||||
---
|
||||
|
||||
## Hooks Awareness
|
||||
|
||||
This project may have hooks that auto-format code after writes or validate operations. If a tool call behaves unexpectedly, hooks are likely the cause. Continue working - they're intentional.
|
||||
|
||||
---
|
||||
|
||||
## Session Log
|
||||
|
||||
| Date | Tasks Completed | Files Changed | Notes |
|
||||
|------|-----------------|---------------|-------|
|
||||
| [DATE] | Project created | CLAUDE.md | Initial setup |
|
||||
|
||||
---
|
||||
|
||||
## Current Task Queue
|
||||
|
||||
### Active Ralph Loop
|
||||
**Status**: Not Active
|
||||
**Completion Phrase**: -
|
||||
|
||||
### Pending Tasks
|
||||
- [ ] <!-- Add tasks here -->
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plans
|
||||
|
||||
<!-- Document plans before major implementations -->
|
||||
|
||||
---
|
||||
|
||||
## Notes & Decisions
|
||||
|
||||
<!-- Track important decisions and context -->
|
||||
- `EXIT_SIGNAL: true` only when ALL tasks are verifiably done — then also output the completion phrase.
|
||||
- `STATUS: BLOCKED` when you need human input; describe the blocker in RECOMMENDATION.
|
||||
- Never set `EXIT_SIGNAL: true` while tests are failing.
|
||||
|
||||
@@ -15,18 +15,17 @@ import { fileURLToPath } from 'node:url';
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const BUNDLED_TEMPLATE_PATH = join(__dirname, 'case-template.md');
|
||||
|
||||
const MINIMAL_FALLBACK = `# CLAUDE.md - Project Configuration
|
||||
const MINIMAL_FALLBACK = `# CLAUDE.md
|
||||
|
||||
<!-- Generated by Codeman on [DATE]. Add the commands, code style rules, and
|
||||
workflow notes Claude can't infer from the code. Keep it short. -->
|
||||
|
||||
This file guides Claude Code when working in this repository.
|
||||
|
||||
## Project
|
||||
|
||||
## Project Overview
|
||||
- **Project Name**: [PROJECT_NAME]
|
||||
- **Description**: [PROJECT_DESCRIPTION]
|
||||
- **Last Updated**: [DATE]
|
||||
|
||||
## Session Log
|
||||
|
||||
| Date | Tasks Completed | Files Changed | Notes |
|
||||
|------|-----------------|---------------|-------|
|
||||
| [DATE] | Project created | CLAUDE.md | Initial setup |
|
||||
`;
|
||||
|
||||
/**
|
||||
|
||||
+78
-2
@@ -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
@@ -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) */
|
||||
|
||||
+6
-2
@@ -13,8 +13,12 @@
|
||||
* @module types/update
|
||||
*/
|
||||
|
||||
/** Which init system supervises the running server (decides how we restart it). */
|
||||
export type SupervisorKind = 'systemd' | 'launchd' | 'none';
|
||||
/**
|
||||
* Which init system supervises the running server (decides how we restart it).
|
||||
* `launchd-daemon` = a KeepAlive system-level LaunchDaemon (headless Macs, no GUI
|
||||
* login): restart works by killing the server and letting launchd respawn it.
|
||||
*/
|
||||
export type SupervisorKind = 'systemd' | 'launchd' | 'launchd-daemon' | 'none';
|
||||
|
||||
/** How Codeman was installed — only `git` installs can self-update in place. */
|
||||
export type InstallKind = 'git' | 'npm' | 'unknown';
|
||||
|
||||
@@ -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 { findClaudeDir, getAugmentedPath } from './claude-cli-resolver.js';
|
||||
export { resolveOpenCodeDir } from './opencode-cli-resolver.js';
|
||||
export { resolveCodexDir, isCodexAvailable } from './codex-cli-resolver.js';
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
+28
-1
@@ -161,7 +161,9 @@ export function parseGitHubRepo(remoteUrl: string): { owner: string; repo: strin
|
||||
* persist — or null to leave it untouched.
|
||||
*
|
||||
* Rules (see plan "Hardening"):
|
||||
* - Terminal phases → untouched.
|
||||
* - Terminal phases → untouched, EXCEPT `completed-needs-manual-restart`: once we
|
||||
* boot into the staged target version the manual restart evidently happened, so
|
||||
* it flips to `completed` (otherwise the stale instruction lingers in the UI).
|
||||
* - Only the `restarting` marker (written right before the updater triggers our
|
||||
* restart) flips to completed/failed by comparing running version vs. target.
|
||||
* - Other in-flight phases are owned by the still-running updater scope — leave
|
||||
@@ -174,6 +176,17 @@ export function reconcileStatusDecision(
|
||||
now: number
|
||||
): UpdateStatus | null {
|
||||
if (!status) return null;
|
||||
|
||||
// A staged update that asked for a manual restart: if we're now running the
|
||||
// target version, the user (or supervisor) did restart — mark it completed so
|
||||
// the UI stops showing the stale "restart Codeman to apply" instruction.
|
||||
if (status.phase === 'completed-needs-manual-restart') {
|
||||
if (status.toVersion && runningVersion === status.toVersion) {
|
||||
return { ...status, phase: 'completed', message: `Updated to v${runningVersion}`, updatedAt: now };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!IN_FLIGHT_PHASES.has(status.phase)) return null;
|
||||
|
||||
if (status.phase === 'restarting') {
|
||||
@@ -275,6 +288,16 @@ function detectInstallKind(dir: string): InstallKind {
|
||||
export function detectSupervisor(): SupervisorKind {
|
||||
if (process.platform === 'darwin') {
|
||||
if (existsSync(join(homedir(), 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`))) return 'launchd';
|
||||
// Headless Macs (no GUI login → no gui domain) run Codeman as a system-level
|
||||
// LaunchDaemon instead. Restarting one needs no root IF it has KeepAlive: the
|
||||
// updater just kills the server and launchd respawns it on the new build. Only
|
||||
// claim this supervisor when the daemon is actually bootstrapped and KeepAlive.
|
||||
const daemonPlist = join('/Library/LaunchDaemons', `${LAUNCHD_LABEL}.plist`);
|
||||
if (existsSync(daemonPlist)) {
|
||||
const loaded = tryExec('launchctl', ['print', `system/${LAUNCHD_LABEL}`]) !== null;
|
||||
const keepAlive = tryExec('plutil', ['-extract', 'KeepAlive', 'raw', '-o', '-', daemonPlist]);
|
||||
if (loaded && keepAlive === 'true') return 'launchd-daemon';
|
||||
}
|
||||
return 'none';
|
||||
}
|
||||
if (process.platform === 'linux') {
|
||||
@@ -535,6 +558,10 @@ export async function startUpdate(): Promise<StartUpdateResult> {
|
||||
process.execPath,
|
||||
'--log',
|
||||
logFile,
|
||||
// For the launchd-daemon restart path: the updater kills this PID and the
|
||||
// KeepAlive daemon respawns the server on the freshly built dist/.
|
||||
'--server-pid',
|
||||
String(process.pid),
|
||||
];
|
||||
if (prevSha) args.push('--prev-sha', prevSha);
|
||||
if (info.dirty) args.push('--stash');
|
||||
|
||||
+13
-13
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -153,4 +153,17 @@ describe('reconcileStatusDecision (boot handoff state machine)', () => {
|
||||
expect(out?.phase).toBe('failed');
|
||||
expect(out?.error).toContain('building');
|
||||
});
|
||||
|
||||
it('needs-manual-restart + now running the target version → completed', () => {
|
||||
const out = reconcileStatusDecision(base({ phase: 'completed-needs-manual-restart' }), '0.9.4', NOW);
|
||||
expect(out?.phase).toBe('completed');
|
||||
expect(out?.message).toContain('0.9.4');
|
||||
expect(out?.updatedAt).toBe(NOW);
|
||||
});
|
||||
|
||||
it('needs-manual-restart + still on the old version → untouched (restart pending)', () => {
|
||||
expect(reconcileStatusDecision(base({ phase: 'completed-needs-manual-restart' }), '0.9.3', NOW)).toBeNull();
|
||||
const noTarget = base({ phase: 'completed-needs-manual-restart', toVersion: undefined });
|
||||
expect(reconcileStatusDecision(noTarget, '0.9.4', NOW)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
+33
-24
@@ -51,7 +51,7 @@ describe('generateClaudeMd', () => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const result = generateClaudeMd('my-project');
|
||||
|
||||
expect(result).toContain(`**Last Updated**: ${today}`);
|
||||
expect(result).toContain(`Generated by Codeman on ${today}`);
|
||||
});
|
||||
|
||||
it('should include Codeman environment section', () => {
|
||||
@@ -61,55 +61,61 @@ describe('generateClaudeMd', () => {
|
||||
expect(result).toContain('CODEMAN_MUX=1');
|
||||
});
|
||||
|
||||
it('should include work principles', () => {
|
||||
it('should include workflow rules', () => {
|
||||
const result = generateClaudeMd('my-project');
|
||||
|
||||
expect(result).toContain('## Work Principles');
|
||||
expect(result).toContain('### Autonomy');
|
||||
expect(result).toContain('### Git Discipline');
|
||||
expect(result).toContain('## Workflow');
|
||||
expect(result).toContain('conventional commits');
|
||||
});
|
||||
|
||||
it('should include TodoWrite guidance', () => {
|
||||
it('should include Ralph Loop section with slash commands', () => {
|
||||
const result = generateClaudeMd('my-project');
|
||||
|
||||
expect(result).toContain('### Task Tracking (TodoWrite)');
|
||||
expect(result).toContain('**ALWAYS use TodoWrite**');
|
||||
});
|
||||
|
||||
it('should include Ralph Wiggum Loop section', () => {
|
||||
const result = generateClaudeMd('my-project');
|
||||
|
||||
expect(result).toContain('## Ralph Wiggum Loop');
|
||||
expect(result).toContain('## Ralph Loop');
|
||||
expect(result).toContain('/ralph-loop:ralph-loop');
|
||||
expect(result).toContain('/ralph-loop:cancel-ralph');
|
||||
});
|
||||
|
||||
it('should include planning mode section', () => {
|
||||
it('should include the RALPH_STATUS contract parsed by ralph-status-parser', () => {
|
||||
const result = generateClaudeMd('my-project');
|
||||
|
||||
expect(result).toContain('## Planning Mode');
|
||||
expect(result).toContain('Multi-file changes');
|
||||
expect(result).toContain('---RALPH_STATUS---');
|
||||
expect(result).toContain('---END_RALPH_STATUS---');
|
||||
expect(result).toContain('STATUS: IN_PROGRESS | COMPLETE | BLOCKED');
|
||||
expect(result).toContain('EXIT_SIGNAL: false | true');
|
||||
expect(result).toContain('completion phrase');
|
||||
});
|
||||
|
||||
it('should include session log table', () => {
|
||||
it('should stay under the 200-line CLAUDE.md guidance', () => {
|
||||
const result = generateClaudeMd('my-project');
|
||||
|
||||
expect(result).toContain('## Session Log');
|
||||
expect(result).toContain('| Date | Tasks Completed | Files Changed | Notes |');
|
||||
expect(result.split('\n').length).toBeLessThan(200);
|
||||
});
|
||||
|
||||
it('should not include legacy bloat sections', () => {
|
||||
const result = generateClaudeMd('my-project');
|
||||
|
||||
expect(result).not.toContain('## Session Log');
|
||||
expect(result).not.toContain('TodoWrite');
|
||||
expect(result).not.toContain('## Planning Mode');
|
||||
expect(result).not.toContain('[TECHNOLOGIES_USED]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom template', () => {
|
||||
it('should use custom template when provided and exists', () => {
|
||||
const templatePath = join(testDir, 'custom-template.md');
|
||||
writeFileSync(templatePath, `
|
||||
writeFileSync(
|
||||
templatePath,
|
||||
`
|
||||
# [PROJECT_NAME]
|
||||
|
||||
Description: [PROJECT_DESCRIPTION]
|
||||
Date: [DATE]
|
||||
|
||||
Custom content here.
|
||||
`);
|
||||
`
|
||||
);
|
||||
|
||||
const result = generateClaudeMd('my-project', 'Test desc', templatePath);
|
||||
|
||||
@@ -120,11 +126,14 @@ Custom content here.
|
||||
|
||||
it('should replace all placeholder occurrences', () => {
|
||||
const templatePath = join(testDir, 'multi-placeholder.md');
|
||||
writeFileSync(templatePath, `
|
||||
writeFileSync(
|
||||
templatePath,
|
||||
`
|
||||
[PROJECT_NAME] is a project.
|
||||
The name is [PROJECT_NAME].
|
||||
About [PROJECT_NAME]: [PROJECT_DESCRIPTION]
|
||||
`);
|
||||
`
|
||||
);
|
||||
|
||||
const result = generateClaudeMd('awesome-app', 'Cool stuff', templatePath);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user