feat: inject effort as soft default via CLI flags instead of env var (#104)

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 <level>
  (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)

Co-authored-by: Teigen <teigenzhang@gmail.com>
This commit is contained in:
Tenggan Zhang
2026-06-07 04:33:11 +02:00
committed by GitHub
co-authored by Teigen
parent e3c496e1a4
commit b2f8b03b3c
15 changed files with 294 additions and 23 deletions
+1
View File
@@ -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 - **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) - **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_*` 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 - **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 - **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`. - **`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`.
+6 -1
View File
@@ -14,6 +14,7 @@ import type {
ClaudeMode, ClaudeMode,
SessionMode, SessionMode,
OpenCodeConfig, OpenCodeConfig,
EffortLevel,
} from './types.js'; } from './types.js';
/** /**
@@ -63,8 +64,10 @@ export interface CreateSessionOptions {
openCodeConfig?: OpenCodeConfig; openCodeConfig?: OpenCodeConfig;
/** When restoring after reboot, resume a previous Claude conversation by its session ID */ /** When restoring after reboot, resume a previous Claude conversation by its session ID */
resumeSessionId?: string; resumeSessionId?: string;
/** Extra env vars exported before launching the CLI (e.g., CLAUDE_CODE_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<string, string>; envOverrides?: Record<string, string>;
/** Claude CLI effort level, injected as a `--settings` soft default (overridable via /effort in-session) */
effort?: EffortLevel;
} }
/** Options for respawning a dead pane. */ /** Options for respawning a dead pane. */
@@ -81,6 +84,8 @@ export interface RespawnPaneOptions {
resumeSessionId?: string; resumeSessionId?: string;
/** Extra env vars exported before launching the CLI (preserved across respawns). */ /** Extra env vars exported before launching the CLI (preserved across respawns). */
envOverrides?: Record<string, string>; envOverrides?: Record<string, string>;
/** Claude CLI effort level (preserved across respawns, injected via `--settings`) */
effort?: EffortLevel;
} }
/** /**
+23 -2
View File
@@ -8,7 +8,8 @@
* @module session-cli-builder * @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'; 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 <level>` (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). * 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 claudeMode - Permission mode for the CLI
* @param model - Optional model override (e.g., 'opus', 'sonnet') * @param model - Optional model override (e.g., 'opus', 'sonnet')
* @param allowedTools - Optional comma-separated allowed tools list * @param allowedTools - Optional comma-separated allowed tools list
* @param effort - Optional effort level, injected via --settings (overridable in-session)
* @returns Array of CLI arguments * @returns Array of CLI arguments
*/ */
export function buildInteractiveArgs( export function buildInteractiveArgs(
sessionId: string, sessionId: string,
claudeMode: ClaudeMode, claudeMode: ClaudeMode,
model?: string, model?: string,
allowedTools?: string allowedTools?: string,
effort?: EffortLevel
): string[] { ): string[] {
const args = [...buildPermissionArgs(claudeMode, allowedTools), '--session-id', sessionId]; const args = [...buildPermissionArgs(claudeMode, allowedTools), '--session-id', sessionId];
if (model) args.push('--model', model); if (model) args.push('--model', model);
args.push(...buildEffortCliArgs(effort));
return args; return args;
} }
+27 -5
View File
@@ -42,9 +42,11 @@ import {
NiceConfig, NiceConfig,
DEFAULT_NICE_CONFIG, DEFAULT_NICE_CONFIG,
getErrorMessage, getErrorMessage,
isEffortLevel,
type ClaudeMode, type ClaudeMode,
type SessionMode, type SessionMode,
type OpenCodeConfig, type OpenCodeConfig,
type EffortLevel,
} from './types.js'; } from './types.js';
import type { TerminalMultiplexer, MuxSession } from './mux-interface.js'; import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
import { TaskTracker, type BackgroundTask } from './task-tracker.js'; import { TaskTracker, type BackgroundTask } from './task-tracker.js';
@@ -311,10 +313,15 @@ export class Session extends EventEmitter {
private _openCodeConfig: OpenCodeConfig | undefined; private _openCodeConfig: OpenCodeConfig | undefined;
private _resumeSessionId: string | undefined; private _resumeSessionId: string | undefined;
// Ephemeral env overrides (e.g., CLAUDE_CODE_EFFORT_LEVEL). Exported by tmux at spawn, // Ephemeral env overrides (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Exported by tmux
// preserved across respawns via persisted state. Not written to .claude/settings.local.json. // at spawn, preserved across respawns via persisted state. Not written to .claude/settings.local.json.
private _envOverrides: Record<string, string> | undefined; private _envOverrides: Record<string, string> | 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 // Session color for visual differentiation
private _color: import('./types.js').SessionColor = 'default'; private _color: import('./types.js').SessionColor = 'default';
@@ -376,6 +383,8 @@ export class Session extends EventEmitter {
resumeSessionId?: string; resumeSessionId?: string;
/** Extra env vars exported to the CLI at spawn time (no disk persistence) */ /** Extra env vars exported to the CLI at spawn time (no disk persistence) */
envOverrides?: Record<string, string>; envOverrides?: Record<string, string>;
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort?: EffortLevel;
} }
) { ) {
super(); super();
@@ -423,9 +432,19 @@ export class Session extends EventEmitter {
this._openCodeConfig = config.openCodeConfig; 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) { 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) // Initialize task tracker and forward events (store handlers for cleanup)
@@ -847,6 +866,7 @@ export class Session extends EventEmitter {
cliLatestVersion: this._cliLatestVersion || undefined, cliLatestVersion: this._cliLatestVersion || undefined,
openCodeConfig: this._openCodeConfig, openCodeConfig: this._openCodeConfig,
resumeSessionId: this._resumeSessionId, resumeSessionId: this._resumeSessionId,
effort: this._effort,
// envOverrides intentionally NOT on the public SessionState type — they must not // envOverrides intentionally NOT on the public SessionState type — they must not
// leak into SSE / GET /api/sessions broadcasts (schema allows OPENCODE_*, which // leak into SSE / GET /api/sessions broadcasts (schema allows OPENCODE_*, which
// can carry secrets). For disk persistence, session-manager calls // can carry secrets). For disk persistence, session-manager calls
@@ -1038,6 +1058,7 @@ export class Session extends EventEmitter {
openCodeConfig: this._openCodeConfig, openCodeConfig: this._openCodeConfig,
resumeSessionId: this._resumeSessionId, resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides, envOverrides: this._envOverrides,
effort: this._effort,
}, },
createSessionOptions: { createSessionOptions: {
sessionId: this.id, sessionId: this.id,
@@ -1051,6 +1072,7 @@ export class Session extends EventEmitter {
openCodeConfig: this._openCodeConfig, openCodeConfig: this._openCodeConfig,
resumeSessionId: this._resumeSessionId, resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides, envOverrides: this._envOverrides,
effort: this._effort,
}, },
spawnErrLabel: 'mux attachment', spawnErrLabel: 'mux attachment',
}); });
@@ -1120,7 +1142,7 @@ export class Session extends EventEmitter {
try { try {
// Pass --session-id to use the SAME ID as the Codeman session // Pass --session-id to use the SAME ID as the Codeman session
// This ensures subagents can be directly matched to the correct tab // This ensures subagents can be directly matched to the correct tab
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, { this.ptyProcess = pty.spawn('claude', args, {
name: 'xterm-256color', name: 'xterm-256color',
cols: 120, cols: 120,
+37 -3
View File
@@ -39,7 +39,9 @@ import {
type ClaudeMode, type ClaudeMode,
type SessionMode, type SessionMode,
type OpenCodeConfig, type OpenCodeConfig,
type EffortLevel,
} from './types.js'; } 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 } from './utils/index.js';
import type { import type {
TerminalMultiplexer, TerminalMultiplexer,
@@ -259,6 +261,20 @@ function buildOpenCodeCommand(config?: OpenCodeConfig): string {
* Build the spawn command for any session mode. * Build the spawn command for any session mode.
* Shared by createSession() and respawnPane() to avoid duplication. * Shared by createSession() and respawnPane() to avoid duplication.
*/ */
/**
* Build the shell fragment carrying the effort level as a SOFT default
* (see buildEffortCliArgs — `--effort <level>` 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: { function buildSpawnCommand(options: {
mode: SessionMode; mode: SessionMode;
sessionId: string; sessionId: string;
@@ -267,11 +283,13 @@ function buildSpawnCommand(options: {
allowedTools?: string; allowedTools?: string;
openCodeConfig?: OpenCodeConfig; openCodeConfig?: OpenCodeConfig;
resumeSessionId?: string; resumeSessionId?: string;
effort?: EffortLevel;
}): string { }): string {
if (options.mode === 'claude') { if (options.mode === 'claude') {
// Validate model to prevent command injection // Validate model to prevent command injection
const safeModel = options.model && /^[a-zA-Z0-9._\-[\]]+$/.test(options.model) ? options.model : undefined; const safeModel = options.model && /^[a-zA-Z0-9._\-[\]]+$/.test(options.model) ? options.model : undefined;
const modelFlag = safeModel ? ` --model "${safeModel}"` : ''; const modelFlag = safeModel ? ` --model "${safeModel}"` : '';
const effortFlag = buildEffortSettingsFlag(options.effort);
// Use --resume to restore a previous conversation, otherwise --session-id for new sessions. // 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.), // 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. // 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; options.resumeSessionId && /^[a-f0-9-]+$/.test(options.resumeSessionId) ? options.resumeSessionId : undefined;
const permFlags = buildClaudePermissionFlags(options.claudeMode, options.allowedTools); const permFlags = buildClaudePermissionFlags(options.claudeMode, options.allowedTools);
if (safeResumeId) { if (safeResumeId) {
const resumeCmd = `claude${permFlags} --resume "${safeResumeId}"${modelFlag}`; const resumeCmd = `claude${permFlags} --resume "${safeResumeId}"${modelFlag}${effortFlag}`;
const fallbackCmd = `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}`; const fallbackCmd = `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}${effortFlag}`;
return `${resumeCmd} || ${fallbackCmd}`; return `${resumeCmd} || ${fallbackCmd}`;
} }
return `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}`; return `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}${effortFlag}`;
} }
if (options.mode === 'opencode') { if (options.mode === 'opencode') {
return buildOpenCodeCommand(options.openCodeConfig); 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. * shell-metachar injection even if upstream schema check is bypassed.
*/ */
private applyEnvOverrides(muxName: string, envOverrides?: Record<string, string>): void { private applyEnvOverrides(muxName: string, envOverrides?: Record<string, string>): 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; if (!envOverrides) return;
const VALID_KEY = /^[A-Z_][A-Z0-9_]*$/; const VALID_KEY = /^[A-Z_][A-Z0-9_]*$/;
for (const [key, value] of Object.entries(envOverrides)) { for (const [key, value] of Object.entries(envOverrides)) {
@@ -582,6 +612,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
openCodeConfig, openCodeConfig,
resumeSessionId, resumeSessionId,
envOverrides, envOverrides,
effort,
} = options; } = options;
const muxName = `codeman-${sessionId.slice(0, 8)}`; const muxName = `codeman-${sessionId.slice(0, 8)}`;
@@ -628,6 +659,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
allowedTools, allowedTools,
openCodeConfig, openCodeConfig,
resumeSessionId, resumeSessionId,
effort,
}); });
const config = niceConfig || DEFAULT_NICE_CONFIG; const config = niceConfig || DEFAULT_NICE_CONFIG;
@@ -821,6 +853,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
openCodeConfig, openCodeConfig,
resumeSessionId, resumeSessionId,
envOverrides, envOverrides,
effort,
} = options; } = options;
const session = this.sessions.get(sessionId); const session = this.sessions.get(sessionId);
if (!session) return null; if (!session) return null;
@@ -841,6 +874,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
allowedTools, allowedTools,
openCodeConfig, openCodeConfig,
resumeSessionId, resumeSessionId,
effort,
}); });
const config = niceConfig || DEFAULT_NICE_CONFIG; const config = niceConfig || DEFAULT_NICE_CONFIG;
const cmd = wrapWithNice(baseCmd, config); const cmd = wrapWithNice(baseCmd, config);
+17
View File
@@ -40,6 +40,21 @@ export type ClaudeMode = 'dangerously-skip-permissions' | 'normal' | 'allowedToo
/** Session mode: which CLI backend a session runs */ /** Session mode: which CLI backend a session runs */
export type SessionMode = 'claude' | 'shell' | 'opencode'; export type SessionMode = 'claude' | 'shell' | 'opencode';
/**
* 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 */ /** OpenCode session configuration */
export interface OpenCodeConfig { export interface OpenCodeConfig {
/** Model identifier (e.g., "anthropic/claude-sonnet-4-5", "openai/gpt-5.2", "ollama/codellama") */ /** Model identifier (e.g., "anthropic/claude-sonnet-4-5", "openai/gpt-5.2", "ollama/codellama") */
@@ -145,6 +160,8 @@ export interface SessionState {
openCodeConfig?: OpenCodeConfig; openCodeConfig?: OpenCodeConfig;
/** Claude conversation session ID to resume after reboot (set by restore script) */ /** Claude conversation session ID to resume after reboot (set by restore script) */
resumeSessionId?: string; resumeSessionId?: string;
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort?: EffortLevel;
} }
/** /**
+2 -1
View File
@@ -1104,8 +1104,9 @@
<option value="high">High</option> <option value="high">High</option>
<option value="xhigh">XHigh</option> <option value="xhigh">XHigh</option>
<option value="max">Max</option> <option value="max">Max</option>
<option value="ultracode">Ultracode (multi-agent workflows)</option>
</select> </select>
<span class="form-hint">Set CLAUDE_CODE_EFFORT_LEVEL for all new sessions (default = no override)</span> <span class="form-hint">Default effort for new Claude sessions — soft default, switchable anytime in-session via /effort (e.g. /effort ultracode)</span>
</div> </div>
<!-- Nice Priority Section --> <!-- Nice Priority Section -->
<div class="form-section-header">Nice Priority</div> <div class="form-section-header">Nice Priority</div>
+4 -4
View File
@@ -1032,10 +1032,9 @@ Object.assign(CodemanApp.prototype, {
const enabledItems = config.generatedPlan?.filter(i => i.enabled); const enabledItems = config.generatedPlan?.filter(i => i.enabled);
try { try {
const envOverrides = this.buildEnvOverrides( const ralphGlobalSettings = this.loadAppSettingsFromStorage();
this.getCaseSettings(config.caseName), const envOverrides = this.buildEnvOverrides(this.getCaseSettings(config.caseName), ralphGlobalSettings);
this.loadAppSettingsFromStorage() const effort = this.getEffortSetting(ralphGlobalSettings);
);
const res = await fetch('/api/ralph-loop/start', { const res = await fetch('/api/ralph-loop/start', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -1047,6 +1046,7 @@ Object.assign(CodemanApp.prototype, {
enableRespawn: config.enableRespawn, enableRespawn: config.enableRespawn,
planItems: enabledItems?.length ? enabledItems : undefined, planItems: enabledItems?.length ? enabledItems : undefined,
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
...(effort ? { effort } : {}),
}), }),
}); });
const data = await res.json(); const data = await res.json();
+19 -4
View File
@@ -22,12 +22,24 @@ Object.assign(CodemanApp.prototype, {
if (caseSettings?.agentTeams || globalSettings?.agentTeamsEnabled) { if (caseSettings?.agentTeams || globalSettings?.agentTeamsEnabled) {
env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1'; env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1';
} }
if (globalSettings?.thinkingEffort) { // NOTE: thinkingEffort is intentionally NOT emitted as CLAUDE_CODE_EFFORT_LEVEL —
env.CLAUDE_CODE_EFFORT_LEVEL = globalSettings.thinkingEffort; // 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; 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 // Quick Start
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
@@ -337,6 +349,7 @@ Object.assign(CodemanApp.prototype, {
const globalSettings = this.loadAppSettingsFromStorage(); const globalSettings = this.loadAppSettingsFromStorage();
const envOverrides = this.buildEnvOverrides(caseSettings, globalSettings); const envOverrides = this.buildEnvOverrides(caseSettings, globalSettings);
const hasEnvOverrides = Object.keys(envOverrides).length > 0; const hasEnvOverrides = Object.keys(envOverrides).length > 0;
const effort = this.getEffortSetting(globalSettings);
const useOpus1m = caseSettings.opusContext1m || globalSettings.opusContext1mEnabled; const useOpus1m = caseSettings.opusContext1m || globalSettings.opusContext1mEnabled;
const modelOverride = useOpus1m ? 'opus[1m]' : ''; const modelOverride = useOpus1m ? 'opus[1m]' : '';
@@ -349,6 +362,7 @@ Object.assign(CodemanApp.prototype, {
body: JSON.stringify({ body: JSON.stringify({
workingDir, name, workingDir, name,
...(hasEnvOverrides ? { envOverrides } : {}), ...(hasEnvOverrides ? { envOverrides } : {}),
...(effort ? { effort } : {}),
...(modelOverride !== undefined ? { modelOverride } : {}), ...(modelOverride !== undefined ? { modelOverride } : {}),
}) })
}).then(r => r.json()) }).then(r => r.json())
@@ -538,7 +552,8 @@ Object.assign(CodemanApp.prototype, {
return; 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 envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
const res = await fetch('/api/quick-start', { const res = await fetch('/api/quick-start', {
method: 'POST', method: 'POST',
+4 -1
View File
@@ -1244,7 +1244,9 @@ Object.assign(CodemanApp.prototype, {
// Match by path (not basename) so linked/renamed cases still resolve correctly. // Match by path (not basename) so linked/renamed cases still resolve correctly.
const matchingCase = (this.cases || []).find((c) => c.path === workingDir); const matchingCase = (this.cases || []).find((c) => c.path === workingDir);
const caseName = matchingCase?.name || workingDir.split('/').pop() || ''; 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', { const createRes = await fetch('/api/sessions', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -1253,6 +1255,7 @@ Object.assign(CodemanApp.prototype, {
name, name,
resumeSessionId: sessionId, resumeSessionId: sessionId,
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
...(effort ? { effort } : {}),
}), }),
}); });
const createData = await createRes.json(); const createData = await createRes.json();
+11 -2
View File
@@ -268,8 +268,16 @@ export function registerRalphRoutes(
); );
} }
const { caseName, taskDescription, completionPhrase, maxIterations, enableRespawn, planItems, envOverrides } = const {
parseBody(RalphLoopStartSchema, req.body); caseName,
taskDescription,
completionPhrase,
maxIterations,
enableRespawn,
planItems,
envOverrides,
effort,
} = parseBody(RalphLoopStartSchema, req.body);
const casePath = join(CASES_DIR, caseName); const casePath = join(CASES_DIR, caseName);
@@ -315,6 +323,7 @@ export function registerRalphRoutes(
claudeMode: rlClaudeModeConfig.claudeMode, claudeMode: rlClaudeModeConfig.claudeMode,
allowedTools: rlClaudeModeConfig.allowedTools, allowedTools: rlClaudeModeConfig.allowedTools,
envOverrides, envOverrides,
effort,
}); });
// Configure Ralph tracker // Configure Ralph tracker
+3
View File
@@ -335,6 +335,7 @@ export function registerSessionRoutes(
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined, openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
resumeSessionId: validatedResumeId, resumeSessionId: validatedResumeId,
envOverrides: body.envOverrides, envOverrides: body.envOverrides,
effort: body.effort,
}); });
ctx.addSession(session); ctx.addSession(session);
@@ -1106,6 +1107,7 @@ export function registerSessionRoutes(
mode = 'claude', mode = 'claude',
openCodeConfig, openCodeConfig,
envOverrides, envOverrides,
effort,
} = parseBody(QuickStartSchema, req.body); } = parseBody(QuickStartSchema, req.body);
// Check OpenCode availability if requested // Check OpenCode availability if requested
@@ -1186,6 +1188,7 @@ export function registerSessionRoutes(
allowedTools: qsClaudeModeConfig.allowedTools, allowedTools: qsClaudeModeConfig.allowedTools,
openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined, openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
envOverrides, envOverrides,
effort,
}); });
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting // Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
+15
View File
@@ -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 ========== // ========== Session Routes ==========
/** /**
@@ -124,6 +133,8 @@ export const CreateSessionSchema = z.object({
mode: z.enum(['claude', 'shell', 'opencode']).optional(), mode: z.enum(['claude', 'shell', 'opencode']).optional(),
name: z.string().max(100).optional(), name: z.string().max(100).optional(),
envOverrides: safeEnvOverridesSchema, envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort: effortLevelSchema,
/** Model override to write to .claude/settings.local.json (e.g., "opus[1m]"). Empty string clears. */ /** Model override to write to .claude/settings.local.json (e.g., "opus[1m]"). Empty string clears. */
modelOverride: z.string().max(50).optional(), modelOverride: z.string().max(50).optional(),
openCodeConfig: OpenCodeConfigSchema, openCodeConfig: OpenCodeConfigSchema,
@@ -179,6 +190,8 @@ export const QuickStartSchema = z.object({
mode: z.enum(['claude', 'shell', 'opencode']).optional(), mode: z.enum(['claude', 'shell', 'opencode']).optional(),
openCodeConfig: OpenCodeConfigSchema, openCodeConfig: OpenCodeConfigSchema,
envOverrides: safeEnvOverridesSchema, envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort: effortLevelSchema,
}); });
// ========== Hook Events ========== // ========== Hook Events ==========
@@ -543,6 +556,8 @@ export const RalphLoopStartSchema = z.object({
maxIterations: z.number().int().min(0).max(1000).nullable().default(10), maxIterations: z.number().int().min(0).max(1000).nullable().default(10),
enableRespawn: z.boolean().default(false), enableRespawn: z.boolean().default(false),
envOverrides: safeEnvOverridesSchema, envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort: effortLevelSchema,
planItems: z planItems: z
.array( .array(
z.object({ z.object({
+3
View File
@@ -1716,6 +1716,8 @@ export class WebServer extends EventEmitter {
const recoveryClaudeMode = await this.getClaudeModeConfig(); const recoveryClaudeMode = await this.getClaudeModeConfig();
// Recover envOverrides from the internal __envOverrides field written by // Recover envOverrides from the internal __envOverrides field written by
// session-manager (see updateSessionState). Cast to read the non-public field. // 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<string, string> })?.__envOverrides; const savedEnvOverrides = (savedState as { __envOverrides?: Record<string, string> })?.__envOverrides;
const session = new Session({ const session = new Session({
id: muxSession.sessionId, // Preserve the original session ID id: muxSession.sessionId, // Preserve the original session ID
@@ -1728,6 +1730,7 @@ export class WebServer extends EventEmitter {
claudeMode: recoveryClaudeMode.claudeMode, claudeMode: recoveryClaudeMode.claudeMode,
allowedTools: recoveryClaudeMode.allowedTools, allowedTools: recoveryClaudeMode.allowedTools,
envOverrides: savedEnvOverrides, envOverrides: savedEnvOverrides,
effort: savedState?.effort,
}); });
// Update session name if it was a "Restored:" placeholder or doesn't match saved name // Update session name if it was a "Restored:" placeholder or doesn't match saved name
+122
View File
@@ -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();
});
});