SLOPSHOPPER

9router-hud

9router context, account, 5h/7d quota and tools band above the prompt

newbandguardnetworktimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · 9router-hud
› 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 Context 49% 97.4k/200.0k · Account Claude subscription 5h 69% left · 7d -- Tools 9 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Context 49% 97.4k/200.0k · Account Claude subscription 5h 69% left · 7d -- Tools 9
README

9router HUD (Claude Code mod)

Context · account · 5h/7d quota · tools, drawn above the Claude Code prompt. Replaces the 9router-hud npm wrapper for Claude (Codex still needs it).

/plugin install 9router-hud --marketplace vutranHS/9router

Subscription login (no custom endpoint): shows the logged-in email and Claude's own 5h/7d rate limits, no router needed.

With 9router: reads ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN/ANTHROPIC_API_KEY from Claude's env (~/.claude/settings.json). No proxy: the router binds Claude Code's own X-Claude-Code-Session-Id header, so the router must include that change. Remove the old wrapper first (9router-hud uninstall) or claude keeps launching through it.

Test: claude plugin test packages/9router-hud-mod

Source 2 files
hooks/register.tsx 122 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Hud, Quota, Window } from '../types'
5
6const hud = atom({ plugin: '9router-hud', key: 'hud' } as const, { quota: null, offline: false, tools: 0, running: [] } as Hud)
7
8// The router injects nothing here: Claude Code already sends X-Claude-Code-Session-Id,
9// which the router binds to the account that served the request.
10async function refresh($: EngineInterface) {
11  const url = await $.env.get('ANTHROPIC_BASE_URL')
12  const key = (await $.env.get('ANTHROPIC_AUTH_TOKEN')) || (await $.env.get('ANTHROPIC_API_KEY'))
13  if (!url || !key) return subscription($)
14  const base = url.replace(/\/+$/, '').replace(/\/v1$/, '')
15  const session = await $.session.id()
16  try {
17    const r = await $.http.fetch(`${base}/v1/hud/quota?session=${encodeURIComponent(session)}`, { headers: { authorization: `Bearer ${key}` } })
18    const quota: Quota = JSON.parse(r.text)
19    await update($, hud, s => ({ ...s, offline: !r.ok && r.status >= 500, quota: r.ok ? quota : { ...s.quota, status: quota.error ? String(quota.error) : `HTTP ${r.status}` } }))
20  } catch {
21    await update($, hud, s => ({ ...s, offline: true }))
22  }
23}
24
25// Subscription login (no custom endpoint): quota comes from Claude's own rate-limit
26// headers at render time; the account is the logged-in OAuth email.
27async function subscription($: EngineInterface) {
28  const dir = (await $.env.get('CLAUDE_CONFIG_DIR'))?.split(':')[0] || (await $.env.get('HOME'))
29  let label = 'Claude subscription'
30  try {
31    label = JSON.parse(await $.fs.read(`${dir}/.claude.json`)).oauthAccount?.emailAddress || label
32  } catch {}
33  await update($, hud, s => ({ ...s, offline: false, quota: { status: 'ok', account: { label } } }))
34}
35
36export function left(w: Window | undefined, now: number) {
37  if (!w) return '--'
38  const reset = w.resets_at ? Date.parse(w.resets_at) : NaN
39  if (reset <= now) return 'awaiting refresh'
40  const pct = `${Math.round(100 - w.used_percentage)}% left`
41  if (!Number.isFinite(reset)) return pct
42  const m = Math.ceil((reset - now) / 60000)
43  return `${pct} (${m >= 1440 ? `${Math.floor(m / 1440)}d ${Math.floor((m % 1440) / 60)}h` : `${Math.floor(m / 60)}h ${m % 60}m`})`
44}
45
46const tone = (pctLeft: number) => (pctLeft > 50 ? 'success' : pctLeft > 20 ? 'warning' : 'error')
47
48export const register: Register = on => {
49  let timer: { cancel: () => void } | undefined
50
51  on('session.start', async ($, e, next) => {
52    timer?.cancel()
53    timer = $.clock.every(60000, () => void refresh($))
54    void refresh($)
55    return next(e)
56  })
57
58  on('tool.call', async ($, e, next) => {
59    await update($, hud, s => ({ ...s, tools: s.tools + 1, running: [...s.running, e.tool].slice(-3) }))
60    try {
61      return await next(e)
62    } finally {
63      await update($, hud, s => {
64        const i = s.running.lastIndexOf(e.tool)
65        return i < 0 ? s : { ...s, running: s.running.filter((_, j) => j !== i) }
66      })
67    }
68  }).catch(($, e, next) => next(e)) // HUD must never block a tool
69
70  on('turn.complete', async ($, e, next) => {
71    void refresh($)
72    return next(e)
73  })
74
75  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
76    if (e.props.hasSurvey) return next(e)
77    const s = await read($, hud)
78    const { context, rateLimits } = await $.session.usage()
79    const limit = (kind: string): Window | undefined => {
80      const r = rateLimits.find(l => l.kind === kind)
81      return r ? { used_percentage: r.percentUsed, resets_at: r.resetsAt ?? null } : undefined
82    }
83    const now = await $.clock.now()
84    const { Box, Text } = $.ui.resolve(e)
85    const q = s.quota ?? {}
86    const ctxPct = context.percent != null ? Math.round(context.percent) : null
87    const k = (n?: number) => (n != null ? `${(n / 1000).toFixed(1)}k` : '?')
88    const status = s.offline ? 'router offline' : q.status && !['ok', 'waiting'].includes(q.status) ? q.status : ''
89    const age = q.updated_at ? Math.max(0, Math.floor((now - Date.parse(q.updated_at)) / 60000)) : null
90    const win = (label: string, w: Window | undefined) => (
91      <Text>
92        <Text bold color="suggestion">{label} </Text>
93        <Text color={w ? tone(100 - w.used_percentage) : 'subtle'}>{left(w, now)}</Text>
94      </Text>
95    )
96
97    return (
98      <Box flexDirection="column">
99        <Text wrap="truncate">
100          <Text bold color="suggestion">Context </Text>
101          <Text color={ctxPct == null ? 'subtle' : ctxPct < 50 ? 'success' : ctxPct < 80 ? 'warning' : 'error'}>{ctxPct == null ? '--' : `${ctxPct}%`}</Text>
102          <Text dimColor> {k(context.tokens)}/{k(context.window)} · </Text>
103          <Text bold color="suggestion">Account </Text>
104          <Text>{q.account?.label ?? 'waiting for first response'}</Text>
105          {status ? <Text color="warning"> · {status}</Text> : null}
106        </Text>
107        <Text wrap="truncate">
108          {win('5h', q.five_hour ?? limit('five_hour'))}
109          <Text dimColor> · </Text>
110          {win('7d', q.seven_day ?? limit('seven_day'))}
111          {age != null ? <Text dimColor> · updated {age}m ago</Text> : null}
112        </Text>
113        <Text wrap="truncate">
114          <Text bold color="suggestion">Tools </Text>
115          <Text bold>{s.tools}</Text>
116          {s.running.length ? <Text color="warning"> · running {s.running.join(', ')}</Text> : null}
117        </Text>
118      </Box>
119    )
120  })
121}
122
types/index.d.ts 17 lines
1export type Window = { used_percentage: number; resets_at: string | null } | null
2export type Quota = {
3  status?: string
4  account?: { label: string } | null
5  five_hour?: Window
6  seven_day?: Window
7  updated_at?: string | null
8  error?: string
9}
10export type Hud = { quota: Quota | null; offline: boolean; tools: number; running: string[] }
11
12declare module 'claude-code' {
13  interface PluginState {
14    '9router-hud': { hud: Hud }
15  }
16}
17