--- id: known-bugs/the-model-picker-shows-the-bare-harness title: 'The model picker shows `claude-code` instead of the model that is running' theme: known-bugs status: open created: 2026-08-25 date_source: stated order: 5 --- **The model picker shows `claude-code` — the bare harness — instead of a model.** Reported by the owner 2026-08-25 with a screenshot: the composer's harness pill reads "Claude Code" (correct) and the model dropdown beside it reads `claude-code` (useless). He reports it happens often, both mid-turn and idle, and cannot reproduce it deliberately. What he wants is simple and worth quoting: **the dropdown should always show either the running model, or the model last used when the conversation finished.** **Where the raw string comes from.** `ModelSelector.tsx:329` deliberately prints the raw id when the effective model matches no catalogue entry — the alternative is a blank control, and the comment there already names `claude-code` as one of the ids that reaches it. So the picker is reporting honestly; the question is why it is being handed a harness with no tier. Three sources of a bare `claude-code`, all verified in the tree: 1. **`DEFAULT_MODEL` is `claude-code`** (`websocket.ts:48`), used when the client names no model and the user has no default (`websocket.ts:829`). 2. **Every adopted session** — `adoptOrphanedSession(ws, sessionId, DEFAULT_MODEL, '')` (`websocket.ts:1631`), the path a browser refresh takes when officer no longer holds the session. That record's model is then pinned for its life and sent to the client as `session:init.model`. Observed directly in the boot log after a `pm2 restart officer`: two of three adopted sessions logged `model=claude-code`, one `model=claude-code/sonnet`. 3. **It cannot self-heal.** `record()` refuses to store a model that names no tier (`websocket.ts:471`, and the comment there explains why: storing it would render blank and then feed itself). So a session with no `chat_session_models` row sends no model, the server re-derives the bare harness, and nothing ever writes a real one. Sixteen rows exist for a great many transcripts. **The event that would fix it already exists and is thrown away.** `model:resolved` carries exactly this answer — the harness's own resolved model, mapped through `officerModelForApiModel` — and the client already handles it as display-only, deliberately not touching `selectedModel` (`useChat.ts:645`, and the comment is right that it must not invent a choice). It is emitted `durable: false` (`turn-stream.ts:163`), so **a refresh never receives it**. That is the shape of the fix: on `attach`, tell the client the session's resolved model the same way, rather than leaving it on the bare default. Display-only, no choice invented, no new vocabulary. --- **One half fixed 2026-08-25: the dashboard chat panel was seeding the picker from the wrong id space.** `ChatPanelWrapper.tsx` passed `detail.model` where `/chat` and Command HQ both pass `detail.officerModel` — and both of those carry a long comment saying exactly why. `detail.model` is what the API served (`claude-fable-5`); the picker offers Officer ids (`claude-code/fable`). The server already resolves the right one, falling back to a family guess from the transcript when no row is stored (`chat.ts:248`), so the panel was discarding a correct answer and showing a raw API id. Worth more than the label: that value is **sent back**. `isClaudeModel` is `startsWith('claude-code')` (`websocket.ts:66`), so `claude-fable-5` fails it and the turn dispatches to **OpenCode**. The clamp above it only fires when officer still holds the session in memory (`websocket.ts:845`) — which is precisely not the case after a restart, the same condition that produces the bare harness. Not observed in the wild, and filed here rather than separately because it is the same one-line cause. --- **Root cause found and fixed 2026-08-25 — it was `/chat`, and one argument.** `ChatDetailPanel`'s `NewChat` called `useChat(undefined, locationState?.model, …)`. `location.state` is only populated by an in-app navigation, so **on a refresh or a deep link it is empty** and the model argument was undefined. That is the value `useChat` seeds `selectedModel` from, so the composer sent no model at all — confirmed in the live log, `Model selected for chat … clientModel=null`, on the owner's own session while he was looking at it. The server then took source 1 above, `DEFAULT_MODEL`, and the bare harness reached the picker. The same file already knew this. Twelve lines below, `initialModel={props.model ?? locationState?.model}` carries a comment saying `locationState.model` "is written by nothing, so it was the only source and always undefined on resume". That fix was applied to the child prop and missed on the `useChat` call above it — and the `useChat` call is the one that decides what gets SENT. `props.model` is `detail.officerModel`, correct and populated on every refresh; it was simply not passed. Why it looked random: clicking a session in the list carries location state, so the picker was right. Only a **refresh** — or a pasted link — lost it. The owner's screenshot shows the session list reading `opus` for the same conversation whose composer read `claude-code`, which is exactly that split: the list has its own data, the composer had an empty `location.state`. This also explains why it never healed. No model sent → server falls back to the bare harness → `record()` refuses to store a tierless model → still no row → next turn identical. **Still open, and worth doing anyway:** sources 2 and 3. An adopted session is still pinned to `DEFAULT_MODEL` for its life, so `session:init` still reports a bare harness to any client that has nothing better — this fix means `/chat` now always has something better, rather than making the server's answer honest. The `attach` remedy above (answer with `model:resolved`, which is already display-only on the client) is the fix for that, and it is independent of this one.