Compacts an idle interactive session shortly before its prompt cache expires, so coming back later doesn't re-read the whole context uncached.

A Claude Code mod that compacts an idle session shortly before its prompt cache expires. You come back to a small, warm context instead of a big, cold one.
Needs Claude Code 2.1.287 or later, the first release with mods on by default.
You often step away from a session without knowing you'll be gone for more than an hour. The main conversation's prompt cache lives for 1 hour on a Claude subscription. When you come back after that, the next request re-sends the whole context and writes it to the cache again at full price. For a 367k-token session, that is a lot of usage spent on saying "ok, where were we".
Running /compact before you leave would fix this, but only if you knew you were leaving.
idle-compact does it for you. After 50 minutes with no activity, it compacts the conversation while the cache is still warm:
| Cold return, no compaction | idle-compact at 50 min | |
|---|---|---|
| Summary pass | none | reads 367k from cache at about 0.1× input price, writes a few thousand output tokens |
| First request back | re-caches 367k at 2× input price (1-hour cache write) | re-caches the ~12k summary, plus the system prompt and tools |
| Roughly, in input-token equivalents | ~730k | ~100k, plus twice the system prompt and tools |
Cache reads are even cheaper than 0.1× on some current models, which makes the gap bigger. The cost is that the conversation is now a summary: see Caveats.
/clear or exit cancels it. A slash command that doesn't call the model leaves it running, because it doesn't refresh the cache./context's Messages row counts it. Below 40k tokens it doesn't compact, because a cold re-read is cheap and not worth losing detail over, and leaves a line in the transcript saying so (not compacted: the conversation is 12k tokens, under the 40k floor). The system prompt and tool definitions don't count: compaction can't shrink them, and they get re-cached on your return either way./compact runs. The instructions tell the summariser that the user stepped away, and what to keep: open tasks, decisions and their reasons, follow-ups with dates, file paths, and anything the user asked to remember. ● idle-compact: compacted at 19:13 after 50 min idle (367k tokens → 12k summary, 99% read from cache)
The first figure is the whole context before. The second is the summary alone: the context you come back to is that plus the system prompt and tool definitions, which compaction can't shrink. The percentage is how much of the summary request's input the prompt cache served, which is the saving. The status line goes away when the next turn starts.
The plugin is one hooks module, hooks/register.ts, and nothing else: no skills, commands, agents or MCP servers. It handles four events:
| Event | What the hook does |
|---|---|
turn.complete | For the main conversation only, starts the idle timer ($.clock.after) and notes the time. |
turn.start | Cancels the timer, forgets the time and clears the status line. |
session.end | Cancels the timer, forgets the time and clears the status line. |
session.start | When the plugin is loaded or reloaded, restarts the timer for what's left of it, if anything. |
It lets every event continue unchanged. It doesn't hook your prompts or Claude's tool calls.
In prose: the hook starts a timer when a turn of the main conversation completes, and cancels it when the next turn starts or when the session ends. If the timer runs out, the hook compacts the conversation, unless the timer fired too late for the cache to still be warm. When the plugin is reloaded, the hook restarts the timer for the time that was left.
The only things it reads are the session's context size and its /context breakdown ($.session.usage), the session's id ($.session.id), and the one value it keeps: the session id and the time of its last turn, in the plugin state Claude Code holds for the running session ($.state). It reads no environment variables, settings or files.
When the timer fires it asks for that breakdown counted properly, which has Claude Code make the same token-count requests /context makes (they cost no tokens), and then calls $.session.compact, which makes the same model request /compact makes, on your own account. Those are the only things it sends anywhere. It makes no network requests of its own, starts no processes, and reads and writes no files. It writes one line to the transcript ($.ui.log) and one to the status line ($.ui.status).
idle-compact is for sessions with a 1-hour prompt cache, which is what a Claude subscription gets within its usage limits. It doesn't check: it waits 50 minutes, which leaves 10 minutes of margin. A 367k-token summary pass took under 2 minutes.
On a 5-minute cache (an API key, Bedrock, Vertex or Foundry by default) it has nothing to offer, because the cache is long cold by the time it fires. Don't install it there, unless you also set "promptCacheTtl": "1h" in your settings.
It's in the Anthropic Directory, which Claude Code has built in. Find Idle Compact under /plugin, or run:
/plugin install idle-compact@anthropic-plugin-directory
Each version there is reviewed by Anthropic before it goes live, so it can trail this repo. To get releases as soon as they're pushed, add this repo as a marketplace and install from it instead:
/plugin marketplace add davidar/claude-idle-compact
/plugin install idle-compact@idle-compact
Install one or the other, not both.
To try it without installing, run claude --plugin-dir /path/to/claude-idle-compact.
If you set CLAUDE_CODE_ENABLE_FUNCTION_HOOKS during the mods early access, you can remove it. Claude Code ignores it now.
Set these with /plugin configure idle-compact@anthropic-plugin-directory (or idle-compact@idle-compact if you installed from this repo), or in /config:
| Option | Default | Meaning |
|---|---|---|
idleMinutes | 50 | Minutes idle before compacting. |
minTokens | 40000 | Leave conversations smaller than this alone. It counts the conversation only (/context's Messages row), not the system prompt and tools. |
minTokens or disable the plugin for that session./compact, so the transcript JSONL marks it with trigger: "manual". To find one afterwards, look for the "compacted at …" line, which is written to the transcript and shown again after --resume.claude plugin validate . on a clone shows what the module hooks, calls and reads, without running it.claude plugin test . # tests (mocked clock and usage)
claude plugin validate .claude-plugin/plugin.json # what the module hooks, calls and reads
npx -p typescript tsc -p . # type-check
The types come from Claude Code itself. It writes .claude-plugin/types/ (gitignored) the first time it loads the mod from this folder, stamped with its version. The root tsconfig.json extends the tsconfig.json in there. On a fresh clone, run the mod once, for example claude --plugin-dir . -p ok, before type-checking.
tests/live.sh checks what the mocked tests can't: it runs throwaway Haiku sessions in tmux with a one-minute delay and watches for the compaction after a plain turn, after a slash command, and with a background agent running. It takes about four minutes.
To poke at it by hand, use a throwaway session with a one-minute delay. A copy loaded with --plugin-dir replaces the installed one for that session, and takes its options from idle-compact@inline:
claude --plugin-dir . --model haiku --settings \
'{"pluginConfigs":{"idle-compact@inline":{"options":{"idleMinutes":1,"minTokens":100}}}}'
cache-tax goes at the same problem from the other side. Before you step away, you run /keepwarm, and it keeps the cache warm with a small fork over the transcript every 50 idle minutes. If you come back to a cold cache anyway, it stops your first send and shows what the rewrite will cost. The trade-off:
MIT
hooks/register.ts 202 lines1import type { EngineInterface, ModelUsage, PluginOptions, Register, SessionContextUsage, Timer } from 'claude-code'
2
3// How long before a 1-hour cache lapses to compact. A 367k-token summary pass took about 2 minutes.
4// Also how late the timer may fire and still compact.
5const MARGIN_MS = 10 * 60_000
6const HOUR_MS = 60 * 60_000
7// Below this many conversation tokens a cold re-read is cheap; not worth losing detail. The system
8// prompt and tools don't count: compaction leaves them in place, and they are re-cached either way.
9const DEFAULT_MIN_TOKENS = 40_000
10
11// When a session's last main-loop turn ended (declared in types/index.d.ts). Held by the host for the
12// process, so it outlives a reload of this module.
13const LAST_TURN = { plugin: 'idle-compact', key: 'lastTurn' } as const
14
15function nonNegative(raw: unknown): number | undefined {
16 if (raw === undefined || raw === '') return undefined
17 const n = Number(raw)
18 return Number.isFinite(n) && n >= 0 ? n : undefined
19}
20
21const hhmm = (ms: number) => new Date(ms).toTimeString().slice(0, 5)
22const inThousands = (n: number) => `${Math.round(n / 1000)}k`
23// Small figures as they are: a 100-token floor isn't "0k".
24const approx = (n: number) => (n < 1000 ? `${n}` : inThousands(n))
25
26/** How much of the summary request's input the prompt cache served: the saving this mod exists for. */
27function cacheShare(usage: ModelUsage | undefined) {
28 if (!usage) return ''
29 const input = usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
30 return input ? `, ${Math.floor((usage.cache_read_input_tokens * 100) / input)}% read from cache` : ''
31}
32
33/**
34 * What compaction can shrink: the conversation, as /context's Messages row counts it. The whole
35 * context when there is no breakdown.
36 *
37 * The row is a residual: the last response's input less the other rows. That needs the `full`
38 * breakdown, which counts the other rows with the token-count API; the `summary` one estimates
39 * them, and was seen putting the tools at twice their size, which left 2 tokens of conversation.
40 */
41function conversationTokens(context: SessionContextUsage) {
42 const row = context.breakdown?.categories.find((c) => c.kind === 'used' && c.name === 'Messages')
43 return row?.tokens ?? context.tokens
44}
45
46/** Idle time before compacting. It assumes the 1-hour cache: the mod is no use on a 5-minute one. */
47function idleDelayMs(options: PluginOptions) {
48 const minutes = nonNegative(options.idleMinutes)
49 return minutes ? minutes * 60_000 : HOUR_MS - MARGIN_MS
50}
51
52function minTokens(options: PluginOptions) {
53 return nonNegative(options.minTokens) ?? DEFAULT_MIN_TOKENS
54}
55
56const instructions = (at: string, idleMin: number) =>
57 `This compaction ran automatically at ${at}, after ${idleMin} minutes idle, before the prompt cache expired. ` +
58 'Open the summary by saying so. Keep open tasks, decisions and their reasons, pending follow-ups with dates, ' +
59 'file paths touched, and anything the user asked to be remembered.'
60
61/** Per-activation state: the armed timer and what the module has put on screen. */
62type State = {
63 timer?: Timer
64 // Bumped on every disarm, so an arm still awaiting its reads can tell it was overtaken.
65 generation: number
66 hasStatus: boolean
67}
68
69function disarm(state: State) {
70 state.generation++
71 state.timer?.cancel()
72 state.timer = undefined
73}
74
75/** A reload while a turn runs, or after the session ended, must not re-arm. */
76function forget($: EngineInterface) {
77 return $.state.set(LAST_TURN, null).catch(() => {})
78}
79
80function clearStatus($: EngineInterface, state: State) {
81 if (state.hasStatus) $.ui.status(undefined)
82 state.hasStatus = false
83}
84
85async function compactIfWorthIt($: EngineInterface, state: State, idleSince: number, latest: number, floor: number) {
86 const fired = state.generation
87 try {
88 const { context } = await $.session.usage({ breakdown: 'full' })
89 // Unknown after a compaction until the next response: nothing to shrink then either.
90 if (context.tokens === undefined) return
91 const conversation = conversationTokens(context) ?? 0
92 if (conversation < floor) {
93 $.ui.log(`not compacted: the conversation is ${approx(conversation)} tokens, under the ${approx(floor)} floor`)
94 return
95 }
96 const before = context.tokens ?? 0
97 const now = await $.clock.now()
98 const at = hhmm(now)
99 const idleMin = Math.round((now - idleSince) / 60_000)
100 // A timer that fires late (the machine slept) finds the cache cold: compacting now would cost
101 // what the cold return costs, and lose detail on top.
102 if (now > latest) {
103 $.ui.log(`not compacted: the timer fired ${idleMin} min after the last turn, too late for a warm cache`)
104 return
105 }
106 const result = await $.session.compact({ instructions: instructions(at, idleMin) })
107 if (result.messages === undefined) return
108 // tokensBefore is the whole context; tokensAfter is only the summary, without the system prompt and tools.
109 const summary = result.tokensAfter === undefined ? '' : ` → ${inThousands(result.tokensAfter)} summary`
110 const sizes = `${inThousands(result.tokensBefore ?? before)} tokens${summary}${cacheShare(result.usage)}`
111 const line = `compacted at ${at} after ${idleMin} min idle (${sizes})`
112 $.ui.log(line)
113 // A turn started while the summary ran has already cleared the status; don't pin a stale one.
114 if (fired !== state.generation) return
115 $.ui.status(line)
116 state.hasStatus = true
117 } catch {
118 // compact rejects while a turn runs; the next turn.complete re-arms the timer
119 }
120}
121
122/** Starts the timer: due `ms` from now, it measures the idle time from `idleSince`. */
123function schedule($: EngineInterface, state: State, idleSince: number, ms: number, delay: number, floor: number) {
124 state.timer = $.clock.after(ms, () => {
125 state.timer = undefined
126 void compactIfWorthIt($, state, idleSince, idleSince + delay + MARGIN_MS, floor)
127 })
128}
129
130async function arm($: EngineInterface, state: State, options: PluginOptions) {
131 disarm(state)
132 const armed = state.generation
133 const delay = idleDelayMs(options)
134 const idleSince = await $.clock.now()
135 const sessionId = await $.session.id()
136 if (armed !== state.generation) return
137 schedule($, state, idleSince, delay, delay, minTokens(options))
138 // Kept for a reload of the module, which cancels the timer.
139 await $.state.set(LAST_TURN, { sessionId, at: idleSince })
140 // A turn.start that disarmed meanwhile may have cleared it before this write landed.
141 if (armed !== state.generation) await forget($)
142}
143
144/** After a reload: re-arms for what is left of the delay since this session's last turn, if any. */
145async function rearm($: EngineInterface, state: State, options: PluginOptions) {
146 disarm(state)
147 const armed = state.generation
148 const delay = idleDelayMs(options)
149 const { value: last } = await $.state.get(LAST_TURN)
150 const sessionId = await $.session.id()
151 const now = await $.clock.now()
152 if (armed !== state.generation || !last || last.sessionId !== sessionId) return
153 // Past the deadline the timer has fired already, or the session was resumed long after: either way
154 // there's nothing to do now.
155 const left = last.at + delay - now
156 if (left > 0) schedule($, state, last.at, left, delay, minTokens(options))
157}
158
159/**
160 * Arms a timer at the end of every main-loop turn and cancels it when the next turn starts, so it
161 * runs from the last model request: the last time the cache was refreshed. A prompt that starts no
162 * turn, such as a local slash command, doesn't touch the cache and so doesn't touch the timer.
163 * When it fires on time and the context is big enough, compacts while the cache is still warm and
164 * leaves a note in the transcript and the status line. The time of the last turn is kept in
165 * `$.state`, so a reload of the module, which cancels the timer, re-arms it for the time left.
166 */
167export const register: Register = (on, options) => {
168 // A reloaded module can't tell whether the one before it pinned a status: assume it did.
169 const state: State = { generation: 0, hasStatus: true }
170
171 on('turn.start', async ($, e, next) => {
172 disarm(state)
173 clearStatus($, state)
174 await forget($)
175 return next(e)
176 })
177
178 on('turn.complete', async ($, e, next) => {
179 const result = await next(e)
180 if (e.agentId === undefined) {
181 // never let arming the timer break the turn
182 await arm($, state, options).catch(() => {})
183 }
184 return result
185 })
186
187 on('session.end', async ($, e, next) => {
188 disarm(state)
189 clearStatus($, state)
190 await forget($)
191 return next(e)
192 })
193
194 // Also fires when the module is reloaded (/reload-plugins, an update, an options change), which
195 // cancels the timer: pick up where the last turn left off.
196 on('session.start', async ($, e, next) => {
197 const result = await next(e)
198 await rearm($, state, options).catch(() => {})
199 return result
200 })
201}
202types/index.d.ts 12 lines1// idle-compact's contract: the one value it keeps in `$.state`.
2
3/** When a session's last main-loop turn ended, kept so a reload of the module can re-arm its timer. */
4export type IdleCompactLastTurn = { sessionId: string; at: number }
5
6declare module 'claude-code' {
7 interface PluginState {
8 // null while a turn runs and after the session ends
9 'idle-compact': { lastTurn: IdleCompactLastTurn | null }
10 }
11}
12