SLOPSHOPPER

jev-monitor

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

newbandcommandtimer
v0.1.1no licenseupdated 2026-10-24sebasvermeir-sys/jev-router/jev-monitor
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jev-monitor
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /jev ⎿ jev-monitor: Bandeau Jev basculé. [ ▾ Jev ] ? Pas encore de journal Jev. Répartition des conseils (0 messages) Haiku 0 Sonnet 0 Opus 0 Changements de modèle : 0 · latence moyenne : 0 ms Historique Aucun message routé pour l'instant. ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
[ ▾ Jev ] ? Pas encore de journal Jev. Répartition des conseils (0 messages) Haiku 0 Sonnet 0 Opus 0 Changements de modèle : 0 · latence moyenne : 0 ms Historique Aucun message routé pour l'instant. ⟨Claude Code's own drawing⟩
README

jev-router

Before Claude works on your prompt, this mod asks TypeSafe Jev which Claude model the prompt really needs (Haiku, Sonnet or Opus).

Modes (pick in /config → jev-router: Mode)

  • auto (default): the turn runs on Jev's pick, no click. It changes the model of each request in that turn; the model picker keeps showing your own choice, the band shows what really ran.
  • advise: only shows the advice, with a Use X next button (runs /model).
  • off: does nothing.

What you see

Jev → Haiku (95%) · chat is small, switching is cheap · 210 ms [Hide]

When it switches (auto) or offers to (advise)

  • Up (to a bigger model): whenever Jev is 45%+ sure. Quality first.
  • Down (to a smaller model): only when Jev is 70%+ sure and switching is cheap: the chat is still small (under ~20k tokens), or its cache went cold (idle about an hour) and the chat is under 100k tokens.
  • Otherwise it stays on the model that answered last, so the cache stays warm.
  • Subagents are left alone.

Install

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.

Setup: the API key

The key never goes in a chat. The mod looks in two places, in this order:

  1. macOS Keychain (recommended). In Terminal, run the line below. It asks for the key without showing it, so it never lands in your shell history:
   security add-generic-password -a "$USER" -s typesafe-jev -w
  1. A file: ~/.jev/api_key containing only the key.

Get a key at https://console.typesafe.ai/.

Files

FileWhat it is
hooks/register.tsxThe mod itself: the prompt hook, the Jev call, the band
types/index.d.tsThe shape of the values the band shows
.claude-plugin/plugin.jsonThe mod's name card
~/.jev/router-log.jsonlOne line per prompt: Jev's pick, confidence, speed

Rules it follows

  • Jev gets 1.5 s. Slower, down, or no key: your prompt goes on unchanged.
  • It skips slash commands, hidden prompts and other plugins' prompts.
  • Only the first 4,000 characters of a prompt are sent to Jev.

Privacy

With this mod on, every prompt you type is also sent to TypeSafe. Turn the mod off for sensitive work.

jev-monitor (optional companion)

A collapsible band above the prompt that shows jev-router at work, read from ~/.jev/router-log.jsonl (local only, no network):

  • Collapsed: ▸ Jev, the mode, and the last pick → model used, e.g. Haiku 92 % → Opus
  • Expanded (click ▸ 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).

Source 2 files
hooks/register.tsx 180 lines
1// 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}
180
types/index.d.ts 20 lines
1export 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