mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
@@ -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`.
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user