SLOPSHOPPER

decision-router

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

newbandtoastpromptnetworktimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · decision-router
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ decision-router │ ⏺ Read(src/auth.ts) │ Decision router is idle: DECISION_MOD_KEY │ ⎿ Read 6 lines │ is not set │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

decision-router

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.

Read this before you install: your prompt leaves your machine

  • The first 5000 characters of your first prompt of a session, and of each routed subagent prompt, are sent as typed, with no secret masking, to the host of 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.
  • For subagents the excerpt is sent before the band appears (ask) or with no band at all (auto).
  • Nothing is sent while DECISION_MOD_KEY is unset. The mod then shows one notice per session and stays idle.
  • The first prompt of a session waits for your answer in a confirmation dialog (Switch or Keep) with no time limit; closing the dialog, or typing free text under "Other", counts as Keep. Each ask subagent spawn may be held up to 10 seconds (the decision call included) for its confirmation band.

What it does

  • On the first non-slash prompt of a new session (not a resumed one) it asks the decision model which model and effort fit. Later prompts of the session trigger no call.
  • A dialog shows the suggested model and effort, each with its confidence, and holds the prompt until you answer. Switch applies them to the current request. Switch also makes the chosen model your default for new sessions, as /model does; change it back with /model. Keep, closing the dialog, or a non-interactive run (-p) keeps everything as it was.
  • A value whose confidence is under 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.
  • The effort is applied per request. The mod never runs /effort; the only setting it writes is the default model, and only when you press Switch.
  • A call that fails or takes longer than 3 s keeps the current model and effort and shows a toast.
  • Subagents follow 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.

Configuration

Set these as environment variables before starting Claude Code.

VariableDefaultMeaning
DECISION_MOD_KEYnone (required)API key for the decision endpoint. Unset means the mod is idle and sends nothing.
DECISION_MOD_URLhttps://openrouter.ai/apiDecision endpoint. https is required; http is accepted only for localhost.
DECISION_MOD_MODELtypesafe/jev-1.13Decision model. The confidence threshold is tuned per model version; re-check DECISION_MOD_MIN_CONFIDENCE if you change it.
DECISION_MOD_MIN_CONFIDENCE0.6Minimum confidence (0 to 1) for a value to be offered.
DECISION_MOD_SUBAGENTSaskask, auto or off.

An invalid value falls back to its default, with one notice per session.

With mode

Works 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.

Install

/plugin marketplace add danielmalka/malka-claude-mods
/plugin install decision-router@malka-claude-mods

Uninstall

/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.

Development

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.


Português

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.

Leia antes de instalar: seu prompt sai da sua máquina

  • Os primeiros 5000 caracteres do primeiro prompt de uma sessão, e do prompt de cada subagente roteado, são enviados exatamente como digitados, sem mascarar segredos, ao host de 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.
  • Para subagentes o trecho é enviado antes de a faixa aparecer (ask) ou sem faixa nenhuma (auto).
  • Nada é enviado enquanto DECISION_MOD_KEY não estiver definida. O mod mostra um aviso por sessão e fica ocioso.
  • O primeiro prompt de uma sessão espera sua resposta em um diálogo de confirmação (Switch ou Keep) sem limite de tempo; fechar o diálogo, ou digitar texto livre em "Other", conta como Keep. Cada spawn de subagente em ask pode ficar retido até 10 segundos (a chamada de decisão incluída) esperando sua faixa de confirmação.

O que faz

  • No primeiro prompt que não seja comando / 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.
  • Um diálogo mostra o modelo e o esforço sugeridos, cada um com sua confiança, e retém o prompt até você responder. Switch aplica à requisição atual. Switch também torna o modelo escolhido o seu padrão para novas sessões, como o /model faz; volte com /model. Keep, fechar o diálogo ou uma execução não interativa (-p) mantém tudo como estava.
  • Um valor com confiança abaixo de 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.
  • O esforço é aplicado por requisição. O mod nunca roda /effort; a única configuração que grava é o modelo padrão, e só quando você aperta Switch.
  • Chamada que falha ou passa de 3 s mantém modelo e esforço atuais e mostra um aviso.
  • Subagentes seguem 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.
  • Cuidado com 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.

Configuração

Defina como variáveis de ambiente antes de iniciar o Claude Code.

VariávelPadrãoSignificado
DECISION_MOD_KEYnenhum (obrigatória)Chave de API do endpoint de decisão. Sem ela o mod fica ocioso e não envia nada.
DECISION_MOD_URLhttps://openrouter.ai/apiEndpoint de decisão. https é obrigatório; http só para localhost.
DECISION_MOD_MODELtypesafe/jev-1.13Modelo de decisão. O limiar de confiança é calibrado por versão do modelo; revise DECISION_MOD_MIN_CONFIDENCE se trocar.
DECISION_MOD_MIN_CONFIDENCE0.6Confiança mínima (0 a 1) para um valor ser oferecido.
DECISION_MOD_SUBAGENTSaskask, auto ou off.

Um valor inválido volta ao padrão, com um aviso por sessão.

Com o mode

Funciona 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.

Source 7 files
hooks/register.tsx 365 lines
1import { 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}
365
hooks/band.tsx 50 lines
1import 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}
50
hooks/config.ts 67 lines
1import 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}
67
hooks/decide.ts 75 lines
1import 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}
75
hooks/io.ts 26 lines
1import 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}
26
hooks/questions.ts 30 lines
1// 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}
30
types/index.d.ts 12 lines
1export 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