Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
- **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically)
- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `GEMINI_*` / `GOOGLE_*` / `ANTIGRAVITY_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` env vars, plus exact-key `CLAUDE_CONFIG_DIR`** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `<case>/.claude/settings.local.json` — that's the old path and creates UI/disk drift. (`GOOGLE_*` is the deliberately-broad Vertex-AI namespace for Gemini — see Multi-CLI prefix discipline.) `CLAUDE_CONFIG_DIR` (#255, exact match via `ALLOWED_ENV_KEYS` in `schemas.ts`) points a session at a separate Claude account/config dir for per-client subscriptions; it persists to state.json (a path, not a secret; losing it on restart would silently switch accounts). ⚠️ A relocated config dir writes transcripts outside `~/.claude/projects`, so the response viewer, subagent windows, ultracode panel and Read My Mind capture go blind for that session unless the user symlinks `projects` back into the shared tree (`ln -s ~/.claude/projects <configDir>/projects`). ⚠️ It is also one of claude's `privilegedEnvKeys` (Custom Model Endpoint Profiles, since it can redirect a session's traffic same as any other injected var), so in multi-user mode setting it via `envOverrides` is admin-only, and a non-granted owner's already-persisted `CLAUDE_CONFIG_DIR` is stripped on reboot-restore — silently returning that session to the default Claude account rather than the one it was pointed at (see `session-env-clamp.ts`). → [architecture-invariants#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir](docs/architecture-invariants.md#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir)
- **Effort is NOT an env var** — never carry effort as `CLAUDE_CODE_EFFORT_LEVEL`: the env var hard-locks effort and blocks in-session `/effort` switching (incl. ultracode). It flows as the dedicated `effort` payload field → `Session._effort` → `claude --effort <level>` for regular levels incl. `max` (the settings `effortLevel` key is `enum(["low","medium","high","xhigh"]).catch(undefined)` — `max` gets SILENTLY dropped there), or `claude --settings '{"ultracode":true}'` for ultracode (rejected by `--effort`). Both are soft defaults the user can override anytime. Legacy env-var entries are auto-migrated by the Session constructor and unset from tmux sessions in `applyEnvOverrides()`. See `buildEffortCliArgs()` in `session-cli-builder.ts`, tests in `test/effort-injection.test.ts`
- **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: a persistent default in `settings.local.json`, a per-session `--model`, never env** — the App Settings **Claude Model** picker (`claudeModel` in `settings.json`) is read by `session-ui.js` at session create (wins over the legacy 1M-Opus toggles `opusContext1m`/`opusContext1mEnabled`), sent as the `modelOverride` payload field, and `updateCaseModel()` (`hooks-config.ts`) writes/deletes the `model` key in `<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. A caller that wants one session on a model without touching the case sends `model` on `POST /api/sessions` instead: it goes out as `claude --model <id>`, writes nothing, wins over the app-wide default, and is persisted as `SessionState.model` so both recovery paths relaunch on it (`test/routes/session-routes-claude-model.test.ts`, `test/session-model-recovery.test.ts`).
- **Multi-CLI prefix discipline** — env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `GEMINI_*` vs `ANTIGRAVITY_*` vs `PI_*` vs `GROK_*` vs `DSH_*`) and the `ALLOWED_ENV_PREFIXES` allowlist in `schemas.ts` enforces this; non-prefix exceptions are exact keys in `ALLOWED_ENV_KEYS` (currently only `CLAUDE_CONFIG_DIR`), never a widened prefix. Gemini additionally allowlists the **broad `GOOGLE_*`** namespace (intentional: Vertex AI auth needs `GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI`; it is the loosest allowlist entry, affecting only the user's own spawned CLI), and Grok allowlists **`XAI_*`** for the same vendor-namespace reason (`XAI_API_KEY` is grok's documented auth var). When adding a setting, decide which CLI(s) it applies to and gate the env export accordingly. Never blanket-forward all prefixes. ⚠️ Pi is the case that proves the rule: its ~34 provider keys (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `HF_TOKEN`, …) share NO prefix, and the allowlist is one GLOBAL list applied by a refine with no mode context, so admitting them for pi would widen it for every mode at once — they stay out, and pi users authenticate via `/login` or the server process's own env. ⚠️ DeepSeek repeats pi's lesson exactly: a dsh `settings.yaml` can nominate ANY env var as a provider credential (`apiKeyEnv`), so only the vendor namespaces `DSH_*` (launcher inputs incl. `DSH_PERMISSION_MODE`) and `DEEPSEEK_*` (`DEEPSEEK_API_KEY`/`DEEPSEEK_BASE_URL`) are admitted; foreign provider keys authenticate from dsh's own files or the server env. Resolver design pattern: `docs/opencode-integration.md`, `docs/pi-integration.md`, `docs/grok-integration.md`, `docs/deepseek-integration.md`
- **Zod `.optional()` rejects `null`** — accepts `undefined` only. When the frontend builds a request body with `JSON.stringify`, an explicit `null` field is preserved on the wire and fails validation with `INVALID_INPUT`. Convert `null` → `undefined` before stringifying (e.g. `field: value ?? undefined`), or declare the schema `.nullish()`. This has caused real shipped bugs twice
- **Local-echo overlay stays on screen**: the overlay lays its wrapped lines out DOWNWARD from the prompt row, and the text has not reached the PTY yet, so the CLI never learns the prompt is long and nothing scrolls to make room. With the keyboard up only a handful of rows are visible, so a long prompt used to run off the bottom and the user typed blind. The block now grows UPWARD once it would pass the last visible row (optional `totalRows` in `RenderParams`; the line divs are opaque, so they cover transcript above), and a prompt taller than the viewport keeps its TAIL. ⚠️ Separately, `_shrinkPaddingToFit()` (mobile-handlers.js) must never shrink `main`'s padding-bottom below the MEASURED height of the fixed bars: on phones the toolbar and accessory bar are `position: fixed`, so that padding is the only thing reserving room for them, and taking it pulled the terminal's bottom row behind them. Tests: `packages/xterm-zerolag-input/test/overlay-renderer.test.ts`, `test/mobile-keyboard-bottom-padding.test.ts`.
Expand Down
7 changes: 4 additions & 3 deletions plugins/codeman/skills/codeman/reference/endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -384,17 +384,18 @@ every claude create path installs them, so a linked case and a raw path both get

**The two-step alternative, `POST /api/v1/sessions`.** Use it when you need a session in
a directory that is not a case (body takes `workingDir`, `mode`, `name`, `effort`,
`envOverrides`). Three differences that break copied code:
`envOverrides`, and for claude a per-session `model` passed as `--model`). Three
differences that break copied code:

- The id is at **`.data.session.id`**, not quick-start's `.data.sessionId`
(`session-routes.ts:878` returns `{ session: lightState }`).
(the `POST /api/sessions` handler in `session-routes.ts` returns `{ session: lightState }`).
- **It spawns no PTY.** The session exists with `pid:null` and nothing running, so
`wait?until=exit` answers `exit` immediately. Follow it with
`POST /api/v1/sessions/:id/interactive` (claude and the other agent CLIs) or
`POST /api/v1/sessions/:id/shell` (shell mode) to actually start the worker.
- Its capacity failure is **`OPERATION_FAILED` (422)**, not quick-start's
`SESSION_BUSY` (409), from the same global-50 / per-user-25 caps
(`session-routes.ts:648`).
(`sessionCapacityMessage()` in `route-helpers.ts`).

⚠️ `POST .../interactive` accepts `{"clearBreaker":true}`, which resets the **PTY-exit
circuit breaker**. That breaker exists to stop a session that keeps crashing on spawn
Expand Down
7 changes: 4 additions & 3 deletions skills/codeman/reference/endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -384,17 +384,18 @@ every claude create path installs them, so a linked case and a raw path both get

**The two-step alternative, `POST /api/v1/sessions`.** Use it when you need a session in
a directory that is not a case (body takes `workingDir`, `mode`, `name`, `effort`,
`envOverrides`). Three differences that break copied code:
`envOverrides`, and for claude a per-session `model` passed as `--model`). Three
differences that break copied code:

- The id is at **`.data.session.id`**, not quick-start's `.data.sessionId`
(`session-routes.ts:878` returns `{ session: lightState }`).
(the `POST /api/sessions` handler in `session-routes.ts` returns `{ session: lightState }`).
- **It spawns no PTY.** The session exists with `pid:null` and nothing running, so
`wait?until=exit` answers `exit` immediately. Follow it with
`POST /api/v1/sessions/:id/interactive` (claude and the other agent CLIs) or
`POST /api/v1/sessions/:id/shell` (shell mode) to actually start the worker.
- Its capacity failure is **`OPERATION_FAILED` (422)**, not quick-start's
`SESSION_BUSY` (409), from the same global-50 / per-user-25 caps
(`session-routes.ts:648`).
(`sessionCapacityMessage()` in `route-helpers.ts`).

⚠️ `POST .../interactive` accepts `{"clearBreaker":true}`, which resets the **PTY-exit
circuit breaker**. That breaker exists to stop a session that keeps crashing on spawn
Expand Down
1 change: 1 addition & 0 deletions src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1827,6 +1827,7 @@ export class Session extends EventEmitter {
ompConfig: this._ompConfig,
resumeSessionId: this._resumeSessionId,
effort: this._effort,
model: this._model,
customModel: this.customModel,
// COD-118: runtime-only — surfaced so the frontend can require explicit user
// intent before restarting a crash-looped session. Deliberately NOT restored
Expand Down
7 changes: 7 additions & 0 deletions src/types/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -795,6 +795,13 @@ export interface SessionState {
resumeSessionId?: string;
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort?: EffortLevel;
/**
* The model the session was LAUNCHED with (`--model`): the caller's per-session `model`, or
* the app-wide default when there was none. Persisted so a recovered session relaunches on
* the same model rather than whatever the default is by then. Not `cliModel`, which is what
* the CLI's banner reports.
*/
model?: string;
/**
* Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md): the custom
* OpenAI-compatible endpoint (local or cloud) this session's CLI is currently pointed
Expand Down
1 change: 1 addition & 0 deletions src/web/routes/reboot-restore-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes
(saved as { __envOverrides?: Record<string, string> }).__envOverrides
),
effort: saved.effort,
model: saved.model,
attachmentHistory:
(saved as { __attachmentHistory?: SessionAttachmentHistoryItem[] }).__attachmentHistory ??
saved.attachmentHistory,
Expand Down
18 changes: 14 additions & 4 deletions src/web/routes/session-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -888,6 +888,15 @@ export function registerSessionRoutes(
if (capMsg) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, capMsg);

const body = parseBody(CreateSessionSchema, req.body);
// The top-level `model` is Claude's per-session `--model`. Every other CLI takes its model
// in its own config object (`codexConfig.model` and so on), so a `model` here would be
// dropped without a word; refuse it before anything is written for the session.
if (body.model && getCli(body.mode ?? 'claude')?.capabilities.model.source !== 'claude-settings-file') {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'model applies to claude sessions only; other CLIs take their model in their own config object, such as codexConfig.model'
);
}
let workingDir = body.workingDir || process.cwd();
let remote = undefined;

Expand Down Expand Up @@ -1073,9 +1082,10 @@ export function registerSessionRoutes(
// genuinely different mechanisms:
// 'flag' — the CLI takes --model, so read the value the caller sent
// in that CLI's own config object.
// 'claude-settings-file' — claude alone, whose model is written to
// <case>/.claude/settings.local.json rather than passed as
// a flag, so the app-wide default applies here.
// 'claude-settings-file' — claude alone, whose persistent model is written to
// <case>/.claude/settings.local.json (`modelOverride`). A
// per-session `model` from the caller goes out as --model and
// wins; without one, the app-wide default applies.
// 'none' — shell has no model; deepseek's is a composition entry in
// the profile's config tree, not a session field
// (docs/deepseek-integration.md). Both get nothing.
Expand All @@ -1086,7 +1096,7 @@ export function registerSessionRoutes(
| string
| undefined)
: modelSource?.source === 'claude-settings-file'
? modelConfig?.defaultModel || undefined
? body.model || modelConfig?.defaultModel || undefined
: undefined;
const claudeModeConfig = await ctx.getClaudeModeConfig();
// Section 6.3: force non-granted users to a classifier-guarded mode.
Expand Down
13 changes: 13 additions & 0 deletions src/web/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,19 @@ export const CreateSessionSchema = z.object({
effort: effortLevelSchema,
/** Model override to write to .claude/settings.local.json (e.g., "opus[1m]"). Empty string clears. */
modelOverride: z.string().max(50).optional(),
/**
* Claude model for THIS session only, passed as `claude --model <id>`; nothing is written to
* disk. Wins over the app-wide default model. Same character set as the registry's
* `model-claude` pattern, so a value accepted here is never rejected at launch. An empty
* string means no per-session model, as it does for `modelOverride`. Claude only: the route
* refuses it for any other CLI.
*/
model: z
.string()
.max(100)
.regex(/^[a-zA-Z0-9._\-[\]]+$/)
.or(z.literal(''))
.optional(),
openCodeConfig: OpenCodeConfigSchema,
codexConfig: CodexConfigSchema,
geminiConfig: GeminiConfigSchema,
Expand Down
1 change: 1 addition & 0 deletions src/web/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3489,6 +3489,7 @@ export class WebServer extends EventEmitter {
ompConfig: muxSession.mode === 'omp' ? savedState?.ompConfig : undefined,
envOverrides: savedEnvOverrides,
effort: savedState?.effort,
model: savedState?.model,
attachmentHistory: savedAttachmentHistory,
// The pane's last Enter. Without it the response viewer would show
// the launch conversation until the user types again, even though
Expand Down
13 changes: 13 additions & 0 deletions test/cli-registry-spawn-golden.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,19 @@ describe('claude', () => {
);
});

it('renders a model as the quoted value of --model, even one that opens with a dash', () => {
// POST /api/sessions admits a leading '-' in `model`. It still lands as the option's
// value: quoted here, and Claude's option parser takes the word after `--model` as its
// value whatever it starts with, so it can never become a flag of its own.
expect(claude({ model: 'claude-fable-5-1' })).toBe(
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" --model "claude-fable-5-1"'
);
expect(claude({ model: '--dangerously-skip-permissions' })).toBe(
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" ' +
'--model "--dangerously-skip-permissions"'
);
});

it('resumes through a shell fallback to a fresh session', () => {
// The ` || ` is emitted by the ENGINE, not by config — no registry field can hold shell
// text. This pin is what proves the fallback chain still renders as one command line.
Expand Down
Loading
Loading