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.

Personal Claude Code mods, published as one local-style marketplace named heitor-mods.
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.
| Mod | What it does | Command | |
|---|---|---|---|
next-lite | Suggests 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-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. Needs TYPESAFE_API_KEY in the environment. | /stack | |
tidy-tools | Draws each tool call as one compact line, or folds a whole turn into one "N steps this turn" line. | /lines | |
blast-radius | Holds 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-gate | Holds git commit in a pane listing the staged files, flags files not edited this session and blocks AI attribution trailers. | ||
replay-theater | Steps through the file edits Claude made in the last turn, one diff at a time. | /replay | |
usage-weather | A forecast band above the prompt: context fill, sparkline, rate limits, model and effort. | ||
prompt-git | Appends the git branch and working-tree counts to the hint line under the prompt. | ||
pixel-quest | Replaces the spinner with a pixel RPG scene for each tool, with XP, levels and achievements. No model calls. | /toons | |
brief-mode | Appends 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-neon | Draws 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.
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.
hooks/register.tsx 623 lines1import { 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}
623hooks/jev.ts 181 lines1import { 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}
181hooks/panel.tsx 515 lines1import 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}
515hooks/quality.ts 102 lines1import 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}
102hooks/routing.ts 297 lines1import 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}
297hooks/metrics.ts 154 lines1import 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}
154types/index.d.ts 115 lines1export 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