A live side pane of session stats (model, tokens, prompt cache and its savings, usage limits, cost and burn rate, tools, agents), threshold alerts, a done…

Part of claude-toolkit. See there for every plugin and how to add the marketplace.
A Claude Code side pane with live stats for the current session. In fullscreen mode it docks on the right of the transcript.
It also includes cache-tax: it keeps the one-hour prompt cache warm during breaks, and stops a send that would re-write a cold cache once, showing what it would cost. If you have cache-tax installed separately, uninstall it so the guard doesn't run twice.
At the Claude Code prompt:
/plugin marketplace add miskopisko/claude-toolkit /plugin install session-stats@miskopisko
From a shell:
claude plugin install session-stats --marketplace miskopisko/claude-toolkit
The pane opens by itself when the terminal is at least 144 columns wide.
/session-stats opens it at any width. It docks on the right in fullscreen mode from 110 columns, and sits above the prompt otherwise./session-stats close hides it./keepwarm keep the cache warm for six hours /keepwarm 90m keep it warm for a window of your own (also 2h30m, 6h) /keepwarm always arm a six-hour window at every session start /keepwarm 6h every 2m ping every two minutes (a testing knob, 1m to 59m) /keepwarm status the window, next ping and last ping /keepwarm off stop, and turn always off /cache-tax the cache card: state, cold cost, keepwarm, tally /cache-tax guard warn show the price of a cold send and send it /cache-tax guard refuse drop a cold send once; the resend goes through (default)
While keepwarm is on, after 50 idle minutes it sends one small, tool-less request over the conversation so the cache is read rather than re-written. Each ping costs tokens. Keepwarm stops if a ping finds the cache already gone. A cold write that gets paid arms keepwarm for three hours.
The guard steps in only when the context is at least 50,000 tokens and the one-hour clock says the cache is cold. The cold cost is an upper bound.
Prices are the Claude API list prices on Anthropic's pricing page, read 2026-10-21, including Haiku 5.5's higher rates for a prompt over 100,000 tokens. Cloud-provider and US-only (inference_geo) pricing differ. Older Sonnet and Haiku models the page no longer lists are priced as Sonnet 4 and Haiku 4.5.
Keepwarm assumes a one-hour cache. Subscription usage defaults to one hour; for API-key or cloud-provider sessions, set promptCacheTtl to "1h".
The plugin writes a dim line in the transcript, once each, when:
They appear whether or not the pane is open. /session-stats alerts off turns them off in every session; /session-stats alerts on turns them back on.
When a turn runs two minutes or longer, a desktop notification says so when it finishes, with the first line of the answer, so you can step away during long runs. It goes through your own notification channel (preferredNotifChannel); a turn that was interrupted or ended in an API error sends none. /session-stats notify off turns it off in every session; /session-stats notify on turns it back on.
The pane's Controls section holds four switches. Click one, or focus the pane (ctrl+x tab) and press its key:
k keepwarm on arms a six-hour window, as /keepwarm; off as /keepwarm off (shown once cache-tax has started, just after the session does) g guard refuse a cold send once, or only warn a alerts the transcript alerts above n notify the done notification
Each is remembered across sessions, the same as its slash command.
/clear. A /clear starts the prompt cache rows (last request, idle time) and the compaction count over, along with cache-tax's clock and tally. Cost, context and usage limits come from Claude Code itself, so those are always complete./session-stats close or its close mark), even while another pane's tab is in front. A pane never drawn costs nothing. The agent list is refreshed every 2 seconds while it ticks.The hooks module is hooks/register.tsx, with number formatting in hooks/format.ts, burn rate, forecasts and cache savings in hooks/pace.ts, alert thresholds in hooks/alerts.ts, cache-tax in hooks/cache-tax.ts and the pane's state types in types/index.d.ts.
claude plugin validate . claude plugin test . tsc -p .
tsconfig.json extends .claude-plugin/types/tsconfig.json, which is not in git: Claude Code writes .claude-plugin/types/ (the plugin API's typings and that tsconfig) each time it loads the plugin from a folder you own. On a fresh clone, start one session with it loaded before running tsc:
claude --plugin-dir .
cache-tax is Copyright (c) 2026 Karan Bansal, used under the MIT License: see LICENSE-cache-tax. hooks/cache-tax.ts and tests/cache-tax.test.ts are ported from it.
hooks/register.tsx 680 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionContextUsage, SessionCost, SessionRateLimit, Timer } from 'claude-code'
3
4import type { AgentRow, CacheTaxView, Pace, Savings, Settings, Snapshot, ToolStats, Tokens, TurnInfo } from '../types'
5import { checkAlerts, freshAlerts, turnCostAlert } from './alerts'
6import type { Alerts } from './alerts'
7import {
8 addUsage,
9 bar,
10 duration,
11 emptyUsage,
12 hitRate,
13 levelColor,
14 limitName,
15 tokens as tok,
16 usd,
17} from './format'
18import {
19 cacheTaxSessionCompact,
20 cacheTaxSessionStart,
21 cacheTaxTurnComplete,
22 cacheTaxTurnStep,
23 freshState,
24 hookCacheTax,
25 keepwarmToggle,
26 priceOf,
27 promptOf,
28 setGuard,
29} from './cache-tax'
30import { addSample, cacheSavings, perHour } from './pace'
31
32const PANE = 'session-stats'
33const TITLE = 'Session stats'
34const WIDTH = 46
35/** Cells of space between the pane's left edge and its text. */
36const GUTTER = 1
37const ACTIVE = new Set(['pending', 'running', 'waiting'])
38/** A main turn at least this long sends a desktop notification when it finishes. */
39const NOTIFY_AFTER_MS = 2 * 60 * 1000
40/** The store keys of the settings, each written only while it is off. */
41const SETTING_KEYS = { alerts: 'alerts', notify: 'notify' } as const
42
43const snap = atom({ plugin: 'session-stats', key: 'snap' } as const, {
44 startedAt: null,
45 model: null,
46 effort: null,
47 contextTokens: null,
48 contextWindow: null,
49 contextPercent: null,
50 rateLimits: [],
51 costUsd: null,
52 version: null,
53} satisfies Snapshot)
54const tokens = atom({ plugin: 'session-stats', key: 'tokens' } as const, {
55 main: emptyUsage(),
56 agents: emptyUsage(),
57 last: null,
58} satisfies Tokens)
59const turn = atom({ plugin: 'session-stats', key: 'turn' } as const, {
60 count: 0,
61 runningSince: null,
62 lastMs: null,
63 steps: 0,
64 compactions: 0,
65} satisfies TurnInfo)
66const tools = atom({ plugin: 'session-stats', key: 'tools' } as const, {
67 counts: {},
68 errors: 0,
69 running: [],
70} satisfies ToolStats)
71const agents = atom({ plugin: 'session-stats', key: 'agents' } as const, [] as AgentRow[])
72const now = atom({ plugin: 'session-stats', key: 'now' } as const, 0)
73// Written by hooks/cache-tax.ts, which declares the same reference.
74const cacheTaxView = atom({ plugin: 'session-stats', key: 'cacheTax' } as const, null as CacheTaxView | null)
75const pace = atom({ plugin: 'session-stats', key: 'pace' } as const, { cost: [], limits: {} } as Pace)
76const savings = atom({ plugin: 'session-stats', key: 'savings' } as const, {
77 readsUsd: 0,
78 premiumUsd: 0,
79 unpriced: 0,
80} satisfies Savings)
81const settings = atom({ plugin: 'session-stats', key: 'settings' } as const, { alerts: true, notify: true } satisfies Settings)
82
83type Measured = { context: SessionContextUsage; rateLimits: SessionRateLimit[]; cost?: SessionCost }
84
85async function measured($: EngineInterface, m: Measured) {
86 const at = await $.clock.now()
87 await update($, pace, p => ({
88 cost: m.cost === undefined ? p.cost : addSample(p.cost, at, m.cost.usd),
89 limits: {
90 ...p.limits,
91 ...Object.fromEntries(m.rateLimits.map(r => [r.kind, addSample(p.limits[r.kind] ?? [], at, r.percentUsed)])),
92 },
93 }))
94 await update($, snap, s => ({
95 ...s,
96 contextTokens: m.context.tokens ?? s.contextTokens,
97 contextWindow: m.context.window,
98 contextPercent: m.context.percent ?? s.contextPercent,
99 rateLimits: m.rateLimits.length > 0 ? m.rateLimits.map(r => ({ ...r })) : s.rateLimits,
100 costUsd: m.cost?.usd ?? s.costUsd,
101 }))
102}
103
104async function pollAgents($: EngineInterface) {
105 const at = await $.clock.now()
106 const list = await $.agent.list()
107 await update($, agents, rows => {
108 const byId = new Map(rows.map(r => [r.id, r]))
109 for (const a of list) {
110 const was = byId.get(a.id)
111 byId.set(a.id, {
112 id: a.id,
113 description: a.description,
114 type: a.type,
115 status: a.status,
116 firstSeen: was?.firstSeen ?? at,
117 lastSeen: ACTIVE.has(a.status) || was === undefined ? at : was.lastSeen,
118 tokens: was?.tokens ?? 0,
119 tools: was?.tools ?? 0,
120 lastTool: was?.lastTool ?? null,
121 })
122 }
123 // An agent the engine dropped from its list while we last saw it active has finished.
124 const listed = new Set(list.map(a => a.id))
125 for (const [id, r] of byId) {
126 if (!listed.has(id) && ACTIVE.has(r.status)) byId.set(id, { ...r, status: 'completed' })
127 }
128 return [...byId.values()].slice(-30)
129 })
130}
131
132// The pane's clock: one tick a second, from the pane's first draw until it is
133// closed, so a pane never drawn costs nothing. The agent list is polled on every other tick.
134let ticker: Timer | null = null
135
136function startTicker($: EngineInterface) {
137 if (ticker !== null) return
138 let ticks = 0
139 ticker = $.clock.every(1000, async () => {
140 ticks += 1
141 const tickAt = await $.clock.now()
142 await update($, now, () => tickAt)
143 if (ticks % 2 === 0) await pollAgents($)
144 })
145}
146
147function stopTicker() {
148 ticker?.cancel()
149 ticker = null
150}
151
152function say($: EngineInterface, line: string) {
153 $.ui.log(`session-stats: ${line}`)
154}
155
156/** Turns a setting on or off, for this session and the ones after it. */
157async function setSetting($: EngineInterface, name: keyof Settings, isOn: boolean) {
158 await update($, settings, v => ({ ...v, [name]: isOn }))
159 if (isOn) await $.store.delete(SETTING_KEYS[name])
160 else await $.store.set(SETTING_KEYS[name], false)
161}
162
163/** Flips a setting from what it is now, not from what a drawing showed: two quick presses land as two. */
164async function toggleSetting($: EngineInterface, name: keyof Settings) {
165 await setSetting($, name, !(await read($, settings))[name])
166}
167
168/** Logs what crossed an alert threshold since the last check, unless alerts are off. */
169async function raiseAlerts($: EngineInterface, alerts: Alerts) {
170 if (!(await read($, settings)).alerts) return
171 const [s, p] = await Promise.all([read($, snap), read($, pace)])
172 for (const line of checkAlerts(alerts, s, p, await $.clock.now())) say($, line)
173}
174
175/** The first line of an answer, cut to fit a notification. */
176function firstLine(text: string): string {
177 const line = text.trim().split('\n')[0]?.trim() ?? ''
178 return line.length > 100 ? `${line.slice(0, 99)}…` : line
179}
180
181/** Notifies the desktop that a long main turn finished; a channel that is off or fails is passed over. */
182async function notifyDone($: EngineInterface, ms: number, answer: string) {
183 const summary = firstLine(answer)
184 try {
185 await $.ui.notify(`Done after ${duration(ms)}${summary === '' ? '' : `: ${summary}`}`, { title: 'Claude Code' })
186 } catch {
187 // A hook refused it or the write failed: nothing to do.
188 }
189}
190
191function touchAgent(id: string, at: number, change: (r: AgentRow) => AgentRow) {
192 return (rows: AgentRow[]) => {
193 const row = rows.find(r => r.id === id) ?? {
194 id,
195 description: '',
196 type: 'agent',
197 status: 'running',
198 firstSeen: at,
199 lastSeen: at,
200 tokens: 0,
201 tools: 0,
202 lastTool: null,
203 }
204 return [...rows.filter(r => r.id !== id), change(row)].slice(-30)
205 }
206}
207
208export const register: Register = on => {
209 const cacheTax = freshState()
210 hookCacheTax(on, cacheTax)
211 const alerts = freshAlerts()
212
213 on('session.start', async ($, e, next) => {
214 await $.command.register({
215 name: 'session-stats',
216 description: 'Open the session stats pane (/session-stats close hides it; alerts on|off, notify on|off)',
217 argumentHint: '[close | alerts on|off | notify on|off]',
218 })
219 const [alertsOff, notifyOff] = await Promise.all([$.store.get(SETTING_KEYS.alerts), $.store.get(SETTING_KEYS.notify)])
220 await update($, settings, () => ({ alerts: alertsOff !== false, notify: notifyOff !== false }))
221 const started = await $.clock.now()
222 const [usage, version] = await Promise.all([$.session.usage(), $.session.version()])
223 await update($, snap, s => ({ ...s, startedAt: usage.startedAt ?? started, version: version.version }))
224 await measured($, usage)
225 await update($, now, () => started)
226 await raiseAlerts($, alerts)
227
228 // Unasked, the pane is placed only on a wide terminal; its first draw starts the clock.
229 void $.ui.open({ id: PANE, title: TITLE, columns: WIDTH })
230
231 const result = await next(e)
232 await cacheTaxSessionStart($, cacheTax)
233 return result
234 })
235
236 on('command.run', { command: 'session-stats' }, async ($, e) => {
237 const words = e.args.trim().split(/\s+/)
238 const name = words[0]
239 if (name === 'alerts' || name === 'notify') {
240 const what = name === 'alerts' ? 'Alerts' : 'Notifications when a long turn finishes'
241 if (words[1] !== 'on' && words[1] !== 'off') {
242 const isOn = (await read($, settings))[name]
243 return { text: `${what} are ${isOn ? 'on' : 'off'}: /session-stats ${name} on|off` }
244 }
245 await setSetting($, name, words[1] === 'on')
246 return { text: words[1] === 'on' ? `${what} are on.` : `${what} are off, in every session until /session-stats ${name} on.` }
247 }
248 if (words[0] === 'close') {
249 await $.ui.close({ id: PANE })
250 stopTicker()
251 return { text: 'Session stats pane closed.' }
252 }
253 if ((name ?? '') !== '') return { text: 'Usage: /session-stats [close | alerts on|off | notify on|off]' }
254 const opened = await $.ui.open({ id: PANE, title: TITLE, columns: WIDTH })
255 return {
256 text: opened.isPlaced
257 ? 'Session stats pane opened. In fullscreen mode it docks on the right.'
258 : `Session stats pane is waiting to be placed: ${opened.reason}`,
259 }
260 })
261
262 on('ui.close', { id: PANE }, async ($, e, next) => {
263 const closed = await next(e)
264 stopTicker()
265 return closed
266 }).catch(($, e, next) => next(e))
267
268 // /clear starts a new conversation in the same process: its prompt cache and compactions start over;
269 // the token, tool and turn counts run on for the whole process.
270 on('session.end', async ($, e, next) => {
271 const ended = await next(e)
272 if (e.reason === 'clear') {
273 await update($, tokens, t => ({ ...t, last: null }))
274 await update($, turn, t => ({ ...t, compactions: 0 }))
275 }
276 return ended
277 }).catch(($, e, next) => next(e))
278
279 on('session.measure', async ($, e, next) => {
280 await measured($, e)
281 await raiseAlerts($, alerts)
282 return next(e)
283 })
284
285 on('turn.start', async ($, e, next) => {
286 if (e.agentId === undefined) {
287 const at = await $.clock.now()
288 await update($, turn, t => ({ ...t, count: t.count + 1, runningSince: at, steps: 0 }))
289 alerts.turnStartUsd = (await $.session.usage()).cost?.usd ?? null
290 }
291 return next(e)
292 })
293
294 on('turn.complete', async ($, e, next) => {
295 const done = await next(e)
296 await cacheTaxTurnComplete($, cacheTax, e)
297 if (e.agentId === undefined) {
298 await update($, turn, t => ({ ...t, runningSince: null, lastMs: e.durationMs }))
299 await measured($, await $.session.usage())
300 const costly = turnCostAlert(alerts, (await read($, snap)).costUsd)
301 const prefs = await read($, settings)
302 if (costly !== null && prefs.alerts) say($, costly)
303 await raiseAlerts($, alerts)
304 // An interrupted turn, or one an API error ended, did not finish: nothing to announce.
305 if (prefs.notify && !e.isAborted && e.reason !== 'error' && e.durationMs >= NOTIFY_AFTER_MS) {
306 await notifyDone($, e.durationMs, e.reason === 'refusal' ? 'the model declined' : e.answer)
307 }
308 } else {
309 const at = await $.clock.now()
310 await update($, agents, touchAgent(e.agentId, at, r => ({ ...r, lastSeen: at })))
311 }
312 return done
313 })
314
315 on('turn.step', async function* ($, e, next) {
316 await cacheTaxTurnStep($, cacheTax, e)
317 if (e.agentId === undefined) {
318 await update($, turn, t => ({ ...t, steps: e.index + 1 }))
319 await update($, snap, s => ({
320 ...s,
321 model: e.model,
322 effort: e.effort === undefined ? null : String(e.effort),
323 }))
324 }
325
326 const result = yield* next(e)
327
328 const u = result.usage
329 if (u !== null) {
330 const at = await $.clock.now()
331 const price = priceOf(u.model, promptOf(u))
332 const saved = price === null ? null : cacheSavings(u, price)
333 await update($, savings, v =>
334 saved === null
335 ? { ...v, unpriced: v.unpriced + 1 }
336 : { ...v, readsUsd: v.readsUsd + saved.readsUsd, premiumUsd: v.premiumUsd + saved.premiumUsd },
337 )
338 if (e.agentId === undefined) {
339 await update($, tokens, t => ({
340 ...t,
341 main: addUsage(t.main, u),
342 last: {
343 model: u.model,
344 at,
345 input: u.input_tokens,
346 output: u.output_tokens,
347 cacheRead: u.cache_read_input_tokens,
348 cacheWrite: u.cache_creation_input_tokens,
349 },
350 }))
351 } else {
352 const spent = u.input_tokens + u.output_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
353 await update($, tokens, t => ({ ...t, agents: addUsage(t.agents, u) }))
354 await update($, agents, touchAgent(e.agentId, at, r => ({ ...r, tokens: r.tokens + spent, lastSeen: at })))
355 }
356 }
357
358 return result
359 })
360
361 on('tool.call', async ($, e, next) => {
362 const at = await $.clock.now()
363 const id = e.tool_use_id
364 const name = String(e.tool)
365 await update($, tools, t => ({
366 ...t,
367 running: [...t.running.filter(r => r.id !== id), { id, tool: name, since: at, agentId: e.agentId }].slice(-50),
368 }))
369 if (e.agentId !== undefined) {
370 await update($, agents, touchAgent(e.agentId, at, r => ({ ...r, tools: r.tools + 1, lastTool: name, lastSeen: at })))
371 }
372
373 let isError = true
374 try {
375 const ran = await next(e)
376 isError = ran.isError === true || typeof ran.deny === 'string'
377 return ran
378 } finally {
379 await update($, tools, t => ({
380 counts: { ...t.counts, [name]: (t.counts[name] ?? 0) + 1 },
381 errors: t.errors + (isError ? 1 : 0),
382 running: t.running.filter(r => r.id !== id),
383 }))
384 }
385 }).catch(($, e, next) => next(e))
386
387 on('session.compact', async ($, e, next) => {
388 const done = await next(e)
389 // A precompute installs nothing: the conversation is not compacted until a later dispatch uses it.
390 if (e.trigger !== 'precompute') {
391 await update($, turn, t => ({ ...t, compactions: t.compactions + 1 }))
392 await cacheTaxSessionCompact($, cacheTax, e)
393 }
394 return done
395 }).catch(($, e, next) => next(e))
396
397 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
398 startTicker($)
399 const { Box, Button, Text } = $.ui.resolve(e)
400 const [s, tk, t, tl, ag, tickAt, ct, pc, sv, prefs] = await Promise.all([
401 read($, snap),
402 read($, tokens),
403 read($, turn),
404 read($, tools),
405 read($, agents),
406 read($, now),
407 read($, cacheTaxView),
408 read($, pace),
409 read($, savings),
410 read($, settings),
411 ])
412 // The tick redraws the pane each second; the live clock keeps a pane drawn again after a while hidden from showing an old time.
413 const at = Math.max(tickAt, await $.clock.now())
414 const cols = Math.max(24, e.props.bodyColumns - GUTTER)
415 const barWidth = Math.max(6, Math.min(20, cols - 26))
416 const half = Math.floor(cols / 2)
417
418 const head = (children: string) => (
419 <Box marginTop={1}>
420 <Text bold color="claude">
421 {children}
422 </Text>
423 </Box>
424 )
425 const row = (label: string, value: string, color?: string) => (
426 <Box>
427 <Box width={13} flexShrink={0}>
428 <Text dimColor>{label}</Text>
429 </Box>
430 <Text color={color} wrap="truncate-end">
431 {value}
432 </Text>
433 </Box>
434 )
435
436 // Model and context
437 const model = tk.last?.model ?? s.model
438 const ctxPct = s.contextPercent
439
440 // Cache
441 const last = tk.last
442 const lastHit = last === null ? null : hitRate(last)
443 const allHit = hitRate({
444 input: tk.main.input + tk.agents.input,
445 cacheRead: tk.main.cacheRead + tk.agents.cacheRead,
446 cacheWrite: tk.main.cacheWrite + tk.agents.cacheWrite,
447 })
448 const age = last === null ? null : at - last.at
449 // Warm or cold by the clock now, not as of cache-tax's last change: the cache lapses with nothing happening.
450 const netSaved = sv.readsUsd - sv.premiumUsd
451 const cacheState = ct === null || ct.expiresAt === null ? 'unknown' : at >= ct.expiresAt ? 'cold' : 'warm'
452
453 // Pace
454 const burn = perHour(pc.cost, at)
455
456 // Tools
457 const totalCalls = Object.values(tl.counts).reduce((a, b) => a + b, 0)
458 const top = Object.entries(tl.counts)
459 .sort((a, b) => b[1] - a[1])
460 .slice(0, 5)
461 .map(([n, c]) => `${n} ${c}`)
462 .join(' · ')
463
464 // Agents
465 const active = ag.filter(a => ACTIVE.has(a.status))
466 const finished = ag.filter(a => !ACTIVE.has(a.status))
467 const shownAgents = [...active, ...finished.slice(-Math.max(0, 6 - active.length))]
468
469 const both = {
470 input: tk.main.input + tk.agents.input,
471 output: tk.main.output + tk.agents.output,
472 cacheRead: tk.main.cacheRead + tk.agents.cacheRead,
473 cacheWrite: tk.main.cacheWrite + tk.agents.cacheWrite,
474 }
475
476 return (
477 <Box flexDirection="column" width={cols + GUTTER} paddingLeft={GUTTER}>
478 <Text bold>Session</Text>
479 {row("Model", model ?? 'not seen yet')}
480 {s.effort !== null && row("Effort", s.effort)}
481 {row("Status", t.runningSince === null ? 'idle' : `working ${duration(at - t.runningSince)} · step ${t.steps}`, t.runningSince === null ? undefined : 'warning')}
482 {row("Uptime", s.startedAt === null ? '—' : duration(at - s.startedAt))}
483 {row("Turns", `${t.count}${t.lastMs === null ? '' : ` · last ${duration(t.lastMs)}`}${t.compactions > 0 ? ` · ${t.compactions} compacted` : ''}`)}
484 {s.costUsd !== null && row("Cost", usd(s.costUsd))}
485 {burn !== null && row("Burn rate", `${usd(burn)} an hour`)}
486
487 {head('Context')}
488 {ctxPct === null ? (
489 <Text dimColor>No reading until the next response.</Text>
490 ) : (
491 <>
492 <Box>
493 <Text color={levelColor(ctxPct)}>{bar(ctxPct, barWidth)}</Text>
494 <Text> {ctxPct}%</Text>
495 </Box>
496 <Text dimColor>
497 {tok(s.contextTokens ?? 0)} of {tok(s.contextWindow ?? 0)} · {tok(Math.max(0, (s.contextWindow ?? 0) - (s.contextTokens ?? 0)))} free
498 </Text>
499 </>
500 )}
501
502 {head('Usage limits')}
503 {s.rateLimits.length === 0 ? (
504 <Text dimColor>No reading (API key, or no response yet).</Text>
505 ) : (
506 s.rateLimits.map(r => (
507 <Box key={r.kind}>
508 <Box width={13} flexShrink={0}>
509 <Text dimColor>{limitName(r.kind)}</Text>
510 </Box>
511 <Text color={levelColor(r.percentUsed)}>{bar(r.percentUsed, barWidth)}</Text>
512 <Text> {Math.round(r.percentUsed)}%</Text>
513 </Box>
514 ))
515 )}
516
517 {head('Tokens')}
518 {row("Input", `${tok(both.input)} new · ${tok(both.cacheRead)} cached`)}
519 {row("Cache write", tok(both.cacheWrite))}
520 {row("Output", tok(both.output))}
521 {row("Requests", `${tk.main.requests} main · ${tk.agents.requests} agents`)}
522 {tk.agents.requests > 0 &&
523 row("Agents' share", `${tok(tk.agents.input + tk.agents.cacheRead + tk.agents.cacheWrite + tk.agents.output)} tokens`)}
524
525 {head('Prompt cache')}
526 {last === null ? (
527 <Text dimColor>No request yet.</Text>
528 ) : (
529 <Box flexDirection="column">
530 {row("Last request", `${lastHit ?? 0}% hit`, lastHit !== null && lastHit >= 70 ? 'success' : 'warning')}
531 <Text dimColor>
532 {' '}
533 read {tok(last.cacheRead)} · write {tok(last.cacheWrite)} · new {tok(last.input)}
534 </Text>
535 {row("Session hit", allHit === null ? '—' : `${allHit}%`)}
536 {row("Idle for", age === null ? '—' : duration(age))}
537 {(sv.readsUsd > 0 || sv.premiumUsd > 0) && (
538 <>
539 {row(
540 "Cache saved",
541 netSaved >= 0 ? `${usd(netSaved)} net` : `-${usd(-netSaved)} net`,
542 netSaved >= 0 ? 'success' : 'warning',
543 )}
544 <Text dimColor wrap="truncate-end">
545 {' '}
546 {usd(sv.readsUsd)} on reads · {usd(sv.premiumUsd)} on writes
547 {sv.unpriced > 0 ? ` · ${sv.unpriced} unpriced` : ''}
548 </Text>
549 </>
550 )}
551 </Box>
552 )}
553
554 {head('Cache tax')}
555 {ct === null ? (
556 <Text dimColor>Starting…</Text>
557 ) : (
558 <Box flexDirection="column">
559 {row(
560 'Cache',
561 cacheState === 'unknown'
562 ? 'unknown until the next turn'
563 : cacheState === 'cold'
564 ? `cold for ${duration(at - (ct.expiresAt ?? at))}`
565 : `warm, ${duration((ct.expiresAt ?? at) - at)} left`,
566 cacheState === 'warm' ? 'success' : cacheState === 'cold' ? 'error' : undefined,
567 )}
568 {ct.ctx > 0 &&
569 row(
570 'Cold cost',
571 `${ct.coldUsd === null ? 'n/a' : usd(ct.coldUsd)} to re-write${ct.warmUsd === null ? '' : ` · warm ${usd(ct.warmUsd)}`}`,
572 )}
573 {row(
574 'Keepwarm',
575 ct.stopped !== null
576 ? `stopped: ${ct.stopped}`
577 : ct.deadline === null
578 ? ct.always ? 'off until next session (always)' : 'off · /keepwarm to arm'
579 : `on, ${duration(ct.deadline - at)} left${ct.always ? ' (always)' : ''}`,
580 ct.stopped !== null ? 'warning' : ct.deadline === null ? undefined : 'success',
581 )}
582 {ct.deadline !== null &&
583 row(
584 'Next ping',
585 ct.nextPingAt !== null && cacheState === 'warm' ? `in ${duration(Math.max(0, ct.nextPingAt - at))}` : 'after the next turn',
586 )}
587 {ct.lastPing !== null &&
588 row(
589 'Last ping',
590 `read ${tok(ct.lastPing.read)} · ${ct.lastPing.usd === null ? 'n/a' : usd(ct.lastPing.usd)} · ${duration(at - ct.lastPing.at)} ago`,
591 )}
592 {row('Guard', ct.guard === 'refuse' ? 'refuse a cold send once' : 'warn only')}
593 {row(
594 'Cold writes',
595 `${ct.coldWrites} paid${ct.coldWrites > 0 ? `, ${usd(ct.paidUsd)}` : ''}`,
596 ct.coldWrites > 0 ? 'warning' : undefined,
597 )}
598 </Box>
599 )}
600
601 {head('Tools')}
602 {row("Calls", `${totalCalls}${tl.errors > 0 ? ` · ${tl.errors} failed` : ''}`)}
603 {top !== '' && <Text dimColor wrap="wrap">{top}</Text>}
604 {tl.running.map(r => (
605 <Text color="warning" wrap="truncate-end" key={r.id}>
606 ▸ {r.tool} {duration(at - r.since)}
607 {r.agentId === undefined ? '' : ' (agent)'}
608 </Text>
609 ))}
610
611 {head('Agents')}
612 {shownAgents.length === 0 ? (
613 <Text dimColor>None this session.</Text>
614 ) : (
615 <Box flexDirection="column">
616 <Text dimColor>
617 {active.length} running · {finished.length} finished
618 </Text>
619 {shownAgents.map(a => {
620 const isActive = ACTIVE.has(a.status)
621 const color = isActive ? 'warning' : a.status === 'completed' || a.status === 'idle' ? 'success' : 'error'
622 return (
623 <Box flexDirection="column" key={a.id}>
624 <Text wrap="truncate-end">
625 <Text color={color}>{isActive ? '●' : a.status === 'completed' || a.status === 'idle' ? '✓' : '✗'}</Text>{' '}
626 <Text bold>{a.type}</Text> {a.description}
627 </Text>
628 <Text dimColor wrap="truncate-end">
629 {' '}
630 {a.status} · {duration((isActive ? at : a.lastSeen) - a.firstSeen)} · {a.tools} tools · {tok(a.tokens)} tok
631 {isActive && a.lastTool !== null ? ` · ${a.lastTool}` : ''}
632 </Text>
633 </Box>
634 )
635 })}
636 </Box>
637 )}
638
639 {head('Controls')}
640 <Box>
641 <Box width={half} flexShrink={0}>
642 {ct === null ? (
643 <Text dimColor>k: keepwarm …</Text>
644 ) : (
645 <Button
646 key="keepwarm"
647 plain
648 hotkey="k"
649 label={`keepwarm ${ct.deadline === null ? 'off' : 'on'}`}
650 onPress={() => keepwarmToggle($, cacheTax)}
651 />
652 )}
653 </Box>
654 {ct !== null && (
655 <Button
656 key="guard"
657 plain
658 hotkey="g"
659 label={`guard ${ct.guard}`}
660 onPress={() => setGuard($, cacheTax, cacheTax.guard === 'refuse' ? 'warn' : 'refuse')}
661 />
662 )}
663 </Box>
664 <Box>
665 <Box width={half} flexShrink={0}>
666 <Button key="alerts" plain hotkey="a" label={`alerts ${prefs.alerts ? 'on' : 'off'}`} onPress={() => toggleSetting($, 'alerts')} />
667 </Box>
668 <Button key="notify" plain hotkey="n" label={`notify ${prefs.notify ? 'on' : 'off'}`} onPress={() => toggleSetting($, 'notify')} />
669 </Box>
670 <Text dimColor>Click, or ctrl+x tab then the key.</Text>
671 {s.version !== null && (
672 <Box marginTop={1}>
673 <Text dimColor>Claude Code {s.version}</Text>
674 </Box>
675 )}
676 </Box>
677 )
678 })
679}
680hooks/alerts.ts 80 lines1import type { Pace, Snapshot } from '../types'
2import { duration, limitName, tokens as tok, usd } from './format'
3import { forecast, perHour } from './pace'
4
5/** Context fill that warns, and the fill it must drop under (a compaction) to warn again. */
6export const CONTEXT_ALERT = 80
7const CONTEXT_REARM = 70
8/** A usage limit this full warns, once per window. */
9export const LIMIT_ALERT = 90
10/** A forecast that runs a limit out within this long, before it resets, warns; at twice it re-arms. */
11export const FORECAST_ALERT_MS = 60 * 60 * 1000
12/** A main turn that costs this much warns. */
13export const TURN_ALERT_USD = 5
14
15export type Alerts = {
16 context: boolean
17 limits: Set<string>
18 forecasts: Set<string>
19 /** The session's cost when the running main turn started. */
20 turnStartUsd: number | null
21}
22
23export const freshAlerts = (): Alerts => ({
24 context: false,
25 limits: new Set(),
26 forecasts: new Set(),
27 turnStartUsd: null,
28})
29
30/** The lines to log for what crossed a threshold since the last check; each warns once until it re-arms. */
31export function checkAlerts(a: Alerts, s: Snapshot, pace: Pace, now: number): string[] {
32 const lines: string[] = []
33
34 const ctx = s.contextPercent
35 if (ctx !== null) {
36 if (ctx >= CONTEXT_ALERT && !a.context) {
37 a.context = true
38 lines.push(
39 `context is ${ctx}% full (${tok(s.contextTokens ?? 0)} of ${tok(s.contextWindow ?? 0)}). ` +
40 'Compaction is coming; /compact at a natural break keeps the timing yours.',
41 )
42 } else if (ctx < CONTEXT_REARM) a.context = false
43 }
44
45 for (const r of s.rateLimits) {
46 const name = limitName(r.kind)
47 const resetsAt = r.resetsAt === undefined ? NaN : Date.parse(r.resetsAt)
48 const resetsIn = Number.isNaN(resetsAt) ? null : Math.max(0, resetsAt - now)
49 const resetText = resetsIn === null ? '' : `, resets in ${duration(resetsIn)}`
50
51 if (r.percentUsed >= LIMIT_ALERT) {
52 if (!a.limits.has(r.kind)) {
53 a.limits.add(r.kind)
54 lines.push(`the ${name} limit is ${r.percentUsed}% used${resetText}.`)
55 }
56 continue
57 }
58 a.limits.delete(r.kind)
59
60 const f = forecast(r.percentUsed, perHour(pace.limits[r.kind] ?? [], now), resetsIn)
61 const isSoon = f !== null && f.kind === 'full' && f.inMs < FORECAST_ALERT_MS
62 if (isSoon && !a.forecasts.has(r.kind)) {
63 a.forecasts.add(r.kind)
64 const early = resetsIn === null ? '' : `, ${duration(resetsIn - f.inMs)} before it resets`
65 lines.push(`at this pace the ${name} limit runs out in ${duration(f.inMs)}${early}.`)
66 } else if (f === null || f.kind !== 'full' || f.inMs >= 2 * FORECAST_ALERT_MS) a.forecasts.delete(r.kind)
67 }
68
69 return lines
70}
71
72/** The line for a main turn that cost `TURN_ALERT_USD` or more, if this one did. */
73export function turnCostAlert(a: Alerts, costUsd: number | null): string | null {
74 const start = a.turnStartUsd
75 a.turnStartUsd = null
76 if (start === null || costUsd === null) return null
77 const spent = costUsd - start
78 return spent >= TURN_ALERT_USD ? `that turn cost ${usd(spent)} (session ${usd(costUsd)}).` : null
79}
80hooks/format.ts 73 lines1import type { Usage } from '../types'
2
3export const emptyUsage = (): Usage => ({
4 input: 0,
5 output: 0,
6 cacheRead: 0,
7 cacheWrite: 0,
8 requests: 0,
9})
10
11/** 950 -> "950", 12_345 -> "12.3k", 1_250_000 -> "1.25M". */
12export function tokens(n: number): string {
13 if (n < 1000) return String(Math.round(n))
14 if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 2 : 1).replace(/\.?0+$/, '')}k`
15 return `${(n / 1_000_000).toFixed(2).replace(/\.?0+$/, '')}M`
16}
17
18/** 45_000 -> "45s", 192_000 -> "3m12s", 8_040_000 -> "2h14m", 2 days -> "2d3h". */
19export function duration(ms: number): string {
20 const s = Math.max(0, Math.floor(ms / 1000))
21 if (s < 60) return `${s}s`
22 const m = Math.floor(s / 60)
23 if (m < 60) return `${m}m${String(s % 60).padStart(2, '0')}s`
24 const h = Math.floor(m / 60)
25 if (h < 48) return `${h}h${String(m % 60).padStart(2, '0')}m`
26 return `${Math.floor(h / 24)}d${h % 24}h`
27}
28
29/** A bar `width` cells wide, filled to `percent` (clamped to 0-100). */
30export function bar(percent: number, width: number): string {
31 const w = Math.max(1, width)
32 const filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * w)
33 return '█'.repeat(filled) + '░'.repeat(w - filled)
34}
35
36/** The theme colour for how full something is: green, then amber past 70, red past 90. */
37export function levelColor(percent: number): 'success' | 'warning' | 'error' {
38 if (percent >= 90) return 'error'
39 if (percent >= 70) return 'warning'
40 return 'success'
41}
42
43/** Share of the input the prompt cache served: read over all input, 0-100; null with no input. */
44export function hitRate(u: { input: number; cacheRead: number; cacheWrite: number }): number | null {
45 const all = u.input + u.cacheRead + u.cacheWrite
46 return all === 0 ? null : Math.round((u.cacheRead / all) * 100)
47}
48
49export function addUsage(
50 into: Usage,
51 u: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number },
52): Usage {
53 return {
54 input: into.input + u.input_tokens,
55 output: into.output + u.output_tokens,
56 cacheRead: into.cacheRead + u.cache_read_input_tokens,
57 cacheWrite: into.cacheWrite + u.cache_creation_input_tokens,
58 requests: into.requests + 1,
59 }
60}
61
62const LIMIT_NAMES: Record<string, string> = {
63 five_hour: '5-hour',
64 seven_day: '7-day',
65 seven_day_opus: '7-day Opus',
66 seven_day_sonnet: '7-day Sonnet',
67 spend_limit: 'Spend',
68}
69
70export const limitName = (kind: string): string => LIMIT_NAMES[kind] ?? kind.replace(/_/g, ' ')
71
72export const usd = (n: number): string => `$${n.toFixed(2)}`
73hooks/cache-tax.ts 557 lines1// Ported from cache-tax 2.2.1 (https://github.com/karanb192/cache-tax),
2// Copyright (c) 2026 Karan Bansal, MIT License: see LICENSE-cache-tax.
3// Changes: the icon band above the prompt is gone (the session-stats pane
4// shows the same state), and each state change is published to the pane.
5import { atom, update } from 'claude-code'
6import type { EngineInterface, On, SessionCompactInput, TurnCompleteInput, TurnStepInput } from 'claude-code'
7
8import type { CacheTaxView } from '../types'
9
10const TTL_MS = 60 * 60 * 1000
11const PING_AFTER_MS = 50 * 60 * 1000
12const MIN_PING_MS = 60 * 1000
13const AUTO_WARM_MS = 3 * 60 * 60 * 1000
14const DEFAULT_WINDOW_MS = 6 * 60 * 60 * 1000
15const BIG_TOKENS = 50000
16const PING_PROMPT = 'Reply with the single word: warm'
17const KEY_DEADLINE = 'deadline'
18const KEY_EVERY = 'every'
19const KEY_GUARD = 'guard'
20const KEY_ALWAYS = 'always'
21
22const cacheTaxView = atom({ plugin: 'session-stats', key: 'cacheTax' } as const, null as CacheTaxView | null)
23
24// $ per million tokens, [cache read, 1h cache write, output]: Claude API list prices from
25// https://platform.claude.com/docs/en/about-claude/pricing, read 2026-10-21. The base
26// input rate is half the 1h write rate on every row.
27// Longer family names first: a model id matches the first row it contains.
28const PRICES: Array<[string, number, number, number]> = [
29 ['fable-5-1', 0.25, 20, 50],
30 ['mythos-5-1', 0.25, 20, 50],
31 ['fable-5', 1, 20, 50],
32 ['mythos-5', 1, 20, 50],
33 ['opus-5-5', 0.2, 8, 20],
34 ['opus-5', 0.5, 10, 25],
35 ['opus-4-8', 0.5, 10, 25],
36 ['opus-4-7', 0.5, 10, 25],
37 ['opus-4-6', 0.5, 10, 25],
38 ['opus-4-5', 0.5, 10, 25],
39 // Opus 4 and 4.1, retired on the Claude API.
40 ['opus-4', 1.5, 30, 75],
41 ['sonnet-5-5', 0.1, 4, 10],
42 ['sonnet-5', 0.2, 4, 10],
43 ['sonnet-4', 0.3, 6, 15],
44 // Older Sonnets, not on the pricing page: priced as Sonnet 4.
45 ['sonnet', 0.3, 6, 15],
46 ['haiku-5-5', 0.01, 0.2, 0.5],
47 ['haiku-4', 0.1, 2, 5],
48 ['haiku-3-5', 0.08, 1.6, 4],
49 ['3-5-haiku', 0.08, 1.6, 4],
50 // Older Haikus, not on the pricing page: priced as Haiku 4.5.
51 ['haiku', 0.1, 2, 5],
52]
53
54// A request whose prompt (input, cache reads and cache writes together) is over `over`
55// tokens pays these prices instead, the whole request at the higher rate.
56const LONG_PROMPT: Array<[string, number, [number, number, number]]> = [
57 ['haiku-5-5', 100_000, [0.05, 1, 2.5]],
58]
59
60type PingRecord = { at: number; read: number; write: number; usd: number | null; warm: boolean }
61type Miss = { at: number; tokens: number; usd: number | null }
62type GuardMode = 'refuse' | 'warn'
63
64export type State = {
65 sid: string
66 deadline: number
67 every: number
68 always: boolean
69 lastRequestAt: number
70 lastModel: string | null
71 ctx: number
72 compacted: boolean
73 guard: GuardMode
74 ackedAt: number
75 coldWritePending: boolean
76 misses: Miss[]
77 pending: { cancel: () => void } | null
78 last: PingRecord | null
79 stopped: string | null
80}
81
82/** The prices of `model` for a request whose prompt is `promptTokens` long; null for a model with no row. */
83export function priceOf(model: string | null, promptTokens = 0): [number, number, number] | null {
84 const m = (model ?? '').toLowerCase().replace(/[\s.]+/g, '-')
85 const family = PRICES.find(([f]) => m.includes(f))
86 if (family === undefined) return null
87 const long = LONG_PROMPT.find(([f, over]) => f === family[0] && promptTokens > over)
88 return long === undefined ? [family[1], family[2], family[3]] : long[2]
89}
90
91/** A response's prompt length: what the long-prompt tier is measured on. */
92export function promptOf(u: { input_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }): number {
93 return u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
94}
95
96export function parseDuration(text: string): number | null {
97 const m = /^(?:(\d+)h)?(?:(\d+)m)?$/.exec(text.trim())
98 if (!m || (m[1] === undefined && m[2] === undefined)) return null
99 return (Number(m[1] ?? 0) * 60 + Number(m[2] ?? 0)) * 60 * 1000
100}
101
102export function fmtDuration(ms: number): string {
103 const total = Math.max(0, Math.round(ms / 60000))
104 const h = Math.floor(total / 60)
105 const m = total % 60
106 if (h >= 48) return `${Math.floor(h / 24)}d ${h % 24}h`
107 return h > 0 ? `${h}h${String(m).padStart(2, '0')}m` : `${m}m`
108}
109
110function fmtUsd(usd: number | null): string {
111 return usd == null ? 'n/a' : '$' + (usd >= 100 ? usd.toFixed(0) : usd.toFixed(2))
112}
113
114function fmtTok(n: number): string {
115 return n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1000 ? Math.round(n / 1000) + 'k' : String(n)
116}
117
118function coldUsd(s: State): number | null {
119 const price = priceOf(s.lastModel, s.ctx)
120 return price ? s.ctx * price[1] / 1e6 : null
121}
122
123function warmUsd(s: State): number | null {
124 const price = priceOf(s.lastModel, s.ctx)
125 return price ? s.ctx * price[0] / 1e6 : null
126}
127
128/** Everything a fork bills: the cache read, any cache write, uncached input at the base rate (half the 1h write rate), and the output. */
129function pingUsd(u: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }, price: [number, number, number]): number {
130 return (u.cache_read_input_tokens * price[0] + u.cache_creation_input_tokens * price[1] + u.input_tokens * price[1] / 2 + u.output_tokens * price[2]) / 1e6
131}
132
133/** The read-only upper bound: pings at the cache-read rate that cost as much as one cold write of the same context. */
134function breakEvenPings(s: State): number | null {
135 const price = priceOf(s.lastModel, s.ctx)
136 if (!price || s.ctx <= 0) return null
137 return Math.floor(price[1] / price[0])
138}
139
140function isCold(s: State, now: number): boolean {
141 return s.lastRequestAt > 0 && !s.compacted && now - s.lastRequestAt >= TTL_MS
142}
143
144function guardText(s: State, now: number): string {
145 const price = priceOf(s.lastModel, s.ctx)
146 const rate = price ? `$${price[1]}/MTok` : 'the cache-write rate'
147 const warm = warmUsd(s)
148 return `the prompt cache went cold ${fmtDuration(now - s.lastRequestAt - TTL_MS)} ago. Sending this re-writes ` +
149 `up to ${s.ctx.toLocaleString('en-US')} tokens at ${rate} = ${fmtUsd(coldUsd(s))}` +
150 (warm == null ? '' : ` (a warm turn would have cost ${fmtUsd(warm)})`) + '.'
151}
152
153export type ResumeFields = {
154 source: string
155 model?: string
156 context_tokens?: number
157 seconds_since_last_response?: number
158 prompt_cache_likely_expired?: boolean
159 estimated_cache_write_usd?: number
160}
161
162/** /clear starts a new conversation in the same process; nothing priced before it still exists. */
163export function resetForClear(s: State) {
164 s.ctx = 0
165 s.lastRequestAt = 0
166 s.compacted = false
167 s.ackedAt = 0
168 s.coldWritePending = false
169 s.misses = []
170 disarm(s)
171}
172
173/** Applies a resumed session's fields to the state; returns the line to log, if any. */
174export function seedFromResume(s: State, e: ResumeFields, now: number): string | null {
175 if (e.source !== 'resume' && e.source !== 'fork') return null
176 if (typeof e.context_tokens === 'number' && e.context_tokens > 0) s.ctx = e.context_tokens
177 if (typeof e.seconds_since_last_response === 'number') s.lastRequestAt = now - e.seconds_since_last_response * 1000
178 if (typeof e.model === 'string') s.lastModel = e.model
179 s.compacted = false
180 if (e.prompt_cache_likely_expired !== true || s.ctx < BIG_TOKENS) return null
181 const usd = typeof e.estimated_cache_write_usd === 'number' ? fmtUsd(e.estimated_cache_write_usd) : fmtUsd(coldUsd(s))
182 return `resuming cold. The first message re-writes ${s.ctx.toLocaleString('en-US')} tokens, about ${usd}. /clear and paste a summary if you only need the conclusions.`
183}
184
185function statusText(s: State, now: number): string | undefined {
186 if (s.stopped) return `keepwarm stopped: ${s.stopped}`
187 if (!s.deadline) return undefined
188 const pingText = s.last ? ` · last ping read ${fmtTok(s.last.read)} ${fmtUsd(s.last.usd)}` : ''
189 const nextText = !s.lastRequestAt ? ' · waiting for the first turn'
190 : s.compacted ? ' · waiting for the first turn after compaction'
191 : isCold(s, now) ? ` · cold now, first ping ${fmtDuration(s.every)} after the next turn`
192 : ` · ping in ${fmtDuration(s.lastRequestAt + s.every - now)}`
193 return `keepwarm ${fmtDuration(s.deadline - now)} left${nextText}${pingText}`
194}
195
196/** What the session-stats pane draws: the same clock and prices as the card; the pane works out warm or cold from `expiresAt` as it draws. */
197export function viewOf(s: State, now: number): CacheTaxView {
198 const isKnown = s.lastRequestAt > 0 && !s.compacted
199 return {
200 lastRequestAt: s.lastRequestAt,
201 expiresAt: isKnown ? s.lastRequestAt + TTL_MS : null,
202 ctx: s.ctx,
203 coldUsd: coldUsd(s),
204 warmUsd: warmUsd(s),
205 deadline: s.deadline > now ? s.deadline : null,
206 nextPingAt: s.deadline > 0 && isKnown && !isCold(s, now) ? s.lastRequestAt + s.every : null,
207 stopped: s.stopped,
208 lastPing: s.last === null ? null : { at: s.last.at, read: s.last.read, usd: s.last.usd },
209 guard: s.guard,
210 always: s.always,
211 coldWrites: s.misses.length,
212 paidUsd: s.misses.reduce((a, m) => a + (m.usd ?? 0), 0),
213 }
214}
215
216async function updateStatus($: EngineInterface, s: State, now: number) {
217 $.ui.status(statusText(s, now))
218 const view = viewOf(s, now)
219 await update($, cacheTaxView, () => view)
220}
221
222function disarm(s: State) {
223 if (s.pending) s.pending.cancel()
224 s.pending = null
225}
226
227// The window and its ping period belong to the session that armed them, so a
228// second session, or one resumed from another transcript, never inherits them
229// and cannot turn them off. The always switch and the guard mode stay global.
230function deadlineKey(s: State): string {
231 return `${KEY_DEADLINE}:${s.sid}`
232}
233
234function everyKey(s: State): string {
235 return `${KEY_EVERY}:${s.sid}`
236}
237
238/** Clears this session's own dead window and the bare keys a store written before 2.1.1 still holds. Other sessions' keys are never touched: a read followed by a delete cannot be made atomic against their renewal. */
239async function prune($: EngineInterface, s: State, now: number) {
240 for (const key of [KEY_DEADLINE, deadlineKey(s)]) {
241 const deadline = await $.store.get(key)
242 if (deadline === undefined) continue
243 if (typeof deadline === 'number' && deadline > now) continue
244 await $.store.delete(key)
245 await $.store.delete(KEY_EVERY + key.slice(KEY_DEADLINE.length))
246 }
247}
248
249async function stop($: EngineInterface, s: State, why: string | null, forgetAlways = false) {
250 s.deadline = 0
251 s.every = PING_AFTER_MS
252 s.stopped = why
253 disarm(s)
254 await $.store.delete(deadlineKey(s))
255 await $.store.delete(everyKey(s))
256 if (forgetAlways) {
257 s.always = false
258 await $.store.delete(KEY_ALWAYS)
259 }
260 await updateStatus($, s, await $.clock.now())
261}
262
263async function arm($: EngineInterface, s: State) {
264 disarm(s)
265 if (!s.deadline) return updateStatus($, s, await $.clock.now())
266 const now = await $.clock.now()
267 if (now >= s.deadline) return stop($, s, null)
268 // A cold window still needs expiry cleanup, but must not send a model request.
269 if (s.lastRequestAt && !s.compacted && !isCold(s, now)) {
270 const untilCold = s.lastRequestAt + TTL_MS - now
271 const delay = Math.min(s.deadline - now, untilCold, Math.max(1000, s.lastRequestAt + s.every - now))
272 s.pending = $.clock.after(delay, () => { void ping($, s) })
273 } else {
274 s.pending = $.clock.after(s.deadline - now, () => { void arm($, s) })
275 }
276 await updateStatus($, s, now)
277}
278
279async function ping($: EngineInterface, s: State) {
280 s.pending = null
281 if (!s.deadline) return
282 const now = await $.clock.now()
283 if (now >= s.deadline) return arm($, s)
284 // A turn in the meantime re-armed the timer; this callback is stale.
285 if (isCold(s, now)) return arm($, s)
286 if (now - s.lastRequestAt < s.every - 1000) return
287 let reply
288 try {
289 reply = await $.model.fork({ prompt: PING_PROMPT })
290 } catch (err) {
291 return stop($, s, `the ping failed, ${err instanceof Error ? err.message : String(err)}`)
292 }
293 // Older Claude Code releases answered null instead of a failure object.
294 if ((reply as unknown) === null) return stop($, s, 'the engine did not send the ping, either the snapshot was cold or the API call failed')
295 if (reply.isAnswered === false) {
296 const reason = reply.reason === 'nothing-to-fork' ? 'no conversation to warm yet'
297 : reply.reason === 'api-error' ? `the API call failed${reply.status === null ? '' : ` (${reply.status})`}`
298 : reply.reason === 'aborted' ? 'the ping was interrupted'
299 : 'the ping returned no text'
300 return stop($, s, reason)
301 }
302 const u = reply.usage
303 const price = priceOf(s.lastModel, promptOf(u))
304 // A warm ping reads the prefix and writes only its own message; a write of a tenth of the read or more means the prefix broke.
305 const warm = u.cache_read_input_tokens > 0 && u.cache_creation_input_tokens < 0.1 * u.cache_read_input_tokens
306 const usd = price ? pingUsd(u, price) : null
307 s.last = { at: now, read: u.cache_read_input_tokens, write: u.cache_creation_input_tokens, usd, warm }
308 if (!warm) return stop($, s, `the ping read ${fmtTok(u.cache_read_input_tokens)} and wrote ${fmtTok(u.cache_creation_input_tokens)} tokens (${fmtUsd(usd)}), the cache was already gone`)
309 s.lastRequestAt = now
310 await arm($, s)
311}
312
313/** The reply to an arming command; on a cold cache it says when the first ping can come. */
314function armedText(s: State, now: number, windowMs: number): string {
315 if (isCold(s, now)) return `keepwarm on for ${fmtDuration(windowMs)}. The cache is cold now, so the first ping comes ${fmtDuration(s.every)} after the next turn`
316 return `keepwarm on for ${fmtDuration(windowMs)}, a ping ${fmtDuration(s.every)} after each idle stretch keeps the cache read, not re-written`
317}
318
319async function startWindow($: EngineInterface, s: State, windowMs: number, every: number) {
320 const now = await $.clock.now()
321 s.every = every
322 if (every === PING_AFTER_MS) await $.store.delete(everyKey(s))
323 else await $.store.set(everyKey(s), every)
324 s.deadline = now + windowMs
325 s.stopped = null
326 await $.store.set(deadlineKey(s), s.deadline)
327 await arm($, s)
328}
329
330/** Arms a six-hour window, as a bare /keepwarm does: the pane's keepwarm button. */
331export async function keepwarmOn($: EngineInterface, s: State) {
332 await startWindow($, s, DEFAULT_WINDOW_MS, PING_AFTER_MS)
333}
334
335/** Stops keepwarm and turns always off, as /keepwarm off does. */
336export async function keepwarmOff($: EngineInterface, s: State) {
337 await stop($, s, null, true)
338}
339
340/** Turns keepwarm off while a window is live, else arms one: the pane's keepwarm button. Read from the state and the clock now, not from a drawing, so a window past its end reads as off. */
341export async function keepwarmToggle($: EngineInterface, s: State) {
342 if (s.deadline > (await $.clock.now())) await keepwarmOff($, s)
343 else await keepwarmOn($, s)
344}
345
346/** Sets the guard mode and remembers it across sessions. */
347export async function setGuard($: EngineInterface, s: State, mode: GuardMode) {
348 s.guard = mode
349 await $.store.set(KEY_GUARD, mode)
350 await updateStatus($, s, await $.clock.now())
351}
352
353function card(s: State, now: number): string {
354 const lines: string[] = []
355 lines.push(`${s.lastModel ?? 'model not seen yet'}`)
356 if (s.compacted) lines.push('state reset by compaction, waiting for the first turn')
357 else if (!s.lastRequestAt) lines.push('state no request yet this session')
358 else if (isCold(s, now)) lines.push(`state COLD, last request ${fmtDuration(now - s.lastRequestAt)} ago`)
359 else lines.push(`state warm, ${fmtDuration(s.lastRequestAt + TTL_MS - now)} left`)
360 lines.push(`context ${s.ctx.toLocaleString('en-US')} tokens`)
361 lines.push(`cold cost ${fmtUsd(coldUsd(s))} to re-write it (warm turn ${fmtUsd(warmUsd(s))})`)
362 const always = s.always ? ' (always)' : ''
363 const idle = s.always ? 'off until the next session start, which arms 6h00m (always)' : 'off (/keepwarm to arm it for 6h00m)'
364 lines.push(`keepwarm ${s.deadline ? (statusText(s, now) ?? '').replace(/^keepwarm /, 'on, ') + always : s.stopped ? `stopped, ${s.stopped}${always}` : idle}`)
365 const pings = breakEvenPings(s)
366 if (pings != null) lines.push(`break-even up to ${pings} pings at the read rate cost one cold write, about ${fmtDuration(pings * s.every)} of idle at one ping per ${fmtDuration(s.every)}`)
367 lines.push(`guard ${s.guard === 'refuse' ? 'refuse once (/cache-tax guard warn to only show the price)' : 'warn only (/cache-tax guard refuse to be stopped once)'}`)
368 const paid = s.misses.reduce((a, m) => a + (m.usd ?? 0), 0)
369 lines.push(`session ${s.misses.length} cold write${s.misses.length === 1 ? '' : 's'} paid, ${fmtUsd(paid)}`)
370 return lines.join('\n')
371}
372
373export function freshState(): State {
374 return {
375 sid: '', deadline: 0, every: PING_AFTER_MS, always: false, lastRequestAt: 0, lastModel: null, ctx: 0, compacted: false,
376 guard: 'refuse', ackedAt: 0, coldWritePending: false, misses: [], pending: null, last: null, stopped: null,
377 }
378}
379
380/** After `next` at session start: restores the window, guard and switches, and registers the commands. */
381export async function cacheTaxSessionStart($: EngineInterface, s: State) {
382 s.sid = await $.session.id()
383 const now = await $.clock.now()
384 await prune($, s, now)
385 const saved = await $.store.get(deadlineKey(s))
386 const savedEvery = await $.store.get(everyKey(s))
387 const savedGuard = await $.store.get(KEY_GUARD)
388 s.deadline = typeof saved === 'number' && saved > now ? saved : 0
389 s.every = typeof savedEvery === 'number' && savedEvery >= MIN_PING_MS ? savedEvery : PING_AFTER_MS
390 s.guard = savedGuard === 'warn' ? 'warn' : 'refuse'
391 s.always = (await $.store.get(KEY_ALWAYS)) === true
392 // Always means a fresh default window every session, whatever the last one left behind.
393 if (s.always) await startWindow($, s, DEFAULT_WINDOW_MS, PING_AFTER_MS)
394 const usage = await $.session.usage()
395 if (usage.context.tokens) s.ctx = usage.context.tokens
396 await $.command.register({
397 name: 'keepwarm',
398 description: 'Keep the prompt cache warm: bare for 6h, a window such as 90m, always, off, or status (session-stats)',
399 argumentHint: '[6h [every 2m] | always | off | status]',
400 immediate: true,
401 })
402 await $.command.register({
403 name: 'cache-tax',
404 description: 'Prompt cache state, cold price and this session\'s cold writes; guard warn|refuse (session-stats)',
405 argumentHint: '[status | guard warn | guard refuse]',
406 immediate: true,
407 })
408 // The hook form of cache-tax ships a /cache-tax:status skill; both installed means two guards.
409 const commands = await $.command.list()
410 if (commands.some(c => c.name === 'cache-tax:status')) {
411 $.ui.log('the hook form (cache-tax@claude-code-hooks) is also installed, so a cold send is warned about or refused twice. Uninstall it, or /cache-tax guard warn here.')
412 }
413 await updateStatus($, s, now)
414}
415
416/** Before a step is sent: stamps the main loop's request time. */
417export async function cacheTaxTurnStep($: EngineInterface, s: State, e: TurnStepInput) {
418 if (!e.agentId) s.lastRequestAt = await $.clock.now()
419}
420
421/** After `next` at a turn's end: the context, a paid cold write, and the next ping. */
422export async function cacheTaxTurnComplete($: EngineInterface, s: State, e: TurnCompleteInput) {
423 if (e.agentId) return
424 const now = await $.clock.now()
425 // A sleeping host may deliver this turn before the expired window's timer.
426 if (s.deadline && now >= s.deadline) await stop($, s, null)
427 // turn.step stamps the exact request time; when no step of this turn did, the turn's end is the floor.
428 if (now - s.lastRequestAt > e.durationMs) s.lastRequestAt = now
429 s.compacted = false
430 s.ackedAt = 0
431 const u = e.usage
432 if (u) {
433 if (u.model) s.lastModel = u.model
434 const prev = s.ctx
435 // A turn's usage is its responses summed, so a ten-step turn reports ten
436 // reads of the context. The live window is the engine's figure; the sum
437 // is only the fallback for a host that reports no tokens.
438 const write = u.cache_creation_input_tokens
439 const live = (await $.session.usage()).context.tokens
440 s.ctx = live && live > 0 ? live : u.input_tokens + u.cache_read_input_tokens + write
441 const full = prev > 20000 && write >= 0.5 * prev
442 if (full || s.coldWritePending) {
443 const price = priceOf(s.lastModel, s.ctx)
444 const usd = price ? write * price[1] / 1e6 : null
445 s.misses.push({ at: now, tokens: write, usd })
446 if (s.deadline < now + AUTO_WARM_MS) {
447 await startWindow($, s, AUTO_WARM_MS, s.every)
448 $.ui.log(`cold write of ${fmtTok(write)} tokens paid (${fmtUsd(usd)}). Keeping the cache warm for ${fmtDuration(AUTO_WARM_MS)} so it is not paid again today; /keepwarm off to stop.`)
449 }
450 }
451 }
452 s.coldWritePending = false
453 await arm($, s)
454}
455
456/** After a compaction that installed a summary: the clock is unknown until the next turn. */
457export async function cacheTaxSessionCompact($: EngineInterface, s: State, e: SessionCompactInput) {
458 if (!e.agentId) {
459 s.compacted = true
460 s.ctx = 0
461 s.ackedAt = 0
462 disarm(s)
463 await arm($, s)
464 }
465}
466
467/** Hooks the events only cache-tax uses: resume and /clear, its two commands, and the cold-send guard. */
468export function hookCacheTax(on: On, s: State) {
469 // Resume fields seed the guard before any turn of the resumed session has run.
470 on('classic.SessionStart', async ($, e, next) => {
471 const r = await next(e)
472 if (e.source === 'clear') {
473 await stop($, s, null)
474 s.stopped = null
475 resetForClear(s)
476 s.sid = await $.session.id()
477 await updateStatus($, s, await $.clock.now())
478 return r
479 }
480 const line = seedFromResume(s, e, await $.clock.now())
481 // The resume payload may omit the model; without it the guard cannot price the cold write.
482 if (!s.lastModel) s.lastModel = await $.session.model()
483 if (line) $.ui.log(line)
484 // The seeded clock decides whether a restored or always window pings before the first turn: never when it is cold.
485 await arm($, s)
486 return r
487 }).catch(($, e, next) => next(e))
488
489 on('command.run', { command: 'keepwarm' }, async ($, e) => {
490 const words = String(e.args ?? '').trim().split(/\s+/).filter(Boolean)
491 const now = await $.clock.now()
492 if (words[0] === 'off') {
493 const wasAlways = s.always
494 await keepwarmOff($, s)
495 return { text: wasAlways ? 'keepwarm is off, and no longer arms itself at session start' : 'keepwarm is off' }
496 }
497 if (words[0] === 'always') {
498 s.always = true
499 await $.store.set(KEY_ALWAYS, true)
500 await startWindow($, s, DEFAULT_WINDOW_MS, PING_AFTER_MS)
501 const cold = isCold(s, now) ? `. The cache is cold now, so the first ping comes ${fmtDuration(s.every)} after the next turn` : ''
502 return { text: `keepwarm always on: every session starts with a ${fmtDuration(DEFAULT_WINDOW_MS)} window; /keepwarm off turns it off for good${cold}` }
503 }
504 if (!words.length) {
505 await keepwarmOn($, s)
506 return { text: armedText(s, now, DEFAULT_WINDOW_MS) }
507 }
508 const first = words[0] ?? ''
509 if (first !== 'status') {
510 const window = parseDuration(first)
511 if (window == null) return { text: 'keepwarm takes a window such as 6h or 90m, or always, off, or status' }
512 // "every 2m" is a testing knob and lasts only for the window it was given with.
513 let every = PING_AFTER_MS
514 if (words[1] === 'every') {
515 const period = parseDuration(words[2] ?? '')
516 // A period of the TTL or more lands every ping on a cache already cold, so none is ever sent.
517 if (period == null || period < MIN_PING_MS || period >= TTL_MS) return { text: 'every takes a period from 1m to 59m' }
518 every = period
519 }
520 await startWindow($, s, window, every)
521 return { text: armedText(s, now, window) }
522 }
523 return { text: statusText(s, now) ?? 'keepwarm is off' }
524 })
525
526 on('command.run', { command: 'cache-tax' }, async ($, e) => {
527 const words = String(e.args ?? '').trim().split(/\s+/).filter(Boolean)
528 const now = await $.clock.now()
529 if (words[0] === 'guard') {
530 if (words[1] !== 'warn' && words[1] !== 'refuse') return { text: '/cache-tax guard takes warn or refuse' }
531 await setGuard($, s, words[1])
532 return { text: s.guard === 'refuse' ? 'guard set to refuse once: a cold send is dropped with its price, the resend goes through' : 'guard set to warn: a cold send goes through with its price shown' }
533 }
534 return { text: card(s, now) }
535 })
536
537 // The message that pays. Only its first character is read.
538 on('prompt.submit', async ($, e, next) => {
539 if (e.origin.kind === 'plugin') return next(e)
540 if (typeof e.text !== 'string' || e.text.trimStart().startsWith('/')) return next(e)
541 const now = await $.clock.now()
542 if (!isCold(s, now) || s.ctx < BIG_TOKENS) return next(e)
543 if (s.guard === 'warn') {
544 $.ui.log(`${guardText(s, now)} Sending anyway; keepwarm will hold the cache for ${fmtDuration(AUTO_WARM_MS)} once it lands.`)
545 s.coldWritePending = true
546 return next(e)
547 }
548 if (s.ackedAt === s.lastRequestAt) {
549 s.ackedAt = 0
550 s.coldWritePending = true
551 return next(e)
552 }
553 s.ackedAt = s.lastRequestAt
554 return { drop: `cache-tax: ${guardText(s, now)} Send it again to pay it, and keepwarm will then hold the cache for ${fmtDuration(AUTO_WARM_MS)}. Or /clear and start from a note.` }
555 }).catch(($, e, next) => next(e))
556}
557hooks/pace.ts 74 lines1import type { Sample } from '../types'
2
3const HOUR = 60 * 60 * 1000
4/** How far back a pace is measured. */
5export const HORIZON_MS = HOUR
6/** Less history than this gives no pace: a few minutes' spend says little about the next hour. */
7const MIN_SPAN_MS = 5 * 60 * 1000
8
9/**
10 * Adds a reading, keeping the ones inside the horizon plus the newest one
11 * before it, the baseline a pace is measured from. A reading lower than the
12 * last (a usage limit that reset) starts the series over.
13 */
14export function addSample(samples: Sample[], at: number, value: number): Sample[] {
15 const last = samples[samples.length - 1]
16 if (last !== undefined && value < last.value) return [{ at, value }]
17 // A flat run keeps its first and last readings: the ones between change no pace.
18 const before = samples[samples.length - 2]
19 const kept = before !== undefined && last !== undefined && before.value === value && last.value === value ? samples.slice(0, -1) : samples
20 const all = [...kept, { at, value }]
21 const cut = at - HORIZON_MS
22 let first = 0
23 while (first + 1 < all.length && (all[first + 1] as Sample).at <= cut) first += 1
24 return all.slice(first)
25}
26
27/**
28 * Growth per hour over the last hour up to `now`. Idle time counts, so a break
29 * slows the pace; null with under five minutes of history.
30 */
31export function perHour(samples: Sample[], now: number): number | null {
32 const latest = samples[samples.length - 1]
33 if (latest === undefined) return null
34 const cut = now - HORIZON_MS
35 let base = samples[0] as Sample
36 for (const s of samples) if (s.at <= cut) base = s
37 const span = now - base.at
38 if (span < MIN_SPAN_MS) return null
39 return ((latest.value - base.value) / span) * HOUR
40}
41
42export type Forecast = { kind: 'full'; inMs: number } | { kind: 'at-reset'; percent: number }
43
44/**
45 * Where a usage limit lands at `ratePerHour`: full before it resets (and when),
46 * or the share used when it resets. Null with no pace, or no pace and no reset.
47 */
48export function forecast(percentUsed: number, ratePerHour: number | null, resetsInMs: number | null): Forecast | null {
49 if (percentUsed >= 100) return { kind: 'full', inMs: 0 }
50 if (ratePerHour === null) return null
51 if (ratePerHour <= 0) return resetsInMs === null ? null : { kind: 'at-reset', percent: percentUsed }
52 const inMs = ((100 - percentUsed) / ratePerHour) * HOUR
53 if (resetsInMs === null || inMs < resetsInMs) return { kind: 'full', inMs }
54 return { kind: 'at-reset', percent: percentUsed + (ratePerHour * resetsInMs) / HOUR }
55}
56
57/**
58 * What the prompt cache saved on one response, in dollars, at `price` ($ per
59 * million tokens: [cache read, 1h cache write, output]). Reads are saved
60 * against the base input rate, half the 1h write rate; writes cost a premium
61 * over it, priced at the 1h rate.
62 */
63export function cacheSavings(
64 u: { cache_read_input_tokens: number; cache_creation_input_tokens: number },
65 price: [number, number, number],
66): { readsUsd: number; premiumUsd: number } {
67 const [read, write] = price
68 const base = write / 2
69 return {
70 readsUsd: (u.cache_read_input_tokens * (base - read)) / 1e6,
71 premiumUsd: (u.cache_creation_input_tokens * (write - base)) / 1e6,
72 }
73}
74types/index.d.ts 128 lines1export type Usage = {
2 input: number
3 output: number
4 cacheRead: number
5 cacheWrite: number
6 requests: number
7}
8
9export type LastRequest = {
10 model: string
11 at: number
12 input: number
13 output: number
14 cacheRead: number
15 cacheWrite: number
16}
17
18export type Tokens = {
19 main: Usage
20 agents: Usage
21 /** The last main-loop request: what the prompt cache holds now. */
22 last: LastRequest | null
23}
24
25export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
26
27export type Snapshot = {
28 startedAt: number | null
29 model: string | null
30 effort: string | null
31 contextTokens: number | null
32 contextWindow: number | null
33 contextPercent: number | null
34 rateLimits: Limit[]
35 costUsd: number | null
36 version: string | null
37}
38
39export type TurnInfo = {
40 count: number
41 runningSince: number | null
42 lastMs: number | null
43 steps: number
44 compactions: number
45}
46
47export type RunningTool = { id: string; tool: string; since: number; agentId?: string }
48
49export type ToolStats = {
50 counts: Record<string, number>
51 errors: number
52 running: RunningTool[]
53}
54
55export type AgentRow = {
56 id: string
57 description: string
58 type: string
59 status: string
60 firstSeen: number
61 lastSeen: number
62 tokens: number
63 tools: number
64 lastTool: string | null
65}
66
67/** The cache-tax part of the pane: its one-hour clock, prices, keepwarm window and tally. */
68export type CacheTaxView = {
69 lastRequestAt: number
70 /** When the one-hour cache lapses (or lapsed); null before the first request or after compaction. */
71 expiresAt: number | null
72 ctx: number
73 coldUsd: number | null
74 warmUsd: number | null
75 /** The keepwarm window's end; null while keepwarm is off. */
76 deadline: number | null
77 nextPingAt: number | null
78 stopped: string | null
79 lastPing: { at: number; read: number; usd: number | null } | null
80 guard: 'refuse' | 'warn'
81 always: boolean
82 coldWrites: number
83 paidUsd: number
84}
85
86/** One reading of a running total (cost in dollars, a limit's percent used) at a time. */
87export type Sample = { at: number; value: number }
88
89/** The last hour or so of readings each pace is measured over. */
90export type Pace = {
91 cost: Sample[]
92 /** Keyed by the limit's `kind`. */
93 limits: Record<string, Sample[]>
94}
95
96/** What the prompt cache saved this session, main loop and agents, in dollars. */
97export type Savings = {
98 readsUsd: number
99 /** Cache writes' cost over the base input rate, priced at the 1-hour write rate. */
100 premiumUsd: number
101 /** Responses on a model with no price, left out of both figures. */
102 unpriced: number
103}
104
105/** The switches the pane's buttons and /session-stats toggle; each is remembered across sessions. */
106export type Settings = {
107 alerts: boolean
108 /** A desktop notification when a long turn finishes. */
109 notify: boolean
110}
111
112declare module 'claude-code' {
113 interface PluginState {
114 'session-stats': {
115 snap: Snapshot
116 tokens: Tokens
117 turn: TurnInfo
118 tools: ToolStats
119 agents: AgentRow[]
120 now: number
121 cacheTax: CacheTaxView | null
122 pace: Pace
123 savings: Savings
124 settings: Settings
125 }
126 }
127}
128