Picks the model and effort for each prompt, never above High without asking

hooks/register.ts 86 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { decide } from './flow'
5import { classifierPrompt, guardFallback, parseChoice, statusFor, wireId, type Choice } from './policy'
6
7const paused = atom({ plugin: 'agent-switcher-mod', key: 'paused' } as const, false)
8
9const CLASSIFIED = ['composer', 'bridge', 'sdk', 'scheduled-trigger']
10const INTERACTIVE = ['composer', 'bridge']
11
12export const register: Register = on => {
13 // The decision for the main loop's running turn: null leaves it untouched.
14 let current: Choice | 'keep' | null = null
15 let inTurn = false
16
17 on('session.start', async ($, e, next) => {
18 await $.command.register({
19 name: 'agent-switcher',
20 description: 'Pause or resume automatic model and effort switching for this session (on | off)',
21 })
22 $.ui.status((await read($, paused)) ? 'auto: paused' : 'auto: on')
23 return next(e)
24 })
25
26 on('command.run', { command: 'agent-switcher' }, async ($, e) => {
27 const arg = e.args.trim()
28 if (arg === 'off' || arg === 'on') {
29 await update($, paused, () => arg === 'off')
30 $.ui.status(arg === 'off' ? 'auto: paused' : 'auto: on')
31 return { text: `agent-switcher ${arg === 'off' ? 'paused' : 'resumed'} for this session.` }
32 }
33 if (arg === '') return { text: `agent-switcher is ${(await read($, paused)) ? 'paused' : 'on'} in this session.` }
34 return { text: 'Usage: /agent-switcher [on|off]' }
35 })
36
37 on('turn.start', ($, e, next) => {
38 if (e.agentId === undefined) inTurn = true
39 return next(e)
40 })
41
42 on('turn.complete', ($, e, next) => {
43 if (e.agentId === undefined) {
44 inTurn = false
45 current = null
46 }
47 return next(e)
48 })
49
50 on('prompt.submit', async ($, e, next) => {
51 if (e.command || inTurn || e.turnId !== undefined || !CLASSIFIED.includes(e.origin.kind) || (await read($, paused)))
52 return next(e)
53
54 current = await decide({
55 interactive: INTERACTIVE.includes(e.origin.kind),
56 classify: async () => {
57 // The engine rejects a request it refuses to send: that is a failure too.
58 try {
59 const r = await $.model.complete({ model: wireId('haiku', await $.session.model()), effort: 'low', prompt: classifierPrompt(e.text), timeoutMs: 15000 })
60 return r.isAnswered ? parseChoice(r.text) : null
61 } catch {
62 return null
63 }
64 },
65 ask: (question, options, header) => $.ui.ask(question, { options, header }),
66 })
67 $.ui.status(current === 'keep' ? 'auto: not switched' : statusFor(current))
68 const result = await next(e)
69 // A dropped prompt starts no turn, so nothing would clear its decision.
70 if (result.drop !== undefined) current = null
71 return result
72 })
73
74 on('turn.step', async function* ($, e, next) {
75 if (e.agentId !== undefined) return yield* next(e)
76 if (current !== null && current !== 'keep') return yield* next({ ...e, model: wireId(current.model, e.model), effort: current.effort })
77 if (current === null && (await read($, paused))) return yield* next(e)
78
79 // Credit guard: while on, an unswitched turn still never runs off the allowlist.
80 const guard = guardFallback(e.model, e.effort)
81 if (!guard) return yield* next(e)
82 $.ui.status(`auto: sonnet · ${guard.effort ?? 'default'}`)
83 return yield* next({ ...e, model: wireId(guard.model, e.model), ...(guard.effort ? { effort: guard.effort } : {}) })
84 })
85}
86hooks/flow.ts 48 lines1import { isAboveHigh, type Choice, type Effort, type Model } from './policy'
2
3// Resolves to the chosen label or typed text; rejects when dismissed.
4export type Ask = (question: string, options: readonly string[], header: string) => Promise<string>
5export type Classify = () => Promise<Choice | null>
6
7const MODEL_LABELS: Record<string, Model> = { Haiku: 'haiku', Sonnet: 'sonnet', Opus: 'opus' }
8const EFFORT_LABELS: Record<string, Effort> = { Low: 'low', Medium: 'medium', High: 'high' }
9const ABOVE_HIGH: readonly Effort[] = ['xhigh', 'max']
10
11// A dismissed dialog reads as undefined, like an answer that is not an option.
12const answer = (ask: Ask, question: string, options: readonly string[], header: string) =>
13 ask(question, options, header).then(
14 picked => (options.includes(picked) ? picked : undefined),
15 () => undefined,
16 )
17
18async function confirm(choice: Choice, ask: Ask, interactive: boolean): Promise<Choice> {
19 if (!isAboveHigh(choice.effort)) return choice
20 if (!interactive) return { ...choice, effort: 'high' }
21 const use = `Use ${choice.effort}`
22 const picked = await answer(ask, `This prompt looks like it needs ${choice.effort} effort. Use it?`, [use, 'Use high'], 'Effort')
23 return picked === use ? choice : { ...choice, effort: 'high' }
24}
25
26async function chooseManually(ask: Ask): Promise<Choice | 'keep'> {
27 const model = MODEL_LABELS[(await answer(ask, 'Which model for this prompt?', ['Haiku', 'Sonnet', 'Opus'], 'Model')) ?? '']
28 if (!model) return 'keep'
29 const level = await answer(ask, 'Which effort?', ['Low', 'Medium', 'High', 'Above High'], 'Effort')
30 if (level === undefined) return 'keep'
31 const effort = level === 'Above High'
32 ? (await answer(ask, 'Which level above High?', ABOVE_HIGH, 'Effort')) as Effort | undefined
33 : EFFORT_LABELS[level]
34 return effort ? { model, effort } : 'keep'
35}
36
37export async function decide(deps: { classify: Classify; ask: Ask; interactive: boolean }): Promise<Choice | 'keep'> {
38 const { classify, ask, interactive } = deps
39 for (;;) {
40 const choice = await classify()
41 if (choice) return confirm(choice, ask, interactive)
42 if (!interactive) return 'keep'
43 const picked = await answer(ask, "Couldn't pick a model for this prompt. Retry or choose yourself?", ['Retry', "I'll choose"], 'Auto model')
44 if (picked === "I'll choose") return chooseManually(ask)
45 if (picked !== 'Retry') return 'keep'
46 }
47}
48hooks/policy.ts 72 lines1export type Model = 'haiku' | 'sonnet' | 'opus'
2export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
3export type Choice = { model: Model; effort: Effort }
4
5// The only models the mod may ever choose: never Fable or anything on credits.
6export const MODELS: readonly Model[] = ['haiku', 'sonnet', 'opus']
7export const EFFORTS: readonly Effort[] = ['low', 'medium', 'high', 'xhigh', 'max']
8
9export const MODEL_IDS: Record<Model, string> = {
10 haiku: 'claude-haiku-5-5',
11 sonnet: 'claude-sonnet-5-5',
12 opus: 'claude-opus-5-5',
13}
14
15// Names the model the way the session does: through the gateway (`cc/claude-opus-5-5`)
16// it keeps the `cc/` prefix, on the Anthropic API it sends the plain id.
17export const wireId = (model: Model, sessionModel: string): string =>
18 (sessionModel.match(/^[\w.-]+\//)?.[0] ?? '') + MODEL_IDS[model]
19
20export function parseChoice(text: string): Choice | null {
21 const json = text.match(/\{[\s\S]*?\}/)?.[0]
22 if (!json) return null
23 try {
24 const raw = JSON.parse(json) as { model?: unknown; effort?: unknown }
25 const model = String(raw.model ?? '').trim().toLowerCase() as Model
26 const effort = String(raw.effort ?? '').trim().toLowerCase() as Effort
27 return MODELS.includes(model) && EFFORTS.includes(effort) ? { model, effort } : null
28 } catch {
29 return null
30 }
31}
32
33export const isAboveHigh = (effort: Effort): boolean => effort === 'xhigh' || effort === 'max'
34
35export function capAtHigh(effort: Effort | number | undefined): Effort | undefined {
36 if (effort === undefined) return undefined
37 if (typeof effort === 'number' || isAboveHigh(effort)) return 'high'
38 return effort
39}
40
41// An optional provider prefix (`cc/`) is fine; a `[1m]` or other suffix counts as not allowed.
42export const isAllowedSessionModel = (id: string): boolean =>
43 /^(?:[\w.-]+\/)?(?:claude-)?(haiku|sonnet|opus)(?:-[\w.-]*)?$/i.test(id)
44
45export function guardFallback(
46 sessionModel: string,
47 sessionEffort: Effort | number | undefined,
48): { model: Model; effort: Effort | undefined } | null {
49 return isAllowedSessionModel(sessionModel) ? null : { model: 'sonnet', effort: capAtHigh(sessionEffort) }
50}
51
52export const classifierPrompt = (prompt: string): string => `You route a coding assistant's prompt to a model and a reasoning effort.
53Reply with only JSON: {"model": "<model>", "effort": "<effort>"}
54
55Models:
56- haiku: lookups, quick questions, trivial or mechanical edits
57- sonnet: ordinary coding, refactors, writing and reviewing code
58- opus: hard design, tricky debugging, large multi-file changes
59
60Efforts, by how much reasoning the task needs:
61- low: almost none
62- medium: some
63- high: careful multi-step reasoning
64- xhigh: very hard problems
65- max: the hardest problems, where depth matters more than time
66
67<prompt>
68${prompt}
69</prompt>`
70
71export const statusFor = (choice: Choice): string => `auto: ${choice.model} · ${choice.effort}`
72types/index.d.ts 6 lines1declare module 'claude-code' {
2 interface PluginState {
3 'agent-switcher-mod': { paused: boolean }
4 }
5}
6