Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<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
- **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`.
Expand Down
7 changes: 6 additions & 1 deletion src/mux-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
ClaudeMode,
SessionMode,
OpenCodeConfig,
EffortLevel,
} from './types.js';

/**
Expand Down Expand Up @@ -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<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. */
Expand All @@ -81,6 +84,8 @@ export interface RespawnPaneOptions {
resumeSessionId?: string;
/** Extra env vars exported before launching the CLI (preserved across respawns). */
envOverrides?: Record<string, string>;
/** Claude CLI effort level (preserved across respawns, injected via `--settings`) */
effort?: EffortLevel;
}

/**
Expand Down
25 changes: 23 additions & 2 deletions src/session-cli-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -31,23 +32,43 @@ 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).
*
* @param sessionId - The Codeman session ID (passed as --session-id to Claude)
* @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;
}

Expand Down
32 changes: 27 additions & 5 deletions src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<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
private _color: import('./types.js').SessionColor = 'default';

Expand Down Expand Up @@ -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<string, string>;
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort?: EffortLevel;
}
) {
super();
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -1051,6 +1072,7 @@ export class Session extends EventEmitter {
openCodeConfig: this._openCodeConfig,
resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides,
effort: this._effort,
},
spawnErrLabel: 'mux attachment',
});
Expand Down Expand Up @@ -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,
Expand Down
40 changes: 37 additions & 3 deletions src/tmux-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 <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: {
mode: SessionMode;
sessionId: string;
Expand All @@ -267,23 +283,25 @@ 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.
const safeResumeId =
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);
Expand Down Expand Up @@ -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<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;
const VALID_KEY = /^[A-Z_][A-Z0-9_]*$/;
for (const [key, value] of Object.entries(envOverrides)) {
Expand Down Expand Up @@ -582,6 +612,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
openCodeConfig,
resumeSessionId,
envOverrides,
effort,
} = options;
const muxName = `codeman-${sessionId.slice(0, 8)}`;

Expand Down Expand Up @@ -628,6 +659,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
allowedTools,
openCodeConfig,
resumeSessionId,
effort,
});

const config = niceConfig || DEFAULT_NICE_CONFIG;
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down
17 changes: 17 additions & 0 deletions src/types/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") */
Expand Down Expand Up @@ -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;
}

/**
Expand Down
3 changes: 2 additions & 1 deletion src/web/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1104,8 +1104,9 @@ <h3>App Settings</h3>
<option value="high">High</option>
<option value="xhigh">XHigh</option>
<option value="max">Max</option>
<option value="ultracode">Ultracode (multi-agent workflows)</option>
</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>
<!-- Nice Priority Section -->
<div class="form-section-header">Nice Priority</div>
Expand Down
8 changes: 4 additions & 4 deletions src/web/public/ralph-wizard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand All @@ -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();
Expand Down
Loading