SLOPSHOPPER

gearshift

Routes each prompt to Haiku, Sonnet or Opus by difficulty, and triages prompts sent while the agent is busy

newpanespinnercommandtoaststatus
★ 1v0.1.4MITupdated 2026-10-23joezhuo2/gearshift
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · gearshift
│ ┃ gearshift ✕ › fix the failing auth test and add an audit log call │ ┃ no tier yet (session model) │ ┃ mode: auto ⏺ Read(src/auth.ts) │ ┃ why: no model available to classify, kept ⎿ Read 6 lines │ ┃ the session model ⏺ Update(src/auth.ts) │ ┃ classifier: default: timed out; sonnet: ⎿ Added 2 lines, removed 1 line │ ┃ timed out; subagent: spawn returned ⏺ Bash(bun test) │ ┃ {"answer":"","durationMs":0,"isAborted":fals ⎿ 3 pass, 1 fail │ ┃ e,"turnId":"agent-1","reason":"answer"} │ ┃ queued: 0 ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /gearshift │ ⎿ gearshift: gearshift mode: auto. Current tier: none. Last decisi │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · gearshift
no tier yet (session model) mode: auto why: no model available to classify, kept the session model classifier: default: timed out; sonnet: timed out; subagent: spawn returned {"answer":"","durationMs":0,"isAborted":false,"turnId":"agen t-1","reason":"answer"} queued: 0
README

<h1 align="center">gearshift</h1>

<em>Cheaper where it can be, smarter where it counts.</em>

<img src="assets/icon.webp" alt="gearshift icon" width="160">

<a href="https://github.com/joezhuo2/gearshift/stargazers"><img src="https://img.shields.io/github/stars/joezhuo2/gearshift?style=flat-square&logo=github" alt="GitHub stars"></a> <a href="https://github.com/joezhuo2/gearshift/releases"><img src="https://img.shields.io/github/downloads/joezhuo2/gearshift/total?style=flat-square&logo=github" alt="Release downloads"></a> <a href="https://github.com/joezhuo2/gearshift/blob/main/LICENSE"><img src="https://img.shields.io/github/license/joezhuo2/gearshift?style=flat-square" alt="License"></a> <a href="https://github.com/joezhuo2/gearshift/releases/latest"><img src="https://img.shields.io/github/v/release/joezhuo2/gearshift?style=flat-square" alt="Latest version"></a> <a href="https://github.com/joezhuo2/gearshift#-requirements"><img src="https://img.shields.io/badge/node-%3E%3D18-339933?style=flat-square&logo=nodedotjs&logoColor=white" alt="Node.js 18+"></a>

<a href="#-requirements">Requirements</a> • <a href="#-install">Install</a> • <a href="#-how-routing-works">Routing</a> • <a href="#-how-triage-works">Triage</a> • <a href="#-configuration">Configuration</a> • <a href="#-token-usage">Token Usage</a> • <a href="#-privacy">Privacy</a> • <a href="#-troubleshooting">Troubleshooting</a> • <a href="#-development">Development</a>

gearshift runs two jobs in the background, so you never pick a model or babysit a queue. Routing sends each prompt to the model that fits it: Haiku for simple work, Sonnet for everyday work, Opus for hard work, subagents included. Triage decides whether a prompt typed mid-run should interrupt, steer, or queue behind the current task.

📋 Requirements

  • Claude Code with plugin mod support (built and tested against Claude Code 2.1.299). gearshift runs as a mod, so it only works inside Claude Code: the CLI, the desktop app's Code tab, and the IDE extensions.
  • Access to the models you route to. By default that is claude-haiku-5-5, claude-sonnet-5-5 and claude-opus-5-5. If one is unavailable on your plan, gearshift falls back to your session model (see Fallbacks).
  • Node.js 18+ only if you install through npx/npm.

No API key or build step is needed. gearshift uses your existing Claude Code session.

🚀 Install

⚡ npx (one command)

npx github:joezhuo2/gearshift

The gearshift name on npm belongs to an unrelated package, so install from GitHub as shown; a bare npx gearshift fails with "could not determine executable to run".

This runs claude plugin marketplace add joezhuo2/gearshift and claude plugin install gearshift@gearshift for you. Restart Claude Code (or start a new session) so the mod loads.

Prefer a global install?

npm install -g github:joezhuo2/gearshift
gearshift install

gearshift update and gearshift uninstall work the same way (npx github:joezhuo2/gearshift update, npx github:joezhuo2/gearshift uninstall).

