A Claude Mod for the shunt gateway: /shunt:usage reads the shared account pool's remaining headroom and reset times from shunt's GET /usage, so you can see how…

A Claude Mod — a Claude Code plugin whose behaviour is a function hooks module — that answers /shunt:usage with the shunt gateway's shared account pool usage.
> /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.
remaining is the fraction of the pool's combined capacity still unused, so 62% means 62% of the headroom is left, not that 62% is spent. It is mean(1 - utilization) over the non-disabled accounts that report the window: nine exhausted accounts plus one fresh one read 10%, not 100%.
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 [server.auth], so both tables must be present in shunt.toml: [server.auth]
# Presence alone opts in; the table takes no keys.
[server.usage]
[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 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
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.
apiKeyHelper-only sessionsThe mod reads the credential from the environment and nowhere else, so a session whose credential arrives through apiKeyHelper has none it can see: Claude Code consumes the helper's output internally and never re-exports it. That covers both shunt gateway claude, whose launcher scrubs ANTHROPIC_AUTH_TOKEN and ANTHROPIC_API_KEY from the environment it hands Claude Code and wires apiKeyHelper instead, and a hand-configured apiKeyHelper. In those sessions /shunt:usage reports that it has no client token even though the session itself is authenticating fine.
Export a client token for the mod to use alongside it — one of the name:token pairs the gateway was given in SHUNT_CLIENT_TOKENS:
export SHUNT_TOKEN=<your client token>
It has to be a [server.auth] client token, and not the output of shunt gateway token: that command prints the [server.gateway] session's access token, which is a separate credential that rotates on refresh. GET /usage authenticates only against the configured client tokens, so a session token sent as a Bearer is refused with a 401.
/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.ts | The one hook: command.run on shunt:usage |
hooks/endpoint.ts | Resolves the gateway URL and credential header |
hooks/report.ts | Reads a GET /usage body, defensively |
hooks/views.ts | Renders the bars, percentages and reset times |
hooks/names.ts | The command name and the fixed texts |
commands/usage.md | The command, and the no-function-hooks fallback |
tests/ | Vitest suite over the pure modules |
# 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
# Run it from source against a live gateway.
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir plugins/shunt
hooks/register.ts is the only 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.ts from npm run typecheck — 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.ts 130 lines1import type { On } from 'claude-code'
2
3import { endpointOf } from './endpoint'
4import {
5 COMMAND_NAME,
6 HOOK_FAILED_TEXT,
7 NOT_ENABLED_TEXT,
8 NO_POOL_TEXT,
9 REFUSED_TEXT,
10} from './names'
11import { WINDOW_KEYS, parseReport } from './report'
12import { reportText } from './views'
13
14/**
15 * An error's message, less the `shunt: ` the engine stamps on its own — the
16 * transcript line already carries the plugin's name, so keeping it would read
17 * `shunt: ... — shunt: ...`.
18 */
19const messageOf = (error: unknown): string =>
20 (error instanceof Error ? error.message : String(error)).replace(
21 /^shunt: /,
22 '',
23 )
24
25/**
26 * The first line of a body, clipped, for an error the mod has no words of its
27 * own for — enough to recognize the gateway's own message without pasting a
28 * page of HTML into the transcript.
29 */
30const firstLineOf = (text: string): string => {
31 const line = text.split('\n', 1)[0]?.trim() ?? ''
32
33 return line.length > 200 ? `${line.slice(0, 200)}…` : line
34}
35
36/**
37 * Registers the mod's one hook: `/shunt:usage`, answered from the gateway's
38 * `GET /usage` rather than sent to the model.
39 *
40 * The command itself is `commands/usage.md`, which lists as `shunt:usage`
41 * because the plugin namespaces it; the markdown body is the fallback for a
42 * session without function hooks, and this hook answers before it is ever
43 * read. `$.command.register` cannot serve this command: it takes a bare global
44 * name and refuses `usage` as the built-in's.
45 *
46 * @param on the engine's registrar
47 */
48export function register(on: On) {
49 const registration = on(
50 'command.run',
51 { command: COMMAND_NAME },
52 async ($, e, next) => {
53 const resolved = endpointOf({
54 shuntBaseUrl: await $.env.get('SHUNT_BASE_URL'),
55 anthropicBaseUrl: await $.env.get('ANTHROPIC_BASE_URL'),
56 shuntToken: await $.env.get('SHUNT_TOKEN'),
57 anthropicAuthToken: await $.env.get('ANTHROPIC_AUTH_TOKEN'),
58 anthropicApiKey: await $.env.get('ANTHROPIC_API_KEY'),
59 })
60
61 if ('problem' in resolved) {
62 return { text: resolved.problem }
63 }
64
65 const { endpoint } = resolved
66
67 let response
68
69 try {
70 response = await $.http.fetch(endpoint.url, {
71 headers: endpoint.headers,
72 })
73 } catch (error) {
74 return {
75 text:
76 `could not reach the gateway at ${endpoint.base} ` +
77 `${'—'} ${messageOf(error)}`,
78 }
79 }
80
81 if (response.status === 404) {
82 return { text: NOT_ENABLED_TEXT }
83 }
84
85 if (response.status === 401 || response.status === 403) {
86 return { text: REFUSED_TEXT }
87 }
88
89 if (!response.ok) {
90 const detail = firstLineOf(response.text)
91
92 return {
93 text:
94 `the gateway answered GET /usage with ${response.status}` +
95 `${detail === '' ? '' : ` ${'—'} ${detail}`}`,
96 }
97 }
98
99 const parsed = parseReport(response.text)
100
101 if ('problem' in parsed) {
102 return { text: parsed.problem }
103 }
104
105 const { report } = parsed
106
107 const isSilent =
108 report.providers.length === 0 &&
109 WINDOW_KEYS.every(key => report.pool.windows[key].remaining === null)
110
111 if (isSilent) {
112 return { text: NO_POOL_TEXT }
113 }
114
115 return {
116 text: reportText(report, endpoint.base, await $.clock.now()),
117 }
118 },
119 )
120
121 /**
122 * A hook that throws or overruns its budget is treated as absent, and core
123 * runs in its place — for this command that is `commands/usage.md`, which
124 * asks the model to fetch the endpoint with a tool call. The answer would
125 * still arrive, but it would cost a model turn in a mod whose whole point is
126 * that it costs none, so the failure is reported rather than handed on.
127 */
128 registration.catch(() => ({ text: HOOK_FAILED_TEXT }))
129}
130hooks/endpoint.ts 82 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
15/**
16 * Where to ask, and with what: the absolute `GET /usage` URL and the one
17 * credential header to send.
18 */
19export type Endpoint = {
20 /** The gateway's base, as it was configured, for error text. */
21 base: string
22 url: string
23 headers: Record<string, string>
24}
25
26/**
27 * Either an endpoint to call or the reason there is none to call.
28 */
29export type Resolved = { endpoint: Endpoint } | { problem: string }
30
31/**
32 * Joins `base` and `USAGE_PATH` without doubling or dropping the separator.
33 */
34const usageUrlOf = (base: string) => `${base.replace(/\/+$/, '')}${USAGE_PATH}`
35
36/**
37 * Resolves the gateway endpoint from the session's environment.
38 *
39 * The base URL is the gateway this session already sends every message to, so
40 * asking it for `/usage` reaches no host the session was not already using —
41 * with no base URL set there is nothing to ask, and the mod says so instead of
42 * sending the session's credential to Anthropic's own API.
43 *
44 * The credential rides the header Claude Code itself uses for that variable —
45 * `ANTHROPIC_AUTH_TOKEN` as a `Bearer`, `ANTHROPIC_API_KEY` as `x-api-key` —
46 * so whichever shape the operator's `[server.auth]` matches on, it matches the
47 * same way here. `SHUNT_TOKEN` overrides both and rides as a `Bearer`.
48 *
49 * A credential that is set but blank is no credential: `$.env.get` reads an
50 * exported-but-empty variable as `''`, so every candidate is normalized to
51 * absent before the choice is made rather than after. That cuts both ways — a
52 * blank override falls back instead of shadowing a working one, and a blank
53 * fallback reports the missing token here instead of sending an empty header
54 * for the gateway to reject as a wrong one.
55 */
56export function endpointOf(env: Environment): Resolved {
57 const base = env.shuntBaseUrl?.trim() || env.anthropicBaseUrl
58
59 if (base === undefined || base.trim() === '') {
60 return { problem: NO_BASE_URL_TEXT }
61 }
62
63 const bearer = env.shuntToken?.trim() || env.anthropicAuthToken?.trim()
64 const apiKey = env.anthropicApiKey?.trim()
65
66 const headers: Record<string, string> | undefined = bearer
67 ? { authorization: `Bearer ${bearer}` }
68 : apiKey
69 ? { 'x-api-key': apiKey }
70 : undefined
71
72 if (headers === undefined) {
73 return { problem: NO_TOKEN_TEXT }
74 }
75
76 const trimmed = base.trim()
77
78 return {
79 endpoint: { base: trimmed, url: usageUrlOf(trimmed), headers },
80 }
81}
82hooks/names.ts 47 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 client token to authenticate with — GET /usage requires ' +
24 '[server.auth], and none of SHUNT_TOKEN, ANTHROPIC_AUTH_TOKEN or ' +
25 'ANTHROPIC_API_KEY is set. Ask the gateway operator for a client token.'
26
27export const NOT_ENABLED_TEXT =
28 'the gateway answered, but GET /usage is not enabled on it. Add an ' +
29 '[server.usage] table to the gateway config and restart it; the table takes ' +
30 'no keys, but it requires [server.auth] to be set as well.'
31
32export const REFUSED_TEXT =
33 'the gateway refused the client token this session is using. The first ' +
34 'of SHUNT_TOKEN, ANTHROPIC_AUTH_TOKEN and ANTHROPIC_API_KEY that is set ' +
35 'is the one sent, and the rest are ignored — check that one against the ' +
36 "gateway's [server.auth]."
37
38export const HOOK_FAILED_TEXT =
39 'could not read the pool usage — the hook that answers this command ' +
40 'failed or ran past its budget. Run /shunt:usage again; if it keeps ' +
41 'failing, check that the gateway is up and reachable.'
42
43export const NO_POOL_TEXT =
44 'the gateway reports no pooled provider, so there is no shared quota ' +
45 'to show. Pool usage appears once a provider is configured with pooled ' +
46 'accounts (auth mode oauth or chatgpt).'
47hooks/report.ts 124 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 *unused*, `0..=1`, or
17 * `null` where no non-disabled account reports the window. Headroom, not
18 * consumption: a full bar is a fresh pool.
19 */
20 remaining: number | null
21 /** Earliest reported reset, unix epoch seconds, or `null`. */
22 resetsAt: number | null
23}
24
25export type PoolStatus = {
26 /** `ok`, `degraded` or `exhausted`, derived from availability alone. */
27 status: string
28 windows: Record<WindowKey, WindowStatus>
29}
30
31export type UsageReport = {
32 pool: PoolStatus
33 /** One entry per pooled provider, by configured name, sorted. */
34 providers: readonly (readonly [string, PoolStatus])[]
35}
36
37export type Parsed = { report: UsageReport } | { problem: string }
38
39const isRecord = (value: unknown): value is Record<string, unknown> =>
40 typeof value === 'object' && value !== null && !Array.isArray(value)
41
42/**
43 * A finite number in `0..=1`, or `null` for anything else (including the
44 * `null` the gateway sends for an unreported window).
45 */
46const fractionOf = (value: unknown): number | null =>
47 typeof value === 'number' && Number.isFinite(value)
48 ? Math.min(1, Math.max(0, value))
49 : null
50
51const epochOf = (value: unknown): number | null =>
52 typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null
53
54const windowOf = (value: unknown): WindowStatus => {
55 const row = isRecord(value) ? value : {}
56
57 return { remaining: fractionOf(row.remaining), resetsAt: epochOf(row.resets_at) }
58}
59
60const poolOf = (value: unknown): PoolStatus | null => {
61 if (!isRecord(value)) {
62 return null
63 }
64
65 const windows = isRecord(value.windows) ? value.windows : {}
66
67 return {
68 status: typeof value.status === 'string' ? value.status : 'unknown',
69 windows: {
70 '5h': windowOf(windows['5h']),
71 '7d': windowOf(windows['7d']),
72 fable: windowOf(windows.fable),
73 },
74 }
75}
76
77/**
78 * Reads a `GET /usage` body, keeping every field the mod draws and refusing a
79 * body that is not one — a proxy's HTML error page parses as neither.
80 *
81 * @param text the response body
82 * @returns the report, or the reason it could not be read
83 */
84export function parseReport(text: string): Parsed {
85 let value: unknown
86
87 try {
88 value = JSON.parse(text)
89 } catch {
90 return {
91 problem:
92 'the gateway answered GET /usage with something that is not ' +
93 'JSON. Check that ANTHROPIC_BASE_URL points at the gateway itself and ' +
94 'not at a proxy in front of it.',
95 }
96 }
97
98 if (!isRecord(value)) {
99 return { problem: 'the gateway answered GET /usage with an unexpected body.' }
100 }
101
102 const pool = poolOf(value.pool)
103
104 if (pool === null) {
105 return {
106 problem:
107 'the gateway answered GET /usage without a pool aggregate. ' +
108 'This build may be older than the endpoint the mod reads.',
109 }
110 }
111
112 const rows = isRecord(value.providers) ? value.providers : {}
113
114 const providers = Object.keys(rows)
115 .sort()
116 .flatMap(name => {
117 const status = poolOf(rows[name])
118
119 return status === null ? [] : [[name, status] as const]
120 })
121
122 return { report: { pool, providers } }
123}
124hooks/views.ts 153 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 *unused* fraction
123 * across the pool's accounts, so a bare `62%` invites exactly the wrong
124 * reading ("62% burned"), and it is an aggregate rather than a prediction that
125 * the next request is admitted.
126 *
127 * @param report the parsed `GET /usage` body
128 * @param base the gateway the report came from
129 * @param nowMs the engine's clock, for the reset times
130 */
131export function reportText(
132 report: UsageReport,
133 base: string,
134 nowMs: number,
135): string {
136 const head = `pool ${DASH} ${report.pool.status} ${base}`
137
138 const providers = providerLines(report.providers)
139
140 const legend =
141 ` headroom left, averaged over the pool's accounts; a shared figure, ` +
142 `not a promise about your next request`
143
144 return [
145 head,
146 '',
147 ...poolLines(report.pool, nowMs),
148 ...(providers.length === 0 ? [] : ['', ...providers]),
149 '',
150 legend,
151 ].join('\n')
152}
153