SLOPSHOPPER

jev-router-mod

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…

newbandpromptprocess
A shopper browsing a rack in a slop shop
README

jev-router-mod

A function-hooks mod that replaces the .claude/hooks/jev-router.sh UserPromptSubmit command hook with an in-engine equivalent.

What it hooks

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.

What it draws

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.

Network

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.

Parity

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

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.

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