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

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
hooks/register.tsx 122 lines1import { 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}
122types/index.d.ts 17 lines1export 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