SLOPSHOPPER

model-stack

Routes the model and effort of the main chat turn and of each subagent with TypeSafe's Jev, and shows the roster, decisions and agent graph in a pane.

newpaneguardcommandnetworktimer
v0.9.0no licenseupdated 2026-10-18fhva29/claude-code-mods/model-stack
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · model-stack
│ ┃ Model stack ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ Model stack │ ⏺ Read(src/auth.ts) │ ┃ │ ◆ Jev · rota automática │ ⎿ Read 6 lines │ ┃ │ │ ⏺ Update(src/auth.ts) │ ┃ │ Sessão │ ⎿ Added 2 lines, removed 1 line │ ┃ │ gasto $0 │ ⏺ Bash(bun test) │ ┃ │ poupado $0 │ ⎿ 3 pass, 1 fail │ ┃ │ vs. tudo no opus │ │ ┃ │ cache ························ — │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ lido 0 · escrito 0 │ │ ┃ │ modelos ························ │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ h 0 · s 0 · o 0 │ │ ┃ │ custo ························ │ › /stack │ ┃ │ por turno · 0 gerados │ ⎿ model-stack: Model stack pane opened. │ ┃ │ │ │ ┃ │ Main │ │ ┃ │ aguardando o primeiro prompt │ │ ┃ │ │ │ ┃ │ Decisão │ │ ┃ │ Nada roteado ainda. │ │ ┃ │ │ │ ┃ │ Agentes · ocioso │ │ ┃ │ ● main │ │ ┃ │ └─ nenhum subagent neste turno │ │ ┃ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Model stack
╭──────────────────────────────────────────────────────────╮ │ Model stack │ │ ◆ Jev · rota automática │ │ │ │ Sessão │ │ gasto $0 │ │ poupado $0 │ │ vs. tudo no opus │ │ cache ························ — │ │ lido 0 · escrito 0 │ │ modelos ························ │ │ h 0 · s 0 · o 0 │ │ custo ························ │ │ por turno · 0 gerados │ │ │ │ Main │ │ aguardando o primeiro prompt │ │ │ │ Decisão │ │ Nada roteado ainda. │ │ │ │ Agentes · ocioso │ │ ● main │ │ └─ nenhum subagent neste turno │ │ │ │ Histórico │ │ vazio │ │ │ │ /stack details │ │ /stack export │ │ /stack mode off|recommend|route │ ╰──────────────────────────────────────────────────────────╯
README

claude-code-mods

Personal Claude Code mods, published as one local-style marketplace named heitor-mods.

Install

Requires a Claude Code build with mod (function hooks) support.

1. Add the marketplace. The repository is private, so gh auth login (or any git credential that can read it) must work first.

claude plugin marketplace add fhva29/claude-code-mods

Or clone it and add the folder; Claude Code then reads the mods straight from it, so an edit plus /reload-plugins is enough, with no reinstall:

git clone https://github.com/fhva29/claude-code-mods ~/.claude/mods
claude plugin marketplace add ~/.claude/mods

2. Install the mods you want (user scope):

claude plugin install next-lite@heitor-mods
claude plugin install tidy-tools@heitor-mods
# ...or all of them:
for m in blast-radius commit-gate model-stack next-lite pixel-quest prompt-git \
         replay-theater tidy-tools usage-weather brief-mode; do
  claude plugin install "$m@heitor-mods"
done

3. Reload inside Claude Code with /reload-plugins.

Options (userConfig) are set in /plugin → pick the mod → configure, then /reload-plugins. They are stored in ~/.claude/settings.json under pluginConfigs.

Mods

ModWhat it doesCommand
next-liteSuggests up to three next prompts above the input (press 1/2/3 to fill the prompt box, 0 to dismiss; the top one is also Tab ghost text). Sends a ~1–2k token digest of the turn to a small model instead of forking the whole session, so its cost does not grow with the conversation. Replacement for next-steps. Options: mode (auto/manual), model, minAnswerChars, suggestSkills./next
model-stackRoutes the model and effort of the main chat turn and of each subagent with TypeSafe's Jev, and shows the roster, decisions and agent graph in a pane. Needs TYPESAFE_API_KEY in the environment./stack
tidy-toolsDraws each tool call as one compact line, or folds a whole turn into one "N steps this turn" line./lines
blast-radiusHolds risky shell commands (rm -rf, git reset --hard, git clean, force push, migrations) and shows what they would change, with Proceed and Cancel buttons.
commit-gateHolds git commit in a pane listing the staged files, flags files not edited this session and blocks AI attribution trailers.
replay-theaterSteps through the file edits Claude made in the last turn, one diff at a time./replay
usage-weatherA forecast band above the prompt: context fill, sparkline, rate limits, model and effort.
prompt-gitAppends the git branch and working-tree counts to the hint line under the prompt.
pixel-questReplaces the spinner with a pixel RPG scene for each tool, with XP, levels and achievements. No model calls./toons
brief-modeAppends a short-answer style section to the system prompt: answer or next action first, numbered steps, lists capped at five, no preamble, recap or closers. Edit hooks/rules.ts to tune it.
midnight-neonDraws assistant replies with a dark neon palette: filled h1 banner, colored h2–h6, colored bullets, quote bars, highlighted bold, italic, inline code and links. Fences and tables keep the engine's renderer. Not installed by default; /neon flips it per session, `claude plugin enable\disable` flips it for good./neon

_archive holds mods that are kept but not in use (branch-hud); they are not listed in the marketplace.

Development

Each mod is a folder with .claude-plugin/plugin.json, hooks/hooks.json and a hooks module (register.ts or register.tsx). Check one with:

claude plugin validate <mod>
claude plugin test <mod>

claude plugin test does not type-check; run tsc against the SDK declarations Claude Code writes to <mod>/.claude-plugin/types/ as well.

next-lite builds on next-steps (MIT, Thariq Shihipar): it reuses the text sanitizer and the reply parsing, and replaces the session fork with a small digest.

