SLOPSHOPPER

session-pulse

A band above the prompt with the rate-limit windows, a prompt-cache countdown, and warming that keeps the cache from going cold.

panebandcommandtoastmodeltimer
★ 1v0.3.4MITupdated 2026-10-20dlin10/ai-coding-plugins/mods/session-pulse
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-pulse
│ ┃ session-pulse-auto ✕ › fix the failing auth test and add an audit log call │ ┃ How long should auto-warming keep the cache │ ┃ warm? ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ [ 1 h (1 warm-up) ] [ 2 h (2 warm-ups) ] [ 4 ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Other: hours, up to 24 ⏎ Start ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /session-pulse │ ⎿ session-pulse: Session pulse hidden. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · session-pulse-auto
How long should auto-warming keep the cache warm? [ 1 h (1 warm-up) ] [ 2 h (2 warm-ups) ] [ 4 h (4 warm-ups) Other: hours, up to 24 ⏎ Start
README

Session Pulse 0.3.4

A Claude Code mod that keeps the session's cost in view above the prompt and keeps the prompt cache from going cold while you are away.

cache   ██░░░░░░ 18 min  5h ░░░░░░░░ 3% resets in 3h 2m  week ████░░░░ 44%          [Auto Warm]

The row

  • The countdown and the limits. How much of the prompt cache's life is left, counted from the main conversation's last request (blue, orange once a quarter of it is left, cold once it has gone); the five-hour window's share and time to reset; the weekly window's share. The limits are the ones the status line gets from the API's rate-limit headers, so off a subscription there are none. They are the account's, so every session of the mod shares the newest reading through the mod's store: an idle, resumed or new session shows what another session last read, within five seconds.
  • Auto Warm opens a small dialog of the mod's own asking how long to keep the cache warm — 1 h (1 warm-up), 2 h (2 warm-ups), 4 h, 8 h, or a number of hours typed under Other (no field on mobile); Cancel or Esc closes it — then warms the cache two minutes before each cooling until the hours or the warm-ups run out. The button counts the warm-ups (Auto Warm 1/2); pressing it again turns auto-warming off. A turn that is running refreshes the cache itself, so no warm-up is sent during one.

/session-pulse hides or shows the band. Subagents cache prefixes of their own and do not move the countdown.

What warming costs

A warm-up is a one-word fork of the conversation ($.model.fork): it is served from the cache, which restarts the countdown, and it adds nothing to the conversation. It reads the whole cached prefix from the cache, billed at the cache-read rate (a tenth of the input price), plus a few dozen fresh tokens. Letting the cache go cold costs a write of the whole prefix at the cache-write rate on the next turn. With an 80K-token conversation an hour's warm-up costs about 8K input-token equivalents, against about 160K for the rewrite.

The cache TTL

A mod is not told how long a cache entry lives: the usage it sees has no split between five-minute and one-hour writes. With cacheTtl set to auto (the default) the mod starts at an hour, as Claude Code's subscription cache lives, and learns from what the requests say:

  • two requests in a row on the same model that read almost nothing from the cache, each after five and a half minutes to an hour of quiet, point to five minutes. A lone one does not: the API can evict an hour-long entry early;
  • one that still reads most of it after more than five and a half minutes proves an hour, and starts the count of misses over.

The learned value is kept across sessions. Set cacheTtl to 5m or 1h in /config to pin it instead.

Install

Install it from the marketplace:

/plugin install session-pulse --marketplace dlin10/ai-coding-plugins

Or load it from this folder for one session:

claude --plugin-dir /path/to/ai-coding-plugins/mods/session-pulse

Or load it in every session by naming the folder in ~/.claude/settings.json:

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/ai-coding-plugins/mods/session-pulse"
  }
}

Mods are a Claude Code-only feature; this one is written and tested against Claude Code 2.1.302.

Develop

claude plugin validate mods/session-pulse
claude plugin test mods/session-pulse

Once Claude Code has loaded the mod it lays the API's types in .claude-plugin/types/ (ignored by git), and tsc -p mods/session-pulse type-checks it.

