SLOPSHOPPER

cache-timer

Claude Code mod that counts down the prompt cache, compacts a large conversation just before it expires, and can push a reminder to your phone.

newbandtoastprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-timer
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ 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 cache — compact notify ⚙ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
cache — compact notify ⚙
README

cache-timer

cache-timer is a Claude Code mod that shows how long the prompt cache of the current conversation stays warm, and compacts a large conversation just before that cache expires, so returning to it does not re-send the whole context at the cache-write price.

It runs only in Claude Code, which loads its function hooks (an early-access plugin API). Other agent tools do not load it.

What It Does

  • Countdown band above the prompt: 54:12 ━━━━━━──── 1h · 612k/500k reads as the time left on the cache, a bar of the lifetime left, the cache lifetime, and the tokens the next request re-sends against the compaction threshold (the threshold is left out while auto-compaction is off). Icons on the right show whether auto-compaction and notifications are on; the gear opens a row with a switch and threshold picker (300k, 500k, or 700k) for compaction, and a switch and lead-time picker (10, 15, or 20 minutes) for notifications. In the terminal, click them or press ctrl+x tab to focus the band. The band shows in the terminal and in sessions the desktop app runs; viewers that attach over Remote Control, including the iOS app, do not draw mod UI.
  • Notification before expiry: when a 1-hour cache has the notification lead time left, it sends one push notification per idle period with the session's name and the question its last reply asked, for example Mod check / 「Shall I release 0.2.0?」 / — cache expires in 10 min. It goes through Claude Code's own push notification, so it reaches the phone only while the session has Remote Control on and you are not active in it. A 5-minute cache does not notify.
  • Compaction before expiry: when it is on and the context holds at least the threshold, it runs the same compaction /compact runs once the cache has the lead time left, at most once per idle period, and reports the result in a toast. A running turn keeps the cache warm on its own, so no compaction runs then. Sessions without a person at the prompt (the desktop app, the SDK, -p) do not let a mod compact directly, so there the mod runs /compact as if you typed it.
  • Exact cache lifetime: Claude Code gives status-line scripts the lifetime as prompt_cache.ttl, but the function-hook API does not expose it. After each turn the mod reads the tail of the session transcript, where each response records whether its cache write was 1-hour or 5-minute, and uses that. The transcript format is Claude Code's own and may change; when no write is found there, the mod falls back to inference: it starts from 1 hour and, after a gap of 5.5 to 58 minutes on the same model, a cache hit confirms 1 hour and a miss switches to 5 minutes. Pinning cache_ttl overrides both.

Settings

Each field is a row under /config; a change there or in the band reloads the mod, and the countdown carries over the reload. Sessions the desktop app runs have no /config rows for plugins, so a change made in the band there is kept in the mod's own store and applies to later sessions too; a later /config change to the same field replaces it.

FieldDefaultMeaning
auto_compacttrueCompact before expiry at all
threshold_k500Compact only when the context holds at least this many thousand tokens
notifytrueSend a push notification before a 1-hour cache expires
notify_lead_minutes10Minutes before a 1-hour cache expires to notify
cache_ttlautoauto (transcript, then inference), 1h, or 5m
lead_seconds_1h300Seconds before a 1-hour cache expires to start compacting
lead_seconds_5m60Seconds before a 5-minute cache expires to start compacting

Layout

.claude-plugin/plugin.json   Name and settings
hooks/hooks.json             Names the hooks module
hooks/register.tsx           Countdown, inference, compaction, and the band
hooks/register.test.tsx      Mock-clock tests: claude plugin test plugins/cache-timer
types/index.d.ts             Type of the cache clock the module keeps

License

MIT

