Panneau en direct du routage Jev : dernier choix, historique, répartition des modèles et latence.

Before Claude works on your prompt, this mod asks TypeSafe Jev which Claude model the prompt really needs (Haiku, Sonnet or Opus).
/config → jev-router: Mode)/model).Jev → Haiku (95%) · chat is small, switching is cheap · 210 ms [Hide]
You need Claude Code (the desktop app or the terminal) and your own TypeSafe Jev API key. No key is included here.
In a terminal session, at the prompt:
/plugin install jev-router --marketplace sebasvermeir-sys/jev-router
Answer y to add the marketplace, pick the user scope (Enter), choose a mode (keep auto). From a shell instead:
claude plugin install jev-router --marketplace sebasvermeir-sys/jev-router
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with sebasvermeir-sys/jev-router, then Add plugin.
Then add your key (next section) and open a new chat.
The key never goes in a chat. The mod looks in two places, in this order:
security add-generic-password -a "$USER" -s typesafe-jev -w
~/.jev/api_key containing only the key.Get a key at https://console.typesafe.ai/.
| File | What it is |
|---|---|
hooks/register.tsx | The mod itself: the prompt hook, the Jev call, the band |
types/index.d.ts | The shape of the values the band shows |
.claude-plugin/plugin.json | The mod's name card |
~/.jev/router-log.jsonl | One line per prompt: Jev's pick, confidence, speed |
With this mod on, every prompt you type is also sent to TypeSafe. Turn the mod off for sensitive work.
A collapsible band above the prompt that shows jev-router at work, read from ~/.jev/router-log.jsonl (local only, no network):
▸ Jev, the mode, and the last pick → model used, e.g. Haiku 92 % → Opus▸ Jev or type /jev): last prompt in detail, the Haiku / Sonnet / Opus split of Jev's picks, model switches, average latency, and the last 12 prompts.It draws above the other plugins' bands (e.g. Ribbon) without hiding them.
Install: /plugin install jev-monitor@jev-router (desktop app: + → Plugins).
hooks/register.tsx 180 lines1// jev-monitor: a pane beside the chat that follows jev-router live,
2// read from its own log, ~/.jev/router-log.jsonl.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, Register } from 'claude-code'
5
6import type { Entry, Snapshot, Tier } from '../types'
7
8const POLL_MS = 3000
9const HISTORY = 12
10
11const snapshot = atom({ plugin: 'jev-monitor', key: 'snapshot' } as const, {
12 entries: [],
13 mode: '?',
14 error: null,
15} as Snapshot)
16
17const isExpanded = atom({ plugin: 'jev-monitor', key: 'isExpanded' } as const, false)
18
19const LABEL: Record<Tier, string> = { haiku: 'Haiku', sonnet: 'Sonnet', opus: 'Opus', other: '—' }
20const COLOR: Record<Tier, string> = { haiku: 'green', sonnet: 'cyan', opus: 'magenta', other: 'gray' }
21
22function time(at: number): string {
23 const d = new Date(at)
24 const pad = (n: number) => String(n).padStart(2, '0')
25 return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
26}
27
28function parse(text: string): Entry[] {
29 const out: Entry[] = []
30 for (const line of text.split('\n')) {
31 if (!line.trim()) continue
32 try {
33 const row = JSON.parse(line)
34 // jev-router also logs timeouts and errors (no pick): those are not picks
35 if (typeof row.pick !== 'string') continue
36 // rows from jev-router 0.1 have no route: nothing was switched then
37 out.push({ ...row, route: row.route ?? null } as Entry)
38 } catch {
39 // a line being written: skip it, the next poll reads it whole
40 }
41 }
42 return out
43}
44
45let lastMtime = -1
46
47
48async function refresh($: EngineInterface) {
49 const home = await $.env.get('HOME')
50 const path = `${home}/.jev/router-log.jsonl`
51 try {
52 const rows = await $.config.list()
53 const mode = String(rows.find(r => r.key.startsWith('jev-router') && r.key.endsWith('.mode'))?.value ?? '?')
54 if (!(await $.fs.exists(path))) {
55 await update($, snapshot, s => ({ ...s, mode, error: 'Pas encore de journal Jev.' }))
56 return
57 }
58 const { mtimeMs } = await $.fs.stat(path)
59 if (mtimeMs === lastMtime) {
60 await update($, snapshot, s => (s.mode === mode ? s : { ...s, mode }))
61 return
62 }
63 lastMtime = mtimeMs
64 const entries = parse(await $.fs.read(path))
65 await update($, snapshot, () => ({ entries, mode, error: null }))
66 } catch (err) {
67 await update($, snapshot, s => ({ ...s, error: `Lecture impossible : ${String(err)}` }))
68 }
69}
70
71export const register: Register = on => {
72 on('session.start', async ($, e, next) => {
73 await $.command.register({ name: 'jev', description: 'Déplier ou replier le bandeau Jev' })
74 await refresh($)
75 $.clock.every(POLL_MS, () => refresh($))
76 return next(e)
77 })
78
79 on('command.run', { command: 'jev' }, async $ => {
80 await refresh($)
81 await update($, isExpanded, v => !v)
82 return { text: 'Bandeau Jev basculé.' }
83 })
84
85 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
86 if (e.props.hasSurvey) return next(e)
87 const { Box, Text, Button } = $.ui.resolve(e)
88 const s = await read($, snapshot)
89 const expanded = await read($, isExpanded)
90 const all = s.entries
91 const last = all[all.length - 1]
92
93 const counts: Record<Tier, number> = { haiku: 0, sonnet: 0, opus: 0, other: 0 }
94 for (const x of all) counts[x.pick] = (counts[x.pick] ?? 0) + 1
95 const routed = all.filter(x => x.route !== null && x.route !== x.current).length
96 const avgMs = all.length ? Math.round(all.reduce((t, x) => t + x.ms, 0) / all.length) : 0
97 const bar = (n: number) => '█'.repeat(all.length ? Math.round((n / all.length) * 20) : 0)
98
99 const used = last ? last.route ?? last.current : null
100 const header = (
101 <Box flexDirection="row" gap={1}>
102 <Button
103 label={expanded ? '▾ Jev' : '▸ Jev'}
104 onPress={() => update($, isExpanded, v => !v)}
105 />
106 <Text dimColor>{s.mode}</Text>
107 {last && used && (
108 <Text>
109 <Text color={COLOR[last.pick]}>{LABEL[last.pick]}</Text>
110 <Text dimColor> {Math.round(last.confidence * 100)} % → </Text>
111 <Text color={COLOR[used]}>{LABEL[used]}</Text>
112 </Text>
113 )}
114 </Box>
115 )
116
117 // The bands of the plugins beneath (Ribbon, jev-router…), drawn under ours
118 const below = await next(e)
119
120 if (!expanded)
121 return (
122 <Box flexDirection="column" gap={1}>
123 {header}
124 {below}
125 </Box>
126 )
127
128 return (
129 <Box flexDirection="column" gap={1}>
130 {header}
131 {s.error && <Text color="yellow">{s.error}</Text>}
132
133 {last && (
134 <Box flexDirection="column">
135 <Text bold>Dernier message · {time(last.at)}</Text>
136 <Text>
137 Jev conseille <Text color={COLOR[last.pick]} bold>{LABEL[last.pick]}</Text> (
138 {Math.round(last.confidence * 100)} %)
139 </Text>
140 <Text>
141 Utilisé : <Text color={COLOR[last.route ?? last.current]}>{LABEL[last.route ?? last.current]}</Text>
142 </Text>
143 <Text dimColor>{last.note} · {last.ms} ms · {last.chars} car.</Text>
144 </Box>
145 )}
146
147 <Box flexDirection="column">
148 <Text bold>Répartition des conseils ({all.length} messages)</Text>
149 {(['haiku', 'sonnet', 'opus'] as const).map(t => (
150 <Text>
151 <Text color={COLOR[t]}>{LABEL[t].padEnd(7)}</Text>
152 {bar(counts[t])} {counts[t]}
153 </Text>
154 ))}
155 <Text dimColor>
156 Changements de modèle : {routed} · latence moyenne : {avgMs} ms
157 </Text>
158 </Box>
159
160 <Box flexDirection="column">
161 <Text bold>Historique</Text>
162 {all.length === 0 && <Text dimColor>Aucun message routé pour l'instant.</Text>}
163 {all
164 .slice(-HISTORY)
165 .reverse()
166 .map(x => (
167 <Text>
168 <Text dimColor>{time(x.at)} </Text>
169 <Text color={COLOR[x.pick]}>{LABEL[x.pick].padEnd(7)}</Text>
170 {String(Math.round(x.confidence * 100)).padStart(3)} % →{' '}
171 {LABEL[x.route ?? x.current]}
172 </Text>
173 ))}
174 </Box>
175 {below}
176 </Box>
177 )
178 })
179}
180types/index.d.ts 20 lines1export type Tier = 'haiku' | 'sonnet' | 'opus' | 'other'
2export type Entry = {
3 at: number
4 mode: string
5 pick: Tier
6 confidence: number
7 current: Tier
8 route: Tier | null
9 note: string
10 ms: number
11 chars: number
12}
13export type Snapshot = { entries: Entry[]; mode: string; error: string | null }
14
15declare module 'claude-code' {
16 interface PluginState {
17 'jev-monitor': { snapshot: Snapshot; isExpanded: boolean }
18 }
19}
20