🖥️ Claude Code CLI (manual)

/plugin marketplace add joezhuo2/gearshift
/plugin install gearshift@gearshift

Restart Claude Code (or start a new session) so the mod loads.

🪟 Claude desktop app (Code tab)

The /plugin command does not run in the Code tab, so use the UI:

  1. Click + next to the prompt box, then Plugins.
  2. Choose Add marketplace and enter joezhuo2/gearshift.
  3. Install gearshift from that marketplace.
  4. Start a new session.

✅ Check it works

Run /gearshift. You should see a line like:

gearshift mode: auto. Current tier: none. Last decision: no prompt yet. ...

The footer beside the model picker shows gearshift: auto right away, and the tier (for example gearshift: everyday) once a prompt has been routed. In the terminal the prompt hint also shows the routed model, for example ⚙ everyday · sonnet-5-5.

🔄 Update

npx github:joezhuo2/gearshift update

This refreshes the marketplace and updates the plugin to the latest release. Restart Claude Code (or start a new session) so the new version loads.

Inside Claude Code CLI, run:

/plugin marketplace update gearshift
/plugin update gearshift@gearshift

To see the newest version, check the latest release.

🗑️ Uninstall

npx github:joezhuo2/gearshift uninstall

Or, inside Claude Code:

/plugin uninstall gearshift@gearshift

🧭 How routing works

Every prompt you send gets a tier:

TierDefault modelTypical work
🟢 simpleclaude-haiku-5-5Typos, renames, formatting, docs, version bumps, quick lookups
🟡 everydayclaude-sonnet-5-5Features, clear bug fixes, tests, refactors with a clear spec
🔴 hardclaude-opus-5-5Architecture, race conditions, memory leaks, root-cause debugging, security reviews

The tier is picked in this order:

  1. Override prefix. !haiku, !sonnet or !opus at the start of a prompt forces that model for that one prompt. The prefix is stripped before the agent sees it.
  2. Keyword fast path. No model call. Words like "architecture", "race condition", "root cause" or "security review" mean hard. Short prompts (120 characters or less) with words like "typo", "rename", "readme" or "bump" mean simple. Short acknowledgements ("ok", "yes", "go ahead", "continue") keep the current tier.
  3. Classifier. Anything else is labelled by a small model call (see Classifier).

🧲 Stickiness

Switching the main model throws away the prompt cache, which costs tokens. So gearshift is sticky:

  • It moves up a tier immediately when a prompt needs it.
  • It moves down only after two prompts in a row suggest a lower tier.

🤖 Subagents

When the agent spawns a subagent without choosing a model, gearshift classifies the subagent's prompt and picks the model. If the caller already chose a model, gearshift leaves it alone.

🚦 How triage works

When you send a prompt while a task is running, gearshift picks one of three paths:

DecisionWhenWhat happens
🛑 interruptThe prompt cancels, contradicts or redirects the task ("stop", "wait, use pnpm", "wrong file")The running turn is aborted, then the agent gets your new prompt along with what it was doing, so it can keep useful progress
🧭 steerA small tweak to the current taskThe prompt goes into the running turn (Claude Code's normal behavior)
📥 queueA separate task for laterThe prompt is held and sent when the current task finishes. Several queued prompts are sent together as one numbered list

Prompts starting with "stop", "wait", "cancel", "hold on", "never mind", "no", "wrong", "actually" or "instead" interrupt immediately with no model call. Others go to the classifier. If the classifier can't decide, the prompt is queued (configurable with triage_unsure).

gearshift never interrupts when you explicitly queue a prompt with ctrl+x enter.

💬 Commands

CommandWhat it does
/gearshiftShow mode, current tier and model, why it was picked, classifier status and queue size
/gearshift autoRoute automatically (default)
/gearshift simple / everyday / hardPin every prompt to one tier
/gearshift offStop routing and use the session model
/gearshift nowSend the newest queued prompt right away (interrupts the running task)
/gearshift dropClear the queue
/gearshift panelOpen a side panel with live status
/gearshift probeDiagnostic: checks that a Haiku subagent can be spawned

/gearshift runs immediately, even while a task is running.

🔧 Configuration

Set these in the plugin's settings after install (in the CLI, through /plugin; in the desktop app, from the plugin's entry under Plugins).

