diff --git a/src/servers/api/chat/nudger.ts b/src/servers/api/chat/nudger.ts index f03a1a14..b55592c4 100644 --- a/src/servers/api/chat/nudger.ts +++ b/src/servers/api/chat/nudger.ts @@ -1,6 +1,6 @@ import { dueNudges, recordNudgeSent, getUserById } from 'officerdb'; import { resolveHomeDir } from '@@/user-home'; -import { startClaudeTurn } from './websocket'; +import { startClaudeTurn, CHAT_DEFAULT_MODEL } from './websocket'; // Nudger: the schedule that keeps a chat moving while nobody is watching it. // @@ -83,7 +83,9 @@ async function fire(nudge: { id: number; userId: number; sessionId: string; mess // they would see a turn started from another tab. ws: null, sessionId: nudge.sessionId, - model: 'claude-code/sonnet', + // The constant, not a copy of its value — a nudge is an unattended turn and belongs with every + // other surface that never had a picker. + model: CHAT_DEFAULT_MODEL, msg: { prompt: nudge.message, resumeSessionId: nudge.sessionId }, effectivePrompt: nudge.message, onOutcome: (outcome) => { diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index a0f2a602..7e87769f 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -44,9 +44,36 @@ import { logger } from './logger'; import { isSuperAdmin } from '@@/super-admin'; import { snapshotClaudeUsage } from './usage-store'; -// Default model when no user preference is set +/** + * The bare harness, `claude-code` with no tier. **This is a HARNESS IDENTITY, not a model choice.** + * + * It is what `decideResume` assumes when neither the server nor the client knows which sidecar a resuming + * session belongs to — see that function for the three failures a wrong guess here produced. Keep it bare: + * adoption and delivery compare harnesses, and a tier suffix on this value would be comparing something + * else. **What a NEW chat runs on is `CHAT_DEFAULT_MODEL` below**, which is a different question that + * happened to share a constant. + */ export const DEFAULT_MODEL = 'claude-code'; +/** + * What a chat runs on when nobody has said otherwise — no model from the client, no account default. + * + * Sonnet, since 2026-08-25. It used to be the bare harness, which means whatever Claude Code picks for + * itself, and that is Opus: every unconfigured surface — a plugin's chat panel, a nudge, a chat opened + * from anywhere that does not carry a picker — was quietly running the most expensive model available for + * work that rarely needs it. The owner watched a commander spend ~$0.26 of Opus on a single routing + * decision on 2026-08-25 and pinned commanders to Sonnet; this is the same reasoning applied to everything + * that never had a picker in the first place. + * + * **It is a floor, not a ceiling.** An account default still wins, and a client that names a model still + * wins over both, so nothing that could already choose loses the ability. This only changes what happens + * when nothing chose. + * + * A better answer is wanted — per-surface defaults, or something that reads the work rather than the + * caller. This is the honest interim: one constant, one place, easy to move. + */ +export const CHAT_DEFAULT_MODEL = 'claude-code/sonnet'; + /** * What a commander runs on, regardless of the account's default. * @@ -842,8 +869,9 @@ async function handleChat( .join('\n')}` : withSummary; - // Use provided model, or fall back to user default, or the system default. - const requested = msg.model || (await getUserDefaultModel(userId)) || DEFAULT_MODEL; + // Provided model, then the account default, then Sonnet. NOT `DEFAULT_MODEL` — that is the bare harness + // and means "whatever Claude Code picks", which is Opus. See `CHAT_DEFAULT_MODEL`. + const requested = msg.model || (await getUserDefaultModel(userId)) || CHAT_DEFAULT_MODEL; /* A conversation may change TIER mid-flight; it may not change HARNESS. diff --git a/src/servers/api/tasks/pipeline-executor.ts b/src/servers/api/tasks/pipeline-executor.ts index 70f8ec73..ed534818 100644 --- a/src/servers/api/tasks/pipeline-executor.ts +++ b/src/servers/api/tasks/pipeline-executor.ts @@ -12,7 +12,17 @@ import type { TurnMessage, MessageCost } from '../chat/types'; import * as jobManager from './pipeline-job-manager'; import { ANTHROPIC_PROXY_URL } from '../../officer-url.mjs'; -const DEFAULT_MODEL = 'claude-code'; +/** + * What a task runs on when the account has set no task default. + * + * Sonnet since 2026-08-25, for the same reason as `CHAT_DEFAULT_MODEL` in `api/chat/websocket.ts`: the + * previous value was the bare harness, which means whatever Claude Code chooses for itself, and that is + * Opus. A task is the surface where that costs most — it runs unattended, often repeatedly, and nobody is + * watching the bill. + * + * The account default still wins, so anybody who chose a model keeps it. + */ +const DEFAULT_MODEL = 'claude-code/sonnet'; async function resolveModel(userId: number): Promise { try {