Context, cost and rate-limit meter beside the chat; warns when the session gets too expensive and hands it over to a fresh one.

A Claude Code mod that shows how heavy the session has become, and moves the work to a fresh session when it gets too expensive to keep going.
Where it shows. The claude.ai web and mobile views of a cloud session attach no surface for mods, so no pane or band can draw there (checked 22 Oct: $.session.surfaces() was empty). There:
/meter prints the whole meter as text in the chat;/meter handover.In the Claude Code desktop app, the terminal or an IDE, it also draws a pane beside the chat and a summary line above the prompt.
/meter limits <contextK> <turnUsd> <totalUsd>; they are remembered across sessions./meter handover): asks the model to write a handover (the repo's own convention if it has one), commit and push it, then open a new cloud session that starts from it. The pane then links to the new session. Without the cloud session tools, it tells you to /clear and paste one line.Dollar figures are the session ledger's list-price estimates; on a subscription they measure how fast the limits go.
This repo is a plugin marketplace (.claude-plugin/marketplace.json), and .claude/settings.json declares it and turns the mod on for anyone who opens the project (Claude Code asks once to trust the marketplace).
At the prompt of a terminal session:
/plugin install session-meter --marketplace DastanZar/Autonomous_Game_Studio_Agent
For every cloud session, add these lines to the cloud environment's setup script (environment menu in the session's title bar → Edit → Setup script):
claude plugin marketplace add DastanZar/Autonomous_Game_Studio_Agent
claude plugin install session-meter@studio-mods
claude plugin marketplace update studio-mods
claude plugin update session-meter@studio-mods
The environment caches the setup result, so a new plugin version reaches sessions only after the setup script is edited (any edit forces a fresh run).
While developing it, run claude --plugin-dir .claude/mods/session-meter. Check it with claude plugin validate and claude plugin test in this folder.
hooks/register.tsx 545 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Handover, Level, Limits, Orchestration, Snapshot } from '../types'
5
6const PANE = 'session-meter'
7const TITLE = 'Session meter'
8const DEFAULT_LIMITS: Limits = { contextK: 250, turnUsd: 3, totalUsd: 60 }
9
10const snap = atom({ plugin: 'session-meter', key: 'snap' } as const, null)
11const turnCosts = atom({ plugin: 'session-meter', key: 'turnCosts' } as const, [])
12const limits = atom({ plugin: 'session-meter', key: 'limits' } as const, DEFAULT_LIMITS)
13const alerted = atom({ plugin: 'session-meter', key: 'alerted' } as const, 'calm')
14const handover = atom({ plugin: 'session-meter', key: 'handover' } as const, { status: 'idle' })
15const lastUsd = atom({ plugin: 'session-meter', key: 'lastUsd' } as const, -1)
16const announced = atom({ plugin: 'session-meter', key: 'announced' } as const, 'calm')
17const panelVersion = atom({ plugin: 'session-meter', key: 'panelVersion' } as const, 0)
18const handledRequest = atom({ plugin: 'session-meter', key: 'handledRequest' } as const, 0)
19const sessionTitle = atom({ plugin: 'session-meter', key: 'sessionTitle' } as const, '')
20const isBandHidden = atom({ plugin: 'session-meter', key: 'isBandHidden' } as const, false)
21
22const NO_ORCH: Orchestration = { reads: 0, denied: 0, agents: {}, edited: [] }
23const orch = atom({ plugin: 'session-meter', key: 'orchestration' } as const, NO_ORCH)
24
25// Reading the main thread may do per user turn before it must hand the rest to the Explore subagent.
26const READS_PER_TURN = 3
27const EXPLORE_DENY = 'Delegate this reading to the Explore subagent (Haiku) and keep only its summary; see docs/orchestration.md'
28const WEB_DENY = 'Send web lookups to the researcher subagent (Haiku)'
29const AGENT_MODEL: Record<string, string> = { Explore: 'haiku', researcher: 'haiku', worker: 'sonnet' }
30const READ_ONLY_BASH = /^\s*(cat|grep|rg|find|ls|head|tail|sed\s+-n|wc|tree|stat|file)\b(?!.*(?:>|\|\s*(?:tee|sh|bash)\b|-exec|-delete))/
31const ALWAYS_OK = /(^|\/)\.claude\/|(^|\/)docs\/orchestration\.md$/
32
33const COLORS = { calm: '#3fb68b', warm: '#e0a03a', over: '#e5534b', track: '#8b8b8b33', ink: '#9aa0a6' }
34
35const HANDOVER_PROMPT = `This session has become expensive to keep running (its context is re-read on every turn). Hand it over to a fresh session:
36
371. Write a handover note: the goal, what is done (with commit hashes and file paths), decisions made and why, what is in progress, the exact next step, open questions, and anything the next session must not redo. Keep it under 150 lines. If the repository already has a handover file or convention (for example docs/HANDOVER.md, or a closeout step in AGENTS.md), use it; otherwise write docs/handover/<YYYY-MM-DD>-<topic>.md (or a scratchpad file if there is no repository). Follow the repository's own rules (logs, decision records) as usual.
382. Commit and push it, with any finished work, to the current branch.
393. If the create_session tool (claude-code-remote) is available, create a new session on the same repository and branch, with the same model, whose prompt is: "Read <handover path> on branch <branch> and continue from its next step." Reply with the new session's id and its link (https://claude.ai/code/<session id>).
40 If it is not available, tell me to run /clear and then send: "Read <handover path> and continue from its next step."
414. Then stop: do no further work in this session.`
42
43const fmtK = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(2)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}K` : `${n}`)
44const fmtUsd = (n: number) => (n >= 100 ? `$${n.toFixed(0)}` : `$${n.toFixed(2)}`)
45const worstOf = <T extends { ratio: number }>(ps: T[]) => ps.reduce((a, b) => (b.ratio > a.ratio ? b : a))
46const avg = (xs: number[]) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0)
47
48function fmtAge(ms: number) {
49 const m = Math.max(0, Math.round(ms / 60000))
50 return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`
51}
52
53function fmtReset(iso: string | undefined, now: number) {
54 if (!iso) return ''
55 const left = Date.parse(iso) - now
56 return Number.isFinite(left) ? `resets in ${fmtAge(left)}` : ''
57}
58
59// The three pressures, each as a fraction of its limit. The worst one sets the level.
60function pressures(s: Snapshot, costs: number[], l: Limits) {
61 const recent = avg(costs.slice(-3))
62 return [
63 { id: 'context', label: 'Context', ratio: s.tokens / (l.contextK * 1000), detail: `${fmtK(s.tokens)} of ${l.contextK}K` },
64 { id: 'turn', label: 'Cost per turn', ratio: costs.length ? recent / l.turnUsd : 0, detail: `${fmtUsd(recent)} of ${fmtUsd(l.turnUsd)}` },
65 { id: 'total', label: 'Session total', ratio: s.usd / l.totalUsd, detail: `${fmtUsd(s.usd)} of ${fmtUsd(l.totalUsd)}` },
66 ]
67}
68
69function levelOf(ratio: number): Level {
70 return ratio >= 1 ? 'over' : ratio >= 0.7 ? 'warm' : 'calm'
71}
72
73function bar(ratio: number, width: number) {
74 const filled = Math.max(0, Math.min(width, Math.round(Math.min(1, ratio) * width)))
75 return '█'.repeat(filled) + '░'.repeat(width - filled)
76}
77
78function ringSvg(percent: number, level: Level, center: string, sub: string) {
79 const r = 46
80 const c = 2 * Math.PI * r
81 const dash = (Math.min(100, percent) / 100) * c
82 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
83<circle cx="60" cy="60" r="${r}" fill="none" stroke="${COLORS.track}" stroke-width="10"/>
84<circle cx="60" cy="60" r="${r}" fill="none" stroke="${COLORS[level]}" stroke-width="10" stroke-linecap="round"
85 stroke-dasharray="${dash.toFixed(1)} ${c.toFixed(1)}" transform="rotate(-90 60 60)"/>
86<text x="60" y="60" text-anchor="middle" font-family="system-ui,sans-serif" font-size="22" font-weight="600" fill="${COLORS[level]}">${center}</text>
87<text x="60" y="80" text-anchor="middle" font-family="system-ui,sans-serif" font-size="11" fill="${COLORS.ink}">${sub}</text>
88</svg>`
89}
90
91function sparkSvg(costs: number[], cap: number) {
92 const w = 240
93 const h = 44
94 const xs = costs.slice(-24)
95 if (xs.length < 2) return ''
96 const top = Math.max(cap, ...xs) * 1.1
97 const pts = xs.map((v, i) => `${((i / (xs.length - 1)) * (w - 4) + 2).toFixed(1)},${(h - 2 - (v / top) * (h - 6)).toFixed(1)}`)
98 const capY = (h - 2 - (cap / top) * (h - 6)).toFixed(1)
99 const last = xs[xs.length - 1] ?? 0
100 const [lx, ly] = (pts[pts.length - 1] ?? '0,0').split(',')
101 const color = COLORS[levelOf(avg(xs.slice(-3)) / cap)]
102 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}">
103<line x1="0" x2="${w}" y1="${capY}" y2="${capY}" stroke="${COLORS.over}" stroke-opacity="0.45" stroke-dasharray="3 3"/>
104<polyline points="${pts.join(' ')}" fill="none" stroke="${color}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
105<circle cx="${lx}" cy="${ly}" r="3" fill="${color}"><title>${fmtUsd(last)}</title></circle>
106</svg>`
107}
108
109async function measure($: EngineInterface) {
110 const u = await $.session.usage()
111 const next: Snapshot = {
112 tokens: u.context.tokens ?? 0,
113 window: u.context.window,
114 percent: u.context.percent ?? 0,
115 usd: u.cost?.usd ?? 0,
116 rateLimits: u.rateLimits.map(r => ({ kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt })),
117 startedAt: u.startedAt,
118 model: await $.session.model(),
119 turns: await $.session.turns(),
120 }
121 await update($, snap, () => next)
122 return next
123}
124
125async function judge($: EngineInterface, s: Snapshot) {
126 const l = await read($, limits)
127 const costs = await read($, turnCosts)
128 const worst = worstOf(pressures(s, costs, l))
129 const level = levelOf(worst.ratio)
130 const before = await read($, alerted)
131 if (level === before) return
132 await update($, alerted, () => level)
133 $.ui.status(level === 'calm' ? undefined : `${level === 'over' ? '●' : '◐'} ${worst.label} ${Math.round(worst.ratio * 100)}%`)
134 if (level === 'over' && before !== 'over') {
135 await update($, isBandHidden, () => false)
136 void $.ui.notify(`${worst.label} is past its limit (${worst.detail}). Hand over to a fresh session to keep turns cheap.`, {
137 title: 'Session getting expensive',
138 }).catch(() => undefined)
139 } else if (level === 'warm' && before === 'calm') {
140 $.ui.toast(`Session meter: ${worst.label.toLowerCase()} at ${Math.round(worst.ratio * 100)}% of its limit`)
141 }
142}
143
144async function startHandover($: EngineInterface) {
145 if ((await read($, handover)).status === 'writing') return
146 await update($, handover, (): Handover => ({ status: 'writing' }))
147 $.ui.toast('Writing the handover; a new session follows')
148 await $.prompt.submit({ text: HANDOVER_PROMPT })
149}
150
151// The meter as text: what /meter prints, for clients that draw no pane (the claude.ai web and mobile views).
152async function report($: EngineInterface) {
153 const s = await read($, snap)
154 if (!s) return 'Session meter: no response measured yet.'
155 const l = await read($, limits)
156 const costs = await read($, turnCosts)
157 const now = await $.clock.now()
158 const ps = pressures(s, costs, l)
159 const level = levelOf(worstOf(ps).ratio)
160 const icon = (lv: Level) => (lv === 'over' ? '🔴' : lv === 'warm' ? '🟡' : '🟢')
161 const verdict = level === 'over' ? 'Too expensive to keep going' : level === 'warm' ? 'Getting heavy' : 'Healthy'
162 const row = (label: string, ratio: number, detail: string) =>
163 `${icon(levelOf(ratio))} ${label.padEnd(14)} ${bar(ratio, 20)} ${String(Math.round(ratio * 100)).padStart(3)}% ${detail}`
164 const lines = [
165 `${icon(level)} ${verdict} · ${s.model} · ${s.turns} turns · ${fmtAge(now - s.startedAt)}`,
166 '',
167 `Context window ${fmtK(s.tokens)} of ${fmtK(s.window)} (${s.percent}%)`,
168 `Spent so far ${fmtUsd(s.usd)}${costs.length ? ` · last turn ${fmtUsd(costs[costs.length - 1] ?? 0)} · average ${fmtUsd(avg(costs))}` : ''}`,
169 costs.length >= 2
170 ? `Cost per turn ${costs.slice(-30).map(c => '▁▂▃▄▅▆▇█'[Math.min(7, Math.floor((c / Math.max(l.turnUsd, ...costs)) * 8))]).join('')}`
171 : '',
172 '',
173 'Against your limits',
174 ...ps.map(p => row(p.label, p.ratio, p.detail)),
175 ...(s.rateLimits.length
176 ? ['', 'Plan limits', ...s.rateLimits.map(r =>
177 row(r.kind === 'five_hour' ? '5-hour' : r.kind === 'seven_day' ? 'Weekly' : r.kind, r.percentUsed / 100, fmtReset(r.resetsAt, now)))]
178 : []),
179 '',
180 level === 'over'
181 ? 'Run /meter handover to write the handover and continue in a new session.'
182 : 'Commands: /meter handover · /meter limits <contextK> <turnUsd> <totalUsd>',
183 ].filter((x, i, a) => !(x === '' && a[i - 1] === ''))
184 return '```\n' + lines.join('\n') + '\n```'
185}
186
187const PANEL = 'https://claude.ai/artifact/DJi4GZJYZrKox92i9cqdCx'
188const PANEL_LOG = '/tmp/claude-0/-home-user/a0c53fe4-4e70-5b3c-a4fb-23fcabbfce94/scratchpad/meter-diag/panel.json'
189
190// Reports this session's figures to the Session Meter page (its "sessions" collection), which claude.ai shows beside the chat.
191async function report2panel($: EngineInterface, s: Snapshot) {
192 const cse = await $.env.get('CLAUDE_CODE_REMOTE_SESSION_ID')
193 const id = cse ? cse.replace(/^cse_/, 'session_') : `local_${await $.session.id()}`
194 const five = s.rateLimits.find(r => r.kind === 'five_hour') ?? s.rateLimits[0]
195 if (!(await read($, sessionTitle))) {
196 // The session's own title, as the claude.ai sidebar shows it.
197 const got = await $.mcp.call('claude-code-remote', 'get_session', {}).catch(() => null)
198 const title = JSON.stringify(got ?? '').match(/\\?"title\\?"\s*:\s*\\?"((?:[^"\\]|\\.)*?)\\?"/)?.[1]
199 if (title) await update($, sessionTitle, () => title.replace(/\\(.)/g, '$1'))
200 }
201 const data = {
202 title: (await read($, sessionTitle)) || (await $.session.cwd()).split('/').pop() || 'Claude Code session',
203 model: s.model, tokens: s.tokens, window: s.window, usd: s.usd, turns: s.turns,
204 updated: await $.clock.now(), startedAt: s.startedAt,
205 rate: five ? { kind: five.kind, percentUsed: five.percentUsed, status: five.percentUsed >= 100 ? 'limited' : 'allowed', resetsAt: five.resetsAt ? Date.parse(five.resetsAt) / 1000 : 0 } : null,
206 }
207 const send = (version: number) =>
208 $.tool.call({ tool: 'ArtifactData', action: 'set', url: PANEL, collection: 'sessions', doc_id: id, data, ...(version > 0 ? { if_version: version } : {}) } as never)
209 const versionIn = (r: { text?: string; result?: unknown }) => Number((r.text ?? JSON.stringify(r.result ?? '')).match(/version\\?"?\s*:?\s*(\d+)/)?.[1] ?? 0)
210 let ran = await send(await read($, panelVersion))
211 if (ran.deny || ran.isError) {
212 // Unknown or stale version: read the document's current one and try once more.
213 const got = await $.tool.call({ tool: 'ArtifactData', action: 'get', url: PANEL, collection: 'sessions', doc_id: id } as never)
214 ran = await send(versionIn(got))
215 }
216 const version = versionIn(ran)
217 if (version) await update($, panelVersion, () => version)
218 await $.fs.write(PANEL_LOG, JSON.stringify({ at: data.updated, id, ok: !ran.deny && !ran.isError, deny: ran.deny, text: ran.text?.slice(0, 400) }, null, 2)).catch(() => undefined)
219}
220
221// The page's "Continue in a new session" button leaves {handover: <ms>} in requests/<session id>; a session picks it up here.
222async function checkHandover($: EngineInterface) {
223 const cse = await $.env.get('CLAUDE_CODE_REMOTE_SESSION_ID')
224 if (!cse) return
225 const doc_id = cse.replace(/^cse_/, 'session_')
226 const got = await $.tool.call({ tool: 'ArtifactData', action: 'get', url: PANEL, collection: 'requests', doc_id } as never)
227 if (got.deny || got.isError) return
228 const body = got.text ?? JSON.stringify(got.result ?? '')
229 const asked = Number(body.match(/handover\\?"?\s*:\s*(\d+)/)?.[1] ?? 0)
230 if (!asked) return
231 if (asked > (await read($, handledRequest))) {
232 await update($, handledRequest, () => asked)
233 // Consume the request so a later session start does not repeat it; if_version keeps a newer request safe.
234 const version = Number(body.match(/version\\?"?\s*:?\s*(\d+)/)?.[1] ?? 0)
235 await $.tool.call({ tool: 'ArtifactData', action: 'delete', url: PANEL, collection: 'requests', doc_id, ...(version > 0 ? { if_version: version } : {}) } as never).catch(() => undefined)
236 await startHandover($)
237 }
238}
239
240// Counts for /meter orchestration live in $.store, keyed by session, so the command reads them whichever loop wrote them.
241async function bump($: EngineInterface, field: 'denied' | 'agents', key?: string) {
242 const k = `orch:${await $.session.id()}`
243 const cur = ((await $.store.get(k)) as { denied: number; agents: Record<string, number> } | undefined) ?? { denied: 0, agents: {} }
244 if (field === 'denied') cur.denied += 1
245 else if (key) cur.agents[key] = (cur.agents[key] ?? 0) + 1
246 await $.store.set(k, cur)
247}
248
249async function orchestrationText($: EngineInterface) {
250 const o = ((await $.store.get(`orch:${await $.session.id()}`)) as { denied: number; agents: Record<string, number> } | undefined) ?? { denied: 0, agents: {} }
251 const by = Object.entries(o.agents).map(([m, n]) => `${m} ${n}`).join(', ') || 'none'
252 return `Subagent calls this session: ${by}. Main-thread reads denied (sent to Haiku): ${o.denied}.`
253}
254
255const isCostlyMain = (model: string) => /opus|fable/i.test(model)
256
257export const register: Register = on => {
258 on('session.start', async ($, e, next) => {
259 const stored = (await $.store.get('limits')) as Limits | undefined
260 if (stored) await update($, limits, () => ({ ...DEFAULT_LIMITS, ...stored }))
261 await $.command.register({
262 name: 'meter',
263 description: 'Session meter: show the figures, set limits (/meter limits <contextK> <turnUsd> <totalUsd>), or hand over now (/meter handover)',
264 })
265 await $.command.register({ name: 'orchestration', description: 'Delegation check: subagent calls by model and calls denied this session' })
266 const first = await measure($)
267 if ((await read($, lastUsd)) < 0) await update($, lastUsd, () => first.usd)
268 void $.ui.open({ id: PANE, title: TITLE })
269 void checkHandover($).catch(() => undefined)
270 $.clock.every(60_000, () => void checkHandover($).catch(() => undefined))
271 return next(e)
272 })
273
274 // A surface that attaches later (desktop app, terminal, IDE) gets the pane.
275 on('session.attach', async ($, e, next) => {
276 const done = await next(e)
277 void $.ui.open({ id: PANE, title: TITLE })
278 return done
279 })
280
281 // No pane reaches the web and mobile views, so the alert goes through the conversation: once per level, Claude says it.
282 on('prompt.submit', async ($, e, next) => {
283 if (e.origin.kind === 'plugin') return next(e)
284 await update($, orch, o => ({ ...o, reads: 0 }))
285 void checkHandover($).catch(() => undefined)
286 const level = await read($, alerted)
287 const said = await read($, announced)
288 if (level === 'calm' || level === said || (level === 'warm' && said === 'over')) return next(e)
289 await update($, announced, () => level)
290 const s = await read($, snap)
291 const note =
292 level === 'over'
293 ? `[session-meter] This session is past its cost limits (${s ? `${fmtK(s.tokens)} context, ${fmtUsd(s.usd)} spent` : ''}). Before anything else in your reply, tell the user in one line that the session has become expensive to continue, and that /meter shows the figures and /meter handover moves the work to a fresh session. Then answer their message as usual.`
294 : `[session-meter] This session is at 70% of a cost limit. At the end of your reply, add one short line telling the user, and that /meter shows the figures.`
295 return next({ ...e, context: [...(e.context ?? []), note] })
296 })
297
298 on('session.measure', async ($, e, next) => {
299 const s = await measure($)
300 await judge($, s)
301 return next(e)
302 })
303
304 on('turn.complete', async ($, e, next) => {
305 const done = await next(e)
306 const s = await measure($)
307 const before = await read($, lastUsd)
308 if (before >= 0 && s.usd > before) await update($, turnCosts, xs => [...xs, s.usd - before].slice(-48))
309 await update($, lastUsd, () => s.usd)
310 await judge($, s)
311 await report2panel($, s).catch(() => undefined)
312 return done
313 })
314
315 on('tool.call', { tool: 'mcp__claude-code-remote__create_session' }, async ($, e, next) => {
316 const ran = await next(e)
317 if ((await read($, handover)).status === 'writing' && !ran.isError && !ran.deny) {
318 const id = JSON.stringify(ran.result ?? ran.text ?? '').match(/session_[A-Za-z0-9]+/)?.[0]
319 if (id) {
320 await update($, handover, (): Handover => ({ status: 'ready', sessionId: id }))
321 void $.ui.notify('The new session is ready with the handover. Open it from the session meter.', { title: 'Handover done' }).catch(() => undefined)
322 }
323 }
324 return ran
325 })
326
327 // Delegation, enforced: on an Opus or Fable main thread, web lookups and reading past the third call of a turn go to Haiku.
328 on('tool.call', async ($, e, next) => {
329 if (e.agentId) return next(e)
330 const tool = e.tool
331 if (tool === 'Agent') {
332 const key = e.model ?? AGENT_MODEL[e.subagent_type ?? ''] ?? 'inherit'
333 await bump($, 'agents', key)
334 return next(e)
335 }
336 if (tool === 'Edit' || tool === 'Write') {
337 await update($, orch, o => ({ ...o, edited: [...o.edited, e.file_path].slice(-50) }))
338 return next(e)
339 }
340 const isWeb = tool === 'WebSearch' || tool === 'WebFetch'
341 const isRead =
342 tool === 'Read' || tool === 'Grep' || tool === 'Glob' || (tool === 'Bash' && READ_ONLY_BASH.test(e.command))
343 if (!isWeb && !isRead) return next(e)
344 if (!isCostlyMain(await $.session.model())) return next(e)
345 const deny = async (reason: string) => {
346 await bump($, 'denied')
347 return { deny: reason }
348 }
349 if (isWeb) return deny(WEB_DENY)
350 const path = tool === 'Read' ? e.file_path : tool === 'Bash' ? e.command : (e.path ?? '')
351 const state = await read($, orch)
352 if (ALWAYS_OK.test(path) || state.edited.includes(path)) return next(e)
353 if (state.reads >= READS_PER_TURN) return deny(EXPLORE_DENY)
354 await update($, orch, o => ({ ...o, reads: o.reads + 1 }))
355 return next(e)
356 })
357
358 on('command.run', { command: 'orchestration' }, async $ => ({ text: await orchestrationText($) }))
359
360 on('command.run', { command: 'meter' }, async ($, e) => {
361 const [verb, ...rest] = (e.args ?? '').trim().split(/\s+/)
362 if (verb === 'orchestration') return { text: await orchestrationText($) }
363 if (verb === 'handover') {
364 await startHandover($)
365 return { text: 'Handover started.' }
366 }
367 if (verb === 'limits') {
368 const [contextK = NaN, turnUsd = NaN, totalUsd = NaN] = rest.map(Number)
369 if (![contextK, turnUsd, totalUsd].every(n => Number.isFinite(n) && n > 0)) {
370 return { text: 'Usage: /meter limits <contextK> <turnUsd> <totalUsd>, e.g. /meter limits 250 3 60' }
371 }
372 const l = { contextK, turnUsd, totalUsd }
373 await update($, limits, () => l)
374 await $.store.set('limits', l)
375 await judge($, await measure($))
376 return { text: `Limits set: context ${contextK}K tokens, ${fmtUsd(turnUsd)} per turn, ${fmtUsd(totalUsd)} per session.` }
377 }
378 void $.ui.open({ id: PANE, title: TITLE, focus: true })
379 return { text: await report($) }
380 })
381
382 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
383 const { Box, Text, Button } = $.ui.resolve(e)
384 const s = await read($, snap)
385 if (!s) return <Text dimColor>Waiting for the first response…</Text>
386 const l = await read($, limits)
387 const costs = await read($, turnCosts)
388 const ho = await read($, handover)
389 const now = await $.clock.now()
390 const ps = pressures(s, costs, l)
391 const worst = worstOf(ps)
392 const level = levelOf(worst.ratio)
393 const cols = Math.max(24, e.props.bodyColumns ?? 40)
394 const barW = Math.max(10, Math.min(28, cols - 18))
395 const isRich = e.surface !== 'terminal'
396 const els = $.ui.resolve(e)
397 const Svg = 'Svg' in els ? els.Svg : null
398 const Link = els.Link
399 const verdict = level === 'over' ? 'Too expensive to keep going' : level === 'warm' ? 'Getting heavy' : 'Healthy'
400
401 return (
402 <Box flexDirection="column" gap={1} paddingX={1}>
403 <Box flexDirection="column">
404 <Text size="title" weight="semibold" bold color={COLORS[level]}>
405 {level === 'over' ? '●' : level === 'warm' ? '◐' : '○'} {verdict}
406 </Text>
407 <Text dimColor size="footnote">
408 {s.model} · {s.turns} turns · {fmtAge(now - s.startedAt)}
409 </Text>
410 </Box>
411
412 {isRich && Svg ? (
413 <Box flexDirection="row" gap={2} alignItems="center">
414 <Svg
415 source={ringSvg(s.percent, levelOf(s.tokens / (l.contextK * 1000)), `${s.percent}%`, `of ${fmtK(s.window)}`)}
416 alt={`Context window ${s.percent}% full`}
417 width={120}
418 height={120}
419 />
420 <Box flexDirection="column">
421 <Text size="caption" dimColor>CONTEXT</Text>
422 <Text size="heading" weight="semibold" tabular>{fmtK(s.tokens)}</Text>
423 <Text size="caption" dimColor>SPENT</Text>
424 <Text size="heading" weight="semibold" tabular>{fmtUsd(s.usd)}</Text>
425 </Box>
426 </Box>
427 ) : (
428 <Box flexDirection="column">
429 <Text>
430 <Text dimColor>Context </Text>
431 <Text bold>{fmtK(s.tokens)}</Text>
432 <Text dimColor> / {fmtK(s.window)} ({s.percent}%)</Text>
433 </Text>
434 <Text>
435 <Text dimColor>Spent </Text>
436 <Text bold>{fmtUsd(s.usd)}</Text>
437 </Text>
438 </Box>
439 )}
440
441 <Box flexDirection="column">
442 <Text size="caption" dimColor>AGAINST YOUR LIMITS</Text>
443 {ps.map(p => (
444 <Box key={p.id} flexDirection="column">
445 <Text>
446 <Text>{p.label.padEnd(14)}</Text>
447 <Text dimColor tabular>{p.detail}</Text>
448 </Text>
449 <Text color={COLORS[levelOf(p.ratio)]} mono>
450 {bar(p.ratio, barW)} {Math.round(p.ratio * 100)}%
451 </Text>
452 </Box>
453 ))}
454 </Box>
455
456 {costs.length >= 2 && (
457 <Box flexDirection="column">
458 <Text size="caption" dimColor>
459 COST PER TURN · last {fmtUsd(costs[costs.length - 1] ?? 0)} · avg {fmtUsd(avg(costs))}
460 </Text>
461 {isRich && Svg ? (
462 <Svg source={sparkSvg(costs, l.turnUsd)} alt="Cost of each recent turn" width={240} height={44} />
463 ) : (
464 <Text color={COLORS[levelOf(avg(costs.slice(-3)) / l.turnUsd)]} mono>
465 {costs
466 .slice(-barW)
467 .map(c => '▁▂▃▄▅▆▇█'[Math.min(7, Math.floor((c / Math.max(l.turnUsd, ...costs)) * 8))])
468 .join('')}
469 </Text>
470 )}
471 </Box>
472 )}
473
474 {s.rateLimits.length > 0 && (
475 <Box flexDirection="column">
476 <Text size="caption" dimColor>PLAN LIMITS</Text>
477 {s.rateLimits.map(r => (
478 <Box key={r.kind} flexDirection="column">
479 <Text>
480 <Text>{r.kind === 'five_hour' ? '5-hour' : r.kind === 'seven_day' ? 'Weekly' : r.kind}</Text>
481 <Text dimColor> · {fmtReset(r.resetsAt, now)}</Text>
482 </Text>
483 <Text color={COLORS[levelOf(r.percentUsed / 100)]} mono>
484 {bar(r.percentUsed / 100, barW)} {r.percentUsed}%
485 </Text>
486 </Box>
487 ))}
488 </Box>
489 )}
490
491 <Box flexDirection="column" gap={1}>
492 {ho.status === 'ready' && ho.sessionId ? (
493 <Box flexDirection="column">
494 <Text color={COLORS.calm}>Handover done. The new session has the notes.</Text>
495 <Link href={`https://claude.ai/code/${ho.sessionId}`} label="Open the new session →" />
496 </Box>
497 ) : ho.status === 'writing' ? (
498 <Text dimColor>Writing the handover…</Text>
499 ) : (
500 <Button
501 key="handover"
502 label="Continue in a new session"
503 variant={level === 'over' ? 'primary' : 'secondary'}
504 onPress={() => startHandover($)}
505 />
506 )}
507 <Text dimColor size="footnote">
508 Limits: {l.contextK}K context · {fmtUsd(l.turnUsd)}/turn · {fmtUsd(l.totalUsd)} total. Change with /meter limits.
509 </Text>
510 </Box>
511 </Box>
512 )
513 })
514
515 // A one-line summary that is always on, for surfaces that seat no pane; the alert and its buttons join it past a limit.
516 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
517 const s = await read($, snap)
518 if (e.props.hasSurvey || !s) return next(e)
519 const l = await read($, limits)
520 const costs = await read($, turnCosts)
521 const ho = await read($, handover)
522 const level = levelOf(worstOf(pressures(s, costs, l)).ratio)
523 const isAlerting = level === 'over' && ho.status === 'idle' && !(await read($, isBandHidden))
524 const five = s.rateLimits.find(r => r.kind === 'five_hour')
525 const { Box, Text, Button } = $.ui.resolve(e)
526 return (
527 <Box flexDirection="row" gap={1} alignItems="center" flexWrap="wrap">
528 <Text color={COLORS[level]}>{level === 'over' ? '●' : level === 'warm' ? '◐' : '○'}</Text>
529 <Text>
530 <Text dimColor>context </Text>
531 <Text bold>{fmtK(s.tokens)}</Text>
532 <Text dimColor> ({s.percent}%) · spent </Text>
533 <Text bold>{fmtUsd(s.usd)}</Text>
534 {costs.length > 0 && <Text dimColor> · {fmtUsd(avg(costs.slice(-3)))}/turn</Text>}
535 {five && <Text dimColor> · 5h {five.percentUsed}%</Text>}
536 </Text>
537 {isAlerting && <Text color={COLORS.over}>Too expensive to keep going.</Text>}
538 {isAlerting && <Button key="go" label="Continue in a new session" variant="primary" onPress={() => startHandover($)} />}
539 {isAlerting && <Button key="later" label="Later" onPress={() => update($, isBandHidden, () => true)} />}
540 {ho.status === 'writing' && <Text dimColor>Writing the handover…</Text>}
541 </Box>
542 )
543 })
544}
545types/index.d.ts 43 lines1export type Limits = { contextK: number; turnUsd: number; totalUsd: number }
2
3export type RateWindow = { kind: string; percentUsed: number; resetsAt?: string }
4
5export type Snapshot = {
6 tokens: number
7 window: number
8 percent: number
9 usd: number
10 rateLimits: RateWindow[]
11 startedAt: number
12 model: string
13 turns: number
14}
15
16export type Orchestration = { reads: number; denied: number; agents: Record<string, number>; edited: string[] }
17
18export type Level = 'calm' | 'warm' | 'over'
19
20export type Handover = {
21 status: 'idle' | 'writing' | 'ready'
22 sessionId?: string
23}
24
25declare module 'claude-code' {
26 interface PluginState {
27 'session-meter': {
28 snap: Snapshot | null
29 turnCosts: number[]
30 limits: Limits
31 alerted: Level
32 handover: Handover
33 isBandHidden: boolean
34 lastUsd: number
35 announced: Level
36 panelVersion: number
37 handledRequest: number
38 sessionTitle: string
39 orchestration: Orchestration
40 }
41 }
42}
43