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…

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.
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:
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:
/clear first. The old conversation isn't sent again./compact doesn't help here. It re-reads the whole conversation to summarize it, so it costs the same.These rules come from 30 days of one heavy user's sessions (1,341 idle gaps of an hour or more):
The details are in Claude Code's prompt caching docs.
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).
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.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.claude plugin disable afk-timer@afk-timer
claude plugin uninstall afk-timer@afk-timer
auto guesses from your account type. When you resume a session it uses Claude Code's own verdict instead.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.
MIT
hooks/register.tsx 221 lines1import { 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}
221types/index.d.ts 19 lines1export 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