Claude Code mod that shows token usage, 5-hour and weekly limits, reset countdown and cost per turn as an animated hamster above the prompt


Token Hamster is a Claude Code mod, a plugin whose behaviour lives in a hooks module, that shows your token usage, your 5-hour and weekly plan limits, when they reset, and what each turn costs, live, inside Claude Code. An animated hamster eats every token Claude eats, so you see your usage at a glance instead of running a separate command.
refill in … countdown to the reset.🐹 all 3.9M · session 62% · week 95% · credits $1.99 under the prompt.No API key, no log scraping, nothing estimated: the numbers come from Claude Code's own usage data at the end of every turn.
Install the mod and keep coding. Token Hamster answers the usual questions without leaving the session:
| Question | Where you see it |
|---|---|
| How many tokens has this session used? | The hamster's cheeks, the pane's counts, the statusline |
| How close am I to the 5-hour limit? | session 62% in the statusline; the hamster's mood |
| How much of the weekly limit is left? | week 95% in the statusline; the pane's limit bars |
| When does my Claude limit reset? | refill in … on the cage and in the pane |
| When will I hit the limit at this pace? | The pane's pace forecast |
| What does a turn cost? Is the cache working? | The pane's cost per turn and cache hit rate |
| Which subagent, tool or model burns the most tokens? | The pane's breakdown |
The cage band sits above the prompt. While a turn runs the hamster eats; between turns it naps.
The hamster's mood follows the emptiest limit, the 5-hour session or the week:
| Eating | Napping | ≤ 50% left | ≤ 25% left | 0% left |
|---|---|---|---|---|
At half the limit it frowns and sweats. With a quarter left it runs it off in its wheel. When the limit is used up it sleeps in its house and counts down to the reset.
/hamster (or Details) opens a pane with the counts and where the tokens went:

