Compare commits

..
Author SHA1 Message Date
Codeman maintainer d00229ee29 feat(web): show each CLI's logo in the Run menus instead of a colour dot
The Run menus (toolbar dropdown, phone overview picker, Custom Endpoint rows,
model picker) marked every backend with an 8px colour dot, so telling Codex
from DeepSeek meant reading the label. Each known backend now draws its own
logo in that slot. It is CSS only: every surface already renders
`.run-mode-dot <id>`, so no markup changes.

- Brand-coloured marks (Claude, Gemini, Antigravity, DeepSeek, OMP) paint as a
  background image; monochrome ones (Codex, OpenCode, Pi, Grok, plus Shell and
  web URLs) are masks over the row's text colour, so they follow every skin.
- Logos are inline SVG data URIs (img-src already allows data:), from
  @lobehub/icons-static-svg 1.95.1 (MIT); the OMP mark is omp.sh's own.
- Drops the non-og skin overrides that re-tinted four dots with a
  `background:` shorthand, which would have wiped the logo.
- An id with no logo (a clis.json addition) keeps a dot, now in --text-dim
  instead of being transparent.
- test/run-menu-cli-logos.test.ts pins that every stock agent plus shell/web
  has a logo in exactly one paint group and that nothing resets the slot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:52:31 +02:00
