diff --git a/CLAUDE.md b/CLAUDE.md index d127c2ef..4aa86dc3 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: a persistent default in `settings.local.json`, a per-session `--model`, never 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. A caller that wants one session on a model without touching the case sends `model` on `POST /api/sessions` instead: it goes out as `claude --model `, writes nothing, wins over the app-wide default, and is persisted as `SessionState.model` so both recovery paths relaunch on it (`test/routes/session-routes-claude-model.test.ts`, `test/session-model-recovery.test.ts`). - **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/plugins/codeman/skills/codeman/SKILL.md b/plugins/codeman/skills/codeman/SKILL.md index c2aac7da..d328b967 100644 --- a/plugins/codeman/skills/codeman/SKILL.md +++ b/plugins/codeman/skills/codeman/SKILL.md @@ -47,7 +47,7 @@ later call opens with, and your first REAL call performs them anyway: ```bash . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null -[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } +[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } ``` ⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same @@ -75,8 +75,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" mkdir -p "$(dirname "$PRE")" # Rewrite unless the file already ends with THIS version's stamp, so a stale or a # half-written file self-heals here instead of costing you a round trip to rm it. -grep -qs '^CODEMAN_PREAMBLE=1.30.1$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE' -# ---- Codeman agent preamble 1.30.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- +grep -qs '^CODEMAN_PREAMBLE=1.33.4$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE' +# ---- Codeman agent preamble 1.33.4 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}" SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}" # Credentials, cheapest first. Your session has usually INHERITED the server's @@ -196,6 +196,11 @@ _accept_trust() { # -> 0 once it has answered the dialog, 1 if it could n # rather than handed back, because a worker that never drew its composer would eat the # task prompt with its trust dialog. There is deliberately no pid poll: wait-output # already blocks until the composer draws, and pid!=null proved startup, never readiness. +# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while +# it is set Claude Code's advisor tool: a stronger model the worker consults before +# committing to an approach, on a recurring error and before declaring the task done. +# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an +# advisor that ranks below the worker's model is accepted but never attached by claude. spawn_worker() { local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r # parentSessionId doubles the CURL header, so a spawn_worker copied off the shared @@ -207,12 +212,19 @@ spawn_worker() { # server clamps this back to `workspace-write` for an owner without the grant. # Spawn by hand (§5.1) when you want a worker that asks. q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \ - -d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" \ + -d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \ '{caseName:$n,mode:$m,parentSessionId:$p} - + (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)')") + + (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end) + + (if $m == "claude" and $a != "" then {advisorModel:$a} else {} end)')") sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q") # NOT retryable in a loop: every quick-start failure code is terminal (§5.1). [ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; } + # A server without advisor support DROPS the field instead of refusing it, so read it + # back: a worker silently missing the advisor it was asked for is worth one line. + if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] && + [ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then + echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2 + fi if [ "$mode" = deepseek ]; then # The one non-claude mode with REAL end-of-turn signals: its TUI reports # idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals @@ -372,10 +384,10 @@ last_text() { # The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept # bare on purpose: the write condition above anchors on it with $, so an inline comment # here would fail that match and rewrite this file on every single bootstrap. -CODEMAN_PREAMBLE=1.30.1 +CODEMAN_PREAMBLE=1.33.4 PREAMBLE ) -. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; } +. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; } ``` Every later Bash call that touches the API starts with the same two loader lines from @@ -426,7 +438,7 @@ and no per-call body to hand-build. ```bash . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader -[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } +[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } N=(alpha beta) # INVENT one fresh case name per worker; never list cases first # (a name may carry a mode: `beta:deepseek`, see below) T=('reply with one line: the absolute path of your working directory' @@ -493,6 +505,12 @@ Four things this block leans on, each one link away, no detour needed to run it: `sendwait` reads the composer and keeps pressing Enter until the prompt has left it. All three are reasons to let `sendwait` build the call rather than hand-rolling it. - Each `sendwait` costs that worker one billed turn, as does every prompt you send it. +- For long or high-stakes worker tasks, `CODEMAN_WORKER_ADVISOR=opus spawn_workers "${N[@]}"` + (`fable`, `opus` or `sonnet`) gives each claude worker Claude Code's advisor tool: a + stronger model it consults before committing to an approach, on a recurring error and + before declaring the task done. Advisor calls bill extra tokens, and an advisor ranked + below the worker's own model is never attached (on an Opus worker only `opus` and + `fable` do anything). - Deleting the sessions does **not** remove the case directories. They are marked as agent-created, so `GET /api/v1/cases/agent-created` lists them for cleanup: §5.14. diff --git a/plugins/codeman/skills/codeman/preamble.sh b/plugins/codeman/skills/codeman/preamble.sh index 11982e34..285e885f 100644 --- a/plugins/codeman/skills/codeman/preamble.sh +++ b/plugins/codeman/skills/codeman/preamble.sh @@ -1,4 +1,4 @@ -# ---- Codeman agent preamble 1.30.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- +# ---- Codeman agent preamble 1.33.4 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}" SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}" # Credentials, cheapest first. Your session has usually INHERITED the server's @@ -118,6 +118,11 @@ _accept_trust() { # -> 0 once it has answered the dialog, 1 if it could n # rather than handed back, because a worker that never drew its composer would eat the # task prompt with its trust dialog. There is deliberately no pid poll: wait-output # already blocks until the composer draws, and pid!=null proved startup, never readiness. +# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while +# it is set Claude Code's advisor tool: a stronger model the worker consults before +# committing to an approach, on a recurring error and before declaring the task done. +# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an +# advisor that ranks below the worker's model is accepted but never attached by claude. spawn_worker() { local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r # parentSessionId doubles the CURL header, so a spawn_worker copied off the shared @@ -129,12 +134,19 @@ spawn_worker() { # server clamps this back to `workspace-write` for an owner without the grant. # Spawn by hand (§5.1) when you want a worker that asks. q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \ - -d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" \ + -d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \ '{caseName:$n,mode:$m,parentSessionId:$p} - + (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)')") + + (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end) + + (if $m == "claude" and $a != "" then {advisorModel:$a} else {} end)')") sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q") # NOT retryable in a loop: every quick-start failure code is terminal (§5.1). [ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; } + # A server without advisor support DROPS the field instead of refusing it, so read it + # back: a worker silently missing the advisor it was asked for is worth one line. + if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] && + [ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then + echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2 + fi if [ "$mode" = deepseek ]; then # The one non-claude mode with REAL end-of-turn signals: its TUI reports # idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals @@ -294,4 +306,4 @@ last_text() { # The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept # bare on purpose: the write condition above anchors on it with $, so an inline comment # here would fail that match and rewrite this file on every single bootstrap. -CODEMAN_PREAMBLE=1.30.1 +CODEMAN_PREAMBLE=1.33.4 diff --git a/plugins/codeman/skills/codeman/reference/endpoints.md b/plugins/codeman/skills/codeman/reference/endpoints.md index 1913df20..62c6eafd 100644 --- a/plugins/codeman/skills/codeman/reference/endpoints.md +++ b/plugins/codeman/skills/codeman/reference/endpoints.md @@ -345,6 +345,13 @@ ESC=$(printf '\033') `.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory on the user's disk) if missing, do not retry it in a loop, and remember the name. +A claude worker also takes `"advisorModel":"opus"` (`fable`, `opus`, `sonnet` or a full +model id): Claude Code's advisor tool, a stronger model the worker consults before +committing to an approach, on a recurring error and before declaring the task done. It is +a soft default the worker can change with `/advisor`. Remote and docker cases refuse it +(400), as they refuse `effort`. `spawn_worker` and `spawn_workers` send it for you when +`CODEMAN_WORKER_ADVISOR` is set. + ⚠️ A `mode` whose CLI is **not installed on the server** fails the spawn with `OPERATION_FAILED`; it never falls back to claude. Probe first whenever you did not pick the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`, @@ -384,7 +391,7 @@ every claude create path installs them, so a linked case and a raw path both get **The two-step alternative, `POST /api/v1/sessions`.** Use it when you need a session in a directory that is not a case (body takes `workingDir`, `mode`, `name`, `effort`, -`envOverrides`, and for claude a per-session `model` passed as `--model`). Three +`advisorModel`, `envOverrides`, and for claude a per-session `model` passed as `--model`). Three differences that break copied code: - The id is at **`.data.session.id`**, not quick-start's `.data.sessionId` diff --git a/plugins/codeman/skills/codeman/reference/recipes.md b/plugins/codeman/skills/codeman/reference/recipes.md index af814653..352d4e00 100644 --- a/plugins/codeman/skills/codeman/reference/recipes.md +++ b/plugins/codeman/skills/codeman/reference/recipes.md @@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version ```bash . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null -[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; } +[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; } ``` Do **not** re-paste the preamble body into each call. Sourcing it is what retires the diff --git a/skills/codeman/SKILL.md b/skills/codeman/SKILL.md index c2aac7da..d328b967 100644 --- a/skills/codeman/SKILL.md +++ b/skills/codeman/SKILL.md @@ -47,7 +47,7 @@ later call opens with, and your first REAL call performs them anyway: ```bash . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null -[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } +[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } ``` ⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same @@ -75,8 +75,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" mkdir -p "$(dirname "$PRE")" # Rewrite unless the file already ends with THIS version's stamp, so a stale or a # half-written file self-heals here instead of costing you a round trip to rm it. -grep -qs '^CODEMAN_PREAMBLE=1.30.1$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE' -# ---- Codeman agent preamble 1.30.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- +grep -qs '^CODEMAN_PREAMBLE=1.33.4$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE' +# ---- Codeman agent preamble 1.33.4 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}" SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}" # Credentials, cheapest first. Your session has usually INHERITED the server's @@ -196,6 +196,11 @@ _accept_trust() { # -> 0 once it has answered the dialog, 1 if it could n # rather than handed back, because a worker that never drew its composer would eat the # task prompt with its trust dialog. There is deliberately no pid poll: wait-output # already blocks until the composer draws, and pid!=null proved startup, never readiness. +# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while +# it is set Claude Code's advisor tool: a stronger model the worker consults before +# committing to an approach, on a recurring error and before declaring the task done. +# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an +# advisor that ranks below the worker's model is accepted but never attached by claude. spawn_worker() { local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r # parentSessionId doubles the CURL header, so a spawn_worker copied off the shared @@ -207,12 +212,19 @@ spawn_worker() { # server clamps this back to `workspace-write` for an owner without the grant. # Spawn by hand (§5.1) when you want a worker that asks. q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \ - -d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" \ + -d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \ '{caseName:$n,mode:$m,parentSessionId:$p} - + (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)')") + + (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end) + + (if $m == "claude" and $a != "" then {advisorModel:$a} else {} end)')") sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q") # NOT retryable in a loop: every quick-start failure code is terminal (§5.1). [ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; } + # A server without advisor support DROPS the field instead of refusing it, so read it + # back: a worker silently missing the advisor it was asked for is worth one line. + if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] && + [ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then + echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2 + fi if [ "$mode" = deepseek ]; then # The one non-claude mode with REAL end-of-turn signals: its TUI reports # idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals @@ -372,10 +384,10 @@ last_text() { # The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept # bare on purpose: the write condition above anchors on it with $, so an inline comment # here would fail that match and rewrite this file on every single bootstrap. -CODEMAN_PREAMBLE=1.30.1 +CODEMAN_PREAMBLE=1.33.4 PREAMBLE ) -. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; } +. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; } ``` Every later Bash call that touches the API starts with the same two loader lines from @@ -426,7 +438,7 @@ and no per-call body to hand-build. ```bash . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader -[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } +[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } N=(alpha beta) # INVENT one fresh case name per worker; never list cases first # (a name may carry a mode: `beta:deepseek`, see below) T=('reply with one line: the absolute path of your working directory' @@ -493,6 +505,12 @@ Four things this block leans on, each one link away, no detour needed to run it: `sendwait` reads the composer and keeps pressing Enter until the prompt has left it. All three are reasons to let `sendwait` build the call rather than hand-rolling it. - Each `sendwait` costs that worker one billed turn, as does every prompt you send it. +- For long or high-stakes worker tasks, `CODEMAN_WORKER_ADVISOR=opus spawn_workers "${N[@]}"` + (`fable`, `opus` or `sonnet`) gives each claude worker Claude Code's advisor tool: a + stronger model it consults before committing to an approach, on a recurring error and + before declaring the task done. Advisor calls bill extra tokens, and an advisor ranked + below the worker's own model is never attached (on an Opus worker only `opus` and + `fable` do anything). - Deleting the sessions does **not** remove the case directories. They are marked as agent-created, so `GET /api/v1/cases/agent-created` lists them for cleanup: §5.14. diff --git a/skills/codeman/preamble.sh b/skills/codeman/preamble.sh index 11982e34..285e885f 100644 --- a/skills/codeman/preamble.sh +++ b/skills/codeman/preamble.sh @@ -1,4 +1,4 @@ -# ---- Codeman agent preamble 1.30.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- +# ---- Codeman agent preamble 1.33.4 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}" SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}" # Credentials, cheapest first. Your session has usually INHERITED the server's @@ -118,6 +118,11 @@ _accept_trust() { # -> 0 once it has answered the dialog, 1 if it could n # rather than handed back, because a worker that never drew its composer would eat the # task prompt with its trust dialog. There is deliberately no pid poll: wait-output # already blocks until the composer draws, and pid!=null proved startup, never readiness. +# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while +# it is set Claude Code's advisor tool: a stronger model the worker consults before +# committing to an approach, on a recurring error and before declaring the task done. +# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an +# advisor that ranks below the worker's model is accepted but never attached by claude. spawn_worker() { local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r # parentSessionId doubles the CURL header, so a spawn_worker copied off the shared @@ -129,12 +134,19 @@ spawn_worker() { # server clamps this back to `workspace-write` for an owner without the grant. # Spawn by hand (§5.1) when you want a worker that asks. q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \ - -d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" \ + -d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \ '{caseName:$n,mode:$m,parentSessionId:$p} - + (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)')") + + (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end) + + (if $m == "claude" and $a != "" then {advisorModel:$a} else {} end)')") sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q") # NOT retryable in a loop: every quick-start failure code is terminal (§5.1). [ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; } + # A server without advisor support DROPS the field instead of refusing it, so read it + # back: a worker silently missing the advisor it was asked for is worth one line. + if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] && + [ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then + echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2 + fi if [ "$mode" = deepseek ]; then # The one non-claude mode with REAL end-of-turn signals: its TUI reports # idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals @@ -294,4 +306,4 @@ last_text() { # The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept # bare on purpose: the write condition above anchors on it with $, so an inline comment # here would fail that match and rewrite this file on every single bootstrap. -CODEMAN_PREAMBLE=1.30.1 +CODEMAN_PREAMBLE=1.33.4 diff --git a/skills/codeman/reference/endpoints.md b/skills/codeman/reference/endpoints.md index 1913df20..62c6eafd 100644 --- a/skills/codeman/reference/endpoints.md +++ b/skills/codeman/reference/endpoints.md @@ -345,6 +345,13 @@ ESC=$(printf '\033') `.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory on the user's disk) if missing, do not retry it in a loop, and remember the name. +A claude worker also takes `"advisorModel":"opus"` (`fable`, `opus`, `sonnet` or a full +model id): Claude Code's advisor tool, a stronger model the worker consults before +committing to an approach, on a recurring error and before declaring the task done. It is +a soft default the worker can change with `/advisor`. Remote and docker cases refuse it +(400), as they refuse `effort`. `spawn_worker` and `spawn_workers` send it for you when +`CODEMAN_WORKER_ADVISOR` is set. + ⚠️ A `mode` whose CLI is **not installed on the server** fails the spawn with `OPERATION_FAILED`; it never falls back to claude. Probe first whenever you did not pick the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`, @@ -384,7 +391,7 @@ every claude create path installs them, so a linked case and a raw path both get **The two-step alternative, `POST /api/v1/sessions`.** Use it when you need a session in a directory that is not a case (body takes `workingDir`, `mode`, `name`, `effort`, -`envOverrides`, and for claude a per-session `model` passed as `--model`). Three +`advisorModel`, `envOverrides`, and for claude a per-session `model` passed as `--model`). Three differences that break copied code: - The id is at **`.data.session.id`**, not quick-start's `.data.sessionId` diff --git a/skills/codeman/reference/recipes.md b/skills/codeman/reference/recipes.md index af814653..352d4e00 100644 --- a/skills/codeman/reference/recipes.md +++ b/skills/codeman/reference/recipes.md @@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version ```bash . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null -[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; } +[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; } ``` Do **not** re-paste the preamble body into each call. Sourcing it is what retires the 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 08b64194..5835b1ce 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; @@ -1828,6 +1839,7 @@ export class Session extends EventEmitter { resumeSessionId: this._resumeSessionId, effort: this._effort, model: this._model, + 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 @@ -2206,6 +2218,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, @@ -2668,6 +2681,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, @@ -2792,7 +2806,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 a2d03f49..c4570950 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -387,6 +387,26 @@ export const CODEX_REASONING_EFFORTS = ['none', 'minimal', 'low', 'medium', 'hig /** Codex reasoning effort for a session, passed as `--config model_reasoning_effort=` */ export type CodexReasoningEffort = (typeof CODEX_REASONING_EFFORTS)[number]; +/** + * 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") */ @@ -807,6 +827,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; /** * The model the session was LAUNCHED with (`--model`): the caller's per-session `model`, or * the app-wide default when there was none. Persisted so a recovered session relaunches on diff --git a/src/web/public/index.html b/src/web/public/index.html index fb16fb5e..10618032 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2188,6 +2188,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 a6f0dd14..8b315d45 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -531,6 +531,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; @@ -631,6 +632,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 @@ -722,6 +724,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(); @@ -997,8 +1000,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