SLOPSHOPPER

ctx-bar

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

newstatus
v0.1.0MITupdated 2026-10-09bitcars/ctx-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ctx-bar
› fix the failing auth test and add an audit log call ● ctx-bar: ctx-bar: status: ctx ▕████░░░░░░▏ 48% 97k/200k · $0.42 [fill=97400 raw=200000] ● ctx-bar: ctx-bar: status: ctx ▕████░░░░░░▏ 48% 97k/200k · $0.42 [fill=97400 raw=200000] ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ● ctx-bar: ctx-bar: status: ctx ▕████░░░░░░▏ 48% 97k/200k · $0.42 [fill=97400 raw=200000] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ ctx-bar: ctx ▕████░░░░░░▏ 48% 97k/200k · $0.42
README

ctx-bar

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
SegmentMeaning
bar, 22%, 45k/200ktokens 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 offheadroom until auto-compaction
$0.36session 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
! prefixfill at or above warnAt (default 80%)
ctx – /200k · compacted 182k→24kno 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).

Use

claude --plugin-dir /path/to/ctx-bar

Options (userConfig):

OptionType, defaultMeaning
showCostboolean, trueshow the $ segment
warnAtnumber, 80prefix the line with ! at or above this fill percentage
pricingFilestring, ""a JSON price table (below); relative to the session's working directory, or absolute; ~ is not expanded
costSourcestring, "engine"engine (the engine's cost), table (your table's), or both ($engine (est $table)); any other value is engine

Custom pricing

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"}}}}'
  • A model is matched by its exact id (the one that answered, subagents included), else by default; there is no prefix match.
  • longContext applies to a request whose input + cache-read + cache-write tokens are strictly above above.
  • Rates are numbers from 0 to 1,000,000; above is a whole number of at least 1; at most 256 models.
  • The file is read once, at session start. A table edit needs a new session (/clear keeps the table).
  • If the file is missing or invalid, or a model with usage has no price and there is no 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.
  • An admin-managed 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.

How it works

  • 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.

Develop

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.

Known limitations

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.

  • Subagents are ignored by the bar: their requests don't move it (the main thread's next response includes their cost). With a price table their usage still counts toward the table's spend.
  • After a /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.
  • Two updates racing can paint the older figures until the next event (LOW).
  • The compacted X→Y note clears on the next response.
  • The auto-compact threshold falls back to rawMax − buffer (33k on 2.1.295) when the engine gives none. That fallback is a heuristic.

Custom pricing (costSource table or both):

  • (MED) One 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.
  • (LOW) Resume starts the spend at zero: the mod's state is not kept across a resume.
  • (LOW) Calls the mod cannot see (other plugins' model calls, side queries) are not counted.
  • (LOW) Per-use fees for server-side tools are not priced, only their tokens.
  • (LOW) Compaction usage names no model: it is priced at the last main-thread model, else default, else dropped (with a debug line).
  • (LOW) A table edit needs a new session.
  • (INFO) A machine-level, admin-managed modelPricing reprices the engine's figure; not tested.
  • (LOW) If the compaction's summarizer request also reaches turn.step, compaction is counted twice (not checkable headless).

License

MIT. See LICENSE. The package is marked "private": true and is not published to npm.

Source 5 files
hooks/register.ts 258 lines
1// 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}
258
hooks/format.ts 115 lines
1// 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)
115
hooks/measure.ts 52 lines
1// 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}
52
hooks/pricing.ts 134 lines
1// 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}
134
types/index.d.ts 41 lines
1/** 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