Compare commits

...
Author SHA1 Message Date
Codeman maintainer 7064b3c1d5 feat(skill): CODEMAN_WORKER_ADVISOR gives spawned claude workers an advisor
spawn_worker builds its quick-start body itself ({caseName, mode,
parentSessionId}), so an agent driving the skill had no way to give a worker
the advisor without hand-building the call and losing the readiness ladder,
hooks vetting and trust-dialog fallback. Setting CODEMAN_WORKER_ADVISOR
(fable / opus / sonnet) now adds `advisorModel` for every claude worker that
spawn_worker or spawn_workers starts; other modes ignore it.

A refused value fails the spawn with the server's INVALID_INPUT message. A
server without advisor support drops the field silently (the schema is not
strict), so spawn_worker reads it back and says so on stderr.

The preamble changed, so CODEMAN_PREAMBLE is bumped to 1.33.4 and stale
cached copies are rewritten instead of silently ignoring the variable.
SKILL.md's heredoc and the plugin mirror are synced.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 19:05:49 +02:00
Codeman maintainer 01f403dc1b 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) <noreply@anthropic.com>
2026-10-04 19:05:43 +02:00
30 changed files with 518 additions and 57 deletions
+1
View File
@@ -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 `<case>/.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 <configDir>/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 <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`
- **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 `<case>/.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
+4 -1
View File
@@ -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
+15 -6
View File
@@ -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
+26 -8
View File
@@ -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() { # <sid> -> 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.
+16 -4
View File
@@ -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() { # <sid> -> 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
@@ -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`). Three differences that break copied code:
`advisorModel`, `envOverrides`). Three differences that break copied code:
- The id is at **`.data.session.id`**, not quick-start's `.data.sessionId`
(`session-routes.ts:878` returns `{ session: lightState }`).
@@ -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
+26 -8
View File
@@ -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() { # <sid> -> 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.
+16 -4
View File
@@ -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() { # <sid> -> 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
+8 -1
View File
@@ -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`). Three differences that break copied code:
`advisorModel`, `envOverrides`). Three differences that break copied code:
- The id is at **`.data.session.id`**, not quick-start's `.data.sessionId`
(`session-routes.ts:878` returns `{ session: lightState }`).
+1 -1
View File
@@ -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
+4
View File
@@ -115,6 +115,8 @@ export interface CreateSessionOptions {
envOverrides?: Record<string, string>;
/** 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 */
+33 -3
View File
@@ -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;
}
+17 -9
View File
@@ -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<string, unknown> =
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<string, unknown> = {
...(effortFlag === '--settings' && effortValue ? JSON.parse(effortValue) : {}),
...advisorSettings,
};
if (options.statusLineCommand) {
settingsObj.statusLine = { type: 'command', command: options.statusLineCommand };
}
+16 -1
View File
@@ -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<string, string>;
/** 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, {
+6
View File
@@ -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,
});
+22
View File
@@ -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
+13
View File
@@ -2172,6 +2172,19 @@
<option value="ultracode">Ultracode</option>
</select>
</div>
<div class="set-row set-row-block" data-search="advisor fable opus sonnet second opinion review consult">
<div class="set-row-text">
<span class="set-row-label">Advisor</span>
<span class="set-row-desc">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.</span>
</div>
<div class="set-segment" id="appSettingsAdvisorSegment" role="radiogroup" aria-label="Advisor"></div>
<select id="appSettingsClaudeAdvisor" class="set-select set-field-hidden" aria-hidden="true" tabindex="-1">
<option value="">Default</option>
<option value="sonnet">Sonnet</option>
<option value="opus">Opus</option>
<option value="fable">Fable</option>
</select>
</div>
</div>
</div>
+2
View File
@@ -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();
+13
View File
@@ -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())
+30 -6
View File
@@ -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 <select>, which stays the single
* source of truth for load/save (the same contract as the model cards).
*/
_buildSelectSegment(selectId, segId) {
const select = document.getElementById(selectId);
const seg = document.getElementById(segId);
if (!select || !seg || seg.dataset.built === '1' || !select.options) return;
seg.innerHTML = '';
[...select.options].forEach(opt => {
@@ -1005,16 +1028,16 @@ Object.assign(CodemanApp.prototype, {
btn.textContent = opt.textContent;
btn.addEventListener('click', () => {
select.value = opt.value;
this._syncEffortSegment();
this._syncSelectSegment(selectId, segId);
});
seg.appendChild(btn);
});
seg.dataset.built = '1';
},
_syncEffortSegment() {
const select = document.getElementById('appSettingsThinkingEffort');
const seg = document.getElementById('appSettingsEffortSegment');
_syncSelectSegment(selectId, segId) {
const select = document.getElementById(selectId);
const seg = document.getElementById(segId);
if (!select || !seg) return;
seg.querySelectorAll('button').forEach(btn => {
const on = btn.dataset.value === (select.value || '');
@@ -2214,6 +2237,7 @@ Object.assign(CodemanApp.prototype, {
opusContext1mEnabled: document.getElementById('appSettingsOpusContext1m').checked,
remoteAutoReconnect: document.getElementById('appSettingsRemoteAutoReconnect').checked,
thinkingEffort: document.getElementById('appSettingsThinkingEffort').value,
claudeAdvisorModel: document.getElementById('appSettingsClaudeAdvisor').value,
// CPU Priority settings
nice: {
enabled: document.getElementById('appSettingsNiceEnabled').checked,
+3
View File
@@ -3155,6 +3155,7 @@ Object.assign(CodemanApp.prototype, {
const globalSettings = this.loadAppSettingsFromStorage();
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), globalSettings);
const effort = this.getEffortSetting(globalSettings);
const advisorModel = this.getAdvisorSetting(globalSettings);
// `resumeSessionId` is a Claude conversation UUID (server reads it from
// ~/.claude/projects); an external-CLI row has no such thing, so sending
// it there gets silently ignored while the OMITTED `mode` field defaults
@@ -3204,6 +3205,8 @@ Object.assign(CodemanApp.prototype, {
...modeConfig,
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
...(effort ? { effort } : {}),
// The advisor is a claude-only feature; other CLIs would carry it inertly.
...(advisorModel && effectiveMode === 'claude' ? { advisorModel } : {}),
}),
});
const createData = await createRes.json();
+1 -1
View File
@@ -18,7 +18,7 @@
* session record involved. A dropped plan therefore returns the user to
* resuming by hand, one at a time, which is where they are without this
* feature. What the plan held that a transcript does not is the owner, the
* name, the env overrides, the effort and the lineage.
* name, the env overrides, the effort, the advisor model and the lineage.
* - Module-level singleton in the style of `web/approval-inbox.ts`: no `Session`
* import and no IO, which keeps it unit-testable and cycle-free.
* - Spending is take-then-build: `take()` removes entries synchronously, before
+2
View File
@@ -293,6 +293,7 @@ export function registerRalphRoutes(
planItems,
envOverrides,
effort,
advisorModel,
} = parseBody(RalphLoopStartSchema, req.body);
// Multi-user: cases live in the requesting user's space.
@@ -343,6 +344,7 @@ export function registerRalphRoutes(
allowedTools: rlClaudeModeConfig.allowedTools,
envOverrides,
effort,
advisorModel,
owner: rlOwner,
});
+1
View File
@@ -195,6 +195,7 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes
(saved as { __envOverrides?: Record<string, string> }).__envOverrides
),
effort: saved.effort,
advisorModel: saved.advisorModel,
attachmentHistory:
(saved as { __attachmentHistory?: SessionAttachmentHistoryItem[] }).__attachmentHistory ??
saved.attachmentHistory,
+7 -2
View File
@@ -1130,6 +1130,7 @@ export function registerSessionRoutes(
resumeSessionId: validatedResumeId,
envOverrides: await clampEnvOverridesForOwner(owner, body.envOverrides),
effort: body.effort,
advisorModel: body.advisorModel,
tmuxHistoryLimit: terminalHistoryConfig.tmuxHistoryLimit,
remote,
owner,
@@ -3393,6 +3394,7 @@ export function registerSessionRoutes(
ompConfig,
envOverrides,
effort,
advisorModel,
parentSessionId,
agentOrigin,
customModel,
@@ -3440,6 +3442,7 @@ export function registerSessionRoutes(
if (
(envOverrides && Object.keys(envOverrides).length > 0) ||
effort ||
advisorModel ||
modelOverride !== undefined ||
codexConfig ||
geminiConfig ||
@@ -3453,7 +3456,7 @@ export function registerSessionRoutes(
) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'envOverrides, effort, modelOverride, per-CLI config, and custom model endpoints are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.'
'envOverrides, effort, advisorModel, modelOverride, per-CLI config, and custom model endpoints are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.'
);
}
@@ -3510,6 +3513,7 @@ export function registerSessionRoutes(
if (
(envOverrides && Object.keys(envOverrides).length > 0) ||
effort ||
advisorModel ||
codexConfig ||
geminiConfig ||
antigravityConfig ||
@@ -3522,7 +3526,7 @@ export function registerSessionRoutes(
) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'envOverrides, effort, per-CLI config, and custom model endpoints are not supported for docker cases (they do not cross into the container). Configure the container via the docker host command override instead.'
'envOverrides, effort, advisorModel, per-CLI config, and custom model endpoints are not supported for docker cases (they do not cross into the container). Configure the container via the docker host command override instead.'
);
}
@@ -3977,6 +3981,7 @@ export function registerSessionRoutes(
ompConfig: qsResolvedOmpConfig,
envOverrides: qsCustomModelEnvOverrides,
effort,
advisorModel,
remote,
docker,
resumeSessionId: dockerResumeId,
+27
View File
@@ -23,6 +23,7 @@ import { MAX_WAKE_MACS } from '../config/remote-wake-limits.js';
import { MAX_INPUT_LENGTH } from '../config/terminal-limits.js';
import { enabledCliIds, enabledClis } from '../config/cli-registry/registry.js';
import type { SessionMode } from '../types.js';
import { isAdvisorModel } from '../types/session.js';
// ========== Path Validation ==========
@@ -251,6 +252,20 @@ const safeEnvOverridesSchema = z
*/
const effortLevelSchema = z.enum(['low', 'medium', 'high', 'xhigh', 'max', 'ultracode']).optional();
/**
* Claude advisor model for new sessions: `fable`/`opus`/`sonnet` or a full model id in one of
* those families (isAdvisorModel). Merged into the launch `--settings` JSON as `advisorModel`,
* a soft default that /advisor still switches in-session. The allowlist is also the injection
* guard for the single-quoted `--settings` argument.
*/
const advisorModelSchema = z
.string()
.max(64)
.refine((value) => isAdvisorModel(value), {
message: 'advisorModel must be fable, opus, sonnet or a full claude-fable/opus/sonnet model id',
})
.optional();
// ========== Session Routes ==========
/**
@@ -524,6 +539,8 @@ export const CreateSessionSchema = z.object({
envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort: effortLevelSchema,
/** Claude advisor model (soft default via --settings, switchable in-session via /advisor) */
advisorModel: advisorModelSchema,
/** Model override to write to .claude/settings.local.json (e.g., "opus[1m]"). Empty string clears. */
modelOverride: z.string().max(50).optional(),
openCodeConfig: OpenCodeConfigSchema,
@@ -1055,6 +1072,8 @@ export const QuickStartSchema = z.object({
envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort: effortLevelSchema,
/** Claude advisor model (soft default via --settings, switchable in-session via /advisor) */
advisorModel: advisorModelSchema,
/**
* Who is spawning this worker (`codeman-skill` from the packaged agent skill), or,
* equivalently, the `X-Codeman-Agent-Origin` header; the body wins when both are
@@ -1374,6 +1393,12 @@ export const SettingsUpdateSchema = z
// auto-reattached.
remoteAutoReconnect: z.boolean().optional(),
thinkingEffort: z.string().max(20).optional(),
/** Advisor model for new Claude sessions ('' = leave it to the CLI's own /advisor choice). */
claudeAdvisorModel: z
.string()
.max(64)
.refine((value) => value === '' || isAdvisorModel(value), { message: 'Invalid advisor model' })
.optional(),
// UI visibility
showFontControls: z.boolean().optional(),
showSystemStats: z.boolean().optional(),
@@ -1862,6 +1887,8 @@ export const RalphLoopStartSchema = z.object({
envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort: effortLevelSchema,
/** Claude advisor model (soft default via --settings, switchable in-session via /advisor) */
advisorModel: advisorModelSchema,
planItems: z
.array(
z.object({
+1
View File
@@ -3489,6 +3489,7 @@ export class WebServer extends EventEmitter {
ompConfig: muxSession.mode === 'omp' ? savedState?.ompConfig : undefined,
envOverrides: savedEnvOverrides,
effort: savedState?.effort,
advisorModel: savedState?.advisorModel,
attachmentHistory: savedAttachmentHistory,
// The pane's last Enter. Without it the response viewer would show
// the launch conversation until the user types again, even though
+197
View File
@@ -0,0 +1,197 @@
/**
* @fileoverview Tests for Claude Code's advisor tool (code.claude.com/docs/en/advisor)
* carried as a per-session `advisorModel`.
*
* The advisor rides the launch's ONE `--settings` JSON object as the `advisorModel` key,
* never the `--advisor` flag: the flag exits at launch on a pairing the CLI refuses
* (`claude --advisor haiku` prints "cannot be used as an advisor" and exits 1), while the
* settings key degrades to "no advisor". Verified against Claude Code 2.1.289 with
* `claude -p --settings '{"advisorModel":"opus"}' /advisor` → "Advisor: Opus 5.5".
*
* `--settings` is extracted through a REAL shell, as in statusline-cli-flag.test.ts, so the
* assertions see exactly what a spawned pane would.
*/
import { describe, it, expect } from 'vitest';
import { execFileSync } from 'node:child_process';
import { buildAdvisorSettings, buildInteractiveArgs } from '../src/session-cli-builder.js';
import { buildSpawnCommand } from '../src/tmux-manager.js';
import { isAdvisorModel, ADVISOR_MODEL_ALIASES } from '../src/types.js';
import { Session } from '../src/session.js';
import {
CreateSessionSchema,
QuickStartSchema,
RalphLoopStartSchema,
SettingsUpdateSchema,
} from '../src/web/schemas.js';
const EXPORTER_CMD = 'curl -sfk -X POST "$CODEMAN_API_URL/api/status-telemetry" --data @- 2>/dev/null || true';
function extractSettingsJson(cmd: string): unknown {
const idx = cmd.indexOf('--settings ');
expect(idx).toBeGreaterThan(-1);
const out = execFileSync('bash', ['-c', `set -- ${cmd.slice(idx)}; printf '%s' "$2"`]).toString();
return JSON.parse(out);
}
describe('isAdvisorModel', () => {
it('accepts the documented aliases', () => {
for (const alias of ADVISOR_MODEL_ALIASES) expect(isAdvisorModel(alias)).toBe(true);
});
it('accepts full model ids in the advisor-capable families', () => {
expect(isAdvisorModel('claude-opus-5-5')).toBe(true);
expect(isAdvisorModel('claude-fable-5-1')).toBe(true);
expect(isAdvisorModel('claude-sonnet-5-5')).toBe(true);
});
it('rejects haiku, which can call an advisor but never act as one', () => {
expect(isAdvisorModel('haiku')).toBe(false);
expect(isAdvisorModel('claude-haiku-4-5-20251001')).toBe(false);
});
it('rejects anything that could break out of the quoted --settings argument', () => {
for (const bad of [
'',
'OPUS',
'opus[1m]',
"opus'; rm -rf /; '",
'opus"}',
'claude-opus-5-5 --dangerously-skip-permissions',
`claude-opus-${'5-'.repeat(40)}5`,
undefined,
null,
42,
]) {
expect(isAdvisorModel(bad)).toBe(false);
}
});
});
describe('buildAdvisorSettings', () => {
it('returns the settings key for a valid model and nothing otherwise', () => {
expect(buildAdvisorSettings('opus')).toEqual({ advisorModel: 'opus' });
expect(buildAdvisorSettings(undefined)).toEqual({});
expect(buildAdvisorSettings('haiku')).toEqual({});
});
});
describe('buildSpawnCommand advisorModel (tmux launch, claude mode)', () => {
it('rides --settings as the advisorModel key, never the --advisor flag', () => {
const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1', advisorModel: 'opus' });
expect(cmd).not.toContain('--advisor');
expect(cmd.match(/--settings/g)).toHaveLength(1);
expect(extractSettingsJson(cmd)).toEqual({ advisorModel: 'opus' });
});
it('merges ultracode, advisor and the statusLine exporter into ONE --settings object', () => {
const cmd = buildSpawnCommand({
mode: 'claude',
sessionId: 'sid-1',
effort: 'ultracode',
advisorModel: 'fable',
statusLineCommand: EXPORTER_CMD,
});
expect(cmd.match(/--settings/g)).toHaveLength(1);
expect(extractSettingsJson(cmd)).toEqual({
ultracode: true,
advisorModel: 'fable',
statusLine: { type: 'command', command: EXPORTER_CMD },
});
});
it('keeps a regular --effort flag beside the advisor settings', () => {
const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1', effort: 'high', advisorModel: 'sonnet' });
expect(cmd).toContain("--effort 'high'");
expect(extractSettingsJson(cmd)).toEqual({ advisorModel: 'sonnet' });
});
it('carries the advisor on the resume variant too', () => {
const cmd = buildSpawnCommand({
mode: 'claude',
sessionId: 'sid-1',
resumeSessionId: '11111111-2222-3333-4444-555555555555',
advisorModel: 'opus',
});
expect(cmd).toContain('--resume');
expect(extractSettingsJson(cmd)).toEqual({ advisorModel: 'opus' });
});
it('leaves the command byte-identical when no advisor (or an invalid one) is set', () => {
for (const base of [
{ mode: 'claude', sessionId: 'sid-1' },
{ mode: 'claude', sessionId: 'sid-1', effort: 'ultracode' as const, statusLineCommand: EXPORTER_CMD },
]) {
const without = buildSpawnCommand(base);
expect(buildSpawnCommand({ ...base, advisorModel: undefined })).toBe(without);
expect(buildSpawnCommand({ ...base, advisorModel: 'haiku' })).toBe(without);
}
});
it('is inert for a CLI that has no --settings carrier', () => {
const cmd = buildSpawnCommand({ mode: 'codex', sessionId: 'sid-1', advisorModel: 'opus' });
expect(cmd).not.toContain('advisorModel');
});
});
describe('buildInteractiveArgs advisorModel (direct-PTY fallback)', () => {
const settingsOf = (args: string[]) => {
expect(args.filter((a) => a === '--settings')).toHaveLength(1);
return JSON.parse(args[args.indexOf('--settings') + 1]);
};
it('adds a --settings object holding only the advisor', () => {
const args = buildInteractiveArgs('sid', 'normal', undefined, undefined, undefined, undefined, null, 'opus');
expect(args).not.toContain('--advisor');
expect(settingsOf(args)).toEqual({ advisorModel: 'opus' });
});
it("folds the advisor into ultracode's --settings object", () => {
const args = buildInteractiveArgs('sid', 'normal', undefined, undefined, 'ultracode', undefined, null, 'fable');
expect(settingsOf(args)).toEqual({ ultracode: true, advisorModel: 'fable' });
});
it('keeps --effort beside it for a regular level', () => {
const args = buildInteractiveArgs('sid', 'normal', undefined, undefined, 'max', undefined, null, 'sonnet');
expect(args).toEqual(expect.arrayContaining(['--effort', 'max']));
expect(settingsOf(args)).toEqual({ advisorModel: 'sonnet' });
});
it('is unchanged without an advisor', () => {
expect(buildInteractiveArgs('sid', 'normal', undefined, undefined, 'high', undefined, null, undefined)).toEqual(
buildInteractiveArgs('sid', 'normal', undefined, undefined, 'high', undefined, null)
);
});
});
describe('Session advisorModel', () => {
it('stores a valid advisor and persists it through toState()', () => {
const session = new Session({ workingDir: '/tmp', advisorModel: 'opus' });
expect(session.toState().advisorModel).toBe('opus');
});
it('drops an invalid value instead of forwarding it to the launch', () => {
expect(new Session({ workingDir: '/tmp', advisorModel: 'haiku' }).toState().advisorModel).toBeUndefined();
expect(new Session({ workingDir: '/tmp' }).toState().advisorModel).toBeUndefined();
});
});
describe('advisorModel request validation', () => {
it.each([
['CreateSessionSchema', CreateSessionSchema, { workingDir: '/tmp' }],
['QuickStartSchema', QuickStartSchema, {}],
['RalphLoopStartSchema', RalphLoopStartSchema, { taskDescription: 'x' }],
] as const)('%s accepts advisor models and rejects the rest', (_name, schema, base) => {
expect(schema.safeParse({ ...base, advisorModel: 'opus' }).success).toBe(true);
expect(schema.safeParse({ ...base, advisorModel: 'claude-fable-5-1' }).success).toBe(true);
expect(schema.safeParse({ ...base }).success).toBe(true);
expect(schema.safeParse({ ...base, advisorModel: 'haiku' }).success).toBe(false);
expect(schema.safeParse({ ...base, advisorModel: "opus'" }).success).toBe(false);
});
it('SettingsUpdateSchema takes claudeAdvisorModel, with "" meaning the CLI default', () => {
expect(SettingsUpdateSchema.safeParse({ claudeAdvisorModel: '' }).success).toBe(true);
expect(SettingsUpdateSchema.safeParse({ claudeAdvisorModel: 'fable' }).success).toBe(true);
expect(SettingsUpdateSchema.safeParse({ claudeAdvisorModel: 'haiku' }).success).toBe(false);
});
});
@@ -55,6 +55,7 @@ function makeApp() {
getCaseSettings: () => ({}),
buildEnvOverrides: () => ({}),
getEffortSetting: () => undefined,
getAdvisorSetting: () => undefined,
selectSession: vi.fn(async () => {}),
};
}