SLOPSHOPPER

usage-bell

Rings when the session nears a limit: context fill, rate-limit windows, the auto-memory index

newspinnerguardcommandtoaststatus
★ 1v0.1.0MITupdated 2026-10-06KTCrisis/flux7-mods/usage-bell
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-bell
› fix the failing auth test and add an audit log call ⏺ 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 › /usage7 ⎿ usage-bell: context: 49%, rings at 70 / 85 / 95 ⎿ usage-bell: 5h: 31%, resets 09:53, rings at 80 / 95 ⎿ usage-bell: memory index: not found in this session ⎿ usage-bell: mods (subscription): no model call yet this session ⎿ usage-bell: api: ops7 did not answer on :8710 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

usage-bell

Rings, inside a Claude Code session, when the session nears a limit.

What it does

On each session.measure (the engine pushes one after every turn, and when a rate-limit window moves a point):

  • context: a toast at 70, 85 and 95 % of the window, with the tokens;
  • rate limits: a toast at 80 and 95 % of each window the last response reported (5h, 7d), with the time it resets; none off a subscription.

And for the auto-memory index (MEMORY.md, found in the session's own context breakdown at start): read at start and after each Write or Edit into its folder. The engine cuts it at load past 200 lines or 25,000 bytes (2.1.288); the bell rings at 90 % of either, and again once it is past.

Each threshold rings once on the way up; a value that falls 5 points under it (a compaction, /clear, a window that reset) arms it again.

The readings ride dim at the end of the hint line under the prompt, ctx 62% · 5h 41% · 7d 12% · mem 145/200, rather than as a pinned status line, which the engine draws with a warning sign; /usage7 prints them against their thresholds.

With avatar7 loaded, the avatar announces each toast in its own voice, in amber. Neither mod depends on the other. /usage7 test rings a sample toast, to hear that voice without waiting for a threshold.

Spend

Two kinds, never added up:

  • mods: every model call another mod makes ($.model.complete: avatar7's Haiku, jukebox7's intents) goes through the subscription. The bell counts their tokens per model and prices them as the API would, mods 41k tok ≈$0.06: what they take from the plan, not dollars billed.
  • api: the real dollars of the API keys (projects run outside Claude Code), from flux7-ops's GET /spend on 127.0.0.1:8710, asked every five minutes. ops7 alone holds the organization's Admin key; the bell sees amounts only: api $6.20 today, and in /usage7 yesterday, the month and each workspace. A day rings at $5, $10 and $20. Without ops7 the bell says so and stays quiet.

Limits

  • It signals and never acts: no compaction, no memory pruning.
  • The memory limits are the engine's constants in 2.1.288; a later build may move them.
  • The session's own cost is left out: on a subscription it means nothing.
Source 2 files
hooks/register.ts 270 lines
1import type { Register, SessionRateLimit } from 'claude-code'
2
3// Each threshold rings once on the way up; a value that falls REARM points
4// under it (a compaction, /clear, a window that reset) arms it again.
5const CONTEXT_STEPS = [70, 85, 95]
6const LIMIT_STEPS = [80, 95]
7const REARM = 5
8
9// The engine cuts the auto-memory index past these when it loads it (2.1.288);
10// the bell rings at 90 % of either, and again once it is cut.
11const MEMORY_MAX_LINES = 200
12const MEMORY_MAX_BYTES = 25_000
13const MEMORY_NEAR = 0.9
14const MEMORY_POLL_MS = 2_000
15
16const LIMIT_LABEL: Record<string, string> = { five_hour: '5h', seven_day: '7d' }
17
18// The mods' own model calls ($.model, avatar7's Haiku): they go through the
19// subscription, so no dollar is billed; their worth at API prices says how
20// much of the plan they take. $ per million tokens, input and output; a cache
21// read is a tenth of the input.
22const PRICES: [string, number, number][] = [
23  ['haiku', 1, 5],
24  ['sonnet', 2, 10],
25  ['opus', 4, 20],
26  ['fable', 10, 50],
27]
28export type ModelSpend = { calls: number; input: number; output: number; cacheRead: number; usd: number }
29export const priced = (model: string, u: { input_tokens: number; output_tokens: number; cache_read_input_tokens?: number }): number => {
30  const [, pin, pout] = PRICES.find(([name]) => model.includes(name)) ?? ['', 0, 0]
31  return (u.input_tokens * pin + (u.cache_read_input_tokens ?? 0) * pin * 0.1 + u.output_tokens * pout) / 1e6
32}
33
34// The API keys' spend (hoshi7, bayes, the agents...), real dollars, from ops7,
35// which alone holds the Admin key: asked every five minutes, as ops7 caches it.
36const SPEND_URL = 'http://127.0.0.1:8710/spend'
37const SPEND_POLL_MS = 300_000
38// A day's API spend rings at these dollars, once each on the way up.
39const API_DAY_STEPS = [5, 10, 20]
40export type ApiSpend = {
41  ok: boolean
42  error?: string
43  total?: { today: number; yesterday: number; month: number }
44  workspaces?: Record<string, { today: number; yesterday: number; month: number }>
45}
46export const parseSpend = (stdout: string): ApiSpend | undefined => {
47  try {
48    const j = JSON.parse(stdout) as ApiSpend
49    return typeof j === 'object' && j !== null && typeof j.ok === 'boolean' ? j : undefined
50  } catch {
51    return undefined
52  }
53}
54// One more call on a model's tally.
55export const addUsage = (was: ModelSpend | undefined, model: string, u: { input_tokens: number; output_tokens: number; cache_read_input_tokens?: number }): ModelSpend => {
56  const w = was ?? { calls: 0, input: 0, output: 0, cacheRead: 0, usd: 0 }
57  return {
58    calls: w.calls + 1,
59    input: w.input + u.input_tokens,
60    output: w.output + u.output_tokens,
61    cacheRead: w.cacheRead + (u.cache_read_input_tokens ?? 0),
62    usd: w.usd + priced(model, u),
63  }
64}
65const usd = (v: number): string => (v < 10 ? `$${v.toFixed(2)}` : `$${Math.round(v)}`)
66const ktok = (n: number): string => (n < 1000 ? String(n) : `${Math.round(n / 1000)}k`)
67
68const label = (kind: string): string => LIMIT_LABEL[kind] ?? kind
69
70const hhmm = (iso: string | undefined): string => {
71  if (iso === undefined) return ''
72  const d = new Date(iso)
73  if (Number.isNaN(d.getTime())) return ''
74  const days = Math.floor((d.getTime() - Date.now()) / 86_400_000)
75  const at = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
76  return days >= 1 ? `${d.toLocaleDateString('en-GB', { weekday: 'short' })} ${at}` : at
77}
78
79// The highest step at or under `value`, 0 when none.
80const stepOf = (steps: number[], value: number): number => steps.filter(s => value >= s).at(-1) ?? 0
81
82// The step still held once `value` has fallen: each step it fell REARM under
83// is released.
84const held = (steps: number[], rung: number, value: number): number =>
85  steps.filter(s => s <= rung && value > s - REARM).at(-1) ?? 0
86
87export const register: Register = on => {
88  // What each gauge last rang at, by key (`context`, `limit:five_hour`, `memory`).
89  const rung = new Map<string, number>()
90  let context: number | undefined
91  let limits: SessionRateLimit[] = []
92  let memory: { path: string; lines: number; bytes: number } | undefined
93  let memoryPath: string | undefined
94  let memoryDirty = false
95  // This session's mods' calls, by model; and the API's day, month, workspaces.
96  const mods = new Map<string, ModelSpend>()
97  let api: ApiSpend | undefined
98
99  // The rings a new reading raises; `rung` follows it.
100  const ring = (key: string, steps: number[], value: number): number | undefined => {
101    const before = held(steps, rung.get(key) ?? 0, value)
102    const now = Math.max(before, stepOf(steps, value))
103    rung.set(key, now)
104    return now > before ? now : undefined
105  }
106
107  // The readings, dim at the end of the hint line under the prompt: a pinned
108  // status line would come with the engine's warning sign, and these are not
109  // warnings; the toasts are.
110  let tail = ''
111  const statusLine = (): string | undefined => {
112    const parts: string[] = []
113    if (context !== undefined) parts.push(`ctx ${Math.round(context)}%`)
114    for (const l of limits) parts.push(`${label(l.kind)} ${Math.round(l.percentUsed)}%`)
115    if (memory !== undefined) parts.push(`mem ${memory.lines}/${MEMORY_MAX_LINES}`)
116    const m = [...mods.values()].reduce((a, b) => ({ tok: a.tok + b.input + b.output + b.cacheRead, usd: a.usd + b.usd }), { tok: 0, usd: 0 })
117    if (m.tok > 0) parts.push(`mods ${ktok(m.tok)} tok ≈${usd(m.usd)}`)
118    if (api?.ok === true && api.total !== undefined) parts.push(`api ${usd(api.total.today)} today`)
119    return parts.length === 0 ? undefined : parts.join(' · ')
120  }
121
122  on('session.start', async ($, e, next) => {
123    // For avatar7, when loaded: a limit near is a warning, in amber.
124    await $.state.set({ plugin: 'usage-bell', key: 'announce' }, { mood: 'error', event: 'the session is nearing a limit' })
125    await $.command.register({ name: 'usage7', description: 'Context, rate limits, memory index, the mods\' model calls and the API spend (test: a sample ring)' })
126    // A status line pinned by an earlier version stays until cleared.
127    $.ui.status(undefined)
128
129    // The auto-memory index this session loaded, from the free local estimate.
130    try {
131      const usage = await $.session.usage({ breakdown: 'summary' })
132      memoryPath = usage.context.breakdown?.memoryFiles.find(
133        f => f.type === 'AutoMem' && f.path.endsWith('/MEMORY.md'),
134      )?.path
135      memoryDirty = memoryPath !== undefined
136    } catch {
137      // No breakdown here: the bell watches context and limits only.
138    }
139
140    // The memory index is read here, the only hook that keeps the $; a write
141    // into its folder only raises the flag.
142    $.clock.every(MEMORY_POLL_MS, async () => {
143      if (!memoryDirty || memoryPath === undefined) return
144      memoryDirty = false
145      try {
146        const text = await $.fs.read(memoryPath)
147        const lines = text.trim().split('\n').length
148        const bytes = new TextEncoder().encode(text).length
149        memory = { path: memoryPath, lines, bytes }
150        const fill = Math.max(lines / MEMORY_MAX_LINES, bytes / MEMORY_MAX_BYTES) * 100
151        const step = ring('memory', [MEMORY_NEAR * 100, 100], fill)
152        if (step === 100) {
153          $.ui.toast(`memory index is cut at load: ${lines} lines, ${bytes} bytes (limit ${MEMORY_MAX_LINES} / ${MEMORY_MAX_BYTES})`)
154        } else if (step !== undefined) {
155          $.ui.toast(`memory index near its limit: ${lines}/${MEMORY_MAX_LINES} lines, ${Math.round(bytes / 1000)}/${MEMORY_MAX_BYTES / 1000} kB`)
156        }
157        tail = statusLine() ?? ''
158        $.ui.invalidate('ui.render')
159      } catch {
160        // Unreadable for now: try again at the next write.
161      }
162    })
163
164    // The API spend, from ops7 (absent when ops7 or its key is not there).
165    const askSpend = async (): Promise<void> => {
166      const r = await $.process.run(['curl', '-s', '--max-time', '5', SPEND_URL]).catch(() => undefined)
167      const got = r?.exitCode === 0 ? parseSpend(r.stdout) : undefined
168      api = got ?? { ok: false, error: 'ops7 did not answer on :8710' }
169      if (api.ok && api.total !== undefined) {
170        const step = ring('api:today', API_DAY_STEPS, api.total.today)
171        if (step !== undefined) $.ui.toast(`API spend today ${usd(api.total.today)}, past $${step} (month ${usd(api.total.month)})`)
172      }
173      tail = statusLine() ?? ''
174      $.ui.invalidate('ui.render')
175    }
176    void askSpend()
177    $.clock.every(SPEND_POLL_MS, askSpend)
178
179    return next(e)
180  })
181
182  // Each model call a mod publishes (avatar7's lines and journals, jukebox7's
183  // intents): counted, and priced as the API would, though the subscription
184  // bills none of it. A hook on model.complete does not see another mod's call.
185  for (const plugin of ['avatar7', 'jukebox7'] as const) {
186    on('state.set', { plugin, key: 'modelUse' }, async ($, e, next) => {
187      const done = await next(e)
188      const u = e.value
189      mods.set(u.model, addUsage(mods.get(u.model), u.model, {
190        input_tokens: u.input, output_tokens: u.output, cache_read_input_tokens: u.cacheRead,
191      }))
192      tail = statusLine() ?? ''
193      $.ui.invalidate('ui.render')
194      return done
195    })
196  }
197
198  on('session.measure', async ($, e, next) => {
199    if (e.context.percent !== undefined) {
200      context = e.context.percent
201      const step = ring('context', CONTEXT_STEPS, context)
202      if (step !== undefined) {
203        const tokens = e.context.tokens === undefined ? '' : ` (${Math.round(e.context.tokens / 1000)}k of ${Math.round(e.context.window / 1000)}k)`
204        $.ui.toast(`context ${Math.round(context)}% full${tokens}${step >= 95 ? ', compaction is close' : ''}`)
205      }
206    }
207
208    limits = e.rateLimits
209    for (const l of limits) {
210      const step = ring(`limit:${l.kind}`, LIMIT_STEPS, l.percentUsed)
211      if (step !== undefined) {
212        const reset = hhmm(l.resetsAt)
213        $.ui.toast(`${label(l.kind)} rate limit ${Math.round(l.percentUsed)}% used${reset === '' ? '' : `, resets ${reset}`}`)
214      }
215    }
216
217    tail = statusLine() ?? ''
218    $.ui.invalidate('ui.render')
219    return next(e)
220  })
221
222  on('ui.render', { component: 'PromptHint' }, async ($, e, next) =>
223    tail === '' ? next(e) : next({ ...e, props: { ...e.props, tail: `${e.props.tail ?? ''} · ${tail}` } }),
224  )
225
226  // A write into the memory folder: the clock rereads the index.
227  for (const tool of ['Write', 'Edit'] as const) {
228    on('tool.call', { tool }, async ($, e, next) => {
229      const done = await next(e)
230      const dir = memoryPath?.slice(0, memoryPath.lastIndexOf('/') + 1)
231      if (dir !== undefined && e.file_path.startsWith(dir)) memoryDirty = true
232      return done
233    })
234  }
235
236  // `/usage7 test`: a sample ring, so the voice avatar7 lends the bell can
237  // be heard without waiting for a real threshold.
238  on('command.run', { command: 'usage7' }, async ($, e) => {
239    if (e.args.trim() === 'test') {
240      $.ui.toast('test ring: context 72% full, a sample, no real limit is near')
241      return { text: 'test ring sent.' }
242    }
243    const rows: string[] = []
244    rows.push(context === undefined ? 'context: no reading yet' : `context: ${Math.round(context)}%, rings at ${CONTEXT_STEPS.join(' / ')}`)
245    if (limits.length === 0) rows.push('rate limits: no reading (not on a subscription, or no response yet)')
246    for (const l of limits) {
247      const reset = hhmm(l.resetsAt)
248      rows.push(`${label(l.kind)}: ${Math.round(l.percentUsed)}%${reset === '' ? '' : `, resets ${reset}`}, rings at ${LIMIT_STEPS.join(' / ')}`)
249    }
250    rows.push(
251      memory === undefined
252        ? 'memory index: not found in this session'
253        : `memory index: ${memory.lines}/${MEMORY_MAX_LINES} lines, ${memory.bytes}/${MEMORY_MAX_BYTES} bytes (${memory.path})`,
254    )
255    if (mods.size === 0) rows.push('mods (subscription): no model call yet this session')
256    for (const [model, m] of mods) {
257      rows.push(`mods ${model}: ${m.calls} calls, ${ktok(m.input)} in, ${ktok(m.output)} out, ${ktok(m.cacheRead)} cached; ≈${usd(m.usd)} at API prices, billed to the plan, not in dollars`)
258    }
259    if (api === undefined) rows.push('api: not asked yet')
260    else if (!api.ok || api.total === undefined) rows.push(`api: ${api.error ?? 'no answer'}`)
261    else {
262      rows.push(`api: ${usd(api.total.today)} today, ${usd(api.total.yesterday)} yesterday, ${usd(api.total.month)} this month (UTC days), rings at $${API_DAY_STEPS.join(' / $')} a day`)
263      for (const [ws, v] of Object.entries(api.workspaces ?? {})) {
264        rows.push(`  ${ws}: ${usd(v.today)} today, ${usd(v.month)} month`)
265      }
266    }
267    return { text: rows.join('\n') }
268  })
269}
270
types/index.d.ts 16 lines
1// What this mod asks of avatar7 when it toasts, if avatar7 is loaded: the
2// face (calm, or amber for a warning) and what happened. avatar7 hears the
3// write; nothing here depends on it.
4export type Announce = { mood: 'watch' | 'error'; event: string }
5
6// A model call another mod published (avatar7, jukebox7): read only here.
7export type ModelUse = { model: string; input: number; output: number; cacheRead: number; at: number }
8
9declare module 'claude-code' {
10  interface PluginState {
11    'usage-bell': { announce: Announce }
12    avatar7: { modelUse: ModelUse }
13    jukebox7: { modelUse: ModelUse }
14  }
15}
16