From 01f403dc1b1d7d81f4db9ee4c0ff7c3aa9e4a36f Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 4 Oct 2026 19:05:43 +0200 Subject: [PATCH 1/2] feat(claude): advisor tool support, per session and as an App Settings default Claude Code's advisor tool (code.claude.com/docs/en/advisor) lets the session's main model consult a second, stronger model at decision points: before committing to an approach, on a recurring error, and before declaring a task done. Codeman can now start claude sessions with one. - `advisorModel` field on POST /api/sessions, /api/quick-start and /api/ralph-loop/start (fable, opus, sonnet or a full model id in those families; haiku cannot advise and is refused). Stored on the session and persisted, so respawn, boot restore and reboot restore keep it. Remote and docker quick-starts refuse it, as they refuse effort. - App Settings, Models, "Advisor" segment (Default / Sonnet / Opus / Fable), synced as `claudeAdvisorModel`. Run, resume and the Ralph wizard send it. Default sends nothing, leaving the CLI's own /advisor choice in charge. - Carried as the `advisorModel` key in the launch's single --settings JSON, merged with ultracode and the statusLine exporter, never the --advisor flag: `claude --advisor haiku` exits 1 at launch, which would leave a dead pane on every respawn, while the settings key degrades to no advisor. A launch without an advisor is byte-identical to before. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 1 + docs/wiki/Agent-CLIs.md | 5 +- docs/wiki/Settings-Reference.md | 21 ++- src/mux-interface.ts | 4 + src/session-cli-builder.ts | 36 +++- src/session-cli-registry-bridge.ts | 26 ++- src/session.ts | 17 +- src/tmux-manager.ts | 6 + src/types/session.ts | 22 +++ src/web/public/index.html | 13 ++ src/web/public/ralph-wizard.js | 2 + src/web/public/session-ui.js | 13 ++ src/web/public/settings-ui.js | 36 +++- src/web/public/terminal-ui.js | 3 + src/web/reboot-restore-registry.ts | 2 +- src/web/routes/ralph-routes.ts | 2 + src/web/routes/reboot-restore-routes.ts | 1 + src/web/routes/session-routes.ts | 9 +- src/web/schemas.ts | 27 +++ src/web/server.ts | 1 + test/advisor-model.test.ts | 197 ++++++++++++++++++++++ test/resume-history-mode-fidelity.test.ts | 1 + 22 files changed, 416 insertions(+), 29 deletions(-) create mode 100644 test/advisor-model.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 2ba2a201..ddd5f489 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -137,6 +137,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph - **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_*` / `CODEX_*` / `GEMINI_*` / `GOOGLE_*` / `ANTIGRAVITY_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` env vars, plus exact-key `CLAUDE_CONFIG_DIR`** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `/.claude/settings.local.json` — that's the old path and creates UI/disk drift. (`GOOGLE_*` is the deliberately-broad Vertex-AI namespace for Gemini — see Multi-CLI prefix discipline.) `CLAUDE_CONFIG_DIR` (#255, exact match via `ALLOWED_ENV_KEYS` in `schemas.ts`) points a session at a separate Claude account/config dir for per-client subscriptions; it persists to state.json (a path, not a secret; losing it on restart would silently switch accounts). ⚠️ A relocated config dir writes transcripts outside `~/.claude/projects`, so the response viewer, subagent windows, ultracode panel and Read My Mind capture go blind for that session unless the user symlinks `projects` back into the shared tree (`ln -s ~/.claude/projects /projects`). ⚠️ It is also one of claude's `privilegedEnvKeys` (Custom Model Endpoint Profiles, since it can redirect a session's traffic same as any other injected var), so in multi-user mode setting it via `envOverrides` is admin-only, and a non-granted owner's already-persisted `CLAUDE_CONFIG_DIR` is stripped on reboot-restore — silently returning that session to the default Claude account rather than the one it was pointed at (see `session-env-clamp.ts`). → [architecture-invariants#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir](docs/architecture-invariants.md#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir) - **Effort is NOT an env var** — never carry effort as `CLAUDE_CODE_EFFORT_LEVEL`: the env var hard-locks effort and blocks in-session `/effort` switching (incl. ultracode). It flows as the dedicated `effort` payload field → `Session._effort` → `claude --effort ` for regular levels incl. `max` (the settings `effortLevel` key is `enum(["low","medium","high","xhigh"]).catch(undefined)` — `max` gets SILENTLY dropped there), or `claude --settings '{"ultracode":true}'` for ultracode (rejected by `--effort`). Both are soft defaults the user can override anytime. Legacy env-var entries are auto-migrated by the Session constructor and unset from tmux sessions in `applyEnvOverrides()`. See `buildEffortCliArgs()` in `session-cli-builder.ts`, tests in `test/effort-injection.test.ts` +- **The advisor rides `--settings`, NEVER the `--advisor` flag**: Claude Code's advisor tool (a stronger model consulted at decision points, code.claude.com/docs/en/advisor) flows as the `advisorModel` payload field → `Session._advisorModel` (persisted, so respawn and reboot restore keep it) → the `advisorModel` key in the launch's ONE `--settings` JSON, merged with ultracode and the statusLine exporter by `buildAdvisorSettings()` (`session-cli-builder.ts`). ⚠️ The flag EXITS at launch on any pairing the CLI refuses (`claude --advisor haiku` exits 1, so does Fable before its usage-credit consent), which would leave a dead pane on every respawn; the settings key degrades to "no advisor" instead. ⚠️ `isAdvisorModel()` (fable/opus/sonnet aliases or full ids, no haiku) is also the injection guard for the single-quoted argument. Soft default: `/advisor` still switches it in-session. App Settings key `claudeAdvisorModel` (SYNCED, `''` = leave it to the CLI). Remote/docker quick-start refuses it, like `effort`. Tests: `test/advisor-model.test.ts` - **Model choice flows via `settings.local.json`, NOT `--model` or env** — the App Settings **Claude Model** picker (`claudeModel` in `settings.json`) is read by `session-ui.js` at session create (wins over the legacy 1M-Opus toggles `opusContext1m`/`opusContext1mEnabled`), sent as the `modelOverride` payload field, and `updateCaseModel()` (`hooks-config.ts`) writes/deletes the `model` key in `/.claude/settings.local.json`. This is the intended exception to the envOverrides rule above: model legitimately lives in `settings.local.json` (a soft default — in-session `/model` still works); env vars do not - **Multi-CLI prefix discipline** — env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `GEMINI_*` vs `ANTIGRAVITY_*` vs `PI_*` vs `GROK_*` vs `DSH_*`) and the `ALLOWED_ENV_PREFIXES` allowlist in `schemas.ts` enforces this; non-prefix exceptions are exact keys in `ALLOWED_ENV_KEYS` (currently only `CLAUDE_CONFIG_DIR`), never a widened prefix. Gemini additionally allowlists the **broad `GOOGLE_*`** namespace (intentional: Vertex AI auth needs `GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI`; it is the loosest allowlist entry, affecting only the user's own spawned CLI), and Grok allowlists **`XAI_*`** for the same vendor-namespace reason (`XAI_API_KEY` is grok's documented auth var). When adding a setting, decide which CLI(s) it applies to and gate the env export accordingly. Never blanket-forward all prefixes. ⚠️ Pi is the case that proves the rule: its ~34 provider keys (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `HF_TOKEN`, …) share NO prefix, and the allowlist is one GLOBAL list applied by a refine with no mode context, so admitting them for pi would widen it for every mode at once — they stay out, and pi users authenticate via `/login` or the server process's own env. ⚠️ DeepSeek repeats pi's lesson exactly: a dsh `settings.yaml` can nominate ANY env var as a provider credential (`apiKeyEnv`), so only the vendor namespaces `DSH_*` (launcher inputs incl. `DSH_PERMISSION_MODE`) and `DEEPSEEK_*` (`DEEPSEEK_API_KEY`/`DEEPSEEK_BASE_URL`) are admitted; foreign provider keys authenticate from dsh's own files or the server env. Resolver design pattern: `docs/opencode-integration.md`, `docs/pi-integration.md`, `docs/grok-integration.md`, `docs/deepseek-integration.md` - **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()`. This has caused real shipped bugs twice diff --git a/docs/wiki/Agent-CLIs.md b/docs/wiki/Agent-CLIs.md index d7ad4b22..9e1d335a 100644 --- a/docs/wiki/Agent-CLIs.md +++ b/docs/wiki/Agent-CLIs.md @@ -76,7 +76,7 @@ output. The other CLIs expose no equivalent. | Read My Mind | Yes | No | | Ralph loop and its task tracker | Yes | No | | Subagent and team windows | Yes | No | -| Model, effort, and ultracode controls | Yes | No | +| Model, effort, advisor, and ultracode controls | Yes | No | | `stop` and `blocked` wait signals | Yes | DeepSeek yes; elsewhere 400 if you ask for them explicitly | | The bundled agent skill | Yes | No | @@ -96,6 +96,9 @@ The defaults you will care about, all under **App Settings**: - **Effort** (`low` through `max`) or **ultracode** for dynamic multi-agent workflows. Also a soft default: `/effort` overrides it any time. Effort is deliberately not passed as an environment variable, because that would hard-lock it and block in-session switching. +- **Advisor** (Sonnet, Opus or Fable): a stronger model Claude consults at decision points, + via Claude Code's [advisor tool](https://code.claude.com/docs/en/advisor). Also a soft + default: `/advisor` switches it or turns it off inside the session. - **Startup permission mode** (Agents & CLIs section). The default is `--dangerously-skip-permissions`, which is why the security model matters. You can switch new sessions to Anthropic's classifier-guarded `auto` mode, normal prompting, or an diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 0c93b7a4..8d761abb 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -87,13 +87,22 @@ every session or only the active tab. ### Models -Claude model cards, the 1M context window switch, and the thinking effort segment. The cards -and the switch compose into one model choice, so there is no separate "which one wins" -question. +Claude model cards, the 1M context window switch, the thinking effort segment and the +advisor segment. The cards and the switch compose into one model choice, so there is no +separate "which one wins" question. -Model and effort are both **soft defaults**: the model is written into the case's -`.claude/settings.local.json` and effort is passed at start, so `/model` and `/effort` -inside a session override them at any time. +Model, effort and advisor are all **soft defaults**: the model is written into the case's +`.claude/settings.local.json` and effort and advisor are passed at start, so `/model`, +`/effort` and `/advisor` inside a session override them at any time. + +**Advisor** gives new Claude sessions Claude Code's +[advisor tool](https://code.claude.com/docs/en/advisor): a second, stronger model that Claude +consults before committing to an approach, when an error keeps coming back, and before it +calls a task done. A common pairing is a Sonnet main model with an Opus or Fable advisor, +which costs less than running the stronger model all the time. **Default** leaves it to +whatever you picked with `/advisor` yourself. The advisor needs the Anthropic API (not +Bedrock or Vertex), and an advisor that ranks below the session's model is simply not +attached. **Custom model endpoints** (off by default) adds a saved-endpoint list plus a matching section to the Run dropdown, for pointing a harness at your own OpenAI-compatible server diff --git a/src/mux-interface.ts b/src/mux-interface.ts index 8461c660..56214633 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -115,6 +115,8 @@ export interface CreateSessionOptions { envOverrides?: Record; /** Claude CLI effort level, injected as a `--settings` soft default (overridable via /effort in-session) */ effort?: EffortLevel; + /** Claude advisor model, merged into the same `--settings` JSON (overridable via /advisor in-session) */ + advisorModel?: string; /** tmux history-limit (scrollback lines) allocated when this session is created. */ historyLimit?: number; /** Remote execution metadata for local tmux sessions wrapping SSH */ @@ -164,6 +166,8 @@ export interface RespawnPaneOptions { unsetEnvKeys?: string[]; /** Claude CLI effort level (preserved across respawns, injected via `--settings`) */ effort?: EffortLevel; + /** Claude advisor model (preserved across respawns, merged into the same `--settings` JSON) */ + advisorModel?: string; /** Original tmux history-limit retained for config parity; respawn cannot resize the existing pane. */ historyLimit?: number; /** Remote execution metadata for local tmux sessions wrapping SSH */ diff --git a/src/session-cli-builder.ts b/src/session-cli-builder.ts index f4807a23..3abba682 100644 --- a/src/session-cli-builder.ts +++ b/src/session-cli-builder.ts @@ -9,7 +9,7 @@ */ import type { ClaudeMode, EffortLevel } from './types.js'; -import { isEffortLevel } from './types.js'; +import { isAdvisorModel, isEffortLevel } from './types.js'; import { getAugmentedPath } from './utils/index.js'; import { compareVersions } from './utils/dependency-checker.js'; import { dataPath } from './config/instance.js'; @@ -54,6 +54,25 @@ export function buildEffortCliArgs(effort?: EffortLevel): string[] { return effort === 'ultracode' ? ['--settings', '{"ultracode":true}'] : ['--effort', effort]; } +/** + * The `--settings` keys that switch on Claude Code's advisor tool for one session: a + * stronger model the main model consults at decision points (code.claude.com/docs/en/advisor). + * Returns `{}` for an absent or non-allowlisted value, so callers can spread it unconditionally. + * + * ⚠️ Carried as the `advisorModel` SETTINGS key, never the `--advisor` flag. The flag EXITS at + * launch on any pairing the CLI refuses (`claude --advisor haiku` prints "cannot be used as an + * advisor" and exits 1, as does a Fable advisor still awaiting usage-credit consent), which + * would leave a dead pane on every spawn and respawn. The settings key degrades instead: the + * CLI simply does not attach an advisor it cannot use. It is a SOFT default either way: + * `/advisor` still switches or turns it off inside the running session. + * + * ⚠️ Claude Code reads only ONE `--settings` flag per invocation, so this must be merged into + * the same JSON object as ultracode and the statusLine exporter, never rendered on its own. + */ +export function buildAdvisorSettings(advisorModel?: string): { advisorModel?: string } { + return isAdvisorModel(advisorModel) ? { advisorModel } : {}; +} + /** * Minimum Claude CLI version for passing `--name` at spawn. 2.1.224 is the release * that ships cross-session messaging (the feature that makes the peer name matter), @@ -111,6 +130,7 @@ export function buildNameCliArgs(sessionName: string | undefined, cliVersion: st * @param effort - Optional effort level, injected via --settings (overridable in-session) * @param sessionName - Optional Codeman session name, passed as `--name` (version-gated) * @param cliVersion - Installed Claude CLI version for the `--name` gate (null = omit the flag) + * @param advisorModel - Optional advisor model, merged into the one `--settings` JSON (see buildAdvisorSettings) * @returns Array of CLI arguments */ export function buildInteractiveArgs( @@ -120,11 +140,21 @@ export function buildInteractiveArgs( allowedTools?: string, effort?: EffortLevel, sessionName?: string, - cliVersion?: string | null + cliVersion?: string | null, + advisorModel?: string ): string[] { const args = [...buildPermissionArgs(claudeMode, allowedTools), '--session-id', sessionId]; if (model) args.push('--model', model); - args.push(...buildEffortCliArgs(effort)); + const effortArgs = buildEffortCliArgs(effort); + const advisor = buildAdvisorSettings(advisorModel); + if (advisor.advisorModel === undefined) { + args.push(...effortArgs); + } else if (effortArgs[0] === '--settings') { + // One --settings flag only: fold the advisor into ultracode's JSON object. + args.push('--settings', JSON.stringify({ ...JSON.parse(effortArgs[1]), ...advisor })); + } else { + args.push(...effortArgs, '--settings', JSON.stringify(advisor)); + } args.push(...buildNameCliArgs(sessionName, cliVersion)); return args; } diff --git a/src/session-cli-registry-bridge.ts b/src/session-cli-registry-bridge.ts index e11e1449..287ab547 100644 --- a/src/session-cli-registry-bridge.ts +++ b/src/session-cli-registry-bridge.ts @@ -20,7 +20,7 @@ import type { CliEntry } from './config/cli-registry/types.js'; import { renderLaunch, type EngineValues, type ParamValues } from './config/cli-registry/argv.js'; import { matchesPattern } from './config/cli-registry/patterns.js'; -import { buildEffortCliArgs, sanitizeCliSessionName } from './session-cli-builder.js'; +import { buildAdvisorSettings, buildEffortCliArgs, sanitizeCliSessionName } from './session-cli-builder.js'; import { compareVersions } from './utils/dependency-checker.js'; import { getClaudeCliVersion } from './utils/claude-cli-resolver.js'; import { launcherDefaultTarget } from './utils/cli-launcher.js'; @@ -54,6 +54,8 @@ export interface SpawnBridgeOptions { ompConfig?: OmpConfig; resumeSessionId?: string; effort?: EffortLevel; + /** Claude advisor model; rides the same `--settings` JSON as ultracode (see buildAdvisorSettings). */ + advisorModel?: string; sessionName?: string; claudeCliVersion?: string | null; /** @@ -198,14 +200,20 @@ export function buildSpawnCommandFromRegistry(entry: CliEntry, options: SpawnBri engineValues.effortLevel = effortValue; } - // Fold the ephemeral plan-usage statusLine exporter (see resolveStatusLineCliCommand in - // hooks-config.ts) into the SAME `--settings` JSON object as ultracode/ effort, since Claude - // Code accepts only one `--settings` flag per invocation — rendering them as two independent - // params would let the second one silently win. Claude-only in practice (statusLineCommand - // is resolved claude-mode-only upstream), but this merge is mode-agnostic. - if ((effortFlag === '--settings' && effortValue) || options.statusLineCommand) { - const settingsObj: Record = - effortFlag === '--settings' && effortValue ? JSON.parse(effortValue) : {}; + // Fold the advisor model and the ephemeral plan-usage statusLine exporter (see + // resolveStatusLineCliCommand in hooks-config.ts) into the SAME `--settings` JSON object as + // ultracode/ effort, since Claude Code accepts only one `--settings` flag per invocation: + // rendering them as independent params would let the last one silently win. Claude-only in + // practice: only claude's launch template renders this engine value, so another CLI's + // session carrying an advisorModel launches exactly as before. + // Key order (ultracode, advisorModel, statusLine) keeps a launch without an advisor + // byte-identical to one from before the advisor existed. + const advisorSettings = buildAdvisorSettings(options.advisorModel); + if ((effortFlag === '--settings' && effortValue) || advisorSettings.advisorModel || options.statusLineCommand) { + const settingsObj: Record = { + ...(effortFlag === '--settings' && effortValue ? JSON.parse(effortValue) : {}), + ...advisorSettings, + }; if (options.statusLineCommand) { settingsObj.statusLine = { type: 'command', command: options.statusLineCommand }; } diff --git a/src/session.ts b/src/session.ts index 3a26e90e..b91582ff 100644 --- a/src/session.ts +++ b/src/session.ts @@ -42,6 +42,7 @@ import { NiceConfig, DEFAULT_NICE_CONFIG, getErrorMessage, + isAdvisorModel, isEffortLevel, type ClaudeMode, type SessionMode, @@ -658,6 +659,11 @@ export class Session extends EventEmitter { // the CLAUDE_CODE_EFFORT_LEVEL env var, which would hard-lock the session. private _effort: EffortLevel | undefined; + // Claude advisor model (code.claude.com/docs/en/advisor), merged into the same launch + // `--settings` JSON as ultracode, never the `--advisor` flag (which exits on a refused + // pairing). A soft default: /advisor still switches or disables it in-session. + private _advisorModel: string | undefined; + // Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md). `envKeys`, // `configDir` and `launchModel` are internal bookkeeping ONLY (never surfaced via // toState()/the customModel getter): they are what setCustomModel() needs to undo a @@ -774,6 +780,8 @@ export class Session extends EventEmitter { envOverrides?: Record; /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ effort?: EffortLevel; + /** Claude advisor model (soft default via --settings, switchable in-session via /advisor) */ + advisorModel?: string; /** tmux history-limit (scrollback lines) allocated when this session's pane is created. */ tmuxHistoryLimit?: number; /** Restored per-session attachment history. May include server-private external paths. */ @@ -934,6 +942,9 @@ export class Session extends EventEmitter { if (config.effort && isEffortLevel(config.effort)) { this._effort = config.effort; } + if (isAdvisorModel(config.advisorModel)) { + this._advisorModel = config.advisorModel; + } this._tmuxHistoryLimit = config.tmuxHistoryLimit ?? DEFAULT_TMUX_HISTORY_LIMIT; this._remote = config.remote; this._docker = config.docker; @@ -1827,6 +1838,7 @@ export class Session extends EventEmitter { ompConfig: this._ompConfig, resumeSessionId: this._resumeSessionId, effort: this._effort, + advisorModel: this._advisorModel, customModel: this.customModel, // COD-118: runtime-only — surfaced so the frontend can require explicit user // intent before restarting a crash-looped session. Deliberately NOT restored @@ -2205,6 +2217,7 @@ export class Session extends EventEmitter { envOverrides: this._envOverrides, unsetEnvKeys: this._pendingEnvUnsets.size > 0 ? [...this._pendingEnvUnsets] : undefined, effort: this._effort, + advisorModel: this._advisorModel, historyLimit: this._tmuxHistoryLimit, remote: this._remote, docker: this._docker, @@ -2667,6 +2680,7 @@ export class Session extends EventEmitter { resumeSessionId: this._resumeSessionId, envOverrides: this._envOverrides, effort: this._effort, + advisorModel: this._advisorModel, historyLimit: this._tmuxHistoryLimit, remote: this._remote, docker: this._docker, @@ -2791,7 +2805,8 @@ export class Session extends EventEmitter { this._allowedTools, this._effort, this.cliPinnedName, - getClaudeCliVersion() + getClaudeCliVersion(), + this._advisorModel ); this.ptyProcess = spawnPtyWithHelperRepair(() => pty.spawn(getClaudeBinaryPath(), args, { diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 77078ec2..49db4ab0 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -882,6 +882,8 @@ export function buildSpawnCommand(options: { ompConfig?: OmpConfig; resumeSessionId?: string; effort?: EffortLevel; + /** Claude advisor model, merged into the launch's one `--settings` JSON (see buildAdvisorSettings). */ + advisorModel?: string; /** Resolved by resolveStatusLineCliCommand (hooks-config.ts) — undefined skips the exporter. Claude only. */ statusLineCommand?: string; /** Name pinned on claude as `--name` (version-gated, sanitized; local spawns only). Only a user-chosen name: see `Session.cliPinnedName`. */ @@ -2083,6 +2085,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { resumeSessionId, envOverrides, effort, + advisorModel, historyLimit = DEFAULT_TMUX_HISTORY_LIMIT, remote, docker, @@ -2170,6 +2173,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { ompConfig, resumeSessionId, effort, + advisorModel, statusLineCommand, sessionName: cliName, }); @@ -2397,6 +2401,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { envOverrides, unsetEnvKeys, effort, + advisorModel, remote, docker, cliName, @@ -2435,6 +2440,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { ompConfig, resumeSessionId, effort, + advisorModel, statusLineCommand, sessionName: cliName, }); diff --git a/src/types/session.ts b/src/types/session.ts index 49249aff..5f5bc0fd 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -377,6 +377,26 @@ export function isEffortLevel(value: string | undefined): value is EffortLevel { return value !== undefined && (EFFORT_LEVELS as readonly string[]).includes(value); } +/** + * Model aliases Claude Code accepts for its advisor tool (a stronger model the session's + * main model consults at decision points; code.claude.com/docs/en/advisor). Haiku is left + * out on purpose: it can call an advisor but never act as one. + */ +export const ADVISOR_MODEL_ALIASES = ['fable', 'opus', 'sonnet'] as const; + +/** A full model id in one of the advisor-capable families, e.g. `claude-opus-5-5`. */ +const ADVISOR_MODEL_ID_PATTERN = /^claude-(?:fable|opus|sonnet)-[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** + * Type guard: is the value an advisor model Codeman will pass to claude? An alias from + * ADVISOR_MODEL_ALIASES or a full fable/opus/sonnet model id. ⚠️ This allowlist is also the + * injection guard: the value is rendered inside the single-quoted `--settings` JSON argument. + */ +export function isAdvisorModel(value: unknown): value is string { + if (typeof value !== 'string' || value.length > 64) return false; + return (ADVISOR_MODEL_ALIASES as readonly string[]).includes(value) || ADVISOR_MODEL_ID_PATTERN.test(value); +} + /** OpenCode session configuration */ export interface OpenCodeConfig { /** Model identifier (e.g., "anthropic/claude-sonnet-4-5", "openai/gpt-5.2", "ollama/codellama") */ @@ -795,6 +815,8 @@ export interface SessionState { resumeSessionId?: string; /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ effort?: EffortLevel; + /** Claude advisor model (`advisorModel` in the launch `--settings`, switchable in-session via /advisor) */ + advisorModel?: string; /** * Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md): the custom * OpenAI-compatible endpoint (local or cloud) this session's CLI is currently pointed diff --git a/src/web/public/index.html b/src/web/public/index.html index 40d19750..efd38927 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2172,6 +2172,19 @@ +
+
+ Advisor + A stronger model Claude consults before big decisions, on repeated errors and before calling a task done. Uses extra tokens. Switchable in-session with /advisor. +
+
+ +
diff --git a/src/web/public/ralph-wizard.js b/src/web/public/ralph-wizard.js index 33938801..7c31c2d8 100644 --- a/src/web/public/ralph-wizard.js +++ b/src/web/public/ralph-wizard.js @@ -1038,6 +1038,7 @@ Object.assign(CodemanApp.prototype, { const ralphGlobalSettings = this.loadAppSettingsFromStorage(); const envOverrides = this.buildEnvOverrides(this.getCaseSettings(config.caseName), ralphGlobalSettings); const effort = this.getEffortSetting(ralphGlobalSettings); + const advisorModel = this.getAdvisorSetting(ralphGlobalSettings); const res = await fetch('/api/ralph-loop/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -1050,6 +1051,7 @@ Object.assign(CodemanApp.prototype, { planItems: enabledItems?.length ? enabledItems : undefined, ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), ...(effort ? { effort } : {}), + ...(advisorModel ? { advisorModel } : {}), }), }); const data = await res.json(); diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 307e2ff3..27a74ec8 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -169,6 +169,17 @@ Object.assign(CodemanApp.prototype, { return valid.includes(effort) ? effort : undefined; }, + /** + * Resolve the advisor model for new Claude sessions from global settings. + * Returns 'fable' | 'opus' | 'sonnet', or undefined (= leave it to the CLI's own + * /advisor choice). Sent as the `advisorModel` payload field; the backend merges it + * into the launch's `claude --settings` JSON, so /advisor still switches it in-session. + */ + getAdvisorSetting(globalSettings) { + const advisor = globalSettings?.claudeAdvisorModel; + return ['fable', 'opus', 'sonnet'].includes(advisor) ? advisor : undefined; + }, + // ═══════════════════════════════════════════════════════════════ // Quick Start // ═══════════════════════════════════════════════════════════════ @@ -1965,6 +1976,7 @@ Object.assign(CodemanApp.prototype, { const envOverrides = this.buildEnvOverrides(caseSettings, globalSettings); const hasEnvOverrides = Object.keys(envOverrides).length > 0; const effort = this.getEffortSetting(globalSettings); + const advisorModel = this.getAdvisorSetting(globalSettings); // Explicit Claude Model choice (App Settings) wins over the legacy 1M Opus // toggles; both flow as `modelOverride` → the case's .claude/settings.local.json const useOpus1m = caseSettings.opusContext1m || globalSettings.opusContext1mEnabled; @@ -1980,6 +1992,7 @@ Object.assign(CodemanApp.prototype, { workingDir, name, ...(hasEnvOverrides ? { envOverrides } : {}), ...(effort ? { effort } : {}), + ...(advisorModel ? { advisorModel } : {}), ...(modelOverride !== undefined ? { modelOverride } : {}), }) }).then(r => r.json()) diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 6c5208e8..faa2f3ed 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -527,6 +527,7 @@ Object.assign(CodemanApp.prototype, { document.getElementById('appSettingsOpusContext1m').checked = settings.opusContext1mEnabled ?? false; document.getElementById('appSettingsRemoteAutoReconnect').checked = settings.remoteAutoReconnect ?? true; document.getElementById('appSettingsThinkingEffort').value = settings.thinkingEffort ?? ''; + document.getElementById('appSettingsClaudeAdvisor').value = settings.claudeAdvisorModel ?? ''; // CPU Priority settings const niceSettings = settings.nice || {}; document.getElementById('appSettingsNiceEnabled').checked = niceSettings.enabled ?? false; @@ -627,6 +628,7 @@ Object.assign(CodemanApp.prototype, { this._syncSettingsChips(); this._syncModelCards(); this._syncEffortSegment(); + this._syncAdvisorSegment(); // Back to the top of the document (one scroll, not a tab reset). Updates is // first now: the version this install is running, and whether a newer one is // waiting, are the two things worth seeing before any preference. The rest of @@ -718,6 +720,7 @@ Object.assign(CodemanApp.prototype, { if (!modal || !doc || typeof modal.querySelectorAll !== 'function') return; this._buildModelCards(); this._buildEffortSegment(); + this._buildAdvisorSegment(); // Rebuilt on every open: admin-ui.js appends its Users entry to the rail // after the first open, and the menu must not drift from the rail. this._buildSettingsJumpMenu(); @@ -993,8 +996,28 @@ Object.assign(CodemanApp.prototype, { }, _buildEffortSegment() { - const select = document.getElementById('appSettingsThinkingEffort'); - const seg = document.getElementById('appSettingsEffortSegment'); + this._buildSelectSegment('appSettingsThinkingEffort', 'appSettingsEffortSegment'); + }, + + _syncEffortSegment() { + this._syncSelectSegment('appSettingsThinkingEffort', 'appSettingsEffortSegment'); + }, + + _buildAdvisorSegment() { + this._buildSelectSegment('appSettingsClaudeAdvisor', 'appSettingsAdvisorSegment'); + }, + + _syncAdvisorSegment() { + this._syncSelectSegment('appSettingsClaudeAdvisor', 'appSettingsAdvisorSegment'); + }, + + /** + * Build a radio segment as a view over a hidden