SLOPSHOPPER

agent-switcher-mod

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

newcommandstatuspromptmodel
v0.1.0no licenseupdated 2026-10-16wendyyyanto/agent-switcher-mod
A shopper browsing a rack in a slop shop
Preview could not run: the mod kept running for 20s in the preview session (a hold loop or a long wait); no drawing was captured
Source 4 files
hooks/register.ts 86 lines
1import { 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}
86
hooks/flow.ts 48 lines
1import { 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}
48
hooks/policy.ts 72 lines
1export 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}`
72
types/index.d.ts 6 lines
1declare module 'claude-code' {
2  interface PluginState {
3    'agent-switcher-mod': { paused: boolean }
4  }
5}
6