SLOPSHOPPER

session-stats

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…

paneguardcommandstatuspromptmodel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-stats
│ ┃ Session stats ✕ › fix the failing auth test and add an audit log call │ ┃ Session │ ┃ Model not seen yet ⏺ Read(src/auth.ts) │ ┃ Status idle ⎿ Read 6 lines │ ┃ Uptime 30m00s ⏺ Update(src/auth.ts) │ ┃ Turns 1 · last 42s ⎿ Added 2 lines, removed 1 line │ ┃ Cost $0.42 ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Context │ ┃ ██████████░░░░░░░░░░ 49% ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 97.4k of 200k · 102.6k free │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ Usage limits │ ┃ 5-hour ██████░░░░░░░░░░░░░░ 31% › /session-stats │ ┃ ⎿ session-stats: Session stats pane opened. In fullscreen mode it │ ┃ Tokens │ ┃ Input 0 new · 0 cached │ ┃ Cache write 0 │ ┃ Output 0 │ ┃ Requests 0 main · 0 agents │ ┃ │ ┃ Prompt cache │ ┃ No request yet. │ ┃ │ ┃ Cache tax │ ┃ Cache warm, 59m59s left ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ session-stats: keepwarm 6h00m left · ping in 50m

Draws

Pane · Session stats
Session Model not seen yet Status idle Uptime 30m00s Turns 1 · last 42s Cost $0.42 Context ██████████░░░░░░░░░░ 49% 97.4k of 200k · 102.6k free Usage limits 5-hour ██████░░░░░░░░░░░░░░ 31% Tokens Input 0 new · 0 cached Cache write 0 Output 0 Requests 0 main · 0 agents Prompt cache No request yet. Cache tax Cache warm, 59m59s left Cold cost $0.78 to re-write · warm $0.02 Keepwarm on, 5h59m left Next ping in 49m59s Guard refuse a cold send once Cold writes 0 paid Tools Calls 9 · 1 failed Bash 4 · Write 2 · Read 1 · Grep 1 · Edit 1 Agents None this session. Controls k: keepwarm on g: guard refuse a: alerts on n: notify on Click, or ctrl+x tab then the key. Claude Code 2.1.289
README

session-stats

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.

What it shows

  • Session: the model in use and its effort level, whether Claude is idle or working (with elapsed time and step), uptime, turns and the last turn's length, compactions, cost in dollars, and the burn rate: cost over the last hour, idle time included.
  • Context: how full the context window is, as a bar, with tokens used, window size and free space.
  • Usage limits: the 5-hour and 7-day windows, each a bar with its percent used (subscription accounts only).
  • Tokens: new input, cached input, cache writes, output and request counts, for the main session and for agents.
  • Prompt cache: the cache hit rate for the last request and for the whole session (main session and agents), the read/write/new split, how long the session has been idle, and what the cache saved: reads priced against the base input rate, less the premium paid on cache writes.
  • Cache tax: whether the one-hour cache is warm (and for how long) or cold, what re-writing it would cost against a warm turn, the keepwarm window and next ping, the last ping's cost, the guard mode, and the cold writes paid this session.
  • Tools: total calls and failures, the most-used tools, and any tool running now with its elapsed time.
  • Agents: each subagent's status, type and task, elapsed time, tool calls, tokens and the tool it's using now.
  • Controls: switches for keepwarm, the cold-send guard, alerts and the done notification (see below).

Install

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

Use

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 and the cold-send guard

/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".

Alerts

The plugin writes a dim line in the transcript, once each, when:

  • the context window passes 80% (again only after it drops under 70%);
  • a usage limit passes 90%;
  • at the current pace, a usage limit will run out within the hour, before it resets;
  • a single turn costs $5 or more.

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.

Done notification

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.

Controls

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.

Notes

  • Token, tool, turn and savings counts start when the plugin loads and run on through /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.
  • Cache savings are priced at list prices, with cache writes at the one-hour rate (as cache-tax assumes). On a five-minute cache the write premium is a quarter of that, so the net saving is understated.
  • The pane's clock ticks once a second from the pane's first draw until it is closed (/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.
  • Where an organization manages Claude Code, plugins you install don't receive the resume and compaction events, so cache-tax can't use a resumed session's timing or reset itself after a compaction there.

Develop

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 .

License

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.

Source 6 files
hooks/register.tsx 680 lines
1import { 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}
680
hooks/alerts.ts 80 lines
1import 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}
80
hooks/format.ts 73 lines
1import 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)}`
73
hooks/cache-tax.ts 557 lines
1// 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}
557
hooks/pace.ts 74 lines
1import 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}
74
types/index.d.ts 128 lines
1export 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