32 changed files with 206 additions and 542 deletions
-1
View File
@@ -137,7 +137,6 @@ 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) - **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) - **`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` - **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 - **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` - **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 - **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
+1 -4
View File
@@ -76,7 +76,7 @@ output. The other CLIs expose no equivalent.
| Read My Mind | Yes | No | | Read My Mind | Yes | No |
| Ralph loop and its task tracker | Yes | No | | Ralph loop and its task tracker | Yes | No |
| Subagent and team windows | Yes | No | | Subagent and team windows | Yes | No |
| Model, effort, advisor, and ultracode controls | Yes | No | | Model, effort, and ultracode controls | Yes | No |
| `stop` and `blocked` wait signals | Yes | DeepSeek yes; elsewhere 400 if you ask for them explicitly | | `stop` and `blocked` wait signals | Yes | DeepSeek yes; elsewhere 400 if you ask for them explicitly |
| The bundled agent skill | Yes | No | | The bundled agent skill | Yes | No |
@@ -96,9 +96,6 @@ The defaults you will care about, all under **App Settings**:
- **Effort** (`low` through `max`) or **ultracode** for dynamic multi-agent workflows. Also - **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 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. 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 - **Startup permission mode** (Agents & CLIs section). The default is
`--dangerously-skip-permissions`, which is why the security model matters. You can switch `--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 new sessions to Anthropic's classifier-guarded `auto` mode, normal prompting, or an
+6 -15
View File
@@ -87,22 +87,13 @@ every session or only the active tab.
### Models ### Models
Claude model cards, the 1M context window switch, the thinking effort segment and the Claude model cards, the 1M context window switch, and the thinking effort segment. The cards
advisor segment. The cards and the switch compose into one model choice, so there is no and the switch compose into one model choice, so there is no separate "which one wins"
separate "which one wins" question. question.
Model, effort and advisor are all **soft defaults**: the model is written into the case's Model and effort are both **soft defaults**: the model is written into the case's
`.claude/settings.local.json` and effort and advisor are passed at start, so `/model`, `.claude/settings.local.json` and effort is passed at start, so `/model` and `/effort`
`/effort` and `/advisor` inside a session override them at any time. 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 **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 section to the Run dropdown, for pointing a harness at your own OpenAI-compatible server
+8 -26
View File
@@ -47,7 +47,7 @@ later call opens with, and your first REAL call performs them anyway:
```bash ```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } [ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { 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 ⚠️ **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")" mkdir -p "$(dirname "$PRE")"
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a # 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. # half-written file self-heals here instead of costing you a round trip to rm it.
grep -qs '^CODEMAN_PREAMBLE=1.33.4$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE' grep -qs '^CODEMAN_PREAMBLE=1.30.1$' "$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) ---- # ---- Codeman agent preamble 1.30.1 (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}" API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}" SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
# Credentials, cheapest first. Your session has usually INHERITED the server's # Credentials, cheapest first. Your session has usually INHERITED the server's
@@ -196,11 +196,6 @@ _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 # 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 # 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. # 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() { spawn_worker() {
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r 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 # parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
@@ -212,19 +207,12 @@ spawn_worker() {
# server clamps this back to `workspace-write` for an owner without the grant. # 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. # 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' \ 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" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \ -d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" \
'{caseName:$n,mode:$m,parentSessionId:$p} '{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") 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). # NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 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 if [ "$mode" = deepseek ]; then
# The one non-claude mode with REAL end-of-turn signals: its TUI reports # 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 # idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
@@ -384,10 +372,10 @@ last_text() {
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept # 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 # 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. # here would fail that match and rewrite this file on every single bootstrap.
CODEMAN_PREAMBLE=1.33.4 CODEMAN_PREAMBLE=1.30.1
PREAMBLE PREAMBLE
) )
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; } . "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { 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 Every later Bash call that touches the API starts with the same two loader lines from
@@ -438,7 +426,7 @@ and no per-call body to hand-build.
```bash ```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } [ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { 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 N=(alpha beta) # INVENT one fresh case name per worker; never list cases first
# (a name may carry a mode: `beta:deepseek`, see below) # (a name may carry a mode: `beta:deepseek`, see below)
T=('reply with one line: the absolute path of your working directory' T=('reply with one line: the absolute path of your working directory'
@@ -505,12 +493,6 @@ 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. `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. 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. - 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 - 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. agent-created, so `GET /api/v1/cases/agent-created` lists them for cleanup: §5.14.
+4 -16
View File
@@ -1,4 +1,4 @@
# ---- Codeman agent preamble 1.33.4 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- # ---- Codeman agent preamble 1.30.1 (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}" API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}" SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
# Credentials, cheapest first. Your session has usually INHERITED the server's # Credentials, cheapest first. Your session has usually INHERITED the server's
@@ -118,11 +118,6 @@ _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 # 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 # 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. # 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() { spawn_worker() {
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r 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 # parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
@@ -134,19 +129,12 @@ spawn_worker() {
# server clamps this back to `workspace-write` for an owner without the grant. # 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. # 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' \ 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" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \ -d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" \
'{caseName:$n,mode:$m,parentSessionId:$p} '{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") 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). # NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 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 if [ "$mode" = deepseek ]; then
# The one non-claude mode with REAL end-of-turn signals: its TUI reports # 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 # idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
@@ -306,4 +294,4 @@ last_text() {
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept # 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 # 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. # here would fail that match and rewrite this file on every single bootstrap.
CODEMAN_PREAMBLE=1.33.4 CODEMAN_PREAMBLE=1.30.1
@@ -345,13 +345,6 @@ ESC=$(printf '\033')
`.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory `.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. 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 ⚠️ 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 `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`, the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`,
@@ -391,7 +384,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 **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`, a directory that is not a case (body takes `workingDir`, `mode`, `name`, `effort`,
`advisorModel`, `envOverrides`). Three differences that break copied code: `envOverrides`). Three differences that break copied code:
- The id is at **`.data.session.id`**, not quick-start's `.data.sessionId` - The id is at **`.data.session.id`**, not quick-start's `.data.sessionId`
(`session-routes.ts:878` returns `{ session: lightState }`). (`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 ```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; } [ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { 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 Do **not** re-paste the preamble body into each call. Sourcing it is what retires the
+8 -26
View File
@@ -47,7 +47,7 @@ later call opens with, and your first REAL call performs them anyway:
```bash ```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } [ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { 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 ⚠️ **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")" mkdir -p "$(dirname "$PRE")"
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a # 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. # half-written file self-heals here instead of costing you a round trip to rm it.
grep -qs '^CODEMAN_PREAMBLE=1.33.4$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE' grep -qs '^CODEMAN_PREAMBLE=1.30.1$' "$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) ---- # ---- Codeman agent preamble 1.30.1 (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}" API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}" SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
# Credentials, cheapest first. Your session has usually INHERITED the server's # Credentials, cheapest first. Your session has usually INHERITED the server's
@@ -196,11 +196,6 @@ _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 # 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 # 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. # 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() { spawn_worker() {
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r 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 # parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
@@ -212,19 +207,12 @@ spawn_worker() {
# server clamps this back to `workspace-write` for an owner without the grant. # 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. # 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' \ 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" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \ -d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" \
'{caseName:$n,mode:$m,parentSessionId:$p} '{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") 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). # NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 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 if [ "$mode" = deepseek ]; then
# The one non-claude mode with REAL end-of-turn signals: its TUI reports # 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 # idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
@@ -384,10 +372,10 @@ last_text() {
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept # 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 # 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. # here would fail that match and rewrite this file on every single bootstrap.
CODEMAN_PREAMBLE=1.33.4 CODEMAN_PREAMBLE=1.30.1
PREAMBLE PREAMBLE
) )
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; } . "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { 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 Every later Bash call that touches the API starts with the same two loader lines from
@@ -438,7 +426,7 @@ and no per-call body to hand-build.
```bash ```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } [ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { 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 N=(alpha beta) # INVENT one fresh case name per worker; never list cases first
# (a name may carry a mode: `beta:deepseek`, see below) # (a name may carry a mode: `beta:deepseek`, see below)
T=('reply with one line: the absolute path of your working directory' T=('reply with one line: the absolute path of your working directory'
@@ -505,12 +493,6 @@ 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. `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. 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. - 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 - 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. agent-created, so `GET /api/v1/cases/agent-created` lists them for cleanup: §5.14.
+4 -16
View File
@@ -1,4 +1,4 @@
# ---- Codeman agent preamble 1.33.4 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- # ---- Codeman agent preamble 1.30.1 (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}" API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}" SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
# Credentials, cheapest first. Your session has usually INHERITED the server's # Credentials, cheapest first. Your session has usually INHERITED the server's
@@ -118,11 +118,6 @@ _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 # 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 # 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. # 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() { spawn_worker() {
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r 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 # parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
@@ -134,19 +129,12 @@ spawn_worker() {
# server clamps this back to `workspace-write` for an owner without the grant. # 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. # 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' \ 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" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \ -d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" \
'{caseName:$n,mode:$m,parentSessionId:$p} '{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") 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). # NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 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 if [ "$mode" = deepseek ]; then
# The one non-claude mode with REAL end-of-turn signals: its TUI reports # 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 # idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
@@ -306,4 +294,4 @@ last_text() {
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept # 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 # 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. # here would fail that match and rewrite this file on every single bootstrap.
CODEMAN_PREAMBLE=1.33.4 CODEMAN_PREAMBLE=1.30.1
+1 -8
View File
@@ -345,13 +345,6 @@ ESC=$(printf '\033')
`.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory `.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. 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 ⚠️ 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 `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`, the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`,
@@ -391,7 +384,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 **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`, a directory that is not a case (body takes `workingDir`, `mode`, `name`, `effort`,
`advisorModel`, `envOverrides`). Three differences that break copied code: `envOverrides`). Three differences that break copied code:
- The id is at **`.data.session.id`**, not quick-start's `.data.sessionId` - The id is at **`.data.session.id`**, not quick-start's `.data.sessionId`
(`session-routes.ts:878` returns `{ session: lightState }`). (`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 ```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; } [ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { 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 Do **not** re-paste the preamble body into each call. Sourcing it is what retires the
-4
View File
@@ -115,8 +115,6 @@ export interface CreateSessionOptions {
envOverrides?: Record<string, string>; envOverrides?: Record<string, string>;
/** Claude CLI effort level, injected as a `--settings` soft default (overridable via /effort in-session) */ /** Claude CLI effort level, injected as a `--settings` soft default (overridable via /effort in-session) */
effort?: EffortLevel; 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. */ /** tmux history-limit (scrollback lines) allocated when this session is created. */
historyLimit?: number; historyLimit?: number;
/** Remote execution metadata for local tmux sessions wrapping SSH */ /** Remote execution metadata for local tmux sessions wrapping SSH */
@@ -166,8 +164,6 @@ export interface RespawnPaneOptions {
unsetEnvKeys?: string[]; unsetEnvKeys?: string[];
/** Claude CLI effort level (preserved across respawns, injected via `--settings`) */ /** Claude CLI effort level (preserved across respawns, injected via `--settings`) */
effort?: EffortLevel; 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. */ /** Original tmux history-limit retained for config parity; respawn cannot resize the existing pane. */
historyLimit?: number; historyLimit?: number;
/** Remote execution metadata for local tmux sessions wrapping SSH */ /** Remote execution metadata for local tmux sessions wrapping SSH */
+3 -33
View File
@@ -9,7 +9,7 @@
*/ */
import type { ClaudeMode, EffortLevel } from './types.js'; import type { ClaudeMode, EffortLevel } from './types.js';
import { isAdvisorModel, isEffortLevel } from './types.js'; import { isEffortLevel } from './types.js';
import { getAugmentedPath } from './utils/index.js'; import { getAugmentedPath } from './utils/index.js';
import { compareVersions } from './utils/dependency-checker.js'; import { compareVersions } from './utils/dependency-checker.js';
import { dataPath } from './config/instance.js'; import { dataPath } from './config/instance.js';
@@ -54,25 +54,6 @@ export function buildEffortCliArgs(effort?: EffortLevel): string[] {
return effort === 'ultracode' ? ['--settings', '{"ultracode":true}'] : ['--effort', effort]; 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 * 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), * that ships cross-session messaging (the feature that makes the peer name matter),
@@ -130,7 +111,6 @@ export function buildNameCliArgs(sessionName: string | undefined, cliVersion: st
* @param effort - Optional effort level, injected via --settings (overridable in-session) * @param effort - Optional effort level, injected via --settings (overridable in-session)
* @param sessionName - Optional Codeman session name, passed as `--name` (version-gated) * @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 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 * @returns Array of CLI arguments
*/ */
export function buildInteractiveArgs( export function buildInteractiveArgs(
@@ -140,21 +120,11 @@ export function buildInteractiveArgs(
allowedTools?: string, allowedTools?: string,
effort?: EffortLevel, effort?: EffortLevel,
sessionName?: string, sessionName?: string,
cliVersion?: string | null, cliVersion?: string | null
advisorModel?: string
): string[] { ): string[] {
const args = [...buildPermissionArgs(claudeMode, allowedTools), '--session-id', sessionId]; const args = [...buildPermissionArgs(claudeMode, allowedTools), '--session-id', sessionId];
if (model) args.push('--model', model); if (model) args.push('--model', model);
const effortArgs = buildEffortCliArgs(effort); args.push(...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)); args.push(...buildNameCliArgs(sessionName, cliVersion));
return args; return args;
} }
+9 -17
View File
@@ -20,7 +20,7 @@
import type { CliEntry } from './config/cli-registry/types.js'; import type { CliEntry } from './config/cli-registry/types.js';
import { renderLaunch, type EngineValues, type ParamValues } from './config/cli-registry/argv.js'; import { renderLaunch, type EngineValues, type ParamValues } from './config/cli-registry/argv.js';
import { matchesPattern } from './config/cli-registry/patterns.js'; import { matchesPattern } from './config/cli-registry/patterns.js';
import { buildAdvisorSettings, buildEffortCliArgs, sanitizeCliSessionName } from './session-cli-builder.js'; import { buildEffortCliArgs, sanitizeCliSessionName } from './session-cli-builder.js';
import { compareVersions } from './utils/dependency-checker.js'; import { compareVersions } from './utils/dependency-checker.js';
import { getClaudeCliVersion } from './utils/claude-cli-resolver.js'; import { getClaudeCliVersion } from './utils/claude-cli-resolver.js';
import { launcherDefaultTarget } from './utils/cli-launcher.js'; import { launcherDefaultTarget } from './utils/cli-launcher.js';
@@ -54,8 +54,6 @@ export interface SpawnBridgeOptions {
ompConfig?: OmpConfig; ompConfig?: OmpConfig;
resumeSessionId?: string; resumeSessionId?: string;
effort?: EffortLevel; effort?: EffortLevel;
/** Claude advisor model; rides the same `--settings` JSON as ultracode (see buildAdvisorSettings). */
advisorModel?: string;
sessionName?: string; sessionName?: string;
claudeCliVersion?: string | null; claudeCliVersion?: string | null;
/** /**
@@ -200,20 +198,14 @@ export function buildSpawnCommandFromRegistry(entry: CliEntry, options: SpawnBri
engineValues.effortLevel = effortValue; engineValues.effortLevel = effortValue;
} }
// Fold the advisor model and the ephemeral plan-usage statusLine exporter (see // Fold the ephemeral plan-usage statusLine exporter (see resolveStatusLineCliCommand in
// resolveStatusLineCliCommand in hooks-config.ts) into the SAME `--settings` JSON object as // hooks-config.ts) into the SAME `--settings` JSON object as ultracode/ effort, since Claude
// ultracode/ effort, since Claude Code accepts only one `--settings` flag per invocation: // Code accepts only one `--settings` flag per invocation — rendering them as two independent
// rendering them as independent params would let the last one silently win. Claude-only in // params would let the second one silently win. Claude-only in practice (statusLineCommand
// practice: only claude's launch template renders this engine value, so another CLI's // is resolved claude-mode-only upstream), but this merge is mode-agnostic.
// session carrying an advisorModel launches exactly as before. if ((effortFlag === '--settings' && effortValue) || options.statusLineCommand) {
// Key order (ultracode, advisorModel, statusLine) keeps a launch without an advisor const settingsObj: Record<string, unknown> =
// byte-identical to one from before the advisor existed. effortFlag === '--settings' && effortValue ? JSON.parse(effortValue) : {};
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) { if (options.statusLineCommand) {
settingsObj.statusLine = { type: 'command', command: options.statusLineCommand }; settingsObj.statusLine = { type: 'command', command: options.statusLineCommand };
} }
+1 -16
View File
@@ -42,7 +42,6 @@ import {
NiceConfig, NiceConfig,
DEFAULT_NICE_CONFIG, DEFAULT_NICE_CONFIG,
getErrorMessage, getErrorMessage,
isAdvisorModel,
isEffortLevel, isEffortLevel,
type ClaudeMode, type ClaudeMode,
type SessionMode, type SessionMode,
@@ -659,11 +658,6 @@ export class Session extends EventEmitter {
// the CLAUDE_CODE_EFFORT_LEVEL env var, which would hard-lock the session. // the CLAUDE_CODE_EFFORT_LEVEL env var, which would hard-lock the session.
private _effort: EffortLevel | undefined; 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`, // Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md). `envKeys`,
// `configDir` and `launchModel` are internal bookkeeping ONLY (never surfaced via // `configDir` and `launchModel` are internal bookkeeping ONLY (never surfaced via
// toState()/the customModel getter): they are what setCustomModel() needs to undo a // toState()/the customModel getter): they are what setCustomModel() needs to undo a
@@ -780,8 +774,6 @@ export class Session extends EventEmitter {
envOverrides?: Record<string, string>; envOverrides?: Record<string, string>;
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort?: EffortLevel; 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. */ /** tmux history-limit (scrollback lines) allocated when this session's pane is created. */
tmuxHistoryLimit?: number; tmuxHistoryLimit?: number;
/** Restored per-session attachment history. May include server-private external paths. */ /** Restored per-session attachment history. May include server-private external paths. */
@@ -942,9 +934,6 @@ export class Session extends EventEmitter {
if (config.effort && isEffortLevel(config.effort)) { if (config.effort && isEffortLevel(config.effort)) {
this._effort = config.effort; this._effort = config.effort;
} }
if (isAdvisorModel(config.advisorModel)) {
this._advisorModel = config.advisorModel;
}
this._tmuxHistoryLimit = config.tmuxHistoryLimit ?? DEFAULT_TMUX_HISTORY_LIMIT; this._tmuxHistoryLimit = config.tmuxHistoryLimit ?? DEFAULT_TMUX_HISTORY_LIMIT;
this._remote = config.remote; this._remote = config.remote;
this._docker = config.docker; this._docker = config.docker;
@@ -1838,7 +1827,6 @@ export class Session extends EventEmitter {
ompConfig: this._ompConfig, ompConfig: this._ompConfig,
resumeSessionId: this._resumeSessionId, resumeSessionId: this._resumeSessionId,
effort: this._effort, effort: this._effort,
advisorModel: this._advisorModel,
customModel: this.customModel, customModel: this.customModel,
// COD-118: runtime-only — surfaced so the frontend can require explicit user // COD-118: runtime-only — surfaced so the frontend can require explicit user
// intent before restarting a crash-looped session. Deliberately NOT restored // intent before restarting a crash-looped session. Deliberately NOT restored
@@ -2217,7 +2205,6 @@ export class Session extends EventEmitter {
envOverrides: this._envOverrides, envOverrides: this._envOverrides,
unsetEnvKeys: this._pendingEnvUnsets.size > 0 ? [...this._pendingEnvUnsets] : undefined, unsetEnvKeys: this._pendingEnvUnsets.size > 0 ? [...this._pendingEnvUnsets] : undefined,
effort: this._effort, effort: this._effort,
advisorModel: this._advisorModel,
historyLimit: this._tmuxHistoryLimit, historyLimit: this._tmuxHistoryLimit,
remote: this._remote, remote: this._remote,
docker: this._docker, docker: this._docker,
@@ -2680,7 +2667,6 @@ export class Session extends EventEmitter {
resumeSessionId: this._resumeSessionId, resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides, envOverrides: this._envOverrides,
effort: this._effort, effort: this._effort,
advisorModel: this._advisorModel,
historyLimit: this._tmuxHistoryLimit, historyLimit: this._tmuxHistoryLimit,
remote: this._remote, remote: this._remote,
docker: this._docker, docker: this._docker,
@@ -2805,8 +2791,7 @@ export class Session extends EventEmitter {
this._allowedTools, this._allowedTools,
this._effort, this._effort,
this.cliPinnedName, this.cliPinnedName,
getClaudeCliVersion(), getClaudeCliVersion()
this._advisorModel
); );
this.ptyProcess = spawnPtyWithHelperRepair(() => this.ptyProcess = spawnPtyWithHelperRepair(() =>
pty.spawn(getClaudeBinaryPath(), args, { pty.spawn(getClaudeBinaryPath(), args, {
-6
View File
@@ -882,8 +882,6 @@ export function buildSpawnCommand(options: {
ompConfig?: OmpConfig; ompConfig?: OmpConfig;
resumeSessionId?: string; resumeSessionId?: string;
effort?: EffortLevel; 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. */ /** Resolved by resolveStatusLineCliCommand (hooks-config.ts) — undefined skips the exporter. Claude only. */
statusLineCommand?: string; statusLineCommand?: string;
/** Name pinned on claude as `--name` (version-gated, sanitized; local spawns only). Only a user-chosen name: see `Session.cliPinnedName`. */ /** Name pinned on claude as `--name` (version-gated, sanitized; local spawns only). Only a user-chosen name: see `Session.cliPinnedName`. */
@@ -2085,7 +2083,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
resumeSessionId, resumeSessionId,
envOverrides, envOverrides,
effort, effort,
advisorModel,
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT, historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
remote, remote,
docker, docker,
@@ -2173,7 +2170,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
ompConfig, ompConfig,
resumeSessionId, resumeSessionId,
effort, effort,
advisorModel,
statusLineCommand, statusLineCommand,
sessionName: cliName, sessionName: cliName,
}); });
@@ -2401,7 +2397,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
envOverrides, envOverrides,
unsetEnvKeys, unsetEnvKeys,
effort, effort,
advisorModel,
remote, remote,
docker, docker,
cliName, cliName,
@@ -2440,7 +2435,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
ompConfig, ompConfig,
resumeSessionId, resumeSessionId,
effort, effort,
advisorModel,
statusLineCommand, statusLineCommand,
sessionName: cliName, sessionName: cliName,
}); });
-22
View File
@@ -377,26 +377,6 @@ export function isEffortLevel(value: string | undefined): value is EffortLevel {
return value !== undefined && (EFFORT_LEVELS as readonly string[]).includes(value); 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 */ /** OpenCode session configuration */
export interface OpenCodeConfig { export interface OpenCodeConfig {
/** Model identifier (e.g., "anthropic/claude-sonnet-4-5", "openai/gpt-5.2", "ollama/codellama") */ /** Model identifier (e.g., "anthropic/claude-sonnet-4-5", "openai/gpt-5.2", "ollama/codellama") */
@@ -815,8 +795,6 @@ export interface SessionState {
resumeSessionId?: string; resumeSessionId?: string;
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort?: EffortLevel; 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 * 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 * OpenAI-compatible endpoint (local or cloud) this session's CLI is currently pointed
-13
View File
@@ -2172,19 +2172,6 @@
<option value="ultracode">Ultracode</option> <option value="ultracode">Ultracode</option>
</select> </select>
</div> </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>
</div> </div>
-2
View File
@@ -1038,7 +1038,6 @@ Object.assign(CodemanApp.prototype, {
const ralphGlobalSettings = this.loadAppSettingsFromStorage(); const ralphGlobalSettings = this.loadAppSettingsFromStorage();
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(config.caseName), ralphGlobalSettings); const envOverrides = this.buildEnvOverrides(this.getCaseSettings(config.caseName), ralphGlobalSettings);
const effort = this.getEffortSetting(ralphGlobalSettings); const effort = this.getEffortSetting(ralphGlobalSettings);
const advisorModel = this.getAdvisorSetting(ralphGlobalSettings);
const res = await fetch('/api/ralph-loop/start', { const res = await fetch('/api/ralph-loop/start', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -1051,7 +1050,6 @@ Object.assign(CodemanApp.prototype, {
planItems: enabledItems?.length ? enabledItems : undefined, planItems: enabledItems?.length ? enabledItems : undefined,
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
...(effort ? { effort } : {}), ...(effort ? { effort } : {}),
...(advisorModel ? { advisorModel } : {}),
}), }),
}); });
const data = await res.json(); const data = await res.json();
-13
View File
@@ -169,17 +169,6 @@ Object.assign(CodemanApp.prototype, {
return valid.includes(effort) ? effort : undefined; 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 // Quick Start
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
@@ -1976,7 +1965,6 @@ Object.assign(CodemanApp.prototype, {
const envOverrides = this.buildEnvOverrides(caseSettings, globalSettings); const envOverrides = this.buildEnvOverrides(caseSettings, globalSettings);
const hasEnvOverrides = Object.keys(envOverrides).length > 0; const hasEnvOverrides = Object.keys(envOverrides).length > 0;
const effort = this.getEffortSetting(globalSettings); const effort = this.getEffortSetting(globalSettings);
const advisorModel = this.getAdvisorSetting(globalSettings);
// Explicit Claude Model choice (App Settings) wins over the legacy 1M Opus // Explicit Claude Model choice (App Settings) wins over the legacy 1M Opus
// toggles; both flow as `modelOverride` → the case's .claude/settings.local.json // toggles; both flow as `modelOverride` → the case's .claude/settings.local.json
const useOpus1m = caseSettings.opusContext1m || globalSettings.opusContext1mEnabled; const useOpus1m = caseSettings.opusContext1m || globalSettings.opusContext1mEnabled;
@@ -1992,7 +1980,6 @@ Object.assign(CodemanApp.prototype, {
workingDir, name, workingDir, name,
...(hasEnvOverrides ? { envOverrides } : {}), ...(hasEnvOverrides ? { envOverrides } : {}),
...(effort ? { effort } : {}), ...(effort ? { effort } : {}),
...(advisorModel ? { advisorModel } : {}),
...(modelOverride !== undefined ? { modelOverride } : {}), ...(modelOverride !== undefined ? { modelOverride } : {}),
}) })
}).then(r => r.json()) }).then(r => r.json())
+6 -30
View File
@@ -527,7 +527,6 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsOpusContext1m').checked = settings.opusContext1mEnabled ?? false; document.getElementById('appSettingsOpusContext1m').checked = settings.opusContext1mEnabled ?? false;
document.getElementById('appSettingsRemoteAutoReconnect').checked = settings.remoteAutoReconnect ?? true; document.getElementById('appSettingsRemoteAutoReconnect').checked = settings.remoteAutoReconnect ?? true;
document.getElementById('appSettingsThinkingEffort').value = settings.thinkingEffort ?? ''; document.getElementById('appSettingsThinkingEffort').value = settings.thinkingEffort ?? '';
document.getElementById('appSettingsClaudeAdvisor').value = settings.claudeAdvisorModel ?? '';
// CPU Priority settings // CPU Priority settings
const niceSettings = settings.nice || {}; const niceSettings = settings.nice || {};
document.getElementById('appSettingsNiceEnabled').checked = niceSettings.enabled ?? false; document.getElementById('appSettingsNiceEnabled').checked = niceSettings.enabled ?? false;
@@ -628,7 +627,6 @@ Object.assign(CodemanApp.prototype, {
this._syncSettingsChips(); this._syncSettingsChips();
this._syncModelCards(); this._syncModelCards();
this._syncEffortSegment(); this._syncEffortSegment();
this._syncAdvisorSegment();
// Back to the top of the document (one scroll, not a tab reset). Updates is // 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 // 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 // waiting, are the two things worth seeing before any preference. The rest of
@@ -720,7 +718,6 @@ Object.assign(CodemanApp.prototype, {
if (!modal || !doc || typeof modal.querySelectorAll !== 'function') return; if (!modal || !doc || typeof modal.querySelectorAll !== 'function') return;
this._buildModelCards(); this._buildModelCards();
this._buildEffortSegment(); this._buildEffortSegment();
this._buildAdvisorSegment();
// Rebuilt on every open: admin-ui.js appends its Users entry to the rail // 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. // after the first open, and the menu must not drift from the rail.
this._buildSettingsJumpMenu(); this._buildSettingsJumpMenu();
@@ -996,28 +993,8 @@ Object.assign(CodemanApp.prototype, {
}, },
_buildEffortSegment() { _buildEffortSegment() {
this._buildSelectSegment('appSettingsThinkingEffort', 'appSettingsEffortSegment'); const select = document.getElementById('appSettingsThinkingEffort');
}, const seg = document.getElementById('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; if (!select || !seg || seg.dataset.built === '1' || !select.options) return;
seg.innerHTML = ''; seg.innerHTML = '';
[...select.options].forEach(opt => { [...select.options].forEach(opt => {
@@ -1028,16 +1005,16 @@ Object.assign(CodemanApp.prototype, {
btn.textContent = opt.textContent; btn.textContent = opt.textContent;
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
select.value = opt.value; select.value = opt.value;
this._syncSelectSegment(selectId, segId); this._syncEffortSegment();
}); });
seg.appendChild(btn); seg.appendChild(btn);
}); });
seg.dataset.built = '1'; seg.dataset.built = '1';
}, },
_syncSelectSegment(selectId, segId) { _syncEffortSegment() {
const select = document.getElementById(selectId); const select = document.getElementById('appSettingsThinkingEffort');
const seg = document.getElementById(segId); const seg = document.getElementById('appSettingsEffortSegment');
if (!select || !seg) return; if (!select || !seg) return;
seg.querySelectorAll('button').forEach(btn => { seg.querySelectorAll('button').forEach(btn => {
const on = btn.dataset.value === (select.value || ''); const on = btn.dataset.value === (select.value || '');
@@ -2237,7 +2214,6 @@ Object.assign(CodemanApp.prototype, {
opusContext1mEnabled: document.getElementById('appSettingsOpusContext1m').checked, opusContext1mEnabled: document.getElementById('appSettingsOpusContext1m').checked,
remoteAutoReconnect: document.getElementById('appSettingsRemoteAutoReconnect').checked, remoteAutoReconnect: document.getElementById('appSettingsRemoteAutoReconnect').checked,
thinkingEffort: document.getElementById('appSettingsThinkingEffort').value, thinkingEffort: document.getElementById('appSettingsThinkingEffort').value,
claudeAdvisorModel: document.getElementById('appSettingsClaudeAdvisor').value,
// CPU Priority settings // CPU Priority settings
nice: { nice: {
enabled: document.getElementById('appSettingsNiceEnabled').checked, enabled: document.getElementById('appSettingsNiceEnabled').checked,
File diff suppressed because one or more lines are too long
-3
View File
@@ -3155,7 +3155,6 @@ Object.assign(CodemanApp.prototype, {
const globalSettings = this.loadAppSettingsFromStorage(); const globalSettings = this.loadAppSettingsFromStorage();
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), globalSettings); const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), globalSettings);
const effort = this.getEffortSetting(globalSettings); const effort = this.getEffortSetting(globalSettings);
const advisorModel = this.getAdvisorSetting(globalSettings);
// `resumeSessionId` is a Claude conversation UUID (server reads it from // `resumeSessionId` is a Claude conversation UUID (server reads it from
// ~/.claude/projects); an external-CLI row has no such thing, so sending // ~/.claude/projects); an external-CLI row has no such thing, so sending
// it there gets silently ignored while the OMITTED `mode` field defaults // it there gets silently ignored while the OMITTED `mode` field defaults
@@ -3205,8 +3204,6 @@ Object.assign(CodemanApp.prototype, {
...modeConfig, ...modeConfig,
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
...(effort ? { effort } : {}), ...(effort ? { effort } : {}),
// The advisor is a claude-only feature; other CLIs would carry it inertly.
...(advisorModel && effectiveMode === 'claude' ? { advisorModel } : {}),
}), }),
}); });
const createData = await createRes.json(); const createData = await createRes.json();
+1 -1
View File
@@ -18,7 +18,7 @@
* session record involved. A dropped plan therefore returns the user to * 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 * 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 * feature. What the plan held that a transcript does not is the owner, the
* name, the env overrides, the effort, the advisor model and the lineage. * name, the env overrides, the effort and the lineage.
* - Module-level singleton in the style of `web/approval-inbox.ts`: no `Session` * - 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. * import and no IO, which keeps it unit-testable and cycle-free.
* - Spending is take-then-build: `take()` removes entries synchronously, before * - Spending is take-then-build: `take()` removes entries synchronously, before
-2
View File
@@ -293,7 +293,6 @@ export function registerRalphRoutes(
planItems, planItems,
envOverrides, envOverrides,
effort, effort,
advisorModel,
} = parseBody(RalphLoopStartSchema, req.body); } = parseBody(RalphLoopStartSchema, req.body);
// Multi-user: cases live in the requesting user's space. // Multi-user: cases live in the requesting user's space.
@@ -344,7 +343,6 @@ export function registerRalphRoutes(
allowedTools: rlClaudeModeConfig.allowedTools, allowedTools: rlClaudeModeConfig.allowedTools,
envOverrides, envOverrides,
effort, effort,
advisorModel,
owner: rlOwner, owner: rlOwner,
}); });
-1
View File
@@ -195,7 +195,6 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes
(saved as { __envOverrides?: Record<string, string> }).__envOverrides (saved as { __envOverrides?: Record<string, string> }).__envOverrides
), ),
effort: saved.effort, effort: saved.effort,
advisorModel: saved.advisorModel,
attachmentHistory: attachmentHistory:
(saved as { __attachmentHistory?: SessionAttachmentHistoryItem[] }).__attachmentHistory ?? (saved as { __attachmentHistory?: SessionAttachmentHistoryItem[] }).__attachmentHistory ??
saved.attachmentHistory, saved.attachmentHistory,
+2 -7
View File
@@ -1130,7 +1130,6 @@ export function registerSessionRoutes(
resumeSessionId: validatedResumeId, resumeSessionId: validatedResumeId,
envOverrides: await clampEnvOverridesForOwner(owner, body.envOverrides), envOverrides: await clampEnvOverridesForOwner(owner, body.envOverrides),
effort: body.effort, effort: body.effort,
advisorModel: body.advisorModel,
tmuxHistoryLimit: terminalHistoryConfig.tmuxHistoryLimit, tmuxHistoryLimit: terminalHistoryConfig.tmuxHistoryLimit,
remote, remote,
owner, owner,
@@ -3394,7 +3393,6 @@ export function registerSessionRoutes(
ompConfig, ompConfig,
envOverrides, envOverrides,
effort, effort,
advisorModel,
parentSessionId, parentSessionId,
agentOrigin, agentOrigin,
customModel, customModel,
@@ -3442,7 +3440,6 @@ export function registerSessionRoutes(
if ( if (
(envOverrides && Object.keys(envOverrides).length > 0) || (envOverrides && Object.keys(envOverrides).length > 0) ||
effort || effort ||
advisorModel ||
modelOverride !== undefined || modelOverride !== undefined ||
codexConfig || codexConfig ||
geminiConfig || geminiConfig ||
@@ -3456,7 +3453,7 @@ export function registerSessionRoutes(
) { ) {
return createErrorResponse( return createErrorResponse(
ApiErrorCode.INVALID_INPUT, ApiErrorCode.INVALID_INPUT,
'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.' '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.'
); );
} }
@@ -3513,7 +3510,6 @@ export function registerSessionRoutes(
if ( if (
(envOverrides && Object.keys(envOverrides).length > 0) || (envOverrides && Object.keys(envOverrides).length > 0) ||
effort || effort ||
advisorModel ||
codexConfig || codexConfig ||
geminiConfig || geminiConfig ||
antigravityConfig || antigravityConfig ||
@@ -3526,7 +3522,7 @@ export function registerSessionRoutes(
) { ) {
return createErrorResponse( return createErrorResponse(
ApiErrorCode.INVALID_INPUT, ApiErrorCode.INVALID_INPUT,
'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.' '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.'
); );
} }
@@ -3981,7 +3977,6 @@ export function registerSessionRoutes(
ompConfig: qsResolvedOmpConfig, ompConfig: qsResolvedOmpConfig,
envOverrides: qsCustomModelEnvOverrides, envOverrides: qsCustomModelEnvOverrides,
effort, effort,
advisorModel,
remote, remote,
docker, docker,
resumeSessionId: dockerResumeId, resumeSessionId: dockerResumeId,
-27
View File
@@ -23,7 +23,6 @@ import { MAX_WAKE_MACS } from '../config/remote-wake-limits.js';
import { MAX_INPUT_LENGTH } from '../config/terminal-limits.js'; import { MAX_INPUT_LENGTH } from '../config/terminal-limits.js';
import { enabledCliIds, enabledClis } from '../config/cli-registry/registry.js'; import { enabledCliIds, enabledClis } from '../config/cli-registry/registry.js';
import type { SessionMode } from '../types.js'; import type { SessionMode } from '../types.js';
import { isAdvisorModel } from '../types/session.js';
// ========== Path Validation ========== // ========== Path Validation ==========
@@ -252,20 +251,6 @@ const safeEnvOverridesSchema = z
*/ */
const effortLevelSchema = z.enum(['low', 'medium', 'high', 'xhigh', 'max', 'ultracode']).optional(); 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 ========== // ========== Session Routes ==========
/** /**
@@ -539,8 +524,6 @@ export const CreateSessionSchema = z.object({
envOverrides: safeEnvOverridesSchema, envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort: effortLevelSchema, 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. */ /** Model override to write to .claude/settings.local.json (e.g., "opus[1m]"). Empty string clears. */
modelOverride: z.string().max(50).optional(), modelOverride: z.string().max(50).optional(),
openCodeConfig: OpenCodeConfigSchema, openCodeConfig: OpenCodeConfigSchema,
@@ -1072,8 +1055,6 @@ export const QuickStartSchema = z.object({
envOverrides: safeEnvOverridesSchema, envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort: effortLevelSchema, 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, * 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 * equivalently, the `X-Codeman-Agent-Origin` header; the body wins when both are
@@ -1393,12 +1374,6 @@ export const SettingsUpdateSchema = z
// auto-reattached. // auto-reattached.
remoteAutoReconnect: z.boolean().optional(), remoteAutoReconnect: z.boolean().optional(),
thinkingEffort: z.string().max(20).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 // UI visibility
showFontControls: z.boolean().optional(), showFontControls: z.boolean().optional(),
showSystemStats: z.boolean().optional(), showSystemStats: z.boolean().optional(),
@@ -1887,8 +1862,6 @@ export const RalphLoopStartSchema = z.object({
envOverrides: safeEnvOverridesSchema, envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort: effortLevelSchema, effort: effortLevelSchema,
/** Claude advisor model (soft default via --settings, switchable in-session via /advisor) */
advisorModel: advisorModelSchema,
planItems: z planItems: z
.array( .array(
z.object({ z.object({
-1
View File
@@ -3489,7 +3489,6 @@ export class WebServer extends EventEmitter {
ompConfig: muxSession.mode === 'omp' ? savedState?.ompConfig : undefined, ompConfig: muxSession.mode === 'omp' ? savedState?.ompConfig : undefined,
envOverrides: savedEnvOverrides, envOverrides: savedEnvOverrides,
effort: savedState?.effort, effort: savedState?.effort,
advisorModel: savedState?.advisorModel,
attachmentHistory: savedAttachmentHistory, attachmentHistory: savedAttachmentHistory,
// The pane's last Enter. Without it the response viewer would show // The pane's last Enter. Without it the response viewer would show
// the launch conversation until the user types again, even though // the launch conversation until the user types again, even though
-197
View File
@@ -1,197 +0,0 @@
/**
* @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,7 +55,6 @@ function makeApp() {
getCaseSettings: () => ({}), getCaseSettings: () => ({}),
buildEnvOverrides: () => ({}), buildEnvOverrides: () => ({}),
getEffortSetting: () => undefined, getEffortSetting: () => undefined,
getAdvisorSetting: () => undefined,
selectSession: vi.fn(async () => {}), selectSession: vi.fn(async () => {}),
}; };
} }
+85
View File
@@ -0,0 +1,85 @@
/**
* @fileoverview Static guard for the Run menus' per-CLI logos (styles.css).
*
* Every launch surface (toolbar Run menu, phone overview picker, Custom Endpoint
* rows, model picker) renders `<span class="run-mode-dot <id>">`, and styles.css
* turns that slot into the CLI's logo through a `--run-mode-logo` data URI plus
* one of two paint groups: brand-coloured marks paint as a background image,
* monochrome marks as a mask over the row's text colour. Three ways that breaks
* silently, each pinned here:
* - a new stock CLI lands with no logo rule (its row shows a bare grey dot);
* - a logo rule exists but the id is in neither paint group (the variable is
* set and never painted, so the slot is a blank 15px gap);
* - a later `background:` shorthand on `.run-mode-dot.<id>` (e.g. a skin
* override like the ones the non-og block used to carry) resets
* background-image and wipes the logo.
*
* Port: none (pure static analysis).
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
// Comments stripped: they sit between rules and carry commas, which would bleed
// into the selector lists split below.
const styles = readFileSync(resolve('src/web/public/styles.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');
/** Every id a Run menu renders as `.run-mode-dot <id>`: the stock agents, Shell, and web tabs. */
const RUN_MENU_IDS = [
...STOCK_CLIS.filter((cli) => cli.kind === 'agent').map((cli) => cli.id as string),
'shell',
'web',
];
/** The selector list of the one rule whose body contains `marker`. */
function groupIds(marker: string): string[] {
const rule = [...styles.matchAll(/([^{}]+)\{([^{}]*)\}/g)].filter((m) => m[2].includes(marker));
expect(rule, `exactly one rule paints via "${marker}"`).toHaveLength(1);
return rule[0][1]
.split(',')
.map((s) => s.trim().match(/^\.run-mode-dot\.([a-z0-9-]+)$/)?.[1])
.filter((id): id is string => Boolean(id));
}
function logoSvg(id: string): string {
const m = styles.match(
new RegExp(`\\.run-mode-dot\\.${id} \\{ --run-mode-logo: url\\("data:image/svg\\+xml,([^"]+)"\\); \\}`)
);
expect(m, `.run-mode-dot.${id} has a --run-mode-logo rule`).not.toBeNull();
return decodeURIComponent(m![1]);
}
describe('Run menu CLI logos', () => {
const colourIds = groupIds('background: var(--run-mode-logo)');
const maskIds = groupIds('mask: var(--run-mode-logo)');
it.each(RUN_MENU_IDS)('%s has a logo that one paint group draws', (id) => {
const svg = logoSvg(id);
expect(svg.startsWith('<svg')).toBe(true);
expect(svg).toMatch(/viewBox='[\d. ]+'/);
expect(colourIds.includes(id) !== maskIds.includes(id), `${id} is in exactly one paint group`).toBe(true);
});
it('defines no logo that no surface renders, and no group member without a logo', () => {
expect([...colourIds, ...maskIds].sort()).toEqual([...RUN_MENU_IDS].sort());
});
it('carries no script or event handler inside a data URI', () => {
for (const id of RUN_MENU_IDS) {
const svg = logoSvg(id);
expect(svg).not.toMatch(/<script|\son[a-z]+=|javascript:|href=/i);
}
});
it('never resets a logo slot with a background shorthand outside the colour group', () => {
const offenders = [...styles.matchAll(/([^{}]+)\{([^{}]*)\}/g)].filter(
([, selector, body]) =>
/\.run-mode-dot\.[a-z]/.test(selector) &&
/(^|[;\s])background\s*:/.test(body) &&
!body.includes('var(--run-mode-logo)')
);
expect(offenders.map((m) => m[1].trim())).toEqual([]);
});
});