KeyDefaultDescription
enabledtrueRoute prompts automatically. false starts in off mode
model_simpleclaude-haiku-5-5Model for simple work
model_everydayclaude-sonnet-5-5Model for everyday work
model_hardclaude-opus-5-5Model for hard work
classifier_modelemptyModel that labels prompts. Empty uses Claude Code's own small fast model
triage_enabledtrueTriage prompts sent while a task runs. false keeps Claude Code's default (steer)
triage_unsurequeueWhat to do when triage can't decide: queue or steer

🧠 Classifier

When the keyword fast path doesn't match, gearshift asks a model to label the prompt. It tries, in order:

  1. classifier_model if set, otherwise Claude Code's default small fast model
  2. sonnet
  3. A short Haiku subagent

Each attempt has a timeout (3 seconds for direct calls, 12 seconds for the subagent). A classifier that errors is skipped for 5 minutes. If none answers, gearshift keeps the current tier (routing) or uses triage_unsure (triage).

🛟 Fallbacks

If a routed model fails (for example, your plan doesn't include it), gearshift retries the step on your session model, shows a toast, and avoids that model for 5 minutes. Your work is never blocked by routing.

📊 Token usage

gearshift aims to spend less overall by running simple work on cheaper models. It also adds some overhead of its own:

  • Classifier calls. One small labelling call per prompt that misses the keyword fast path, and one per mid-run prompt that isn't an obvious "stop/wait". Override prefixes, keyword matches and acknowledgements cost nothing extra.
  • Prompt cache. Each switch of the main model starts a fresh prompt cache. Stickiness keeps switches rare.
  • Interrupts. An interrupt resubmits a short summary of the old task (up to 500 characters) with your new prompt.

📈 Measured results

Results pending. Numbers below will be filled in from real sessions.

Test setup: TBD (Claude Code version, task mix, number of sessions)

ScenarioWithout gearshiftWith gearshiftDifference
Input tokensTBDTBDTBD
Output tokensTBDTBDTBD
Cache readsTBDTBDTBD
Classifier overheadn/aTBDTBD
Estimated costTBDTBDTBD

Tier mix across the test prompts: TBD (% simple / everyday / hard)

🔒 Privacy

gearshift runs locally inside Claude Code. Classification uses the same Claude session and account you're already using. Nothing is sent to any other service, and gearshift stores no data outside the session.

🩺 Troubleshooting

  • Hint never shows a model. Run /gearshift. If mode is off, run /gearshift auto. If the tier is none, send a prompt first.
  • Classifier shows errors. Run /gearshift to see which classifier failed and why. Set classifier_model to a model your plan includes, or run /gearshift probe to check subagent spawning.
  • Too many interrupts. Set triage_unsure to steer, or set triage_enabled to false.
  • Want one prompt on a specific model. Start it with !haiku, !sonnet or !opus.

🧪 Development

git clone https://github.com/joezhuo2/gearshift
cd gearshift
claude plugin validate .
claude plugin test .

Source lives in hooks/: route.ts (tiers, keywords, stickiness), triage.ts (interrupt/steer/queue) and register.tsx (the mod wiring). Tests sit next to each file.

📄 License

MIT

Source 4 files
hooks/register.tsx 330 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Snapshot } from '../types'
5
6import { applySticky, fastTier, LABELS, parseOverride, tierFromLabel, TIERS } from './route'
7import type { Sticky, Tier } from './route'
8import { decisionFromLabel, fastTriage, flushPrompt, steerPrompt, TRIAGE_LABELS, triageInput } from './triage'
9import type { Decision } from './triage'
10
11const CLASSIFY_TIMEOUT_MS = 3000
12const BLOCK_MS = 5 * 60 * 1000
13const AGENT_TIMEOUT_MS = 12000
14const AGENT_POLL_MS = 250
15
16const PANE = 'gearshift'
17
18const view = atom({ plugin: 'gearshift', key: 'snapshot' } as const, {
19  mode: 'auto',
20  tier: null,
21  model: null,
22  reason: 'no prompt yet',
23  classifier: 'not called yet',
24  queued: 0,
25} as Snapshot)
26
27type Mode = 'auto' | 'off' | Tier
28
29type State = {
30  mode: Mode
31  sticky: Sticky
32  pending?: Promise<Tier | undefined>
33  oneShot?: Tier
34  lastReason: string
35  running?: { turnId: string; text: string }
36  queue: string[]
37  steer?: string
38  classifier: string
39  classifierModel?: string
40  blockedUntil: Record<string, number>
41  probeId?: string
42  probe: string
43  answers: Record<string, string>
44  hintRuns: number
45  hintSurface: string
46}
47
48async function viaAgent($: EngineInterface, s: State, text: string, labels: readonly string[]): Promise<string | { error: string }> {
49  const prompt = ['Classify the text below. Reply with exactly one of these labels and nothing else:', ...labels, '', 'Text:', text].join('\n')
50  const started = await $.agent.spawn({ prompt, description: 'gearshift classify', model: 'haiku' }).catch((err: unknown) => ({ error: String(err).slice(0, 120) }))
51  if (!('agentId' in started) || !started.agentId) return { error: `spawn returned ${JSON.stringify(started).slice(0, 100)}` }
52  const id = started.agentId
53  for (let waited = 0; waited < AGENT_TIMEOUT_MS; waited += AGENT_POLL_MS) {
54    const answer = s.answers[id]
55    if (answer !== undefined) {
56      delete s.answers[id]
57      return labels.find(l => answer.includes(l)) ?? { error: `agent answered "${answer.slice(0, 60)}"` }
58    }
59    await $.clock.sleep(AGENT_POLL_MS).catch(() => undefined)
60  }
61  return { error: 'agent timed out' }
62}
63
64async function pick($: EngineInterface, s: State, text: string, labels: readonly string[]): Promise<string | undefined> {
65  const candidates = [s.classifierModel ?? '', 'sonnet', 'agent'].filter((m, i, all) => all.indexOf(m) === i)
66  const notes: string[] = []
67  for (const model of candidates) {
68    const key = `classifier:${model}`
69    if ((s.blockedUntil[key] ?? 0) > Date.now()) {
70      notes.push(`${model || 'default'} paused after an error`)
71      continue
72    }
73    const started = Date.now()
74    let result: string | undefined | { error: string }
75    if (model === 'agent') {
76      result = await viaAgent($, s, text, labels)
77    } else {
78      const timeout = $.clock.sleep(CLASSIFY_TIMEOUT_MS).then(
79        () => ({ error: 'timed out' }),
80        () => new Promise<never>(() => {}),
81      )
82      result = await Promise.race([
83        $.model.classify(text, labels, model ? { model } : undefined).catch((err: unknown) => ({ error: String(err).slice(0, 120) })),
84        timeout,
85      ])
86    }
87    const ms = Date.now() - started
88    if (typeof result === 'string') {
89      s.classifier = `${model === 'agent' ? 'haiku subagent' : model || 'default'} answered in ${ms} ms`
90      return result
91    }
92    if (result === undefined) {
93      s.classifier = `${model || 'default'} answered no label in ${ms} ms`
94      return undefined
95    }
96    s.blockedUntil[key] = Date.now() + BLOCK_MS
97    notes.push(`${model === 'agent' ? 'subagent' : model || 'default'}: ${result.error}`)
98  }
99  s.classifier = notes.join('; ')
100  return undefined
101}
102
103async function classify($: EngineInterface, s: State, text: string): Promise<Tier | undefined> {
104  const fast = fastTier(text)
105  if (fast === 'keep') {
106    s.lastReason = 'follow-up, kept tier'
107    return s.sticky.current
108  }
109  if (fast !== undefined) {
110    s.lastReason = 'keyword match'
111    return fast
112  }
113  const tier = tierFromLabel(await pick($, s, text, LABELS))
114  if (tier) {
115    s.lastReason = 'classifier'
116    return tier
117  }
118  s.lastReason = 'no model available to classify, kept the session model'
119  return s.sticky.current
120}
121
122async function interrupt($: EngineInterface, s: State, message: string) {
123  const running = s.running
124  if (!running) return
125  s.steer = steerPrompt(running.text, message)
126  await $.turn.abort({ turnId: running.turnId })
127}
128
129async function flush($: EngineInterface, s: State) {
130  const steer = s.steer
131  s.steer = undefined
132  if (steer) await $.prompt.submit({ text: steer, asUser: true })
133  if (s.queue.length === 0) return
134  const text = flushPrompt(s.queue)
135  s.queue = []
136  await $.prompt.submit({ text, asUser: true })
137}
138
139async function publish($: EngineInterface, snapshot: Snapshot) {
140  await update($, view, () => snapshot)
141}
142
143export const register: Register = (on, options) => {
144  const modelFor = (tier: Tier) => String(options[`model_${tier}`] ?? '')
145  const unsure: Decision = options.triage_unsure === 'steer' ? 'steer' : 'queue'
146  const s: State = {
147    mode: options.enabled === false ? 'off' : 'auto',
148    sticky: { lowerVotes: 0 },
149    lastReason: 'no prompt yet',
150    queue: [],
151    classifier: 'not called yet',
152    classifierModel: String(options.classifier_model ?? '') || undefined,
153    blockedUntil: {},
154    probe: 'not run',
155    answers: {},
156    hintRuns: 0,
157    hintSurface: 'none',
158  }
159  const usable = (model: string) => model !== '' && (s.blockedUntil[model] ?? 0) < Date.now()
160  const snap = (): Snapshot => {
161    const tier = s.oneShot ?? (s.mode === 'auto' ? s.sticky.current : s.mode === 'off' ? undefined : s.mode)
162    return {
163      mode: s.mode,
164      tier: tier ?? null,
165      model: tier ? modelFor(tier) : null,
166      reason: s.lastReason,
167      classifier: s.classifier,
168      queued: s.queue.length,
169    }
170  }
171
172  on('session.start', async ($, e, next) => {
173    await $.command.register({
174      name: 'gearshift',
175      description: 'Model routing and triage: /gearshift [auto|off|simple|everyday|hard|now|drop|panel]',
176      immediate: true,
177    })
178    await publish($, snap())
179    return next(e)
180  })
181
182  on('command.run', { command: 'gearshift' }, async ($, e) => {
183    const arg = (e.args ?? '').trim().toLowerCase()
184    if (arg === 'panel') {
185      await $.ui.open({ id: PANE, title: 'gearshift' })
186      return { text: 'gearshift: panel opened.' }
187    }
188    if (arg === 'probe') {
189      s.probe = 'running'
190      const started = await $.agent
191        .spawn({ prompt: 'Reply with exactly one word: ok', description: 'gearshift probe', model: 'haiku' })
192        .catch((err: unknown) => ({ error: String(err).slice(0, 200) }))
193      s.probeId = 'agentId' in started ? started.agentId : undefined
194      if (!s.probeId) s.probe = `spawn returned ${JSON.stringify(started).slice(0, 200)}`
195      return { text: `gearshift: spawn returned ${JSON.stringify(started).slice(0, 200)}. Run /gearshift in a few seconds.` }
196    }
197    if (arg === 'now') {
198      const last = s.queue.pop()
199      if (!last) return { text: 'gearshift: nothing queued.' }
200      if (s.running) await interrupt($, s, last)
201      else await $.prompt.submit({ text: last, asUser: true })
202      return { text: 'gearshift: sent the last queued prompt now.' }
203    }
204    if (arg === 'drop') {
205      const n = s.queue.length
206      s.queue = []
207      return { text: `gearshift: dropped ${n} queued prompt${n === 1 ? '' : 's'}.` }
208    }
209    if (arg === 'auto' || arg === 'off' || (TIERS as readonly string[]).includes(arg)) s.mode = arg as Mode
210    else if (arg) return { text: `gearshift: unknown "${arg}". Use auto, off, simple, everyday, hard, now, drop or panel.` }
211    if (s.mode === 'off') $.ui.status(undefined)
212    await publish($, snap())
213    const tier = s.sticky.current
214    return {
215      text: `gearshift mode: ${s.mode}. Current tier: ${tier ?? 'none'}${tier ? ` (${modelFor(tier)})` : ''}. Last decision: ${s.lastReason}. Classifier: ${s.classifier}. Subagent probe: ${s.probe}. Hint line: drawn ${s.hintRuns} times (${s.hintSurface}). Queued: ${s.queue.length}.`,
216    }
217  })
218
219  on('turn.start', async ($, e, next) => {
220    if (e.agentId === undefined) s.running = { turnId: e.turnId, text: e.text || s.running?.text || '' }
221    return next(e)
222  })
223
224  on('turn.complete', async ($, e, next) => {
225    const done = await next(e)
226    if (e.agentId !== undefined) {
227      s.answers[e.agentId] = e.reason === 'answer' ? e.answer : ''
228      if (e.agentId === s.probeId) s.probe = e.reason === 'answer' ? `ok, answered "${e.answer.slice(0, 40)}"` : `ended: ${e.reason}`
229    }
230    if (e.agentId === undefined && s.running?.turnId === e.turnId) {
231      s.running = undefined
232      await flush($, s)
233      await publish($, snap())
234    }
235    return done
236  })
237
238  on('prompt.submit', async ($, e, next) => {
239    if (e.command || e.isMeta) return next(e)
240
241    if (e.turnId !== undefined) {
242      if (options.triage_enabled === false || e.wait || e.origin.kind !== 'composer' || !s.running) return next(e)
243      const decision =
244        fastTriage(e.text) ?? decisionFromLabel(await pick($, s, triageInput(s.running.text, e.text), TRIAGE_LABELS)) ?? unsure
245      if (decision === 'steer') {
246        $.ui.toast('gearshift · Added to the current task')
247        return next(e)
248      }
249      if (decision === 'interrupt') {
250        await interrupt($, s, e.text)
251        return { drop: 'gearshift · Redirecting. The current task was stopped and will restart with your update.' }
252      }
253      s.queue.push(e.text)
254      await publish($, snap())
255      const n = s.queue.length
256      return { drop: `gearshift · Queued${n > 1 ? ` (${n} waiting)` : ''}. Sends when the current task finishes; /gearshift now sends it immediately.` }
257    }
258
259    if (s.mode === 'off') return next(e)
260    const { tier, text } = parseOverride(e.text)
261    if (tier) {
262      s.oneShot = tier
263      s.lastReason = `!${modelFor(tier)} override`
264      return next({ ...e, text })
265    }
266    s.oneShot = undefined
267    s.pending = s.mode === 'auto' ? classify($, s, text) : undefined
268    return next(e)
269  })
270
271  on('turn.step', async function* ($, e, next) {
272    if (s.mode === 'off' || e.agentId !== undefined) return yield* next(e)
273    if (e.index === 0 && s.pending) {
274      const suggested = await s.pending
275      s.pending = undefined
276      if (suggested) s.sticky = applySticky(s.sticky, suggested)
277    }
278    const tier = s.oneShot ?? (s.mode === 'auto' ? s.sticky.current : s.mode)
279    const model = tier ? modelFor(tier) : ''
280    if (e.index === 0) await publish($, snap())
281    if (!tier || !usable(model) || model === e.model) return yield* next(e)
282    $.ui.status(`⚙ ${tier} · ${model.replace(/^claude-/, '')}`)
283    try {
284      return yield* next({ ...e, model })
285    } catch (err) {
286      s.blockedUntil[model] = Date.now() + BLOCK_MS
287      s.lastReason = `${model} failed (${String(err).slice(0, 80)}), used the session model for 5 min`
288      $.ui.toast(`gearshift: ${model} unavailable, using the session model`)
289      await publish($, snap())
290      return yield* next(e)
291    }
292  })
293
294  on('agent.spawn', async ($, e, next) => {
295    if (s.mode === 'off' || e.model !== undefined) return next(e)
296    const tier = s.mode === 'auto' ? await classify($, s, e.prompt) : s.mode
297    return tier && usable(modelFor(tier)) ? next({ ...e, model: modelFor(tier) }) : next(e)
298  })
299
300  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
301    const v = await read($, view)
302    if (v.mode === 'off') return next(e)
303    return next({ ...e, props: { ...e.props, modes: [...e.props.modes, `gearshift: ${v.tier ?? v.mode}`] } })
304  })
305
306  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
307    s.hintRuns += 1
308    s.hintSurface = e.surface
309    const v = await read($, view)
310    if (v.mode === 'off') return next(e)
311    const routed = v.tier ? `${v.tier} · ${(v.model ?? '').replace(/^claude-/, '')}` : 'session model'
312    const tag = `⚙ ${routed}${v.queued ? ` · ${v.queued} queued` : ''}`
313    return next({ ...e, props: { ...e.props, hint: e.props.hint ? `${tag} · ${e.props.hint}` : tag } })
314  })
315
316  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
317    const { Box, Text } = $.ui.resolve(e)
318    const v = await read($, view)
319    return (
320      <Box flexDirection="column">
321        <Text bold>{v.tier ? `${v.tier} · ${(v.model ?? '').replace(/^claude-/, '')}` : 'no tier yet (session model)'}</Text>
322        <Text dimColor>mode: {v.mode}</Text>
323        <Text dimColor>why: {v.reason}</Text>
324        <Text dimColor>classifier: {v.classifier}</Text>
325        <Text dimColor>queued: {String(v.queued)}</Text>
326      </Box>
327    )
328  })
329}
330
hooks/route.ts 47 lines
1export type Tier = 'simple' | 'everyday' | 'hard'
2
3export const TIERS: readonly Tier[] = ['simple', 'everyday', 'hard']
4
5export const LABELS = [
6  'simple-edit-docs-or-lookup',
7  'everyday-feature-fix-or-tests',
8  'hard-design-subtle-bug-or-security',
9] as const
10
11const HARD = /\b(architect\w*|system design|race condition|deadlock|concurren\w*|memory leak|root cause|security (audit|review)|threat model|algorithm|rewrite the|migrate (the|our) (whole|entire))\b/i
12const SIMPLE = /\b(typo|rename|reformat|format|spelling|comment|docstring|readme|changelog|bump|version)\b/i
13const ACK = /^(y|yes|yep|ok|okay|sure|go|go ahead|continue|do it|next|thanks|thank you|lgtm|ship it)[.!]*$/i
14
15export type FastResult = Tier | 'keep' | undefined
16
17export function fastTier(text: string): FastResult {
18  const t = text.trim()
19  if (ACK.test(t)) return 'keep'
20  if (HARD.test(t)) return 'hard'
21  if (t.length <= 120 && SIMPLE.test(t)) return 'simple'
22  return undefined
23}
24
25export function tierFromLabel(label: string | undefined): Tier | undefined {
26  const i = LABELS.findIndex(l => l === label)
27  return i === -1 ? undefined : TIERS[i]
28}
29
30const OVERRIDE = /^!(haiku|sonnet|opus)\b\s*/i
31const OVERRIDE_TIER: Record<string, Tier> = { haiku: 'simple', sonnet: 'everyday', opus: 'hard' }
32
33export function parseOverride(text: string): { tier?: Tier; text: string } {
34  const m = OVERRIDE.exec(text)
35  return m ? { tier: OVERRIDE_TIER[(m[1] ?? '').toLowerCase()], text: text.slice(m[0].length) } : { text }
36}
37
38export type Sticky = { current?: Tier; lowerVotes: number }
39
40export function applySticky(s: Sticky, suggested: Tier): Sticky {
41  if (s.current === undefined || TIERS.indexOf(suggested) >= TIERS.indexOf(s.current)) {
42    return { current: suggested, lowerVotes: 0 }
43  }
44  const votes = s.lowerVotes + 1
45  return votes >= 2 ? { current: suggested, lowerVotes: 0 } : { current: s.current, lowerVotes: votes }
46}
47
hooks/triage.ts 34 lines
1export type Decision = 'interrupt' | 'steer' | 'queue'
2
3export const TRIAGE_LABELS = [
4  'interrupt-cancels-or-contradicts-current-task',
5  'steer-small-tweak-to-current-task',
6  'queue-separate-task-for-later',
7] as const
8
9const DECISIONS: readonly Decision[] = ['interrupt', 'steer', 'queue']
10
11const STOP = /^(stop|wait|cancel|abort|halt|hold on|hang on|nevermind|never mind|no[,.!]|nope|wrong|actually|instead)\b/i
12
13export function fastTriage(text: string): Decision | undefined {
14  return STOP.test(text.trim()) ? 'interrupt' : undefined
15}
16
17export function decisionFromLabel(label: string | undefined): Decision | undefined {
18  const i = TRIAGE_LABELS.findIndex(l => l === label)
19  return i === -1 ? undefined : DECISIONS[i]
20}
21
22export function triageInput(currentTask: string, message: string): string {
23  return `Task the agent is working on now:\n${currentTask.slice(0, 2000)}\n\nNew message from the user:\n${message}`
24}
25
26export function steerPrompt(currentTask: string, message: string): string {
27  return `I interrupted you. You were working on: "${currentTask.slice(0, 500)}". Keep any useful progress, but change direction:\n\n${message}`
28}
29
30export function flushPrompt(queue: readonly string[]): string {
31  if (queue.length === 1) return queue[0] ?? ''
32  return `Requests I sent while you were busy, in order:\n${queue.map((q, i) => `${i + 1}. ${q}`).join('\n')}`
33}
34
types/index.d.ts 15 lines
1export type Snapshot = {
2  mode: string
3  tier: string | null
4  model: string | null
5  reason: string
6  classifier: string
7  queued: number
8}
9
10declare module 'claude-code' {
11  interface PluginState {
12    gearshift: { snapshot: Snapshot }
13  }
14}
15