Live context-window bar in the status line: fill, tokens/window, cache share, auto-compact headroom, cost

A Claude Code mod that keeps the context window in the status line and updates it on every model request of a turn, not only at its end.
ctx ▕██░░░░░░░░▏ 22% 45k/200k (model 1M) · cache 99% · compact in 121k · $0.36
| Segment | Meaning |
|---|---|
bar, 22%, 45k/200k | tokens the last request was answered over, against the auto-compact window |
(model 1M) | the model's own window, shown only when its k-units differ from the auto-compact window's |
cache 99% | share of the last main-thread request served from the prompt cache |
compact in 121k / compact due / autocompact off | headroom until auto-compaction |
$0.36 | session cost (turn off with showCost): the engine's figure, or your price table's (costSource) |
$0.36 (est $0.41) | costSource: both: the engine's figure, then your table's estimate |
! prefix | fill at or above warnAt (default 80%) |
ctx – /200k · compacted 182k→24k | no reading yet: a fresh session, after /clear, or after a compaction (the note clears on the next response) |
| (blank) | no usable window yet, or the mod's own work failed (a ctx-bar: line in the debug log says why) |
Requires Claude Code 2.1.295 or later (the function-hooks API is early access; names may move).
claude --plugin-dir /path/to/ctx-bar
Options (userConfig):
| Option | Type, default | Meaning |
|---|---|---|
showCost | boolean, true | show the $ segment |
warnAt | number, 80 | prefix the line with ! at or above this fill percentage |
pricingFile | string, "" | a JSON price table (below); relative to the session's working directory, or absolute; ~ is not expanded |
costSource | string, "engine" | engine (the engine's cost), table (your table's), or both ($engine (est $table)); any other value is engine |
With costSource set to table or both, ctx-bar prices the session itself from pricingFile, in USD per million tokens:
{
"claude-opus-5-5": { "input": 5, "output": 25, "cacheRead": 0.5, "cacheWrite": 6.25 },
"claude-sonnet-5": {
"input": 3, "output": 15, "cacheRead": 0.3, "cacheWrite": 3.75,
"longContext": { "above": 200000, "input": 6, "output": 22.5, "cacheRead": 0.6, "cacheWrite": 7.5 }
},
"default": { "input": 3, "output": 15, "cacheRead": 0.3, "cacheWrite": 3.75 }
}
claude --plugin-dir /path/to/ctx-bar --settings '{"pluginConfigs":{"ctx-bar":{"options":{"pricingFile":"/path/to/prices.json","costSource":"both"}}}}'
default; there is no prefix match.longContext applies to a request whose input + cache-read + cache-write tokens are strictly above above.above is a whole number of at least 1; at most 256 models./clear keeps the table).default, the segment shows the engine's figure and one ctx-bar: pricing: line goes to the debug log./clear and resume start the spend at zero.modelPricing (machine-level managed settings only) reprices the engine's own figure; a user-level or --settings one does not (spike V0). The managed path was not tested.turn.step (main thread only): after each model response, $.session.usage() gives the live fill. With a price table, every response's usage (subagents' too) is added to the spend.session.measure: the engine's pushed figures, used as they are.turn.complete: a local summary breakdown refreshes the auto-compact window. full is never used.session.compact: shows compacted before→after (not for precomputes or subagents). With a price table, the compaction's own usage is added to the spend, precomputes' and subagents' included.session.end (/clear, resume): resets the live figures and keeps the window.The mod never changes a turn: each hook calls next first and returns its result, and every streamed chunk, untouched (tests E2, E7, G1–G4 assert both). Its own work runs in a guard; if that fails the line goes blank and a ctx-bar: line goes to the debug log.
It can delay one: after each main-thread response the mod awaits one $.session.usage() call. A slow host session.usage() can delay a request up to the slow-hook budget (10 s on 2.1.295); measured 0–1 ms per call in spike 1 and V6.
mkdir -p .claude-plugin/types/claude-code # types for tsc (gitignored), from the plugin-authoring skill
cp <skill>/types/claude-code.d.ts .claude-plugin/types/claude-code/index.d.ts
claude plugin validate .
npm ci --ignore-scripts && npm run typecheck # typescript 5.6.3, pinned and integrity-checked by package-lock.json
claude plugin test . # pure rows, control, mutant kill sets, engine rows
S=<scratch dir> bash scripts/neg.sh # negative runs: each mutation must be caught
The expected status strings in tests/fixtures.ts were generated from an independent reference of the format rules (kept with the private planning records) before the implementation existed. Because that reference follows the same written rules, rows hand-checked against them (F12, F25–F29, F39, plus the reviewer's spot-checks) are the cross-check against a mistake shared by both.
Not yet checked in a live interactive session: real compaction figures, status-line rendering next to a custom statusLine, a real subagent, hot reload, redraw cadence, per-step latency.
/model switch to a model with a larger window, the bar keeps the old auto-compact window until the turn ends (LOW). A switch to a smaller window is clamped at once.compacted X→Y note clears on the next response.rawMax − buffer (33k on 2.1.295) when the engine gives none. That fallback is a heuristic.Custom pricing (costSource table or both):
cacheWrite rate. The engine bills 5-minute and 1-hour cache writes at different rates (a 1-hour write costs 2× input), and the usage counts don't say which, so the table can drift from the engine. costSource: both shows the drift.default, else dropped (with a debug line).modelPricing reprices the engine's figure; not tested.turn.step, compaction is counted twice (not checkable headless).MIT. See LICENSE. The package is marked "private": true and is not published to npm.
hooks/register.ts 258 lines1// ctx-bar: the context window, live, in the status line (plan v2 §2–§3).
2// Lines `// @neg:<case>` are mutation points for scripts/neg.sh (V4); they do nothing.
3// Every hook passes the turn through untouched: its own work runs after `next`,
4// inside `guard`, and a failure there blanks the line instead of reaching the turn.
5import { atom, update } from 'claude-code'
6import type { EngineInterface, Register, SessionUsage, TurnUsage } from 'claude-code'
7import type { View } from '../types'
8import { effectiveRaw, formatStatus, optsFrom, type FormatOpts } from './format'
9import { INITIAL, cachePct, cleared, windowFrom } from './measure'
10import { addUsage, entryFor, hasBadCount, parseTable, type Table, type UsageCounts } from './pricing'
11
12// v3: the view gained the price table and the spend (plan ctx-bar-pricing v2 §2, D8)
13let viewShape = 'v3'
14// @neg:shape-v2
15const view = atom({ plugin: 'ctx-bar', key: 'view' } as const, INITIAL, { shape: viewShape })
16
17/** `warned` holds this marker once compaction usage has been dropped for want of a model. */
18// Shares `warned` with model ids; real model ids never start with '#', so a collision is unreachable (A-L3).
19const DROPPED = '#compaction'
20/** `warned` holds this marker once a usage count has been skipped as not finite and ≥ 0 (A7). */
21const BAD_COUNT = '#bad-count'
22
23/** An error as one short debug line: its name and the first 200 characters of its message. */
24function errorLine(err: unknown): string {
25 return err instanceof Error ? `${err.name}: ${err.message.slice(0, 200)}` : typeof err
26}
27
28/** Runs a hook's own work; on a throw, blanks the line and records why (plan v2 §3). */
29async function guard($: EngineInterface, event: string, work: () => Promise<void>): Promise<void> {
30 try {
31 await work()
32 } catch (err) {
33 $.ui.log(`ctx-bar: ${event}: ${errorLine(err)}`, { to: 'debug' })
34 // @neg:guard-off
35 $.ui.status(undefined)
36 }
37}
38
39/**
40 * Draws `v` (the value just written, never a re-read) and traces it to the debug log.
41 * Two writers racing can paint the older value until the next event (accepted, plan §6 D-d).
42 */
43function render($: EngineInterface, v: View, opts: FormatOpts): void {
44 const text = formatStatus(v, opts)
45 $.ui.status(text)
46 $.ui.log(`ctx-bar: status: ${text ?? '(blank)'} [fill=${v.tokens} raw=${effectiveRaw(v)}]`, { to: 'debug' })
47}
48
49/** Runs pricing work that must never blank the line (a subagent's or a compaction's): a throw is logged only. */
50async function quiet($: EngineInterface, event: string, work: () => Promise<void>): Promise<void> {
51 try {
52 await work()
53 } catch (err) {
54 $.ui.log(`ctx-bar: ${event}: ${errorLine(err)}`, { to: 'debug' })
55 }
56}
57
58/** One `ctx-bar: pricing:` debug line. */
59function pricingLog($: EngineInterface, text: string): void {
60 $.ui.log(`ctx-bar: pricing: ${text}`, { to: 'debug' })
61}
62
63/** The price table named by `pricingFile` (D1, D2), or null and the fixed reason it is unusable. */
64async function loadTable($: EngineInterface, file: string): Promise<{ table: Table | null; note: string | null }> {
65 if (file === '') return { table: null, note: 'no pricingFile' }
66 let text: string
67 try {
68 text = await $.fs.read(file)
69 } catch {
70 return { table: null, note: `${file}: read failed` }
71 }
72 const parsed = parseTable(text)
73 return 'ok' in parsed ? { table: parsed.ok, note: null } : { table: null, note: `${file}: ${parsed.error}` }
74}
75
76/**
77 * `x` with one call's usage added under `model` (no-op without a table), the unpriced ids it
78 * newly warns about, and whether it skipped a bad count for the first time this session (A7).
79 * A main step also records its model for pricing compaction usage.
80 */
81function accounted(x: View, usage: UsageCounts, model: string, isMain: boolean): { v: View; warn: string[]; bad: boolean } {
82 if (x.table === null) return { v: x, warn: [], bad: false }
83 const warn = entryFor(x.table, model) === undefined && !x.warned.includes(model) ? [model] : []
84 const bad = hasBadCount(usage) && !x.warned.includes(BAD_COUNT)
85 const v = {
86 ...x,
87 spend: addUsage(x.spend, usage, model, x.table),
88 warned: [...x.warned, ...warn, ...(bad ? [BAD_COUNT] : [])],
89 lastModel: isMain ? model : x.lastModel,
90 }
91 return { v, warn, bad }
92}
93
94/** The debug line for a skipped count (A7). */
95const BAD_COUNT_LOG = 'usage count skipped (not a finite number ≥ 0)'
96
97/** The live figures `usage()` carries, merged over `x`. */
98function withUsage(x: View, u: SessionUsage): View {
99 return { ...x, tokens: u.context.tokens ?? null, window: u.context.window, cost: u.cost?.usd ?? null }
100}
101
102/** A summary breakdown, or null when the engine refuses one (the bar then keeps its window). */
103async function summary($: EngineInterface): Promise<SessionUsage | null> {
104 try {
105 return await $.session.usage({ breakdown: 'summary' })
106 } catch (err) {
107 $.ui.log(`ctx-bar: breakdown unavailable: ${errorLine(err)}`, { to: 'debug' })
108 return null
109 }
110}
111
112export const register: Register = (on, options) => {
113 const opts = optsFrom(options)
114 // Pricing work happens only when asked for: under the default `engine` source no hook makes a new `$` call.
115 const priced = opts.costSource !== 'engine'
116
117 on('session.start', async ($, e, next) => {
118 const r = await next(e)
119 await guard($, 'session.start', async () => {
120 // @neg:v1-fs
121 // @neg:v9-full
122 const loaded = priced ? await loadTable($, opts.pricingFile) : null
123 if (loaded?.note) pricingLog($, loaded.note)
124 const u = (await summary($)) ?? (await $.session.usage())
125 const b = u.context.breakdown
126 const v = await update($, view, x => ({
127 ...withUsage(x, u),
128 win: b ? windowFrom(b) : x.win,
129 ...(loaded ? { table: loaded.table, tableNoted: x.tableNoted || loaded.note !== null } : {}),
130 }))
131 render($, v, opts)
132 })
133 return r
134 }).catch(($, e, next) => next(e))
135
136 on('turn.step', async function* ($, e, next) {
137 const r = yield* next(e)
138 // @neg:sub-skip
139 if (e.agentId !== undefined) {
140 // A subagent's usage counts toward spend (H2); the bar ignores subagents and is never blanked by one.
141 const usage = r.usage
142 if (priced && usage) {
143 await quiet($, 'turn.step (subagent)', async () => {
144 let warn: string[] = []
145 let bad = false
146 await update($, view, x => {
147 const a = accounted(x, usage, usage.model, false)
148 warn = a.warn
149 bad = a.bad
150 return a.v
151 })
152 if (bad) pricingLog($, BAD_COUNT_LOG)
153 for (const m of warn) pricingLog($, `unpriced model ${m}`)
154 })
155 }
156 return r
157 }
158 await guard($, 'turn.step', async () => {
159 // @neg:live-throw
160 const u = await $.session.usage()
161 const usage: TurnUsage | null = r.usage
162 let warn: string[] = []
163 let bad = false
164 let noTable = false
165 const v = await update($, view, x => {
166 const bar: View = { ...withUsage(x, u), cachePct: cachePct(usage, x.cachePct), lastCompact: null }
167 if (!priced) return bar
168 noTable = bar.table === null && !bar.tableNoted
169 const a = usage ? accounted(bar, usage, usage.model, true) : { v: bar, warn: [], bad: false }
170 warn = a.warn
171 bad = a.bad
172 return noTable ? { ...a.v, tableNoted: true } : a.v
173 })
174 if (noTable) pricingLog($, 'no table loaded')
175 if (bad) pricingLog($, BAD_COUNT_LOG)
176 for (const m of warn) pricingLog($, `unpriced model ${m}`)
177 render($, v, opts)
178 })
179 return r
180 }).catch(async function* ($, e, next) {
181 return yield* next(e)
182 })
183
184 on('session.measure', async ($, e, next) => {
185 const r = await next(e)
186 await guard($, 'session.measure', async () => {
187 const v = await update($, view, x => ({
188 ...x,
189 tokens: e.context.tokens ?? null,
190 window: e.context.window,
191 cost: e.cost?.usd ?? x.cost,
192 }))
193 render($, v, opts)
194 })
195 return r
196 }).catch(($, e, next) => next(e))
197
198 on('turn.complete', async ($, e, next) => {
199 const r = await next(e)
200 if (e.agentId !== undefined) return r
201 await guard($, 'turn.complete', async () => {
202 const u = await summary($)
203 const b = u?.context.breakdown
204 const v = await update($, view, x => ({ ...(u ? withUsage(x, u) : x), win: b ? windowFrom(b) : x.win }))
205 render($, v, opts)
206 })
207 return r
208 }).catch(($, e, next) => next(e))
209
210 on('session.compact', async ($, e, next) => {
211 const r = await next(e)
212 // Compaction usage counts too, precompute and subagent ones included (H2; a reused summary reports none).
213 const usage = r.skip === undefined ? r.usage : undefined
214 if (priced && usage) {
215 await quiet($, 'session.compact (pricing)', async () => {
216 let dropped = false
217 let bad = false
218 await update($, view, x => {
219 dropped = false
220 bad = false
221 if (x.table === null) return x
222 const model = x.lastModel ?? (Object.hasOwn(x.table, 'default') ? 'default' : null)
223 if (model !== null) {
224 const a = accounted(x, usage, model, false)
225 bad = a.bad
226 return a.v
227 }
228 if (x.warned.includes(DROPPED)) return x
229 dropped = true
230 return { ...x, warned: [...x.warned, DROPPED] }
231 })
232 if (dropped) pricingLog($, 'compaction usage dropped (no model)')
233 if (bad) pricingLog($, BAD_COUNT_LOG)
234 })
235 }
236 if (e.agentId !== undefined || e.trigger === 'precompute' || r.skip !== undefined) return r
237 await guard($, 'session.compact', async () => {
238 const v = await update($, view, x => ({
239 ...x,
240 tokens: null,
241 lastCompact: { before: r.tokensBefore ?? null, after: r.tokensAfter ?? null },
242 }))
243 render($, v, opts)
244 })
245 return r
246 }).catch(($, e, next) => next(e))
247
248 on('session.end', async ($, e, next) => {
249 const r = await next(e)
250 if (e.reason !== 'clear' && e.reason !== 'resume') return r
251 await guard($, 'session.end', async () => {
252 const v = await update($, view, cleared)
253 render($, v, opts)
254 })
255 return r
256 }).catch(($, e, next) => next(e))
257}
258hooks/format.ts 115 lines1// Pure: the status-line text (plan v2 §1). No `$`, no state.
2import type { PluginOptions } from 'claude-code'
3import type { View } from '../types'
4import { priceOf } from './pricing'
5
6/** Where the `$` figure comes from: the engine's, the user's price table, or both side by side. */
7export type CostSource = 'engine' | 'table' | 'both'
8
9export type FormatOpts = { showCost: boolean; warnAt: number; costSource: CostSource; pricingFile: string }
10
11const COST_SOURCES: readonly string[] = ['engine', 'table', 'both']
12
13/**
14 * userConfig → format options; a missing or non-finite `warnAt` is 80 (D7). `costSource` takes
15 * exactly `engine`, `table` or `both`, anything else is `engine`; a non-string `pricingFile` is ''.
16 */
17export function optsFrom(options: PluginOptions): FormatOpts {
18 const { warnAt, costSource, pricingFile } = options
19 return {
20 showCost: options.showCost !== false,
21 warnAt: typeof warnAt === 'number' && Number.isFinite(warnAt) ? warnAt : 80,
22 costSource: typeof costSource === 'string' && COST_SOURCES.includes(costSource) ? (costSource as CostSource) : 'engine',
23 pricingFile: typeof pricingFile === 'string' ? pricingFile : '',
24 }
25}
26
27const usable = (x: number | null): x is number => x !== null && Number.isFinite(x) && x >= 0
28
29/**
30 * The cost segment, or null. `table` falls back to the engine figure when the table cannot price
31 * the spend (D4); `both` shows whichever side exists, plain, and `$e (est $t)` when both do (D3).
32 */
33export function costSegment(v: View, o: FormatOpts): string | null {
34 if (!o.showCost) return null
35 const engine = usable(v.cost) ? v.cost : null
36 const priced = o.costSource !== 'engine' && v.table ? priceOf(v.spend, v.table) : null
37 const table = usable(priced) ? priced : null
38 if (o.costSource === 'engine') return engine === null ? null : usd(engine)
39 if (o.costSource === 'table') return table !== null ? usd(table) : engine !== null ? usd(engine) : null
40 if (engine !== null && table !== null) return `${usd(engine)} (est ${usd(table)})`
41 return engine !== null ? usd(engine) : table !== null ? usd(table) : null
42}
43
44/** The two rules a mutant may swap (plan v2 §4c); production uses DEFAULTS. */
45export type FormatRules = {
46 kUnits: (n: number) => string
47 isDue: (tokens: number, threshold: number) => boolean
48}
49
50/** k-units: under 1,000 the integer; under 1M `floor(n/1000)k`; else `floor(n/1e5)/10` M. */
51export function k(n: number): string {
52 if (n < 1000) return `${n}`
53 if (n < 1_000_000) return `${Math.floor(n / 1000)}k`
54 return `${Math.floor(n / 100_000) / 10}M`
55}
56
57/** `$<int>.<2 digits>`, cents floored; the epsilon keeps 0.29 from flooring to 0.28. */
58export function usd(x: number): string {
59 const cents = Math.floor(x * 100 + 1e-6)
60 return `$${Math.floor(cents / 100)}.${String(cents % 100).padStart(2, '0')}`
61}
62
63/** 10 cells, `floor(tokens*10/raw)` filled, capped at 10. */
64export function bar(tokens: number, raw: number): string {
65 const filled = Math.max(0, Math.min(10, Math.floor((tokens * 10) / raw)))
66 return `▕${'█'.repeat(filled)}${'░'.repeat(10 - filled)}▏`
67}
68
69/** The window the bar measures against: the compaction window, clamped to the model's. */
70export function effectiveRaw(v: View): number {
71 return Math.min(v.win?.rawMax ?? v.window, v.window)
72}
73
74export const DEFAULTS: FormatRules = {
75 kUnits: k,
76 isDue: (tokens, threshold) => tokens >= threshold,
77}
78
79/** The status text, or undefined (a blank line) when there is no usable window (D7). */
80export function makeFormat(rules: FormatRules): (v: View, o: FormatOpts) => string | undefined {
81 const { kUnits } = rules
82 return (v, o) => {
83 const raw = effectiveRaw(v)
84 if (!Number.isFinite(raw) || raw <= 0) return undefined
85 const isStale = v.win !== null && v.win.rawMax > v.window
86 const threshold = isStale ? null : (v.win?.threshold ?? null)
87 const autoOn = v.win?.autoOn ?? true
88 const model = kUnits(v.window) !== kUnits(raw) ? ` (model ${kUnits(v.window)})` : ''
89 const segs: string[] = []
90 let head: string
91
92 if (v.tokens === null) {
93 head = `ctx – /${kUnits(raw)}${model}`
94 if (v.lastCompact) {
95 const { before, after } = v.lastCompact
96 segs.push(`compacted ${before === null ? '?' : kUnits(before)}→${after === null ? '?' : kUnits(after)}`)
97 }
98 } else {
99 const pct = Math.floor((v.tokens * 100) / raw)
100 head = `${pct >= o.warnAt ? '!' : ''}ctx ${bar(v.tokens, raw)} ${pct}% ${kUnits(v.tokens)}/${kUnits(raw)}${model}`
101 if (v.cachePct !== null) segs.push(`cache ${v.cachePct}%`)
102 if (!autoOn) segs.push('autocompact off')
103 else if (threshold !== null) {
104 segs.push(rules.isDue(v.tokens, threshold) ? 'compact due' : `compact in ${kUnits(threshold - v.tokens)}`)
105 }
106 }
107 const cost = costSegment(v, o)
108 if (cost !== null) segs.push(cost)
109
110 return [head, ...segs].join(' · ')
111 }
112}
113
114export const formatStatus = makeFormat(DEFAULTS)
115hooks/measure.ts 52 lines1// Pure: what the engine's figures mean for the bar (plan v2 §2). No `$`.
2import type { ModelUsage, SessionContextBreakdown } from 'claude-code'
3import type { View, Win } from '../types'
4
5export type BreakdownWindow = Pick<
6 SessionContextBreakdown,
7 'rawMaxTokens' | 'isAutoCompactEnabled' | 'autoCompactThreshold' | 'categories'
8>
9
10/**
11 * The compaction window and its threshold. With auto-compact off there is no threshold.
12 * When the engine gives none, `rawMax − buffer` from the `kind: 'buffer'` row is a
13 * spike-observed heuristic (33,000 on 2.1.295), not documented in the types.
14 */
15export function windowFrom(b: BreakdownWindow): Win {
16 const autoOn = b.isAutoCompactEnabled
17 const buffer = b.categories.find(c => c.kind === 'buffer')
18 const threshold = !autoOn
19 ? null
20 : (b.autoCompactThreshold ?? (buffer ? b.rawMaxTokens - buffer.tokens : null))
21
22 return { rawMax: b.rawMaxTokens, threshold, autoOn }
23}
24
25/** Cache-read share of the request's input; null usage keeps `prev`; a zero input is null. */
26export function cachePct(u: ModelUsage | null | undefined, prev: number | null): number | null {
27 if (!u) return prev
28 const total = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
29 if (total === 0) return null
30
31 return Math.floor((u.cache_read_input_tokens * 100) / total)
32}
33
34export const INITIAL: View = {
35 tokens: null,
36 window: 0,
37 cost: null,
38 cachePct: null,
39 win: null,
40 lastCompact: null,
41 table: null,
42 spend: {},
43 warned: [],
44 lastModel: null,
45 tableNoted: false,
46}
47
48/** Live figures cleared by /clear and resume; the compaction window and the price table stay. */
49export function cleared(v: View): View {
50 return { ...v, tokens: null, cost: null, cachePct: null, lastCompact: null, spend: {}, warned: [], lastModel: null }
51}
52hooks/pricing.ts 134 lines1// Pure: a user's per-token price table and the session's spend priced by it (plan ctx-bar-pricing v2 §1a–§1b, §2).
2// No `$`, no state. Rates are USD per million tokens. Model ids are looked up as own keys only, so an id
3// like `__proto__` or `toString` is an ordinary entry (PR16, PR17, PT27).
4// Lines `// @neg:<case>` are mutation points for scripts/neg.sh (V4); they do nothing.
5
6import type { Counts, Entry, Rates, Spend, Table, Tiers } from '../types'
7export type { Counts, Entry, Rates, Spend, Table, Tiers }
8
9/** The usage counts one model call reports (ModelUsage's four fields). */
10export type UsageCounts = {
11 input_tokens: number
12 output_tokens: number
13 cache_read_input_tokens: number
14 cache_creation_input_tokens: number
15}
16
17export const MAX_MODELS = 256
18export const MAX_RATE = 1_000_000
19const FIELDS = ['input', 'output', 'cacheRead', 'cacheWrite'] as const
20const ZERO: Counts = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
21
22const isPlainObject = (x: unknown): x is Record<string, unknown> =>
23 typeof x === 'object' && x !== null && !Array.isArray(x)
24const isRate = (x: unknown): x is number =>
25 typeof x === 'number' && Number.isFinite(x) && x >= 0 && x <= MAX_RATE
26
27/** Reads the four rates of `o`, or names the first bad one (with `prefix`). */
28function ratesOf(o: Record<string, unknown>, prefix: string): Rates | string {
29 const out: Partial<Rates> = {}
30 for (const f of FIELDS) {
31 const v = o[f]
32 if (!isRate(v)) return `bad ${prefix}${f}`
33 out[f] = v
34 }
35 return out as Rates
36}
37
38/** One entry, validated; unknown keys are dropped. */
39function entryOf(x: unknown): Entry | string {
40 if (!isPlainObject(x)) return 'not an object'
41 const base = ratesOf(x, '')
42 if (typeof base === 'string') return base
43 if (!Object.hasOwn(x, 'longContext')) return base
44 const lc = x.longContext
45 if (!isPlainObject(lc)) return 'bad longContext'
46 const above = lc.above
47 if (typeof above !== 'number' || !Number.isSafeInteger(above) || above < 1) return 'bad longContext.above'
48 const long = ratesOf(lc, 'longContext.')
49 if (typeof long === 'string') return long
50 return { ...base, longContext: { above, ...long } }
51}
52
53/**
54 * The table in `text`, or a fixed reason it is unusable. Reasons never quote the file (CD-L2).
55 * A leading U+FEFF is stripped; at most MAX_MODELS entries.
56 */
57export function parseTable(text: string): { ok: Table } | { error: string } {
58 let raw: unknown
59 try {
60 raw = JSON.parse(text.startsWith('') ? text.slice(1) : text)
61 } catch {
62 return { error: 'not JSON' }
63 }
64 if (!isPlainObject(raw)) return { error: 'not an object' }
65 const keys = Object.keys(raw)
66 if (keys.length > MAX_MODELS) return { error: 'too many models' }
67 const pairs: [string, Entry][] = []
68 for (const k of keys) {
69 const e = entryOf(raw[k])
70 if (typeof e === 'string') return { error: `${k}: ${e}` }
71 pairs.push([k, e])
72 }
73 return { ok: Object.assign(Object.create(null) as Table, Object.fromEntries(pairs)) }
74}
75
76/** The entry for `model`: its own key, else `default`, else undefined (D7: exact match only). */
77export function entryFor(table: Table, model: string): Entry | undefined {
78 if (Object.hasOwn(table, model)) return table[model]
79 return Object.hasOwn(table, 'default') ? table.default : undefined
80}
81
82/** A usable token count: a finite number ≥ 0 (plan v2 §9 A7). */
83const isCount = (x: unknown): x is number => typeof x === 'number' && Number.isFinite(x) && x >= 0
84/** A count as summed: anything that is not a usable count is skipped (counts 0), so it never nulls priceOf. */
85const n = (x: number) => (isCount(x) ? x : 0)
86
87/** True when one of the call's four counts is not a usable count, so it will be skipped (A7). */
88export const hasBadCount = (u: UsageCounts): boolean =>
89 ![u.input_tokens, u.output_tokens, u.cache_read_input_tokens, u.cache_creation_input_tokens].every(isCount)
90
91const add = (c: Counts, u: UsageCounts): Counts => ({
92 input: c.input + n(u.input_tokens),
93 output: c.output + n(u.output_tokens),
94 cacheRead: c.cacheRead + n(u.cache_read_input_tokens),
95 cacheWrite: c.cacheWrite + n(u.cache_creation_input_tokens),
96})
97
98/**
99 * `spend` plus one call's usage under `model`. The call is long-context when its entry has a
100 * `longContext` and input + cache read + cache write is strictly above `above` (D5). An unpriced
101 * model is still recorded, under `normal`. A count that is not finite and ≥ 0 is skipped (A7).
102 */
103export function addUsage(spend: Spend, u: UsageCounts, model: string, table: Table): Spend {
104 const lc = entryFor(table, model)?.longContext
105 const isLong = lc !== undefined && n(u.input_tokens) + n(u.cache_read_input_tokens) + n(u.cache_creation_input_tokens) > lc.above
106 const prev: Tiers = Object.hasOwn(spend, model) ? spend[model]! : { normal: ZERO, long: ZERO }
107 const next: Tiers = isLong ? { normal: prev.normal, long: add(prev.long, u) } : { normal: add(prev.normal, u), long: prev.long }
108 // fromEntries defines own properties, so a `__proto__` model id stays data
109 return Object.fromEntries([...Object.entries(spend).filter(([k]) => k !== model), [model, next]])
110}
111
112const isZero = (c: Counts) => c.input === 0 && c.output === 0 && c.cacheRead === 0 && c.cacheWrite === 0
113const cost = (c: Counts, r: Rates) => c.input * r.input + c.output * r.output + c.cacheRead * r.cacheRead + c.cacheWrite * r.cacheWrite
114
115/** Models in `spend` with non-zero counts that `table` cannot price. */
116export function unpricedIn(spend: Spend, table: Table): string[] {
117 return Object.entries(spend)
118 .filter(([m, t]) => !(isZero(t.normal) && isZero(t.long)) && entryFor(table, m) === undefined)
119 .map(([m]) => m)
120}
121
122/** The spend in USD, or null when a model with non-zero counts is unpriced or the sum is not finite (D4). */
123export function priceOf(spend: Spend, table: Table): number | null {
124 if (unpricedIn(spend, table).length > 0) return null
125 let total = 0
126 for (const [m, t] of Object.entries(spend)) {
127 const e = entryFor(table, m)
128 if (e === undefined) continue
129 total += cost(t.normal, e) + cost(t.long, e.longContext ?? e)
130 }
131 // @neg:cent-round
132 return Number.isFinite(total) ? total / 1e6 : null
133}
134types/index.d.ts 41 lines1/** USD per million tokens, per kind of token (pricing plan v2). */
2export type Rates = { input: number; output: number; cacheRead: number; cacheWrite: number }
3/** A price-table entry: its rates, and optionally the long-context tier's. */
4export type Entry = Rates & { longContext?: Rates & { above: number } }
5/** A parsed price table: model id (or `default`) → entry. */
6export type Table = Record<string, Entry>
7/** Integer token counts of one model, one tier. */
8export type Counts = Rates
9export type Tiers = { normal: Counts; long: Counts }
10/** Token counts per answering model. */
11export type Spend = Record<string, Tiers>
12
13export type Win = { rawMax: number; threshold: number | null; autoOn: boolean }
14
15export type LastCompact = { before: number | null; after: number | null }
16
17export type View = {
18 tokens: number | null
19 window: number
20 cost: number | null
21 cachePct: number | null
22 win: Win | null
23 lastCompact: LastCompact | null
24 /** The parsed price table, loaded once at session.start (null: none, or unusable). */
25 table: Table | null
26 /** Integer token counts per answering model, split by long-context tier. */
27 spend: Spend
28 /** Unpriced model ids already logged this session. */
29 warned: string[]
30 /** The last main step's answering model: compaction usage is priced at it. */
31 lastModel: string | null
32 /** A pricing debug line has fired, so a missing table is not reported again. */
33 tableNoted: boolean
34}
35
36declare module 'claude-code' {
37 interface PluginState {
38 'ctx-bar': { view: Shaped<View> }
39 }
40}
41