Source 7 files
hooks/register.tsx 623 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { JEV_ENDPOINT, type JevAnswer, type PastResult, parseTierResponse, tierRequest } from './jev'
5import { FRAME_MS, PANE, paneTree, SWEEP_MS } from './panel'
6import {
7  classifyFollowUp,
8  deriveOutcome,
9  isInjectedPrompt,
10  type EditEntry,
11  feedbackFromJev,
12  isEditTool,
13  isTestCommand,
14  mergeFeedback,
15  recordTestRun,
16  reworkTargets,
17  testsLabel,
18} from './quality'
19import {
20  applyPlan,
21  asRecommendation,
22  decide,
23  isStruggling,
24  isTier,
25  NO_SIGNALS,
26  recordToolOutcome,
27  shouldRoute,
28  skipMainPrompt,
29  type MainInputs,
30  type MainRouting,
31  modelRank,
32  raisePlan,
33  resolveMain,
34  type Signals,
35  TIER_ORDER,
36  TIERS,
37  type Thresholds,
38  tierCovering,
39  tierForEffort,
40} from './routing'
41import type { Feedback, MainPlan, Mode, RoutingDecision, Run, Tier, View } from '../types'
42
43const LOG_KEY = 'log'
44const LOG_LIMIT = 500
45const SESSION_LIMIT = 200
46const RUN_LIMIT = 100
47const LOGGED_PROMPT_CHARS = 200
48const HISTORY_SIZE = 8
49const ACTION_CHARS = 60
50const RUN_TEXT_CHARS = 80
51const MODES: readonly Mode[] = ['off', 'recommend', 'route']
52
53const decisions = atom({ plugin: 'model-stack', key: 'decisions' } as const, [])
54const plan = atom({ plugin: 'model-stack', key: 'plan' } as const, null)
55const runs = atom({ plugin: 'model-stack', key: 'runs' } as const, [])
56const currentRun = atom({ plugin: 'model-stack', key: 'currentRun' } as const, null)
57const view = atom({ plugin: 'model-stack', key: 'view' } as const, 'overview')
58const modeOverride = atom({ plugin: 'model-stack', key: 'mode' } as const, null)
59const tick = atom({ plugin: 'model-stack', key: 'tick' } as const, 0)
60const jevAskedAt = atom({ plugin: 'model-stack', key: 'jevAskedAt' } as const, null)
61
62const TIER_PROMPT = [
63  'You are a subagent working for an orchestrating Claude session.',
64  'Do exactly the task you are given, stay inside its scope, and report back',
65  'concisely: what you found or changed, with file paths, and anything left undone.',
66].join(' ')
67
68const USAGE = '/stack [details|overview|export|good [note]|bad [note]|mode off|recommend|route]'
69
70function isMode(value: unknown): value is Mode {
71  return typeof value === 'string' && (MODES as readonly string[]).includes(value)
72}
73
74function describeAction(e: { tool: string } & Record<string, unknown>): string {
75  const arg = [e.command, e.file_path, e.pattern, e.path, e.url, e.description].find(
76    value => typeof value === 'string' && value !== '',
77  )
78  const text = typeof arg === 'string' ? `${e.tool} ${arg.replace(/\s+/g, ' ')}` : e.tool
79  return text.length > ACTION_CHARS ? `${text.slice(0, ACTION_CHARS - 1)}…` : text
80}
81
82async function persist($: EngineInterface, decision: RoutingDecision, prompt: string): Promise<void> {
83  const previous = await $.store.get(LOG_KEY)
84  const log = Array.isArray(previous) ? previous : []
85  const entry = { ...decision, prompt: prompt.slice(0, LOGGED_PROMPT_CHARS) }
86  await $.store.set(LOG_KEY, [...log, entry].slice(-LOG_LIMIT))
87}
88
89async function pastResults($: EngineInterface): Promise<PastResult[]> {
90  // History only sharpens Jev's answer; a store that cannot be read leaves it out.
91  const previous = await $.store.get(LOG_KEY).catch(() => null)
92  if (!Array.isArray(previous)) return []
93  return (previous as RoutingDecision[])
94    .filter(one => one.status !== 'running')
95    .slice(-HISTORY_SIZE)
96    .map(one => ({
97      subagent_type: one.subagentType,
98      task_summary: one.description,
99      model: one.actualModel ?? one.applied,
100      status: one.status,
101      duration_s: one.durationMs === undefined ? null : Math.round(one.durationMs / 1000),
102      escalated_to: one.escalatedTo ?? null,
103      tests: testsLabel(one.tests),
104      rework: one.rework === true,
105      user_feedback: one.feedback ?? null,
106      outcome: one.outcome ?? 'unknown',
107    }))
108}
109
110/** Rewrites one persisted entry; a decision that is not in the log yet is saved whole when its turn ends. */
111async function amendLog($: EngineInterface, id: string, change: Partial<RoutingDecision>): Promise<void> {
112  const previous = await $.store.get(LOG_KEY).catch(() => null)
113  if (!Array.isArray(previous) || !previous.some(one => one.id === id)) return
114  await $.store.set(
115    LOG_KEY,
116    previous.map(one => (one.id === id ? { ...one, ...change } : one)),
117  )
118}
119
120async function modeOf($: EngineInterface, configured: Mode): Promise<Mode> {
121  return (await read($, modeOverride)) ?? configured
122}
123
124async function patch(
125  $: EngineInterface,
126  id: string,
127  change: Partial<RoutingDecision>,
128): Promise<RoutingDecision | undefined> {
129  let patched: RoutingDecision | undefined
130  await update($, decisions, list =>
131    list.map(one => {
132      if (one.id !== id) return one
133      patched = { ...one, ...change }
134      return patched
135    }),
136  )
137  return patched
138}
139
140async function loggedDecision($: EngineInterface, id: string): Promise<RoutingDecision | undefined> {
141  const previous = await $.store.get(LOG_KEY).catch(() => null)
142  return Array.isArray(previous) ? (previous as RoutingDecision[]).find(one => one.id === id) : undefined
143}
144
145/** A quality signal arrived: update the decision in the session and in the log, and re-derive its outcome. */
146async function annotate(
147  $: EngineInterface,
148  id: string,
149  change: Partial<RoutingDecision>,
150): Promise<RoutingDecision | undefined> {
151  const live = (await read($, decisions)).find(one => one.id === id)
152  const current = live ?? (await loggedDecision($, id))
153  if (current === undefined) return undefined
154  const merged = { ...current, ...change }
155  const full = { ...change, outcome: deriveOutcome(merged) }
156  if (live !== undefined) await patch($, id, full)
157  await amendLog($, id, full)
158  return { ...merged, outcome: full.outcome }
159}
160
161/** Infers what the new prompt says about the previous main turn; a label given by hand is kept. */
162async function recordFollowUp($: EngineInterface, previousId: string, inferred: Feedback | null): Promise<void> {
163  if (inferred === null) return
164  const current = (await read($, decisions)).find(one => one.id === previousId) ?? (await loggedDecision($, previousId))
165  if (current === undefined) return
166  await annotate($, previousId, { feedback: mergeFeedback(current.feedback, inferred) })
167}
168
169type Asked = { answer: JevAnswer | null; ms: number }
170
171async function askJev(
172  $: EngineInterface,
173  question: Parameters<typeof tierRequest>[0],
174  timeoutMs: number,
175): Promise<Asked> {
176  const started = await $.clock.now()
177  await update($, jevAskedAt, () => started)
178  try {
179    const fetched = $.http.fetch(JEV_ENDPOINT, tierRequest(question)).catch(() => null)
180    const timeout = $.clock.sleep(timeoutMs).then(() => null)
181    const response = await Promise.race([fetched, timeout])
182    const answer = response !== null && response.ok ? parseTierResponse(response.text) : null
183    return { answer, ms: (await $.clock.now()) - started }
184  } finally {
185    await update($, jevAskedAt, () => null)
186  }
187}
188
189/** Something on the pane is moving: a spinner, Jev's pulse, or a bar still sweeping in. */
190function isAnimating(list: readonly RoutingDecision[], asked: number | null, now: number): boolean {
191  if (asked !== null) return true
192  const last = list.at(-1)
193  if (last !== undefined && last.jevMs !== undefined && now - (last.at + last.jevMs) < SWEEP_MS + FRAME_MS) return true
194  return list.some(one => one.status === 'running')
195}
196
197/** After the person corrects a turn, the tier that turn ran on, so the next one does not go below it. */
198function correctionFloorOf(
199  previous: RoutingDecision | undefined,
200  reaction: Feedback | null,
201  routing: MainRouting,
202): Tier | null {
203  if (previous === undefined || reaction !== 'correction') return null
204  if (previous.escalatedTo !== undefined) return previous.escalatedTo
205  if (routing === 'effort') return tierForEffort(previous.actualEffort)
206  return previous.actualModel === undefined ? null : tierCovering(previous.actualModel, previous.actualEffort)
207}
208
209type EscalationState = { signals: Map<string, Signals>; raisedTurns: Set<string> }
210
211/** Raises the turn's plan one tier once its tool calls keep failing; later steps of the turn use it. */
212async function escalateIfStruggling(
213  $: EngineInterface,
214  state: EscalationState,
215  call: { tool: string; filePath?: string; isError: boolean },
216  configured: Mode,
217  routing: MainRouting,
218): Promise<void> {
219  const planned = await read($, plan)
220  if (planned === null || state.raisedTurns.has(planned.turnId)) return
221  const updated = recordToolOutcome(state.signals.get(planned.turnId) ?? NO_SIGNALS, call)
222  state.signals.set(planned.turnId, updated)
223  if (!isStruggling(updated)) return
224  // One raise per turn: every switch costs the prompt cache.
225  state.raisedTurns.add(planned.turnId)
226  const raised = raisePlan(planned, routing)
227  if (raised === null || (await modeOf($, configured)) !== 'route') return
228  await update($, plan, (): MainPlan | null => raised)
229  await patch($, planned.turnId, {
230    escalatedTo: raised.tier,
231    applied: `${runLabel(raised.model, raised.effort)} (raised)`,
232  })
233}
234
235/** `sonnet · high` for a full model id and an effort. */
236function runLabel(model: string, effort: string): string {
237  return `${['haiku', 'sonnet', 'opus'][modelRank(model)] ?? model} · ${effort}`
238}
239
240/** What turn.start learned about a main turn; turn.step resolves it once it sees the model the turn would run on. */
241type PendingMain = Omit<MainInputs, 'thresholds' | 'floor' | 'routing'> & { turnId: string }
242
243type QualityState = { edits: Map<string, EditEntry & { files: Set<string> }>; runOrder: string[] }
244
245/** Test and lint runs go to the decision that made them; an edit of a file edited earlier flags that decision as rework. */
246async function observeQuality(
247  $: EngineInterface,
248  state: QualityState,
249  owner: RoutingDecision,
250  call: { tool: string; command?: string; filePath?: string; isError: boolean },
251): Promise<void> {
252  if (call.tool === 'Bash' && call.command !== undefined && isTestCommand(call.command)) {
253    await annotate($, owner.id, { tests: recordTestRun(owner.tests, call.isError) })
254  }
255  if (!isEditTool(call.tool) || call.filePath === undefined || owner.runId === null) return
256  if (owner.scope === 'main') {
257    for (const id of reworkTargets([...state.edits.values()], state.runOrder, owner.runId, call.filePath)) {
258      await annotate($, id, { rework: true })
259    }
260  }
261  const entry = state.edits.get(owner.id) ?? { id: owner.id, runId: owner.runId, scope: owner.scope, files: new Set<string>() }
262  entry.files.add(call.filePath)
263  state.edits.set(owner.id, entry)
264}
265
266/** How sure Jev must be of its reading of the previous turn before it labels that turn. */
267const FEEDBACK_MIN_CONFIDENCE = 0.6
268
269export const register: Register = (on, options) => {
270  const thresholds: Thresholds = {
271    up: typeof options.minUpgradeConfidence === 'number' ? options.minUpgradeConfidence : 0.3,
272    down: typeof options.minDowngradeConfidence === 'number' ? options.minDowngradeConfidence : 0.6,
273  }
274  const riskyThreshold = typeof options.riskyThreshold === 'number' ? options.riskyThreshold : 0.7
275  const mainRouting: MainRouting = options.mainRouting === 'effort' ? 'effort' : 'model'
276  const timeoutMs = typeof options.timeoutMs === 'number' ? options.timeoutMs : 2000
277  const defaultTier: Tier = isTier(options.defaultTier) ? options.defaultTier : 'default'
278  const configuredMode: Mode = isMode(options.mode) ? options.mode : 'route'
279  // Until the threshold is calibrated, Jev may not take the main chat below this tier; 'none' lifts it.
280  const mainFloor: Tier | null = options.mainFloor === 'none' ? null : isTier(options.mainFloor) ? options.mainFloor : 'deep'
281  // Prompts only feed the persisted log; losing them on a reload trims that log's text, nothing else.
282  const prompts = new Map<string, string>()
283  // A reload between a turn's start and its first step leaves that one turn unrouted.
284  const pendingMain = new Map<string, PendingMain>()
285  // Per main-chat turn; a reload restarts the count, which only delays an escalation.
286  const escalation: EscalationState = { signals: new Map(), raisedTurns: new Set() }
287  // Files each decision edited, to spot the next runs editing them again. A reload only forgets older edits,
288  // which at worst misses a rework flag.
289  const quality: QualityState = { edits: new Map(), runOrder: [] }
290
291  on('session.start', async ($, e, next) => {
292    for (const tier of TIER_ORDER) {
293      const spec = TIERS[tier]
294      await $.agent.register({
295        name: spec.agent,
296        description: `General-purpose subagent on ${spec.model} at ${spec.effort} effort (${tier} tier).`,
297        prompt: TIER_PROMPT,
298        model: spec.model,
299        effort: spec.effort,
300      })
301    }
302    await $.command.register({ name: 'stack', description: `Model stack pane: ${USAGE}` })
303    $.clock.every(FRAME_MS, async () => {
304      const now = await $.clock.now()
305      if (isAnimating(await read($, decisions), await read($, jevAskedAt), now)) {
306        await update($, tick, () => now)
307      }
308    })
309    void $.ui.open({ id: PANE, title: 'Model stack' })
310
311    return next(e)
312  })
313
314  on('command.run', { command: 'stack' }, async ($, e) => {
315    const [verb, arg] = e.args.trim().split(/\s+/)
316    if (verb === 'details' || verb === 'overview') {
317      await update($, view, (): View => verb)
318    } else if (verb === 'mode') {
319      if (!isMode(arg)) return { text: 'Usage: /stack mode off|recommend|route' }
320      await update($, modeOverride, (): Mode => arg)
321      await $.ui.open({ id: PANE, title: 'Model stack' })
322      return { text: `Model stack mode: ${arg} (this session).` }
323    } else if (verb === 'export') {
324      const now = await $.clock.now()
325      const home = (await $.env.get('HOME')) ?? '.'
326      const path = `${home}/.claude/model-stack/exports/decisions-${new Date(now).toISOString().replace(/[:.]/g, '-')}.json`
327      const payload = { exportedAt: new Date(now).toISOString(), runs: await read($, runs), decisions: await read($, decisions) }
328      await $.fs.write(path, JSON.stringify(payload, null, 2))
329      return { text: `Decisions exported to ${path}` }
330    } else if (verb === 'good' || verb === 'bad') {
331      const target = (await read($, decisions)).filter(one => one.scope === 'main' && one.status !== 'running').at(-1)
332      if (target === undefined) return { text: 'No finished main-chat decision to label yet.' }
333      const note = e.args.trim().slice(verb.length).trim()
334      await annotate($, target.id, {
335        feedback: verb === 'good' ? 'manual_good' : 'manual_bad',
336        ...(note !== '' && { feedbackNote: note }),
337      })
338      return { text: `Marked "${target.description.slice(0, 40)}" as ${verb}.` }
339    } else if (verb !== undefined && verb !== '') {
340      return { text: `Usage: ${USAGE}` }
341    }
342    await $.ui.open({ id: PANE, title: 'Model stack' })
343    return { text: 'Model stack pane opened.' }
344  })
345
346  on('turn.start', async ($, e, next) => {
347    const at = await $.clock.now()
348    const run: Run = { id: e.turnId, at, text: e.text.slice(0, RUN_TEXT_CHARS) }
349    await update($, currentRun, () => e.turnId)
350    await update($, runs, list => [...list, run].slice(-RUN_LIMIT))
351    await update($, plan, (): MainPlan | null => null)
352    quality.runOrder.push(e.turnId)
353    if (quality.runOrder.length > RUN_LIMIT) quality.runOrder.shift()
354    for (const [id, entry] of quality.edits) {
355      if (entry.runId !== null && !quality.runOrder.includes(entry.runId)) quality.edits.delete(id)
356    }
357
358    const mode = await modeOf($, configuredMode)
359    if (mode === 'off') return next(e)
360
361    // The turn before this one is what the prompt reacts to; engine-injected prompts are not a reaction.
362    const previousMain = isInjectedPrompt(e.text)
363      ? undefined
364      : (await read($, decisions)).filter(one => one.scope === 'main' && one.status !== 'running').at(-1)
365    let reaction: Feedback | null = classifyFollowUp(e.text)
366
367    const base: RoutingDecision = {
368      id: e.turnId,
369      at,
370      scope: 'main',
371      runId: e.turnId,
372      description: e.text.trim().replace(/\s+/g, ' ').slice(0, RUN_TEXT_CHARS) || '(continuação)',
373      subagentType: 'main',
374      requestedModel: null,
375      tier: null,
376      probabilities: {},
377      confidence: null,
378      verdict: 'skipped',
379      mode,
380      applied: 'as picked',
381      status: 'running',
382    }
383
384    let decision = base
385    let jev: JevAnswer | null = null
386    const apiKey = await $.env.get('TYPESAFE_API_KEY')
387    if (skipMainPrompt(e.text)) {
388      decision = { ...base, reason: 'prompt curto: mantém o modelo atual' }
389    } else if (!apiKey) {
390      decision = { ...base, verdict: 'fallback', reason: 'TYPESAFE_API_KEY not set' }
391    } else {
392      const asked = await askJev(
393        $,
394        {
395          apiKey,
396          scope: 'main',
397          prompt: e.text,
398          description: base.description,
399          subagentType: 'main',
400          history: await pastResults($),
401          ...(previousMain !== undefined && {
402            previousTurn: { summary: previousMain.description, status: previousMain.status },
403          }),
404        },
405        timeoutMs,
406      )
407      jev = asked.answer
408      if (jev?.previous != null) {
409        reaction = feedbackFromJev(jev.previous.choice, jev.previous.confidence, FEEDBACK_MIN_CONFIDENCE) ?? reaction
410      }
411      decision = {
412        ...base,
413        tier: jev?.choice ?? null,
414        probabilities: jev?.probabilities ?? {},
415        confidence: jev?.confidence ?? null,
416        jevConfidence: jev?.reported ?? null,
417        why: jev?.why ?? undefined,
418        jevMs: asked.ms,
419        risk: jev?.risk ?? null,
420        verdict: jev === null ? 'fallback' : 'doubt',
421      }
422    }
423    // A prompt Jev was not asked about still may not drop below a corrected turn.
424    const correctionFloor = correctionFloorOf(previousMain, reaction, mainRouting)
425    if (correctionFloor !== null) decision = { ...decision, correctionFloor }
426    pendingMain.set(e.turnId, {
427      turnId: e.turnId,
428      jev,
429      correctionFloor,
430      risky: jev?.risk != null && jev.risk >= riskyThreshold,
431    })
432
433    await update($, decisions, list => [...list, decision].slice(-SESSION_LIMIT))
434    prompts.set(decision.id, e.text)
435    if (previousMain !== undefined) await recordFollowUp($, previousMain.id, reaction)
436    return next(e)
437  })
438
439  on('turn.step', async function* ($, e, next) {
440    const pending = e.agentId === undefined ? pendingMain.get(e.turnId) : undefined
441    if (pending !== undefined) {
442      pendingMain.delete(e.turnId)
443      // Up or down depends on what the turn would run on, which only the step knows.
444      const resolved = resolveMain(e, { ...pending, thresholds, floor: mainFloor, routing: mainRouting })
445      const route = (await modeOf($, configuredMode)) === 'route' && resolved.run !== null
446      if (route && resolved.run !== null) {
447        const run = resolved.run
448        await update($, plan, (): MainPlan | null => ({ turnId: e.turnId, ...run }))
449      }
450      await patch($, e.turnId, {
451        ...(pending.jev !== null && { verdict: resolved.verdict }),
452        ...(resolved.needed !== null && { neededConfidence: resolved.needed }),
453        applied: route && resolved.run !== null ? runLabel(resolved.run.model, resolved.run.effort) : 'as picked',
454      })
455    }
456    const request = applyPlan(e, e.agentId === undefined ? await read($, plan) : null)
457    if (e.agentId === undefined && e.index === 0) {
458      await patch($, e.turnId, {
459        actualModel: request.model,
460        ...(typeof request.effort === 'string' && { actualEffort: request.effort }),
461      })
462    }
463    return yield* next(request)
464  })
465
466  on('tool.call', async ($, e, next) => {
467    const result = await next(e)
468    try {
469      const input: Record<string, unknown> = e
470      const call = {
471        tool: e.tool,
472        command: typeof input.command === 'string' ? input.command : undefined,
473        filePath: typeof input.file_path === 'string' ? input.file_path : undefined,
474        isError: result.isError === true,
475      }
476      if (e.agentId !== undefined) {
477        const owner = (await read($, decisions)).find(one => one.agentId === e.agentId)
478        if (owner !== undefined) {
479          await patch($, owner.id, {
480            lastAction: describeAction(e as { tool: string } & Record<string, unknown>),
481            lastActionAt: await $.clock.now(),
482          })
483          await observeQuality($, quality, owner, call)
484        }
485      } else {
486        await escalateIfStruggling($, escalation, call, configuredMode, mainRouting)
487        const turn = (await read($, decisions)).find(one => one.id === (quality.runOrder.at(-1) ?? '') && one.scope === 'main')
488        if (turn !== undefined) await observeQuality($, quality, turn, call)
489      }
490    } catch {
491      // Observing the call never blocks it.
492    }
493    return result
494  })
495
496  on('agent.spawn', async ($, e, next) => {
497    const facts = {
498      subagentType: e.subagentType,
499      requestedModel: e.model ?? null,
500      isFork: e.fork,
501      isTeammate: e.isTeammate === true,
502    }
503    const mode = await modeOf($, configuredMode)
504
505    const base: RoutingDecision = {
506      id: e.tool_use_id,
507      at: await $.clock.now(),
508      scope: 'subagent',
509      runId: await read($, currentRun),
510      description: e.description,
511      subagentType: e.subagentType,
512      parentAgentId: e.parentAgentId,
513      requestedModel: facts.requestedModel,
514      tier: null,
515      probabilities: {},
516      confidence: null,
517      verdict: 'skipped',
518      mode,
519      applied: 'as requested',
520      status: 'running',
521    }
522
523    let decision = base
524    let rewritten = e
525    if (mode !== 'off' && shouldRoute(facts)) {
526      const apiKey = await $.env.get('TYPESAFE_API_KEY')
527      const asked = apiKey
528        ? await askJev(
529            $,
530            {
531              apiKey,
532              scope: 'subagent',
533              prompt: e.prompt,
534              description: e.description,
535              subagentType: e.subagentType,
536              history: await pastResults($),
537            },
538            timeoutMs,
539          )
540        : null
541      const jev = asked?.answer ?? null
542      const risky = jev?.risk != null && jev.risk >= riskyThreshold
543      const decided = decide(facts, jev, thresholds, defaultTier, risky)
544      const outcome = mode === 'route' ? decided : asRecommendation(decided)
545      decision = {
546        ...base,
547        tier: jev?.choice ?? outcome.tier,
548        probabilities: jev?.probabilities ?? {},
549        confidence: jev?.confidence ?? null,
550        jevConfidence: jev?.reported ?? null,
551        why: jev?.why ?? undefined,
552        jevMs: asked?.ms,
553        risk: jev?.risk ?? null,
554        verdict: outcome.verdict,
555        applied: outcome.applied,
556        reason: apiKey ? undefined : 'TYPESAFE_API_KEY not set',
557      }
558      rewritten = {
559        ...e,
560        ...(outcome.subagentType !== undefined && { subagentType: outcome.subagentType }),
561        ...(outcome.model !== undefined && { model: outcome.model }),
562      }
563    }
564
565    await update($, decisions, list => [...list, decision].slice(-SESSION_LIMIT))
566    prompts.set(decision.id, e.prompt)
567
568    const result = await next(rewritten)
569    if (result.deny !== undefined) {
570      await patch($, decision.id, { status: 'refused', reason: result.deny })
571    } else if (result.agentId !== undefined) {
572      await patch($, decision.id, { agentId: result.agentId, actualModel: result.model })
573    }
574    return result
575  }).catch(($, e, next) => next(e))
576
577  on('turn.complete', async ($, e, next) => {
578    const result = await next(e)
579
580    const list = await read($, decisions)
581    const match =
582      e.agentId === undefined
583        ? list.find(one => one.id === e.turnId && one.scope === 'main')
584        : list.find(one => one.agentId === e.agentId && one.status === 'running')
585    if (match === undefined) return result
586
587    const status = e.isAborted ? 'aborted' : e.reason === 'answer' ? 'done' : 'error'
588    const finished = await patch($, match.id, {
589      status,
590      outcome: deriveOutcome({ ...match, status }),
591      durationMs: e.durationMs,
592      inputTokens: e.usage?.input_tokens,
593      // The last response's model: after an escalation it is the raised one.
594      ...(e.usage?.model !== undefined && { actualModel: e.usage.model }),
595      outputTokens: e.usage?.output_tokens,
596      cacheReadTokens: e.usage?.cache_read_input_tokens,
597      cacheCreationTokens: e.usage?.cache_creation_input_tokens,
598    })
599    escalation.signals.delete(match.id)
600    escalation.raisedTurns.delete(match.id)
601    if (finished !== undefined) {
602      await persist($, finished, prompts.get(match.id) ?? '')
603      prompts.delete(match.id)
604    }
605    return result
606  })
607
608  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
609    return paneTree($.ui.resolve(e), {
610      list: await read($, decisions),
611      currentRun: await read($, currentRun),
612      view: await read($, view),
613      mode: await modeOf($, configuredMode),
614      jevAskedAt: await read($, jevAskedAt),
615      // Reading `tick` redraws the pane every frame while something on it moves.
616      now: Math.max(await read($, tick), await $.clock.now()),
617      thresholds,
618      columns: e.viewport?.columns ?? 100,
619      rows: e.viewport?.rows ?? 40,
620    })
621  })
622}
623
hooks/jev.ts 181 lines
1import { isTier, type JevChoice, TIER_ORDER } from './routing'
2import type { Outcome, Tier } from '../types'
3
4export const JEV_ENDPOINT = 'https://api.typesafe.ai/v1/systemone'
5export const JEV_MODEL = 'jev-latest'
6const TASK_CHARS = 1500
7
8const CRITERIA: Record<Tier, string> = {
9  light: 'Haiku, medium effort: simple lookup, file search, reading or summarizing code',
10  default: 'Sonnet, medium effort: typical implementation, tests, review or analysis',
11  deep: 'Sonnet, high effort: subtle analysis or a careful change across several files',
12  heavy: 'Opus, medium effort: hard reasoning or architecture',
13  max: 'Opus, high effort: a risky or hard-to-reverse change that needs the most careful reasoning',
14}
15
16export const REASONS: Record<string, string> = {
17  simple: 'The task is simple or narrow enough for a cheap tier',
18  depth: 'The task needs more reasoning or care than a cheaper tier gives',
19  risk: 'The task is risky: wide changes, architecture, or hard-to-reverse effects',
20  history: 'Past results for similar tasks point to this tier',
21}
22
23/** A finished earlier subagent, as Jev sees it. */
24export type PastResult = {
25  subagent_type: string
26  task_summary: string
27  model: string
28  status: string
29  duration_s: number | null
30  /** The tier the task had to be raised to mid-turn: the first pick was too low. */
31  escalated_to: Tier | null
32  /** Result of the last test or lint run the task made; null when it ran none. */
33  tests: 'passed' | 'failed' | null
34  /** Files the task edited were edited again soon after. */
35  rework: boolean
36  /** What the person's next move said about it. */
37  user_feedback: string | null
38  outcome: Outcome
39}
40
41/** The previous main-chat turn, so Jev can say how the new prompt reacts to it. */
42export type PreviousTurn = { summary: string; status: string }
43
44const RISK_CRITERIA: Record<string, string> = {
45  risky:
46    'It touches production, deploys, infrastructure, money, credentials, database migrations or schema, ' +
47    'or other state that is hard to reverse',
48  safe: 'It reads, explains, or changes code that is easy to review and undo',
49}
50
51const OUTCOME_HINT =
52  'A past result with outcome "bad" is evidence that its tier was too low for a task like it; ' +
53  '"good_weak" means the person moved on without complaint, weaker evidence than "good".'
54
55const FEEDBACK_CRITERIA: Record<string, string> = {
56  accepted: 'The prompt accepts, thanks or approves the previous result',
57  correction: 'The prompt says the previous result was wrong, asks to undo, redo or fix it',
58  new_task: 'The prompt moves on to something unrelated to the previous result',
59}
60
61export type TierQuestion = {
62  apiKey: string
63  /** `main` asks about the chat's own next turn; `subagent` about a spawn. */
64  scope: 'main' | 'subagent'
65  prompt: string
66  description: string
67  subagentType: string
68  history: readonly PastResult[]
69  /** Main scope only: asks how this prompt reacts to the previous turn. */
70  previousTurn?: PreviousTurn
71}
72
73export type JevAnswer = JevChoice & {
74  /** The `confidence` Jev itself reports for the tier, kept apart from the label's probability the threshold uses. */
75  reported: number | null
76  why: string | null
77  /** How the prompt reacts to the previous turn, when it was asked. */
78  previous: { choice: string; confidence: number } | null
79  /** Probability that the task is risky; null when Jev gave none. */
80  risk: number | null
81}
82
83function parseAnswer(body: unknown): JevAnswer | null {
84  if (typeof body !== 'object' || body === null) return null
85  const { answers } = body as {
86    answers?: Record<
87      string,
88      { choice?: unknown; confidence?: unknown; probabilities?: Record<string, unknown> } | undefined
89    >
90  }
91  const tier = answers?.tier
92  if (tier === undefined || !isTier(tier.choice)) return null
93
94  const probabilities: Partial<Record<Tier, number>> = {}
95  for (const name of TIER_ORDER) {
96    const value = tier.probabilities?.[name]
97    if (typeof value === 'number') probabilities[name] = value
98  }
99  // The chosen label's probability is the confidence the threshold compares against.
100  const confidence = probabilities[tier.choice]
101  if (confidence === undefined) return null
102
103  const why = answers?.reason?.choice
104
105  const reaction = answers?.previous_outcome
106  const reactionChoice = reaction?.choice
107  const reactionConfidence = typeof reactionChoice === 'string' ? reaction?.probabilities?.[reactionChoice] : undefined
108  const previous =
109    typeof reactionChoice === 'string' && typeof reactionConfidence === 'number'
110      ? { choice: reactionChoice, confidence: reactionConfidence }
111      : null
112
113  const risk = answers?.risky?.probabilities?.risky
114
115  return {
116    choice: tier.choice,
117    confidence,
118    probabilities,
119    reported: typeof tier.confidence === 'number' ? tier.confidence : null,
120    why: typeof why === 'string' && why in REASONS ? why : null,
121    previous,
122    risk: typeof risk === 'number' ? risk : null,
123  }
124}
125
126export function tierRequest(question: TierQuestion): { method: string; headers: Record<string, string>; body: string } {
127  const body = JSON.stringify({
128    model: JEV_MODEL,
129    state: {
130      scope: question.scope,
131      subagent_type: question.subagentType,
132      task_summary: question.description,
133      task: question.prompt.slice(0, TASK_CHARS),
134      past_results: question.history,
135      ...(question.previousTurn !== undefined && { previous_turn: question.previousTurn }),
136    },
137    questions: {
138      tier: {
139        type: 'choice',
140        instructions:
141          question.scope === 'main'
142            ? 'Which model tier should answer this request in the main chat? Pick the tier whose model and ' +
143              `effort fit how hard the task is, weighing how past results of similar tasks went. ${OUTCOME_HINT}`
144            : 'Which model tier should run this subagent task? Pick the tier whose model and effort fit ' +
145              `how hard the task is, weighing how past results of similar tasks went. ${OUTCOME_HINT}`,
146        criteria: CRITERIA,
147      },
148      reason: {
149        type: 'choice',
150        instructions: 'What is the main reason for that tier?',
151        criteria: REASONS,
152      },
153      risky: {
154        type: 'choice',
155        instructions: 'Is this task risky?',
156        criteria: RISK_CRITERIA,
157      },
158      ...(question.previousTurn !== undefined && {
159        previous_outcome: {
160          type: 'choice',
161          instructions: 'How does this prompt react to the result of the previous turn in state.previous_turn?',
162          criteria: FEEDBACK_CRITERIA,
163        },
164      }),
165    },
166  })
167  return {
168    method: 'POST',
169    headers: { Authorization: `Bearer ${question.apiKey}`, 'Content-Type': 'application/json' },
170    body,
171  }
172}
173
174export function parseTierResponse(text: string): JevAnswer | null {
175  try {
176    return parseAnswer(JSON.parse(text))
177  } catch {
178    return null
179  }
180}
181
hooks/panel.tsx 515 lines
1import type { Elements } from 'claude-code'
2
3import { REASONS } from './jev'
4import {
5  costOf,
6  easeIn,
7  familyOf,
8  mixOf,
9  type ModelFamily,
10  peakParallel,
11  pulseFrame,
12  smoothBar,
13  sparkline,
14  spendOf,
15  spinnerFrame,
16  tokens,
17  usd,
18} from './metrics'
19import { TIER_ORDER, TIERS } from './routing'
20import type { Mode, RoutingDecision, Verdict, View } from '../types'
21
22export const PANE = 'model-stack'
23/** Redraw period while something on the pane moves. */
24export const FRAME_MS = 120
25/** How long a fresh decision's bars take to sweep in. */
26export const SWEEP_MS = 600
27
28/** Lines the fixed sections take: border, header, session, main, decision, titles, footer. */
29const FIXED_LINES = 38
30const MIN_AGENTS = 2
31const MIN_HISTORY = 3
32const LABEL = 9
33const JEV_COLOR = '#e86bb0'
34const FAMILY_COLOR: Record<ModelFamily, string> = { opus: '#f0a070', sonnet: '#f2c46d', haiku: '#5fd3b0' }
35const FAMILIES: readonly ModelFamily[] = ['haiku', 'sonnet', 'opus']
36
37const VERDICT_LABEL: Record<Verdict, string> = {
38  firm: 'firme',
39  doubt: 'dúvida',
40  fallback: 'fallback',
41  skipped: 'ignorado',
42}
43
44const VERDICT_COLOR: Record<Verdict, string> = {
45  firm: 'success',
46  doubt: 'warning',
47  fallback: 'error',
48  skipped: 'subtle',
49}
50
51const WHY_LABEL: Record<keyof typeof REASONS, string> = {
52  simple: 'tarefa simples',
53  depth: 'precisa de profundidade',
54  risk: 'mudança arriscada',
55  history: 'histórico de tarefas parecidas',
56}
57
58const MODE_LABEL: Record<Mode, string> = {
59  route: 'rota automática',
60  recommend: 'só recomenda',
61  off: 'desligado',
62}
63
64const MODE_COLOR: Record<Mode, string> = { route: JEV_COLOR, recommend: 'warning', off: 'subtle' }
65
66function clock(at: number): string {
67  const date = new Date(at)
68  return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
69}
70
71function seconds(ms: number): string {
72  const total = Math.max(0, Math.round(ms / 1000))
73  return total < 60 ? `${total}s` : `${Math.floor(total / 60)}m${String(total % 60).padStart(2, '0')}s`
74}
75
76/** `claude-sonnet-5-5` → `sonnet-5-5`; an alias stays as it is. */
77function shortModel(model: string): string {
78  return model.replace(/^claude-/, '')
79}
80
81function modelColor(model: string): string {
82  const family = familyOf(model)
83  return family === null ? 'text' : FAMILY_COLOR[family]
84}
85
86function recommended(decision: RoutingDecision): string {
87  return decision.tier === null ? '—' : TIERS[decision.tier].model
88}
89
90function actual(decision: RoutingDecision): string {
91  if (decision.actualModel !== undefined) return shortModel(decision.actualModel)
92  if (decision.applied === 'as requested') return decision.requestedModel ?? 'herdado'
93  if (decision.applied === 'as picked') return 'atual'
94  return decision.tier === null ? '?' : TIERS[decision.tier].model
95}
96
97function elapsed(decision: RoutingDecision, now: number): string {
98  if (decision.durationMs !== undefined) return seconds(decision.durationMs)
99  return decision.status === 'running' ? seconds(now - decision.at) : '—'
100}
101
102function statusIcon(decision: RoutingDecision, now: number): { icon: string; color: string } {
103  if (decision.status === 'running') return { icon: spinnerFrame(now, FRAME_MS), color: modelColor(actual(decision)) }
104  if (decision.status === 'done') return { icon: '✓', color: 'success' }
105  if (decision.status === 'aborted') return { icon: '◌', color: 'warning' }
106  return { icon: '✗', color: 'error' }
107}
108
109function cost(decision: RoutingDecision): string {
110  const value = costOf(decision)
111  return value === null || decision.status === 'running' ? '' : usd(value)
112}
113
114export type PaneData = {
115  list: readonly RoutingDecision[]
116  currentRun: string | null
117  view: View
118  mode: Mode
119  jevAskedAt: number | null
120  now: number
121  thresholds: { up: number; down: number }
122  columns: number
123  rows: number
124}
125
126type Parts = Pick<Elements['terminal'], 'Box' | 'Text'>
127
128function mainOf(data: PaneData): RoutingDecision | undefined {
129  return (
130    data.list.findLast(one => one.scope === 'main' && one.id === data.currentRun) ??
131    data.list.findLast(one => one.scope === 'main')
132  )
133}
134
135function label({ Text }: Parts, text: string) {
136  return <Text dimColor>{text.padEnd(LABEL)}</Text>
137}
138
139/** Continuation line aligned under a label's value. */
140function under({ Text }: Parts, text: string) {
141  return <Text dimColor wrap="truncate-end">{`${' '.repeat(LABEL)}${text}`}</Text>
142}
143
144function header({ Box, Text }: Parts, data: PaneData) {
145  const asking = data.jevAskedAt !== null
146  return (
147    <Box flexDirection="column">
148      <Text color="claude" bold>
149        Model stack
150      </Text>
151      <Text wrap="truncate-end">
152        <Text color={MODE_COLOR[data.mode]} bold>
153          {asking ? pulseFrame(data.now, FRAME_MS) : '◆'} Jev
154        </Text>
155        <Text dimColor>{` · ${asking ? 'pensando…' : MODE_LABEL[data.mode]}`}</Text>
156      </Text>
157    </Box>
158  )
159}
160
161/** The session's model mix as one stacked bar, each family in its color. */
162function mixBar(parts: Parts, data: PaneData, cells: number) {
163  const { Box, Text } = parts
164  const mix = mixOf(data.list)
165  const total = FAMILIES.reduce((sum, family) => sum + mix[family], 0)
166  let used = 0
167  const widths = FAMILIES.map((family, index) => {
168    const width = index === FAMILIES.length - 1 ? cells - used : Math.round((mix[family] / Math.max(1, total)) * cells)
169    used += width
170    return Math.max(0, width)
171  })
172  return (
173    <Box flexDirection="column">
174      <Text wrap="truncate-end">
175        {label(parts, 'modelos')}
176        {total === 0 ? (
177          <Text dimColor>{'·'.repeat(cells)}</Text>
178        ) : (
179          FAMILIES.map((family, index) => <Text color={FAMILY_COLOR[family]}>{'▆'.repeat(widths[index] ?? 0)}</Text>)
180        )}
181      </Text>
182      <Text wrap="truncate-end">
183        {' '.repeat(LABEL)}
184        {FAMILIES.map((family, index) => (
185          <Text>
186            <Text dimColor>{index === 0 ? '' : ' · '}</Text>
187            <Text color={FAMILY_COLOR[family]}>{`${family[0]} ${mix[family]}`}</Text>
188          </Text>
189        ))}
190      </Text>
191    </Box>
192  )
193}
194
195function sessionSection(parts: Parts, data: PaneData, cells: number) {
196  const { Box, Text } = parts
197  const spend = spendOf(data.list)
198  const costs = data.list
199    .filter(one => one.status !== 'running')
200    .map(one => costOf(one) ?? 0)
201    .slice(-cells)
202  const total = spend.spent + spend.saved
203  const cacheColor =
204    spend.cacheHit === null ? 'subtle' : spend.cacheHit >= 0.7 ? 'success' : spend.cacheHit >= 0.4 ? 'warning' : 'error'
205  const cacheBar = smoothBar(spend.cacheHit ?? 0, cells)
206  return (
207    <Box flexDirection="column" marginTop={1}>
208      <Text bold>Sessão</Text>
209      <Text wrap="truncate-end">
210        {label(parts, 'gasto')}
211        <Text bold>{usd(spend.spent)}</Text>
212      </Text>
213      <Text wrap="truncate-end">
214        {label(parts, 'poupado')}
215        <Text color={spend.saved > 0 ? 'success' : 'subtle'} bold>
216          {usd(spend.saved)}
217        </Text>
218        {spend.saved > 0 && <Text dimColor>{` (${Math.round((spend.saved / total) * 100)}%)`}</Text>}
219      </Text>
220      {under(parts, 'vs. tudo no opus')}
221      <Text wrap="truncate-end">
222        {label(parts, 'cache')}
223        <Text color={cacheColor}>{cacheBar.fill}</Text>
224        <Text dimColor>{cacheBar.track}</Text>
225        <Text color={cacheColor} bold>
226          {spend.cacheHit === null ? ' —' : ` ${Math.round(spend.cacheHit * 100)}%`}
227        </Text>
228      </Text>
229      {under(parts, `lido ${tokens(spend.cacheRead)} · escrito ${tokens(spend.cacheWrite)}`)}
230      {mixBar(parts, data, cells)}
231      <Text wrap="truncate-end">
232        {label(parts, 'custo')}
233        {costs.length > 1 ? <Text color="claude">{sparkline(costs)}</Text> : <Text dimColor>{'·'.repeat(cells)}</Text>}
234      </Text>
235      {under(parts, `por turno · ${tokens(spend.output)} gerados`)}
236    </Box>
237  )
238}
239
240function mainSection(parts: Parts, data: PaneData) {
241  const { Box, Text } = parts
242  const main = mainOf(data)
243  if (main === undefined) {
244    return (
245      <Box flexDirection="column" marginTop={1}>
246        <Text bold>Main</Text>
247        <Text dimColor>aguardando o primeiro prompt</Text>
248      </Box>
249    )
250  }
251  const { icon, color } = statusIcon(main, data.now)
252  const spent = cost(main)
253  const stats = [
254    elapsed(main, data.now),
255    main.outputTokens !== undefined ? `${tokens(main.outputTokens)} out` : '',
256    spent,
257  ].filter(part => part !== '')
258  return (
259    <Box flexDirection="column" marginTop={1}>
260      <Text bold>Main</Text>
261      <Text wrap="truncate-end">
262        <Text color={color}>{`${icon} `}</Text>
263        <Text color={modelColor(actual(main))} bold>
264          {actual(main)}
265        </Text>
266        <Text dimColor>{main.applied === 'as picked' ? ' · fixo' : ` · tier ${main.tier ?? '—'}`}</Text>
267      </Text>
268      {main.escalatedTo !== undefined && <Text color="warning" wrap="truncate-end">{`↑ escalou p/ ${main.escalatedTo}`}</Text>}
269      <Text dimColor wrap="truncate-end">{stats.join(' · ')}</Text>
270      <Text dimColor italic wrap="truncate-end">{`“${main.description}”`}</Text>
271    </Box>
272  )
273}
274
275function jevSection({ Box, Text }: Parts, data: PaneData, cells: number) {
276  const last = data.list.findLast(one => one.verdict !== 'skipped')
277  if (last === undefined) {
278    return (
279      <Box flexDirection="column" marginTop={1}>
280        <Text color={JEV_COLOR} bold>
281          Decisão
282        </Text>
283        <Text dimColor>Nada roteado ainda.</Text>
284      </Box>
285    )
286  }
287  const decidedAt = last.at + (last.jevMs ?? 0)
288  const sweep = easeIn(data.now, decidedAt, SWEEP_MS)
289  const ran = actual(last)
290  const changed = last.tier !== null && familyOf(ran) !== familyOf(recommended(last))
291  const why = last.why === undefined ? undefined : (WHY_LABEL[last.why] ?? last.why)
292  return (
293    <Box flexDirection="column" marginTop={1}>
294      <Text wrap="truncate-end">
295        <Text color={JEV_COLOR} bold>
296          Decisão
297        </Text>
298        <Text dimColor>{` · ${last.jevMs !== undefined ? `${last.jevMs}ms · ` : ''}há ${seconds(data.now - decidedAt)}`}</Text>
299      </Text>
300      <Text dimColor wrap="truncate-end">
301        {`${last.scope === 'main' ? 'main' : last.subagentType.replace(/^model-stack:/, '')}: “${last.description}”`}
302      </Text>
303      {TIER_ORDER.map(tier => {
304        const value = (last.probabilities[tier] ?? 0) * sweep
305        const isChosen = last.tier === tier
306        const { fill, track } = smoothBar(value, cells)
307        const spec = TIERS[tier]
308        return (
309          <Text wrap="truncate-end">
310            <Text color={isChosen ? VERDICT_COLOR[last.verdict] : 'subtle'}>{isChosen ? '▸' : ' '}</Text>
311            <Text bold={isChosen} dimColor={!isChosen}>
312              {tier.padEnd(8)}
313            </Text>
314            <Text color={FAMILY_COLOR[spec.model]} dimColor={!isChosen}>
315              {fill}
316            </Text>
317            <Text dimColor>{track}</Text>
318            <Text bold={isChosen} dimColor={!isChosen}>
319              {` ${value.toFixed(2)}`}
320            </Text>
321          </Text>
322        )
323      })}
324      <Text wrap="truncate-end">
325        <Text color={VERDICT_COLOR[last.verdict]} bold>
326          {VERDICT_LABEL[last.verdict]}
327        </Text>
328        <Text dimColor>{changed ? ' · rec. ' : ' → '}</Text>
329        {changed && <Text color={modelColor(recommended(last))}>{recommended(last)}</Text>}
330        {changed && <Text dimColor> → </Text>}
331        <Text color={modelColor(ran)}>{ran}</Text>
332      </Text>
333      {last.tier !== null && (
334        <Text dimColor wrap="truncate-end">{`${TIERS[last.tier].model} ${TIERS[last.tier].effort}${why === undefined ? '' : ` · ${why}`}`}</Text>
335      )}
336      {last.verdict === 'doubt' && last.confidence !== null && (
337        <Text dimColor wrap="truncate-end">{`conf ${last.confidence.toFixed(2)} < limiar ${(last.neededConfidence ?? data.thresholds.down).toFixed(2)}`}</Text>
338      )}
339      {last.risk != null && last.risk >= 0.5 && (
340        <Text color="warning" wrap="truncate-end">{`risco ${last.risk.toFixed(2)}`}</Text>
341      )}
342      {last.reason !== undefined && <Text color="error" wrap="truncate-end">{last.reason}</Text>}
343    </Box>
344  )
345}
346
347type TreeRow = { decision: RoutingDecision; prefix: string; indent: string }
348
349/** Spawns of the current run as a tree: a subagent hangs under the agent that spawned it. */
350function treeRows(data: PaneData): TreeRow[] {
351  const spawns = data.list.filter(one => one.scope === 'subagent' && one.runId === data.currentRun)
352  const rows: TreeRow[] = []
353  const walk = (parent: string | undefined, indent: string): void => {
354    const children = spawns.filter(one => one.parentAgentId === parent)
355    children.forEach((one, index) => {
356      const isLast = index === children.length - 1
357      const below = `${indent}${isLast ? '   ' : '│  '}`
358      rows.push({ decision: one, prefix: `${indent}${isLast ? '└─ ' : '├─ '}`, indent: below })
359      if (one.agentId !== undefined) walk(one.agentId, below)
360    })
361  }
362  walk(undefined, '')
363  return rows
364}
365
366/** The agent's last line: what it is doing while it runs, why it got its model once it ended. */
367function detail(decision: RoutingDecision): string {
368  if (decision.status === 'running') return decision.lastAction ?? 'iniciando…'
369  const parts = [VERDICT_LABEL[decision.verdict]]
370  if (decision.tier !== null) parts.push(`tier ${decision.tier}`)
371  if (decision.why !== undefined) parts.push(WHY_LABEL[decision.why] ?? decision.why)
372  if (decision.reason !== undefined) parts.push(decision.reason)
373  return parts.join(' · ')
374}
375
376function agentGraph({ Box, Text }: Parts, data: PaneData, limit: number) {
377  const main = mainOf(data)
378  const rows = treeRows(data)
379  const running = rows.filter(one => one.decision.status === 'running').length
380  const peak = peakParallel(
381    rows.map(one => one.decision),
382    data.now,
383  )
384  return (
385    <Box flexDirection="column" marginTop={1}>
386      <Text wrap="truncate-end">
387        <Text bold>Agentes</Text>
388        <Text dimColor>{running > 0 ? '' : ' · ocioso'}</Text>
389        {running > 0 && <Text color="claude">{` · ${running} rodando`}</Text>}
390        {peak > 1 && <Text dimColor>{` · pico ${peak}`}</Text>}
391      </Text>
392      <Text wrap="truncate-end">
393        <Text color={main === undefined ? 'subtle' : modelColor(actual(main))}>● </Text>
394        <Text bold>main</Text>
395      </Text>
396      {rows.length === 0 && <Text dimColor>└─ nenhum subagent neste turno</Text>}
397      {rows.slice(-limit).map(({ decision, prefix, indent }) => {
398        const { icon, color } = statusIcon(decision, data.now)
399        const isRunning = decision.status === 'running'
400        const stats = [
401          elapsed(decision, data.now),
402          decision.outputTokens !== undefined ? `${tokens(decision.outputTokens)} out` : '',
403          cost(decision),
404        ].filter(part => part !== '')
405        return (
406          <Box flexDirection="column">
407            <Text wrap="truncate-end">
408              <Text dimColor>{prefix}</Text>
409              <Text color={color} bold={isRunning}>{`${icon} `}</Text>
410              <Text dimColor={!isRunning}>{decision.description}</Text>
411            </Text>
412            <Text wrap="truncate-end">
413              <Text dimColor>{`${indent}   `}</Text>
414              <Text color={modelColor(actual(decision))}>{actual(decision)}</Text>
415              {decision.escalatedTo !== undefined && <Text color="warning"> ↑</Text>}
416              <Text dimColor>{` · ${stats.join(' · ')}`}</Text>
417            </Text>
418            <Text dimColor wrap="truncate-end">{`${indent}   ↳ ${detail(decision)}`}</Text>
419          </Box>
420        )
421      })}
422    </Box>
423  )
424}
425
426/** The newest decisions of the session, newest first, two lines each. */
427function history({ Box, Text }: Parts, data: PaneData, limit: number) {
428  const rows = data.list.slice(-limit).reverse()
429  return (
430    <Box flexDirection="column" marginTop={1}>
431      <Text bold>Histórico</Text>
432      {rows.length === 0 && <Text dimColor>vazio</Text>}
433      {rows.map(one => {
434        const { icon, color } = statusIcon(one, data.now)
435        const spent = cost(one)
436        return (
437          <Box flexDirection="column">
438            <Text wrap="truncate-end">
439              <Text dimColor>{`${clock(one.at)} `}</Text>
440              <Text color={color}>{`${icon} `}</Text>
441              <Text color={modelColor(actual(one))}>{actual(one)}</Text>
442              <Text dimColor>{` · ${elapsed(one, data.now)}${spent === '' ? '' : ` · ${spent}`}`}</Text>
443            </Text>
444            <Text dimColor wrap="truncate-end">{`      ${one.scope === 'main' ? 'main' : 'sub'}: ${one.description}`}</Text>
445          </Box>
446        )
447      })}
448    </Box>
449  )
450}
451
452/** The technical log: recommended → real, verdict and reason, three lines per decision. */
453function log({ Box, Text }: Parts, data: PaneData, limit: number) {
454  return (
455    <Box flexDirection="column" marginTop={1}>
456      <Text bold>Log da sessão</Text>
457      {data.list.length === 0 && <Text dimColor>vazio</Text>}
458      {data.list.slice(-limit).map(one => {
459        const { icon, color } = statusIcon(one, data.now)
460        const spent = cost(one)
461        const why = one.why === undefined ? '' : ` · ${WHY_LABEL[one.why] ?? one.why}`
462        return (
463          <Box flexDirection="column">
464            <Text wrap="truncate-end">
465              <Text dimColor>{`${clock(one.at)} `}</Text>
466              <Text color={color}>{`${icon} `}</Text>
467              <Text>{one.description}</Text>
468            </Text>
469            <Text wrap="truncate-end">
470              <Text dimColor>{'      '}</Text>
471              <Text color={VERDICT_COLOR[one.verdict]}>{VERDICT_LABEL[one.verdict]}</Text>
472              <Text dimColor>{` ${recommended(one)} → ${actual(one)}`}</Text>
473              {one.escalatedTo !== undefined && <Text color="warning">{` ↑${one.escalatedTo}`}</Text>}
474            </Text>
475            <Text dimColor wrap="truncate-end">
476              {`      ${one.scope === 'main' ? 'main' : 'sub'} · ${elapsed(one, data.now)}${spent === '' ? '' : ` · ${spent}`}${why}`}
477            </Text>
478          </Box>
479        )
480      })}
481    </Box>
482  )
483}
484
485export function paneTree(parts: Parts, data: PaneData) {
486  const { Box, Text } = parts
487  // Bars follow the pane width: the label column and the border take what is left of it.
488  const cells = Math.max(6, Math.min(24, data.columns - 22))
489  // What the fixed sections leave goes to the lists: an agent takes three lines, a history entry two.
490  const spare = Math.max(0, data.rows - FIXED_LINES)
491  const agents = Math.max(MIN_AGENTS, Math.floor((spare * 0.55) / 3))
492  const historyRows = Math.max(MIN_HISTORY, Math.floor((spare - agents * 3) / 2))
493  return (
494    <Box flexDirection="column" borderStyle="round" borderColor="claude" paddingX={1}>
495      {header(parts, data)}
496      {sessionSection(parts, data, cells)}
497      {mainSection(parts, data)}
498      {jevSection(parts, data, cells)}
499      {data.view === 'details' ? (
500        log(parts, data, Math.max(MIN_HISTORY, Math.floor(spare / 3)))
501      ) : (
502        <Box flexDirection="column">
503          {agentGraph(parts, data, agents)}
504          {history(parts, data, historyRows)}
505        </Box>
506      )}
507      <Box flexDirection="column" marginTop={1}>
508        <Text dimColor wrap="truncate-end">{`/stack ${data.view === 'details' ? 'overview' : 'details'}`}</Text>
509        <Text dimColor wrap="truncate-end">/stack export</Text>
510        <Text dimColor wrap="truncate-end">/stack mode off|recommend|route</Text>
511      </Box>
512    </Box>
513  )
514}
515
hooks/quality.ts 102 lines
1import type { Feedback, Outcome, RoutingDecision, Scope } from '../types'
2
3const TEST_COMMAND = /\b(pytest|vitest|jest|eslint)\b|\bnpm\s+(run\s+)?(test|lint)\b/
4const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
5
6/** How many main-chat runs after an edit still count as reworking it. */
7const REWORK_RUNS = 2
8
9export function isTestCommand(command: string): boolean {
10  return TEST_COMMAND.test(command)
11}
12
13export function isEditTool(tool: string): boolean {
14  return EDIT_TOOLS.has(tool)
15}
16
17export type TestRuns = NonNullable<RoutingDecision['tests']>
18
19export function recordTestRun(previous: TestRuns | undefined, isError: boolean): TestRuns {
20  return { runs: (previous?.runs ?? 0) + 1, lastPassed: !isError }
21}
22
23const CORRECTION = /\b(n[aã]o|errado|errada|volta|desfaz|reverte|revert|undo|wrong|incorreto)\b/i
24const ACCEPTED = /^\s*(ok|okay|beleza|valeu|boa|show|perfeito|isso|certo|top|legal|thanks|obrigado)\b[\s!.]*$/i
25
26/** Longer prompts say too many things for a regex to read them as a reaction. */
27const FOLLOW_UP_CHARS = 80
28
29/** Engine-injected prompts (`<agent-message …>`, `<command-message …>`) are not the person reacting. */
30export function isInjectedPrompt(text: string): boolean {
31  return text.trimStart().startsWith('<')
32}
33
34/** Regex reading of a short follow-up, for when Jev is not asked or does not answer. */
35export function classifyFollowUp(text: string): Feedback | null {
36  if (isInjectedPrompt(text) || text.trim().length > FOLLOW_UP_CHARS) return null
37  if (CORRECTION.test(text)) return 'correction'
38  if (ACCEPTED.test(text)) return 'accepted'
39  return null
40}
41
42/** Jev's label for the previous turn; anything else, or a doubtful pick, says nothing. */
43export function feedbackFromJev(choice: string, confidence: number, threshold: number): Feedback | null {
44  if (confidence < threshold) return null
45  return choice === 'accepted' || choice === 'correction' || choice === 'new_task' ? choice : null
46}
47
48/** A label the person gave by hand is never replaced by an inferred one. */
49export function mergeFeedback(current: Feedback | undefined, inferred: Feedback): Feedback {
50  return current === 'manual_good' || current === 'manual_bad' ? current : inferred
51}
52
53type Judged = Pick<RoutingDecision, 'status' | 'escalatedTo' | 'feedback' | 'tests'>
54
55/** Rework is left out on purpose: iterating on a file is normal, so it only informs. */
56export function deriveOutcome(decision: Judged): Outcome {
57  if (decision.feedback === 'manual_bad') return 'bad'
58  if (decision.feedback === 'manual_good') return 'good'
59  if (
60    decision.status === 'aborted' ||
61    decision.status === 'error' ||
62    decision.escalatedTo !== undefined ||
63    decision.feedback === 'correction' ||
64    decision.tests?.lastPassed === false
65  ) {
66    return 'bad'
67  }
68  if (decision.feedback === 'accepted') return 'good'
69  return decision.feedback === 'new_task' ? 'good_weak' : 'unknown'
70}
71
72export type EditEntry = { id: string; runId: string | null; scope: Scope; files: ReadonlySet<string> }
73
74/**
75 * Decisions whose edit of `file` the main chat is now editing again: those from
76 * the previous few runs, plus subagents of the current run. The current run's own
77 * main turn is never its own rework.
78 */
79export function reworkTargets(
80  ledger: readonly EditEntry[],
81  runOrder: readonly string[],
82  currentRun: string,
83  file: string,
84): string[] {
85  const at = runOrder.indexOf(currentRun)
86  const earlier = at === -1 ? [] : runOrder.slice(Math.max(0, at - REWORK_RUNS), at)
87  return ledger
88    .filter(entry => {
89      if (!entry.files.has(file) || entry.id === currentRun) return false
90      if (entry.runId === currentRun) return entry.scope === 'subagent'
91      return entry.runId !== null && earlier.includes(entry.runId)
92    })
93    .map(entry => entry.id)
94}
95
96export type TestsLabel = 'passed' | 'failed' | null
97
98export function testsLabel(tests: TestRuns | undefined): TestsLabel {
99  if (tests === undefined) return null
100  return tests.lastPassed ? 'passed' : 'failed'
101}
102
hooks/routing.ts 297 lines
1import type { MainPlan, Tier, Verdict } from '../types'
2
3export const PLUGIN = 'model-stack'
4
5export type TierSpec = {
6  model: 'haiku' | 'sonnet' | 'opus'
7  /** Full model id, for the main chat's requests (subagents take the alias). */
8  id: string
9  effort: 'low' | 'medium' | 'high'
10  agent: string
11}
12
13export const TIERS: Record<Tier, TierSpec> = {
14  light: { model: 'haiku', id: 'claude-haiku-5-5', effort: 'medium', agent: 'haiku-medium' },
15  default: { model: 'sonnet', id: 'claude-sonnet-5-5', effort: 'medium', agent: 'sonnet-medium' },
16  deep: { model: 'sonnet', id: 'claude-sonnet-5-5', effort: 'high', agent: 'sonnet-high' },
17  heavy: { model: 'opus', id: 'claude-opus-5-5', effort: 'medium', agent: 'opus-medium' },
18  max: { model: 'opus', id: 'claude-opus-5-5', effort: 'high', agent: 'opus-high' },
19}
20
21export const TIER_ORDER: readonly Tier[] = ['light', 'default', 'deep', 'heavy', 'max']
22
23// Agent types whose whole definition may be swapped for a tier agent. Every other
24// type keeps its own prompt, tools and effort; only its model is changed.
25const GENERIC_TYPES = new Set(['general-purpose', 'claude'])
26
27export type JevChoice = {
28  choice: Tier
29  confidence: number
30  probabilities: Partial<Record<Tier, number>>
31}
32
33export type SpawnFacts = {
34  subagentType: string
35  requestedModel: string | null
36  isFork: boolean
37  isTeammate: boolean
38}
39
40export type RoutingOutcome = {
41  verdict: Verdict
42  tier: Tier | null
43  subagentType?: string
44  model?: string
45  applied: string
46}
47
48export function isTier(value: unknown): value is Tier {
49  return typeof value === 'string' && (TIER_ORDER as readonly string[]).includes(value)
50}
51
52export function tierAgentName(tier: Tier): string {
53  return `${PLUGIN}:${TIERS[tier].agent}`
54}
55
56export function isTierAgent(subagentType: string): boolean {
57  return subagentType.startsWith(`${PLUGIN}:`)
58}
59
60export function tierOfAgent(subagentType: string): Tier | null {
61  return TIER_ORDER.find(tier => tierAgentName(tier) === subagentType) ?? null
62}
63
64/** 0 haiku, 1 sonnet, 2 opus; anything else ranks above all so it is never replaced. */
65export function modelRank(model: string): number {
66  const name = model.toLowerCase()
67  if (name.includes('haiku')) return 0
68  if (name.includes('sonnet')) return 1
69  if (name.includes('opus')) return 2
70  return 3
71}
72
73export function shouldRoute(facts: SpawnFacts): boolean {
74  return !facts.isFork && !facts.isTeammate
75}
76
77function apply(tier: Tier, verdict: Verdict, facts: SpawnFacts): RoutingOutcome {
78  const spec = TIERS[tier]
79  if (GENERIC_TYPES.has(facts.subagentType) || isTierAgent(facts.subagentType)) {
80    const agent = tierAgentName(tier)
81    return { verdict, tier, subagentType: agent, model: spec.model, applied: agent }
82  }
83  return { verdict, tier, model: spec.model, applied: `${facts.subagentType} · ${spec.model}` }
84}
85
86function keepRequested(verdict: Verdict, tier: Tier | null): RoutingOutcome {
87  return { verdict, tier, applied: 'as requested' }
88}
89
90/** Recommend mode: keep Jev's verdict and tier for the record, run the spawn as requested. */
91export function asRecommendation(outcome: RoutingOutcome): RoutingOutcome {
92  return { verdict: outcome.verdict, tier: outcome.tier, applied: 'as requested' }
93}
94
95/** Moving to a dearer tier costs only tokens; moving to a cheaper one risks rework, so it needs more confidence. */
96export type Thresholds = { up: number; down: number }
97
98/** The tier a task flagged as risky runs on, whatever else was decided. */
99export const RISKY_TIER: Tier = 'max'
100
101/** The confidence a move from `current` to `target` needs; staying put is held to the upgrade bar. */
102export function neededConfidence(target: number, current: number, thresholds: Thresholds): number {
103  return target < current ? thresholds.down : thresholds.up
104}
105
106export function decide(
107  facts: SpawnFacts,
108  jev: JevChoice | null,
109  thresholds: Thresholds,
110  defaultTier: Tier,
111  risky = false,
112): RoutingOutcome {
113  // Risk overrides doubt and a requested model alike: the risky tier is the top one, so it never lowers.
114  if (risky) return apply(RISKY_TIER, 'firm', facts)
115  // A tier agent picked by Opus is a floor: Jev may raise it, never lower it.
116  const floor = tierOfAgent(facts.subagentType)
117  if (floor !== null) {
118    if (jev === null) return keepRequested('fallback', null)
119    if (TIER_ORDER.indexOf(jev.choice) <= TIER_ORDER.indexOf(floor)) {
120      return keepRequested(jev.confidence < thresholds.up ? 'doubt' : 'firm', jev.choice)
121    }
122    if (jev.confidence < thresholds.up) return keepRequested('doubt', jev.choice)
123    return apply(jev.choice, 'firm', facts)
124  }
125  if (facts.requestedModel !== null) {
126    if (jev === null) return keepRequested('fallback', null)
127    if (jev.confidence < thresholds.up) return keepRequested('doubt', jev.choice)
128    // Jev may raise or keep what Opus asked for, never lower it.
129    if (modelRank(facts.requestedModel) > modelRank(TIERS[jev.choice].model)) {
130      return keepRequested('firm', jev.choice)
131    }
132    return apply(jev.choice, 'firm', facts)
133  }
134  if (jev === null) return apply(defaultTier, 'fallback', facts)
135  const needed = neededConfidence(TIER_ORDER.indexOf(jev.choice), TIER_ORDER.indexOf(defaultTier), thresholds)
136  if (jev.confidence < needed) return apply(defaultTier, 'doubt', facts)
137  return apply(jev.choice, 'firm', facts)
138}
139
140const MAIN_MIN_CHARS = 15
141
142/** Short acknowledgements, slash commands and empty continuations keep the current model. */
143export function skipMainPrompt(text: string): boolean {
144  const trimmed = text.trim()
145  return trimmed.length < MAIN_MIN_CHARS || trimmed.startsWith('/')
146}
147
148/** The higher of two tiers; null counts as no bound. */
149export function higherTier(a: Tier | null, b: Tier | null): Tier | null {
150  if (a === null) return b
151  if (b === null) return a
152  return TIER_ORDER.indexOf(a) >= TIER_ORDER.indexOf(b) ? a : b
153}
154
155type Effort = MainPlan['effort']
156
157const EFFORT_RANK: Record<string, number> = { low: 0, medium: 1, high: 2 }
158
159/** Effort-only routing keeps the model, so each tier stands for an effort alone. */
160export const EFFORT_BY_TIER: Record<Tier, Effort> = {
161  light: 'low',
162  default: 'medium',
163  deep: 'high',
164  heavy: 'high',
165  max: 'high',
166}
167
168/** low 0, medium 1, high 2; efforts above high count as high, and a numeric or missing one as medium. */
169export function effortRank(effort: unknown): number {
170  return typeof effort === 'string' ? (EFFORT_RANK[effort] ?? 2) : 1
171}
172
173/** Orders a model and effort pair the way TIER_ORDER orders tiers. */
174export function stepRank(model: string, effort: unknown): number {
175  return modelRank(model) * 3 + effortRank(effort)
176}
177
178/** The lowest tier at or above a model and effort, so a floor built from it never goes below them. */
179export function tierCovering(model: string, effort: unknown): Tier {
180  const rank = stepRank(model, effort)
181  return TIER_ORDER.find(tier => stepRank(TIERS[tier].model, TIERS[tier].effort) >= rank) ?? 'max'
182}
183
184/** The lowest tier whose effort-only setting is at or above `effort`. */
185export function tierForEffort(effort: unknown): Tier {
186  const rank = effortRank(effort)
187  return TIER_ORDER.find(tier => effortRank(EFFORT_BY_TIER[tier]) >= rank) ?? 'max'
188}
189
190/** `model` rewrites the main chat's model and effort; `effort` keeps the model, so the prompt cache survives. */
191export type MainRouting = 'model' | 'effort'
192
193export type MainInputs = {
194  jev: JevChoice | null
195  thresholds: Thresholds
196  /** Lifts a confident pick; doubt and fallback keep the picked model. */
197  floor: Tier | null
198  /** After a correction, the previous turn's tier: no verdict runs below it. */
199  correctionFloor: Tier | null
200  risky: boolean
201  routing: MainRouting
202}
203
204export type MainResolution = {
205  verdict: Verdict
206  /** Jev's own choice, kept for the record. */
207  tier: Tier | null
208  /** The confidence the move needed; null when Jev did not answer. */
209  needed: number | null
210  /** What to run instead of the step's own model and effort; null keeps them. */
211  run: { tier: Tier; model: string; effort: Effort } | null
212}
213
214/**
215 * Decides a main-chat turn against the model and effort it would run on: a dearer
216 * tier needs `thresholds.up`, a cheaper one `thresholds.down`. Risk and a correction
217 * only ever raise.
218 */
219export function resolveMain(step: { model: string; effort?: unknown }, inputs: MainInputs): MainResolution {
220  const { jev, thresholds, routing } = inputs
221  const rank = (tier: Tier): number =>
222    routing === 'model' ? stepRank(TIERS[tier].model, TIERS[tier].effort) : effortRank(EFFORT_BY_TIER[tier])
223  const current = routing === 'model' ? stepRank(step.model, step.effort) : effortRank(step.effort)
224
225  let verdict: Verdict = 'fallback'
226  let needed: number | null = null
227  let target: Tier | null = null
228  if (jev !== null) {
229    const picked = higherTier(jev.choice, inputs.floor) ?? jev.choice
230    needed = neededConfidence(rank(picked), current, thresholds)
231    verdict = jev.confidence < needed ? 'doubt' : 'firm'
232    if (verdict === 'firm') target = picked
233  }
234  const minimum = higherTier(inputs.risky ? RISKY_TIER : null, inputs.correctionFloor)
235  if (minimum !== null && rank(minimum) > (target === null ? current : rank(target))) target = minimum
236
237  if (target === null || rank(target) === current) return { verdict, tier: jev?.choice ?? null, needed, run: null }
238  const run =
239    routing === 'model'
240      ? { tier: target, model: TIERS[target].id, effort: TIERS[target].effort }
241      : { tier: target, model: step.model, effort: EFFORT_BY_TIER[target] }
242  return { verdict, tier: jev?.choice ?? null, needed, run }
243}
244
245/** The model and effort a main-chat step runs on: the turn's plan, or the request as the engine built it. */
246export function applyPlan<T extends { turnId: string; agentId?: string }>(
247  step: T,
248  planned: MainPlan | null,
249): T | (T & { model: string; effort: Effort }) {
250  if (step.agentId !== undefined || planned === null || planned.turnId !== step.turnId) return step
251  return { ...step, model: planned.model, effort: planned.effort }
252}
253
254/** The plan one tier up, or null when nothing is left to raise. Effort routing raises the effort alone. */
255export function raisePlan(planned: MainPlan, routing: MainRouting): MainPlan | null {
256  const raised = tierAbove(planned.tier)
257  if (raised === null) return null
258  if (routing === 'model') return { ...planned, tier: raised, model: TIERS[raised].id, effort: TIERS[raised].effort }
259  const effort = EFFORT_BY_TIER[raised]
260  if (effortRank(effort) <= effortRank(planned.effort)) return null
261  return { ...planned, tier: raised, effort }
262}
263
264const ERROR_STREAK_LIMIT = 2
265const EDITS_PER_FILE_LIMIT = 3
266const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
267
268/** What the turn's tool calls say about whether the chosen tier is coping. */
269export type Signals = {
270  errorStreak: number
271  editsByFile: Readonly<Record<string, number>>
272}
273
274export const NO_SIGNALS: Signals = { errorStreak: 0, editsByFile: {} }
275
276export function recordToolOutcome(
277  signals: Signals,
278  outcome: { tool: string; filePath?: string; isError: boolean },
279): Signals {
280  const errorStreak = outcome.isError ? signals.errorStreak + 1 : 0
281  if (!EDIT_TOOLS.has(outcome.tool) || outcome.filePath === undefined) return { ...signals, errorStreak }
282  const edits = (signals.editsByFile[outcome.filePath] ?? 0) + 1
283  return { errorStreak, editsByFile: { ...signals.editsByFile, [outcome.filePath]: edits } }
284}
285
286export function isStruggling(signals: Signals): boolean {
287  return (
288    signals.errorStreak >= ERROR_STREAK_LIMIT ||
289    Object.values(signals.editsByFile).some(count => count >= EDITS_PER_FILE_LIMIT)
290  )
291}
292
293/** One tier up, or null at the top. Escalation never goes down. */
294export function tierAbove(tier: Tier): Tier | null {
295  return TIER_ORDER[TIER_ORDER.indexOf(tier) + 1] ?? null
296}
297
hooks/metrics.ts 154 lines
1import type { RoutingDecision } from '../types'
2
3export type ModelFamily = 'haiku' | 'sonnet' | 'opus'
4
5/** USD per million tokens; a cache write is billed at 1.25x input (5-minute entry). */
6type Price = { input: number; output: number; cacheRead: number; cacheWrite: number }
7
8export const PRICES: Record<ModelFamily, Price> = {
9  opus: { input: 4, output: 20, cacheRead: 0.2, cacheWrite: 5 },
10  sonnet: { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 },
11  haiku: { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.125 },
12}
13
14export function familyOf(model: string | undefined): ModelFamily | null {
15  const name = (model ?? '').toLowerCase()
16  if (name.includes('opus')) return 'opus'
17  if (name.includes('sonnet')) return 'sonnet'
18  if (name.includes('haiku')) return 'haiku'
19  return null
20}
21
22function priced(decision: RoutingDecision, price: Price): number {
23  const total =
24    (decision.inputTokens ?? 0) * price.input +
25    (decision.outputTokens ?? 0) * price.output +
26    (decision.cacheReadTokens ?? 0) * price.cacheRead +
27    (decision.cacheCreationTokens ?? 0) * price.cacheWrite
28  return total / 1_000_000
29}
30
31/** What the decision's tokens cost on the model that ran it; null when that model is unknown. */
32export function costOf(decision: RoutingDecision): number | null {
33  const family = familyOf(decision.actualModel)
34  return family === null ? null : priced(decision, PRICES[family])
35}
36
37/** The same tokens billed as if everything had run on opus: what routing is measured against. */
38export function opusCostOf(decision: RoutingDecision): number {
39  return priced(decision, PRICES.opus)
40}
41
42export type Spend = {
43  spent: number
44  saved: number
45  cacheHit: number | null
46  cacheRead: number
47  cacheWrite: number
48  output: number
49}
50
51export function spendOf(list: readonly RoutingDecision[]): Spend {
52  let spent = 0
53  let baseline = 0
54  let read = 0
55  let fresh = 0
56  let written = 0
57  let output = 0
58  for (const one of list) {
59    const cost = costOf(one)
60    if (cost === null) continue
61    spent += cost
62    baseline += opusCostOf(one)
63    read += one.cacheReadTokens ?? 0
64    fresh += (one.inputTokens ?? 0) + (one.cacheCreationTokens ?? 0)
65    written += one.cacheCreationTokens ?? 0
66    output += one.outputTokens ?? 0
67  }
68  const input = read + fresh
69  return {
70    spent,
71    saved: Math.max(0, baseline - spent),
72    cacheHit: input === 0 ? null : read / input,
73    cacheRead: read,
74    cacheWrite: written,
75    output,
76  }
77}
78
79export type Mix = Record<ModelFamily, number>
80
81export function mixOf(list: readonly RoutingDecision[]): Mix {
82  const mix: Mix = { haiku: 0, sonnet: 0, opus: 0 }
83  for (const one of list) {
84    const family = familyOf(one.actualModel)
85    if (family !== null) mix[family] += 1
86  }
87  return mix
88}
89
90export function usd(value: number): string {
91  if (value === 0) return '$0'
92  if (value < 0.01) return '<$0.01'
93  return value < 10 ? `$${value.toFixed(2)}` : `$${value.toFixed(1)}`
94}
95
96export function tokens(value: number): string {
97  if (value < 1000) return String(value)
98  return value < 1_000_000 ? `${(value / 1000).toFixed(1)}k` : `${(value / 1_000_000).toFixed(1)}M`
99}
100
101const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
102
103/** A bar drawn to an eighth of a cell, padded with a faint track to `cells`. */
104export function smoothBar(fraction: number, cells: number): { fill: string; track: string } {
105  const eighths = Math.round(Math.max(0, Math.min(1, fraction)) * cells * 8)
106  const whole = Math.floor(eighths / 8)
107  const part = EIGHTHS[eighths % 8] ?? ''
108  const fill = '█'.repeat(whole) + part
109  return { fill, track: '·'.repeat(cells - whole - (part === '' ? 0 : 1)) }
110}
111
112const SPARKS = '▁▂▃▄▅▆▇█'
113
114export function sparkline(values: readonly number[]): string {
115  if (values.length === 0) return ''
116  const max = Math.max(...values)
117  if (max <= 0) return '▁'.repeat(values.length)
118  return values.map(value => SPARKS[Math.min(7, Math.floor((value / max) * 7.999))] ?? '▁').join('')
119}
120
121const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
122const PULSE = ['◇', '◈', '◆', '◈']
123
124export function spinnerFrame(now: number, frameMs: number): string {
125  return SPINNER[Math.floor(now / frameMs) % SPINNER.length] ?? '⠋'
126}
127
128export function pulseFrame(now: number, frameMs: number): string {
129  return PULSE[Math.floor(now / (frameMs * 2)) % PULSE.length] ?? '◆'
130}
131
132/** 0 → 1 over `durationMs` from `start`, eased out so the bar decelerates into place. */
133export function easeIn(now: number, start: number, durationMs: number): number {
134  const t = Math.max(0, Math.min(1, (now - start) / durationMs))
135  return 1 - (1 - t) ** 3
136}
137
138/** Most subagents running at once during the run, from their start and end times. */
139export function peakParallel(list: readonly RoutingDecision[], now: number): number {
140  const edges: [number, number][] = []
141  for (const one of list) {
142    edges.push([one.at, 1])
143    edges.push([one.durationMs === undefined ? (one.status === 'running' ? now : one.at) : one.at + one.durationMs, -1])
144  }
145  edges.sort((a, b) => a[0] - b[0] || a[1] - b[1])
146  let current = 0
147  let peak = 0
148  for (const [, delta] of edges) {
149    current += delta
150    peak = Math.max(peak, current)
151  }
152  return peak
153}
154
types/index.d.ts 115 lines
1export type Tier = 'light' | 'default' | 'deep' | 'heavy' | 'max'
2
3/** firm: Jev's choice was applied; doubt: below threshold; fallback: Jev unavailable; skipped: not routed. */
4export type Verdict = 'firm' | 'doubt' | 'fallback' | 'skipped'
5
6export type DecisionStatus = 'running' | 'done' | 'error' | 'aborted' | 'refused'
7
8/** off: nothing is asked or applied; recommend: Jev's tier is shown, never applied; route: applied. */
9export type Mode = 'off' | 'recommend' | 'route'
10
11/** Who the decision is for: the main chat turn or a spawned subagent. */
12export type Scope = 'main' | 'subagent'
13
14/**
15 * What the person's next move said about the decision. The first three are inferred
16 * (Jev reading the follow-up, or a regex for short ones); the manual ones come from
17 * `/stack good|bad` and always win.
18 */
19export type Feedback = 'accepted' | 'correction' | 'new_task' | 'manual_good' | 'manual_bad'
20
21/**
22 * Derived from status, escalation, feedback and tests; `unknown` until a signal says otherwise.
23 * `good_weak`: the person moved on to a new task with no bad signal, weaker evidence than `good`.
24 */
25export type Outcome = 'good' | 'good_weak' | 'bad' | 'unknown'
26
27export type RoutingDecision = {
28  /** The Agent tool call for a subagent; the turn id for the main chat. */
29  id: string
30  at: number
31  scope: Scope
32  /** The main-loop turn the decision happened in; a background subagent stays linked to it. */
33  runId: string | null
34  description: string
35  /** `main` for the chat itself. */
36  subagentType: string
37  /** The loop that spawned the subagent; absent when main did. */
38  parentAgentId?: string
39  requestedModel: string | null
40  /** Jev's recommended tier, whether or not it was applied. */
41  tier: Tier | null
42  probabilities: Partial<Record<Tier, number>>
43  /** Probability Jev gave the tier it chose: what the threshold is compared with. */
44  confidence: number | null
45  /** The `confidence` Jev reports on its own for that answer; logged next to `confidence` to compare them when calibrating. */
46  jevConfidence?: number | null
47  /** Jev's main reason for the tier (a label of its `reason` question). */
48  why?: string
49  verdict: Verdict
50  mode: Mode
51  applied: string
52  /** The model the engine resolved for the decision. */
53  actualModel?: string
54  /** The effort of the decision's first main-chat request, next to `actualModel`. */
55  actualEffort?: string
56  /** After a correction, the previous turn's tier this turn was kept from going below. */
57  correctionFloor?: Tier
58  /** The confidence Jev's move needed: the upgrade or the downgrade threshold. */
59  neededConfidence?: number
60  /** Probability Jev gave the task being risky; at or above the risky threshold it ran on the risky tier. */
61  risk?: number | null
62  reason?: string
63  agentId?: string
64  status: DecisionStatus
65  durationMs?: number
66  /** How long Jev took to answer; absent when it was not asked. */
67  jevMs?: number
68  /** Uncached input tokens of the turn. */
69  inputTokens?: number
70  outputTokens?: number
71  /** Prompt-cache tokens of the turn; a low `cacheReadTokens` after an escalation is its cache cost. */
72  cacheReadTokens?: number
73  cacheCreationTokens?: number
74  /** The tier the turn was raised to after its tool calls kept failing; absent when it never was. */
75  escalatedTo?: Tier
76  /** Test, lint or eslint runs the decision made through Bash; `lastPassed` is the latest one. */
77  tests?: { runs: number; lastPassed: boolean }
78  /** A file this decision edited was edited again soon after. Informative only: it never makes the outcome bad. */
79  rework?: boolean
80  feedback?: Feedback
81  /** The note given with `/stack good|bad`. */
82  feedbackNote?: string
83  outcome?: Outcome
84  lastAction?: string
85  lastActionAt?: number
86}
87
88/** What `turn.step` applies to every main-chat request of one turn. */
89export type MainPlan = {
90  turnId: string
91  tier: Tier
92  model: string
93  effort: 'low' | 'medium' | 'high'
94}
95
96export type Run = { id: string; at: number; text: string }
97
98export type View = 'overview' | 'details'
99
100declare module 'claude-code' {
101  interface PluginState {
102    'model-stack': {
103      decisions: RoutingDecision[]
104      plan: MainPlan | null
105      runs: Run[]
106      currentRun: string | null
107      view: View
108      mode: Mode | null
109      tick: number
110      /** When the pending Jev question was asked; null while none is. */
111      jevAskedAt: number | null
112    }
113  }
114}
115