SLOPSHOPPER

Triage Gate

Triages each message with a small model before the main model runs, and asks (native modal) whether to switch model/effort when another one fits better and the…

newbandrowscommandtoastprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · triage-gate
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ 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 › /triage-gate ⎿ triage-gate: Triagem antes da mensagem: ligada, modal em todas as mensagens, sem advisor. Use /triage-gate on | off | modal ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Modelo em uso Opus 5.5 [ Minimizar ] │ │ │ │ Triagem: Passou sem triagem · O classificador não respondeu a tempo. │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Modelo em uso Opus 5.5 [ Minimizar ] │ │ │ │ Triagem: Passou sem triagem · O classificador não respondeu a tempo. │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

triage-gate

🇧🇷 Leia em português

A Claude Code mod that triages every message before it reaches your main model.

  1. A Haiku call classifies which model and effort the message needs.
  2. If that differs from your current model and switching pays off (cache is per model, so it weighs context size and the 1h cache TTL), the native AskUserQuestion modal opens with a summary box: switch, send anyway, or cancel and edit.
  3. A switch applies to that turn (turn.step) and is saved with /model at turn.complete.
  4. When you have an advisorModel configured, upward suggestions are skipped (the advisor already covers hard moments).
  5. A band above the prompt shows the real model/effort in use and why the last message was routed that way. It can be minimized (the choice is saved in $.store).

Install

claude plugin marketplace add StellaLunaSousa/triage-gate
claude plugin install triage-gate@triage-gate

Inside a session, run /reload-plugins (or restart) to load it.

Update

claude plugin marketplace update triage-gate
claude plugin update triage-gate@triage-gate

Auto-update is off by default for any marketplace. To enable it: /plugin → Marketplaces → triage-gate → Enable auto-update. You only receive a new copy when version in plugin.json changes.

Use

/triage-gate with one of:

| Argument | Effect | | :- | :- | | on / off | Turn the triage on or off | | modal | Open the modal on every divergent message (default) | | band | Hold the message in the band; the modal opens only when the message has images | | why | Print the last triage decision | | demo [1\|2] | Preview the modal (1 = one tier apart, 2 = two tiers apart) |

Very short messages, commands and continuations ("ok", "go ahead"…) skip the triage.

Limitations

  • Model IDs are hardcoded in MODEL_IDS (claude-haiku-5-5, claude-sonnet-5-5, claude-opus-5-5). New model generations need a code change.
  • Price table is hardcoded (reference: claude-api docs, 2026-10-06). Haiku's cache-read price is not listed there, so it is assumed to be 0.1x input. The cost estimate is a heuristic, not billing data.
  • UI text is in Brazilian Portuguese (modal, band, toasts, classifier prompt).
  • The app's model picker does not follow the switch; it can keep showing the previous model. The band shows the real one.
  • A /effort notice bubble appears after an automatic switch.
  • Tested only on the Claude Code Desktop app (Windows), v2.1.29x.

Development

git clone https://github.com/StellaLunaSousa/triage-gate
cd triage-gate
claude plugin validate .
claude plugin validate ./plugins/triage-gate

The generated type declarations (plugins/triage-gate/.claude-plugin/types/) are not committed because they depend on your Claude Code version. See Get the types for your build, then run:

claude plugin test ./plugins/triage-gate

To try local changes: claude --plugin-dir ./plugins/triage-gate.

Uninstall

claude plugin uninstall triage-gate@triage-gate
claude plugin marketplace remove triage-gate

License

MIT

