SLOPSHOPPER

bedrock-meter

Token and USD meter for pay-per-token Amazon Bedrock sessions: a band above the prompt with cost, context, cache and today's total, priced at AWS's own…

newpanebandcommandtoaststatus
v0.2.2MITupdated 2026-10-21weidu-tpvision/claude-mods/bedrock-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · bedrock-meter
│ ┃ Bedrock meter ✕ › fix the failing auth test and add an audit log call │ ┃ earlier requests (Claude Code estimate) │ ┃ $0.462 ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Session $0.462 · cache hit 0% ⏺ Update(src/auth.ts) │ ┃ Today $0.462 · out 0 ⎿ Added 2 lines, removed 1 line │ ┃ Context 49% · 97.4k of 200.0k ⏺ Bash(bun test) │ ┃ Claude Code /cost (global rates) $0.420 ⎿ 3 pass, 1 fail │ ┃ Prices: AWS list of 2026-10-12 │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /bedrock-cost │ ⎿ bedrock-meter: $0.462 this session (Claude Code's estimate of th │ │ Bedrock $0.462 · ctx ███▇▁▁▁▁ 49% 97.4k/200.0k · in 0 out 0 · cache 0 0% · today $0.462 ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Bedrock $0.462 · ctx ███▇▁▁▁▁ 49% 97.4k/200.0k · in 0 out 0 · cache 0 0% · today $0.462 ⟨Claude Code's own drawing⟩
Pane · Bedrock meter
earlier requests (Claude Code estimate) $0.462 Session $0.462 · cache hit 0% Today $0.462 · out 0 Context 49% · 97.4k of 200.0k Claude Code /cost (global rates) $0.420 Prices: AWS list of 2026-10-12
README

bedrock-meter

A token and cost meter for Claude Code on Amazon Bedrock (pay per token, no subscription).

  • Band above the prompt: session cost, a context bar (green, yellow from 75%, red from 90%), input/output tokens, cache reads and hit rate, and today's total. On a narrow terminal it drops the token counts first, then the cache figure, then the bar and today's total, so it stays on one line. With a budget set, the cost turns yellow at 70% of it and red once passed.
  • Next to other band mods: it stacks with hamzafer's claude-code-mods bands (context-bar, token-weather, ...) through their shared hooks/bands.ts (MIT), in the order their checklist saves.
  • /bedrock-cost: opens a pane with a per-model breakdown and the session and daily totals, next to Claude Code's own /cost estimate.
  • Pricing: AWS's own on-demand rates, global and regional, in hooks/prices.ts, generated from the AWS price list (us-east-1) by .github/scripts/bedrock_prices.py. A global. profile bills the global rates; in-region and geo profiles (us., eu., ...) the regional ones, which AWS sets about 10% higher for most current models and not at all for some. Cache writes bill at AWS's 5-minute cache-write rate, the TTL Claude Code uses by default; requests that write the 1-hour cache are underestimated. A weekly workflow reruns the script and opens a pull request when a price moves.
  • Options (/config): a fallback regional premium % (for a model listed with global prices only), a session budget that shows a toast when it is passed, and whether the pane opens when a session starts.

Every model request is counted, including subagents and compaction. Requests made before the mod loaded (or before a resume) are read back from the session transcript. For global. profiles the session cost matches Claude Code's own /cost, which prices at the global rates; for regional ones it comes out higher by AWS's regional surcharge. Daily totals carry across sessions, by local calendar day, and are kept for 31 days. /clear resets the session meter.

Costs are estimates from the price table (the pane says which AWS list it is from); your AWS bill is the final word.

Updating the prices by hand

python3 .github/scripts/bedrock_prices.py

From the repository's root: it fetches the AWS price list, rewrites bedrock-meter/hooks/prices.ts and bumps the mod's patch version when a price changed. Or edit a row of prices.ts directly (USD per million tokens). Then run claude plugin test bedrock-meter, commit and push.

Install

At the prompt of a Claude Code terminal session (tested with Claude Code 2.1.306):

/plugin install bedrock-meter --marketplace weidu-tpvision/claude-mods

Answer y to add the marketplace, then pick a scope. From a shell: claude plugin install bedrock-meter --marketplace weidu-tpvision/claude-mods.

Source 6 files
hooks/register.tsx 356 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Context, Meter, Tally } from '../types'
5import { drawBand, hitRate, tokens, usd } from './band'
6import { bandsFile, parseOrder, stackBands } from './bands'
7import { PUBLISHED } from './prices'
8import { costOf, scopeFor } from './pricing'
9import type { Usage } from './pricing'
10
11const PANE = 'bedrock-meter'
12const EMPTY: Meter = { byModel: {}, isWarned: false, isBackfilled: false }
13const EARLIER = 'earlier requests (Claude Code estimate)'
14
15const ZERO: Tally = { requests: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, usd: 0, isPriced: true }
16
17const meter = atom({ plugin: 'bedrock-meter', key: 'meter' } as const, EMPTY)
18const context = atom({ plugin: 'bedrock-meter', key: 'context' } as const, null as Context | null)
19const todayTally = atom({ plugin: 'bedrock-meter', key: 'today' } as const, ZERO)
20
21// Daily totals older than this are dropped at each session start.
22const KEEP_DAYS = 31
23const DAY_MS = 86_400_000
24const TICK_MS = 60_000
25
26// `us.anthropic.claude-opus-5-5` and the transcript's `claude-opus-5-5` share a row.
27const rowOf = (model: string) => model.replace(/^.*anthropic\./, '')
28
29const add = (t: Tally, u: Usage, cost: number | undefined): Tally => ({
30  requests: t.requests + 1,
31  input: t.input + u.input_tokens,
32  output: t.output + u.output_tokens,
33  cacheRead: t.cacheRead + u.cache_read_input_tokens,
34  cacheWrite: t.cacheWrite + u.cache_creation_input_tokens,
35  usd: t.usd + (cost ?? 0),
36  isPriced: t.isPriced && cost !== undefined,
37})
38
39const sum = (tallies: Tally[]): Tally =>
40  tallies.reduce(
41    (all, t) => ({
42      requests: all.requests + t.requests,
43      input: all.input + t.input,
44      output: all.output + t.output,
45      cacheRead: all.cacheRead + t.cacheRead,
46      cacheWrite: all.cacheWrite + t.cacheWrite,
47      usd: all.usd + t.usd,
48      isPriced: all.isPriced && t.isPriced,
49    }),
50    ZERO,
51  )
52
53const pad = (n: number) => String(n).padStart(2, '0')
54
55// The local calendar day, so "today" turns over at the person's midnight.
56export const dateOf = (ms: number) => {
57  const d = new Date(ms)
58  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
59}
60
61// Daily totals are kept per session (`day:<date>:<session>`), so rebuilding
62// one session's figures never counts another's twice.
63async function refreshToday($: EngineInterface) {
64  const prefix = `day:${dateOf(await $.clock.now())}:`
65  const keys = (await $.store.keys()).filter(key => key.startsWith(prefix))
66  const day = sum(await Promise.all(keys.map(async key => ((await $.store.get(key)) as Tally | undefined) ?? ZERO)))
67  await update($, todayTally, () => day)
68}
69
70// Drops the days past KEEP_DAYS, and the per-day keys of the first version.
71async function prune($: EngineInterface) {
72  const cutoff = dateOf((await $.clock.now()) - KEEP_DAYS * DAY_MS)
73  for (const key of await $.store.keys()) {
74    const date = /^day:(\d{4}-\d\d-\d\d)(:|$)/.exec(key)
75    if (date && (date[2] === '' || date[1]! < cutoff)) await $.store.delete(key)
76  }
77}
78
79type Settings = { premiumPct: number; budgetUsd: number }
80
81async function record($: EngineInterface, model: string, u: Usage, settings: Settings) {
82  const cost = costOf(model, u, scopeFor(model, await $.session.model()), settings.premiumPct)
83  await update($, meter, m => ({
84    ...m,
85    byModel: { ...m.byModel, [rowOf(model)]: add(m.byModel[rowOf(model)] ?? ZERO, u, cost) },
86  }))
87
88  const key = `day:${dateOf(await $.clock.now())}:${await $.session.id()}`
89  await $.store.set(key, add(((await $.store.get(key)) as Tally | undefined) ?? ZERO, u, cost))
90  await refreshToday($)
91
92  const m = await read($, meter)
93  const spent = sum(Object.values(m.byModel)).usd
94  if (settings.budgetUsd > 0 && !m.isWarned && spent >= settings.budgetUsd) {
95    await update($, meter, one => ({ ...one, isWarned: true }))
96    $.ui.toast(`Bedrock session passed ${usd(settings.budgetUsd)} (now ${usd(spent)})`)
97  }
98}
99
100// `at`: the response's time in ms, NaN when the line carries none.
101type Turn = { model: string; usage: Usage; at: number }
102
103// One response per message id: the transcript writes a line per content block.
104function parseTranscript(text: string, into: Map<string, Turn>) {
105  for (const line of text.split('\n')) {
106    if (!line.includes('"usage"')) continue
107    try {
108      const row = JSON.parse(line)
109      const message = row?.message
110      const u = message?.usage
111      if (row?.type !== 'assistant' || !u || typeof message.id !== 'string') continue
112      into.set(message.id, {
113        model: String(message.model ?? 'unknown'),
114        at: Date.parse(String(row.timestamp ?? '')),
115        usage: {
116          input_tokens: u.input_tokens ?? 0,
117          output_tokens: u.output_tokens ?? 0,
118          cache_read_input_tokens: u.cache_read_input_tokens ?? 0,
119          cache_creation_input_tokens: u.cache_creation_input_tokens ?? 0,
120        },
121      })
122    } catch {
123      // A line cut short while being written: the next load reads it whole.
124    }
125  }
126}
127
128// The session's transcript and its subagents', or undefined when one cannot
129// be found or read (over the 4 MiB a read allows).
130async function transcriptFiles($: EngineInterface, sid: string): Promise<string[] | undefined> {
131  const config = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${await $.env.get('HOME')}/.claude`
132  const projects = `${config}/projects`
133  const slug = (await $.session.root()).replace(/[^a-zA-Z0-9]/g, '-')
134  let dir = `${projects}/${slug}`
135  if (!(await $.fs.exists(`${dir}/${sid}.jsonl`))) {
136    const all = await $.fs.list(projects)
137    const found = []
138    for (const entry of all) {
139      if (entry.kind === 'dir' && (await $.fs.exists(`${projects}/${entry.name}/${sid}.jsonl`))) found.push(entry.name)
140    }
141    if (found.length === 0) return undefined
142    dir = `${projects}/${found[0]}`
143  }
144  const files = [`${dir}/${sid}.jsonl`]
145  const subagents = `${dir}/${sid}/subagents`
146  if (await $.fs.exists(subagents)) {
147    for (const entry of await $.fs.list(subagents)) {
148      if (entry.kind === 'file' && entry.name.endsWith('.jsonl')) files.push(`${subagents}/${entry.name}`)
149    }
150  }
151  return files
152}
153
154// Counts what the session spent before the mod loaded, once per session.
155async function backfill($: EngineInterface, settings: Settings) {
156  if ((await read($, meter)).isBackfilled) return
157  const sid = await $.session.id()
158  const sessionModel = await $.session.model()
159  const now = await $.clock.now()
160
161  for (const key of await $.store.keys()) {
162    if (key.startsWith('day:') && key.endsWith(`:${sid}`)) await $.store.delete(key)
163  }
164
165  const turns = new Map<string, Turn>()
166  let isRead = false
167  try {
168    const files = await transcriptFiles($, sid)
169    if (files) {
170      for (const file of files) parseTranscript(await $.fs.read(file), turns)
171      isRead = true
172    }
173  } catch {
174    isRead = false
175  }
176
177  const byModel: Record<string, Tally> = {}
178  const days: Record<string, Tally> = {}
179  if (isRead) {
180    for (const turn of turns.values()) {
181      const cost = costOf(turn.model, turn.usage, scopeFor(turn.model, sessionModel), settings.premiumPct)
182      const date = dateOf(Number.isFinite(turn.at) ? turn.at : now)
183      byModel[rowOf(turn.model)] = add(byModel[rowOf(turn.model)] ?? ZERO, turn.usage, cost)
184      days[date] = add(days[date] ?? ZERO, turn.usage, cost)
185    }
186  } else {
187    // Claude Code prices at the global rates: a regional session adds the premium.
188    const engine = (await $.session.usage()).cost?.usd ?? 0
189    if (engine > 0) {
190      const premium = scopeFor(sessionModel, sessionModel) === 'regional' ? settings.premiumPct : 0
191      const tally = { ...ZERO, usd: engine * (1 + premium / 100) }
192      byModel[EARLIER] = tally
193      days[dateOf(now)] = tally
194    }
195  }
196
197  await update($, meter, () => ({ byModel, isWarned: false, isBackfilled: true }))
198  for (const [date, tally] of Object.entries(days)) await $.store.set(`day:${date}:${sid}`, tally)
199}
200
201// The band order file, as hamzafer's claude-code-mods checklist saves it; [] on any error.
202async function readBandOrder($: EngineInterface): Promise<string[]> {
203  const file = bandsFile(await $.env.get('CLAUDE_CONFIG_DIR'), await $.env.get('HOME'))
204  return file && (await $.fs.exists(file)) ? parseOrder(await $.fs.read(file)) : []
205}
206
207export const register: Register = (on, options) => {
208  const settings: Settings = {
209    premiumPct: Number(options.regionalPremiumPct ?? 10),
210    budgetUsd: Number(options.budgetUsd ?? 0),
211  }
212  let lastModel = ''
213  let timer: Timer | undefined
214  let bandOrder: Promise<string[]> | undefined // read once per load
215
216  on('session.start', async ($, e, next) => {
217    const r = await next(e)
218    await $.command.register({
219      name: 'bedrock-cost',
220      description: 'Show Bedrock token usage and estimated cost by model',
221    })
222    if (options.openPane === true) void $.ui.open({ id: PANE, title: 'Bedrock meter' })
223    $.ui.status(undefined) // the band took over from the status line of the first version
224    await prune($)
225    await backfill($, settings)
226    await refreshToday($)
227    const usage = await $.session.usage().catch(() => undefined)
228    if (usage) {
229      const { tokens, window, percent } = usage.context
230      await update($, context, () => ({ tokens, window, percent }))
231    }
232
233    // Moves "today" on at the local midnight, with no request to trigger it.
234    let day = dateOf(await $.clock.now())
235    timer?.cancel()
236    timer = $.clock.every(TICK_MS, () => {
237      void (async () => {
238        const now = dateOf(await $.clock.now())
239        if (now !== day) {
240          day = now
241          await refreshToday($)
242        }
243      })().catch(() => {})
244    })
245    return r
246  })
247
248  on('command.run', { command: 'bedrock-cost' }, async $ => {
249    const opened = await $.ui.open({ id: PANE, title: 'Bedrock meter' })
250    const total = sum(Object.values((await read($, meter)).byModel))
251    const line =
252      total.requests > 0
253        ? `${usd(total.usd)} this session over ${total.requests} requests (cache hit ${hitRate(total)}%).`
254        : `${usd(total.usd)} this session (Claude Code's estimate of the requests before the meter loaded).`
255    return { text: opened.isPlaced ? line : `${line} Widen the terminal to see the pane.` }
256  })
257
258  // Every model request of every turn, main loop and subagents alike.
259  on('turn.step', async function* ($, e, next) {
260    const r = yield* next(e)
261    if (r.usage) {
262      lastModel = r.usage.model
263      await record($, r.usage.model, r.usage, settings)
264    }
265    return r
266  })
267
268  // The summarizer's own request when the engine compacts. Its usage names no
269  // model: the last one seen, or the session's before any request this load.
270  on('session.compact', async ($, e, next) => {
271    const r = await next(e)
272    if ('usage' in r && r.usage) await record($, lastModel || (await $.session.model()), r.usage, settings)
273    return r
274  }).catch(($, e, next) => next(e)) // a failed count never holds up the compaction: replays it, or runs it
275
276  on('session.measure', async ($, e, next) => {
277    const r = await next(e)
278    await update($, context, () => ({ tokens: e.context.tokens, window: e.context.window, percent: e.context.percent }))
279    return r
280  })
281
282  // A cleared session starts from nothing; there is no transcript to read back.
283  on('session.end', async ($, e, next) => {
284    if (e.reason === 'clear') await update($, meter, () => ({ ...EMPTY, isBackfilled: true }))
285    return next(e)
286  })
287
288  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
289    const rest = await next(e) // what other mods and Claude Code draw here stays
290    if (e.props.hasSurvey) return rest
291    const m = await read($, meter)
292    const figures = {
293      total: sum(Object.values(m.byModel)),
294      today: await read($, todayTally),
295      budgetUsd: settings.budgetUsd,
296      context: await read($, context),
297    }
298    const mine = drawBand($.ui.resolve(e), figures, e.props.bodyColumns - 2)
299    if (!mine) return rest
300    bandOrder ??= readBandOrder($).catch(() => [])
301    return stackBands(rest, 'bedrock-meter', mine, 'above', await bandOrder)
302  })
303
304  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
305    const { Box, Text } = $.ui.resolve(e)
306    const m = await read($, meter)
307    const total = sum(Object.values(m.byModel))
308    const day = await read($, todayTally)
309    const ctx = await read($, context)
310    const engine = (await $.session.usage()).cost?.usd
311    const rows = Object.entries(m.byModel).sort(([, a], [, b]) => b.usd - a.usd)
312
313    return (
314      <Box flexDirection="column">
315        {rows.length === 0 && <Text dimColor>No Bedrock requests yet.</Text>}
316        {rows.map(([model, t]) => (
317          <Box flexDirection="column" key={model}>
318            <Text bold>{model}</Text>
319            <Text>
320              {'  '}
321              {usd(t.usd)}
322              {t.isPriced ? '' : ' (unpriced model)'}
323              {t.requests > 0 ? ` · ${t.requests} req` : ''}
324            </Text>
325            {t.requests > 0 && (
326              <Text dimColor>
327                {'  '}in {tokens(t.input)} · write {tokens(t.cacheWrite)} · read {tokens(t.cacheRead)} · out{' '}
328                {tokens(t.output)}
329              </Text>
330            )}
331          </Box>
332        ))}
333        {rows.length > 0 && (
334          <Box flexDirection="column" marginTop={1}>
335            <Text bold>
336              Session {usd(total.usd)} · cache hit {hitRate(total)}%
337            </Text>
338            <Text>Today {usd(day.usd)} · out {tokens(day.output)}</Text>
339            {ctx?.percent !== undefined && (
340              <Text>
341                Context {Math.round(ctx.percent)}%
342                {ctx.tokens !== undefined ? ` · ${tokens(ctx.tokens)} of ${tokens(ctx.window)}` : ''}
343              </Text>
344            )}
345            {engine !== undefined && <Text dimColor>Claude Code /cost (global rates) {usd(engine)}</Text>}
346            <Text dimColor>
347              Prices: AWS list of {PUBLISHED.slice(0, 10)}
348              {settings.budgetUsd > 0 ? ` · budget ${usd(settings.budgetUsd)}` : ''}
349            </Text>
350          </Box>
351        )}
352      </Box>
353    )
354  })
355}
356
hooks/band.tsx 111 lines
1import type { Elements } from 'claude-code'
2
3import type { Context, Tally } from '../types'
4
5type Ui = Pick<Elements['terminal'], 'Box' | 'Text'>
6
7export const tokens = (n: number) =>
8  n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : `${n}`
9export const usd = (n: number) => (n >= 100 ? `$${n.toFixed(0)}` : n >= 1 ? `$${n.toFixed(2)}` : `$${n.toFixed(3)}`)
10
11// Share of the prompt the cache served: the figure that drives the bill most.
12export const hitRate = (t: Tally) => {
13  const prompt = t.input + t.cacheRead + t.cacheWrite
14  return prompt === 0 ? 0 : Math.round((t.cacheRead / prompt) * 100)
15}
16
17export type Figures = { total: Tally; today: Tally; budgetUsd: number; context: Context | null }
18
19type Segment = { text: string; color?: string; dim?: boolean; bold?: boolean }
20
21type Layout = { isLong: boolean; cells: number; hasTokens: boolean; hasCache: boolean; hasToday: boolean }
22
23// Widest first: the band sheds token counts, then the cache figure and the
24// labels, then the bar and today's total, so it keeps to one line.
25const LAYOUTS: Layout[] = [
26  { isLong: true, cells: 8, hasTokens: true, hasCache: true, hasToday: true },
27  { isLong: true, cells: 8, hasTokens: false, hasCache: true, hasToday: true },
28  { isLong: false, cells: 5, hasTokens: false, hasCache: false, hasToday: true },
29  { isLong: false, cells: 0, hasTokens: false, hasCache: false, hasToday: false },
30]
31
32const SPLIT = ' · '
33const BLOCKS = '▁▂▃▄▅▆▇█'
34
35// Context fill: green, yellow from 75%, red from 90%, as usage-meter colors its windows.
36export const colorFor = (percent: number) => (percent >= 90 ? 'red' : percent >= 75 ? 'yellow' : 'green')
37
38// Spend against the budget: plain under 70%, yellow from there, red once passed.
39export const budgetColor = (usdSpent: number, budgetUsd: number) => {
40  if (budgetUsd <= 0) return undefined
41  const share = usdSpent / budgetUsd
42  return share >= 1 ? 'red' : share >= 0.7 ? 'yellow' : undefined
43}
44
45// Eighth blocks, so a few cells still move with each percent.
46export function bar(percent: number, cells: number) {
47  const filled = (Math.min(Math.max(percent, 0), 100) / 100) * cells
48  let out = ''
49  for (let i = 0; i < cells; i++) {
50    out += BLOCKS[Math.round(Math.min(Math.max(filled - i, 0), 1) * (BLOCKS.length - 1))]
51  }
52  return out
53}
54
55function parts(f: Figures, l: Layout): Segment[][] {
56  const out: Segment[][] = []
57  const { total, context } = f
58
59  if (total.requests > 0 || total.usd > 0) {
60    const color = budgetColor(total.usd, f.budgetUsd)
61    out.push([
62      { text: l.isLong ? 'Bedrock ' : '', dim: true },
63      { text: `${usd(total.usd)}${total.isPriced ? '' : '+'}`, color, bold: color !== undefined },
64      { text: l.isLong && f.budgetUsd > 0 ? ` of ${usd(f.budgetUsd)}` : '', dim: true },
65    ])
66  }
67
68  if (context?.percent !== undefined) {
69    const color = colorFor(context.percent)
70    out.push([
71      { text: 'ctx ', dim: true },
72      { text: l.cells > 0 ? `${bar(context.percent, l.cells)} ` : '', color },
73      { text: `${Math.round(context.percent)}%`, color },
74      {
75        text: l.isLong && context.tokens !== undefined ? ` ${tokens(context.tokens)}/${tokens(context.window)}` : '',
76        dim: true,
77      },
78    ])
79  }
80
81  if (total.requests > 0 || total.usd > 0) {
82    if (l.hasTokens) out.push([{ text: `in ${tokens(total.input + total.cacheWrite)} out ${tokens(total.output)}`, dim: true }])
83    if (l.hasCache) out.push([{ text: 'cache ', dim: true }, { text: `${tokens(total.cacheRead)} ${hitRate(total)}%` }])
84    if (l.hasToday) out.push([{ text: 'today ', dim: true }, { text: usd(f.today.usd) }])
85  }
86  return out
87}
88
89const join = (groups: Segment[][]): Segment[] =>
90  groups.flatMap((g, i) => (i > 0 ? [{ text: SPLIT, dim: true }, ...g] : g)).filter(s => s.text !== '')
91
92const widthOf = (segs: Segment[]) => [...segs.map(s => s.text).join('')].length
93
94// The meter at the widest layout that fits `columns`; undefined with nothing to show yet.
95export function drawBand(ui: Ui, f: Figures, columns: number) {
96  const { Box, Text } = ui
97  const fitting = LAYOUTS.map(l => join(parts(f, l))).find(segs => widthOf(segs) <= columns)
98  const segs = fitting ?? join(parts(f, LAYOUTS[LAYOUTS.length - 1]!))
99  if (segs.length === 0) return undefined
100
101  return (
102    <Box flexDirection="row" paddingX={1}>
103      {segs.map((s, i) => (
104        <Text key={String(i)} color={s.color} dimColor={s.dim} bold={s.bold} wrap="truncate">
105          {s.text}
106        </Text>
107      ))}
108    </Box>
109  )
110}
111
hooks/bands.ts 75 lines
1// From hamzafer/claude-code-mods (shared/bands.ts, MIT, (c) 2026 Hamza Zafar), unchanged,
2// so this band stacks in order with that repo's band mods (context-bar, token-weather, ...).
3// Band order: every band mod ships this same file (canonical copy: shared/bands.ts in the repo).
4//   The order file, <configDir>/claude-code-mods/bands.json, lists mod names top to bottom
5//   (the last sits right above the prompt). Each band mod wraps its band in a Box keyed
6//   `band:<name>` and returns one column keyed `bands`; the next band mod unpacks that
7//   column, adds its own band and sorts again, so the last one to draw sorts them all.
8//   Edit shared/bands.ts, then copy it over every mod's hooks/bands.ts (a repo test checks).
9//   Reading the file needs $, which Claude Code follows only within one file, so each mod
10//   reads it in its own register file with bandsFile and parseOrder, once per session.
11import type { RenderElement, RenderNode } from 'claude-code'
12
13const COLUMN = 'bands'
14const PREFIX = 'band:'
15
16// Where the checklist saves the order: CLAUDE_CONFIG_DIR, or ~/.claude. Undefined with neither.
17export function bandsFile(configDir: string | undefined, home: string | undefined): string | undefined {
18  const dir = configDir ? configDir.replace(/\/+$/, '') : home ? `${home}/.claude` : ''
19  return dir ? `${dir}/claude-code-mods/bands.json` : undefined
20}
21
22// The names in an order file; [] when it is no JSON or not { order: string[] }.
23export function parseOrder(text: string): string[] {
24  try {
25    const order = (JSON.parse(text) as { order?: unknown } | null)?.order
26    return Array.isArray(order) && order.every(n => typeof n === 'string') ? order : []
27  } catch {
28    return []
29  }
30}
31
32/**
33 * One column of bands: `mine` wrapped in a Box keyed `band:<name>`, placed
34 * `at` (above or below) what `next(e)` returned, then sorted by `order`.
35 * With an order, items that are no band come first, then bands not in the
36 * order (in their current order), then the listed ones in file order. With
37 * no order nothing moves, so the screen looks as it did before.
38 */
39export function stackBands(rest: RenderElement | null | undefined, name: string, mine: RenderNode, at: 'above' | 'below', order: string[]): RenderElement {
40  const own: RenderElement = { type: 'Box', props: { key: PREFIX + name, flexDirection: 'column' }, children: [mine] }
41  const others = unpack(rest)
42  const items = at === 'above' ? [own, ...others] : [...others, own]
43  return { type: 'Box', props: { key: COLUMN, flexDirection: 'column' }, children: order.length ? sorted(items, order) : items }
44}
45
46// Another band mod's column gives up its items; anything else is one item.
47function unpack(rest: RenderElement | null | undefined): RenderNode[] {
48  if (!rest) return []
49  if (!isColumn(rest)) return [rest]
50  const kids = rest.children ?? []
51  const owner = rest.part?.plugin
52  // The engine stamped the column with its owner: that stamp belongs on the owner's own band.
53  return kids.map(k => (owner && typeof k !== 'string' && bandName(k) === owner && !('part' in k && k.part) ? { ...k, part: rest.part } : k))
54}
55
56function isColumn(el: RenderElement): el is RenderElement & { type: 'Box' } {
57  return el.type === 'Box' && el.props?.key === COLUMN
58}
59
60function bandName(node: RenderNode): string | undefined {
61  if (typeof node === 'string' || node.type !== 'Box') return undefined
62  const key = node.props?.key
63  return typeof key === 'string' && key.startsWith(PREFIX) ? key.slice(PREFIX.length) : undefined
64}
65
66function sorted(items: RenderNode[], order: string[]): RenderNode[] {
67  const rank = (n: RenderNode) => {
68    const name = bandName(n)
69    if (name === undefined) return -2 // not a band: first
70    const i = order.indexOf(name)
71    return i < 0 ? -1 : i // a band not in the order: on top of the listed ones
72  }
73  return items.map((n, i) => ({ n, i, r: rank(n) })).sort((a, b) => a.r - b.r || a.i - b.i).map(x => x.n)
74}
75
hooks/prices.ts 119 lines
1// Generated by .github/scripts/bedrock_prices.py from the AWS price list
2// (AmazonBedrockFoundationModels, us-east-1). Rerun it, or edit by hand.
3// USD per million tokens, on-demand: `global` for global. inference profiles,
4// `regional` for in-region and geo (us., eu., apac.) ones; `*Long` past the
5// model's long-context threshold. Cache writes are the 5-minute TTL's.
6import type { Row } from './pricing'
7
8export const PUBLISHED = '2026-10-12T08:40:08Z'
9
10// Longest family first, so `opus-5-5` wins over `opus-5`.
11export const ROWS: Row[] = [
12  {
13    family: '3-5-sonnet',
14    regional: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
15  },
16  {
17    family: '3-7-sonnet',
18    regional: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
19  },
20  {
21    family: 'mythos-5-1',
22    global: { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 12.5 },
23    regional: { input: 11, output: 55, cacheRead: 0.275, cacheWrite: 13.75 },
24  },
25  {
26    family: 'sonnet-4-5',
27    global: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
28    regional: { input: 3.3, output: 16.5, cacheRead: 0.33, cacheWrite: 4.125 },
29  },
30  {
31    family: 'sonnet-4-6',
32    global: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
33    regional: { input: 3.3, output: 16.5, cacheRead: 0.33, cacheWrite: 4.125 },
34  },
35  {
36    family: 'sonnet-5-5',
37    global: { input: 2, output: 10, cacheRead: 0.1, cacheWrite: 2.5 },
38    regional: { input: 2.2, output: 11, cacheRead: 0.11, cacheWrite: 2.75 },
39  },
40  {
41    family: '3-5-haiku',
42    regional: { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 },
43  },
44  {
45    family: 'fable-5-1',
46    global: { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 12.5 },
47    regional: { input: 11, output: 55, cacheRead: 0.275, cacheWrite: 13.75 },
48  },
49  {
50    family: 'haiku-4-5',
51    global: { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
52    regional: { input: 1.1, output: 5.5, cacheRead: 0.11, cacheWrite: 1.375 },
53  },
54  {
55    family: 'haiku-5-5',
56    global: { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.125 },
57    regional: { input: 0.11, output: 0.55, cacheRead: 0.011, cacheWrite: 0.1375 },
58    globalLong: { input: 0.5, output: 2.5, cacheRead: 0.05, cacheWrite: 0.625 },
59    regionalLong: { input: 0.55, output: 2.75, cacheRead: 0.055, cacheWrite: 0.6875 },
60  },
61  {
62    family: 'mythos-5',
63    regional: { input: 11, output: 55, cacheRead: 1.1, cacheWrite: 13.75 },
64  },
65  {
66    family: 'opus-4-1',
67    regional: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
68  },
69  {
70    family: 'opus-4-5',
71    global: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
72    regional: { input: 5.5, output: 27.5, cacheRead: 0.55, cacheWrite: 6.875 },
73  },
74  {
75    family: 'opus-4-6',
76    global: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
77    regional: { input: 5.5, output: 27.5, cacheRead: 0.55, cacheWrite: 6.875 },
78  },
79  {
80    family: 'opus-4-7',
81    global: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
82    regional: { input: 5.5, output: 27.5, cacheRead: 0.55, cacheWrite: 6.875 },
83  },
84  {
85    family: 'opus-4-8',
86    global: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
87    regional: { input: 5.5, output: 27.5, cacheRead: 0.55, cacheWrite: 6.875 },
88  },
89  {
90    family: 'opus-5-5',
91    global: { input: 4, output: 20, cacheRead: 0.2, cacheWrite: 5 },
92    regional: { input: 4.4, output: 22, cacheRead: 0.22, cacheWrite: 5.5 },
93  },
94  {
95    family: 'sonnet-4',
96    global: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
97    regional: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
98  },
99  {
100    family: 'sonnet-5',
101    global: { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 },
102    regional: { input: 2.2, output: 11, cacheRead: 0.22, cacheWrite: 2.75 },
103  },
104  {
105    family: 'fable-5',
106    global: { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 },
107    regional: { input: 11, output: 55, cacheRead: 1.1, cacheWrite: 13.75 },
108  },
109  {
110    family: 'opus-4',
111    regional: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
112  },
113  {
114    family: 'opus-5',
115    global: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
116    regional: { input: 5.5, output: 27.5, cacheRead: 0.55, cacheWrite: 6.875 },
117  },
118]
119
hooks/pricing.ts 64 lines
1import { ROWS } from './prices'
2
3// USD per million tokens. The table is in `prices.ts`, generated from AWS's
4// price list by `.github/scripts/bedrock_prices.py`, which a weekly workflow
5// runs to propose a change when AWS moves a price.
6export type Price = { input: number; output: number; cacheRead: number; cacheWrite: number }
7
8// `global` serves global. inference profiles; `regional` in-region and geo
9// ones (us., eu., apac.). A model AWS sells in one kind only has that one.
10export type Row = { family: string; global?: Price; regional?: Price; globalLong?: Price; regionalLong?: Price }
11
12export type Scope = 'global' | 'regional'
13
14// Prompts over this many tokens bill at a model's `*Long` rates, where it has them.
15const LONG_PROMPT: Record<string, number> = { 'haiku-5-5': 100_000 }
16
17// `us.anthropic.claude-opus-5-5`, `anthropic.claude-sonnet-4-5-20250929-v1:0`,
18// an inference-profile ARN: all name the family after `claude-`.
19export const familyOf = (model: string): string | undefined => {
20  const id = model.toLowerCase()
21  return ROWS.find(row => {
22    const name = `claude-${row.family}`
23    const at = id.indexOf(name)
24    return at >= 0 && !/[a-z0-9]/.test(id[at + name.length] ?? '')
25  })?.family
26}
27
28export const isGlobal = (model: string) => /(^|\/)global\./.test(model)
29
30// The transcript names models bare (`claude-opus-5-5`); the session's own
31// model id then says which kind of inference profile served them.
32export const scopeFor = (model: string, sessionModel: string): Scope => {
33  const isNamed = /anthropic\./.test(model) || model.startsWith('arn:')
34  return isGlobal(isNamed ? model : sessionModel) ? 'global' : 'regional'
35}
36
37export type Usage = {
38  input_tokens: number
39  output_tokens: number
40  cache_read_input_tokens: number
41  cache_creation_input_tokens: number
42}
43
44// The request's cost in USD, or undefined for a model the table lacks. A
45// regional request on a model AWS lists globally alone is priced at the global
46// rates plus `premiumPct`, the surcharge AWS puts on the others.
47export const costOf = (model: string, u: Usage, scope: Scope = 'global', premiumPct = 0): number | undefined => {
48  const row = ROWS.find(one => one.family === familyOf(model))
49  if (!row) return undefined
50  const prompt = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
51  const isLong = prompt > (LONG_PROMPT[row.family] ?? Infinity)
52  const pick = (s: Scope) => (isLong && row[`${s}Long`]) || row[s]
53  const p = pick(scope) ?? pick(scope === 'global' ? 'regional' : 'global')
54  if (!p) return undefined
55  const surcharge = !pick(scope) && scope === 'regional' ? 1 + premiumPct / 100 : 1
56  const usd =
57    (u.input_tokens * p.input +
58      u.cache_creation_input_tokens * p.cacheWrite +
59      u.cache_read_input_tokens * p.cacheRead +
60      u.output_tokens * p.output) /
61    1_000_000
62  return usd * surcharge
63}
64
types/index.d.ts 23 lines
1export type Tally = {
2  requests: number
3  input: number
4  output: number
5  cacheRead: number
6  cacheWrite: number
7  usd: number
8  isPriced: boolean
9}
10
11// `isBackfilled`: the requests before the mod loaded are counted, from the
12// session's transcript or, failing that, Claude Code's own estimate.
13export type Meter = { byModel: Record<string, Tally>; isWarned: boolean; isBackfilled: boolean }
14
15// The live context window, as `session.measure` last reported it.
16export type Context = { tokens?: number; window: number; percent?: number }
17
18declare module 'claude-code' {
19  interface PluginState {
20    'bedrock-meter': { meter: Meter; context: Context | null; today: Tally }
21  }
22}
23