/context lists it.z z z when idle.| Token Hamster | ccusage | Usage monitor apps | Statusline scripts | |
|---|---|---|---|---|
| Runs inside Claude Code | ✅ mod / plugin | CLI you run | separate terminal / menu bar app | ✅ |
| Live 5-hour and weekly limits | ✅ | — | ✅ | some |
| Reset countdown and forecast | ✅ | — | ✅ | some |
| Cost per turn, cache hit rate | ✅ | ✅ (from logs) | ✅ | some |
| Breakdown by subagent, tool, model | ✅ | by model | — | — |
| Animated mascot | 🐹 | — | — | — |
Use ccusage for reports over past days and months; use Token Hamster to watch the current session as it happens. They work fine together.
It installs like any Claude Code plugin. In Claude Code, run:
/plugin marketplace add valeryia-piatrova/token-hamster
/plugin install token-hamster@token-hamster
Or from your shell:
claude plugin marketplace add valeryia-piatrova/token-hamster
claude plugin install token-hamster@token-hamster
git clone https://github.com/valeryia-piatrova/token-hamster.git ~/mods/token-hamster
claude --plugin-dir ~/mods/token-hamster
For the desktop app, or anywhere you can't pass a flag, add the folder's path to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json.
The cage band sits above the prompt. To open the pane, press Details (d) or type /hamster. Collapse the band with its [-].
Like the mods that ship inside Claude Code (diff, agents-md), Token Hamster is a plugin with a .claude-plugin/plugin.json, a hooks/hooks.json naming the module, and TypeScript hooks under hooks/.
Data comes from the turn.complete hook's usage field, the API's own token counts, so the counters move once a turn, when it ends. Limits and reset times come from the same event. The lifetime total is kept across sessions in the plugin store.
| Hook | What it does |
|---|---|
session.start | Registers /hamster, loads the lifetime total and draws the cage. |
command.run (hamster) | Answers /hamster itself by opening the pane. It handles no other command. |
tool.call | Counts calls per tool for the pane's breakdown. It never changes, blocks or delays a tool: every call goes on to the tool unchanged. |
turn.start | Starts the eating animation. |
turn.complete | Adds the turn's tokens, refreshes the limits and stops the animation. |
ui.render (AbovePrompt, Pane) | Draws the cage band and the pane. |
It reads only the session's own usage and the list of subagents (to name their types). It runs no processes, reads no files and makes no network requests.
claude plugin validate .
claude plugin test .
| File | What it is |
|---|---|
.claude-plugin/plugin.json | manifest |
hooks/hooks.json | points to the hooks module |
hooks/register.tsx | the hooks: session.start, command.run, tool.call, turn.start, turn.complete, ui.render (band and pane) |
types/index.d.ts | the $.state contract |
tests/hamster.test.tsx | tests on the terminal and desktop surfaces |
Built on the official plugin-authoring API (function hooks). The API is early access and may change between releases.
hooks/register.tsx 759 lines1import { atom, read, update } from 'claude-code'
2import type { BoxProps, ElementConstructor, EngineInterface, MarkdownProps, Register, TextProps, Timer } from 'claude-code'
3
4import type { Cage, FigureBlock, Figures, Left, Meal, Stats } from '../types'
5
6const PANE = 'token-hamster'
7const TITLE = 'Token Hamster'
8const STORE_KEY = 'lifetime'
9const EMPTY: Meal = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
10const NO_LEFT: Left = { limits: [] }
11
12const session = atom({ plugin: 'token-hamster', key: 'session' } as const, EMPTY)
13const lifetime = atom({ plugin: 'token-hamster', key: 'lifetime' } as const, 0)
14const isEating = atom({ plugin: 'token-hamster', key: 'isEating' } as const, false)
15const frame = atom({ plugin: 'token-hamster', key: 'frame' } as const, 0)
16const left = atom({ plugin: 'token-hamster', key: 'left' } as const, NO_LEFT)
17export const NO_STATS: Stats = { turns: 0, biggest: 0, total: 0, agents: {}, models: {}, tools: {}, recent: [], lastCost: 0, context: [] }
18const stats = atom({ plugin: 'token-hamster', key: 'stats' } as const, NO_STATS)
19const cage = atom({ plugin: 'token-hamster', key: 'cage' } as const, { m: EMPTY, l: NO_LEFT, eating: false, key: '' } as Cage)
20
21const KINDS = [
22 { key: 'input', label: 'input', color: '#F2C14E' },
23 { key: 'output', label: 'output', color: '#E76F51' },
24 { key: 'cacheRead', label: 'cache read', color: '#8AB17D' },
25 { key: 'cacheWrite', label: 'cache write', color: '#6C8EBF' },
26] as const
27
28export const sum = (m: Meal) => m.input + m.output + m.cacheRead + m.cacheWrite
29
30export const fmt = (n: number) =>
31 n >= 999_950_000 ? `${(n / 1e9).toFixed(2)}B`
32 : n >= 999_950 ? `${(n / 1e6).toFixed(1)}M`
33 : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k`
34 : String(n)
35
36const esc = (s: string) => s.replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`)
37
38const WINDOWS: Record<string, string> = { five_hour: 'session', seven_day: 'week' }
39
40export const leftText = (l: Left) =>
41 [
42 ...(l.context === undefined ? [] : [`${fmt(l.context)} context left`]),
43 ...l.limits.map(w => `${WINDOWS[w.kind] ?? w.kind} ${w.percentLeft}%`),
44 ].join(' · ')
45
46export type Mood = 'fine' | 'worried' | 'running' | 'empty'
47export const mood = (l: Left): Mood => {
48 const p = Math.min(100, ...l.limits.map(w => w.percentLeft))
49 return p <= 0 ? 'empty' : p <= 25 ? 'running' : p <= 50 ? 'worried' : 'fine'
50}
51const MOOD_TEXT: Record<Mood, string> = {
52 fine: '',
53 worried: 'half the stash is gone',
54 running: 'a quarter left, running it off',
55 empty: 'out of food, asleep in the house',
56}
57
58export const until = (iso: string | undefined, now: number) => {
59 const ms = iso ? Date.parse(iso) - now : NaN
60 if (!(ms > 0)) return ''
61 const hours = Math.floor(ms / 3.6e6)
62 return hours >= 24 ? `${Math.floor(hours / 24)}d ${hours % 24}h` : `${hours}h ${Math.floor(ms / 6e4) % 60}m`
63}
64
65export const moodText = (l: Left, now: number) => {
66 const md = mood(l)
67 if (md === 'fine') return ''
68 const worst = [...l.limits].sort((a, b) => a.percentLeft - b.percentLeft)[0]
69 const t = until(worst?.resetsAt, now)
70 return t ? `${MOOD_TEXT[md]} · refill in ${t}` : MOOD_TEXT[md]
71}
72
73export const cacheHit = (m: Meal) => {
74 const inTotal = m.input + m.cacheRead + m.cacheWrite
75 return inTotal ? Math.round((m.cacheRead / inTotal) * 100) : 0
76}
77
78export const EATING = 'eating tokens…'
79export const eaten = (m: Meal, suffix = '') => (sum(m) ? `${fmt(sum(m))} context tokens eaten${suffix}` : EATING)
80
81export const statusText = (m: Meal, l: Left) =>
82 [
83 sum(m) ? `🐹 all ${fmt(sum(m))}` : `🐹 ${EATING}`,
84 ...l.limits.map(w => `${WINDOWS[w.kind] ?? w.kind} ${w.percentLeft}%`),
85 ...(l.cost === undefined ? [] : [`credits $${l.cost.toFixed(2)}`]),
86 ].join(' · ')
87
88export const cheek = (total: number) => 1 + Math.min(0.8, Math.log10(total + 1) / 11)
89
90export const seedColors = (m: Meal) => {
91 const total = sum(m)
92 if (total === 0) return Array(6).fill(KINDS[0].color) as string[]
93 const out: string[] = []
94 for (const k of KINDS) {
95 const n = Math.round((m[k.key] / total) * 6)
96 for (let i = 0; i < n && out.length < 6; i++) out.push(k.color)
97 }
98 while (out.length < 6) out.push(KINDS[0].color)
99 return out
100}
101
102const hamster = (m: Meal, eating: boolean, md: Mood = 'fine') => {
103 const c = Math.round(cheek(sum(m)) * 10) / 10
104 const crx = (18 * c).toFixed(1)
105 const cry = (15 * c).toFixed(1)
106 const wob = (18 * c + 2).toFixed(1)
107 const cheekAnim = eating
108 ? `<animate attributeName="rx" values="${crx};${wob};${crx}" dur="0.35s" repeatCount="indefinite"/>`
109 : ''
110 const eyes = eating
111 ? [102, 138].map(x => `
112 <ellipse cx="${x}" cy="92" rx="6" ry="6" fill="#2B2118">
113 <animate attributeName="ry" values="6;6;0.6;6" keyTimes="0;0.9;0.95;1" dur="3.5s" repeatCount="indefinite"/>
114 </ellipse>
115 <circle cx="${x + 2}" cy="90" r="2" fill="#fff"/>`).join('')
116 : [102, 138].map(x => `<path d="M${x - 6} 93 Q${x} 98 ${x + 6} 93" stroke="#2B2118" stroke-width="2.5" fill="none" stroke-linecap="round"/>`).join('')
117 const mouth = eating
118 ? `<ellipse cx="120" cy="116" rx="5" ry="2" fill="#7A3B3B">
119 <animate attributeName="ry" values="1;4;1" dur="0.35s" repeatCount="indefinite"/>
120 </ellipse>`
121 : md === 'fine'
122 ? `<path d="M113 112 Q120 118 127 112" stroke="#7A3B3B" stroke-width="2" fill="none" stroke-linecap="round"/>`
123 : `<path d="M113 116 Q120 110 127 116" stroke="#7A3B3B" stroke-width="2" fill="none" stroke-linecap="round"/>`
124 const brows = md === 'fine' || md === 'empty' ? '' : `
125 <path d="M94 80 L108 76" stroke="#7A4B3A" stroke-width="3" stroke-linecap="round"/>
126 <path d="M146 80 L132 76" stroke="#7A4B3A" stroke-width="3" stroke-linecap="round"/>`
127 const sweat = (md === 'worried' ? [120] : md === 'running' ? [112, 128] : []).map((x, i) => `
128 <path d="M${x} 58 q-5 8 0 11 q5 -3 0 -11z" fill="#8FC3F0" opacity="0">
129 <animate attributeName="opacity" values="0;1;0" dur="1.6s" begin="${i * 0.8}s" repeatCount="indefinite"/>
130 <animateTransform attributeName="transform" type="translate" values="0 0;0 14" dur="1.6s" begin="${i * 0.8}s" repeatCount="indefinite"/>
131 </path>`).join('')
132 const shake = md === 'running'
133 ? `<animateTransform attributeName="transform" type="translate" values="0 0;1.5 0;-1.5 0;0 0" dur="0.25s" repeatCount="indefinite"/>`
134 : ''
135 const seeds = eating
136 ? seedColors(m).map((color, i) => {
137 const x = 30 + i * 36
138 return `
139 <g opacity="0">
140 <ellipse rx="4" ry="7" fill="${color}" stroke="#00000033"/>
141 <animateMotion path="M${x} -10 Q${x} 60 120 116" dur="1.2s" begin="${(i * 0.2).toFixed(1)}s" repeatCount="indefinite" rotate="auto"/>
142 <animate attributeName="opacity" values="1;1;0" keyTimes="0;0.85;1" dur="1.2s" begin="${(i * 0.2).toFixed(1)}s" repeatCount="indefinite"/>
143 </g>`
144 }).join('')
145 : ''
146 const zzz = eating
147 ? ''
148 : [0, 1, 2].map(i => `
149 <text x="${172 + i * 12}" y="60" font-family="sans-serif" font-size="${12 + i * 4}" fill="#9A8C7E" opacity="0">z
150 <animate attributeName="y" values="64;30" dur="3s" begin="${i}s" repeatCount="indefinite"/>
151 <animate attributeName="opacity" values="0;1;0" dur="3s" begin="${i}s" repeatCount="indefinite"/>
152 </text>`).join('')
153 const breathe = eating ? '' : `<animateTransform attributeName="transform" type="scale" values="1 1;1.02 0.98;1 1" dur="3s" additive="sum" repeatCount="indefinite"/>`
154
155 return `
156 <g transform-origin="120 190">
157 ${breathe}
158 <g>${shake}
159 <ellipse cx="120" cy="190" rx="70" ry="6" fill="#00000018"/>
160 <ellipse cx="120" cy="145" rx="70" ry="48" fill="#E9A96B"/>
161 <ellipse cx="120" cy="155" rx="42" ry="32" fill="#FBE3C8"/>
162 <circle cx="80" cy="62" r="16" fill="#E9A96B"/><circle cx="80" cy="62" r="9" fill="#F5B8B0"/>
163 <circle cx="160" cy="62" r="16" fill="#E9A96B"/><circle cx="160" cy="62" r="9" fill="#F5B8B0"/>
164 <ellipse cx="120" cy="96" rx="52" ry="44" fill="#E9A96B"/>
165 <ellipse cx="82" cy="112" rx="${crx}" ry="${cry}" fill="#F4C59A">${cheekAnim}</ellipse>
166 <ellipse cx="158" cy="112" rx="${crx}" ry="${cry}" fill="#F4C59A">${cheekAnim}</ellipse>
167 <circle cx="80" cy="110" r="6" fill="#F29E9E" opacity="0.55"/>
168 <circle cx="160" cy="110" r="6" fill="#F29E9E" opacity="0.55"/>
169 <ellipse cx="120" cy="110" rx="26" ry="20" fill="#FBE3C8"/>
170 ${eyes}
171 <ellipse cx="120" cy="104" rx="4" ry="3" fill="#D9707A"/>
172 ${mouth}
173 <ellipse cx="106" cy="132" rx="7" ry="5" fill="#F4C59A"/>
174 <ellipse cx="134" cy="132" rx="7" ry="5" fill="#F4C59A"/>
175 <ellipse cx="96" cy="188" rx="12" ry="5" fill="#F4C59A"/>
176 <ellipse cx="144" cy="188" rx="12" ry="5" fill="#F4C59A"/>
177 ${brows}${sweat}
178 </g>
179 </g>
180 ${seeds}
181 ${zzz}`
182}
183
184export const hamsterSvg = (m: Meal, eating: boolean, md: Mood = 'fine') =>
185 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 200" width="240" height="200">${hamster(m, eating, md)}</svg>`
186
187export const sideHamster = (step: number) => {
188 const leg = (x: number, phase: number, far: boolean) => `
189 <g transform="translate(${x} -13)"><g>
190 <animateTransform attributeName="transform" type="rotate" values="-38;34;-38" dur="${step}s" begin="${(-phase * step).toFixed(3)}s" repeatCount="indefinite"/>
191 <rect x="-3.2" y="0" width="6.4" height="12" rx="3.2" fill="${far ? '#D4914F' : '#E9A96B'}"/>
192 <ellipse cx="2" cy="12" rx="5" ry="2.6" fill="${far ? '#E8B48A' : '#F4C59A'}"/>
193 </g></g>`
194 const bob = (step / 2).toFixed(3)
195 return `
196 <g>
197 <animateTransform attributeName="transform" type="translate" values="0 0;0 -3;0 0" dur="${bob}s" repeatCount="indefinite"/>
198 ${leg(-17, 0.5, true)}${leg(25, 0, true)}
199 ${leg(-21, 0, false)}${leg(21, 0.5, false)}
200 <g transform-origin="0 -6">
201 <animateTransform attributeName="transform" type="scale" values="1 1;1.05 0.95;1 1" dur="${bob}s" repeatCount="indefinite"/>
202 <ellipse cx="-36" cy="-15" rx="4" ry="3" fill="#F4C59A"/>
203 <path d="M-36 -12 C-38 -34 -16 -46 6 -45 C26 -44 40 -36 46 -24 C50 -14 44 -6 30 -6 L-24 -6 C-33 -6 -36 -8 -36 -12 Z" fill="#E9A96B"/>
204 <path d="M-26 -7 C-20 -18 8 -20 28 -10 C30 -7 27 -6 24 -6 L-22 -6 Z" fill="#FBE3C8"/>
205 <ellipse cx="34" cy="-17" rx="11" ry="8" fill="#F4C59A"/>
206 <ellipse cx="45" cy="-24" rx="8" ry="6" fill="#FBE3C8"/>
207 <circle cx="52" cy="-26" r="2.6" fill="#D9707A"/>
208 <path d="M48 -23 L62 -27 M48 -22 L63 -22 M48 -21 L61 -17" stroke="#9A8C7E" stroke-width="0.8" stroke-linecap="round"/>
209 <ellipse cx="36" cy="-32" rx="3.4" ry="3.8" fill="#2B2118"/>
210 <circle cx="37.2" cy="-33.4" r="1.1" fill="#fff"/>
211 <g transform="rotate(-18 20 -44)">
212 <ellipse cx="20" cy="-44" rx="6" ry="7" fill="#E9A96B"/>
213 <ellipse cx="20" cy="-43" rx="3.4" ry="4.4" fill="#F5B8B0"/>
214 </g>
215 <path d="M42 -42 q-4 7 0 10 q4 -3 0 -10z" fill="#8FC3F0">
216 <animate attributeName="opacity" values="1;0" dur="0.8s" repeatCount="indefinite"/>
217 <animateTransform attributeName="transform" type="translate" values="0 0;-12 -6" dur="0.8s" repeatCount="indefinite"/>
218 </path>
219 </g>
220 </g>`
221}
222
223const wheel = (r: number, spin: string, md: Mood, runScale: number) => {
224 const spokes = [0, 45, 90, 135].map(a => `<line x1="0" y1="${4 - r}" x2="0" y2="${r - 4}" transform="rotate(${a})" stroke="#B9C3CF" stroke-width="3"/>`).join('')
225 const running = md === 'running'
226 return `
227 <path d="M${(-0.57 * r).toFixed(1)} ${r + 8} L0 0 L${(0.57 * r).toFixed(1)} ${r + 8}" stroke="#9AA6B4" stroke-width="5" fill="none" stroke-linecap="round"/>
228 <circle r="${r}" fill="none" stroke="#9AA6B4" stroke-width="5"/>
229 <g>${spokes}<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="${running ? '0.5s' : spin}" repeatCount="indefinite"/></g>
230 <circle r="5" fill="#9AA6B4"/>
231 ${running ? `<g transform="translate(0 ${r - 5}) scale(${runScale})">${sideHamster(0.2)}</g>` : ''}`
232}
233
234export const paneWheelSvg = () =>
235 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 200" width="240" height="200">
236 <rect x="0" y="190" width="240" height="10" rx="5" fill="#E8D3A8"/>
237 <g transform="translate(120 106)">${wheel(76, '14s', 'running', 0.92)}</g>
238</svg>`
239
240const house = (asleep: boolean) => `
241 <path d="M0 80 L0 26 Q45 -16 90 26 L90 80 Z" fill="#E58F6B"/>
242 <path d="M-6 30 Q45 -24 96 30" stroke="#C96F4E" stroke-width="8" fill="none" stroke-linecap="round"/>
243 <path d="M30 80 L30 52 Q45 38 60 52 L60 80 Z" fill="#7A4B3A"/>
244 ${asleep ? `
245 <g transform-origin="45 80">
246 <animateTransform attributeName="transform" type="scale" values="1 1;1.03 0.97;1 1" dur="3s" repeatCount="indefinite"/>
247 <circle cx="37" cy="61" r="4.5" fill="#E9A96B"/><circle cx="37" cy="61" r="2.2" fill="#F5B8B0"/>
248 <circle cx="53" cy="61" r="4.5" fill="#E9A96B"/><circle cx="53" cy="61" r="2.2" fill="#F5B8B0"/>
249 <ellipse cx="45" cy="72" rx="13" ry="10" fill="#E9A96B"/>
250 <ellipse cx="45" cy="76" rx="7" ry="4.5" fill="#FBE3C8"/>
251 <path d="M38 70 q2.5 2.5 5 0 M47 70 q2.5 2.5 5 0" stroke="#2B2118" stroke-width="1.5" fill="none" stroke-linecap="round"/>
252 <circle cx="45" cy="74.5" r="1.5" fill="#D9707A"/>
253 </g>
254 ${[0, 1, 2].map(i => `
255 <text x="${56 + i * 8}" y="20" font-family="sans-serif" font-size="${10 + i * 4}" fill="#9A8C7E" opacity="0">z
256 <animate attributeName="y" values="24;-14" dur="3s" begin="${i}s" repeatCount="indefinite"/>
257 <animate attributeName="opacity" values="0;1;0" dur="3s" begin="${i}s" repeatCount="indefinite"/>
258 </text>`).join('')}` : ''}`
259
260export const paneHouseSvg = () =>
261 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 200" width="240" height="200">
262 <rect x="0" y="190" width="240" height="10" rx="5" fill="#E8D3A8"/>
263 <g transform="translate(30 30) scale(2)">${house(true)}</g>
264</svg>`
265
266export const cageKey = (m: Meal, eating: boolean, l: Left) =>
267 JSON.stringify([eating, mood(l), Math.round(cheek(sum(m)) * 10) / 10, seedColors(m)])
268
269const bandText = (m: Meal, l: Left) => {
270 const md = mood(l)
271 return `
272 <text x="285" y="46" font-family="ui-sans-serif, system-ui, sans-serif" font-size="36" font-weight="700" fill="#5B4636">${sum(m) ? `${fmt(sum(m))}<tspan font-size="26" font-weight="600" fill="#7A6656"> context tokens eaten</tspan>` : EATING}</text>
273 <text x="285" y="80" font-family="ui-sans-serif, system-ui, sans-serif" font-size="20" font-weight="600" fill="#5B4636">${esc(leftText(l))}</text>
274 <text x="285" y="110" font-family="ui-sans-serif, system-ui, sans-serif" font-size="19" font-weight="700" fill="${md === 'worried' ? '#C98A2B' : '#C0453A'}">${esc(moodText(l, l.at ?? 0))}</text>`
275}
276
277export const sceneSvg = (m: Meal, eating: boolean, l = NO_LEFT, withText: unknown = true) => {
278 const md = mood(l)
279 const dust = [-300, -120, 60, 150, 330, 420, 700, 790, 960, 1130, 1300, 1480].map((x, i) =>
280 `<path d="M${x} ${143 + (i % 3) * 2} q6 -3 12 0" stroke="#CDB98F" stroke-width="2" fill="none"/>`).join('')
281 const pebbles = [-200, 90, 380, 760, 1050, 1400].map((x, i) =>
282 `<ellipse cx="${x}" cy="${145 + (i % 2) * 2}" rx="${6 + (i % 3) * 2}" ry="2.5" fill="#C9B48A"/>`).join('')
283 const motes = [120, 300, 470, 720, 900, 1110].map((x, i) => `
284 <circle cx="${x}" cy="100" r="${1.5 + (i % 3) * 0.7}" fill="#F2C14E" opacity="0">
285 <animate attributeName="cy" values="120;20" dur="${7 + i}s" begin="${i * 1.1}s" repeatCount="indefinite"/>
286 <animate attributeName="opacity" values="0;0.7;0" dur="${7 + i}s" begin="${i * 1.1}s" repeatCount="indefinite"/>
287 </circle>`).join('')
288 const sprout = (x: number, height: number) =>
289 `<path d="M${x} 140 q-6 -${height / 2} 0 -${height}" stroke="#7FA65A" stroke-width="4" fill="none" stroke-linecap="round">
290 <animateTransform attributeName="transform" type="rotate" values="-3 ${x} 140;3 ${x} 140;-3 ${x} 140" dur="5s" repeatCount="indefinite"/></path>`
291 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 150" width="100%" height="100%" preserveAspectRatio="xMidYMid meet">
292 <rect x="-4000" y="-500" width="9200" height="640" fill="#F7EEDF"/>
293 <rect x="-4000" y="140" width="9200" height="500" fill="#E8D3A8"/>
294 <path d="M-4000 140 H5200" stroke="#D9C091" stroke-width="2"/>
295 ${dust}${pebbles}${motes}
296 ${sprout(40, 34)}${sprout(52, 24)}${sprout(1160, 30)}${sprout(1172, 40)}
297 <g transform="translate(180 67)">${wheel(65, eating ? '1.2s' : '14s', md, 0.87)}</g>
298 <g transform="translate(963 2) scale(1.72)">${house(md === 'empty')}</g>
299 ${md === 'running' || md === 'empty' ? '' : `<g transform="translate(745 -31) scale(0.9)">${hamster(m, eating, md)}</g>`}
300 ${withText ? bandText(m, l) : ''}
301</svg>`
302}
303
304const PX: Record<string, string> = {
305 O: '#E9A96B',
306 C: '#F4C59A',
307 B: '#FBE3C8',
308 P: '#F5B8B0',
309 E: '#2B2118',
310 W: '#FFFFFF',
311 N: '#D9707A',
312 M: '#7A3B3B',
313 D: '#8FC3F0',
314}
315
316export const hamsterPixels = (m: Meal, eating: boolean, f: number, md: Mood = 'fine') => {
317 const g = [
318 '...OO........OO...',
319 '..OPPO......OPPO..',
320 '..OOOOOOOOOOOOOO..',
321 '.OOOOOOOOOOOOOOOO.',
322 '.OOOOOOOOOOOOOOOO.',
323 'OOOOOOOOOOOOOOOOOO',
324 'OCCCOOOBBBBOOOCCCO',
325 'CCCCOOBBNNBBOOCCCC',
326 'CCCCCBBBBBBBBCCCCC',
327 '.CCCBBBBBBBBBBCCC.',
328 '.OOOBBBBBBBBBBOOO.',
329 '..OOOBBBBBBBBOOO..',
330 '...OOOOOOOOOOOO...',
331 '...CCC......CCC...',
332 ].map(r => r.split(''))
333 const put = (y: number, x: number, c: string) => { g[y]![x] = c }
334 const awake = eating || md === 'worried' || md === 'running'
335 for (const x of [4, 12]) {
336 if (md === 'running') { put(4, x, 'W'); put(4, x + 1, 'W'); put(5, x, 'E'); put(5, x + 1, 'E') }
337 else if (awake) { put(4, x, 'E'); put(4, x + 1, 'W'); put(5, x, 'E'); put(5, x + 1, 'E') }
338 else { put(5, x, 'E'); put(5, x + 1, 'E') }
339 }
340 if (md === 'worried' || md === 'running') { put(3, 4, 'E'); put(3, 5, 'E'); put(3, 12, 'E'); put(3, 13, 'E') }
341 if (eating && f % 2) { put(8, 8, 'M'); put(8, 9, 'M') }
342 if (md === 'worried') { put(2, 8, 'D'); put(3, 8, 'D') }
343 if (md === 'running') { put(2, 6, 'D'); put(3, 6, 'D'); put(2, 11, 'D'); put(3, 11, 'D') }
344 const puff = cheek(sum(m)) > 1.35
345 for (const [y, r] of g.entries()) {
346 const c = puff && (y === 7 || y === 8) ? 'C' : '.'
347 r.unshift(c); r.push(c)
348 }
349 return g.map(r => r.join(''))
350}
351
352export const halfBlocks = (rows: string[]) => {
353 const out: { ch: string; fg?: string; bg?: string }[][] = []
354 for (let y = 0; y < rows.length; y += 2) {
355 out.push([...rows[y]!].map((t, x) => {
356 const b = rows[y + 1]?.[x] ?? '.'
357 const top = PX[t], bot = PX[b]
358 return !top && !bot ? { ch: ' ' }
359 : top === bot ? { ch: '█', fg: top }
360 : !bot ? { ch: '▀', fg: top }
361 : !top ? { ch: '▄', fg: bot }
362 : { ch: '▀', fg: top, bg: bot }
363 }))
364 }
365 return out
366}
367
368const refreshLeft = async ($: EngineInterface) => {
369 const u = await $.session.usage().catch(() => undefined)
370 if (!u) return
371 const { tokens, window } = u.context
372 const now = await $.clock.now().catch(() => 0)
373 const next: Left = {
374 context: tokens === undefined ? undefined : Math.max(0, window - tokens),
375 window,
376 cost: u.cost?.usd,
377 at: Math.floor(now / 60_000) * 60_000,
378 limits: u.rateLimits.map(w => ({
379 kind: w.kind,
380 percentLeft: Math.max(0, Math.round(100 - w.percentUsed)),
381 resetsAt: w.resetsAt,
382 })),
383 }
384 if (JSON.stringify(await read($, left)) === JSON.stringify(next)) return
385 await update($, left, () => next)
386}
387
388const feed = async ($: EngineInterface, u: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }) => {
389 const meal: Meal = {
390 input: u.input_tokens,
391 output: u.output_tokens,
392 cacheRead: u.cache_read_input_tokens,
393 cacheWrite: u.cache_creation_input_tokens,
394 }
395 await update($, session, s => ({
396 input: s.input + meal.input,
397 output: s.output + meal.output,
398 cacheRead: s.cacheRead + meal.cacheRead,
399 cacheWrite: s.cacheWrite + meal.cacheWrite,
400 }))
401 const stored = await $.store.get(STORE_KEY)
402 const life = (typeof stored === 'number' && Number.isFinite(stored) ? stored : 0) + sum(meal)
403 await $.store.set(STORE_KEY, life)
404 await update($, lifetime, () => life)
405 await refreshLeft($)
406 await syncCage($)
407 await showStatus($)
408}
409
410const showStatus = async ($: EngineInterface) => {
411 $.ui.status(statusText(await read($, session), await read($, left)))
412}
413
414const syncCage = async ($: EngineInterface) => {
415 const m = await read($, session)
416 const eating = await read($, isEating)
417 const l = await read($, left)
418 const key = cageKey(m, eating, l)
419 if ((await read($, cage)).key !== key) await update($, cage, () => ({ m, l, eating, key }))
420}
421
422const recordTurn = async (
423 $: EngineInterface,
424 agentId: string | undefined,
425 u: { model?: string; input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number } | undefined,
426 tools: Record<string, number>,
427) => {
428 const meal: Meal = u
429 ? { input: u.input_tokens, output: u.output_tokens, cacheRead: u.cache_read_input_tokens, cacheWrite: u.cache_creation_input_tokens }
430 : EMPTY
431 const who = agentId
432 ? ((await $.agent.list().catch(() => [])).find(a => a.id === agentId)?.type ?? 'subagent')
433 : 'main'
434 const breakdown = agentId ? undefined : (await $.session.usage({ breakdown: 'summary' }).catch(() => undefined))?.context.breakdown
435 const l = await read($, left)
436 await update($, stats, st => ({
437 ...addTurn(st, { tokens: sum(meal), who, model: u?.model, tools, hit: cacheHit(meal), l }),
438 context: breakdown
439 ? breakdown.categories.filter(c => c.kind === 'used' && c.tokens > 0).sort((a, b) => b.tokens - a.tokens).slice(0, 6)
440 .map((c, i) => ({ name: c.name, tokens: c.tokens, color: PALETTE[i % PALETTE.length]! }))
441 : st.context,
442 }))
443}
444
445const warnColor = (md: Mood) => (md === 'worried' ? '#C98A2B' : '#C0453A')
446
447export const bandFigures = (m: Meal, l: Left): Figures => {
448 const md = mood(l)
449 return {
450 blocks: [
451 { md: leftText(l) ? `## ${eaten(m)}\n\n**${leftText(l)}**` : `## ${eaten(m)}` },
452 ...(md === 'fine' ? [] : [{ text: moodText(l, l.at ?? 0), bold: true, color: warnColor(md) }]),
453 ],
454 }
455}
456
457export const paneStatus = (m: Meal, l: Left, eating: boolean) => {
458 const md = mood(l)
459 return md !== 'fine' ? moodText(l, l.at ?? 0) : eating ? 'nom nom nom…' : sum(m) ? 'full and napping' : 'hungry, waiting for a prompt'
460}
461
462const RECENT = 12
463
464export const addTurn = (
465 st: Stats,
466 t: { tokens: number; who: string; model?: string; tools: Record<string, number>; hit: number; l: Left },
467): Stats => {
468 const add = (o: Record<string, number>, k: string | undefined, n: number) => (k ? { ...o, [k]: (o[k] ?? 0) + n } : o)
469 const tools = { ...st.tools }
470 for (const [name, calls] of Object.entries(t.tools)) {
471 const was = tools[name] ?? { calls: 0, tokens: 0 }
472 tools[name] = { calls: was.calls + calls, tokens: was.tokens + t.tokens }
473 }
474 const cost = t.l.cost ?? st.lastCost
475 const main = t.who === 'main'
476 const five = t.l.limits.find(w => w.kind === 'five_hour')
477 const pace = !five ? st.pace : !st.pace || five.percentLeft > st.pace.pct ? { at: t.l.at ?? 0, pct: five.percentLeft } : st.pace
478 return {
479 ...st,
480 turns: st.turns + (main ? 1 : 0),
481 biggest: main ? Math.max(st.biggest, t.tokens) : st.biggest,
482 total: st.total + t.tokens,
483 agents: add(st.agents, t.who, t.tokens),
484 models: add(st.models, t.model?.replace(/^claude-/, ''), t.tokens),
485 tools,
486 recent: main ? [...st.recent, { tokens: t.tokens, hit: t.hit, cost: Math.max(0, cost - st.lastCost) }].slice(-RECENT) : st.recent,
487 lastCost: cost,
488 pace,
489 }
490}
491
492export const forecast = (st: Stats, l: Left) => {
493 const five = l.limits.find(w => w.kind === 'five_hour')
494 if (!five || !st.pace || l.at === undefined) return ''
495 const spent = st.pace.pct - five.percentLeft
496 const ms = l.at - st.pace.at
497 if (spent <= 0 || ms <= 0) return ''
498 const left = (five.percentLeft / spent) * ms
499 const reset = five.resetsAt ? Date.parse(five.resetsAt) - l.at : Infinity
500 if (left >= reset) return 'at this pace the session limit lasts until it resets'
501 return `at this pace the session limit runs out in ~${until(new Date(l.at + left).toISOString(), l.at) || '1m'}`
502}
503
504const PALETTE = ['#E9A96B', '#6C8EBF', '#8AB17D', '#E76F51', '#F2C14E', '#B48EAD', '#9AA6B4']
505const top = (o: Record<string, number>, n = 6) => Object.entries(o).sort((a, b) => b[1] - a[1]).slice(0, n)
506
507export const statsFigures = (st: Stats, l: Left): FigureBlock[] => {
508 const out: FigureBlock[] = []
509 const used = st.context.reduce((n, c) => n + c.tokens, 0)
510 if (used) {
511 out.push({ head: 'What fills the context' })
512 for (const c of st.context) out.push({ bar: { label: c.name, share: c.tokens / used, value: fmt(c.tokens), color: c.color } })
513 }
514 if (st.recent.length) {
515 out.push({ head: 'Pace' })
516 out.push({ spark: { label: 'tokens / turn', values: st.recent.map(t => t.tokens), value: `avg ${fmt(Math.round(st.recent.reduce((n, t) => n + t.tokens, 0) / st.recent.length))}` } })
517 out.push({ text: `${st.turns} turns · biggest ${fmt(st.biggest)}`, dim: true })
518 const f = forecast(st, l)
519 if (f) out.push({ text: f, bold: true })
520 }
521 if (st.total) {
522 out.push({ head: 'Who spends' })
523 top(st.agents).forEach(([who, n], i) => out.push({ bar: { label: who, share: n / st.total, value: `${Math.round((n / st.total) * 100)}%`, color: PALETTE[i % PALETTE.length] } }))
524 }
525 const tools = Object.entries(st.tools).sort((a, b) => b[1].tokens - a[1].tokens).slice(0, 6)
526 if (tools.length) {
527 out.push({ head: 'Tools · tokens of the turns that used them' })
528 for (const [name, t] of tools) out.push({ bar: { label: name, share: st.total ? t.tokens / st.total : 0, value: `${t.calls}× · ${fmt(t.tokens)}`, color: '#6C8EBF' } })
529 }
530 if (Object.keys(st.models).length) {
531 out.push({ head: 'Models' })
532 top(st.models).forEach(([name, n], i) => out.push({ bar: { label: name, share: st.total ? n / st.total : 0, value: fmt(n), color: PALETTE[(i + 3) % PALETTE.length] } }))
533 }
534 if (st.recent.length) {
535 out.push({ head: 'Cache and cost' })
536 const last = st.recent[st.recent.length - 1]!
537 out.push({ spark: { label: 'cache hit', values: st.recent.map(t => t.hit), value: `${last.hit}%`, color: '#8AB17D' } })
538 if (st.recent.some(t => t.cost > 0)) {
539 out.push({ spark: { label: 'cost / turn', values: st.recent.map(t => t.cost), value: `last $${last.cost.toFixed(2)}`, color: '#E76F51' } })
540 }
541 }
542 return out
543}
544
545export const paneFigures = (m: Meal, life: number, l: Left, eating: boolean, st: Stats = NO_STATS): Figures => ({
546 blocks: [
547 { text: eaten(m, ' this session'), bold: true },
548 { text: `${fmt(life)} lifetime · ${paneStatus(m, l, eating)}`, dim: true },
549 ...l.limits.map(w => {
550 const t = until(w.resetsAt, l.at ?? 0)
551 const name = w.kind === 'five_hour' ? 'Session limit' : w.kind === 'seven_day' ? 'Weekly' : w.kind
552 return { text: `${name}: ${w.percentLeft}% left${t ? ` · resets in ${t}` : ''}` }
553 }),
554 ...(l.context === undefined ? [] : [{ text: `Context: ${fmt(l.context)} left` }]),
555 { text: `${l.cost === undefined ? '' : `$${l.cost.toFixed(2)} · `}cache hit ${cacheHit(m)}%`, dim: true },
556 ...KINDS.map(k => ({ text: `${k.label}: ${fmt(m[k.key])}`, dot: k.color })),
557 ...statsFigures(st, l),
558 ],
559})
560
561const LABEL = 16
562const BAR = 14
563export const spark = (values: number[]) => {
564 const max = Math.max(...values, 0)
565 return values.map(v => (max ? '▁▂▃▄▅▆▇█'[Math.min(7, Math.round((v / max) * 7))] : '▁')).join('')
566}
567
568const TRACK = '#D9D2C7'
569const drawFigures = (
570 Box: ElementConstructor<BoxProps>,
571 Text: ElementConstructor<TextProps>,
572 Markdown: ElementConstructor<MarkdownProps>,
573 f: Figures,
574 cells = false,
575) => (
576 <Box flexDirection="column">
577 {f.blocks.map(b =>
578 'md' in b ? <Markdown text={b.md} />
579 : 'head' in b ? <Box marginTop={1}><Text bold>{b.head}</Text></Box>
580 : 'bar' in b ? (
581 <Box flexDirection="row" gap={1}>
582 <Box width={LABEL}><Text wrap="truncate-end">{b.bar.label}</Text></Box>
583 {cells ? (
584 <Text>
585 <Text color={b.bar.color}>{'█'.repeat(Math.round(Math.min(1, b.bar.share) * BAR))}</Text>
586 <Text dimColor>{'░'.repeat(BAR - Math.round(Math.min(1, b.bar.share) * BAR))}</Text>
587 </Text>
588 ) : (
589 <Box width={BAR} height={1} backgroundColor={TRACK}>
590 <Box width={`${Math.round(Math.min(1, b.bar.share) * 100)}%`} height={1} backgroundColor={b.bar.color} />
591 </Box>
592 )}
593 <Text dimColor>{b.bar.value}</Text>
594 </Box>
595 )
596 : 'spark' in b ? (
597 <Box flexDirection="row" gap={1}>
598 <Box width={LABEL}><Text wrap="truncate-end">{b.spark.label}</Text></Box>
599 <Box width={BAR}><Text color={b.spark.color ?? '#E9A96B'}>{spark(b.spark.values)}</Text></Box>
600 <Text dimColor>{b.spark.value}</Text>
601 </Box>
602 ) : (
603 <Text bold={b.bold} dimColor={b.dim} color={b.color}>
604 {b.dot && <Text color={b.dot}>● </Text>}
605 {b.text}
606 </Text>
607 ))}
608 </Box>
609)
610
611const hamsterCells = (Text: ElementConstructor<TextProps>, m: Meal, eating: boolean, f: number, md: Mood) =>
612 halfBlocks(hamsterPixels(m, eating, f, md)).map((row, y) => (
613 <Text>
614 {row.map(c => <Text color={c.fg} backgroundColor={c.bg}>{c.ch}</Text>)}
615 {y === 3 && <Text color="#F2C14E">{md === 'running' ? '' : eating && md !== 'empty' ? ' ∘ • · ∘ •'.slice(f % 4) : ' z Z z'}</Text>}
616 </Text>
617 ))
618
619const countTool = (calling: Map<string, Record<string, number>>, loop: string, tool: string) => {
620 const counts = calling.get(loop) ?? {}
621 calling.set(loop, { ...counts, [tool]: (counts[tool] ?? 0) + 1 })
622}
623
624export const register: Register = on => {
625 let chew: Timer | undefined
626 const calling = new Map<string, Record<string, number>>()
627
628 on('tool.call', ($, e, next) => {
629 countTool(calling, e.agentId ?? 'main', e.tool)
630 return next(e)
631 })
632
633 on('session.start', async ($, e, next) => {
634 await $.command.register({ name: 'hamster', description: 'Open the Token Hamster pane' })
635 const stored = await $.store.get(STORE_KEY)
636 await update($, lifetime, () => (typeof stored === 'number' && Number.isFinite(stored) ? stored : 0))
637 await refreshLeft($)
638 await syncCage($)
639 return next(e)
640 })
641
642 on('command.run', { command: 'hamster' }, async $ => {
643 await $.ui.open({ id: PANE, title: TITLE })
644 return { text: 'Token Hamster pane opened.' }
645 })
646
647 on('turn.start', async ($, e, next) => {
648 await update($, isEating, () => true)
649 await syncCage($)
650 chew?.cancel()
651 chew = $.clock.every(300, () => void update($, frame, n => n + 1))
652 return next(e)
653 })
654
655 on('turn.complete', async ($, e, next) => {
656 if (e.usage) await feed($, e.usage)
657 if (!e.agentId) {
658 chew?.cancel()
659 chew = undefined
660 await update($, isEating, () => false)
661 await refreshLeft($)
662 await syncCage($)
663 }
664 await showStatus($)
665 const loop = e.agentId ?? 'main'
666 const tools = calling.get(loop) ?? {}
667 calling.delete(loop)
668 await recordTurn($, e.agentId, e.usage, tools)
669 return next(e)
670 })
671
672 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
673 if (e.props.hasSurvey) return next(e)
674 const details = () => void $.ui.open({ id: PANE, title: TITLE })
675
676 if (e.surface === 'terminal') {
677 const m = await read($, session)
678 const eating = await read($, isEating)
679 const l = await read($, left)
680 const md = mood(l)
681 const { Box, Text, Button } = $.ui.resolve(e)
682 const f = eating ? await read($, frame) : 0
683 return (
684 <Box flexDirection="row" gap={2}>
685 <Box flexDirection="column">
686 {hamsterCells(Text, m, eating, f, md)}
687 </Box>
688 <Box flexDirection="column">
689 <Text bold>{eaten(m)}</Text>
690 <Text dimColor>{leftText(l)}</Text>
691 {md !== 'fine' && <Text bold color={md === 'worried' ? 'yellow' : 'red'}>{moodText(l, l.at ?? 0)}</Text>}
692 <Button key="details" label="Details" hotkey="d" variant="secondary" onPress={details} />
693 </Box>
694 </Box>
695 )
696 }
697
698 const c = await read($, cage)
699 const { Box, Svg, Button, Markdown, Text } = $.ui.resolve(e)
700 const figures = drawFigures(Box, Text, Markdown, bandFigures(await read($, session), await read($, left)))
701 const w = e.props.bodyColumns * 8
702 const scale = Math.min(w / 1200, 140 / 150)
703 const figuresLeft = Math.round(((w - 1200 * scale) / 2 + 285 * scale) / 8)
704 return (
705 <Box position="relative" width="100%" flexGrow={1} borderStyle="round" borderColor="#E9A96B" paddingX={1}>
706 <Box width="100%" position="relative">
707 <Svg
708 source={sceneSvg(c.m, c.eating, c.l, false)}
709 alt={`A hamster in its cage, ${c.eating ? 'eating' : 'napping'}`}
710 width={w}
711 height={140}
712 isInteractive
713 />
714 <Box key="figures" position="absolute" top={0} left={figuresLeft}>
715 {figures}
716 </Box>
717 </Box>
718 <Box position="absolute" top={0} right={2} flexDirection="row" gap={1}>
719 <Button key="details" label=" Details " hotkey="d" variant="secondary" onPress={details} />
720 </Box>
721 </Box>
722 )
723 })
724
725 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
726 if (e.surface === 'terminal') {
727 const m = await read($, session)
728 const life = await read($, lifetime)
729 const l = await read($, left)
730 const eating = await read($, isEating)
731 const { Box, Text } = $.ui.resolve(e)
732 const f = eating ? await read($, frame) : 0
733 return (
734 <Box flexDirection="column">
735 {hamsterCells(Text, m, eating, f, mood(l))}
736 {drawFigures(Box, Text, $.ui.resolve(e).Markdown, paneFigures(m, life, l, eating, await read($, stats)), true)}
737 </Box>
738 )
739 }
740
741 const c = await read($, cage)
742 const md = mood(c.l)
743 const { Box, Svg, Markdown, Text } = $.ui.resolve(e)
744 const figures = drawFigures(Box, Text, Markdown, paneFigures(await read($, session), await read($, lifetime), await read($, left), await read($, isEating), await read($, stats)))
745 return (
746 <Box flexDirection="column" alignItems="center">
747 <Svg
748 source={md === 'running' ? paneWheelSvg() : md === 'empty' ? paneHouseSvg() : hamsterSvg(c.m, c.eating, md)}
749 alt={`A hamster, ${md === 'running' ? 'running in its wheel' : md === 'empty' ? 'asleep in its house' : c.eating ? 'eating' : 'napping'}`}
750 width={320}
751 height={267}
752 isInteractive
753 />
754 {figures}
755 </Box>
756 )
757 })
758}
759types/index.d.ts 52 lines1export type Meal = {
2 input: number
3 output: number
4 cacheRead: number
5 cacheWrite: number
6}
7
8export type Left = {
9 context?: number
10 window?: number
11 cost?: number
12 at?: number
13 limits: { kind: string; percentLeft: number; resetsAt?: string }[]
14}
15
16export type FigureBlock =
17 | { md: string }
18 | { text: string; color?: string; bold?: boolean; dim?: boolean; dot?: string }
19 | { head: string }
20 | { bar: { label: string; share: number; value: string; color?: string } }
21 | { spark: { label: string; values: number[]; value: string; color?: string } }
22
23export type Stats = {
24 turns: number
25 biggest: number
26 total: number
27 agents: Record<string, number>
28 models: Record<string, number>
29 tools: Record<string, { calls: number; tokens: number }>
30 recent: { tokens: number; hit: number; cost: number }[]
31 lastCost: number
32 pace?: { at: number; pct: number }
33 context: { name: string; tokens: number; color: string }[]
34}
35export type Figures = { blocks: FigureBlock[] }
36
37export type Cage = { m: Meal; l: Left; eating: boolean; key: string }
38
39declare module 'claude-code' {
40 interface PluginState {
41 'token-hamster': {
42 session: Meal
43 lifetime: number
44 isEating: boolean
45 frame: number
46 left: Left
47 cage: Cage
48 stats: Stats
49 }
50 }
51}
52