Source 2 files
hooks/register.tsx 617 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Decision, Effort, Pending, Tier } from '../types'
5
6const pending = atom({ plugin: 'triage-gate', key: 'pending' } as const, null)
7const asking = atom({ plugin: 'triage-gate', key: 'asking' } as const, null)
8const override = atom({ plugin: 'triage-gate', key: 'override' } as const, null)
9const isOn = atom({ plugin: 'triage-gate', key: 'isOn' } as const, true)
10const isModal = atom({ plugin: 'triage-gate', key: 'isModal' } as const, true)
11const shownModel = atom({ plugin: 'triage-gate', key: 'shownModel' } as const, '')
12const shownEffort = atom({ plugin: 'triage-gate', key: 'shownEffort' } as const, '')
13const decision = atom({ plugin: 'triage-gate', key: 'decision' } as const, { verdict: 'Nenhuma mensagem avaliada ainda' })
14const isBandOpen = atom({ plugin: 'triage-gate', key: 'isBandOpen' } as const, true)
15
16export const TIERS: readonly Tier[] = ['haiku', 'sonnet', 'opus']
17
18export type Verdict = { family: Tier; effort: Effort; reason: string }
19
20export type Choice = 'switch' | 'send' | 'cancel'
21
22const EFFORT_PT: Record<Effort, string> = { low: 'baixo', medium: 'médio', high: 'alto' }
23
24const CONTINUATION = /^(sim|n[ãa]o|ok|pode|continu\w*|segu\w*|faz|vai|isso|beleza|certo|quero|bora|valeu|obrigad\w+|manda|tenta|vamos|testa|teste)\b/i
25
26export function isSkippable(text: string): boolean {
27  const trimmed = text.trim()
28  return trimmed.length < 12 || trimmed.startsWith('/') || (trimmed.length < 60 && CONTINUATION.test(trimmed))
29}
30
31export function familyOf(modelId: string): Tier | null {
32  const family = /(haiku|sonnet|opus)/i.exec(modelId)?.[1]?.toLowerCase()
33  return family === undefined ? null : (family as Tier)
34}
35
36export function capitalize(text: string): string {
37  return text.charAt(0).toUpperCase() + text.slice(1)
38}
39
40const MODEL = /^(?:claude[-\s])?(haiku|sonnet|opus|fable)(?:[-\s]?(\d{1,2})(?:[-.\s](\d{1,2}))?)?/i
41
42export function prettyModel(raw: string): string {
43  const match = MODEL.exec(raw.trim())
44  if (match === null) return raw.trim()
45  const version = match[2] === undefined ? '' : match[3] === undefined ? match[2] : `${match[2]}.${match[3]}`
46  const family = capitalize(match[1]!.toLowerCase())
47  return version === '' ? family : `${family} ${version}`
48}
49
50// turn.step rejects the bare alias ("haiku não está disponível"); it needs the full id.
51const MODEL_IDS: Record<Tier, string> = { haiku: 'claude-haiku-5-5', sonnet: 'claude-sonnet-5-5', opus: 'claude-opus-5-5' }
52
53export function modelIdFor(tier: Tier): string {
54  return MODEL_IDS[tier]
55}
56
57export function modelNotice(text: string | undefined): string | null {
58  const name = /Set model to `([^`]+)`/.exec(text ?? '')?.[1]
59  return name === undefined ? null : `Modelo da sessão: ${name}`
60}
61
62export function effortNotice(text: string | undefined): string | null {
63  const level = /Set effort level to (\w+)/.exec(text ?? '')?.[1]
64  return level === undefined ? null : `Esforço da sessão: ${level in EFFORT_PT ? EFFORT_PT[level as Effort] : level}`
65}
66
67const EFFORT_LABEL: Record<string, string> = { ...EFFORT_PT, xhigh: 'extra alto', max: 'máximo' }
68
69export function effortLabel(raw: unknown): string {
70  if (typeof raw === 'number') return String(raw)
71  if (typeof raw !== 'string' || raw === '') return ''
72  return EFFORT_LABEL[raw.toLowerCase()] ?? raw
73}
74
75export function effortFromOutput(text: string | undefined): string | null {
76  return /Set effort level to (\w+)/.exec(text ?? '')?.[1]?.toLowerCase() ?? null
77}
78
79export function effortWord(effort: Effort): string {
80  return EFFORT_PT[effort]
81}
82
83const LIMIT = 1500
84
85export function buildPrompt(text: string, lastReply: string): string {
86  const context = lastReply.trim() === '' ? '' : `Última resposta do assistente (contexto, cortada): """${lastReply.trim().slice(0, 300)}"""\n`
87  return [
88    'Escolha o modelo mais barato que dá conta da mensagem do usuário.',
89    'haiku: perguntas simples, pesquisa, textos, docs, planilhas, mensagens, uso do browser, edição pontual de código.',
90    'sonnet: código com várias etapas (rodar, testar, iterar), features, bugs.',
91    'opus: arquitetura, debug difícil, refactors grandes, planejamento, revisão crítica.',
92    'esforço: low (trivial), medium (padrão), high (várias etapas ou armadilhas).',
93    'Responda numa linha só, neste formato exato: modelo | esforço | motivo em até 8 palavras, em português.',
94    'Exemplo: haiku | low | pergunta simples de clima',
95    context + `Mensagem do usuário: """${text.trim().slice(0, LIMIT)}"""`,
96  ].join('\n')
97}
98
99export function parseVerdict(reply: string): Verdict | null {
100  const match = /^\s*(haiku|sonnet|opus)\s*\|\s*(low|medium|high)\s*\|\s*(.+?)\s*$/im.exec(reply)
101  if (match === null) return null
102  return { family: match[1]!.toLowerCase() as Tier, effort: match[2]!.toLowerCase() as Effort, reason: capitalize(match[3]!) }
103}
104
105export function gapOf(current: Tier, family: Tier): number {
106  return Math.abs(TIERS.indexOf(current) - TIERS.indexOf(family))
107}
108
109export type Price = { input: number; output: number; read: number }
110
111// $/MTok from the claude-api reference (cached 2026-10-06); Haiku's cache read is not listed, assumed 0.1x input.
112const PRICES: Record<Tier, Price> = {
113  haiku: { input: 0.1, output: 0.5, read: 0.01 },
114  sonnet: { input: 2, output: 10, read: 0.2 },
115  opus: { input: 4, output: 20, read: 0.2 },
116}
117const HAIKU_LONG: Price = { input: 0.5, output: 2.5, read: 0.05 }
118const HAIKU_LONG_FROM = 100_000
119// Claude Code writes with the 1-hour TTL, billed at 2x input.
120const WRITE = 2
121export const CACHE_TTL_MS = 60 * 60 * 1000
122const REPLY_TOKENS = 2000
123const PAYBACK_TURNS = 3
124
125export function priceFor(tier: Tier, tokens: number): Price {
126  return tier === 'haiku' && tokens > HAIKU_LONG_FROM ? HAIKU_LONG : PRICES[tier]
127}
128
129export function turnCost(tier: Tier, tokens: number, isWarm: boolean): number {
130  const price = priceFor(tier, tokens)
131  return (tokens * (isWarm ? price.read : price.input * WRITE) + REPLY_TOKENS * price.output) / 1_000_000
132}
133
134export type Economics = { isDown: boolean; isWorth: boolean; ratio: number; saving: number; payback: number }
135
136export function economicsOf(current: Tier, target: Tier, tokens: number, isWarm: boolean): Economics {
137  const stay = turnCost(current, tokens, isWarm)
138  const change = turnCost(target, tokens, false)
139  const saving = turnCost(current, tokens, true) - turnCost(target, tokens, true)
140  const extra = change - stay
141  const payback = extra <= 0 ? 0 : saving > 0 ? Math.ceil(extra / saving) : Infinity
142  return {
143    isDown: TIERS.indexOf(target) < TIERS.indexOf(current),
144    isWorth: payback <= PAYBACK_TURNS,
145    ratio: stay > 0 ? change / stay : 1,
146    saving: saving / turnCost(current, tokens, true),
147    payback,
148  }
149}
150
151export function costNote(econ: Economics, tokens: number): string | null {
152  const size = `~${Math.round(tokens / 1000)}K tokens`
153  if (econ.isDown) {
154    const pct = Math.round(econ.saving * 100)
155    return econ.payback === 0
156      ? `Contexto de ${size}: já fica ~${pct}% mais barato nesta mensagem.`
157      : `Contexto de ${size}: compensa em ${econ.payback} mensagens, depois ~${pct}% mais barato.`
158  }
159  return econ.ratio >= 3 ? `Contexto de ${size}: esta troca custa ~${Math.round(econ.ratio)}× mais nesta mensagem.` : null
160}
161
162const ADVISOR_RANK: Record<string, number> = { haiku: 0, sonnet: 1, opus: 2, fable: 3 }
163
164export function advisorRank(raw: unknown): number | null {
165  if (typeof raw !== 'string') return null
166  const family = /(haiku|sonnet|opus|fable)/i.exec(raw)?.[1]?.toLowerCase()
167  return family === undefined ? null : ADVISOR_RANK[family]!
168}
169
170export function advisorCovers(advisor: unknown, current: Tier, target: Tier): boolean {
171  const rank = advisorRank(advisor)
172  if (rank === null) return false
173  const to = TIERS.indexOf(target)
174  return to > TIERS.indexOf(current) && rank >= to && rank >= TIERS.indexOf(current)
175}
176
177export function asDecision(raw: unknown): Decision {
178  if (typeof raw === 'string') return { verdict: raw }
179  if (typeof raw === 'object' && raw !== null && typeof (raw as Decision).verdict === 'string') return raw as Decision
180  return { verdict: 'Nenhuma mensagem avaliada ainda' }
181}
182
183export function decisionText(d: Decision): string {
184  return [d.verdict, d.route === undefined ? '' : `${d.route}${d.reason === undefined ? '' : ` (${d.reason})`}`, d.detail ?? '']
185    .filter(part => part !== '')
186    .join(' · ')
187}
188
189export function switchedText(p: Pending): string {
190  return `Modelo alterado para ${capitalize(p.family)} com esforço ${effortWord(p.effort)}. O seletor do app pode continuar mostrando o anterior.`
191}
192
193export function failedText(p: Pending): string {
194  return `Não foi possível alterar para ${capitalize(p.family)}. Troque pelo seletor do app.`
195}
196
197type Palette = { border: string; fill: string; accent: string; text: string; muted: string }
198
199const CAUTION: Palette = { border: '#d97706', fill: '#3b1d06', accent: '#fbbf24', text: '#fffbeb', muted: '#fcd34d' }
200const BIG: Palette = { border: '#dc2626', fill: '#3b0a0a', accent: '#f87171', text: '#fef2f2', muted: '#fca5a5' }
201
202export function paletteFor(gap: number): Palette {
203  return gap >= 2 ? BIG : CAUTION
204}
205
206export function statusFor(gap: number): string {
207  return gap >= 2 ? '❚❚ Aguardando você' : '⇄ Troca sugerida'
208}
209
210export function headerFor(p: Pending): string {
211  return `${p.gap >= 2 ? '❚❚' : '⇄'} Triagem`
212}
213
214export function labelsFor(p: Pending): [string, string, string] {
215  return [`Trocar para ${capitalize(p.family)} e enviar`, 'Enviar mesmo assim', 'Cancelar e editar']
216}
217
218export function choiceOf(answer: string, p: Pending): Choice | null {
219  const [change, send, cancel] = labelsFor(p)
220  return answer === change ? 'switch' : answer === send ? 'send' : answer === cancel ? 'cancel' : null
221}
222
223export function questionFor(p: Pending) {
224  const [change, send, cancel] = labelsFor(p)
225  const family = capitalize(p.family)
226  const preview = [
227    `Atual:         ${p.current}`,
228    `Recomendado:   ${family}`,
229    `Esforço:       ${effortWord(p.effort)}`,
230    `Motivo:        ${p.reason}`,
231  ].join('\n')
232  return {
233    header: headerFor(p),
234    question: 'Como seguir com esta mensagem?',
235    multiSelect: false,
236    options: [
237      { label: change, description: `Passa para ${family} com esforço ${effortWord(p.effort)}.`, preview },
238      { label: send, description: `Segue com ${p.current}, sem trocar.` },
239      {
240        label: cancel,
241        description: p.hasImages
242          ? 'O texto volta para a caixa; as imagens ficam na conversa acima, é só copiar.'
243          : 'A mensagem volta para a caixa de texto.',
244      },
245    ],
246  }
247}
248
249async function refreshModel($: EngineInterface): Promise<void> {
250  try {
251    const name = prettyModel(String(await $.session.model()))
252    await update($, shownModel, () => name)
253  } catch {
254    return
255  }
256}
257
258async function readAdvisor($: { settings: { read: () => Promise<unknown> } }): Promise<unknown> {
259  try {
260    return ((await $.settings.read()) as Record<string, unknown>).advisorModel
261  } catch {
262    return undefined
263  }
264}
265
266export const register: Register = on => {
267  // 0 = unknown (fresh load): treated as a warm cache, which only makes downgrades rarer.
268  let lastTurnAt = 0
269  // Set synchronously right before $.ui.ask so the AskUserQuestion render never reads a stale atom.
270  let askedNow: Pending | null = null
271
272  on('session.start', async ($, e, next) => {
273    await $.command.register({
274      name: 'triage-gate',
275      description: 'Pre-message model triage: on | off | modal | band | why | demo [1|2].',
276      argumentHint: 'on | off | modal | band | why | demo [1|2]',
277    })
278    const result = await next(e)
279    await refreshModel($)
280    const saved = await $.store.get('isBandOpen')
281    if (typeof saved === 'boolean') await update($, isBandOpen, () => saved)
282    return result
283  })
284
285  on('command.run', { command: 'triage-gate' }, async ($, e) => {
286    const arg = e.args.trim().toLowerCase()
287    if (arg === 'on' || arg === 'off') await update($, isOn, () => arg === 'on')
288    if (arg === 'off') await update($, pending, () => null)
289    if (arg === 'modal' || arg === 'band') await update($, isModal, () => arg === 'modal')
290    if (arg === 'why') return { text: `Última decisão da triagem: ${decisionText(asDecision(await read($, decision)))}` }
291    if (arg.startsWith('demo')) {
292      const big = arg.endsWith('2')
293      const sample: Pending = {
294        text: 'demo',
295        current: big ? 'Opus 5.5' : 'Sonnet 5.5',
296        family: 'haiku',
297        effort: 'low',
298        reason: 'Pergunta simples de clima',
299        gap: big ? 2 : 1,
300      }
301      await update($, asking, () => sample)
302      let answer = ''
303      try {
304        answer = await $.ui.ask(questionFor(sample).question, { options: labelsFor(sample), header: headerFor(sample) })
305      } catch (error) {
306        answer = `(modal recusado: ${String(error)})`
307      }
308      await update($, asking, () => null)
309      return { text: `Demo do modal. Resposta: ${answer} → ${choiceOf(answer, sample) ?? 'nenhuma das opções'}` }
310    }
311    const state = (await read($, isOn)) ? 'ligada' : 'desligada'
312    const style = (await read($, isModal)) ? 'modal em todas as mensagens' : 'faixa (modal só com imagem)'
313    const advisor = await readAdvisor($)
314    const advisorText = advisorRank(advisor) === null ? 'sem advisor' : `advisor ${String(advisor)} (não sugere subir até ele)`
315    return { text: `Triagem antes da mensagem: ${state}, ${style}, ${advisorText}. Use /triage-gate on | off | modal | band | demo.` }
316  })
317
318  on('command.run', { command: 'model' }, async ($, e, next) => {
319    const result = await next(e)
320    await refreshModel($)
321    const notice = e.origin.kind === 'plugin' && e.origin.name.includes('triage-gate') ? modelNotice(result.text) : null
322    return notice === null ? result : { ...result, text: notice }
323  })
324
325  on('command.run', { command: 'effort' }, async ($, e, next) => {
326    const result = await next(e)
327    const level = effortFromOutput(result.text)
328    if (level !== null) await update($, shownEffort, () => level)
329    const notice = e.origin.kind === 'plugin' && e.origin.name.includes('triage-gate') ? effortNotice(result.text) : null
330    return notice === null ? result : { ...result, text: notice }
331  })
332
333  on('prompt.submit', async ($, e, next) => {
334    if (e.origin.kind !== 'composer' || e.turnId !== undefined) return next(e)
335    const decide = (d: Decision) => update($, decision, () => d)
336    await update($, pending, () => null)
337    if (!(await read($, isOn))) {
338      await decide({ verdict: 'Triagem desligada', detail: 'Use /triage-gate on para ligar.' })
339      return next(e)
340    }
341    if (isSkippable(e.text)) {
342      await decide({ verdict: 'Passou sem triagem', detail: 'Mensagem curta, comando ou continuação.' })
343      return next(e)
344    }
345
346    let current: Tier | null = null
347    let verdict: Verdict | null = null
348    let currentName = ''
349    try {
350      currentName = String(await $.session.model())
351      current = familyOf(currentName)
352      const rows = await $.session.messages()
353      const lastReply = rows.filter(row => row.role === 'assistant').at(-1)?.text ?? ''
354      const answer = await $.model.complete({
355        model: 'haiku',
356        prompt: buildPrompt(e.text, lastReply),
357        maxTokens: 60,
358        effort: 'low',
359        timeoutMs: 8000,
360      })
361      verdict = answer.isAnswered ? parseVerdict(answer.text) : null
362    } catch {
363      verdict = null
364    }
365    if (currentName !== '') await update($, shownModel, () => prettyModel(currentName))
366    if (current === null || verdict === null) {
367      await decide({ verdict: 'Passou sem triagem', detail: 'O classificador não respondeu a tempo.' })
368      return next(e)
369    }
370
371    const gap = gapOf(current, verdict.family)
372    const route = `${prettyModel(currentName)} → ${capitalize(verdict.family)}`
373    const reason = verdict.reason
374    if (gap === 0) {
375      await decide({ verdict: 'Sem troca: o modelo atual é o recomendado', route: prettyModel(currentName), reason })
376      return next(e)
377    }
378
379    let tokens = 0
380    try {
381      tokens = (await $.session.usage()).context.tokens ?? 0
382    } catch {
383      tokens = 0
384    }
385    const isWarm = lastTurnAt === 0 || (await $.clock.now()) - lastTurnAt < CACHE_TTL_MS
386    const econ = economicsOf(current, verdict.family, tokens, isWarm)
387    const size = `Contexto de ~${Math.round(tokens / 1000)}K tokens, cache ${isWarm ? 'ativo' : 'expirado'}`
388    if (econ.isDown && !econ.isWorth) {
389      const when = econ.payback === Infinity ? 'nunca se pagaria' : `só se pagaria em ${econ.payback} mensagens`
390      await decide({ verdict: 'Sem troca: descer não compensa', route, reason, detail: `${size}; a troca ${when}.` })
391      return next(e)
392    }
393    if (advisorCovers(await readAdvisor($), current, verdict.family)) {
394      await decide({ verdict: 'Sem troca: o advisor cobre a subida', route, reason, detail: 'O Opus é consultado nos momentos difíceis, sem perder o cache.' })
395      return next(e)
396    }
397    await decide({ verdict: 'Troca sugerida no modal', route, reason, detail: `${size}.` })
398
399    const p: Pending = {
400      text: e.text,
401      current: prettyModel(currentName),
402      family: verdict.family,
403      effort: verdict.effort,
404      reason: verdict.reason,
405      gap,
406      hasImages: e.attachments !== undefined,
407      note: tokens > 0 ? (costNote(econ, tokens) ?? undefined) : undefined,
408    }
409
410    if (e.attachments !== undefined || (await read($, isModal))) {
411      const askedAt = await $.clock.now()
412      askedNow = p
413      await update($, asking, () => p)
414      let answer = ''
415      let isRefused = false
416      try {
417        answer = await $.ui.ask(questionFor(p).question, { options: labelsFor(p), header: headerFor(p) })
418      } catch {
419        isRefused = (await $.clock.now()) - askedAt < 700
420      }
421      askedNow = null
422      await update($, asking, () => null)
423      if (isRefused) {
424        $.ui.toast('O modal da triagem não abriu; a mensagem foi enviada sem triagem.')
425        return next(e)
426      }
427      const choice = choiceOf(answer, p)
428      if (choice === 'switch') {
429        await update($, override, () => ({ model: p.family, effort: p.effort }))
430        $.ui.toast(switchedText(p))
431        return next(e)
432      }
433      if (choice === 'send') return next(e)
434      await $.prompt.fill({ text: e.text })
435      if (e.attachments !== undefined) $.ui.toast('As imagens não voltam para a caixa, mas ficam na conversa acima: copie e cole.')
436      return { drop: 'Envio cancelado: a mensagem voltou para a caixa de texto.' }
437    }
438
439    await update($, pending, () => p)
440    return { drop: 'Mensagem retida pela triagem: veja a faixa acima do campo de digitação.' }
441  })
442
443  on('turn.step', async function* ($, e, next) {
444    if (e.agentId !== undefined) return yield* next(e)
445    const chosen = await read($, override)
446    const sent = chosen === null ? e.effort : chosen.effort
447    const level = sent === undefined ? '' : String(sent)
448    if (level !== '' && level !== (await read($, shownEffort))) await update($, shownEffort, () => level)
449    if (chosen === null) return yield* next(e)
450    return yield* next({ ...e, model: modelIdFor(chosen.model), effort: chosen.effort })
451  })
452
453  on('turn.complete', async ($, e, next) => {
454    const chosen = await read($, override)
455    const result = await next(e)
456    lastTurnAt = await $.clock.now()
457    if (chosen === null) return result
458    await update($, override, () => null)
459    try {
460      await $.command.run({ command: 'model', args: chosen.model })
461      await $.command.run({ command: 'effort', args: chosen.effort })
462    } catch (error) {
463      $.ui.log(`[triage-gate] não consegui fixar o modelo da sessão: ${String(error).slice(0, 240)}`)
464    }
465    await refreshModel($)
466    return result
467  })
468
469  on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => {
470    const p = askedNow ?? (await read($, asking))
471    if (p === null) return next(e)
472    const native = await next({ ...e, props: { ...e.props, questions: [questionFor(p)] } })
473
474    const { Box, Text } = $.ui.resolve(e)
475    const pal = paletteFor(p.gap)
476    const banner = (
477      <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={2} paddingY={1} marginBottom={1}>
478        <Box flexDirection="row" justifyContent="space-between">
479          <Text bold>Triagem de modelo</Text>
480          <Text bold color={pal.accent}>
481            {statusFor(p.gap)}
482          </Text>
483        </Box>
484        <Box flexDirection="column" marginTop={1}>
485          <Text bold>
486            {p.current} → {capitalize(p.family)}
487          </Text>
488          <Text dimColor>
489            Esforço {effortWord(p.effort)} · {p.reason}
490          </Text>
491          {p.note === undefined ? null : <Text dimColor>{p.note}</Text>}
492        </Box>
493      </Box>
494    )
495    return (
496      <Box flexDirection="column">
497        {banner}
498        {native}
499      </Box>
500    )
501  })
502
503  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
504    if (e.props.hasSurvey) return next(e)
505    const p = await read($, pending)
506    if (p === null) {
507      const { Box, Text, Button } = $.ui.resolve(e)
508      const model = await read($, shownModel)
509      const effort = effortLabel(await read($, shownEffort))
510      const why = asDecision(await read($, decision))
511      const isOpen = await read($, isBandOpen)
512      const advisor = await readAdvisor($)
513      const advisorName = advisorRank(advisor) === null ? null : prettyModel(String(advisor))
514      const head = (
515        <Box flexDirection="row" flexGrow={1} justifyContent="space-between" alignItems="center" columnGap={2}>
516          <Box flexDirection="row" columnGap={1}>
517            <Text dimColor>Modelo em uso</Text>
518            <Text bold>{model === '' ? '…' : model}</Text>
519            {effort === '' ? null : <Text dimColor>· esforço {effort}</Text>}
520            {advisorName === null ? null : <Text dimColor>· advisor {advisorName}</Text>}
521          </Box>
522          <Button
523            key="toggle"
524            variant="secondary"
525            onPress={async () => {
526              await update($, isBandOpen, () => !isOpen)
527              await $.store.set('isBandOpen', !isOpen)
528            }}
529          >
530            {isOpen ? 'Minimizar' : 'Triagem'}
531          </Button>
532        </Box>
533      )
534      if (!isOpen) {
535        return (
536          <Box flexDirection="column" paddingX={1}>
537            {head}
538          </Box>
539        )
540      }
541      return (
542        <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
543          {head}
544          <Box marginTop={1}>
545            <Text dimColor wrap="wrap">
546              Triagem: {decisionText(why)}
547            </Text>
548          </Box>
549        </Box>
550      )
551    }
552
553    const { Box, Text, Button } = $.ui.resolve(e)
554    const pal = paletteFor(p.gap)
555    const fill = { color: pal.text, backgroundColor: pal.fill }
556    const label = { bold: true, color: pal.muted, backgroundColor: pal.fill }
557    const strong = { bold: true, color: pal.text, backgroundColor: pal.fill }
558
559    const send = async (): Promise<void> => {
560      await update($, pending, () => null)
561      void $.prompt.submit({ text: p.text, asUser: true })
562    }
563
564    return (
565      <Box flexDirection="column" borderStyle="round" borderColor={pal.border} backgroundColor={pal.fill} paddingX={1}>
566        <Box flexDirection="row" columnGap={1}>
567          <Text color={pal.muted} backgroundColor={pal.fill}>
568            ◈ Triagem
569          </Text>
570          <Text bold color={pal.accent} backgroundColor={pal.fill}>
571            {statusFor(p.gap)}
572          </Text>
573        </Box>
574        <Box flexDirection="row" columnGap={1}>
575          <Text {...label}>Atual:</Text>
576          <Text {...strong}>{p.current}</Text>
577          <Text {...label}>→ Recomendado:</Text>
578          <Text {...strong}>{capitalize(p.family)}</Text>
579          <Text {...fill}>· Esforço {effortWord(p.effort)}</Text>
580        </Box>
581        <Box flexDirection="row" columnGap={1}>
582          <Text {...label}>Motivo:</Text>
583          <Text {...fill}>{p.reason}</Text>
584        </Box>
585        <Box flexDirection="row" columnGap={1} marginTop={1}>
586          <Button
587            key="switch"
588            variant="primary"
589            onPress={async () => {
590              const model = await $.command.run({ command: 'model', args: p.family })
591              if (model.exitCode) return $.ui.toast(failedText(p))
592              await $.command.run({ command: 'effort', args: p.effort })
593              $.ui.toast(switchedText(p))
594              await send()
595            }}
596          >
597            {`Trocar para ${capitalize(p.family)} e enviar`}
598          </Button>
599          <Button key="send" variant="secondary" onPress={() => void send()}>
600            Enviar mesmo assim
601          </Button>
602          <Button
603            key="edit"
604            variant="secondary"
605            onPress={async () => {
606              await update($, pending, () => null)
607              await $.prompt.fill({ text: p.text })
608            }}
609          >
610            Editar
611          </Button>
612        </Box>
613      </Box>
614    )
615  })
616}
617
types/index.d.ts 25 lines
1export type Tier = 'haiku' | 'sonnet' | 'opus'
2
3export type Effort = 'low' | 'medium' | 'high'
4
5export type Pending = {
6  text: string
7  current: string
8  family: Tier
9  effort: Effort
10  reason: string
11  gap: number
12  hasImages?: boolean
13  note?: string
14}
15
16export type Override = { model: Tier; effort: Effort }
17
18export type Decision = { verdict: string; route?: string; reason?: string; detail?: string }
19
20declare module 'claude-code' {
21  interface PluginState {
22    'triage-gate': { pending: Pending | null; asking: Pending | null; override: Override | null; isOn: boolean; isModal: boolean; shownModel: string; shownEffort: string; decision: Decision; isBandOpen: boolean }
23  }
24}
25