A Claude Mod for the shunt gateway: a band above the prompt shows how much of each window is used and the time to reset — the shared account pool's, read from…

A Claude Mod — a Claude Code plugin whose behaviour is a function hooks module — that shows usage in a band above the prompt — the shunt gateway's shared account pool on shunt, the session's own Claude rate limits otherwise — and answers /shunt:usage with the pool's full breakdown.
shunt · 5H 5% ↻1h 41m · WK 46% ↻1d 7h · Fable 31% ↻1d 7h ⚠ anthropic degraded
> /shunt:usage
shunt: pool — degraded http://127.0.0.1:3001
5h ▓▓▓▓▓▓░░░░ 62% left resets 04:11
7d ▓▓▓▓▓▓▓▓░░ 81% left resets Sun 01:11
fable ▓▓░░░░░░░░ 19% left resets Sun 01:11
claude ok 5h 71% 7d 84% fable 19%
codex exhausted 5h 0% 7d 40% fable —
headroom left, averaged over the pool's accounts; a shared figure, not a promise about your next request
The mod reads the gateway's GET /usage endpoint and prints it. It answers the command itself — the hook returns without calling next, so nothing is sent to the model and the answer costs no tokens.
On a shunt gateway the band shows the pool, tagged shunt ·. Each figure is how much of the window is used — 1 - remaining, the same pool-wide mean the command reports as headroom — followed by the time to that window's earliest reset. Used rather than left, so it reads the same way as Claude Code's own /usage and as the band reads off shunt. A figure turns amber from 70% and red from 90%. Each pooled provider whose status is not ok is flagged at the end (⚠ anthropic degraded: amber for degraded, red for exhausted or capped), or the pool itself when no provider is listed. A window no account reports is left out.
Off shunt — no gateway set, a gateway that answers GET /usage with 404, a successful response that is not a pool report, or one that pools no provider — the band shows the session's own rate limits instead, untagged, as $.session.usage() and session.measure report them:
5H 5% ↻1h 41m · WK 46% ↻1d 7h
On shunt it reads GET /usage when the session starts, every minute after, and after each turn — the moment the pool has just been spent from — and ignores the session's own limits, which come from whichever pool account answered last. A refused token (401 or 403), an unreachable gateway, another error status, or a shunt gateway token helper that fails is a fault on shunt, so it is flagged rather than replaced: shunt · ⚠ credential refused, or shunt · ⚠ gateway login unavailable for the helper. With neither a gateway nor rate limits (an API key sent straight to Anthropic) the band draws nothing. Whatever another plugin draws in the band stays on its left.
Collapse the band for the session with its [-] (ctrl+x ctrl+a), or turn it off with the usageBand option below.
remaining is the fraction of the pool's combined capacity still usable, so 62% means 62% of the headroom is left, not that 62% is spent. It is mean(clamp(cap - utilization, 0, 1)) over the non-disabled accounts that report the window, where cap is the account's max_utilization hard cap for that window (100% when none is set): nine exhausted accounts plus one fresh uncapped one read 10%, not 100%, and an account at 44% under a 50% cap counts only 6%. An account a cap already excludes from the window's requests counts zero; a 5h or 7d cap excludes it from every request, Fable ones included.
It is a pool-wide aggregate, not a prediction — routing also weighs availability, model, session affinity and priority, so a healthy figure is not a promise that your next request is admitted. For the routing-aware worst case, use GET /api/oauth/usage instead.
| Window | What it covers |
|---|---|
5h | The rolling 5-hour session window |
7d | The shared weekly window |
fable | The Fable-scoped weekly window (7d_oi) |
A window reads — when no non-disabled account reports it. ChatGPT/Codex accounts populate 5h and 7d from x-codex-* response headers and have no Fable-scoped signal of their own.
pool is the aggregate across every pooled provider; the rows beneath it are the same aggregate per pooled provider, so a session routed to one provider can read that provider's headroom instead of the blended figure. The endpoint never carries account names, counts, priorities or per-account numbers — that detail stays behind the admin-only GET /admin/api/pool.
export ANTHROPIC_BASE_URL=http://127.0.0.1:3001
export ANTHROPIC_AUTH_TOKEN=<your client token>
GET /usage is opt-in and requires either [server.auth] (client tokens) or [server.gateway] (gateway login), next to the [server.usage] table in shunt.toml: [server.auth]
# Presence alone opts in; the table takes no keys.
[server.usage]
With [server.gateway] instead of [server.auth], see shunt gateway claude sessions below. [server.auth] takes its tokens from the environment rather than the TOML — by default SHUNT_CLIENT_TOKENS, as name:token pairs — and the gateway fails to start when it is unset. Export it where the gateway runs, using the same token you set as ANTHROPIC_AUTH_TOKEN above:
export SHUNT_CLIENT_TOKENS="claude-code:<your client token>"
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude
Without step 3 there is no band, and the command still exists but falls back to commands/usage.md, which asks the model to read the endpoint with a tool call instead.
/plugin marketplace add pleaseai/shunt
/plugin install shunt@shunt
One option, a row in /config (stored in settings.json under pluginConfigs):
| Option | Default | Purpose |
|---|---|---|
usageBand | true | Show the band above the prompt. Off, the mod registers no band and polls nothing; /shunt:usage still answers |
The mod reads five environment variables and writes none. By default it sends the session's own credential to the gateway the session is already sending every message to, so it reaches no host the session was not already using; SHUNT_BASE_URL is the one deliberate exception, and points it at a gateway you name instead. With no base URL set at all it asks nothing rather than sending the credential to Anthropic's API.
| Variable | Purpose |
|---|---|
SHUNT_BASE_URL | The gateway base URL; overrides ANTHROPIC_BASE_URL |
ANTHROPIC_BASE_URL | The gateway this session already routes through |
SHUNT_TOKEN | The client token; overrides both below, sent as Authorization: Bearer |
ANTHROPIC_AUTH_TOKEN | Sent as Authorization: Bearer, as Claude Code sends it |
ANTHROPIC_API_KEY | Sent as x-api-key, as Claude Code sends it |
SHUNT_BASE_URL is what lets you read one gateway's pool while routing traffic through another.
shunt gateway claude sessionsshunt gateway claude scrubs ANTHROPIC_AUTH_TOKEN and ANTHROPIC_API_KEY from the environment it hands Claude Code, and wires apiKeyHelper to shunt gateway token instead; Claude Code consumes the helper's output itself and never re-exports it. When none of the variables above holds a credential, the mod therefore reads the merged settings (--settings included) and, if apiKeyHelper is shunt's own — shunt gateway token, by bare name or by path, as the launcher writes it — runs it directly, without a shell, and sends the gateway login token it prints as Authorization: Bearer. Because there is no shell, a leading ~/ in an unquoted helper path is expanded to the home directory (HOME, else USERPROFILE) by the mod itself; a quoted '~/…' stays literal, as in a shell. The login belongs to the gateway the session talks to (ANTHROPIC_BASE_URL), so it is sent only when SHUNT_BASE_URL is unset or spells that same base URL — compared as a string after trimming whitespace and trailing slashes, so localhost and 127.0.0.1 count as different — and the helper is not even run otherwise; to read another gateway's pool, export SHUNT_TOKEN for it. If that helper fails — a non-zero exit, no output, or it cannot be started — the band flags shunt · ⚠ gateway login unavailable and /shunt:usage says to run shunt gateway login, rather than silently showing the session's own limits.
The token is reused for five minutes, so a poll every minute does not run the helper every minute. A reused token the gateway refuses is dropped and the helper asked once more, since a refresh may have rotated it. Any other apiKeyHelper — a password manager, a script that prompts — is never run: the mod would otherwise run it every few minutes for a usage figure. In such a session export SHUNT_TOKEN with a [server.auth] client token instead, or the band falls back to the session's own rate limits.
The gateway has to accept a gateway login on GET /usage, which needs a shunt build that authenticates [server.gateway] logins there; [server.usage] with only [server.gateway] configured is enough.
/shunt:usage and not /usage/usage is Claude Code's own built-in, and the engine refuses to let a plugin take a built-in's name:
$.command.register: "/usage" refused: it is the built-in /usage
$.command.register takes a bare, global name. A plugin's markdown command is namespaced by the plugin instead, so commands/usage.md in a plugin named shunt lists as shunt:usage and collides with nothing. The hooks module then intercepts command.run for that name and answers it directly.
| Path | What it is |
|---|---|
hooks/register.tsx | The hooks: command.run on shunt:usage, and the band's session.start, session.measure, turn.complete and ui.render on AbovePrompt |
hooks/endpoint.ts | Resolves the gateway URL and credential header |
hooks/report.ts | Reads a GET /usage body, defensively |
hooks/reading.ts | Turns one round trip into a report or a problem, full and brief |
hooks/views.ts | Renders the command's bars, percentages and reset times |
hooks/band.ts | The band's cells, alerts, levels and countdowns, from the pool or the session's own limits |
hooks/names.ts | The command name and the fixed texts |
types/index.d.ts | The $.state contract: the pool snapshot, the session's limits and the clock the band draws from |
commands/usage.md | The command, and the no-function-hooks fallback |
tests/*.spec.ts | Vitest suite over the pure modules |
tests/mod/*.test.tsx | Engine suite over the hooks, run by claude plugin test |
# The static side-effect analysis: which events it hooks, which $ calls and
# environment variables it makes and reads.
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin validate plugins/shunt
# The pure modules' suite and typecheck (both CI gates).
cd plugins/shunt && npm ci && npm run typecheck && npm test
# The hooks against the engine itself: the band and the command, with the
# gateway, clock and environment mocked. Needs a Claude Code binary, so CI
# does not run it.
claude plugin test plugins/shunt
# Run it from source against a live gateway.
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir plugins/shunt
hooks/register.tsx is the only hooks file that imports claude-code. Typechecking it needs the engine's own claude-code.d.ts, which Claude Code writes into a plugin author's project via /plugin-types; the other modules import nothing from the engine, which is why the suite covers them without it. For the same reason tsconfig.json excludes hooks/register.tsx from npm run typecheck, along with tests/mod, the engine suite — that exclusion is deliberate, not an oversight, and typechecking that one file means running /plugin-types first.
Function hooks are early access. Hooks modules load only where they are enabled, and the API this mod is written against may change between Claude Code releases without notice.
MIT OR Apache-2.0, matching the shunt project.
hooks/register.tsx 384 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, PluginOptions } from 'claude-code'
3
4import type { ShuntRateLimit, ShuntSnapshot } from '../types'
5
6import { bandOf, snapshotOf } from './band'
7import type { Level } from './band'
8import type { Endpoint, Environment } from './endpoint'
9import { endpointOf, helperMayRideTo } from './endpoint'
10import { shuntHelperArgvOf } from './helper'
11import { COMMAND_NAME, HOOK_FAILED_TEXT, NO_TOKEN_TEXT } from './names'
12import type { Reading } from './reading'
13import {
14 CREDENTIAL_REFUSED,
15 helperFailedOf,
16 readingOf,
17 unreachableOf,
18} from './reading'
19import { reportText } from './views'
20
21/** How often the band reads `GET /usage` again while the session is open. */
22const POLL_MS = 60_000
23
24/** How long a token from shunt's `apiKeyHelper` is reused before asking again. */
25const HELPER_TTL_MS = 5 * 60_000
26
27const COLORS: Readonly<Record<Level, string | undefined>> = {
28 ok: undefined,
29 warn: 'yellow',
30 hot: 'red',
31}
32
33/** The gateway as last read; `null` off shunt, where `limits` is drawn. */
34const snapshot = atom(
35 { plugin: 'shunt', key: 'snapshot' } as const,
36 null as ShuntSnapshot | null,
37)
38
39/** The session's own rate limits, as Claude Code last measured them. */
40const limits = atom(
41 { plugin: 'shunt', key: 'limits' } as const,
42 [] as ShuntRateLimit[],
43)
44
45/** The engine clock at the last refresh: what the countdowns run from. */
46const now = atom({ plugin: 'shunt', key: 'now' } as const, 0)
47
48/** A plain copy: the engine's values are frozen, the state's are owned. */
49const copyOf = (rateLimits: readonly ShuntRateLimit[]): ShuntRateLimit[] =>
50 rateLimits.map(limit => ({ ...limit }))
51
52/**
53 * The last token shunt's `apiKeyHelper` printed, and when. A module variable
54 * rather than `$.state`: it is a credential, and nothing draws from it. A
55 * reload drops it, and the next read asks the helper again.
56 */
57let helperToken: { value: string; at: number } | undefined
58
59/**
60 * The gateway login token of a `shunt gateway claude` session: what shunt's
61 * own `apiKeyHelper` prints, run the way Claude Code runs it. `undefined`
62 * when the helper is not shunt's (see `hooks/helper.ts`); a `failure` when it
63 * is shunt's but could not give a token, which is a fault to flag rather than
64 * a set-up to fall back from.
65 *
66 * Reused for five minutes so a poll every minute does not run the helper
67 * every minute; `isCached` says the token came from that reuse.
68 */
69async function helperTokenOf(
70 $: EngineInterface,
71): Promise<
72 { value: string; isCached: boolean } | { failure: Reading } | undefined
73> {
74 const at = await $.clock.now()
75
76 if (helperToken !== undefined && at - helperToken.at < HELPER_TTL_MS) {
77 return { value: helperToken.value, isCached: true }
78 }
79
80 helperToken = undefined
81
82 const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE'))
83 const argv = shuntHelperArgvOf((await $.settings.read()).apiKeyHelper, home)
84
85 if (argv === null) {
86 return undefined
87 }
88
89 try {
90 const run = await $.process.run(argv)
91 const value = run.exitCode === 0 ? run.stdout.trim() : ''
92
93 if (value === '') {
94 return { failure: helperFailedOf(run) }
95 }
96
97 helperToken = { value, at }
98
99 return { value, isCached: false }
100 } catch (error) {
101 return { failure: helperFailedOf({ error }) }
102 }
103}
104
105/** One request to `GET /usage`, read into a reading. */
106async function fetchUsage($: EngineInterface, endpoint: Endpoint): Promise<Reading> {
107 try {
108 const response = await $.http.fetch(endpoint.url, {
109 headers: endpoint.headers,
110 })
111
112 return readingOf(endpoint, response)
113 } catch (error) {
114 return unreachableOf(endpoint, error)
115 }
116}
117
118/**
119 * One `GET /usage` round trip against the gateway this session routes
120 * through, with the credential it already sends there: a variable from the
121 * environment, or else the gateway login token shunt's `apiKeyHelper` prints.
122 *
123 * Each variable is read with its own literal `$.env.get`, so
124 * `claude plugin validate` can list them.
125 */
126async function readUsage($: EngineInterface): Promise<Reading> {
127 const env: Environment = {
128 shuntBaseUrl: await $.env.get('SHUNT_BASE_URL'),
129 anthropicBaseUrl: await $.env.get('ANTHROPIC_BASE_URL'),
130 shuntToken: await $.env.get('SHUNT_TOKEN'),
131 anthropicAuthToken: await $.env.get('ANTHROPIC_AUTH_TOKEN'),
132 anthropicApiKey: await $.env.get('ANTHROPIC_API_KEY'),
133 }
134
135 const resolved = endpointOf(env)
136
137 if (!('problem' in resolved)) {
138 return fetchUsage($, resolved.endpoint)
139 }
140
141 if (resolved.problem !== NO_TOKEN_TEXT) {
142 return { problem: resolved.problem, brief: null }
143 }
144
145 // No credential in the environment: a `shunt gateway claude` session keeps
146 // its gateway login behind the helper instead. That login belongs to the
147 // session's own gateway, so when `SHUNT_BASE_URL` names another one the
148 // helper is not even run.
149 if (!helperMayRideTo(env)) {
150 return { problem: resolved.problem, brief: null }
151 }
152
153 const readWithHelper = async (): Promise<Reading & { isCached?: boolean }> => {
154 const token = await helperTokenOf($)
155
156 if (token === undefined) {
157 return { problem: resolved.problem, brief: null }
158 }
159
160 if ('failure' in token) {
161 return token.failure
162 }
163
164 const withHelper = endpointOf({ ...env, helperToken: token.value })
165
166 return 'problem' in withHelper
167 ? { problem: withHelper.problem, brief: null }
168 : { ...(await fetchUsage($, withHelper.endpoint)), isCached: token.isCached }
169 }
170
171 const reading = await readWithHelper()
172
173 // A reused token the gateway refused has likely rotated: drop it and ask
174 // the helper once more. A fresh one it refused is refused for good.
175 if (reading.isCached && 'problem' in reading && reading.brief === CREDENTIAL_REFUSED) {
176 helperToken = undefined
177
178 return readWithHelper()
179 }
180
181 return reading
182}
183
184/** The refresh under way, which a refresh asked for meanwhile joins. */
185let inFlight: Promise<void> | undefined
186
187/** Set when a refresh joined the one under way: it reads once more. */
188let again = false
189
190/**
191 * Reads the pool again and moves the band's clock on. Off a gateway the read
192 * resolves at once, with no request, and only the clock moves. A refresh that
193 * fails outright leaves the last snapshot standing rather than blanking the
194 * band.
195 */
196async function refresh($: EngineInterface) {
197 // The minute's poll and a turn's end can land together: join the read
198 // under way rather than run the helper and the request twice. A read that
199 // began before the turn's usage was recorded would leave the band stale, so
200 // the joiner asks for one trailing read, however many join.
201 if (inFlight !== undefined) {
202 again = true
203
204 return inFlight
205 }
206
207 inFlight = (async () => {
208 try {
209 do {
210 again = false
211
212 try {
213 const reading = await readUsage($)
214 const at = await $.clock.now()
215
216 await update($, snapshot, () => snapshotOf(reading))
217 await update($, now, () => at)
218 } catch {
219 // A timer's callback has no caller to report to: the next tick retries.
220 }
221 } while (again)
222 } finally {
223 inFlight = undefined
224 }
225 })()
226
227 return inFlight
228}
229
230/**
231 * The `/shunt:usage` command: answered from the gateway's `GET /usage` rather
232 * than sent to the model.
233 *
234 * The command itself is `commands/usage.md`, which lists as `shunt:usage`
235 * because the plugin namespaces it; the markdown body is the fallback for a
236 * session without function hooks, and this hook answers before it is ever
237 * read. `$.command.register` cannot serve this command: it takes a bare global
238 * name and refuses `usage` as the built-in's.
239 */
240function registerCommand(on: On) {
241 /**
242 * A hook that throws or overruns its budget is treated as absent, and core
243 * runs in its place — for this command that is `commands/usage.md`, which
244 * asks the model to fetch the endpoint with a tool call. The answer would
245 * still arrive, but it would cost a model turn in a mod whose whole point is
246 * that it costs none, so the failure is reported rather than handed on.
247 */
248 on('command.run', { command: COMMAND_NAME }, async $ => {
249 const reading = await readUsage($)
250
251 if ('problem' in reading) {
252 return { text: reading.problem }
253 }
254
255 return {
256 text: reportText(reading.report, reading.base, await $.clock.now()),
257 }
258 }).catch(() => ({ text: HOOK_FAILED_TEXT }))
259}
260
261/**
262 * The usage band: how much of each window is used and the time to each
263 * reset, one row above the prompt.
264 *
265 * On a shunt gateway that serves `GET /usage` it shows the pool, tagged
266 * `shunt ·`; anywhere else, the session's own rate limits as Claude Code
267 * measures them. It reads the pool when the session starts, every minute
268 * after, and after each turn, which is when the pool has just been spent
269 * from. Whatever another plugin draws in the band stays on its left.
270 */
271function registerBand(on: On) {
272 registerPolling(on)
273 registerDrawing(on)
274}
275
276/**
277 * Keeps the band's figures current: the pool at session start, every minute
278 * and after each turn, and the session's own rate limits as they are measured.
279 */
280function registerPolling(on: On) {
281 on('session.start', async ($, e, next) => {
282 const result = await next(e)
283
284 $.clock.after(0, () => refresh($))
285 $.clock.every(POLL_MS, () => refresh($))
286
287 // The session's own limits are the fallback, so failing to read them
288 // must not cost the pool its polling: `session.measure` brings them later.
289 try {
290 const usage = await $.session.usage()
291
292 await update($, limits, () => copyOf(usage.rateLimits))
293 } catch {
294 // Left empty until the first measurement.
295 }
296
297 return result
298 })
299
300 on('session.measure', async ($, e, next) => {
301 if (e.changed.includes('rateLimits')) {
302 await update($, limits, () => copyOf(e.rateLimits))
303 }
304
305 return next(e)
306 })
307
308 on('turn.complete', async ($, e, next) => {
309 const result = await next(e)
310
311 $.clock.after(0, () => refresh($))
312
313 return result
314 })
315}
316
317/**
318 * Draws the band to the right of whatever is drawn beneath it, or leaves the
319 * row alone when there is nothing to show or a survey holds it.
320 */
321function registerDrawing(on: On) {
322 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
323 const inner = await next(e)
324
325 if (e.props.hasSurvey) {
326 return inner
327 }
328
329 const [current, rateLimits, at] = await Promise.all([
330 read($, snapshot),
331 read($, limits),
332 read($, now),
333 ])
334
335 const band = bandOf(current, rateLimits, at || (await $.clock.now()))
336
337 if (band === null) {
338 return inner
339 }
340
341 const { Box, Text } = $.ui.resolve(e)
342
343 return (
344 <Box flexDirection="row" gap={2} flexGrow={1}>
345 <Box flexGrow={1}>{inner}</Box>
346 <Box key="usage" flexDirection="row">
347 {band.isShunt ? <Text dimColor>{'shunt · '}</Text> : null}
348 {band.cells.map((cell, index) => (
349 <Box key={cell.key} flexDirection="row">
350 <Text dimColor>{`${index === 0 ? '' : ' · '}${cell.label} `}</Text>
351 <Text color={COLORS[cell.level]}>{cell.percent}</Text>
352 {cell.reset === '' ? null : (
353 <Text dimColor>{` ↻${cell.reset}`}</Text>
354 )}
355 </Box>
356 ))}
357 {band.alerts.map((alert, index) => (
358 <Box key={`alert:${index}:${alert.text}`} flexDirection="row">
359 <Text color={COLORS[alert.level]}>
360 {`${index === 0 && band.cells.length === 0 ? '' : ' '}⚠ ${alert.text}`}
361 </Text>
362 </Box>
363 ))}
364 </Box>
365 </Box>
366 )
367 })
368}
369
370/**
371 * Registers `/shunt:usage`, and the usage band unless the `usageBand` option
372 * turns it off — off, the mod polls nothing and draws nothing.
373 *
374 * @param on the engine's registrar
375 * @param options the manifest's `userConfig` values, defaults filled in
376 */
377export function register(on: On, options: PluginOptions) {
378 registerCommand(on)
379
380 if (options.usageBand !== false) {
381 registerBand(on)
382 }
383}
384hooks/band.ts 207 lines1import type { ShuntRateLimit, ShuntSnapshot } from '../types'
2
3import type { Reading } from './reading'
4import type { WindowKey } from './report'
5import { WINDOW_KEYS } from './report'
6import { percentOf } from './views'
7
8/** How loudly a figure is drawn: plain, amber, red. */
9export type Level = 'ok' | 'warn' | 'hot'
10
11/** One window as the band draws it. */
12export type BandCell = {
13 key: string
14 /** `5H`, `WK`, `Fable`, `Spend`. */
15 label: string
16 /** How much of the window is used, whole percent: `46%`. */
17 percent: string
18 /** Time to the window's reset (`1h 41m`), `due`, or `''`. */
19 reset: string
20 level: Level
21}
22
23/** A status worth flagging after the cells: `anthropic degraded`. */
24export type BandAlert = { text: string; level: Level }
25
26/**
27 * The band's whole content. `isShunt` puts the `shunt ·` tag in front: the
28 * figures are the gateway pool's, not the session's own account's.
29 */
30export type Band = { isShunt: boolean; cells: BandCell[]; alerts: BandAlert[] }
31
32/** Usage from which a window turns amber, then red. */
33const WARN_FROM = 0.7
34const HOT_FROM = 0.9
35
36const SHUNT_LABELS: Readonly<Record<WindowKey, string>> = {
37 '5h': '5H',
38 '7d': 'WK',
39 fable: 'Fable',
40}
41
42const LIMIT_LABELS: Readonly<Record<string, string>> = {
43 five_hour: '5H',
44 seven_day: 'WK',
45 spend_limit: 'Spend',
46}
47
48const STATUS_LEVELS: Readonly<Record<string, Level>> = {
49 ok: 'ok',
50 degraded: 'warn',
51 exhausted: 'hot',
52 capped: 'hot',
53}
54
55/**
56 * The level of one window's usage, a fraction: red from nine tenths used,
57 * amber from seven.
58 */
59export const levelOf = (used: number): Level =>
60 used >= HOT_FROM ? 'hot' : used >= WARN_FROM ? 'warn' : 'ok'
61
62/**
63 * A status's level. One this build does not know is amber rather than plain:
64 * the gateway said something other than `ok`.
65 */
66export const statusLevelOf = (status: string): Level =>
67 STATUS_LEVELS[status] ?? 'warn'
68
69/**
70 * The time from `nowMs` to `resetsAt` (epoch seconds) as the band shows it:
71 * `4d 0h`, `2h 55m`, `12m`; `due` once it has passed, `''` with no reset.
72 *
73 * A countdown rather than the clock time `/shunt:usage` prints: the band sits
74 * there for hours, and "in 2h 55m" stays readable without a calendar.
75 */
76export const countdownOf = (resetsAt: number | null, nowMs: number): string => {
77 if (resetsAt === null) {
78 return ''
79 }
80
81 const ms = resetsAt * 1000 - nowMs
82
83 if (ms <= 0) {
84 return 'due'
85 }
86
87 const minutes = Math.max(1, Math.round(ms / 60_000))
88 const days = Math.floor(minutes / 1440)
89 const hours = Math.floor((minutes % 1440) / 60)
90 const rest = minutes % 60
91
92 return days > 0 ? `${days}d ${hours}h` : hours > 0 ? `${hours}h ${rest}m` : `${rest}m`
93}
94
95/**
96 * What the band keeps of a reading: the pool and each provider's status, the
97 * brief reason, or `null` where the session is not on a shunt gateway that
98 * serves `GET /usage` — the band then shows the session's own rate limits.
99 */
100export function snapshotOf(reading: Reading): ShuntSnapshot | null {
101 if ('report' in reading) {
102 const { pool, providers } = reading.report
103
104 return {
105 pool,
106 providers: providers.map(([name, provider]) => ({
107 name,
108 status: provider.status,
109 })),
110 }
111 }
112
113 return reading.brief === null ? null : { problem: reading.brief }
114}
115
116const cellOf = (
117 key: string,
118 label: string,
119 used: number,
120 resetsAt: number | null,
121 nowMs: number,
122): BandCell => ({
123 key,
124 label,
125 percent: percentOf(used).trim(),
126 reset: countdownOf(resetsAt, nowMs),
127 level: levelOf(used),
128})
129
130/**
131 * The gateway pool's band. Usage is `1 - remaining`, the mean share of the
132 * pool's capacity spent, so it reads the same way as the session's own limits
133 * do without the tag. A window no account reports is left out; each provider
134 * whose status is not `ok` is flagged, or the pool itself where none is.
135 */
136function shuntBandOf(snapshot: ShuntSnapshot, nowMs: number): Band {
137 if ('problem' in snapshot) {
138 return {
139 isShunt: true,
140 cells: [],
141 alerts: [{ text: snapshot.problem, level: 'warn' }],
142 }
143 }
144
145 const { pool, providers } = snapshot
146
147 const cells = WINDOW_KEYS.flatMap(key => {
148 const { remaining, resetsAt } = pool.windows[key]
149
150 return remaining === null
151 ? []
152 : [cellOf(key, SHUNT_LABELS[key], 1 - remaining, resetsAt, nowMs)]
153 })
154
155 const flagged = providers
156 .filter(provider => provider.status !== 'ok')
157 .map(provider => ({
158 text: `${provider.name} ${provider.status}`,
159 level: statusLevelOf(provider.status),
160 }))
161
162 const alerts =
163 flagged.length > 0 || pool.status === 'ok'
164 ? flagged
165 : [{ text: `pool ${pool.status}`, level: statusLevelOf(pool.status) }]
166
167 return { isShunt: true, cells, alerts }
168}
169
170const epochOf = (iso: string | undefined): number | null => {
171 const ms = iso === undefined ? NaN : Date.parse(iso)
172
173 return Number.isFinite(ms) ? ms / 1000 : null
174}
175
176/** The session's own rate limits, as Claude Code reports them. */
177function limitsBandOf(limits: readonly ShuntRateLimit[], nowMs: number): Band {
178 const cells = limits.map(limit =>
179 cellOf(
180 limit.kind,
181 LIMIT_LABELS[limit.kind] ?? limit.kind.replace(/_/g, ' '),
182 limit.percentUsed / 100,
183 epochOf(limit.resetsAt),
184 nowMs,
185 ),
186 )
187
188 return { isShunt: false, cells, alerts: [] }
189}
190
191/**
192 * What the band draws: the gateway pool when the session is on shunt, else
193 * the session's own rate limits, else nothing (`null`) — an API-key session
194 * off any gateway has no limits to show.
195 */
196export function bandOf(
197 snapshot: ShuntSnapshot | null,
198 limits: readonly ShuntRateLimit[],
199 nowMs: number,
200): Band | null {
201 if (snapshot !== null) {
202 return shuntBandOf(snapshot, nowMs)
203 }
204
205 return limits.length === 0 ? null : limitsBandOf(limits, nowMs)
206}
207hooks/endpoint.ts 110 lines1import { NO_BASE_URL_TEXT, NO_TOKEN_TEXT, USAGE_PATH } from './names'
2
3/**
4 * The environment the mod resolves the gateway from, read once per run with
5 * one literal `$.env.get` each so `claude plugin validate` can list them.
6 */
7export type Environment = {
8 shuntBaseUrl?: string
9 anthropicBaseUrl?: string
10 shuntToken?: string
11 anthropicAuthToken?: string
12 anthropicApiKey?: string
13 /**
14 * What shunt's own `apiKeyHelper` printed: the gateway login token of a
15 * `shunt gateway claude` session, which keeps no credential in the
16 * environment. Asked for only when none of the variables above holds one.
17 */
18 helperToken?: string
19}
20
21/**
22 * Where to ask, and with what: the absolute `GET /usage` URL and the one
23 * credential header to send.
24 */
25export type Endpoint = {
26 /** The gateway's base, as it was configured, for error text. */
27 base: string
28 url: string
29 headers: Record<string, string>
30}
31
32/**
33 * Either an endpoint to call or the reason there is none to call.
34 */
35export type Resolved = { endpoint: Endpoint } | { problem: string }
36
37/**
38 * Joins `base` and `USAGE_PATH` without doubling or dropping the separator.
39 */
40const usageUrlOf = (base: string) => `${base.replace(/\/+$/, '')}${USAGE_PATH}`
41
42const baseOf = (url: string | undefined): string =>
43 (url ?? '').trim().replace(/\/+$/, '')
44
45/**
46 * Whether the helper's gateway login may be sent to the gateway this
47 * environment resolves to. The helper prints the login of the gateway the
48 * session talks to (`ANTHROPIC_BASE_URL`); `SHUNT_BASE_URL` can name another
49 * gateway, and that one must never receive it. So it rides only when
50 * `SHUNT_BASE_URL` is unset or blank, or names the same base — compared after
51 * trimming and dropping trailing slashes, exactly otherwise.
52 */
53export function helperMayRideTo(env: Environment): boolean {
54 const shunt = baseOf(env.shuntBaseUrl)
55
56 return shunt === '' || shunt === baseOf(env.anthropicBaseUrl)
57}
58
59/**
60 * Resolves the gateway endpoint from the session's environment.
61 *
62 * The base URL is the gateway this session already sends every message to, so
63 * asking it for `/usage` reaches no host the session was not already using —
64 * with no base URL set there is nothing to ask, and the mod says so instead of
65 * sending the session's credential to Anthropic's own API.
66 *
67 * The credential rides the header Claude Code itself uses for that variable —
68 * `ANTHROPIC_AUTH_TOKEN` as a `Bearer`, `ANTHROPIC_API_KEY` as `x-api-key` —
69 * so whichever shape the operator's `[server.auth]` matches on, it matches the
70 * same way here. `SHUNT_TOKEN` overrides both and rides as a `Bearer`, and the
71 * helper's gateway login token rides as a `Bearer` when nothing else is set and
72 * `helperMayRideTo` allows it; otherwise it resolves as no credential.
73 *
74 * A credential that is set but blank is no credential: `$.env.get` reads an
75 * exported-but-empty variable as `''`, so every candidate is normalized to
76 * absent before the choice is made rather than after. That cuts both ways — a
77 * blank override falls back instead of shadowing a working one, and a blank
78 * fallback reports the missing token here instead of sending an empty header
79 * for the gateway to reject as a wrong one.
80 */
81export function endpointOf(env: Environment): Resolved {
82 const base = env.shuntBaseUrl?.trim() || env.anthropicBaseUrl
83
84 if (base === undefined || base.trim() === '') {
85 return { problem: NO_BASE_URL_TEXT }
86 }
87
88 const bearer = env.shuntToken?.trim() || env.anthropicAuthToken?.trim()
89 const apiKey = env.anthropicApiKey?.trim()
90 const helper = helperMayRideTo(env) ? env.helperToken?.trim() : undefined
91
92 const headers: Record<string, string> | undefined = bearer
93 ? { authorization: `Bearer ${bearer}` }
94 : apiKey
95 ? { 'x-api-key': apiKey }
96 : helper
97 ? { authorization: `Bearer ${helper}` }
98 : undefined
99
100 if (headers === undefined) {
101 return { problem: NO_TOKEN_TEXT }
102 }
103
104 const trimmed = base.trim()
105
106 return {
107 endpoint: { base: trimmed, url: usageUrlOf(trimmed), headers },
108 }
109}
110hooks/helper.ts 69 lines1/**
2 * Recognizes shunt's own `apiKeyHelper`, so the mod can ask it for the
3 * gateway login token a `shunt gateway claude` session authenticates with.
4 *
5 * That launcher scrubs `ANTHROPIC_AUTH_TOKEN` and `ANTHROPIC_API_KEY` from the
6 * environment and hands Claude Code an inline `--settings` document whose
7 * `apiKeyHelper` is `'<path>/shunt' gateway token`; Claude Code consumes the
8 * helper's output itself and never re-exports it. `shunt gateway login`
9 * suggests the bare `shunt gateway token` for a hand-written setting.
10 *
11 * The command is a shell command line to Claude Code, which expands a leading
12 * `~/` in an unquoted executable word; the mod runs the argv without a shell,
13 * so it expands that one case itself. A quoted `'~/x'` is literal in a shell
14 * and stays literal here.
15 *
16 * Only that command is run. Any other helper — a password manager, a script
17 * that prompts — is left alone: the mod would otherwise run it every few
18 * minutes for a usage figure, with whatever side effects it has.
19 */
20
21/** The executable's quoted or bare spelling, then `gateway token`. */
22const SHUNT_HELPER =
23 /^\s*('(?:[^']|'\\'')*'|"[^"]*"|[^\s'"]+)\s+gateway\s+token\s*$/
24
25const BASENAME = /(?:^|[\\/])shunt(?:\.exe)?$/
26
27/**
28 * Undoes the quoting `shunt gateway claude` applies: POSIX single quotes with
29 * `'\''` for an embedded quote, or plain double quotes on Windows.
30 */
31const unquote = (word: string): string =>
32 word.startsWith("'")
33 ? word.slice(1, -1).replaceAll(`'\\''`, `'`)
34 : word.startsWith('"')
35 ? word.slice(1, -1)
36 : word
37
38/**
39 * The argv to run for an `apiKeyHelper` setting when it is shunt's own, or
40 * `null` for anything else (including no setting at all).
41 *
42 * The argv is run directly rather than through a shell, so nothing in the
43 * setting is interpreted beyond the quoting the launcher writes.
44 *
45 * @param command the merged settings' `apiKeyHelper`, as read
46 * @param home the home directory a leading `~/` expands to, when known
47 */
48export function shuntHelperArgvOf(command: unknown, home?: string): string[] | null {
49 if (typeof command !== 'string') {
50 return null
51 }
52
53 const match = SHUNT_HELPER.exec(command)
54 const word = match?.[1] ?? ''
55 const executable = unquote(word)
56
57 if (!BASENAME.test(executable)) {
58 return null
59 }
60
61 if (word.startsWith('~/')) {
62 const root = home?.replace(/[\\/]+$/, '') ?? ''
63
64 return root === '' ? null : [`${root}${executable.slice(1)}`, 'gateway', 'token']
65 }
66
67 return [executable, 'gateway', 'token']
68}
69hooks/names.ts 51 lines1/**
2 * The command the mod answers, and the fixed texts it prints.
3 *
4 * The name is namespaced by the plugin (`shunt` + `commands/usage.md`), which
5 * is what keeps it off the built-in `/usage`: `$.command.register` takes a
6 * bare global name and refuses `usage` outright ("it is the built-in /usage"),
7 * while a plugin's markdown command lists as `shunt:usage` and is free.
8 */
9export const COMMAND_NAME = 'shunt:usage'
10
11/**
12 * The path `[server.usage]` registers on the gateway.
13 */
14export const USAGE_PATH = '/usage'
15
16export const NO_BASE_URL_TEXT =
17 'no gateway to ask — neither SHUNT_BASE_URL nor ANTHROPIC_BASE_URL is ' +
18 'set, so this session is talking to Anthropic directly. Point Claude Code at ' +
19 'your gateway (export ANTHROPIC_BASE_URL=http://127.0.0.1:3001) and run ' +
20 '/shunt:usage again.'
21
22export const NO_TOKEN_TEXT =
23 'no usable credential to authenticate with — none of SHUNT_TOKEN, ' +
24 'ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY is set, and there is no shunt ' +
25 'gateway token apiKeyHelper to read a gateway login from. Log in with ' +
26 'shunt gateway login, or ask the gateway operator for a client token.'
27
28export const NOT_ENABLED_TEXT =
29 'the gateway answered, but GET /usage is not enabled on it. Add an ' +
30 '[server.usage] table to the gateway config and restart it; the table takes ' +
31 'no keys, but it requires [server.auth] (client tokens) or ' +
32 '[server.gateway] (gateway login) to be set as well.'
33
34export const REFUSED_TEXT =
35 'the gateway refused the credential this session is using. The first ' +
36 'of SHUNT_TOKEN, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY and the ' +
37 'shunt gateway token helper that is set is the one sent, and the rest are ' +
38 'ignored. A client token must be one the gateway\'s [server.auth] accepts; ' +
39 'a gateway login may have expired or been revoked, so log in again with ' +
40 'shunt gateway login.'
41
42export const HOOK_FAILED_TEXT =
43 'could not read the pool usage — the hook that answers this command ' +
44 'failed or ran past its budget. Run /shunt:usage again; if it keeps ' +
45 'failing, check that the gateway is up and reachable.'
46
47export const NO_POOL_TEXT =
48 'the gateway reports no pooled provider, so there is no shared quota ' +
49 'to show. Pool usage appears once a provider is configured with pooled ' +
50 'accounts (auth mode oauth or chatgpt).'
51hooks/reading.ts 143 lines1import type { Endpoint } from './endpoint'
2import { NOT_ENABLED_TEXT, NO_POOL_TEXT, REFUSED_TEXT } from './names'
3import type { UsageReport } from './report'
4import { WINDOW_KEYS, parseReport } from './report'
5
6/**
7 * What one `GET /usage` round trip came to: the report, or the reason there
8 * is none.
9 *
10 * `problem` is the full sentence `/shunt:usage` prints. `brief` is the few
11 * words the band above the prompt flags instead, or `null` where the session
12 * is not on a shunt gateway that serves `GET /usage`: no gateway is set, it
13 * answers 404 or with a body that is not a pool report (another proxy), or it
14 * pools no provider. Those are set-ups, not faults, and the band shows the
15 * session's own rate limits for them rather than nagging.
16 */
17export type Reading =
18 | { report: UsageReport; base: string }
19 | { problem: string; brief: string | null }
20
21/** The band's brief for a 401 or 403: the gateway refused the credential. */
22export const CREDENTIAL_REFUSED = 'credential refused'
23
24/** The band's brief when shunt's own `apiKeyHelper` could not give a token. */
25export const HELPER_FAILED = 'gateway login unavailable'
26
27/** The subset of the engine's `HttpResponse` a reading needs. */
28export type Response = { status: number; ok: boolean; text: string }
29
30/**
31 * An error's message, less the `shunt: ` the engine stamps on its own — the
32 * transcript line already carries the plugin's name, so keeping it would read
33 * `shunt: ... — shunt: ...`.
34 */
35const messageOf = (error: unknown): string =>
36 (error instanceof Error ? error.message : String(error)).replace(
37 /^shunt: /,
38 '',
39 )
40
41/**
42 * The first line of a body, clipped, for an error the mod has no words of its
43 * own for — enough to recognize the gateway's own message without pasting a
44 * page of HTML into the transcript.
45 */
46const firstLineOf = (text: string): string => {
47 const line = text.split('\n', 1)[0]?.trim() ?? ''
48
49 return line.length > 200 ? `${line.slice(0, 200)}…` : line
50}
51
52/**
53 * The reading for shunt's own `apiKeyHelper` (`shunt gateway token`) failing:
54 * it exited non-zero, printed nothing, or could not be started. The session
55 * logs in through that helper, so this is a fault on shunt — flagged rather
56 * than hidden behind the session's own limits.
57 *
58 * @param cause the finished run, or what `$.process.run` rejected with
59 */
60export function helperFailedOf(
61 cause:
62 | { exitCode: number; stdout: string; stderr: string }
63 | { error: unknown },
64): Reading {
65 const stderr = 'error' in cause ? '' : firstLineOf(cause.stderr)
66
67 const detail =
68 'error' in cause
69 ? messageOf(cause.error)
70 : stderr !== ''
71 ? stderr
72 : cause.exitCode === 0
73 ? 'it printed nothing'
74 : `it exited with ${cause.exitCode}`
75
76 return {
77 problem:
78 'the apiKeyHelper is shunt gateway token, but running it failed ' +
79 `${'—'} ${detail}. Run shunt gateway login to log in again.`,
80 brief: HELPER_FAILED,
81 }
82}
83
84/**
85 * The reading for a request that never got an answer.
86 *
87 * @param endpoint where it was sent
88 * @param error what `$.http.fetch` rejected with
89 */
90export function unreachableOf(endpoint: Endpoint, error: unknown): Reading {
91 return {
92 problem:
93 `could not reach the gateway at ${endpoint.base} ` +
94 `${'—'} ${messageOf(error)}`,
95 brief: 'gateway unreachable',
96 }
97}
98
99/**
100 * Reads the gateway's answer to `GET /usage`.
101 *
102 * @param endpoint where it was sent
103 * @param response the status and body the gateway answered with
104 */
105export function readingOf(endpoint: Endpoint, response: Response): Reading {
106 if (response.status === 404) {
107 return { problem: NOT_ENABLED_TEXT, brief: null }
108 }
109
110 if (response.status === 401 || response.status === 403) {
111 return { problem: REFUSED_TEXT, brief: CREDENTIAL_REFUSED }
112 }
113
114 if (!response.ok) {
115 const detail = firstLineOf(response.text)
116
117 return {
118 problem:
119 `the gateway answered GET /usage with ${response.status}` +
120 `${detail === '' ? '' : ` ${'—'} ${detail}`}`,
121 brief: `GET /usage answered ${response.status}`,
122 }
123 }
124
125 const parsed = parseReport(response.text)
126
127 if ('problem' in parsed) {
128 return { problem: parsed.problem, brief: null }
129 }
130
131 const { report } = parsed
132
133 const isSilent =
134 report.providers.length === 0 &&
135 WINDOW_KEYS.every(key => report.pool.windows[key].remaining === null)
136
137 if (isSilent) {
138 return { problem: NO_POOL_TEXT, brief: null }
139 }
140
141 return { report, base: endpoint.base }
142}
143hooks/views.ts 154 lines1import type { PoolStatus, UsageReport, WindowKey } from './report'
2import { WINDOW_KEYS } from './report'
3
4const BAR_WIDTH = 10
5const BAR_FULL = '▓'
6const BAR_EMPTY = '░'
7const DASH = '—'
8
9const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const
10
11const pad2 = (value: number) => String(value).padStart(2, '0')
12
13/**
14 * The bar for one window's headroom; an unreported window has no bar to draw.
15 */
16export const barOf = (remaining: number | null): string => {
17 if (remaining === null) {
18 return ' '.repeat(BAR_WIDTH)
19 }
20
21 const filled = Math.round(remaining * BAR_WIDTH)
22
23 return BAR_FULL.repeat(filled) + BAR_EMPTY.repeat(BAR_WIDTH - filled)
24}
25
26/**
27 * A window's headroom as whole percent, right-aligned; `—` when unreported.
28 *
29 * Rounds away from the ends so a pool with a sliver left does not read `0%`
30 * and one a hair short of full does not read `100%`.
31 */
32export const percentOf = (remaining: number | null): string => {
33 if (remaining === null) {
34 return DASH.padStart(4)
35 }
36
37 const raw = remaining * 100
38
39 const whole =
40 raw > 0 && raw < 1 ? 1 : raw < 100 && raw > 99 ? 99 : Math.round(raw)
41
42 return `${whole}%`.padStart(4)
43}
44
45/**
46 * When the window's earliest reset lands, in local time: the clock alone when
47 * it lands today, the weekday too on any later date, and `due` once it has
48 * passed.
49 *
50 * The cut is the calendar date rather than a 24-hour delta, because a bare
51 * clock is only unambiguous on today's date: at 04:13 on Thursday a reset 22
52 * hours out is Friday 02:13, and rendering it as `02:13` reads as a time that
53 * has already gone by.
54 */
55export const resetTextOf = (
56 resetsAt: number | null,
57 nowMs: number,
58): string => {
59 if (resetsAt === null) {
60 return ''
61 }
62
63 const atMs = resetsAt * 1000
64
65 if (atMs <= nowMs) {
66 return 'resets due'
67 }
68
69 const at = new Date(atMs)
70 const clock = `${pad2(at.getHours())}:${pad2(at.getMinutes())}`
71
72 const now = new Date(nowMs)
73 const today =
74 at.getFullYear() === now.getFullYear() &&
75 at.getMonth() === now.getMonth() &&
76 at.getDate() === now.getDate()
77
78 return today ? `resets ${clock}` : `resets ${DAYS[at.getDay()]} ${clock}`
79}
80
81const windowLabel = (key: WindowKey) => key.padEnd(5)
82
83const poolLines = (pool: PoolStatus, nowMs: number): string[] =>
84 WINDOW_KEYS.map(key => {
85 const window = pool.windows[key]
86
87 const row =
88 ` ${windowLabel(key)} ${barOf(window.remaining)} ` +
89 `${percentOf(window.remaining)} left`
90
91 const reset = resetTextOf(window.resetsAt, nowMs)
92
93 return reset === '' ? row : `${row} ${reset}`
94 })
95
96const providerLines = (
97 providers: UsageReport['providers'],
98): string[] => {
99 if (providers.length === 0) {
100 return []
101 }
102
103 const nameWidth = Math.max(...providers.map(([name]) => name.length))
104
105 const statusWidth = Math.max(
106 ...providers.map(([, status]) => status.status.length),
107 )
108
109 return providers.map(([name, status]) => {
110 const cells = WINDOW_KEYS.map(
111 key => `${key} ${percentOf(status.windows[key].remaining)}`,
112 ).join(' ')
113
114 return ` ${name.padEnd(nameWidth)} ${status.status.padEnd(statusWidth)} ${cells}`
115 })
116}
117
118/**
119 * The whole `/shunt:usage` answer: the pool's three windows as bars, then the
120 * same headroom per pooled provider, then what the numbers mean.
121 *
122 * The legend is not decoration — `remaining` is the mean fraction still
123 * *usable* across the pool's accounts (up to each account's `max_utilization`
124 * hard cap), so a bare `62%` invites exactly the wrong
125 * reading ("62% burned"), and it is an aggregate rather than a prediction that
126 * the next request is admitted.
127 *
128 * @param report the parsed `GET /usage` body
129 * @param base the gateway the report came from
130 * @param nowMs the engine's clock, for the reset times
131 */
132export function reportText(
133 report: UsageReport,
134 base: string,
135 nowMs: number,
136): string {
137 const head = `pool ${DASH} ${report.pool.status} ${base}`
138
139 const providers = providerLines(report.providers)
140
141 const legend =
142 ` headroom left, averaged over the pool's accounts; a shared figure, ` +
143 `not a promise about your next request`
144
145 return [
146 head,
147 '',
148 ...poolLines(report.pool, nowMs),
149 ...(providers.length === 0 ? [] : ['', ...providers]),
150 '',
151 legend,
152 ].join('\n')
153}
154hooks/report.ts 125 lines1/**
2 * `GET /usage`'s sanitized aggregate, as the mod reads it.
3 *
4 * The gateway's own shape (src/usage.rs): `{ pool, providers }`, each a status
5 * string and the three tracked windows. Every number here is pool-wide — the
6 * endpoint deliberately carries no account identity, count or priority.
7 */
8
9/** The three windows the gateway tracks, in the order they are shown. */
10export const WINDOW_KEYS = ['5h', '7d', 'fable'] as const
11
12export type WindowKey = (typeof WINDOW_KEYS)[number]
13
14export type WindowStatus = {
15 /**
16 * The fraction of the pool's combined capacity still *usable* before each
17 * account's `max_utilization` hard cap excludes it, `0..=1`, or
18 * `null` where no non-disabled account reports the window. Headroom, not
19 * consumption: a full bar is a fresh pool.
20 */
21 remaining: number | null
22 /** Earliest reported reset, unix epoch seconds, or `null`. */
23 resetsAt: number | null
24}
25
26export type PoolStatus = {
27 /** `ok`, `degraded` or `exhausted`, derived from availability alone. */
28 status: string
29 windows: Record<WindowKey, WindowStatus>
30}
31
32export type UsageReport = {
33 pool: PoolStatus
34 /** One entry per pooled provider, by configured name, sorted. */
35 providers: readonly (readonly [string, PoolStatus])[]
36}
37
38export type Parsed = { report: UsageReport } | { problem: string }
39
40const isRecord = (value: unknown): value is Record<string, unknown> =>
41 typeof value === 'object' && value !== null && !Array.isArray(value)
42
43/**
44 * A finite number in `0..=1`, or `null` for anything else (including the
45 * `null` the gateway sends for an unreported window).
46 */
47const fractionOf = (value: unknown): number | null =>
48 typeof value === 'number' && Number.isFinite(value)
49 ? Math.min(1, Math.max(0, value))
50 : null
51
52const epochOf = (value: unknown): number | null =>
53 typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null
54
55const windowOf = (value: unknown): WindowStatus => {
56 const row = isRecord(value) ? value : {}
57
58 return { remaining: fractionOf(row.remaining), resetsAt: epochOf(row.resets_at) }
59}
60
61const poolOf = (value: unknown): PoolStatus | null => {
62 if (!isRecord(value)) {
63 return null
64 }
65
66 const windows = isRecord(value.windows) ? value.windows : {}
67
68 return {
69 status: typeof value.status === 'string' ? value.status : 'unknown',
70 windows: {
71 '5h': windowOf(windows['5h']),
72 '7d': windowOf(windows['7d']),
73 fable: windowOf(windows.fable),
74 },
75 }
76}
77
78/**
79 * Reads a `GET /usage` body, keeping every field the mod draws and refusing a
80 * body that is not one — a proxy's HTML error page parses as neither.
81 *
82 * @param text the response body
83 * @returns the report, or the reason it could not be read
84 */
85export function parseReport(text: string): Parsed {
86 let value: unknown
87
88 try {
89 value = JSON.parse(text)
90 } catch {
91 return {
92 problem:
93 'the gateway answered GET /usage with something that is not ' +
94 'JSON. Check that ANTHROPIC_BASE_URL points at the gateway itself and ' +
95 'not at a proxy in front of it.',
96 }
97 }
98
99 if (!isRecord(value)) {
100 return { problem: 'the gateway answered GET /usage with an unexpected body.' }
101 }
102
103 const pool = poolOf(value.pool)
104
105 if (pool === null) {
106 return {
107 problem:
108 'the gateway answered GET /usage without a pool aggregate. ' +
109 'This build may be older than the endpoint the mod reads.',
110 }
111 }
112
113 const rows = isRecord(value.providers) ? value.providers : {}
114
115 const providers = Object.keys(rows)
116 .sort()
117 .flatMap(name => {
118 const status = poolOf(rows[name])
119
120 return status === null ? [] : [[name, status] as const]
121 })
122
123 return { report: { pool, providers } }
124}
125types/index.d.ts 60 lines1/**
2 * The `shunt` mod's `$.state` contract: what the band above the prompt draws
3 * from — the gateway pool as `GET /usage` last reported it, the session's own
4 * Anthropic rate limits, and the clock its countdowns run from.
5 */
6
7/** One window of the pool aggregate, as `hooks/report.ts` reads it. */
8export type ShuntWindow = {
9 /** The unused fraction of the pool's capacity, `0..=1`, or `null`. */
10 remaining: number | null
11 /** The earliest reported reset, unix epoch seconds, or `null`. */
12 resetsAt: number | null
13}
14
15/** The pool-wide aggregate: a status and the three tracked windows. */
16export type ShuntPool = {
17 status: string
18 windows: { '5h': ShuntWindow; '7d': ShuntWindow; fable: ShuntWindow }
19}
20
21/** One pooled provider's status, by its configured name. */
22export type ShuntProviderStatus = { name: string; status: string }
23
24/**
25 * The gateway as last read: the pool and each provider's status, or the short
26 * reason a gateway that serves `GET /usage` could not be read.
27 */
28export type ShuntSnapshot =
29 | { pool: ShuntPool; providers: ShuntProviderStatus[] }
30 | { problem: string }
31
32/**
33 * One of the session's own rate-limit windows, as `$.session.usage()` and
34 * `session.measure` report them.
35 */
36export type ShuntRateLimit = {
37 /** `five_hour`, `seven_day`, `spend_limit`, or another the engine names. */
38 kind: string
39 /** 0 to 100, past 100 on an exceeded spend limit. */
40 percentUsed: number
41 /** ISO 8601. */
42 resetsAt?: string
43}
44
45declare module 'claude-code' {
46 interface PluginState {
47 shunt: {
48 /**
49 * `null` while the session is not on a shunt gateway that serves
50 * `GET /usage`: the band then draws `limits` instead.
51 */
52 snapshot: ShuntSnapshot | null
53 /** The session's own rate limits; empty off a subscription. */
54 limits: ShuntRateLimit[]
55 /** The engine clock at the last refresh, in milliseconds. */
56 now: number
57 }
58 }
59}
60