Source 3 files
hooks/register.tsx 395 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { CacheReading, Limit } from '../types'
5import {
6  AUTO_HOURS,
7  FIVE_MINUTES,
8  MINUTE,
9  ONE_HOUR,
10  autoLabel,
11  autoLead,
12  bar,
13  barCells,
14  cachedPrefix,
15  isRunningLow,
16  kindLabel,
17  learnTtl,
18  minutesLeft,
19  parseHours,
20  resetsIn,
21  ttlFromOption,
22  ttlLabel,
23  warmsFor,
24} from './format'
25
26const COMMAND = 'session-pulse'
27const STORED_TTL = 'ttlMs'
28// The rate limits are the account's, so every session of the mod shares the
29// newest reading through the store: an idle session takes what another read.
30const STORED_LIMITS = 'limits'
31const AUTO_QUESTION = 'How long should auto-warming keep the cache warm?'
32// The mod's own dialog: AskUserQuestion raised outside a turn leaves the
33// desktop showing the session as working once it is answered.
34const AUTO_DIALOG = 'session-pulse-auto'
35const WARM_PROMPT = 'This is a check that keeps the prompt cache warm. Reply with one word: ok.'
36const TICK_MS = 5000
37
38const limits = atom({ plugin: 'session-pulse', key: 'limits' } as const, [])
39const cache = atom({ plugin: 'session-pulse', key: 'cache' } as const, null)
40const ttlMs = atom({ plugin: 'session-pulse', key: 'ttlMs' } as const, ONE_HOUR)
41const isTtlLearned = atom({ plugin: 'session-pulse', key: 'isTtlLearned' } as const, false)
42const now = atom({ plugin: 'session-pulse', key: 'now' } as const, 0)
43const isHidden = atom({ plugin: 'session-pulse', key: 'isHidden' } as const, false)
44const isWarming = atom({ plugin: 'session-pulse', key: 'isWarming' } as const, false)
45const auto = atom({ plugin: 'session-pulse', key: 'auto' } as const, null)
46
47const toLimits = (windows: readonly Limit[]): Limit[] =>
48  windows.map(({ kind, percentUsed, resetsAt }) => ({ kind, percentUsed, resetsAt }))
49
50let lastTickWrite = 0
51let lastTickMinute = 0
52let isTurnRunning = false
53let limitsAt = 0
54let quietMisses = 0
55
56async function shareLimits($: EngineInterface, windows: readonly Limit[]) {
57  const at = await $.clock.now()
58  const shown = toLimits(windows)
59  await update($, limits, () => shown)
60  limitsAt = at
61  await $.store.set(STORED_LIMITS, { at, windows: shown })
62}
63
64async function takeSharedLimits($: EngineInterface) {
65  const stored = await $.store.get(STORED_LIMITS)
66  if (typeof stored !== 'object' || stored === null) return
67  const { at, windows } = stored as { at?: unknown; windows?: unknown }
68  if (typeof at !== 'number' || at <= limitsAt || !Array.isArray(windows) || windows.length === 0) return
69  limitsAt = at
70  await update($, limits, () => toLimits(windows as Limit[]))
71}
72
73/**
74 * Reads the main thread's cached prefix once more with a one-word fork, which
75 * refreshes the cache entry without adding a message to the conversation.
76 */
77async function warm($: EngineInterface): Promise<boolean> {
78  if (await read($, isWarming)) return false
79  await update($, isWarming, () => true)
80  try {
81    const previous = await read($, cache)
82    const at = await $.clock.now()
83    const reply = await $.model.fork({ prompt: WARM_PROMPT })
84    if (!reply.isAnswered) {
85      $.ui.toast(`Could not warm the cache: ${reply.reason}`)
86      return false
87    }
88    const { usage } = reply
89    if (previous !== null && usage.cache_read_input_tokens < cachedPrefix(previous) * 0.5) {
90      $.ui.toast('The cache had already gone cold: the warm-up read the conversation afresh')
91    }
92    await update($, cache, () => ({
93      at,
94      model: previous?.model ?? '',
95      read: usage.cache_read_input_tokens,
96      write: usage.cache_creation_input_tokens,
97      uncached: usage.input_tokens,
98    }))
99    await update($, now, () => at)
100    return true
101  } finally {
102    await update($, isWarming, () => false)
103  }
104}
105
106async function chooseAuto($: EngineInterface) {
107  if ((await read($, auto)) !== null) {
108    await update($, auto, () => null)
109    $.ui.toast('Auto-warming is off')
110    return
111  }
112
113  await $.ui.open({ id: AUTO_DIALOG, title: 'Auto-warm', focus: true, closeOnEscape: true, rows: 4 })
114}
115
116async function startTypedAuto($: EngineInterface, text: string) {
117  const hours = parseHours(text)
118  if (hours === undefined) {
119    $.ui.toast(`Not a number of hours: "${text}"`)
120    return
121  }
122  await startAuto($, hours)
123}
124
125async function startAuto($: EngineInterface, hours: number) {
126  await $.ui.close({ id: AUTO_DIALOG })
127  const ttl = await read($, ttlMs)
128  const at = await $.clock.now()
129  const total = warmsFor(hours, ttl)
130  await update($, auto, () => ({ until: at + hours * ONE_HOUR, done: 0, total }))
131  $.ui.toast(`Auto-warming is on: ${autoLabel(hours, ttl)}`)
132}
133
134// Auto-warming warms once the countdown reaches its lead, and stops when its
135// hours or warms run out or the cache went cold anyway. The band shows whole
136// minutes, so it is redrawn when the minute changes, or every half minute for
137// the reset times.
138async function tick($: EngineInterface) {
139  await takeSharedLimits($)
140  const at = await $.clock.now()
141  const reading = await read($, cache)
142  const ttl = await read($, ttlMs)
143  const left = reading === null ? -1 : reading.at + ttl - at
144
145  const plan = await read($, auto)
146  if (plan !== null) {
147    if (at >= plan.until || plan.done >= plan.total) {
148      await update($, auto, () => null)
149      $.ui.toast('Auto-warming has finished')
150    } else if (reading !== null && left <= 0) {
151      await update($, auto, () => null)
152      $.ui.toast('The cache went cold, so auto-warming is off')
153    } else if (reading !== null && left <= autoLead(ttl) && !isTurnRunning && (await warm($))) {
154      await update($, auto, value => (value === null ? null : { ...value, done: value.done + 1 }))
155    }
156  }
157
158  const minute = Math.ceil(left / MINUTE)
159  if (minute !== lastTickMinute || at - lastTickWrite >= 30_000) {
160    lastTickMinute = minute
161    lastTickWrite = at
162    await update($, now, () => at)
163  }
164}
165
166export const register: Register = (on, options) => {
167  const pinnedTtl = ttlFromOption(options.cacheTtl)
168
169  on('session.start', async ($, e, next) => {
170    await $.command.register({
171      name: COMMAND,
172      description: 'Show or hide the session pulse band: rate limits and prompt cache',
173    })
174
175    // Before its first request a process has no reading yet; a reload or resume
176    // keeps the windows the session's state already holds, or takes the store's.
177    const usage = await $.session.usage()
178    if (usage.rateLimits.length > 0) await shareLimits($, usage.rateLimits)
179    else await takeSharedLimits($)
180
181    const stored = await $.store.get(STORED_TTL)
182    const learned = typeof stored === 'number' ? stored : undefined
183    await update($, ttlMs, () => pinnedTtl ?? learned ?? ONE_HOUR)
184    await update($, isTtlLearned, () => pinnedTtl === undefined && learned !== undefined)
185    const startedAt = await $.clock.now()
186    await update($, now, () => startedAt)
187
188    $.clock.every(TICK_MS, () => void tick($))
189
190    return next(e)
191  })
192
193  on('command.run', { command: COMMAND }, async $ => {
194    const hidden = await update($, isHidden, value => !value)
195
196    return { text: hidden ? 'Session pulse hidden.' : 'Session pulse shown.' }
197  })
198
199  on('session.measure', async ($, e, next) => {
200    if (e.changed.includes('rateLimits')) {
201      if (e.rateLimits.length > 0) await shareLimits($, e.rateLimits)
202      else await update($, limits, () => [])
203    }
204
205    return next(e)
206  })
207
208  on('turn.start', async ($, e, next) => {
209    if (e.agentId === undefined) isTurnRunning = true
210
211    return next(e)
212  })
213
214  on('turn.complete', async ($, e, next) => {
215    if (e.agentId === undefined) isTurnRunning = false
216
217    return next(e)
218  })
219
220  // Every request of the main thread refreshes the cache entry it reads, so the
221  // countdown runs from the last one sent. Subagents cache prefixes of their own.
222  on('turn.step', async function* ($, e, next) {
223    if (e.agentId !== undefined) {
224      return yield* next(e)
225    }
226
227    const at = await $.clock.now()
228    const result = yield* next(e)
229    const usage = result.usage
230    if (usage === null) {
231      return result
232    }
233
234    const current: CacheReading = {
235      at,
236      model: usage.model,
237      read: usage.cache_read_input_tokens,
238      write: usage.cache_creation_input_tokens,
239      uncached: usage.input_tokens,
240    }
241    const previous = await read($, cache)
242    await update($, cache, () => current)
243    await update($, now, () => at)
244
245    if (pinnedTtl === undefined && previous !== null && previous.model !== '') {
246      // A lone miss can be an entry evicted before its hour, so five minutes
247      // takes two in a row; a hit after the same quiet starts the count over.
248      let learned = learnTtl(previous, current)
249      if (learned === FIVE_MINUTES) {
250        quietMisses += 1
251        if (quietMisses < 2) learned = undefined
252      } else if (learned !== undefined) {
253        quietMisses = 0
254      }
255      if (learned !== undefined && learned !== (await read($, ttlMs))) {
256        await update($, ttlMs, () => learned)
257        await update($, isTtlLearned, () => true)
258        await $.store.set(STORED_TTL, learned)
259        $.ui.toast(`The prompt cache seems to live ${ttlLabel(learned)}`)
260      }
261    }
262
263    return result
264  })
265
266  on('session.end', { reason: 'clear' }, async ($, e, next) => {
267    await update($, cache, () => null)
268    await update($, auto, () => null)
269
270    return next(e)
271  })
272
273  on('ui.render', { component: 'Pane', requestId: AUTO_DIALOG }, async ($, e) => {
274    const elements = $.ui.resolve(e)
275    const { Box, Button, Text } = elements
276    const Input = 'Input' in elements ? elements.Input : undefined
277    const ttl = await read($, ttlMs)
278
279    return (
280      <Box flexDirection="column" gap={1}>
281        <Text>{AUTO_QUESTION}</Text>
282        <Box flexDirection="row" gap={1} flexWrap="wrap">
283          {AUTO_HOURS.map((hours, index) => (
284            <Button
285              key={`auto-${hours}`}
286              label={autoLabel(hours, ttl)}
287              autoFocus={index === 0 ? true : undefined}
288              onPress={() => startAuto($, hours)}
289            />
290          ))}
291          <Button key="auto-cancel" label="Cancel" role="dismiss" onPress={() => $.ui.close({ id: AUTO_DIALOG })} />
292        </Box>
293        {Input && (
294          <Input
295            key="auto-hours"
296            label="Other"
297            placeholder="hours, up to 24"
298            submitLabel="Start"
299            onSubmit={text => startTypedAuto($, text)}
300          />
301        )}
302      </Box>
303    )
304  })
305
306  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
307    if (e.props.hasSurvey || (await read($, isHidden))) {
308      return next(e)
309    }
310
311    const { Box, Button, Text } = $.ui.resolve(e)
312    const t = await read($, now)
313    const windows = await read($, limits)
314    const reading = await read($, cache)
315    const ttl = await read($, ttlMs)
316    const plan = await read($, auto)
317
318    const left = reading === null ? 0 : reading.at + ttl - t
319    const isWarm = reading !== null && left > 0
320    const cacheText = reading === null ? 'no requests yet' : isWarm ? minutesLeft(left) : 'cold'
321    const shown = windows.map(limit => ({
322      limit,
323      label: kindLabel(limit.kind),
324      percent: `${Math.round(limit.percentUsed)}%`,
325      reset: limit.kind === 'five_hour' ? resetsIn(limit, t) : undefined,
326    }))
327    const autoText = plan === null ? 'Auto Warm' : `Auto Warm ${plan.done}/${plan.total}`
328
329    // The row's text in cells: the label's column and its gap, each part after a
330    // gap, and the button's `[ label ]` after one.
331    const textCells =
332      8 +
333      cacheText.length +
334      shown.reduce(
335        (sum, { label, percent, reset }) =>
336          sum + 1 + label.length + 1 + percent.length + (reset === undefined ? 0 : ` resets in ${reset}`.length),
337        0,
338      ) +
339      1 +
340      autoText.length +
341      4
342    const cells = barCells(e.props.bodyColumns, textCells, (reading === null ? 0 : 1) + windows.length)
343
344    const row = (label: string, ...children: RenderChildren[]) => (
345      <Box flexDirection="row" gap={1}>
346        <Box width={7} flexShrink={0}>
347          <Text dimColor>{label}</Text>
348        </Box>
349        <Box flexDirection="row" gap={1} flexWrap="wrap" flexShrink={1}>
350          {children}
351        </Box>
352      </Box>
353    )
354
355    let cacheParts
356    if (reading === null) {
357      cacheParts = <Text dimColor>{cacheText}</Text>
358    } else if (isWarm) {
359      const color = isRunningLow(left, ttl) ? 'warning' : 'suggestion'
360      cacheParts = (
361        <Text color={color}>
362          {cells === 0 ? '' : `${bar(left / ttl, cells)} `}
363          {cacheText}
364        </Text>
365      )
366    } else {
367      cacheParts = <Text color="error">{cells === 0 ? '' : `${bar(0, cells)} `}{cacheText}</Text>
368    }
369
370    const limitParts = shown.map(({ limit, label, percent, reset }) => {
371      const color = limit.percentUsed >= 95 ? 'error' : limit.percentUsed >= 80 ? 'warning' : 'suggestion'
372      return (
373        <Text>
374          <Text dimColor>{label} </Text>
375          {cells > 0 && <Text color={color}>{bar(limit.percentUsed / 100, cells)} </Text>}
376          <Text weight="bold">{percent}</Text>
377          {reset !== undefined && <Text dimColor> resets in {reset}</Text>}
378        </Text>
379      )
380    })
381
382    return (
383      <Box flexDirection="row" gap={1} justifyContent="space-between">
384        {row('cache', cacheParts, ...limitParts)}
385        <Button
386          key="auto"
387          label={autoText}
388          variant={plan === null ? 'secondary' : 'primary'}
389          onPress={() => chooseAuto($)}
390        />
391      </Box>
392    )
393  })
394}
395
hooks/format.ts 122 lines
1import type { CacheReading, Limit } from '../types'
2
3export const MINUTE = 60_000
4export const FIVE_MINUTES = 5 * MINUTE
5export const ONE_HOUR = 60 * MINUTE
6
7const KIND_LABELS: Record<string, string> = {
8  five_hour: '5h',
9  seven_day: 'week',
10  seven_day_opus: 'week Opus',
11  seven_day_sonnet: 'week Sonnet',
12  spend_limit: 'spend',
13}
14
15export function kindLabel(kind: string): string {
16  return KIND_LABELS[kind] ?? kind
17}
18
19export function bar(fraction: number, width = 8): string {
20  const filled = Math.round(Math.min(1, Math.max(0, fraction)) * width)
21  return '█'.repeat(filled) + '░'.repeat(width - filled)
22}
23
24/**
25 * The widest bar, up to eight cells, with which `bars` of them, each with its
26 * space, fit beside `textCells` of text in `columns`: a narrowing band shortens
27 * the bars before it drops them. 0, no bars, when not even three-cell ones fit.
28 */
29export function barCells(columns: number, textCells: number, bars: number): number {
30  const cells = Math.min(8, Math.floor((columns - textCells) / bars) - 1)
31  return cells >= 3 ? cells : 0
32}
33
34/** "2h 59m", "18m", "<1m": a span rounded down to the minute. */
35export function humanSpan(ms: number): string {
36  if (ms < MINUTE) return '<1m'
37  const minutes = Math.floor(ms / MINUTE)
38  const days = Math.floor(minutes / 1440)
39  const hours = Math.floor((minutes % 1440) / 60)
40  const rest = minutes % 60
41  if (days > 0) return hours > 0 ? `${days}d ${hours}h` : `${days}d`
42  if (hours > 0) return rest > 0 ? `${hours}h ${rest}m` : `${hours}h`
43  return `${rest}m`
44}
45
46/** What is left of the cache, rounded up: "18 min" until the last minute. */
47export function minutesLeft(ms: number): string {
48  return `${Math.max(1, Math.ceil(ms / MINUTE))} min`
49}
50
51export function ttlLabel(ttlMs: number): string {
52  return ttlMs >= ONE_HOUR ? '1 hour' : `${Math.round(ttlMs / MINUTE)} minutes`
53}
54
55export function resetsIn(limit: Limit, now: number): string | undefined {
56  if (limit.resetsAt === undefined) return undefined
57  const at = Date.parse(limit.resetsAt)
58  return Number.isNaN(at) ? undefined : humanSpan(Math.max(0, at - now))
59}
60
61/** The prefix the cache held after the request: what the next one reads or writes again. */
62export function cachedPrefix(cache: CacheReading): number {
63  return cache.read + cache.write
64}
65
66/** How long before the cache goes cold auto-warming sends its warm. */
67export function autoLead(ttlMs: number): number {
68  return ttlMs >= ONE_HOUR ? 2 * MINUTE : MINUTE
69}
70
71/** The countdown turns from blue to orange at a quarter of the TTL (15 of 60 minutes). */
72export function isRunningLow(left: number, ttlMs: number): boolean {
73  return left <= ttlMs / 4
74}
75
76export function warmsWord(n: number): string {
77  return n === 1 ? 'warm-up' : 'warm-ups'
78}
79
80/** How many warms keep the cache for `hours` at this TTL. */
81export function warmsFor(hours: number, ttlMs: number): number {
82  return Math.max(1, Math.round((hours * ONE_HOUR) / ttlMs))
83}
84
85export const AUTO_HOURS = [1, 2, 4, 8] as const
86
87export function autoLabel(hours: number, ttlMs: number): string {
88  const warms = warmsFor(hours, ttlMs)
89  return `${hours} h (${warms} ${warmsWord(warms)})`
90}
91
92/** The hours an answer names: one of the labels, or a number typed under Other. */
93export function parseHours(answer: string): number | undefined {
94  const match = answer.replace(',', '.').match(/\d+(?:\.\d+)?/)
95  if (match === null) return undefined
96  const hours = Number(match[0])
97  return hours > 0 && hours <= 24 ? hours : undefined
98}
99
100export function ttlFromOption(option: unknown): number | undefined {
101  if (option === '5m') return FIVE_MINUTES
102  if (option === '1h') return ONE_HOUR
103  return undefined
104}
105
106/**
107 * What a request says about the cache's lifetime, given the reading before it:
108 * a hit after more than five minutes of quiet proves an hour; a miss after
109 * five minutes to an hour of quiet, on the same model, points back to five.
110 */
111export function learnTtl(previous: CacheReading, current: CacheReading): number | undefined {
112  const gap = current.at - previous.at
113  const prefix = cachedPrefix(previous)
114  if (prefix === 0 || gap <= FIVE_MINUTES + 30_000) return undefined
115  if (current.read >= prefix * 0.5) return ONE_HOUR
116  const isMiss = current.read < prefix * 0.25 && current.write >= prefix * 0.5
117  if (isMiss && gap < ONE_HOUR - MINUTE && current.model === previous.model) {
118    return FIVE_MINUTES
119  }
120  return undefined
121}
122
types/index.d.ts 29 lines
1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type CacheReading = {
4  /** When the request that last read or wrote the cache was sent, in `$.clock.now()` ms. */
5  at: number
6  model: string
7  read: number
8  write: number
9  uncached: number
10}
11
12/** Auto-warming: on until `until`, `done` of `total` warms sent. */
13export type AutoWarm = { until: number; done: number; total: number }
14
15declare module 'claude-code' {
16  interface PluginState {
17    'session-pulse': {
18      limits: Limit[]
19      cache: CacheReading | null
20      ttlMs: number
21      isTtlLearned: boolean
22      now: number
23      isHidden: boolean
24      isWarming: boolean
25      auto: AutoWarm | null
26    }
27  }
28}
29