Jev picks the skill for each prompt (a hint Claude may ignore) and the model for each subagent; on/off buttons above the prompt (/jev, /jev-model)

Uses Jev (TypeSafe's judgment model) as a second opinion on two routing choices, each with an on/off button so you can compare results with and without it.
| Feature | What it does | Toggle |
|---|---|---|
| Skill routing | On each prompt you send, Jev picks the best-fitting skill. A confident pick (≥ 0.6, not none) reaches Claude as a hint it may ignore, and the transcript logs Jev → <skill> (<confidence>). When Claude loads a skill, a line shows what it loaded next to Jev's pick. | Jev ● on button, /jev |
| Subagent model routing | When Claude starts a subagent without naming a model, Jev scores the task 1–4 and the subagent runs on Haiku (1), Sonnet (2–3) or Opus (4). The main conversation's model never changes, so its prompt cache stays intact. | Model ● on button, /jev-model |
Both settings are saved across sessions.
fork subagent, or a plugin's agent type (plugin:agent, which names its own model).Needs python3 and a TypeSafe API key, read from TYPESAFE_API_KEY or, on macOS, the keychain item with service ai.typesafe.api and your user name as the account (the same one the Jev MCP server uses):
security add-generic-password -s ai.typesafe.api -a "$USER" -w
The key is never written to a file, a log or a command line. Prompts reach the router on stdin, not as arguments.
api.typesafe.ai: your prompt (first 8,000 characters) with every skill's name and description from ~/.claude/skills and the project's .claude/skills; or a subagent's task text.~/.claude/jev_log.jsonl, one line per skill pick with the time, choice and confidence (no prompt text).hooks/register.tsx: the mod (buttons, commands, hooks).scripts/jev_skill_router.py: the Jev calls, Python standard library only. --json <prompt> prints {"choice", "confidence"}; --complexity <task> prints {"score", "confidence"}; - reads the text from stdin.hooks/register.tsx 148 lines1// Jev Router (local): Jev (TypeSafe System One) as a second opinion on routing.
2//
3// Skill routing: on each prompt the person sends, Jev picks the skill that fits
4// best and the pick rides along as a hint the model reads; Claude keeps the final
5// say. The Skill line in the transcript shows what Claude actually loaded next to
6// Jev's pick, so the two can be compared.
7//
8// Model routing: when Claude starts a subagent without naming a model, Jev scores
9// the task's complexity and the subagent runs on Haiku (1), Sonnet (2-3) or Opus (4).
10// The main conversation's model never changes, so its prompt cache stays intact.
11//
12// Both have an on/off button above the prompt and a command: /jev, /jev-model.
13// Everything fails open: if Jev errors, times out or is unsure (< 0.6), nothing
14// is added and nothing is changed. The calls go through scripts/jev_skill_router.py.
15
16import { atom, read, update } from 'claude-code'
17import type { EngineInterface, Register } from 'claude-code'
18
19import type { Pick, Complexity } from '../types'
20
21const enabledAtom = atom({ plugin: 'jev-router', key: 'enabled' } as const, true)
22const modelRoutingAtom = atom({ plugin: 'jev-router', key: 'modelRouting' } as const, true)
23const lastPickAtom = atom({ plugin: 'jev-router', key: 'lastPick' } as const, null)
24
25export const MODEL_FOR: Record<number, 'haiku' | 'sonnet' | 'opus'> = { 1: 'haiku', 2: 'sonnet', 3: 'sonnet', 4: 'opus' }
26export const MIN_CONFIDENCE = 0.6
27// Only a plain skill name reaches the hint: a project's skills may be untrusted, and a name
28// that could carry instructions is dropped (the router filters the same way).
29const SKILL_NAME = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/
30// The script's own HTTP timeout is 5 s; this covers Python's start and the keychain read.
31const ROUTER_TIMEOUT_MS = 8000
32// After a failure (no key, offline, Jev down) skip Jev for a while, so prompts don't wait on it.
33const BACKOFF_MS = 120_000
34// Prompts the person sent; notifications, peers and schedules are left alone.
35const PERSON = new Set(['composer', 'bridge', 'sdk'])
36
37let pausedUntil = 0
38
39// mode is --json or --complexity; the text goes on stdin so it stays out of the process list.
40async function router($: EngineInterface, mode: string, text: string): Promise<unknown> {
41 const now = await $.clock.now()
42 if (now < pausedUntil) return null
43 try {
44 const script = $.plugin.root + '/scripts/jev_skill_router.py'
45 const r = await $.process.run(['python3', script, mode, '-'], { stdin: text, timeoutMs: ROUTER_TIMEOUT_MS })
46 if (r.exitCode === 0 && r.stdout.trim()) return JSON.parse(r.stdout)
47 } catch {}
48 pausedUntil = now + BACKOFF_MS
49 return null
50}
51
52export function asPick(v: unknown): Pick | null {
53 const o = v as Pick | null
54 return o && typeof o.choice === 'string' && typeof o.confidence === 'number' ? { choice: o.choice, confidence: o.confidence } : null
55}
56
57export function asComplexity(v: unknown): Complexity | null {
58 const o = v as Complexity | null
59 return o && typeof o.score === 'number' && typeof o.confidence === 'number' ? { score: o.score, confidence: o.confidence } : null
60}
61
62/** The hint the model reads beside the prompt, or null when Jev has nothing confident to say. */
63export function hintFor(pick: Pick | null): string | null {
64 if (!pick || pick.choice === 'none' || pick.confidence < MIN_CONFIDENCE || !SKILL_NAME.test(pick.choice)) return null
65 return `Jev picked the ${pick.choice} skill (confidence ${pick.confidence.toFixed(2)}).`
66}
67
68/** The model a subagent should run on, or null to leave it as Claude asked. */
69export function modelFor(c: Complexity | null): 'haiku' | 'sonnet' | 'opus' | null {
70 if (!c || c.confidence < MIN_CONFIDENCE) return null
71 return MODEL_FOR[c.score] ?? null
72}
73
74export const register: Register = on => {
75 on('session.start', async ($, e, next) => {
76 await $.command.register({ name: 'jev', description: 'Toggle Jev skill routing', immediate: true })
77 await $.command.register({ name: 'jev-model', description: 'Toggle Jev subagent model routing', immediate: true })
78 const a = await $.store.get('enabled')
79 if (typeof a === 'boolean') await update($, enabledAtom, () => a)
80 const b = await $.store.get('modelRouting')
81 if (typeof b === 'boolean') await update($, modelRoutingAtom, () => b)
82 return next(e)
83 })
84
85 on('command.run', { command: 'jev' }, async $ => {
86 const isOn = await update($, enabledAtom, v => !v)
87 await $.store.set('enabled', isOn)
88 return { text: `Jev skill routing ${isOn ? 'on' : 'off'}` }
89 })
90
91 on('command.run', { command: 'jev-model' }, async $ => {
92 const isOn = await update($, modelRoutingAtom, v => !v)
93 await $.store.set('modelRouting', isOn)
94 return { text: `Jev model routing ${isOn ? 'on' : 'off'}` }
95 })
96
97 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
98 const theirs = await next(e)
99 if (e.props.hasSurvey) return theirs
100 const enabled = await read($, enabledAtom)
101 const modelRouting = await read($, modelRoutingAtom)
102 const { Box, Button } = $.ui.resolve(e)
103 const toggleSkills = async () => $.store.set('enabled', await update($, enabledAtom, v => !v))
104 const toggleModels = async () => $.store.set('modelRouting', await update($, modelRoutingAtom, v => !v))
105 return (
106 <Box flexDirection="column">
107 <Box flexDirection="row" columnGap={2}>
108 <Button key="jev" plain label={enabled ? 'Jev ● on' : 'Jev ○ off'} onPress={toggleSkills} />
109 <Button key="jev-model" plain label={modelRouting ? 'Model ● on' : 'Model ○ off'} onPress={toggleModels} />
110 </Box>
111 {theirs}
112 </Box>
113 )
114 })
115
116 on('prompt.submit', async ($, e, next) => {
117 if (!PERSON.has(e.origin.kind) || !(await read($, enabledAtom))) {
118 await update($, lastPickAtom, () => null)
119 return next(e)
120 }
121 const pick = asPick(await router($, '--json', e.text))
122 await update($, lastPickAtom, () => pick)
123 const hint = hintFor(pick)
124 if (!hint || !pick) return next(e)
125 $.ui.log(`Jev → ${pick.choice} (${pick.confidence.toFixed(2)})`)
126 return next({ ...e, context: [...(e.context ?? []), hint] })
127 })
128
129 on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
130 if (e.tool === 'Skill' && (await read($, enabledAtom))) {
131 const pick = await read($, lastPickAtom)
132 $.ui.log(`Skill used: ${e.skill} · Jev: ${pick ? `${pick.choice} (${pick.confidence.toFixed(2)})` : 'no pick'}`)
133 }
134 return next(e)
135 })
136
137 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
138 if (e.tool !== 'Agent' || e.model || !(await read($, modelRoutingAtom))) return next(e)
139 // A fork keeps the parent's model; a plugin's agent type names its own tier.
140 const type = e.subagent_type ?? ''
141 if (type === 'fork' || type.includes(':')) return next(e)
142 const model = modelFor(asComplexity(await router($, '--complexity', e.prompt)))
143 if (!model) return next(e)
144 $.ui.log(`Jev: subagent "${e.description || 'task'}" → ${model}`)
145 return next({ ...e, model })
146 })
147}
148types/index.d.ts 23 lines1// Jev Router's state contract: the two toggles and Jev's last skill pick.
2
3/** Jev's answer to "which skill fits this prompt". */
4export type Pick = {
5 /** A skill name, or 'none'. */
6 choice: string
7 /** 0 to 1; below 0.6 the pick is not passed on. */
8 confidence: number
9}
10
11/** Jev's answer to "how complex is this subagent task". */
12export type Complexity = {
13 /** 1 (lookup) to 4 (deep reasoning). */
14 score: number
15 confidence: number
16}
17
18declare module 'claude-code' {
19 interface PluginState {
20 'jev-router': { enabled: boolean; modelRouting: boolean; lastPick: Pick | null }
21 }
22}
23