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…

newpaneguardcommandstatusprompt
v0.3.0MITupdated 2026-10-15miskopisko/claude-session-stats
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.97 to re-write · warm $0.05 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

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, 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 install session-stats --marketplace miskopisko/claude-session-stats

From a shell:

claude plugin install session-stats --marketplace miskopisko/claude-session-stats

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, floor 1m) /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. Prices are list prices from September 2026, and the cold cost is an upper bound.

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); an interrupted turn 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 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 and tool counts start when the plugin loads. 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, only while the pane is on screen. 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 .

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 663 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  keepwarmOff,
26  keepwarmOn,
27  priceOf,
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, run only while the pane is drawn, so a
133// hidden pane 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: 'Toggle 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    const opened = await $.ui.open({ id: PANE, title: TITLE, columns: WIDTH })
254    return {
255      text: opened.isPlaced
256        ? 'Session stats pane opened. In fullscreen mode it docks on the right.'
257        : `Session stats pane is waiting to be placed: ${opened.reason}`,
258    }
259  })
260
261  on('ui.close', { id: PANE }, async ($, e, next) => {
262    const closed = await next(e)
263    stopTicker()
264    return closed
265  }).catch(($, e, next) => next(e))
266
267  on('session.measure', async ($, e, next) => {
268    await measured($, e)
269    await raiseAlerts($, alerts)
270    return next(e)
271  })
272
273  on('turn.start', async ($, e, next) => {
274    if (e.agentId === undefined) {
275      const at = await $.clock.now()
276      await update($, turn, t => ({ ...t, count: t.count + 1, runningSince: at, steps: 0 }))
277      alerts.turnStartUsd = (await $.session.usage()).cost?.usd ?? null
278    }
279    return next(e)
280  })
281
282  on('turn.complete', async ($, e, next) => {
283    const done = await next(e)
284    await cacheTaxTurnComplete($, cacheTax, e)
285    if (e.agentId === undefined) {
286      await update($, turn, t => ({ ...t, runningSince: null, lastMs: e.durationMs }))
287      await measured($, await $.session.usage())
288      const costly = turnCostAlert(alerts, (await read($, snap)).costUsd)
289      const prefs = await read($, settings)
290      if (costly !== null && prefs.alerts) say($, costly)
291      await raiseAlerts($, alerts)
292      if (prefs.notify && !e.isAborted && e.durationMs >= NOTIFY_AFTER_MS) {
293        await notifyDone($, e.durationMs, e.reason === 'refusal' ? 'the model declined' : e.answer)
294      }
295    } else {
296      const at = await $.clock.now()
297      await update($, agents, touchAgent(e.agentId, at, r => ({ ...r, lastSeen: at })))
298    }
299    return done
300  })
301
302  on('turn.step', async function* ($, e, next) {
303    await cacheTaxTurnStep($, cacheTax, e)
304    if (e.agentId === undefined) {
305      await update($, turn, t => ({ ...t, steps: e.index + 1 }))
306      await update($, snap, s => ({
307        ...s,
308        model: e.model,
309        effort: e.effort === undefined ? null : String(e.effort),
310      }))
311    }
312
313    const result = yield* next(e)
314
315    const u = result.usage
316    if (u !== null) {
317      const at = await $.clock.now()
318      const price = priceOf(u.model)
319      const saved = price === null ? null : cacheSavings(u, price)
320      await update($, savings, v =>
321        saved === null
322          ? { ...v, unpriced: v.unpriced + 1 }
323          : { ...v, readsUsd: v.readsUsd + saved.readsUsd, premiumUsd: v.premiumUsd + saved.premiumUsd },
324      )
325      if (e.agentId === undefined) {
326        await update($, tokens, t => ({
327          ...t,
328          main: addUsage(t.main, u),
329          last: {
330            model: u.model,
331            at,
332            input: u.input_tokens,
333            output: u.output_tokens,
334            cacheRead: u.cache_read_input_tokens,
335            cacheWrite: u.cache_creation_input_tokens,
336          },
337        }))
338      } else {
339        const spent = u.input_tokens + u.output_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
340        await update($, tokens, t => ({ ...t, agents: addUsage(t.agents, u) }))
341        await update($, agents, touchAgent(e.agentId, at, r => ({ ...r, tokens: r.tokens + spent, lastSeen: at })))
342      }
343    }
344
345    return result
346  })
347
348  on('tool.call', async ($, e, next) => {
349    const at = await $.clock.now()
350    const id = e.tool_use_id
351    const name = String(e.tool)
352    await update($, tools, t => ({
353      ...t,
354      running: [...t.running.filter(r => r.id !== id), { id, tool: name, since: at, agentId: e.agentId }].slice(-50),
355    }))
356    if (e.agentId !== undefined) {
357      await update($, agents, touchAgent(e.agentId, at, r => ({ ...r, tools: r.tools + 1, lastTool: name, lastSeen: at })))
358    }
359
360    let isError = true
361    try {
362      const ran = await next(e)
363      isError = ran.isError === true || typeof ran.deny === 'string'
364      return ran
365    } finally {
366      await update($, tools, t => ({
367        counts: { ...t.counts, [name]: (t.counts[name] ?? 0) + 1 },
368        errors: t.errors + (isError ? 1 : 0),
369        running: t.running.filter(r => r.id !== id),
370      }))
371    }
372  }).catch(($, e, next) => next(e))
373
374  on('session.compact', async ($, e, next) => {
375    const done = await next(e)
376    // A precompute installs nothing: the conversation is not compacted until a later dispatch uses it.
377    if (e.trigger !== 'precompute') {
378      await update($, turn, t => ({ ...t, compactions: t.compactions + 1 }))
379      await cacheTaxSessionCompact($, cacheTax, e)
380    }
381    return done
382  }).catch(($, e, next) => next(e))
383
384  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
385    startTicker($)
386    const { Box, Button, Text } = $.ui.resolve(e)
387    const [s, tk, t, tl, ag, tickAt, ct, pc, sv, prefs] = await Promise.all([
388      read($, snap),
389      read($, tokens),
390      read($, turn),
391      read($, tools),
392      read($, agents),
393      read($, now),
394      read($, cacheTaxView),
395      read($, pace),
396      read($, savings),
397      read($, settings),
398    ])
399    // The tick redraws the pane each second; the live clock keeps a pane drawn again after a while hidden from showing an old time.
400    const at = Math.max(tickAt, await $.clock.now())
401    const cols = Math.max(24, e.props.bodyColumns - GUTTER)
402    const barWidth = Math.max(6, Math.min(20, cols - 26))
403    const half = Math.floor(cols / 2)
404
405    const head = (children: string) => (
406      <Box marginTop={1}>
407        <Text bold color="claude">
408          {children}
409        </Text>
410      </Box>
411    )
412    const row = (label: string, value: string, color?: string) => (
413      <Box>
414        <Box width={13} flexShrink={0}>
415          <Text dimColor>{label}</Text>
416        </Box>
417        <Text color={color} wrap="truncate-end">
418          {value}
419        </Text>
420      </Box>
421    )
422
423    // Model and context
424    const model = tk.last?.model ?? s.model
425    const ctxPct = s.contextPercent
426
427    // Cache
428    const last = tk.last
429    const lastHit = last === null ? null : hitRate(last)
430    const allHit = hitRate(tk.main)
431    const age = last === null ? null : at - last.at
432    // Warm or cold by the clock now, not as of cache-tax's last change: the cache lapses with nothing happening.
433    const netSaved = sv.readsUsd - sv.premiumUsd
434    const cacheState = ct === null || ct.expiresAt === null ? 'unknown' : at >= ct.expiresAt ? 'cold' : 'warm'
435
436    // Pace
437    const burn = perHour(pc.cost, at)
438
439    // Tools
440    const totalCalls = Object.values(tl.counts).reduce((a, b) => a + b, 0)
441    const top = Object.entries(tl.counts)
442      .sort((a, b) => b[1] - a[1])
443      .slice(0, 5)
444      .map(([n, c]) => `${n} ${c}`)
445      .join(' · ')
446
447    // Agents
448    const active = ag.filter(a => ACTIVE.has(a.status))
449    const finished = ag.filter(a => !ACTIVE.has(a.status))
450    const shownAgents = [...active, ...finished.slice(-Math.max(0, 6 - active.length))]
451
452    const both = {
453      input: tk.main.input + tk.agents.input,
454      output: tk.main.output + tk.agents.output,
455      cacheRead: tk.main.cacheRead + tk.agents.cacheRead,
456      cacheWrite: tk.main.cacheWrite + tk.agents.cacheWrite,
457    }
458
459    return (
460      <Box flexDirection="column" width={cols + GUTTER} paddingLeft={GUTTER}>
461        <Text bold>Session</Text>
462        {row("Model", model ?? 'not seen yet')}
463        {s.effort !== null && row("Effort", s.effort)}
464        {row("Status", t.runningSince === null ? 'idle' : `working ${duration(at - t.runningSince)} · step ${t.steps}`, t.runningSince === null ? undefined : 'warning')}
465        {row("Uptime", s.startedAt === null ? '—' : duration(at - s.startedAt))}
466        {row("Turns", `${t.count}${t.lastMs === null ? '' : ` · last ${duration(t.lastMs)}`}${t.compactions > 0 ? ` · ${t.compactions} compacted` : ''}`)}
467        {s.costUsd !== null && row("Cost", usd(s.costUsd))}
468        {burn !== null && row("Burn rate", `${usd(burn)} an hour`)}
469
470        {head('Context')}
471        {ctxPct === null ? (
472          <Text dimColor>No reading until the next response.</Text>
473        ) : (
474          <>
475            <Box>
476              <Text color={levelColor(ctxPct)}>{bar(ctxPct, barWidth)}</Text>
477              <Text> {ctxPct}%</Text>
478            </Box>
479            <Text dimColor>
480              {tok(s.contextTokens ?? 0)} of {tok(s.contextWindow ?? 0)} · {tok(Math.max(0, (s.contextWindow ?? 0) - (s.contextTokens ?? 0)))} free
481            </Text>
482          </>
483        )}
484
485        {head('Usage limits')}
486        {s.rateLimits.length === 0 ? (
487          <Text dimColor>No reading (API key, or no response yet).</Text>
488        ) : (
489          s.rateLimits.map(r => (
490            <Box key={r.kind}>
491              <Box width={13} flexShrink={0}>
492                <Text dimColor>{limitName(r.kind)}</Text>
493              </Box>
494              <Text color={levelColor(r.percentUsed)}>{bar(r.percentUsed, barWidth)}</Text>
495              <Text> {Math.round(r.percentUsed)}%</Text>
496            </Box>
497          ))
498        )}
499
500        {head('Tokens')}
501        {row("Input", `${tok(both.input)} new · ${tok(both.cacheRead)} cached`)}
502        {row("Cache write", tok(both.cacheWrite))}
503        {row("Output", tok(both.output))}
504        {row("Requests", `${tk.main.requests} main · ${tk.agents.requests} agents`)}
505        {tk.agents.requests > 0 &&
506          row("Agents' share", `${tok(tk.agents.input + tk.agents.cacheRead + tk.agents.cacheWrite + tk.agents.output)} tokens`)}
507
508        {head('Prompt cache')}
509        {last === null ? (
510          <Text dimColor>No request yet.</Text>
511        ) : (
512          <>
513            {row("Last request", `${lastHit ?? 0}% hit`, lastHit !== null && lastHit >= 70 ? 'success' : 'warning')}
514            <Text dimColor>
515              {'             '}
516              read {tok(last.cacheRead)} · write {tok(last.cacheWrite)} · new {tok(last.input)}
517            </Text>
518            {row("Session hit", allHit === null ? '—' : `${allHit}%`)}
519            {row("Idle for", age === null ? '—' : duration(age))}
520            {(sv.readsUsd > 0 || sv.premiumUsd > 0) && (
521              <>
522                {row(
523                  "Cache saved",
524                  netSaved >= 0 ? `${usd(netSaved)} net` : `-${usd(-netSaved)} net`,
525                  netSaved >= 0 ? 'success' : 'warning',
526                )}
527                <Text dimColor wrap="truncate-end">
528                  {'             '}
529                  {usd(sv.readsUsd)} on reads · {usd(sv.premiumUsd)} on writes
530                  {sv.unpriced > 0 ? ` · ${sv.unpriced} unpriced` : ''}
531                </Text>
532              </>
533            )}
534          </>
535        )}
536
537        {head('Cache tax')}
538        {ct === null ? (
539          <Text dimColor>Starting…</Text>
540        ) : (
541          <>
542            {row(
543              'Cache',
544              cacheState === 'unknown'
545                ? 'unknown until the next turn'
546                : cacheState === 'cold'
547                  ? `cold for ${duration(at - (ct.expiresAt ?? at))}`
548                  : `warm, ${duration((ct.expiresAt ?? at) - at)} left`,
549              cacheState === 'warm' ? 'success' : cacheState === 'cold' ? 'error' : undefined,
550            )}
551            {ct.ctx > 0 &&
552              row(
553                'Cold cost',
554                `${ct.coldUsd === null ? 'n/a' : usd(ct.coldUsd)} to re-write${ct.warmUsd === null ? '' : ` · warm ${usd(ct.warmUsd)}`}`,
555              )}
556            {row(
557              'Keepwarm',
558              ct.stopped !== null
559                ? `stopped: ${ct.stopped}`
560                : ct.deadline === null
561                  ? ct.always ? 'off until next session (always)' : 'off · /keepwarm to arm'
562                  : `on, ${duration(ct.deadline - at)} left${ct.always ? ' (always)' : ''}`,
563              ct.stopped !== null ? 'warning' : ct.deadline === null ? undefined : 'success',
564            )}
565            {ct.deadline !== null &&
566              row(
567                'Next ping',
568                ct.nextPingAt !== null && cacheState === 'warm' ? `in ${duration(Math.max(0, ct.nextPingAt - at))}` : 'after the next turn',
569              )}
570            {ct.lastPing !== null &&
571              row(
572                'Last ping',
573                `read ${tok(ct.lastPing.read)} · ${ct.lastPing.usd === null ? 'n/a' : usd(ct.lastPing.usd)} · ${duration(at - ct.lastPing.at)} ago`,
574              )}
575            {row('Guard', ct.guard === 'refuse' ? 'refuse a cold send once' : 'warn only')}
576            {row(
577              'Cold writes',
578              `${ct.coldWrites} paid${ct.coldWrites > 0 ? `, ${usd(ct.paidUsd)}` : ''}`,
579              ct.coldWrites > 0 ? 'warning' : undefined,
580            )}
581          </>
582        )}
583
584        {head('Tools')}
585        {row("Calls", `${totalCalls}${tl.errors > 0 ? ` · ${tl.errors} failed` : ''}`)}
586        {top !== '' && <Text dimColor wrap="wrap">{top}</Text>}
587        {tl.running.map(r => (
588          <Text color="warning" wrap="truncate-end" key={r.id}>
589            ▸ {r.tool} {duration(at - r.since)}
590            {r.agentId === undefined ? '' : ' (agent)'}
591          </Text>
592        ))}
593
594        {head('Agents')}
595        {shownAgents.length === 0 ? (
596          <Text dimColor>None this session.</Text>
597        ) : (
598          <>
599            <Text dimColor>
600              {active.length} running · {finished.length} finished
601            </Text>
602            {shownAgents.map(a => {
603              const isActive = ACTIVE.has(a.status)
604              const color = isActive ? 'warning' : a.status === 'completed' || a.status === 'idle' ? 'success' : 'error'
605              return (
606                <Box flexDirection="column" key={a.id}>
607                  <Text wrap="truncate-end">
608                    <Text color={color}>{isActive ? '●' : a.status === 'completed' || a.status === 'idle' ? '✓' : '✗'}</Text>{' '}
609                    <Text bold>{a.type}</Text> {a.description}
610                  </Text>
611                  <Text dimColor wrap="truncate-end">
612                    {'  '}
613                    {a.status} · {duration((isActive ? at : a.lastSeen) - a.firstSeen)} · {a.tools} tools · {tok(a.tokens)} tok
614                    {isActive && a.lastTool !== null ? ` · ${a.lastTool}` : ''}
615                  </Text>
616                </Box>
617              )
618            })}
619          </>
620        )}
621
622        {head('Controls')}
623        <Box>
624          <Box width={half} flexShrink={0}>
625            {ct === null ? (
626              <Text dimColor>k: keepwarm …</Text>
627            ) : (
628              <Button
629                key="keepwarm"
630                plain
631                hotkey="k"
632                label={`keepwarm ${ct.deadline === null ? 'off' : 'on'}`}
633                onPress={() => (cacheTax.deadline ? keepwarmOff($, cacheTax) : keepwarmOn($, cacheTax))}
634              />
635            )}
636          </Box>
637          {ct !== null && (
638            <Button
639              key="guard"
640              plain
641              hotkey="g"
642              label={`guard ${ct.guard}`}
643              onPress={() => setGuard($, cacheTax, cacheTax.guard === 'refuse' ? 'warn' : 'refuse')}
644            />
645          )}
646        </Box>
647        <Box>
648          <Box width={half} flexShrink={0}>
649            <Button key="alerts" plain hotkey="a" label={`alerts ${prefs.alerts ? 'on' : 'off'}`} onPress={() => toggleSetting($, 'alerts')} />
650          </Box>
651          <Button key="notify" plain hotkey="n" label={`notify ${prefs.notify ? 'on' : 'off'}`} onPress={() => toggleSetting($, 'notify')} />
652        </Box>
653        <Text dimColor>Click, or ctrl+x tab then the key.</Text>
654        {s.version !== null && (
655          <Box marginTop={1}>
656            <Text dimColor>Claude Code {s.version}</Text>
657          </Box>
658        )}
659      </Box>
660    )
661  })
662}
663
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 518 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], list prices September 2026.
25// Longer family names first: a model id matches the first row it contains.
26const PRICES: Array<[string, number, number, number]> = [
27  ['fable-5-1', 0.25, 20, 50],
28  ['fable-5', 1, 20, 50],
29  ['opus-5', 0.5, 10, 25],
30  ['opus-4', 0.5, 10, 25],
31  ['sonnet-5', 0.2, 4, 10],
32  ['sonnet', 0.3, 6, 15],
33  ['haiku', 0.1, 2, 5],
34]
35
36type PingRecord = { at: number; read: number; write: number; usd: number | null; warm: boolean }
37type Miss = { at: number; tokens: number; usd: number | null }
38type GuardMode = 'refuse' | 'warn'
39
40export type State = {
41  sid: string
42  deadline: number
43  every: number
44  always: boolean
45  lastRequestAt: number
46  lastModel: string | null
47  ctx: number
48  compacted: boolean
49  guard: GuardMode
50  ackedAt: number
51  coldWritePending: boolean
52  misses: Miss[]
53  pending: { cancel: () => void } | null
54  last: PingRecord | null
55  stopped: string | null
56}
57
58export function priceOf(model: string | null): [number, number, number] | null {
59  const m = (model ?? '').toLowerCase().replace(/[\s.]+/g, '-')
60  for (const [family, read, write, output] of PRICES) if (m.includes(family)) return [read, write, output]
61  return null
62}
63
64export function parseDuration(text: string): number | null {
65  const m = /^(?:(\d+)h)?(?:(\d+)m)?$/.exec(text.trim())
66  if (!m || (m[1] === undefined && m[2] === undefined)) return null
67  return (Number(m[1] ?? 0) * 60 + Number(m[2] ?? 0)) * 60 * 1000
68}
69
70export function fmtDuration(ms: number): string {
71  const total = Math.max(0, Math.round(ms / 60000))
72  const h = Math.floor(total / 60)
73  const m = total % 60
74  if (h >= 48) return `${Math.floor(h / 24)}d ${h % 24}h`
75  return h > 0 ? `${h}h${String(m).padStart(2, '0')}m` : `${m}m`
76}
77
78function fmtUsd(usd: number | null): string {
79  return usd == null ? 'n/a' : '$' + (usd >= 100 ? usd.toFixed(0) : usd.toFixed(2))
80}
81
82function fmtTok(n: number): string {
83  return n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1000 ? Math.round(n / 1000) + 'k' : String(n)
84}
85
86function coldUsd(s: State): number | null {
87  const price = priceOf(s.lastModel)
88  return price ? s.ctx * price[1] / 1e6 : null
89}
90
91function warmUsd(s: State): number | null {
92  const price = priceOf(s.lastModel)
93  return price ? s.ctx * price[0] / 1e6 : null
94}
95
96/** Everything a fork bills: the cache read, any cache write, uncached input at the base rate (half the 1h write rate), and the output. */
97function pingUsd(u: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }, price: [number, number, number]): number {
98  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
99}
100
101/** The read-only upper bound: pings at the cache-read rate that cost as much as one cold write of the same context. */
102function breakEvenPings(s: State): number | null {
103  const price = priceOf(s.lastModel)
104  if (!price || s.ctx <= 0) return null
105  return Math.floor(price[1] / price[0])
106}
107
108function isCold(s: State, now: number): boolean {
109  return s.lastRequestAt > 0 && !s.compacted && now - s.lastRequestAt >= TTL_MS
110}
111
112function guardText(s: State, now: number): string {
113  const price = priceOf(s.lastModel)
114  const rate = price ? `$${price[1]}/MTok` : 'the cache-write rate'
115  const warm = warmUsd(s)
116  return `the prompt cache went cold ${fmtDuration(now - s.lastRequestAt - TTL_MS)} ago. Sending this re-writes ` +
117    `up to ${s.ctx.toLocaleString('en-US')} tokens at ${rate} = ${fmtUsd(coldUsd(s))}` +
118    (warm == null ? '' : ` (a warm turn would have cost ${fmtUsd(warm)})`) + '.'
119}
120
121export type ResumeFields = {
122  source: string
123  model?: string
124  context_tokens?: number
125  seconds_since_last_response?: number
126  prompt_cache_likely_expired?: boolean
127  estimated_cache_write_usd?: number
128}
129
130/** /clear starts a new conversation in the same process; nothing priced before it still exists. */
131export function resetForClear(s: State) {
132  s.ctx = 0
133  s.lastRequestAt = 0
134  s.compacted = false
135  s.ackedAt = 0
136  s.coldWritePending = false
137  s.misses = []
138  disarm(s)
139}
140
141/** Applies a resumed session's fields to the state; returns the line to log, if any. */
142export function seedFromResume(s: State, e: ResumeFields, now: number): string | null {
143  if (e.source !== 'resume' && e.source !== 'fork') return null
144  if (typeof e.context_tokens === 'number' && e.context_tokens > 0) s.ctx = e.context_tokens
145  if (typeof e.seconds_since_last_response === 'number') s.lastRequestAt = now - e.seconds_since_last_response * 1000
146  if (typeof e.model === 'string') s.lastModel = e.model
147  s.compacted = false
148  if (e.prompt_cache_likely_expired !== true || s.ctx < BIG_TOKENS) return null
149  const usd = typeof e.estimated_cache_write_usd === 'number' ? fmtUsd(e.estimated_cache_write_usd) : fmtUsd(coldUsd(s))
150  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.`
151}
152
153function statusText(s: State, now: number): string | undefined {
154  if (s.stopped) return `keepwarm stopped: ${s.stopped}`
155  if (!s.deadline) return undefined
156  const pingText = s.last ? ` · last ping read ${fmtTok(s.last.read)} ${fmtUsd(s.last.usd)}` : ''
157  const nextText = !s.lastRequestAt ? ' · waiting for the first turn'
158    : s.compacted ? ' · waiting for the first turn after compaction'
159    : isCold(s, now) ? ` · cold now, first ping ${fmtDuration(s.every)} after the next turn`
160    : ` · ping in ${fmtDuration(s.lastRequestAt + s.every - now)}`
161  return `keepwarm ${fmtDuration(s.deadline - now)} left${nextText}${pingText}`
162}
163
164/** 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. */
165export function viewOf(s: State, now: number): CacheTaxView {
166  const isKnown = s.lastRequestAt > 0 && !s.compacted
167  return {
168    lastRequestAt: s.lastRequestAt,
169    expiresAt: isKnown ? s.lastRequestAt + TTL_MS : null,
170    ctx: s.ctx,
171    coldUsd: coldUsd(s),
172    warmUsd: warmUsd(s),
173    deadline: s.deadline > now ? s.deadline : null,
174    nextPingAt: s.deadline > 0 && isKnown && !isCold(s, now) ? s.lastRequestAt + s.every : null,
175    stopped: s.stopped,
176    lastPing: s.last === null ? null : { at: s.last.at, read: s.last.read, usd: s.last.usd },
177    guard: s.guard,
178    always: s.always,
179    coldWrites: s.misses.length,
180    paidUsd: s.misses.reduce((a, m) => a + (m.usd ?? 0), 0),
181  }
182}
183
184async function updateStatus($: EngineInterface, s: State, now: number) {
185  $.ui.status(statusText(s, now))
186  const view = viewOf(s, now)
187  await update($, cacheTaxView, () => view)
188}
189
190function disarm(s: State) {
191  if (s.pending) s.pending.cancel()
192  s.pending = null
193}
194
195// The window and its ping period belong to the session that armed them, so a
196// second session, or one resumed from another transcript, never inherits them
197// and cannot turn them off. The always switch and the guard mode stay global.
198function deadlineKey(s: State): string {
199  return `${KEY_DEADLINE}:${s.sid}`
200}
201
202function everyKey(s: State): string {
203  return `${KEY_EVERY}:${s.sid}`
204}
205
206/** 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. */
207async function prune($: EngineInterface, s: State, now: number) {
208  for (const key of [KEY_DEADLINE, deadlineKey(s)]) {
209    const deadline = await $.store.get(key)
210    if (deadline === undefined) continue
211    if (typeof deadline === 'number' && deadline > now) continue
212    await $.store.delete(key)
213    await $.store.delete(KEY_EVERY + key.slice(KEY_DEADLINE.length))
214  }
215}
216
217async function stop($: EngineInterface, s: State, why: string | null, forgetAlways = false) {
218  s.deadline = 0
219  s.every = PING_AFTER_MS
220  s.stopped = why
221  disarm(s)
222  await $.store.delete(deadlineKey(s))
223  await $.store.delete(everyKey(s))
224  if (forgetAlways) {
225    s.always = false
226    await $.store.delete(KEY_ALWAYS)
227  }
228  await updateStatus($, s, await $.clock.now())
229}
230
231async function arm($: EngineInterface, s: State) {
232  disarm(s)
233  if (!s.deadline) return updateStatus($, s, await $.clock.now())
234  const now = await $.clock.now()
235  if (now >= s.deadline) return stop($, s, null)
236  // A cold window still needs expiry cleanup, but must not send a model request.
237  if (s.lastRequestAt && !s.compacted && !isCold(s, now)) {
238    const untilCold = s.lastRequestAt + TTL_MS - now
239    const delay = Math.min(s.deadline - now, untilCold, Math.max(1000, s.lastRequestAt + s.every - now))
240    s.pending = $.clock.after(delay, () => { void ping($, s) })
241  } else {
242    s.pending = $.clock.after(s.deadline - now, () => { void arm($, s) })
243  }
244  await updateStatus($, s, now)
245}
246
247async function ping($: EngineInterface, s: State) {
248  s.pending = null
249  if (!s.deadline) return
250  const now = await $.clock.now()
251  if (now >= s.deadline) return arm($, s)
252  // A turn in the meantime re-armed the timer; this callback is stale.
253  if (isCold(s, now)) return arm($, s)
254  if (now - s.lastRequestAt < s.every - 1000) return
255  let reply
256  try {
257    reply = await $.model.fork({ prompt: PING_PROMPT })
258  } catch (err) {
259    return stop($, s, `the ping failed, ${err instanceof Error ? err.message : String(err)}`)
260  }
261  // Older Claude Code releases answered null instead of a failure object.
262  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')
263  if (reply.isAnswered === false) {
264    const reason = reply.reason === 'nothing-to-fork' ? 'no conversation to warm yet'
265      : reply.reason === 'api-error' ? `the API call failed${reply.status === null ? '' : ` (${reply.status})`}`
266      : reply.reason === 'aborted' ? 'the ping was interrupted'
267      : 'the ping returned no text'
268    return stop($, s, reason)
269  }
270  const u = reply.usage
271  const price = priceOf(s.lastModel)
272  // 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.
273  const warm = u.cache_read_input_tokens > 0 && u.cache_creation_input_tokens < 0.1 * u.cache_read_input_tokens
274  const usd = price ? pingUsd(u, price) : null
275  s.last = { at: now, read: u.cache_read_input_tokens, write: u.cache_creation_input_tokens, usd, warm }
276  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`)
277  s.lastRequestAt = now
278  await arm($, s)
279}
280
281/** The reply to an arming command; on a cold cache it says when the first ping can come. */
282function armedText(s: State, now: number, windowMs: number): string {
283  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`
284  return `keepwarm on for ${fmtDuration(windowMs)}, a ping ${fmtDuration(s.every)} after each idle stretch keeps the cache read, not re-written`
285}
286
287async function startWindow($: EngineInterface, s: State, windowMs: number, every: number) {
288  const now = await $.clock.now()
289  s.every = every
290  if (every === PING_AFTER_MS) await $.store.delete(everyKey(s))
291  else await $.store.set(everyKey(s), every)
292  s.deadline = now + windowMs
293  s.stopped = null
294  await $.store.set(deadlineKey(s), s.deadline)
295  await arm($, s)
296}
297
298/** Arms a six-hour window, as a bare /keepwarm does: the pane's keepwarm button. */
299export async function keepwarmOn($: EngineInterface, s: State) {
300  await startWindow($, s, DEFAULT_WINDOW_MS, PING_AFTER_MS)
301}
302
303/** Stops keepwarm and turns always off, as /keepwarm off does. */
304export async function keepwarmOff($: EngineInterface, s: State) {
305  await stop($, s, null, true)
306}
307
308/** Sets the guard mode and remembers it across sessions. */
309export async function setGuard($: EngineInterface, s: State, mode: GuardMode) {
310  s.guard = mode
311  await $.store.set(KEY_GUARD, mode)
312  await updateStatus($, s, await $.clock.now())
313}
314
315function card(s: State, now: number): string {
316  const lines: string[] = []
317  lines.push(`${s.lastModel ?? 'model not seen yet'}`)
318  if (s.compacted) lines.push('state       reset by compaction, waiting for the first turn')
319  else if (!s.lastRequestAt) lines.push('state       no request yet this session')
320  else if (isCold(s, now)) lines.push(`state       COLD, last request ${fmtDuration(now - s.lastRequestAt)} ago`)
321  else lines.push(`state       warm, ${fmtDuration(s.lastRequestAt + TTL_MS - now)} left`)
322  lines.push(`context     ${s.ctx.toLocaleString('en-US')} tokens`)
323  lines.push(`cold cost   ${fmtUsd(coldUsd(s))} to re-write it (warm turn ${fmtUsd(warmUsd(s))})`)
324  const always = s.always ? ' (always)' : ''
325  const idle = s.always ? 'off until the next session start, which arms 6h00m (always)' : 'off (/keepwarm to arm it for 6h00m)'
326  lines.push(`keepwarm    ${s.deadline ? (statusText(s, now) ?? '').replace(/^keepwarm /, 'on, ') + always : s.stopped ? `stopped, ${s.stopped}${always}` : idle}`)
327  const pings = breakEvenPings(s)
328  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)}`)
329  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)'}`)
330  const paid = s.misses.reduce((a, m) => a + (m.usd ?? 0), 0)
331  lines.push(`session     ${s.misses.length} cold write${s.misses.length === 1 ? '' : 's'} paid, ${fmtUsd(paid)}`)
332  return lines.join('\n')
333}
334
335export function freshState(): State {
336  return {
337    sid: '', deadline: 0, every: PING_AFTER_MS, always: false, lastRequestAt: 0, lastModel: null, ctx: 0, compacted: false,
338    guard: 'refuse', ackedAt: 0, coldWritePending: false, misses: [], pending: null, last: null, stopped: null,
339  }
340}
341
342/** After `next` at session start: restores the window, guard and switches, and registers the commands. */
343export async function cacheTaxSessionStart($: EngineInterface, s: State) {
344  s.sid = await $.session.id()
345  const now = await $.clock.now()
346  await prune($, s, now)
347  const saved = await $.store.get(deadlineKey(s))
348  const savedEvery = await $.store.get(everyKey(s))
349  const savedGuard = await $.store.get(KEY_GUARD)
350  s.deadline = typeof saved === 'number' && saved > now ? saved : 0
351  s.every = typeof savedEvery === 'number' && savedEvery >= MIN_PING_MS ? savedEvery : PING_AFTER_MS
352  s.guard = savedGuard === 'warn' ? 'warn' : 'refuse'
353  s.always = (await $.store.get(KEY_ALWAYS)) === true
354  // Always means a fresh default window every session, whatever the last one left behind.
355  if (s.always) await startWindow($, s, DEFAULT_WINDOW_MS, PING_AFTER_MS)
356  const usage = await $.session.usage()
357  if (usage.context.tokens) s.ctx = usage.context.tokens
358  await $.command.register({
359    name: 'keepwarm',
360    description: 'Keep the prompt cache warm: bare for 6h, a window such as 90m, always, off, or status (session-stats)',
361    argumentHint: '[6h | always | off | status]',
362    immediate: true,
363  })
364  await $.command.register({
365    name: 'cache-tax',
366    description: 'Prompt cache state, cold price and this session\'s cold writes; guard warn|refuse (session-stats)',
367    argumentHint: '[status | guard warn | guard refuse]',
368    immediate: true,
369  })
370  // The hook form of cache-tax ships a /cache-tax:status skill; both installed means two guards.
371  const commands = await $.command.list()
372  if (commands.some(c => c.name === 'cache-tax:status')) {
373    $.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.')
374  }
375  await updateStatus($, s, now)
376}
377
378/** Before a step is sent: stamps the main loop's request time. */
379export async function cacheTaxTurnStep($: EngineInterface, s: State, e: TurnStepInput) {
380  if (!e.agentId) s.lastRequestAt = await $.clock.now()
381}
382
383/** After `next` at a turn's end: the context, a paid cold write, and the next ping. */
384export async function cacheTaxTurnComplete($: EngineInterface, s: State, e: TurnCompleteInput) {
385  if (e.agentId) return
386  const now = await $.clock.now()
387  // A sleeping host may deliver this turn before the expired window's timer.
388  if (s.deadline && now >= s.deadline) await stop($, s, null)
389  // turn.step stamps the exact request time; when no step of this turn did, the turn's end is the floor.
390  if (now - s.lastRequestAt > e.durationMs) s.lastRequestAt = now
391  s.compacted = false
392  s.ackedAt = 0
393  const u = e.usage
394  if (u) {
395    if (u.model) s.lastModel = u.model
396    const prev = s.ctx
397    // A turn's usage is its responses summed, so a ten-step turn reports ten
398    // reads of the context. The live window is the engine's figure; the sum
399    // is only the fallback for a host that reports no tokens.
400    const write = u.cache_creation_input_tokens
401    const live = (await $.session.usage()).context.tokens
402    s.ctx = live && live > 0 ? live : u.input_tokens + u.cache_read_input_tokens + write
403    const full = prev > 20000 && write >= 0.5 * prev
404    if (full || s.coldWritePending) {
405      const price = priceOf(s.lastModel)
406      const usd = price ? write * price[1] / 1e6 : null
407      s.misses.push({ at: now, tokens: write, usd })
408      if (s.deadline < now + AUTO_WARM_MS) {
409        await startWindow($, s, AUTO_WARM_MS, s.every)
410        $.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.`)
411      }
412    }
413  }
414  s.coldWritePending = false
415  await arm($, s)
416}
417
418/** After a compaction that installed a summary: the clock is unknown until the next turn. */
419export async function cacheTaxSessionCompact($: EngineInterface, s: State, e: SessionCompactInput) {
420  if (!e.agentId) {
421    s.compacted = true
422    s.ctx = 0
423    s.ackedAt = 0
424    disarm(s)
425    await arm($, s)
426  }
427}
428
429/** Hooks the events only cache-tax uses: resume and /clear, its two commands, and the cold-send guard. */
430export function hookCacheTax(on: On, s: State) {
431  // Resume fields seed the guard before any turn of the resumed session has run.
432  on('classic.SessionStart', async ($, e, next) => {
433    const r = await next(e)
434    if (e.source === 'clear') {
435      await stop($, s, null)
436      s.stopped = null
437      resetForClear(s)
438      s.sid = await $.session.id()
439      await updateStatus($, s, await $.clock.now())
440      return r
441    }
442    const line = seedFromResume(s, e, await $.clock.now())
443    // The resume payload may omit the model; without it the guard cannot price the cold write.
444    if (!s.lastModel) s.lastModel = await $.session.model()
445    if (line) $.ui.log(line)
446    // The seeded clock decides whether a restored or always window pings before the first turn: never when it is cold.
447    await arm($, s)
448    return r
449  }).catch(($, e, next) => next(e))
450
451  on('command.run', { command: 'keepwarm' }, async ($, e) => {
452    const words = String(e.args ?? '').trim().split(/\s+/).filter(Boolean)
453    const now = await $.clock.now()
454    if (words[0] === 'off') {
455      const wasAlways = s.always
456      await keepwarmOff($, s)
457      return { text: wasAlways ? 'keepwarm is off, and no longer arms itself at session start' : 'keepwarm is off' }
458    }
459    if (words[0] === 'always') {
460      s.always = true
461      await $.store.set(KEY_ALWAYS, true)
462      await startWindow($, s, DEFAULT_WINDOW_MS, PING_AFTER_MS)
463      const cold = isCold(s, now) ? `. The cache is cold now, so the first ping comes ${fmtDuration(s.every)} after the next turn` : ''
464      return { text: `keepwarm always on: every session starts with a ${fmtDuration(DEFAULT_WINDOW_MS)} window; /keepwarm off turns it off for good${cold}` }
465    }
466    if (!words.length) {
467      await keepwarmOn($, s)
468      return { text: armedText(s, now, DEFAULT_WINDOW_MS) }
469    }
470    const first = words[0] ?? ''
471    if (first !== 'status') {
472      const window = parseDuration(first)
473      if (window == null) return { text: 'keepwarm takes a window such as 6h or 90m, or always, off, or status' }
474      // "every 2m" is a testing knob and lasts only for the window it was given with.
475      let every = PING_AFTER_MS
476      if (words[1] === 'every') {
477        const period = parseDuration(words[2] ?? '')
478        if (period == null || period < MIN_PING_MS) return { text: 'every takes a period of at least 1m' }
479        every = period
480      }
481      await startWindow($, s, window, every)
482      return { text: armedText(s, now, window) }
483    }
484    return { text: statusText(s, now) ?? 'keepwarm is off' }
485  })
486
487  on('command.run', { command: 'cache-tax' }, async ($, e) => {
488    const words = String(e.args ?? '').trim().split(/\s+/).filter(Boolean)
489    const now = await $.clock.now()
490    if (words[0] === 'guard') {
491      if (words[1] !== 'warn' && words[1] !== 'refuse') return { text: '/cache-tax guard takes warn or refuse' }
492      await setGuard($, s, words[1])
493      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' }
494    }
495    return { text: card(s, now) }
496  })
497
498  // The message that pays. Only its first character is read.
499  on('prompt.submit', async ($, e, next) => {
500    if (e.origin.kind === 'plugin') return next(e)
501    if (typeof e.text !== 'string' || e.text.trimStart().startsWith('/')) return next(e)
502    const now = await $.clock.now()
503    if (!isCold(s, now) || s.ctx < BIG_TOKENS) return next(e)
504    if (s.guard === 'warn') {
505      $.ui.log(`${guardText(s, now)} Sending anyway; keepwarm will hold the cache for ${fmtDuration(AUTO_WARM_MS)} once it lands.`)
506      s.coldWritePending = true
507      return next(e)
508    }
509    if (s.ackedAt === s.lastRequestAt) {
510      s.ackedAt = 0
511      s.coldWritePending = true
512      return next(e)
513    }
514    s.ackedAt = s.lastRequestAt
515    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.` }
516  }).catch(($, e, next) => next(e))
517}
518
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