Asks how hard a new Claude Code session is before its first prompt runs, then switches to the matching model and logs the answer

A Claude Code mod that asks how hard a new session is before its first prompt runs, then switches to the matching model.
Picking the model yourself takes one keypress, and it makes you think for a moment about the work before it starts. The mod keeps a local log of every answer, so you can later look back at what you picked and what each session cost.


| Level | Model | When to pick it |
|---|---|---|
| Trivial | Haiku | Mechanical work; a wrong result is obvious at a glance. |
| Easy | Sonnet | Goal and method known; tests or a quick look catch mistakes. |
| Hard | Opus | It can look right and be wrong, or the design is not settled. |
| Intense | Fable | Hard, and every claim or word will be judged, or it runs long. |
Picking a level runs /model, which also saves that model as your default for new sessions. A session where you dismiss the menu, and any claude -p run, starts on the last model you picked.
The menu appears once per session, on the first prompt you type. It does not appear for:
claude -p runs, messages from other agents and notifications| What you do at the menu | What happens |
|---|---|
| Pick a level | The model switches and the prompt waits for Enter. If you are already on that model, the prompt is sent straight away. |
| Type an answer under Other | The answer is logged and the model stays as it is. The prompt is sent. |
| Dismiss the menu (Esc) | The skip is logged and the model stays as it is. The prompt is sent. |
In Claude Code:
/plugin marketplace add raam86/model-picker
/plugin install model-picker@model-picker
Or from a shell:
claude plugin marketplace add raam86/model-picker
claude plugin install model-picker@model-picker
The menu appears in the next new session.
It needs a Claude Code version with function hooks (mods). It was tested on Claude Code 2.1.303. The Intense level needs access to Fable. Without it, the switch fails, a status line says so, and the session stays on its current model.
This section is for Claude Code when a user asks it to install, use or change this mod.
Installing. Run these two commands with the Bash tool. Do not edit settings.json by hand.
claude plugin marketplace add raam86/model-picker
claude plugin install model-picker@model-picker
claude plugin list
claude plugin list must show model-picker@model-picker as enabled. Then tell the user that the menu appears from their next new session, not the current one, and that it appears once, on the first prompt they type.
Using it. The user answers the menu. Claude never answers it for them. If the user asks which level to pick, explain the four levels from the table above and let them decide.
Reading the log. Each session writes one JSON file to ~/.local/state/model-picker/<session-id>.json. The fields are listed under The log. To summarise what a user picked, read these files. Each one holds a single JSON object.
Changing the levels or models. Edit MODEL and QUESTION at the top of hooks/register.js. The keys of MODEL are the menu labels, in the form Level · Model, and the values are what is passed to /model. Run claude plugin validate . after any change.
Uninstalling.
claude plugin uninstall model-picker@model-picker
claude plugin marketplace remove model-picker
Each session gets one file in ~/.local/state/model-picker/, named after the session id. The log stays on your machine; the mod sends nothing anywhere.
| Field | What it holds |
|---|---|
sessionId, cwd | The session and the folder it ran in |
askedAt, answeredAt, decisionMs | When the menu appeared, when you answered, and how long you took |
modelBefore, model | The model the session started on, which is your saved default and so usually your last pick, and the one you picked (null if skipped or typed under Other) |
answer, difficulty, skipped | The label you picked or the text you typed, the level, and whether you dismissed the menu |
prompt, promptChars | The first 300 characters of your first prompt, and its full length |
switchedAt, switchError | When the model switch happened, or why it failed |
turns | One entry per turn: when it ended, the session's model, the models that actually answered, the duration, and whether it was aborted |
endedAt, endReason, costUsd, contextTokens | When and why the session ended, its cost and its context size |
The log keeps part of your first prompt. Delete the folder if you don't want that kept.
MIT. See LICENSE.
hooks/register.js 129 lines1// Asks how hard a new session is before its first prompt runs, switches to the
2// matching model, and logs one JSON file per session in
3// ~/.local/state/model-picker/: the answer, how long it took, the model
4// chosen, each turn's model and duration, and the session's end and cost.
5
6const MODEL = {
7 'Trivial · Haiku': 'haiku',
8 'Easy · Sonnet': 'sonnet',
9 'Hard · Opus': 'opus',
10 'Intense · Fable': 'fable',
11}
12
13const QUESTION = [
14 'Trivial: mechanical; a wrong result is obvious at a glance.',
15 'Easy: goal and method known; tests or a quick look catch mistakes.',
16 'Hard: it can look right and be wrong, or the design is not settled.',
17 'Intense: hard, and every claim or word will be judged, or it runs long.',
18 '',
19 'How hard is this session?',
20].join('\n')
21
22async function recordPath($, sessionId) {
23 const home = await $.env.get('HOME')
24 if (!home) throw new Error('HOME is not set')
25 return home + '/.local/state/model-picker/' + sessionId + '.json'
26}
27
28// Models that answered each turn's requests, keyed by turn id, until the turn ends.
29const answeredBy = new Map()
30
31export function register(on) {
32 on('prompt.submit', async ($, e, next) => {
33 // Only prompts a person typed; -p runs, peers and notifications pass through.
34 if (e.origin.kind !== 'composer' && e.origin.kind !== 'bridge') return next(e)
35 const sessionId = await $.session.id()
36 const path = await recordPath($, sessionId)
37 if (await $.fs.exists(path)) return next(e)
38 // A resumed session already has its model and its cache.
39 if ((await $.session.messages()).length > 0) return next(e)
40
41 const record = {
42 sessionId,
43 cwd: await $.session.cwd(),
44 askedAt: new Date().toISOString(),
45 modelBefore: await $.session.model(),
46 prompt: e.text.slice(0, 300),
47 promptChars: e.text.length,
48 answer: null,
49 difficulty: null,
50 model: null,
51 turns: [],
52 }
53 try {
54 record.answer = await $.ui.ask(QUESTION, { options: Object.keys(MODEL), header: 'Difficulty' })
55 } catch {
56 // Dismissed: keep the current model, and log the skip so it counts.
57 record.skipped = true
58 }
59 record.answeredAt = new Date().toISOString()
60 record.decisionMs = Date.parse(record.answeredAt) - Date.parse(record.askedAt)
61 if (record.answer !== null) {
62 record.model = MODEL[record.answer] ?? null
63 // A label from the menu, or the words typed under Other.
64 record.difficulty = record.model ? record.answer.split(' · ')[0] : record.answer
65 }
66 await $.fs.write(path, JSON.stringify(record))
67 // Skipped, typed under Other, or already on the chosen model: send as is.
68 if (!record.model || record.modelBefore.includes(record.model)) return next(e)
69
70 // /model can't run while this hook holds the turn, so hand the prompt back
71 // and switch once the session is idle; nothing is cached yet.
72 $.clock.after(0, async () => {
73 try {
74 await $.command.run({ command: 'model', args: record.model })
75 record.switchedAt = new Date().toISOString()
76 } catch (err) {
77 $.ui.status('model-picker: switch to ' + record.model + ' failed: ' + String(err))
78 record.switchError = String(err)
79 }
80 await $.fs.write(path, JSON.stringify(record))
81 })
82 return { drop: 'Switching to ' + record.answer.split(' · ')[1] + '. Press Enter to send.' }
83 }).catch(async ($, e, next) => {
84 $.ui.status('model-picker failed: ' + next.error.kind + ' ' + (next.error.message ?? ''))
85 return next(e)
86 })
87
88 // The models that actually answered each main-conversation request, by turn;
89 // the session's reported model can differ from the one a request went to.
90 on('turn.step', async function* ($, e, next) {
91 const result = yield* next(e)
92 if (!e.agentId && result.usage?.model) {
93 const models = answeredBy.get(e.turnId) ?? new Set()
94 models.add(result.usage.model)
95 answeredBy.set(e.turnId, models)
96 }
97 return result
98 })
99
100 // Each main-conversation turn: when it ended, the session's model, the
101 // models that answered, how long it took.
102 on('turn.complete', async ($, e, next) => {
103 if (e.agentId) return next(e)
104 const answered = [...(answeredBy.get(e.turnId) ?? [])]
105 answeredBy.delete(e.turnId)
106 const path = await recordPath($, await $.session.id())
107 if (!(await $.fs.exists(path))) return next(e)
108 const record = JSON.parse(await $.fs.read(path))
109 record.turns.push({ at: new Date().toISOString(), model: await $.session.model(), answeredBy: answered, durationMs: e.durationMs, isAborted: e.isAborted ?? false })
110 await $.fs.write(path, JSON.stringify(record))
111 return next(e)
112 })
113
114 // The session ends, or /clear or /resume starts another: close the record.
115 on('session.end', async ($, e, next) => {
116 const path = await recordPath($, e.sessionId)
117 if (await $.fs.exists(path)) {
118 const record = JSON.parse(await $.fs.read(path))
119 const usage = await $.session.usage()
120 record.endedAt = new Date().toISOString()
121 record.endReason = e.reason
122 record.costUsd = usage.cost?.usd ?? null
123 record.contextTokens = usage.context?.tokens ?? null
124 await $.fs.write(path, JSON.stringify(record))
125 }
126 return next(e)
127 })
128}
129