From f5cc2cadf9095cb8335e9d60588b06b7f0349019 Mon Sep 17 00:00:00 2001 From: Teigen Date: Sat, 6 Jun 2026 23:17:06 +0800 Subject: [PATCH] feat: inject effort as soft default via CLI flags instead of env var CLAUDE_CODE_EFFORT_LEVEL hard-locks effort for the whole session and makes Claude reject in-session /effort switching (incl. ultracode). Carry effort as a dedicated payload field instead, injected at spawn as a soft default: - regular levels (incl. max) -> claude --effort (the settings effortLevel key is enum([low,medium,high,xhigh]) with .catch(undefined), so max would be silently dropped there) - ultracode -> claude --settings '{"ultracode":true}' (dedicated boolean settings key, rejected by the --effort flag) Changes: - add effort enum field to create/quick-start/ralph-loop schemas and thread it through Session -> CreateSessionOptions/RespawnPaneOptions -> spawn - buildEffortCliArgs() in session-cli-builder, shared by tmux spawn command and direct-PTY fallback args - frontend: buildEnvOverrides() no longer emits CLAUDE_CODE_EFFORT_LEVEL; validated effort goes into payloads via getEffortSetting() - settings UI: add Ultracode option to the Thinking Effort dropdown - legacy migration: Session constructor extracts CLAUDE_CODE_EFFORT_LEVEL from persisted envOverrides; applyEnvOverrides() unsets the stale tmux session var so respawned panes are no longer locked - tests: test/effort-injection.test.ts (13 cases) --- CLAUDE.md | 1 + src/mux-interface.ts | 7 +- src/session-cli-builder.ts | 25 ++++++- src/session.ts | 32 ++++++-- src/tmux-manager.ts | 40 +++++++++- src/types/session.ts | 17 +++++ src/web/public/index.html | 3 +- src/web/public/ralph-wizard.js | 8 +- src/web/public/session-ui.js | 23 +++++- src/web/public/terminal-ui.js | 5 +- src/web/routes/ralph-routes.ts | 13 +++- src/web/routes/session-routes.ts | 3 + src/web/schemas.ts | 15 ++++ src/web/server.ts | 3 + test/effort-injection.test.ts | 122 +++++++++++++++++++++++++++++++ 15 files changed, 294 insertions(+), 23 deletions(-) create mode 100644 test/effort-injection.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index d403ef53..5f673884 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,6 +95,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph - **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly - **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 `/.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 ` 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 - **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 duplicated** — the local-echo overlay lives in BOTH `packages/xterm-zerolag-input/src/` (published to npm as a standalone library for external consumers — see README "Published Packages") AND inline inside `src/web/public/app.js` (runtime copy the web UI actually loads, since the page ships as plain JS without a bundler). Any change to overlay behavior MUST be applied to both, or dev and prod diverge — and a public API break in the package warrants a separate version bump for `xterm-zerolag-input` in the changeset. Always test on mobile after touching it. See `docs/local-echo-overlay-plan.md`. diff --git a/src/mux-interface.ts b/src/mux-interface.ts index f70e4dbc..7ada57c2 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -14,6 +14,7 @@ import type { ClaudeMode, SessionMode, OpenCodeConfig, + EffortLevel, } from './types.js'; /** @@ -63,8 +64,10 @@ export interface CreateSessionOptions { openCodeConfig?: OpenCodeConfig; /** 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_EFFORT_LEVEL). Ephemeral — not written to disk. */ + /** Extra env vars exported before launching the CLI (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Ephemeral — not written to disk. */ envOverrides?: Record; + /** Claude CLI effort level, injected as a `--settings` soft default (overridable via /effort in-session) */ + effort?: EffortLevel; } /** Options for respawning a dead pane. */ @@ -81,6 +84,8 @@ export interface RespawnPaneOptions { resumeSessionId?: string; /** Extra env vars exported before launching the CLI (preserved across respawns). */ envOverrides?: Record; + /** Claude CLI effort level (preserved across respawns, injected via `--settings`) */ + effort?: EffortLevel; } /** diff --git a/src/session-cli-builder.ts b/src/session-cli-builder.ts index afc538c3..e39c9e95 100644 --- a/src/session-cli-builder.ts +++ b/src/session-cli-builder.ts @@ -8,7 +8,8 @@ * @module session-cli-builder */ -import type { ClaudeMode } from './types.js'; +import type { ClaudeMode, EffortLevel } from './types.js'; +import { isEffortLevel } from './types.js'; import { getAugmentedPath } from './utils/index.js'; /** @@ -31,6 +32,23 @@ function buildPermissionArgs(claudeMode: ClaudeMode, allowedTools?: string): str } } +/** + * Build the CLI args carrying the effort level as a SOFT default (switchable + * in-session via /effort). The CLAUDE_CODE_EFFORT_LEVEL env var is deliberately + * avoided — it hard-locks effort and blocks in-session `/effort` switching. + * + * Two carriers are needed because neither covers all levels: + * - regular levels (incl. `max`) → `--effort ` (the settings `effortLevel` + * key is enum(["low","medium","high","xhigh"]) with .catch(undefined), so `max` + * would be SILENTLY dropped there) + * - `ultracode` → `--settings '{"ultracode":true}'` (its own boolean settings key, + * claude >= 2.1.154; rejected by the --effort flag) + */ +export function buildEffortCliArgs(effort?: EffortLevel): string[] { + if (!effort || !isEffortLevel(effort)) return []; + return effort === 'ultracode' ? ['--settings', '{"ultracode":true}'] : ['--effort', effort]; +} + /** * Build args for an interactive Claude CLI session (direct PTY, non-mux fallback). * @@ -38,16 +56,19 @@ function buildPermissionArgs(claudeMode: ClaudeMode, allowedTools?: string): str * @param claudeMode - Permission mode for the CLI * @param model - Optional model override (e.g., 'opus', 'sonnet') * @param allowedTools - Optional comma-separated allowed tools list + * @param effort - Optional effort level, injected via --settings (overridable in-session) * @returns Array of CLI arguments */ export function buildInteractiveArgs( sessionId: string, claudeMode: ClaudeMode, model?: string, - allowedTools?: string + allowedTools?: string, + effort?: EffortLevel ): string[] { const args = [...buildPermissionArgs(claudeMode, allowedTools), '--session-id', sessionId]; if (model) args.push('--model', model); + args.push(...buildEffortCliArgs(effort)); return args; } diff --git a/src/session.ts b/src/session.ts index ddb343c7..f8d049bf 100644 --- a/src/session.ts +++ b/src/session.ts @@ -42,9 +42,11 @@ import { NiceConfig, DEFAULT_NICE_CONFIG, getErrorMessage, + isEffortLevel, type ClaudeMode, type SessionMode, type OpenCodeConfig, + type EffortLevel, } from './types.js'; import type { TerminalMultiplexer, MuxSession } from './mux-interface.js'; import { TaskTracker, type BackgroundTask } from './task-tracker.js'; @@ -311,10 +313,15 @@ export class Session extends EventEmitter { private _openCodeConfig: OpenCodeConfig | undefined; private _resumeSessionId: string | undefined; - // Ephemeral env overrides (e.g., CLAUDE_CODE_EFFORT_LEVEL). Exported by tmux at spawn, - // preserved across respawns via persisted state. Not written to .claude/settings.local.json. + // Ephemeral env overrides (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Exported by tmux + // at spawn, preserved across respawns via persisted state. Not written to .claude/settings.local.json. private _envOverrides: Record | undefined; + // Claude CLI effort level — injected as a `--settings` soft default at spawn so the + // user can still switch in-session via /effort (incl. ultracode). Never carried as + // the CLAUDE_CODE_EFFORT_LEVEL env var, which would hard-lock the session. + private _effort: EffortLevel | undefined; + // Session color for visual differentiation private _color: import('./types.js').SessionColor = 'default'; @@ -376,6 +383,8 @@ export class Session extends EventEmitter { resumeSessionId?: string; /** Extra env vars exported to the CLI at spawn time (no disk persistence) */ envOverrides?: Record; + /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ + effort?: EffortLevel; } ) { super(); @@ -423,9 +432,19 @@ export class Session extends EventEmitter { this._openCodeConfig = config.openCodeConfig; } - // Apply env overrides (exported at spawn, not persisted to disk) + // Apply env overrides (exported at spawn, not persisted to disk). + // Legacy migration: pre-0.7.2 carried effort as the CLAUDE_CODE_EFFORT_LEVEL env var, + // which hard-locks /effort switching. Extract it into _effort (--settings soft default) + // and never export it as an env var again. Explicit config.effort wins over legacy. if (config.envOverrides && Object.keys(config.envOverrides).length > 0) { - this._envOverrides = { ...config.envOverrides }; + const { CLAUDE_CODE_EFFORT_LEVEL: legacyEffort, ...restOverrides } = config.envOverrides; + this._envOverrides = Object.keys(restOverrides).length > 0 ? restOverrides : undefined; + if (legacyEffort && isEffortLevel(legacyEffort)) { + this._effort = legacyEffort; + } + } + if (config.effort && isEffortLevel(config.effort)) { + this._effort = config.effort; } // Initialize task tracker and forward events (store handlers for cleanup) @@ -847,6 +866,7 @@ export class Session extends EventEmitter { cliLatestVersion: this._cliLatestVersion || undefined, openCodeConfig: this._openCodeConfig, resumeSessionId: this._resumeSessionId, + effort: this._effort, // envOverrides intentionally NOT on the public SessionState type — they must not // leak into SSE / GET /api/sessions broadcasts (schema allows OPENCODE_*, which // can carry secrets). For disk persistence, session-manager calls @@ -1038,6 +1058,7 @@ export class Session extends EventEmitter { openCodeConfig: this._openCodeConfig, resumeSessionId: this._resumeSessionId, envOverrides: this._envOverrides, + effort: this._effort, }, createSessionOptions: { sessionId: this.id, @@ -1051,6 +1072,7 @@ export class Session extends EventEmitter { openCodeConfig: this._openCodeConfig, resumeSessionId: this._resumeSessionId, envOverrides: this._envOverrides, + effort: this._effort, }, spawnErrLabel: 'mux attachment', }); @@ -1120,7 +1142,7 @@ export class Session extends EventEmitter { try { // Pass --session-id to use the SAME ID as the Codeman session // This ensures subagents can be directly matched to the correct tab - const args = buildInteractiveArgs(this.id, this._claudeMode, this._model, this._allowedTools); + const args = buildInteractiveArgs(this.id, this._claudeMode, this._model, this._allowedTools, this._effort); this.ptyProcess = pty.spawn('claude', args, { name: 'xterm-256color', cols: 120, diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 9284e174..5f95ab63 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -39,7 +39,9 @@ import { type ClaudeMode, type SessionMode, type OpenCodeConfig, + type EffortLevel, } from './types.js'; +import { buildEffortCliArgs } from './session-cli-builder.js'; import { wrapWithNice, SAFE_PATH_PATTERN, findClaudeDir, resolveOpenCodeDir } from './utils/index.js'; import type { TerminalMultiplexer, @@ -259,6 +261,20 @@ function buildOpenCodeCommand(config?: OpenCodeConfig): string { * Build the spawn command for any session mode. * Shared by createSession() and respawnPane() to avoid duplication. */ +/** + * Build the shell fragment carrying the effort level as a SOFT default + * (see buildEffortCliArgs — `--effort ` for regular levels incl. max, + * `--settings '{"ultracode":true}'` for ultracode; deliberately not the + * CLAUDE_CODE_EFFORT_LEVEL env var, which hard-locks /effort switching). + * + * Injection-safe: effort is validated against the EFFORT_LEVELS allowlist inside + * buildEffortCliArgs, so the single-quoted values contain no user-controlled characters. + */ +function buildEffortSettingsFlag(effort?: EffortLevel): string { + const [flag, value] = buildEffortCliArgs(effort); + return flag && value ? ` ${flag} '${value}'` : ''; +} + function buildSpawnCommand(options: { mode: SessionMode; sessionId: string; @@ -267,11 +283,13 @@ function buildSpawnCommand(options: { allowedTools?: string; openCodeConfig?: OpenCodeConfig; resumeSessionId?: string; + effort?: EffortLevel; }): string { if (options.mode === 'claude') { // Validate model to prevent command injection const safeModel = options.model && /^[a-zA-Z0-9._\-[\]]+$/.test(options.model) ? options.model : undefined; const modelFlag = safeModel ? ` --model "${safeModel}"` : ''; + const effortFlag = buildEffortSettingsFlag(options.effort); // Use --resume to restore a previous conversation, otherwise --session-id for new sessions. // Wrap --resume in a fallback: if it exits non-zero (session not found, corrupt, etc.), // fall back to a new session with --session-id so the pane doesn't die. @@ -279,11 +297,11 @@ function buildSpawnCommand(options: { options.resumeSessionId && /^[a-f0-9-]+$/.test(options.resumeSessionId) ? options.resumeSessionId : undefined; const permFlags = buildClaudePermissionFlags(options.claudeMode, options.allowedTools); if (safeResumeId) { - const resumeCmd = `claude${permFlags} --resume "${safeResumeId}"${modelFlag}`; - const fallbackCmd = `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}`; + const resumeCmd = `claude${permFlags} --resume "${safeResumeId}"${modelFlag}${effortFlag}`; + const fallbackCmd = `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}${effortFlag}`; return `${resumeCmd} || ${fallbackCmd}`; } - return `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}`; + return `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}${effortFlag}`; } if (options.mode === 'opencode') { return buildOpenCodeCommand(options.openCodeConfig); @@ -518,6 +536,18 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { * shell-metachar injection even if upstream schema check is bypassed. */ private applyEnvOverrides(muxName: string, envOverrides?: Record): void { + // Legacy cleanup: pre-0.7.2 set CLAUDE_CODE_EFFORT_LEVEL via setenv, which persists + // on the tmux session and hard-locks /effort switching in every respawned pane. + // Effort now flows as a `--settings` soft default (see buildEffortSettingsFlag), + // so unconditionally unset the stale var before applying current overrides. + try { + execSync(`${this.tmux()} setenv -t ${shellescape(muxName)} -u CLAUDE_CODE_EFFORT_LEVEL`, { + timeout: EXEC_TIMEOUT_MS, + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch { + /* Non-critical — var may not exist */ + } if (!envOverrides) return; const VALID_KEY = /^[A-Z_][A-Z0-9_]*$/; for (const [key, value] of Object.entries(envOverrides)) { @@ -582,6 +612,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { openCodeConfig, resumeSessionId, envOverrides, + effort, } = options; const muxName = `codeman-${sessionId.slice(0, 8)}`; @@ -628,6 +659,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { allowedTools, openCodeConfig, resumeSessionId, + effort, }); const config = niceConfig || DEFAULT_NICE_CONFIG; @@ -821,6 +853,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { openCodeConfig, resumeSessionId, envOverrides, + effort, } = options; const session = this.sessions.get(sessionId); if (!session) return null; @@ -841,6 +874,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { allowedTools, openCodeConfig, resumeSessionId, + effort, }); const config = niceConfig || DEFAULT_NICE_CONFIG; const cmd = wrapWithNice(baseCmd, config); diff --git a/src/types/session.ts b/src/types/session.ts index e3ee4247..94ee0ea3 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -40,6 +40,21 @@ export type ClaudeMode = 'dangerously-skip-permissions' | 'normal' | 'allowedToo /** Session mode: which CLI backend a session runs */ export type SessionMode = 'claude' | 'shell' | 'opencode'; +/** + * Valid Claude CLI effort levels (claude >= 2.1.154). + * `ultracode` = xhigh effort + standing dynamic-workflow orchestration; it is a + * separate `ultracode` settings key rather than an `effortLevel` value. + */ +export const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max', 'ultracode'] as const; + +/** Claude CLI effort level for new sessions (soft default, switchable via /effort in-session) */ +export type EffortLevel = (typeof EFFORT_LEVELS)[number]; + +/** Type guard: is the string a valid EffortLevel? */ +export function isEffortLevel(value: string | undefined): value is EffortLevel { + return value !== undefined && (EFFORT_LEVELS as readonly string[]).includes(value); +} + /** OpenCode session configuration */ export interface OpenCodeConfig { /** Model identifier (e.g., "anthropic/claude-sonnet-4-5", "openai/gpt-5.2", "ollama/codellama") */ @@ -145,6 +160,8 @@ export interface SessionState { openCodeConfig?: OpenCodeConfig; /** 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) */ + effort?: EffortLevel; } /** diff --git a/src/web/public/index.html b/src/web/public/index.html index 05b48b00..8dc479e7 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1104,8 +1104,9 @@

App Settings

+ - Set CLAUDE_CODE_EFFORT_LEVEL for all new sessions (default = no override) + Default effort for new Claude sessions — soft default, switchable anytime in-session via /effort (e.g. /effort ultracode)
Nice Priority
diff --git a/src/web/public/ralph-wizard.js b/src/web/public/ralph-wizard.js index a8983a5e..b9bc6501 100644 --- a/src/web/public/ralph-wizard.js +++ b/src/web/public/ralph-wizard.js @@ -1032,10 +1032,9 @@ Object.assign(CodemanApp.prototype, { const enabledItems = config.generatedPlan?.filter(i => i.enabled); try { - const envOverrides = this.buildEnvOverrides( - this.getCaseSettings(config.caseName), - this.loadAppSettingsFromStorage() - ); + const ralphGlobalSettings = this.loadAppSettingsFromStorage(); + const envOverrides = this.buildEnvOverrides(this.getCaseSettings(config.caseName), ralphGlobalSettings); + const effort = this.getEffortSetting(ralphGlobalSettings); const res = await fetch('/api/ralph-loop/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -1047,6 +1046,7 @@ Object.assign(CodemanApp.prototype, { enableRespawn: config.enableRespawn, planItems: enabledItems?.length ? enabledItems : undefined, ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), + ...(effort ? { effort } : {}), }), }); const data = await res.json(); diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index e32c1e07..33606a75 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -22,12 +22,24 @@ Object.assign(CodemanApp.prototype, { if (caseSettings?.agentTeams || globalSettings?.agentTeamsEnabled) { env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1'; } - if (globalSettings?.thinkingEffort) { - env.CLAUDE_CODE_EFFORT_LEVEL = globalSettings.thinkingEffort; - } + // NOTE: thinkingEffort is intentionally NOT emitted as CLAUDE_CODE_EFFORT_LEVEL — + // the env var hard-locks effort and blocks in-session /effort switching (e.g., + // ultracode). It flows as the dedicated `effort` payload field instead, which the + // backend injects as a `--settings` soft default. See getEffortSetting(). return env; }, + /** + * Resolve the effort level for new sessions from global settings. + * Returns a valid effort string or undefined (= no override, CLI default). + * Sent as the `effort` payload field — backend turns it into `claude --settings ...`. + */ + getEffortSetting(globalSettings) { + const effort = globalSettings?.thinkingEffort; + const valid = ['low', 'medium', 'high', 'xhigh', 'max', 'ultracode']; + return valid.includes(effort) ? effort : undefined; + }, + // ═══════════════════════════════════════════════════════════════ // Quick Start // ═══════════════════════════════════════════════════════════════ @@ -337,6 +349,7 @@ Object.assign(CodemanApp.prototype, { const globalSettings = this.loadAppSettingsFromStorage(); const envOverrides = this.buildEnvOverrides(caseSettings, globalSettings); const hasEnvOverrides = Object.keys(envOverrides).length > 0; + const effort = this.getEffortSetting(globalSettings); const useOpus1m = caseSettings.opusContext1m || globalSettings.opusContext1mEnabled; const modelOverride = useOpus1m ? 'opus[1m]' : ''; @@ -349,6 +362,7 @@ Object.assign(CodemanApp.prototype, { body: JSON.stringify({ workingDir, name, ...(hasEnvOverrides ? { envOverrides } : {}), + ...(effort ? { effort } : {}), ...(modelOverride !== undefined ? { modelOverride } : {}), }) }).then(r => r.json()) @@ -538,7 +552,8 @@ Object.assign(CodemanApp.prototype, { return; } - // Quick-start with opencode mode (auto-allow tools by default) + // Quick-start with opencode mode (auto-allow tools by default). + // No `effort` field — it's Claude-specific (OpenCode has no /effort). const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage()); const res = await fetch('/api/quick-start', { method: 'POST', diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index d88620f1..dbe36b1f 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -1244,7 +1244,9 @@ Object.assign(CodemanApp.prototype, { // Match by path (not basename) so linked/renamed cases still resolve correctly. const matchingCase = (this.cases || []).find((c) => c.path === workingDir); const caseName = matchingCase?.name || workingDir.split('/').pop() || ''; - const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage()); + const globalSettings = this.loadAppSettingsFromStorage(); + const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), globalSettings); + const effort = this.getEffortSetting(globalSettings); const createRes = await fetch('/api/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -1253,6 +1255,7 @@ Object.assign(CodemanApp.prototype, { name, resumeSessionId: sessionId, ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), + ...(effort ? { effort } : {}), }), }); const createData = await createRes.json(); diff --git a/src/web/routes/ralph-routes.ts b/src/web/routes/ralph-routes.ts index a5c2b3b8..9a9c6937 100644 --- a/src/web/routes/ralph-routes.ts +++ b/src/web/routes/ralph-routes.ts @@ -268,8 +268,16 @@ export function registerRalphRoutes( ); } - const { caseName, taskDescription, completionPhrase, maxIterations, enableRespawn, planItems, envOverrides } = - parseBody(RalphLoopStartSchema, req.body); + const { + caseName, + taskDescription, + completionPhrase, + maxIterations, + enableRespawn, + planItems, + envOverrides, + effort, + } = parseBody(RalphLoopStartSchema, req.body); const casePath = join(CASES_DIR, caseName); @@ -315,6 +323,7 @@ export function registerRalphRoutes( claudeMode: rlClaudeModeConfig.claudeMode, allowedTools: rlClaudeModeConfig.allowedTools, envOverrides, + effort, }); // Configure Ralph tracker diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 5c1753df..c05543e6 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -335,6 +335,7 @@ export function registerSessionRoutes( openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined, resumeSessionId: validatedResumeId, envOverrides: body.envOverrides, + effort: body.effort, }); ctx.addSession(session); @@ -1106,6 +1107,7 @@ export function registerSessionRoutes( mode = 'claude', openCodeConfig, envOverrides, + effort, } = parseBody(QuickStartSchema, req.body); // Check OpenCode availability if requested @@ -1186,6 +1188,7 @@ export function registerSessionRoutes( allowedTools: qsClaudeModeConfig.allowedTools, openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined, envOverrides, + effort, }); // Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 71474319..ae955875 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -80,6 +80,15 @@ const safeEnvOverridesSchema = z } ); +// ========== Effort Level ========== + +/** + * Claude CLI effort level for new sessions. Injected as a `--settings` soft default + * (NOT the CLAUDE_CODE_EFFORT_LEVEL env var, which would hard-lock the session and + * block in-session `/effort` switching). `ultracode` enables dynamic workflow orchestration. + */ +const effortLevelSchema = z.enum(['low', 'medium', 'high', 'xhigh', 'max', 'ultracode']).optional(); + // ========== Session Routes ========== /** @@ -124,6 +133,8 @@ export const CreateSessionSchema = z.object({ mode: z.enum(['claude', 'shell', 'opencode']).optional(), name: z.string().max(100).optional(), envOverrides: safeEnvOverridesSchema, + /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ + effort: effortLevelSchema, /** Model override to write to .claude/settings.local.json (e.g., "opus[1m]"). Empty string clears. */ modelOverride: z.string().max(50).optional(), openCodeConfig: OpenCodeConfigSchema, @@ -179,6 +190,8 @@ export const QuickStartSchema = z.object({ mode: z.enum(['claude', 'shell', 'opencode']).optional(), openCodeConfig: OpenCodeConfigSchema, envOverrides: safeEnvOverridesSchema, + /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ + effort: effortLevelSchema, }); // ========== Hook Events ========== @@ -543,6 +556,8 @@ export const RalphLoopStartSchema = z.object({ maxIterations: z.number().int().min(0).max(1000).nullable().default(10), enableRespawn: z.boolean().default(false), envOverrides: safeEnvOverridesSchema, + /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ + effort: effortLevelSchema, planItems: z .array( z.object({ diff --git a/src/web/server.ts b/src/web/server.ts index c7000b7b..3f747655 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1716,6 +1716,8 @@ export class WebServer extends EventEmitter { const recoveryClaudeMode = await this.getClaudeModeConfig(); // Recover envOverrides from the internal __envOverrides field written by // session-manager (see updateSessionState). Cast to read the non-public field. + // Note: a legacy CLAUDE_CODE_EFFORT_LEVEL entry is auto-migrated to `effort` + // by the Session constructor (env var would hard-lock /effort switching). const savedEnvOverrides = (savedState as { __envOverrides?: Record })?.__envOverrides; const session = new Session({ id: muxSession.sessionId, // Preserve the original session ID @@ -1728,6 +1730,7 @@ export class WebServer extends EventEmitter { claudeMode: recoveryClaudeMode.claudeMode, allowedTools: recoveryClaudeMode.allowedTools, envOverrides: savedEnvOverrides, + effort: savedState?.effort, }); // Update session name if it was a "Restored:" placeholder or doesn't match saved name diff --git a/test/effort-injection.test.ts b/test/effort-injection.test.ts new file mode 100644 index 00000000..7858914c --- /dev/null +++ b/test/effort-injection.test.ts @@ -0,0 +1,122 @@ +/** + * @fileoverview Tests for Claude CLI effort level injection. + * + * Effort must flow as a `--settings` SOFT default (overridable in-session via + * /effort, incl. ultracode) — never as the CLAUDE_CODE_EFFORT_LEVEL env var, + * which hard-locks the session. Also covers the legacy migration path: old + * persisted sessions carried effort inside __envOverrides. + */ + +import { describe, it, expect } from 'vitest'; +import { buildEffortCliArgs, buildInteractiveArgs } from '../src/session-cli-builder.js'; +import { isEffortLevel, EFFORT_LEVELS } from '../src/types.js'; +import { Session } from '../src/session.js'; + +describe('buildEffortCliArgs', () => { + it('maps regular levels (incl. max) to the --effort flag', () => { + // NOT the settings effortLevel key: its enum lacks "max" and silently drops it + expect(buildEffortCliArgs('low')).toEqual(['--effort', 'low']); + expect(buildEffortCliArgs('high')).toEqual(['--effort', 'high']); + expect(buildEffortCliArgs('xhigh')).toEqual(['--effort', 'xhigh']); + expect(buildEffortCliArgs('max')).toEqual(['--effort', 'max']); + }); + + it('maps ultracode to its dedicated --settings boolean key', () => { + // The --effort flag rejects ultracode; only the settings key enables it at spawn + expect(buildEffortCliArgs('ultracode')).toEqual(['--settings', '{"ultracode":true}']); + }); + + it('returns empty args for missing or invalid values', () => { + expect(buildEffortCliArgs(undefined)).toEqual([]); + // Invalid strings must not reach the shell command (injection guard) + expect(buildEffortCliArgs('"; rm -rf /' as never)).toEqual([]); + expect(buildEffortCliArgs('turbo' as never)).toEqual([]); + }); + + it('produces a flag/value pair for every allowed level', () => { + for (const level of EFFORT_LEVELS) { + const args = buildEffortCliArgs(level); + expect(args).toHaveLength(2); + expect(args[0]).toMatch(/^--(effort|settings)$/); + } + }); +}); + +describe('isEffortLevel', () => { + it('accepts all defined levels and rejects everything else', () => { + for (const level of EFFORT_LEVELS) { + expect(isEffortLevel(level)).toBe(true); + } + expect(isEffortLevel(undefined)).toBe(false); + expect(isEffortLevel('')).toBe(false); + expect(isEffortLevel('ULTRACODE')).toBe(false); + }); +}); + +describe('buildInteractiveArgs with effort', () => { + it('appends --settings for ultracode', () => { + const args = buildInteractiveArgs('sid-123', 'dangerously-skip-permissions', undefined, undefined, 'ultracode'); + const idx = args.indexOf('--settings'); + expect(idx).toBeGreaterThan(-1); + expect(args[idx + 1]).toBe('{"ultracode":true}'); + }); + + it('appends --effort for max', () => { + const args = buildInteractiveArgs('sid-123', 'dangerously-skip-permissions', undefined, undefined, 'max'); + const idx = args.indexOf('--effort'); + expect(idx).toBeGreaterThan(-1); + expect(args[idx + 1]).toBe('max'); + }); + + it('omits effort args when effort is absent', () => { + const args = buildInteractiveArgs('sid-123', 'dangerously-skip-permissions'); + expect(args).not.toContain('--settings'); + expect(args).not.toContain('--effort'); + }); +}); + +describe('Session effort handling', () => { + it('stores explicit effort and exposes it in toState()', () => { + const session = new Session({ workingDir: '/tmp', effort: 'ultracode' }); + expect(session.toState().effort).toBe('ultracode'); + }); + + it('migrates legacy CLAUDE_CODE_EFFORT_LEVEL out of envOverrides', () => { + const session = new Session({ + workingDir: '/tmp', + envOverrides: { + CLAUDE_CODE_EFFORT_LEVEL: 'high', + CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', + }, + }); + // Legacy env var becomes the soft-default effort... + expect(session.toState().effort).toBe('high'); + // ...and is never persisted (or exported) as an env var again + expect(session.getEnvOverridesForPersist()).toEqual({ + CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', + }); + }); + + it('drops an invalid legacy effort value instead of forwarding it', () => { + const session = new Session({ + workingDir: '/tmp', + envOverrides: { CLAUDE_CODE_EFFORT_LEVEL: 'bogus-value' }, + }); + expect(session.toState().effort).toBeUndefined(); + expect(session.getEnvOverridesForPersist()).toBeUndefined(); + }); + + it('prefers explicit effort over the legacy env var', () => { + const session = new Session({ + workingDir: '/tmp', + effort: 'ultracode', + envOverrides: { CLAUDE_CODE_EFFORT_LEVEL: 'low' }, + }); + expect(session.toState().effort).toBe('ultracode'); + }); + + it('leaves effort undefined when nothing is configured', () => { + const session = new Session({ workingDir: '/tmp' }); + expect(session.toState().effort).toBeUndefined(); + }); +});