SLOPSHOPPER

afk-timer

Shows how long you have been away from a Claude Code session and warns when its prompt cache has likely expired, so you can /clear instead of paying to re-read…

newbandprompttimer
★ 1v0.2.2MITupdated 2026-10-16tlq5l/afk-timer
A shopper browsing a rack in a slop shop
README

afk-timer

A small Claude Code mod that tells you, above the prompt, how long you have been away and whether the session's prompt cache has likely expired. When it has, starting fresh with /clear is usually cheaper than carrying on in the same session.

AFK for 23 min · cache expires in 37 min
Cache likely expired (last reply 1 h 0 min ago): your next message re-reads ~182k tokens at full price. Same task? Carry on. New task? /clear first, it costs under half as much.

Why it exists

Every message you send makes Claude Code send the whole conversation again. The prompt cache lets the model reuse what it already read, so you mostly pay for what's new. A cache only lasts a fixed time after Claude's last reply:

  • 1 hour on a Claude subscription (Pro, Max), while you are within your plan's included usage.
  • 5 minutes on an API key, on a cloud provider, or once a subscription draws on usage credits.

After that, your next message re-reads the whole conversation at the full, uncached price. On a subscription that uses up more of your plan limits. On a 200k-token session that's a big hit for one message.

So when the warning is up:

  • Starting something new? Run /clear first. The old conversation isn't sent again.
  • Need the old context? Just carry on. You pay the full price once, then it's cached again.
  • /compact doesn't help here. It re-reads the whole conversation to summarize it, so it costs the same.
  • Small conversation (under ~60k tokens)? It doesn't matter much. A fresh session starts at about that size anyway, so the line turns calm and drops the advice.

These rules come from 30 days of one heavy user's sessions (1,341 idle gaps of an hour or more):

  • Over the next 10 messages, resuming a cold session cost about 2.4 times what a fresh session did (medians, API list prices).
  • All cold resumes together were under 3% of total usage, so carrying on when you need the old context is fine.
  • Compacting while away didn't pay for itself. Claude Code's own idle compaction fired only in the terminal, and the first message back still missed the cache.

The details are in Claude Code's prompt caching docs.

Install

In Claude Code running in a terminal, type:

/plugin install afk-timer --marketplace tlq5l/afk-timer

Press y to add the marketplace, then Enter to install for your user. If it asks about the one setting, or says an option isn't set, leave it: it uses auto. It starts working right away. The desktop app's Code tab doesn't accept that command, but once you install it from a terminal the line shows there too.

It needs a Claude Code version with function-hook mods (early access; built and tested on 2.1.296).

What it shows

  • AFK for 23 min · cache expires in 37 min: you haven't typed, sent or clicked anything for 5 minutes. The two numbers count from different moments: AFK from your last action, the cache from Claude's last reply.
  • You were AFK for 23 min (dim): shown for 10 seconds when you come back.
  • Cache likely expired …: shown once the cache has likely lapsed (dim and calm when the conversation is small), including right after you resume an old session. It stays while you type and goes away when you send your next message, /clear or /compact.

Settings

One setting, under /config: Prompt cache lifetime (cacheTtl).

  • auto (default): 1 hour if your account reports plan usage limits (a subscription), otherwise 5 minutes.
  • 1h or 5m: pin it. Use 1h if you set promptCacheTtl to 1h on an API key. Use 5m while your subscription is on usage credits.

Turn it off or remove it

claude plugin disable afk-timer@afk-timer
claude plugin uninstall afk-timer@afk-timer

Limits

  • It estimates. A mod can't read the cache's real lifetime, so auto guesses from your account type. When you resume a session it uses Claude Code's own verdict instead.
  • Only the main conversation counts. Subagents keep caches of their own.
  • The token count is the size of the conversation at Claude's last reply, rounded.
  • Watching a long reply without touching anything counts as AFK.

Develop

