From 42d9068f7b9854afbae9218a6f09edcab78ffb80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 25 Aug 2026 17:51:24 +0000 Subject: [PATCH] sonnet is what runs when nobody chose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit every surface with no model picker was quietly running opus. the fallback was the bare harness, `claude-code`, and bare means the CLI picks its own tier — agent-files.ts says so in as many words. so a plugin's chat panel, a nudge, a task with no configured default, any chat opened where there is nowhere to choose: all opus, for work that rarely needs it. the commander pinned to sonnet on 2026-08-25 after spending ~$0.26 of opus on one routing decision was the same problem noticed in one place. DEFAULT_MODEL could not simply be changed, because it is two questions wearing one constant. decideResume uses it as a HARNESS IDENTITY when neither side knows which sidecar a resuming session belongs to, and that comparison is between harnesses — a tier suffix there would be comparing something else, and the three failures its comment records are all delivery failures. so it stays bare and keeps its one job, now stated. CHAT_DEFAULT_MODEL is the other question, answered separately. a floor, not a ceiling: a client that names a model still wins, an account default still wins, and only "nobody chose" changes. tasks get the same treatment for the same reason and a stronger one — a task runs unattended, often repeatedly, with nobody watching the bill. the nudger held a copy of the string; it holds the constant now. interim on purpose. per-surface defaults, or something that reads the work rather than the caller, is the better answer and is not this. one constant, one place, easy to move. Co-Authored-By: Claude Opus 5 Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 --- src/servers/api/chat/nudger.ts | 6 ++-- src/servers/api/chat/websocket.ts | 34 ++++++++++++++++++++-- src/servers/api/tasks/pipeline-executor.ts | 12 +++++++- 3 files changed, 46 insertions(+), 6 deletions(-) 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 {