Asks a decision model which Claude model and effort fit your prompt. Does nothing until DECISION_MOD_KEY is set.

Asks a decision model which Claude model (haiku, sonnet, opus) and which effort (low to max) fit your first prompt, and offers to switch. It does nothing until DECISION_MOD_KEY is set.
DECISION_MOD_URL. By default that is OpenRouter, which forwards it to the model vendor (TypeSafe). Do not use this mod if your prompts can contain secrets or material you may not share with those parties.ask) or with no band at all (auto).DECISION_MOD_KEY is unset. The mod then shows one notice per session and stays idle.ask subagent spawn may be held up to 10 seconds (the decision call included) for its confirmation band./model does; change it back with /model. Keep, closing the dialog, or a non-interactive run (-p) keeps everything as it was.DECISION_MOD_MIN_CONFIDENCE is shown and kept, not offered. If neither value passes, no dialog is shown: the prompt goes at once and a small informational band disappears by itself after 10 s./effort; the only setting it writes is the default model, and only when you press Switch.DECISION_MOD_SUBAGENTS: ask shows one band per spawn (Switch/Keep buttons, held up to 10 s; no click means the subagent runs as spawned), auto applies the values that pass and shows a toast, off routes nothing. Spawns with an explicit model, forks and teammates are never touched.auto caution: text inside a subagent prompt can steer the suggestion (for example toward opus/max), and auto applies it without asking. Prefer ask if cost matters.Set these as environment variables before starting Claude Code.
| Variable | Default | Meaning |
|---|---|---|
DECISION_MOD_KEY | none (required) | API key for the decision endpoint. Unset means the mod is idle and sends nothing. |
DECISION_MOD_URL | https://openrouter.ai/api | Decision endpoint. https is required; http is accepted only for localhost. |
DECISION_MOD_MODEL | typesafe/jev-1.13 | Decision model. The confidence threshold is tuned per model version; re-check DECISION_MOD_MIN_CONFIDENCE if you change it. |
DECISION_MOD_MIN_CONFIDENCE | 0.6 | Minimum confidence (0 to 1) for a value to be offered. |
DECISION_MOD_SUBAGENTS | ask | ask, auto or off. |
An invalid value falls back to its default, with one notice per session.
modeWorks on its own. Together with mode, a model or effort you picked in mode always wins, and a stored mode effort means no effort is suggested. Install the updated mode (0.1.1 or later) so a level this mod put on a request is not mistaken for your own /effort.
/plugin marketplace add danielmalka/malka-claude-mods
/plugin install decision-router@malka-claude-mods
/plugin uninstall decision-router@malka-claude-mods
The only thing the mod ever writes is the default model after a Switch (undo it with /model), so nothing else needs undoing. Unset DECISION_MOD_KEY to stop any call without uninstalling.
Type declarations in .claude-plugin/types/ are generated by Claude Code when the mod loads (not committed); the mod's own contract is types/index.d.ts. Then claude plugin test plugins/decision-router.
Pergunta a um modelo de decisão qual modelo Claude (haiku, sonnet, opus) e qual esforço (low até max) servem ao seu primeiro prompt, e oferece trocar. Não faz nada até DECISION_MOD_KEY ser definida.
DECISION_MOD_URL. Por padrão é a OpenRouter, que repassa ao fornecedor do modelo (TypeSafe). Não use este mod se seus prompts podem conter segredos ou material que você não pode compartilhar com essas partes.ask) ou sem faixa nenhuma (auto).DECISION_MOD_KEY não estiver definida. O mod mostra um aviso por sessão e fica ocioso.ask pode ficar retido até 10 segundos (a chamada de decisão incluída) esperando sua faixa de confirmação./ de uma sessão nova (não retomada), pergunta ao modelo de decisão qual modelo e esforço servem. Os prompts seguintes não geram chamada./model faz; volte com /model. Keep, fechar o diálogo ou uma execução não interativa (-p) mantém tudo como estava.DECISION_MOD_MIN_CONFIDENCE é mostrado e mantido, não oferecido. Se nenhum dos dois passar, não há diálogo: o prompt segue na hora e uma pequena faixa informativa some sozinha após 10 s./effort; a única configuração que grava é o modelo padrão, e só quando você aperta Switch.DECISION_MOD_SUBAGENTS: ask mostra uma faixa por spawn (botões Switch/Keep, retido até 10 s; sem clique o subagente roda como foi criado), auto aplica os valores que passam e mostra um aviso, off não roteia nada. Spawns com modelo explícito, forks e teammates nunca são alterados.auto: texto dentro do prompt de um subagente pode direcionar a sugestão (por exemplo para opus/max), e auto aplica sem perguntar. Prefira ask se custo importa.Defina como variáveis de ambiente antes de iniciar o Claude Code.
| Variável | Padrão | Significado |
|---|---|---|
DECISION_MOD_KEY | nenhum (obrigatória) | Chave de API do endpoint de decisão. Sem ela o mod fica ocioso e não envia nada. |
DECISION_MOD_URL | https://openrouter.ai/api | Endpoint de decisão. https é obrigatório; http só para localhost. |
DECISION_MOD_MODEL | typesafe/jev-1.13 | Modelo de decisão. O limiar de confiança é calibrado por versão do modelo; revise DECISION_MOD_MIN_CONFIDENCE se trocar. |
DECISION_MOD_MIN_CONFIDENCE | 0.6 | Confiança mínima (0 a 1) para um valor ser oferecido. |
DECISION_MOD_SUBAGENTS | ask | ask, auto ou off. |
Um valor inválido volta ao padrão, com um aviso por sessão.
modeFunciona sozinho. Junto com o mode, o modelo ou esforço que você escolheu no mode sempre vence, e um esforço guardado no mode significa que nenhum esforço é sugerido. Instale o mode atualizado (0.1.1 ou superior) para que um nível colocado por este mod não seja confundido com o seu /effort.
Instalar: /plugin marketplace add danielmalka/malka-claude-mods e depois /plugin install decision-router@malka-claude-mods. Desinstalar: /plugin uninstall decision-router@malka-claude-mods. A única coisa que o mod grava é o modelo padrão após um Switch (desfaça com /model), então não há mais nada a desfazer. Remova DECISION_MOD_KEY para parar qualquer chamada sem desinstalar.
hooks/register.tsx 365 lines1import { atom, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Effort, Model } from '../types'
5import { autoLine, band, bandLines } from './band'
6import { loadConfig } from './config'
7import { decide } from './decide'
8import { fromEngine } from './io'
9import { EFFORTS } from './questions'
10
11const WINDOW_MS = 10_000 // R15a: from submission, the decision call included
12
13// Our routed effort, read by `mode` so it can tell it from the engine's own level (R19c).
14const routedEffort = atom({ plugin: 'decision-router', key: 'effort' } as const, null)
15
16const isEffort = (v: unknown): v is Effort => (EFFORTS as readonly unknown[]).includes(v)
17
18// Foreign state of a plugin that may be absent: a failing read is "no value". References must be literal at the call site.
19const manualSet = async ($: EngineInterface) => {
20 try {
21 return (await $.state.get({ plugin: 'mode', key: 'manual' })).value === true
22 } catch {
23 return false
24 }
25}
26const modeHoldsEffort = async ($: EngineInterface) => {
27 try {
28 return ((await $.state.get({ plugin: 'mode', key: 'effort' })).value ?? null) !== null
29 } catch {
30 return false
31 }
32}
33
34// What an accepted Switch put in force; null until then.
35type Plan = { modelId?: string; effort?: Effort; modelPending: boolean }
36let plan: Plan | null = null
37let done = false // the first prompt was handled (R4/R24)
38type Band = { lines: string[]; canSwitch: boolean; answer: ((v: 'switch' | 'keep') => void) | null }
39let openBand: Band | null = null
40let epoch = 0 // bumped by each session.start: a dialog or hold of an older session must change nothing
41let seenEffort: Effort | undefined
42// Subagents (R17, R19b): bands and holds are per spawn; efforts are keyed by agentId.
43type SubBand = Band & { id: string }
44let subBands: SubBand[] = []
45const subSettlers = new Set<(v: 'switch' | 'keep') => void>()
46const subEffort = new Map<string, Effort>() // agentId -> imposed effort
47// Efforts whose agentId is not known yet, each with a start time (valid for AWAIT_MS only).
48type Awaiting = { effort: Effort; description: string; type: string | undefined; parent: string | undefined; at: number }
49let awaitingId: Awaiting[] = []
50const knownIds = new Set<string>() // every agentId a routed spawn returned, with or without an effort
51let spawnSeq = 0
52const AWAIT_MS = 10_000
53let idleShown = false // R1/R2: once per session
54let invalidShown = false
55let seenModel: string | undefined
56
57export const register: Register = on => {
58 on('session.start', async ($, e, next) => {
59 // $.ui.ask has no cancel: a dialog still open from the previous session stays until answered, and its answer applies nothing (epoch)
60 for (const settle of [...subSettlers]) settle('keep')
61 subSettlers.clear()
62 subBands = []
63 subEffort.clear()
64 awaitingId = []
65 knownIds.clear()
66 spawnSeq = 0
67 idleShown = false
68 invalidShown = false
69 epoch++
70 plan = null
71 done = false
72 openBand = null
73 seenEffort = undefined
74 seenModel = undefined
75 await update($, routedEffort, () => null).catch(() => undefined)
76 return next(e)
77 })
78
79 on('prompt.submit', async ($, e, next) => {
80 // Only the user's own first non-slash prompt of a fresh session is routed (R4).
81 if (done || e.text.startsWith('/') || (e.origin !== undefined && e.origin.kind !== 'composer')) return next(e)
82 done = true // before any await: a second submit right behind this one is not routed
83 const mine = epoch
84 try {
85 if ((await $.session.turns()) > 0) return next(e)
86 const startedAt = await $.clock.now()
87 const io = fromEngine($)
88 const cfg = await loadConfig(io)
89 if (cfg.key === '') {
90 if (!idleShown) $.ui.toast('Decision router is idle: DECISION_MOD_KEY is not set')
91 idleShown = true
92 return next(e)
93 }
94 if (cfg.invalid.length > 0 && !invalidShown) $.ui.toast(`Decision router: invalid ${cfg.invalid.join(', ')}, using the default`)
95 invalidShown = true
96 if (await manualSet($)) return next(e) // R18
97 const effortHeld = await modeHoldsEffort($) // R19a
98 const r = await decide(io, cfg, e.text)
99 if (mine !== epoch) return next(e)
100 if (!r.ok) {
101 $.ui.toast(`Decision router: ${r.reason}; nothing changed`) // R20
102 return next(e)
103 }
104 // the suggested model may already be the one running: nothing to switch, only the effort can be offered
105 const current = await $.session.model().catch(() => '')
106 const modelCurrent = current.includes(`-${r.model}-`)
107 const offer = {
108 model: !modelCurrent && r.modelConfidence >= cfg.minConfidence,
109 effort: !effortHeld && r.effortConfidence >= cfg.minConfidence,
110 effortHeld,
111 modelCurrent: modelCurrent && r.modelConfidence >= cfg.minConfidence,
112 }
113 if (mine !== epoch) return next(e)
114 const lines = bandLines(r, offer, cfg.minConfidence)
115 if (!offer.model && !offer.effort) {
116 // nothing to ask: the prompt goes at once; an informational band leaves by the mod's own timer (R15a)
117 const remaining = WINDOW_MS - ((await $.clock.now()) - startedAt)
118 if (remaining <= 0) return next(e)
119 const redraw = () => void Promise.resolve($.ui.invalidate('ui.render')).catch(() => undefined)
120 const info: Band = { lines, canSwitch: false, answer: null }
121 // arm the timer first: if it throws, no band is left standing
122 $.clock.after(remaining, () => {
123 if (openBand === info) {
124 openBand = null
125 redraw()
126 }
127 })
128 openBand = info
129 redraw()
130 return next(e)
131 }
132 // The band is not drawn while prompt.submit holds the submission: ask in the engine's own dialog (no time limit).
133 // Anything but the exact label 'Switch' (Keep, free text, dismiss, -p) is Keep (R15).
134 let answer: 'switch' | 'keep' = 'keep'
135 try {
136 const chosen = await $.ui.ask(`Decision router suggests for this session:\n${lines.join('\n')}\n${offer.model ? 'Switch also makes the model your default for new sessions.\n' : ''}Switch?`, ['Switch', 'Keep'])
137 if (chosen === 'Switch') answer = 'switch'
138 } catch {}
139 if (answer === 'switch' && mine === epoch) {
140 // The user may have picked in /mode while the dialog was open: that choice wins, nothing is switched.
141 const manual = await manualSet($)
142 const held = await modeHoldsEffort($)
143 const effort = offer.effort && !manual && !held ? r.effort : undefined
144 const wantModel = offer.model && !manual
145 if (mine !== epoch) return next(e)
146 // Fallible steps first, the irreversible one (the model, which persists) last.
147 const mine_: Plan = { effort, modelPending: false }
148 if (effort) {
149 try {
150 await update($, routedEffort, () => effort)
151 } catch (error) {
152 mine_.effort = undefined
153 await update($, routedEffort, () => null).catch(() => undefined)
154 $.ui.toast(`Decision router: effort not applied: ${String(error)}`)
155 }
156 }
157 plan = mine_
158 if (wantModel && mine === epoch) {
159 // turn.step needs a resolved id: set the model like /model does (config.set takes the alias; /model itself is
160 // refused inside prompt.submit) and read the id back. A failure leaves the model alone; the effort still applies.
161 try {
162 const before = await $.session.model()
163 const set = await $.config.set({ key: 'model', value: r.model })
164 if (set.deny !== undefined) throw new Error(set.deny)
165 const id = await $.session.model()
166 if (!id || id === before) throw new Error('the session model did not change')
167 if (plan === mine_) {
168 mine_.modelId = id
169 mine_.modelPending = true
170 }
171 } catch (error) {
172 $.ui.toast(`Decision router: model not switched: ${error instanceof Error ? error.message : String(error)}`)
173 }
174 }
175 }
176 } catch (error) {
177 if (mine === epoch) {
178 plan = null
179 await update($, routedEffort, () => null).catch(() => undefined)
180 $.ui.toast(`Decision router: ${String(error)}; nothing changed`)
181 }
182 }
183 return next(e)
184 })
185
186 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
187 const under = await next(e)
188 if (openBand === null && subBands.length === 0) return under
189 const { Box } = $.ui.resolve(e)
190 return (
191 <Box flexDirection="column">
192 {under}
193 {openBand && band($.ui.resolve(e), openBand.lines, openBand.canSwitch, openBand.answer)}
194 {subBands.map(b => band($.ui.resolve(e), b.lines, b.canSwitch, b.answer, b.id, '◆ Decision router · subagent'))}
195 </Box>
196 )
197 })
198
199 // R5/R6/R17/R19b: a subagent spawned with no explicit model is routed per DECISION_MOD_SUBAGENTS.
200 on('agent.spawn', async ($, e, next) => {
201 if (e.fork || e.isTeammate) return next(e) // forks inherit their model; teammates run no loop here
202 if (e.model !== undefined && e.model !== 'inherit') return next(e) // R5: explicit model, no call
203 const mine = epoch
204 let model: Model | undefined
205 let effort: Effort | undefined
206 let autoToast: string | undefined
207 try {
208 const startedAt = await $.clock.now()
209 const io = fromEngine($)
210 const cfg = await loadConfig(io)
211 if (cfg.subagents === 'off') return next(e) // R6
212 if (cfg.key === '') {
213 if (!idleShown) $.ui.toast('Decision router is idle: DECISION_MOD_KEY is not set')
214 idleShown = true
215 return next(e)
216 }
217 if (cfg.invalid.length > 0 && !invalidShown) $.ui.toast(`Decision router: invalid ${cfg.invalid.join(', ')}, using the default`)
218 invalidShown = true
219 const r = await decide(io, cfg, e.prompt)
220 if (mine !== epoch) return next(e)
221 if (!r.ok) {
222 $.ui.toast(`Decision router: subagent ${r.reason}; nothing changed`) // R20
223 return next(e)
224 }
225 const offer = { model: r.modelConfidence >= cfg.minConfidence, effort: r.effortConfidence >= cfg.minConfidence, effortHeld: false }
226 let apply = false
227 if (cfg.subagents === 'auto') {
228 autoToast = autoLine(r, offer, cfg.minConfidence) // shown once the spawn went through
229 apply = true
230 } else {
231 const remaining = WINDOW_MS - ((await $.clock.now()) - startedAt)
232 if (remaining > 0 && mine === epoch) {
233 const redraw = () => void Promise.resolve($.ui.invalidate('ui.render')).catch(() => undefined)
234 const lines = bandLines(r, offer, cfg.minConfidence)
235 const id = `-sub${++spawnSeq}`
236 if (!offer.model && !offer.effort) {
237 // nothing to ask: the subagent starts at once; the informational band leaves by the mod's timer
238 const info: SubBand = { lines, canSwitch: false, answer: null, id }
239 $.clock.after(remaining, () => {
240 subBands = subBands.filter(b => b !== info)
241 redraw()
242 })
243 subBands.push(info)
244 redraw()
245 } else {
246 const answer = await new Promise<'switch' | 'keep'>(resolve => {
247 let settled = false
248 let timer: { cancel(): void } | undefined
249 const finish = (v: 'switch' | 'keep') => {
250 if (settled) return
251 settled = true
252 timer?.cancel()
253 subBands = subBands.filter(b => b !== own)
254 subSettlers.delete(finish)
255 redraw()
256 resolve(v)
257 }
258 const own: SubBand = { lines, canSwitch: true, answer: finish, id }
259 timer = $.clock.after(remaining, () => finish('keep'))
260 subBands.push(own)
261 subSettlers.add(finish)
262 redraw()
263 })
264 apply = answer === 'switch' && mine === epoch
265 }
266 }
267 }
268 if (apply) {
269 if (offer.model) model = r.model
270 if (offer.effort) effort = r.effort
271 }
272 } catch (error) {
273 if (mine === epoch) $.ui.toast(`Decision router: ${String(error)}; subagent unchanged`)
274 model = undefined
275 effort = undefined
276 }
277 const entry: Awaiting | undefined =
278 effort === undefined ? undefined : { effort, description: e.description, type: e.subagentType, parent: e.agentId, at: await $.clock.now() }
279 if (entry) awaitingId.push(entry)
280 const drop = () => void (awaitingId = awaitingId.filter(w => w !== entry))
281 let result
282 try {
283 result = await next(model !== undefined ? { ...e, model } : e)
284 } catch (error) {
285 drop()
286 throw error
287 }
288 if (result.deny !== undefined) {
289 drop()
290 return result
291 }
292 if (autoToast !== undefined) $.ui.toast(autoToast)
293 if (result.agentId !== undefined) {
294 drop() // known now: the roster fallback is not needed for this entry
295 knownIds.add(result.agentId)
296 // ponytail: subEffort entries are removed at the agent's turn.complete; an agent that never completes keeps one small entry until session.start
297 if (entry && mine === epoch && !subEffort.has(result.agentId)) subEffort.set(result.agentId, entry.effort)
298 }
299 return result
300 })
301
302 on('turn.complete', async ($, e, next) => {
303 if (e.agentId !== undefined) {
304 subEffort.delete(e.agentId)
305 knownIds.delete(e.agentId)
306 }
307 return next(e)
308 })
309
310 // R19c: model on the first request, effort on every main-loop request while the override holds. No decision call here (R24).
311 on('turn.step', async function* ($, e, next) {
312 if (e.agentId !== undefined) {
313 // A subagent's step only gets its own effort; it never touches the main override or baseline (R19b).
314 let level = subEffort.get(e.agentId)
315 if (level === undefined && awaitingId.length > 0 && !knownIds.has(e.agentId)) {
316 // The spawn hook has not got its agentId yet (race). Apply an effort only when it is unambiguous:
317 // one live entry and one roster candidate (same description, type and parent) not already accounted for.
318 const now = await $.clock.now()
319 awaitingId = awaitingId.filter(w => now - w.at <= AWAIT_MS)
320 const roster = await $.agent.list().catch(() => [])
321 const info = roster.find(a => a.id === e.agentId)
322 if (info !== undefined) {
323 const same = (w: Awaiting) => w.description === info.description && (w.type === undefined || w.type === info.type) && w.parent === info.parentId
324 const entries = awaitingId.filter(same)
325 const candidates = roster.filter(a => a.description === info.description && !knownIds.has(a.id) && !subEffort.has(a.id) && (a.parentId === info.parentId))
326 if (entries.length === 1 && candidates.length === 1) {
327 level = entries[0].effort
328 subEffort.set(e.agentId, level)
329 knownIds.add(e.agentId)
330 awaitingId = awaitingId.filter(w => w !== entries[0])
331 }
332 }
333 }
334 return yield* next(level !== undefined && e.effort !== undefined ? { ...e, effort: level } : e)
335 }
336 if (plan === null) return yield* next(e)
337 let out = e
338 // the user may have picked in /mode during the hold: that choice wins, drop the model switch
339 if (plan.modelPending && e.index === 0 && (await manualSet($))) {
340 plan.modelPending = false
341 plan.modelId = undefined
342 }
343 if (plan.modelPending && e.index === 0) {
344 plan.modelPending = false
345 out = { ...out, model: plan.modelId as string } // request 0 carries the resolved id, never the alias
346 }
347 if (plan.effort !== undefined) {
348 // Our hook sits above `mode`, so e.effort is the engine's own level, whatever we wrote below on earlier steps.
349 // Any change of it on the same model is the user's own /effort, newer than ours.
350 if (e.model !== seenModel) seenEffort = undefined // another model (our /model): a new baseline
351 seenModel = e.model
352 let drop = (await manualSet($)) || (await modeHoldsEffort($))
353 if (isEffort(e.effort)) {
354 if (seenEffort !== undefined && e.effort !== seenEffort) drop = true
355 seenEffort = e.effort
356 }
357 if (drop) {
358 plan.effort = undefined
359 await update($, routedEffort, () => null).catch(() => undefined)
360 } else if (e.effort !== undefined) out = { ...out, effort: plan.effort }
361 }
362 return yield* next(out)
363 })
364}
365hooks/band.tsx 50 lines1import type { Decision } from './decide'
2
3export type Offer = { model: boolean; effort: boolean; effortHeld: boolean; modelCurrent?: boolean }
4
5const pct = (c: number) => `${Math.round(c * 100)}%`
6
7// The band's lines: every value shows its confidence (R13); a value under the threshold is "kept" with that confidence (R14).
8export function bandLines(d: Decision, o: Offer, min: number): string[] {
9 const kept = (what: string, name: string, c: number) => `${what} kept: ${name} suggested at ${pct(c)}, below ${pct(min)}`
10 return [
11 o.modelCurrent
12 ? `Model: ${d.model} (current) · confidence ${pct(d.modelConfidence)}`
13 : o.model
14 ? `Suggested model: ${d.model} · confidence ${pct(d.modelConfidence)}`
15 : kept('Model', d.model, d.modelConfidence),
16 o.effort
17 ? `Suggested effort: ${d.effort} · confidence ${pct(d.effortConfidence)}`
18 : o.effortHeld
19 ? `Effort kept: you already chose one in /mode (${d.effort} at ${pct(d.effortConfidence)})`
20 : kept('Effort', d.effort, d.effortConfidence),
21 ]
22}
23
24// R17 auto: one toast, no band, naming what was applied or kept with the confidences.
25export function autoLine(d: Decision, o: Offer, min: number): string {
26 const part = (what: string, name: string, c: number, applied: boolean) =>
27 applied ? `${what} ${name} applied (${pct(c)})` : `${what} kept (${name} suggested at ${pct(c)}, below ${pct(min)})`
28 return `Decision router, subagent: ${part('model', d.model, d.modelConfidence, o.model)}; ${part('effort', d.effort, d.effortConfidence, o.effort)}`
29}
30
31type Ui = { Box: any; Text: any; Button: any }
32export function band({ Box, Text, Button }: Ui, lines: string[], canSwitch: boolean, answer: ((v: 'switch' | 'keep') => void) | null, id = '', title = '◆ Decision router') {
33 return (
34 <Box key={`decision-router-band${id}`} flexDirection="column" paddingX={1}>
35 <Text bold color="claude">
36 {title}
37 </Text>
38 {lines.map(l => (
39 <Text key={l}>{l}</Text>
40 ))}
41 {answer !== null && (
42 <Box gap={2}>
43 {canSwitch && <Button key={`switch${id}`} label="Switch" onPress={() => answer('switch')} />}
44 <Button key={`keep${id}`} label="Keep" onPress={() => answer('keep')} />
45 </Box>
46 )}
47 </Box>
48 )
49}
50hooks/config.ts 67 lines1import type { IO } from './io'
2
3export const SUBAGENT_MODES = ['ask', 'auto', 'off'] as const
4export type Config = {
5 key: string
6 url: string
7 model: string
8 minConfidence: number
9 subagents: (typeof SUBAGENT_MODES)[number]
10 invalid: string[] // names of optional variables whose value was rejected (R2)
11}
12
13export async function loadConfig(io: Pick<IO, 'env'>): Promise<Config> {
14 const get = async (name: string) => {
15 // a failing env read counts as unset, so the worst case is an idle mod (no key)
16 let v: string | undefined
17 try {
18 v = (await io.env(name))?.trim()
19 } catch {}
20 return v === '' ? undefined : v
21 }
22 const invalid: string[] = []
23 const rawMin = await get('DECISION_MOD_MIN_CONFIDENCE')
24 let minConfidence = 0.6
25 if (rawMin !== undefined) {
26 const n = Number(rawMin)
27 if (Number.isFinite(n) && n >= 0 && n <= 1) minConfidence = n
28 else invalid.push('DECISION_MOD_MIN_CONFIDENCE')
29 }
30 const rawSub = await get('DECISION_MOD_SUBAGENTS')
31 let subagents: Config['subagents'] = 'ask'
32 if (rawSub !== undefined) {
33 if ((SUBAGENT_MODES as readonly string[]).includes(rawSub)) subagents = rawSub as Config['subagents']
34 else invalid.push('DECISION_MOD_SUBAGENTS')
35 }
36 // R2: https only; plain http just for the local machine
37 const DEFAULT_URL = 'https://openrouter.ai/api'
38 let url = DEFAULT_URL
39 const rawUrl = await get('DECISION_MOD_URL')
40 if (rawUrl !== undefined) {
41 const clean = normalizedUrl(rawUrl)
42 if (clean !== undefined) url = clean
43 else invalid.push('DECISION_MOD_URL')
44 }
45 return {
46 key: (await get('DECISION_MOD_KEY')) ?? '',
47 url: url.replace(/\/+$/, ''),
48 model: (await get('DECISION_MOD_MODEL')) ?? 'typesafe/jev-1.13',
49 minConfidence,
50 subagents,
51 invalid,
52 }
53}
54
55// R2: https, or http for the local machine only; no userinfo, query or fragment.
56// Returns the parser's own normalized form, so the string validated is the string fetched.
57function normalizedUrl(raw: string): string | undefined {
58 try {
59 const u = new URL(raw)
60 if (u.username !== '' || u.password !== '' || u.search !== '' || u.hash !== '' || /[?#@]/.test(raw)) return undefined
61 const ok = u.protocol === 'https:' ? u.hostname !== '' : u.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(u.hostname)
62 return ok ? (u.origin + u.pathname).replace(/\/+$/, '') : undefined
63 } catch {
64 return undefined
65 }
66}
67hooks/decide.ts 75 lines1import type { Effort, Model } from '../types'
2import type { Config } from './config'
3import type { IO } from './io'
4import { EFFORTS, MODELS, QUESTIONS } from './questions'
5
6export const EXCERPT_CHARS = 5000
7export const TIMEOUT_MS = 3000
8
9export type Decision = { model: Model; modelConfidence: number; effort: Effort; effortConfidence: number }
10export type Result = ({ ok: true } & Decision) | { ok: false; reason: string }
11
12const clamp = (n: number) => Math.min(1, Math.max(0, n))
13// Probabilities in the given key order: every value finite and >= 0, sum > 0; normalized by the sum.
14const probs = (p: unknown, keys: string[]): number[] | undefined => {
15 if (typeof p !== 'object' || p === null) return undefined
16 const v = keys.map(k => (p as Record<string, unknown>)[k])
17 if (!v.every(x => typeof x === 'number' && Number.isFinite(x) && x >= 0)) return undefined
18 const sum = (v as number[]).reduce((s, x) => s + x, 0)
19 return sum > 0 && Number.isFinite(sum) ? (v as number[]).map(x => x / sum) : undefined
20}
21
22// R9: Choice confidence from the peak probability over N options.
23function choiceConfidence(p: number[]) {
24 return clamp((p.length * Math.max(...p) - 1) / (p.length - 1))
25}
26// R9: Score confidence from the probability-weighted distance to the peak level.
27function scoreConfidence(p: number[]) {
28 const m = p.indexOf(Math.max(...p))
29 const spread = p.reduce((s, v, i) => s + v * Math.abs(i - m), 0)
30 const even = p.reduce((s, _, i) => s + Math.abs(i - (p.length - 1) / 2), 0) / p.length
31 return clamp(1 - spread / even)
32}
33
34export function parse(body: unknown): Result {
35 const a = (body as any)?.answers
36 const m = a?.model
37 const e = a?.effort
38 const names = Object.keys(MODELS) as Model[]
39 const mp = probs(m?.probabilities, names)
40 const ep = probs(e?.probabilities, EFFORTS.map((_, i) => String(i)))
41 if (m?.type !== 'choice' || e?.type !== 'score' || !mp || !ep) return { ok: false, reason: 'malformed answer' }
42 const model = names.includes(m.choice) ? (m.choice as Model) : names[mp.indexOf(Math.max(...mp))]
43 const effort = EFFORTS[ep.indexOf(Math.max(...ep))]
44 const own = (c: unknown) => (typeof c === 'number' && Number.isFinite(c) ? clamp(c) : undefined)
45 return { ok: true, model, modelConfidence: own(m.confidence) ?? choiceConfidence(mp), effort, effortConfidence: own(e.confidence) ?? scoreConfidence(ep) }
46}
47
48// One POST, no retry (R20). Every failure becomes { ok: false, reason }; nothing throws.
49export async function decide(io: Pick<IO, 'fetch' | 'after'>, cfg: Config, text: string): Promise<Result> {
50 if (cfg.key === '') return { ok: false, reason: 'no key' }
51 try {
52 const request = (async (): Promise<Result> => {
53 const r = await io.fetch(`${cfg.url}/v1/systemone`, {
54 method: 'POST',
55 headers: { 'content-type': 'application/json', authorization: `Bearer ${cfg.key}` },
56 body: JSON.stringify({ model: cfg.model, state: { prompt: text.slice(0, EXCERPT_CHARS) }, questions: QUESTIONS }),
57 })
58 if (r.status < 200 || r.status > 299) return { ok: false, reason: `HTTP ${r.status}` }
59 return parse(JSON.parse(r.text))
60 })().catch((): Result => ({ ok: false, reason: 'request failed' }))
61 // $.http.fetch has no timeout/abort: race a timer; a late response is ignored (R21).
62 let timer: { cancel(): void } | undefined
63 const timeout = new Promise<Result>(res => {
64 timer = io.after(TIMEOUT_MS, () => res({ ok: false, reason: 'timeout' }))
65 })
66 try {
67 return await Promise.race([request, timeout])
68 } finally {
69 timer?.cancel()
70 }
71 } catch {
72 return { ok: false, reason: 'request failed' }
73 }
74}
75hooks/io.ts 26 lines1import type { EngineInterface } from 'claude-code'
2
3// The only seam to the engine used by config/decide, so both stay pure and testable.
4export type IO = {
5 env: (name: string) => Promise<string | undefined>
6 fetch: (url: string, init: { method: 'POST'; headers: Record<string, string>; body: string }) => Promise<{ status: number; text: string }>
7 after: (ms: number, cb: () => void) => { cancel(): void }
8}
9
10// Engine adapter for the hook slices; exercised there (a test hook cannot call $.env/$.http itself).
11export function fromEngine($: EngineInterface): IO {
12 // $.env.get takes literal names only (the engine lists a module's variables), so one thunk per name
13 const vars: Record<string, () => Promise<string | undefined>> = {
14 DECISION_MOD_URL: () => $.env.get('DECISION_MOD_URL'),
15 DECISION_MOD_KEY: () => $.env.get('DECISION_MOD_KEY'),
16 DECISION_MOD_MODEL: () => $.env.get('DECISION_MOD_MODEL'),
17 DECISION_MOD_MIN_CONFIDENCE: () => $.env.get('DECISION_MOD_MIN_CONFIDENCE'),
18 DECISION_MOD_SUBAGENTS: () => $.env.get('DECISION_MOD_SUBAGENTS'),
19 }
20 return {
21 env: async name => vars[name]?.(),
22 fetch: (url, init) => $.http.fetch(url, init),
23 after: (ms, cb) => $.clock.after(ms, cb),
24 }
25}
26hooks/questions.ts 30 lines1// Question wording sent to the decision model (English, one place). Wording from the 2026-10-14 probe.
2export const MODELS = {
3 haiku: 'Claude Haiku: fastest and cheapest. Fits mechanical, well-specified edits in one place (rename, typo, small config change, a quick lookup).',
4 sonnet: 'Claude Sonnet: balanced. Fits routine feature work, bug fixes with a clear cause, writing tests, refactors inside one module.',
5 opus: 'Claude Opus: strongest and most expensive. Fits ambiguous or high-stakes work: architecture across many components, hard concurrency or distributed bugs, security-sensitive design.',
6}
7
8export const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] as const
9
10const EFFORT_CRITERIA = [
11 'low: the answer is obvious once the right file is open',
12 'medium: a few steps of reasoning, one area of the code',
13 'high: several interacting parts must be understood before acting',
14 'xhigh: deep investigation with many hypotheses or a long multi-step plan',
15 'max: open research problem where every extra step of thinking pays off',
16]
17
18export const QUESTIONS = {
19 model: {
20 type: 'choice',
21 instructions: 'Which Claude model best fits the coding task in `prompt`, balancing quality and cost?',
22 criteria: MODELS,
23 },
24 effort: {
25 type: 'score',
26 instructions: 'How much reasoning effort does the coding task in `prompt` need?',
27 criteria: EFFORT_CRITERIA,
28 },
29}
30types/index.d.ts 12 lines1export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
2export type Model = 'haiku' | 'sonnet' | 'opus'
3
4declare module 'claude-code' {
5 interface PluginState {
6 'decision-router': {
7 // the effort the router imposes on every main-loop request while its override holds; null = none (read by `mode`)
8 effort: Effort | null
9 }
10 }
11}
12