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

<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.
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).npx/npm.No API key or build step is needed. gearshift uses your existing Claude Code session.
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).
/plugin marketplace add joezhuo2/gearshift
/plugin install gearshift@gearshift
Restart Claude Code (or start a new session) so the mod loads.
The /plugin command does not run in the Code tab, so use the UI:
joezhuo2/gearshift.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.
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.
npx github:joezhuo2/gearshift uninstall
Or, inside Claude Code:
/plugin uninstall gearshift@gearshift
Every prompt you send gets a tier:
| Tier | Default model | Typical work |
|---|---|---|
🟢 simple | claude-haiku-5-5 | Typos, renames, formatting, docs, version bumps, quick lookups |
🟡 everyday | claude-sonnet-5-5 | Features, clear bug fixes, tests, refactors with a clear spec |
🔴 hard | claude-opus-5-5 | Architecture, race conditions, memory leaks, root-cause debugging, security reviews |
The tier is picked in this order:
!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.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.Switching the main model throws away the prompt cache, which costs tokens. So gearshift is sticky:
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.
When you send a prompt while a task is running, gearshift picks one of three paths:
| Decision | When | What happens |
|---|---|---|
| 🛑 interrupt | The 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 |
| 🧭 steer | A small tweak to the current task | The prompt goes into the running turn (Claude Code's normal behavior) |
| 📥 queue | A separate task for later | The 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.
| Command | What it does |
|---|---|
/gearshift | Show mode, current tier and model, why it was picked, classifier status and queue size |
/gearshift auto | Route automatically (default) |
/gearshift simple / everyday / hard | Pin every prompt to one tier |
/gearshift off | Stop routing and use the session model |
/gearshift now | Send the newest queued prompt right away (interrupts the running task) |
/gearshift drop | Clear the queue |
/gearshift panel | Open a side panel with live status |
/gearshift probe | Diagnostic: checks that a Haiku subagent can be spawned |
/gearshift runs immediately, even while a task is running.
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).
| Key | Default | Description |
|---|---|---|
enabled | true | Route prompts automatically. false starts in off mode |
model_simple | claude-haiku-5-5 | Model for simple work |
model_everyday | claude-sonnet-5-5 | Model for everyday work |
model_hard | claude-opus-5-5 | Model for hard work |
classifier_model | empty | Model that labels prompts. Empty uses Claude Code's own small fast model |
triage_enabled | true | Triage prompts sent while a task runs. false keeps Claude Code's default (steer) |
triage_unsure | queue | What to do when triage can't decide: queue or steer |
When the keyword fast path doesn't match, gearshift asks a model to label the prompt. It tries, in order:
classifier_model if set, otherwise Claude Code's default small fast modelsonnetEach 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).
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.
gearshift aims to spend less overall by running simple work on cheaper models. It also adds some overhead of its own:
Results pending. Numbers below will be filled in from real sessions.
Test setup: TBD (Claude Code version, task mix, number of sessions)
| Scenario | Without gearshift | With gearshift | Difference |
|---|---|---|---|
| Input tokens | TBD | TBD | TBD |
| Output tokens | TBD | TBD | TBD |
| Cache reads | TBD | TBD | TBD |
| Classifier overhead | n/a | TBD | TBD |
| Estimated cost | TBD | TBD | TBD |
Tier mix across the test prompts: TBD (% simple / everyday / hard)
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.
/gearshift. If mode is off, run /gearshift auto. If the tier is none, send a prompt first./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.triage_unsure to steer, or set triage_enabled to false.!haiku, !sonnet or !opus.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.
hooks/register.tsx 330 lines1import { 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}
330hooks/route.ts 47 lines1export 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}
47hooks/triage.ts 34 lines1export 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}
34types/index.d.ts 15 lines1export 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