claude plugin test .
claude plugin validate .
claude --plugin-dir .

Claude Code writes the API types into .claude-plugin/types/ the first time it loads the mod from a folder, which is what tsconfig.json extends.

License

MIT

Source 2 files
hooks/register.tsx 221 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3import type { AfkView, CacheClock, CacheView } from '../types/index'
4
5export const AWAY_AFTER_MS = 5 * 60_000
6export const BACK_SHOWN_MS = 10_000
7export const TICK_MS = 15_000
8export const TOUCH_THROTTLE_MS = 5_000
9// How long Claude Code's main conversation stays cached after Claude's last reply: 1 h on a subscription within plan usage, 5 min otherwise.
10export const CACHE_TTL_MS = { '5m': 5 * 60_000, '1h': 60 * 60_000 } as const
11// A fresh session already starts near this size (system prompt, tools, CLAUDE.md), so below it /clear saves little.
12export const SMALL_CONTEXT_TOKENS = 60_000
13
14export const formatGap = (minutes: number): string => {
15  if (minutes < 60) return `${minutes} min`
16  return `${Math.floor(minutes / 60)} h ${minutes % 60} min`
17}
18
19export const formatTokens = (tokens: number): string => (tokens >= 1000 ? `${Math.round(tokens / 1000)}k` : `${tokens}`)
20
21export const afkLine = (afk: NonNullable<AfkView>, cache: CacheView): string => {
22  if (afk.kind === 'back') return `You were AFK for ${formatGap(afk.minutes)}`
23  const warm = cache?.kind === 'warm' ? ` · cache expires in ${formatGap(cache.minutesLeft)}` : ''
24  return `AFK for ${formatGap(afk.minutes)}${warm}`
25}
26
27export const isSmallContext = (cache: Extract<CacheView, { kind: 'expired' }>): boolean =>
28  cache.tokens != null && cache.tokens < SMALL_CONTEXT_TOKENS
29
30export const cacheLine = (cache: Extract<CacheView, { kind: 'expired' }>): string => {
31  const since = `Cache likely expired (last reply ${formatGap(cache.minutesSince)} ago)`
32  if (isSmallContext(cache)) return `${since}, but the conversation is small (~${formatTokens(cache.tokens ?? 0)} tokens): carrying on costs little.`
33  const size = cache.tokens == null ? 'the whole conversation' : `~${formatTokens(cache.tokens)} tokens`
34  return `${since}: your next message re-reads ${size} at full price. Same task? Carry on. New task? /clear first, it costs under half as much.`
35}
36
37const lastTouchAt = atom({ plugin: 'afk-timer', key: 'lastTouchAt' } as const, null)
38const view = atom({ plugin: 'afk-timer', key: 'view' } as const, null)
39const cache = atom({ plugin: 'afk-timer', key: 'cache' } as const, null)
40const cacheView = atom({ plugin: 'afk-timer', key: 'cacheView' } as const, null)
41
42const PERSON_ORIGINS = ['composer', 'bridge', 'sdk', 'slack-ping']
43
44let recentTouchAt = 0
45let timer: { cancel: () => void } | null = null
46
47const clearBack = async ($: EngineInterface, at: number) => {
48  const current = await read($, view)
49  if (current?.kind === 'back' && current.at === at) await update($, view, (): AfkView => null)
50}
51
52const touch = async ($: EngineInterface) => {
53  const now = await $.clock.now()
54  if (now - recentTouchAt < TOUCH_THROTTLE_MS) return
55  recentTouchAt = now
56  const last = await read($, lastTouchAt)
57  await update($, lastTouchAt, () => now)
58  if (typeof last === 'number' && now - last >= AWAY_AFTER_MS) {
59    const minutes = Math.floor((now - last) / 60_000)
60    await update($, view, (): AfkView => ({ kind: 'back', minutes, at: now }))
61    // Clear on time: the next tick can come up to TICK_MS late.
62    $.clock.after(BACK_SHOWN_MS, () => {
63      void clearBack($, now).catch(() => {})
64    })
65  }
66}
67
68const cacheViewAt = (clock: CacheClock, now: number): CacheView => {
69  if (clock == null) return null
70  const sinceReply = now - clock.lastReplyAt
71  if (sinceReply < clock.ttlMs) return { kind: 'warm', minutesLeft: Math.ceil((clock.ttlMs - sinceReply) / 60_000) }
72  return { kind: 'expired', minutesSince: Math.floor(sinceReply / 60_000), tokens: clock.tokens }
73}
74
75const refreshCacheView = async ($: EngineInterface, now: number) => {
76  const next = cacheViewAt(await read($, cache), now)
77  if (JSON.stringify(await read($, cacheView)) !== JSON.stringify(next)) await update($, cacheView, (): CacheView => next)
78}
79
80const setCache = async ($: EngineInterface, clock: CacheClock) => {
81  await update($, cache, (): CacheClock => clock)
82  await refreshCacheView($, await $.clock.now())
83}
84
85// `auto` reads a subscription from the plan rate limits the last response reported (none before the first response).
86const guessTtlMs = (choice: unknown, rateLimits: readonly unknown[]) => {
87  if (choice === '1h' || choice === '5m') return CACHE_TTL_MS[choice]
88  return rateLimits.length > 0 ? CACHE_TTL_MS['1h'] : CACHE_TTL_MS['5m']
89}
90
91const markReply = async ($: EngineInterface, choice: unknown) => {
92  const { context, rateLimits } = await $.session.usage()
93  await setCache($, { lastReplyAt: await $.clock.now(), tokens: context.tokens ?? null, ttlMs: guessTtlMs(choice, rateLimits) })
94}
95
96const markResumed = async ($: EngineInterface, choice: unknown, ago: number, tokens: number | null, isExpired: boolean | undefined) => {
97  const guess = guessTtlMs(choice, (await $.session.usage()).rateLimits)
98  // The engine knows the real lifetime: a cache it calls warm past 5 min is the 1 h one, and one it calls expired has lapsed by now.
99  const ttlMs = isExpired ? Math.min(guess, ago) : ago >= CACHE_TTL_MS['5m'] ? CACHE_TTL_MS['1h'] : guess
100  await setCache($, { lastReplyAt: (await $.clock.now()) - ago, tokens, ttlMs })
101}
102
103const startTicking = ($: EngineInterface) => {
104  if (timer) return
105  timer = $.clock.every(TICK_MS, () => {
106    void tick($).catch(() => {})
107  })
108}
109
110const tick = async ($: EngineInterface) => {
111  const now = await $.clock.now()
112  await refreshCacheView($, now)
113  const last = await read($, lastTouchAt)
114  if (typeof last !== 'number') {
115    await update($, lastTouchAt, () => now)
116    return
117  }
118  const gap = now - last
119  const current = await read($, view)
120  if (gap >= AWAY_AFTER_MS) {
121    const minutes = Math.floor(gap / 60_000)
122    if (!(current?.kind === 'away' && current.minutes === minutes)) {
123      await update($, view, (): AfkView => ({ kind: 'away', minutes }))
124    }
125    return
126  }
127  // An away line under the threshold is stale: a touch landed while this tick ran.
128  const isOver = current?.kind === 'away' || (current?.kind === 'back' && now - current.at >= BACK_SHOWN_MS)
129  if (isOver) await update($, view, (): AfkView => null)
130}
131
132export const register: Register = (on, options) => {
133  on('session.start', async ($, e, next) => {
134    const now = await $.clock.now()
135    const last = await read($, lastTouchAt)
136    if (typeof last !== 'number') await update($, lastTouchAt, () => now)
137    timer?.cancel()
138    timer = null
139    if (e.isInteractive || (await $.session.surfaces()).length > 0) startTicking($)
140    return next(e)
141  })
142
143  on('session.attach', ($, e, next) => {
144    startTicking($)
145    return next(e)
146  })
147
148  on('prompt.edit', ($, e, next) => {
149    void touch($).catch(() => {})
150    return next(e)
151  })
152
153  on('prompt.submit', async ($, e, next) => {
154    if (PERSON_ORIGINS.includes(e.origin.kind)) await touch($).catch(() => {})
155    return next(e)
156  })
157
158  on('command.run', async ($, e, next) => {
159    if (PERSON_ORIGINS.includes(e.origin.kind)) await touch($).catch(() => {})
160    return next(e)
161  })
162
163  on('ui.press', async ($, e, next) => {
164    await touch($).catch(() => {})
165    return next(e)
166  })
167
168  on('ui.input', async ($, e, next) => {
169    await touch($).catch(() => {})
170    return next(e)
171  })
172
173  on('ui.select', async ($, e, next) => {
174    await touch($).catch(() => {})
175    return next(e)
176  })
177
178  // A running turn's requests keep the cache warm, so the cache clock restarts when the turn completes.
179  on('turn.start', async ($, e, next) => {
180    await setCache($, null).catch(() => {})
181    return next(e)
182  })
183
184  on('turn.complete', async ($, e, next) => {
185    const result = await next(e)
186    // A subagent's turn warms its own cache, not the main conversation's.
187    if (e.agentId === undefined) await markReply($, options.cacheTtl).catch(() => {})
188    return result
189  })
190
191  on('classic.SessionStart', async ($, e, next) => {
192    // /clear and /compact leave nothing big to re-read.
193    if (e.source === 'clear' || e.source === 'compact') await setCache($, null).catch(() => {})
194    // A resumed or forked session starts from a transcript whose cache may already have lapsed.
195    if ((e.source === 'resume' || e.source === 'fork') && e.seconds_since_last_response !== undefined) {
196      const ago = e.seconds_since_last_response * 1000
197      await markResumed($, options.cacheTtl, ago, e.context_tokens ?? null, e.prompt_cache_likely_expired).catch(() => {})
198    }
199    return next(e)
200  })
201
202  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
203    // A screen is drawing the band, so someone can see it: covers a desktop app that attached before this module loaded.
204    startTicking($)
205    const afk = await read($, view)
206    const cached = await read($, cacheView)
207    const expired = cached?.kind === 'expired' ? cached : null
208    if (e.props.hasSurvey || (afk == null && expired == null)) return next(e)
209    // Mods beneath still draw: "You should know" starts its checks on a non-terminal screen only once its own render runs.
210    const below = await next(e)
211    const { Box, Text } = $.ui.resolve(e)
212    return (
213      <Box flexDirection="column">
214        {afk && <Text key="afk" dimColor={afk.kind === 'back'}>{afkLine(afk, cached)}</Text>}
215        {expired && <Text key="cache" color={isSmallContext(expired) ? undefined : 'warning'} dimColor={isSmallContext(expired)}>{cacheLine(expired)}</Text>}
216        {below}
217      </Box>
218    )
219  })
220}
221
types/index.d.ts 19 lines
1export type AfkView =
2  | { kind: 'away'; minutes: number }
3  | { kind: 'back'; minutes: number; at: number }
4  | null
5
6// When the main conversation's prompt cache was last refreshed. Null before the first reply, while a turn runs, and after /clear or /compact.
7export type CacheClock = { lastReplyAt: number; tokens: number | null; ttlMs: number } | null
8
9export type CacheView =
10  | { kind: 'warm'; minutesLeft: number }
11  | { kind: 'expired'; minutesSince: number; tokens: number | null }
12  | null
13
14declare module 'claude-code' {
15  interface PluginState {
16    'afk-timer': { lastTouchAt: number | null; view: AfkView; cache: CacheClock; cacheView: CacheView }
17  }
18}
19