SLOPSHOPPER

model-picker

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

newstatusprompttimer
★ 1v0.3.0MITupdated 2026-10-19raam86/model-picker
A shopper browsing a rack in a slop shop
README

model-picker

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.

The menu, shown on the first prompt of a new session

How it works

  1. Start a new session and type your first prompt as usual.
  2. Before the prompt is sent, the mod asks how hard the session is.
  3. You pick a level. The mod switches to that model and puts your prompt back in the input box.
  4. Press Enter to send it, now on the model you picked.

After picking Easy: the session is on Sonnet and the prompt is waiting

LevelModelWhen to pick it
TrivialHaikuMechanical work; a wrong result is obvious at a glance.
EasySonnetGoal and method known; tests or a quick look catch mistakes.
HardOpusIt can look right and be wrong, or the design is not settled.
IntenseFableHard, 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:

  • resumed sessions, which already have a model and a cache
  • claude -p runs, messages from other agents and notifications
What you do at the menuWhat happens
Pick a levelThe 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 OtherThe 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.

Install

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.

Instructions for Claude

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

The log

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.

FieldWhat it holds
sessionId, cwdThe session and the folder it ran in
askedAt, answeredAt, decisionMsWhen the menu appeared, when you answered, and how long you took
modelBefore, modelThe 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, skippedThe label you picked or the text you typed, the level, and whether you dismissed the menu
prompt, promptCharsThe first 300 characters of your first prompt, and its full length
switchedAt, switchErrorWhen the model switch happened, or why it failed
turnsOne 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, contextTokensWhen 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.

License

MIT. See LICENSE.

Source 1 files
hooks/register.js 129 lines
1// 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