Runs the Jev prompt router (execution/infrastructure/jev_router.py hook) on each typed prompt, attaches its routing line as model context, and shows the route…

A function-hooks mod that replaces the .claude/hooks/jev-router.sh UserPromptSubmit command hook with an in-engine equivalent.
prompt.submit, main-loop prompts only (skips origin.kind === 'plugin', slash commands, and text starting with /). It reads .claude/jev/state.json with $.fs.read (absent = enabled; "enabled": false = pass through), then runs python3 execution/infrastructure/jev_router.py hook through $.process.run with cwd = $.session.root(), a 10 s timeout, and stdin {"prompt": <text>, "session_id": <id>}, the same JSON shape the shell hook pipes in. If stdout carries hookSpecificOutput.additionalContext, the text is attached with next({ ...e, context: [...(e.context ?? []), additionalContext] }). The types document PromptSubmitInput.context as "what the model reads beside the prompt and the user never sees", which is how a UserPromptSubmit additionalContext reaches the model. The prompt text is not changed. On any failure (non-zero exit, timeout, bad JSON, python missing) it calls next(e) with the event untouched. It never drops or denies a prompt.
It draws a dimmed one-line AbovePrompt band, jev → <tier>/<skill> · <first 80 chars of the routing line>, with a Hide button. The last decision is kept in the atom last: { route, context, prompt, at } | null.
The mod makes no network call and does not use $.http. The wrapped Python script still calls Jev on OpenRouter, the same way the shell hook did.
python3 tests/mods/jev_router_parity.py runs each prompt in tests/mods/jev-samples.json through both bash .claude/hooks/jev-router.sh and the mod's exact argv, then compares stdout. It prints two counts: an exact one and one with confidence digits masked. Jev is a live sampled model, so two runs of the shell hook against each other differ just as often (about 5/10 exact), and the masked count is the real gate. tests/register.test.tsx stubs process.run and checks that the context reaches the engine and that the band renders.
Disable the mod in /plugin, or run /jev off, which the mod honours too. If you use this mod, remove the shell hook from settings so routing does not run twice.
hooks/register.tsx 84 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { JevDecision } from '../types'
5
6const last = atom({ plugin: 'jev-router-mod', key: 'last' } as const, null)
7const isHidden = atom({ plugin: 'jev-router-mod', key: 'isHidden' } as const, false)
8
9export const ARGV = ['python3', 'execution/infrastructure/jev_router.py', 'hook'] as const
10export const TIMEOUT_MS = 10_000
11
12async function isEnabled($: EngineInterface, root: string): Promise<boolean> {
13 try {
14 const text = await $.fs.read(`${root}/.claude/jev/state.json`)
15 return !/"enabled": *false/.test(String(text))
16 } catch {
17 return true // absent = enabled, as in jev-router.sh
18 }
19}
20
21export function routeOf(context: string): string {
22 const tier = /Task tier(?: unclear \(|: )(\w+)/.exec(context)?.[1] ?? '?'
23 const skill = /Skill match: `([^`]+)`/.exec(context)?.[1] ?? 'none'
24 return `${tier}/${skill}`
25}
26
27async function route($: EngineInterface, prompt: string): Promise<string | null> {
28 try {
29 const root = await $.session.root()
30 if (!(await isEnabled($, root))) return null
31 let sessionId = ''
32 try {
33 sessionId = await $.session.id()
34 } catch {
35 sessionId = ''
36 }
37 const res = await $.process.run(ARGV, {
38 cwd: root,
39 stdin: JSON.stringify({ prompt, session_id: sessionId }),
40 timeoutMs: TIMEOUT_MS,
41 })
42 if (res.exitCode !== 0 || !res.stdout.trim()) return null
43 const out = JSON.parse(res.stdout)
44 const ctx = out?.hookSpecificOutput?.additionalContext
45 return typeof ctx === 'string' && ctx.length > 0 ? ctx : null
46 } catch {
47 return null // python missing, timeout, bad JSON: fail open
48 }
49}
50
51export const register: Register = on => {
52 on('prompt.submit', async ($, e, next) => {
53 if (e.origin?.kind === 'plugin' || e.command || e.text.trimStart().startsWith('/')) {
54 return next(e)
55 }
56 const context = await route($, e.text)
57 if (context === null) return next(e)
58 const decision: JevDecision = {
59 route: routeOf(context),
60 context,
61 prompt: e.text,
62 at: await $.clock.now(),
63 }
64 await update($, last, () => decision)
65 await update($, isHidden, () => false)
66 return next({ ...e, context: [...(e.context ?? []), context] })
67 })
68
69 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
70 const d = await read($, last)
71 if (e.props.hasSurvey || d === null || (await read($, isHidden))) return next(e)
72 const { Box, Button, Text } = $.ui.resolve(e)
73 const snippet = d.context.replace(/\s+/g, ' ').slice(0, 80)
74 return (
75 <Box>
76 <Text dimColor>
77 jev → {d.route} · {snippet}{' '}
78 </Text>
79 <Button key="hide" label="Hide" onPress={() => update($, isHidden, () => true)} />
80 </Box>
81 )
82 })
83}
84types/index.d.ts 13 lines1export type JevDecision = {
2 route: string
3 context: string
4 prompt: string
5 at: number
6}
7
8declare module 'claude-code' {
9 interface PluginState {
10 'jev-router-mod': { last: JevDecision | null; isHidden: boolean }
11 }
12}
13