Source 2 files
hooks/register.tsx 530 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import type { CacheClock, CacheTtl } from '../types'
4
5const TTL_MS: Record<CacheTtl, number> = { '5m': 5 * 60_000, '1h': 60 * 60_000 }
6
7// A gap inside this window tells the two lifetimes apart: a 5m cache has
8// lapsed, a 1h cache has not.
9const INFER_MIN_GAP_MS = 5.5 * 60_000
10const INFER_MAX_GAP_MS = 58 * 60_000
11const INFER_MIN_TOKENS = 20_000
12
13const initial: CacheClock = {
14  lastAt: null,
15  tokens: 0,
16  model: null,
17  ttl: '1h',
18  isTtlObserved: false,
19  isTtlExact: false,
20  handledAt: null,
21  notifiedAt: null,
22  title: null,
23  question: null,
24}
25
26// The tail of the transcript read after each turn: enough to reach the
27// turn's last response, well under what one host command returns.
28const TRANSCRIPT_TAIL_BYTES = 512 * 1024
29
30/**
31 * The lifetime of the last main-conversation cache write recorded in the
32 * transcript tail, or null when none is found. The transcript format is
33 * Claude Code's own and may change; callers fall back to inference.
34 */
35export function ttlFromTranscriptTail(tail: string): CacheTtl | null {
36  const lines = tail.split('\n')
37
38  for (let i = lines.length - 1; i >= 0; i -= 1) {
39    let entry: unknown
40    try {
41      entry = JSON.parse(lines[i])
42    } catch {
43      continue
44    }
45    const row = entry as {
46      type?: string
47      isSidechain?: boolean
48      message?: { usage?: { cache_creation?: { ephemeral_1h_input_tokens?: number; ephemeral_5m_input_tokens?: number } } }
49    }
50    const written = row.type === 'assistant' && row.isSidechain !== true ? row.message?.usage?.cache_creation : undefined
51
52    if ((written?.ephemeral_1h_input_tokens ?? 0) > 0) {
53      return '1h'
54    }
55    if ((written?.ephemeral_5m_input_tokens ?? 0) > 0) {
56      return '5m'
57    }
58  }
59
60  return null
61}
62
63// The session's cache clock. Module state, saved to the plugin's store under
64// the session id on each change, so a reload (a /config change, a press in
65// the band) restores it in session.start instead of starting over.
66let clock: CacheClock = initial
67
68const storeKey = (sessionId: string): string => `clock:${sessionId}`
69
70const update = ($: EngineInterface, fn: (prev: CacheClock) => CacheClock): void => {
71  clock = fn(clock)
72  const saved = clock
73  void (async () => {
74    await $.store.set(storeKey(await $.session.id()), saved)
75  })().catch(() => {
76    // An unsaved clock only costs the countdown after a reload.
77  })
78}
79
80const QUESTION_MAX = 140
81
82/**
83 * The question a reply leaves the user with: its last sentence that ends in a
84 * question mark, or else its last paragraph, without Markdown marks and cut to
85 * fit a push notification. Null for an empty reply.
86 */
87export function questionOf(reply: string): string | null {
88  const plain = reply
89    .replace(/```[\s\S]*?```/g, ' ')
90    .replace(/[*_`#>]/g, '')
91    .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
92  const paragraphs = plain.split(/\n\s*\n/).map(p => p.replace(/\s+/g, ' ').trim()).filter(p => p !== '')
93
94  if (paragraphs.length === 0) {
95    return null
96  }
97
98  // A full stop inside a word (a version, a path) does not end a sentence.
99  const sentences = paragraphs.flatMap(p => p.split(/(?<=[。!?])|(?<=[.!?])\s+/)).map(t => t.trim()).filter(t => t !== '')
100  const asked = [...sentences].reverse().find(t => /[??]$/.test(t))
101  const text = asked ?? paragraphs[paragraphs.length - 1] ?? ''
102
103  return text.length > QUESTION_MAX ? `${text.slice(0, QUESTION_MAX - 1)}…` : text
104}
105
106/** What the band shows, set by the clock's tick. */
107type View = {
108  /** Time left (`54:12`), `expired`, `compacting…`, or `—` before a request. */
109  time: string
110  /** Lifetime and tokens against the threshold: `1h · 612k/500k`. */
111  detail: string
112  /** Share of the cache lifetime left, 0 to 1; null when no countdown runs. */
113  left: number | null
114}
115
116let view: View = { time: '—', detail: '', left: null }
117
118// Whether the band shows its settings row; per process, closed after a reload.
119let isExpanded = false
120
121const formatRemaining = (ms: number): string => {
122  const total = Math.max(0, Math.ceil(ms / 1000))
123  const minutes = Math.floor(total / 60)
124  const seconds = String(total % 60).padStart(2, '0')
125
126  return `${minutes}:${seconds}`
127}
128
129const formatK = (tokens: number): string => `${Math.round(tokens / 1000)}k`
130
131let isCompacting = false
132
133const setView = ($: EngineInterface, next: View): void => {
134  if (next.time !== view.time || next.detail !== view.detail || next.left !== view.left) {
135    view = next
136    $.ui.invalidate('ui.render')
137  }
138}
139
140const THRESHOLD_PRESETS_K = [300, 500, 700]
141const NOTIFY_LEAD_PRESETS_MIN = [10, 15, 20]
142
143const BAR_CELLS = 10
144
145// Icons for the desktop band; the terminal draws text instead.
146const ON = '#1D9E75'
147const OFF = '#888780'
148const icon = (paths: string, color: string): string =>
149  `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${paths}</svg>`
150const CLOCK = '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/>'
151const COMPRESS = '<path d="M5 9h4V5M15 5v4h4M5 15h4v4M15 19v-4h4"/>'
152const BELL = '<path d="M10 5a2 2 0 1 1 4 0a7 7 0 0 1 4 6v3a4 4 0 0 0 2 3H4a4 4 0 0 0 2-3v-3a7 7 0 0 1 4-6"/><path d="M9 17v1a3 3 0 0 0 6 0v-1"/>'
153const bar = (left: number): string =>
154  `<svg xmlns="http://www.w3.org/2000/svg" width="80" height="4" viewBox="0 0 80 4"><rect width="80" height="4" rx="2" fill="${OFF}" fill-opacity="0.35"/><rect width="${Math.round(80 * left)}" height="4" rx="2" fill="${left > 0.2 ? ON : '#BA7517'}"/></svg>`
155
156/** The `/config` key of one of this plugin's fields, as the engine names it. */
157async function configKey($: EngineInterface, field: string): Promise<string | undefined> {
158  const rows = await $.config.list()
159
160  return rows.find(row => row.key.startsWith('cache-timer') && row.key.endsWith(`.${field}`))?.key
161}
162
163// Settings changed in the band where `/config` has no rows for them (a session
164// the desktop app runs). Kept in the plugin store across sessions; a `/config`
165// change to the same field drops the entry.
166const OVERRIDES_KEY = 'settings'
167let overrides: Record<string, boolean | number> = {}
168
169const saveOverrides = async ($: EngineInterface): Promise<void> => {
170  try {
171    await $.store.set(OVERRIDES_KEY, overrides)
172  } catch {
173    // Unsaved: the change holds for this process only.
174  }
175}
176
177async function setOption($: EngineInterface, field: string, value: boolean | number): Promise<void> {
178  const key = await configKey($, field)
179
180  if (key === undefined) {
181    overrides = { ...overrides, [field]: value }
182    $.ui.invalidate('ui.render')
183    await saveOverrides($)
184
185    return
186  }
187
188  const result = await $.config.set({ key, value })
189
190  if ('deny' in result && result.deny !== undefined) {
191    $.ui.toast(`cache-timer: ${field} not changed (${result.deny})`)
192  }
193}
194
195// A session with no person at the prompt (-p, the SDK, and so every
196// desktop-app session) refuses $.session.compact(): compaction there runs as
197// a /compact prompt, which $.command.run queues as if the person typed it.
198let isHeadless = false
199
200async function queueCompact($: EngineInterface, s: CacheClock): Promise<void> {
201  // One attempt per idle period: the /compact that runs resets the clock
202  // through the session.compact hook; one that never runs is not repeated.
203  update($, prev => ({ ...prev, handledAt: s.lastAt }))
204
205  try {
206    await $.command.run({ command: 'compact' })
207    $.ui.toast(`cache-timer: ran /compact before cache expiry (${formatK(s.tokens)})`)
208  } catch (err) {
209    $.ui.toast(`cache-timer: could not run /compact (${String(err)})`)
210  }
211}
212
213async function compactBeforeExpiry($: EngineInterface, s: CacheClock): Promise<void> {
214  isCompacting = true
215  setView($, { time: 'compacting…', detail: formatK(s.tokens), left: null })
216
217  try {
218    if (isHeadless) {
219      await queueCompact($, s)
220
221      return
222    }
223
224    const result = await $.session.compact()
225
226    if (result.skip !== undefined) {
227      update($, prev => ({ ...prev, handledAt: s.lastAt }))
228      $.ui.toast(`cache-timer: auto-compaction skipped (${result.skip})`)
229    } else {
230      // The engine skips the caller's own session.compact hook, so the reset
231      // that hook does for other compactions happens here.
232      update($, prev => ({ ...prev, lastAt: null, tokens: result.tokensAfter ?? 0, handledAt: null }))
233      const after = result.tokensAfter === undefined ? '' : ` → ${formatK(result.tokensAfter)}`
234      $.ui.toast(`compacted before cache expiry: ${formatK(s.tokens)}${after}`)
235    }
236  } catch {
237    // A turn is running: compact rejects until it ends, and handledAt stays
238    // unset so a later tick tries again if the turn ends without a response.
239  } finally {
240    isCompacting = false
241  }
242}
243
244export const register: Register = (on, options) => {
245  const option = (field: string, fallback: boolean | number) => overrides[field] ?? options[field] ?? fallback
246  const isAutoCompact = () => option('auto_compact', true) !== false
247  const isNotify = () => option('notify', true) !== false
248  const notifyLeadMin = () => Number(option('notify_lead_minutes', 10))
249  const thresholdK = () => Number(option('threshold_k', 500))
250  const ttlMode = String(options.cache_ttl ?? 'auto')
251  const leadMs: Record<CacheTtl, number> = {
252    '1h': Number(options.lead_seconds_1h ?? 300) * 1000,
253    '5m': Number(options.lead_seconds_5m ?? 60) * 1000,
254  }
255
256  // Tokens against the compaction threshold while auto-compaction is on.
257  const tokensOf = (tokens: number): string => (isAutoCompact() ? `${formatK(tokens)}/${thresholdK()}k` : formatK(tokens))
258
259  const effectiveTtl = (s: CacheClock): { ttl: CacheTtl; label: string } => {
260    if (ttlMode === '1h' || ttlMode === '5m') {
261      return { ttl: ttlMode, label: ttlMode }
262    }
263
264    return { ttl: s.ttl, label: s.ttl }
265  }
266
267  on('turn.step', async function* ($, e, next) {
268    // The cache entry is refreshed when the request is processed, not when
269    // the response ends, so the countdown starts from the send time.
270    const sentAt = await $.clock.now()
271    const result = yield* next(e)
272    const usage = result.usage
273
274    if (e.agentId !== undefined || usage === null) {
275      return result
276    }
277
278    const tokens =
279      usage.input_tokens +
280      usage.cache_read_input_tokens +
281      usage.cache_creation_input_tokens +
282      usage.output_tokens
283
284    update($, prev => {
285      let { ttl, isTtlObserved } = prev
286      const gap = prev.lastAt === null ? 0 : sentAt - prev.lastAt
287      const isComparable =
288        !prev.isTtlExact &&
289        prev.lastAt !== null &&
290        prev.model === usage.model &&
291        prev.tokens >= INFER_MIN_TOKENS &&
292        gap >= INFER_MIN_GAP_MS &&
293        gap <= INFER_MAX_GAP_MS
294
295      if (isComparable) {
296        if (usage.cache_read_input_tokens >= prev.tokens * 0.5) {
297          ttl = '1h'
298          isTtlObserved = true
299        } else if (usage.cache_read_input_tokens <= prev.tokens * 0.1) {
300          ttl = '5m'
301          isTtlObserved = true
302        }
303      }
304
305      return { ...prev, lastAt: sentAt, tokens, model: usage.model, ttl, isTtlObserved }
306    })
307
308    return result
309  })
310
311  // Any compaction (manual, automatic, ours) replaces the cached prefix.
312  on('session.compact', async ($, e, next) => {
313    const result = await next(e)
314
315    if (e.trigger !== 'precompute' && result.messages !== undefined && e.agentId === undefined) {
316      update($, prev => ({
317        ...prev,
318        lastAt: null,
319        tokens: result.tokensAfter ?? 0,
320        handledAt: null,
321      }))
322    }
323
324    return result
325  })
326
327  // At the end of each main turn, take the exact lifetime from the transcript.
328  // The session's name, for the notification.
329  on('classic.UserPromptSubmit', async ($, e, next) => {
330    if (e.session_title !== undefined && e.session_title !== clock.title) {
331      update($, prev => ({ ...prev, title: e.session_title ?? null }))
332    }
333
334    return next(e)
335  })
336
337  on('classic.Stop', async ($, e, next) => {
338    const result = await next(e)
339    update($, prev => ({ ...prev, question: questionOf(e.last_assistant_message ?? '') }))
340
341    try {
342      const ran = await $.process.run(['tail', '-c', String(TRANSCRIPT_TAIL_BYTES), e.transcript_path])
343      const ttl = ran.exitCode === 0 ? ttlFromTranscriptTail(ran.stdout) : null
344
345      if (ttl !== null) {
346        update($, prev => ({ ...prev, ttl, isTtlObserved: true, isTtlExact: true }))
347      }
348    } catch {
349      // No tail command or no transcript: inference keeps the estimate.
350    }
351
352    return result
353  })
354
355  // A /clear or resume starts another conversation in the same process.
356  on('session.end', async ($, e, next) => {
357    try {
358      await $.store.delete(storeKey(e.sessionId))
359    } catch {
360      // A leftover entry is only read back by a session with this id.
361    }
362
363    if (e.reason === 'clear' || e.reason === 'resume') {
364      update($, prev => ({ ...initial, ttl: prev.ttl, isTtlObserved: prev.isTtlObserved, isTtlExact: prev.isTtlExact }))
365    }
366
367    return next(e)
368  })
369
370  // A change made in /config wins over one the band kept in the store.
371  on('config.set', async ($, e, next) => {
372    const result = await next(e)
373    const field = e.key.startsWith('cache-timer') ? e.key.slice(e.key.lastIndexOf('.') + 1) : undefined
374
375    if (field !== undefined && field in overrides && !('deny' in result && result.deny !== undefined)) {
376      const { [field]: _dropped, ...rest } = overrides
377      overrides = rest
378      await saveOverrides($)
379    }
380
381    return result
382  })
383
384  on('session.start', async ($, e, next) => {
385    isHeadless = !e.isInteractive
386
387    try {
388      const saved = await $.store.get(OVERRIDES_KEY)
389
390      if (saved !== null && typeof saved === 'object') {
391        overrides = saved as Record<string, boolean | number>
392        $.ui.invalidate('ui.render')
393      }
394    } catch {
395      // No saved settings: the manifest's values apply.
396    }
397
398    try {
399      const saved = (await $.store.get(storeKey(await $.session.id()))) as CacheClock | undefined
400
401      if (saved !== undefined) {
402        clock = { ...initial, ...saved }
403      }
404    } catch {
405      // Nothing saved: the next request sets the clock.
406    }
407
408    $.clock.every(1000, async () => {
409      if (isCompacting) {
410        return
411      }
412
413      const s = clock
414      const { ttl, label } = effectiveTtl(s)
415
416      if (s.lastAt === null) {
417        setView($, { time: '—', detail: s.tokens > 0 ? tokensOf(s.tokens) : '', left: null })
418
419        return
420      }
421
422      const remaining = TTL_MS[ttl] - ((await $.clock.now()) - s.lastAt)
423      const isLarge = isAutoCompact() && s.tokens >= thresholdK() * 1000
424
425      if (remaining <= 0) {
426        setView($, { time: 'expired', detail: `${label} · ${tokensOf(s.tokens)}`, left: 0 })
427
428        return
429      }
430
431      const willCompact = isLarge && s.handledAt !== s.lastAt
432      setView($, { time: formatRemaining(remaining), detail: `${label} · ${tokensOf(s.tokens)}`, left: remaining / TTL_MS[ttl] })
433
434      // A 1h cache only: a 5m cache would notify after nearly every reply.
435      if (isNotify() && ttl === '1h' && remaining <= notifyLeadMin() * 60_000 && s.notifiedAt !== s.lastAt) {
436        update($, prev => ({ ...prev, notifiedAt: s.lastAt }))
437        const message = [s.title, s.question === null ? null : `「${s.question}」`, `— cache expires in ${Math.ceil(remaining / 60_000)} min`]
438          .filter(line => line !== null && line !== '')
439          .join('\n')
440        void $.tool
441          .call({ tool: 'PushNotification', message, status: 'proactive' } as never)
442          .catch(() => {
443            // No push tool or a refused call: the band still shows the countdown.
444          })
445      }
446
447      if (isLarge && remaining <= leadMs[ttl] && s.handledAt !== s.lastAt) {
448        await compactBeforeExpiry($, s)
449      }
450    })
451
452    return next(e)
453  })
454
455  // The band above the prompt: the countdown, two state icons, and a gear that
456  // opens a row with the compaction and notification settings. A change
457  // writes the setting, which reloads the mod.
458  on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
459    // The mobile app has no Select; it has not drawn mod UI so far either.
460    if (e.props.hasSurvey || e.surface === 'mobile') {
461      return next(e)
462    }
463
464    const isDesktop = e.surface === 'desktop'
465    const { Box, Button, Select, Text } = $.ui.resolve(e)
466    const Svg = isDesktop ? $.ui.resolve(e).Svg : undefined
467    const auto = isAutoCompact()
468    const notify = isNotify()
469    const thresholdNow = thresholdK()
470    const leadNow = notifyLeadMin()
471    const thresholds = THRESHOLD_PRESETS_K.includes(thresholdNow) ? THRESHOLD_PRESETS_K : [...THRESHOLD_PRESETS_K, thresholdNow]
472    const leads = NOTIFY_LEAD_PRESETS_MIN.includes(leadNow) ? NOTIFY_LEAD_PRESETS_MIN : [...NOTIFY_LEAD_PRESETS_MIN, leadNow]
473    const filled = view.left === null ? 0 : Math.round(BAR_CELLS * view.left)
474
475    const state = (paths: string, isOn: boolean, word: string, alt: string) =>
476      Svg !== undefined ? (
477        <Svg source={icon(paths, isOn ? ON : OFF)} alt={`${alt} ${isOn ? 'on' : 'off'}`} width={14} height={14} />
478      ) : (
479        <Text color={isOn ? 'success' : 'inactive'}>{word}</Text>
480      )
481
482    return (
483      <Box flexDirection="column">
484        <Box flexDirection="row" justifyContent="space-between" gap={2}>
485          <Box flexDirection="row" alignItems="center" gap={1}>
486            {Svg !== undefined ? <Svg source={icon(CLOCK, OFF)} alt="cache" width={14} height={14} /> : <Text dimColor>cache</Text>}
487            <Text bold>{view.time}</Text>
488            {view.left === null ? null : Svg !== undefined ? (
489              <Svg source={bar(view.left)} alt={`${Math.round(view.left * 100)}% left`} width={80} height={4} />
490            ) : (
491              <Text>
492                <Text color={view.left > 0.2 ? 'success' : 'warning'}>{'━'.repeat(filled)}</Text>
493                <Text dimColor>{'─'.repeat(BAR_CELLS - filled)}</Text>
494              </Text>
495            )}
496            <Text dimColor>{view.detail}</Text>
497          </Box>
498          <Box flexDirection="row" alignItems="center" gap={1}>
499            {state(COMPRESS, auto, 'compact', 'auto-compact')}
500            {state(BELL, notify, 'notify', 'notify')}
501            <Button key="settings" label="⚙" plain dimColor={!isExpanded} onPress={() => {
502              isExpanded = !isExpanded
503              $.ui.invalidate('ui.render')
504            }} />
505          </Box>
506        </Box>
507        {isExpanded ? (
508          <Box flexDirection="row" alignItems="center" gap={1}>
509            <Button key="auto" label="compact" variant={auto ? 'primary' : 'secondary'} onPress={() => setOption($, 'auto_compact', !auto)} />
510            <Select
511              key="threshold"
512              options={thresholds.map(k => ({ value: String(k), label: `≥${k}k` }))}
513              value={String(thresholdNow)}
514              onSelect={(value: string) => setOption($, 'threshold_k', Number(value))}
515            />
516            <Text dimColor>│</Text>
517            <Button key="notify" label="notify" variant={notify ? 'primary' : 'secondary'} onPress={() => setOption($, 'notify', !notify)} />
518            <Select
519              key="notify_lead"
520              options={leads.map(m => ({ value: String(m), label: `${m}m before` }))}
521              value={String(leadNow)}
522              onSelect={(value: string) => setOption($, 'notify_lead_minutes', Number(value))}
523            />
524          </Box>
525        ) : null}
526      </Box>
527    )
528  })
529}
530
types/index.d.ts 25 lines
1export type CacheTtl = '5m' | '1h'
2
3export type CacheClock = {
4  /** When the last main-thread model request was sent (ms since epoch); null after a compaction or before the first response. */
5  lastAt: number | null
6  /** Prompt tokens the next request re-sends. */
7  tokens: number
8  /** Model that answered last; a switch forfeits the cache, so inference skips it. */
9  model: string | null
10  /** Cache lifetime, and whether it is confirmed (by the transcript or a hit or miss) or assumed. */
11  ttl: CacheTtl
12  isTtlObserved: boolean
13  /** True once the transcript reported the lifetime of a cache write; inference then stands aside. */
14  isTtlExact: boolean
15  /** The lastAt value an automatic compaction was already attempted for. */
16  handledAt: number | null
17  /** The lastAt value an expiry notification was already sent for. */
18  notifiedAt: number | null
19  /** The session's name, as the last prompt reported it. */
20  title: string | null
21  /** What the last reply asked the user, for the notification. */
22  question: string | null
23}
24
25