Compact once after 50 idle minutes, while the 1h prompt cache is still warm.

A Claude Code function-hooks plugin (Mod) that compacts the conversation once after 50 idle minutes, while the 1h prompt cache is still warm.
In simple terms: If you leave a long session alone, coming back after the 1h cache TTL re-caches the whole context. This plugin compacts shortly before that happens, so the next turn starts from a small, cheap context. It never keeps the cache alive, and it does nothing more after that one compaction.
⚠️ Note: Function hooks are early access. Set
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1(for example in theenvblock of~/.claude/settings.json). Written against Claude Code 2.1.280; the cache hit line was checked on 2.1.282.
/plugin marketplace add takahirom/takahirom-claude-code-marketplace
/plugin install idle-compact@takahirom-claude-code-marketplace
flowchart TD
A[main turn.complete<br/>reason: answer] --> B[Arm one 50 min timer<br/>remember wall-clock time and session id]
B --> C{What comes first?}
C -->|turn.start / session.end / manual compact| D[Cancel timer]
D -->|next main turn completes| A
C -->|timer fires| E{elapsed 50–58 min,<br/>same session,<br/>still the latest timer?}
E -->|No: e.g. Mac slept, cache cold| F[Do nothing]
E -->|Yes| G[$.session.compact]
G --> I[Show the cache hit of the summary call]
I --> H[Done: no re-arm, no retry]
agentId), aborted or failed turns, and completions without a matching turn.start are ignored.$.clock.now()) again. A timer that runs late, for example after the Mac slept, does nothing once 58 minutes have passed, because by then the cache may already be cold.turn.complete fired. Claude Code does not expose the cache's own last-activity time or TTL to plugins, so a session on the 5m TTL is not detected.idle-compact: compacts at 15:03 if nothing happens before then. It is kept in the transcript but never sent to the model. After the idle compaction, one more line gives how much of the summary call's input came from the prompt cache, for example idle-compact: compacted with a 95% cache hit (74,482 read, 326 written, 2,813 uncached); it is left out when Claude Code reports no usage for the compaction. Everything else goes to the debug log only (--debug / --debug-file).DISABLE_COMPACT is set, is ignored silently and not retried.The plugin holds at most one timer, and only the end of a turn you sent starts it.
A failed compaction is not retried, and running /compact yourself cancels the timer.
claude plugin validate plugins/idle-compact
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test plugins/idle-compact
Types come from /plugin-types (written to .claude/types, not committed).
hooks/idle-compact.ts 152 lines1import type { EngineInterface, Register, Timer } from 'claude-code'
2
3// Compact once after the main conversation has been idle for 50 minutes, while
4// the 1h prompt cache is still warm. One one-shot timer at most; no polling,
5// no keep-alive, and nothing is re-armed after the compaction.
6export const IDLE_MS = 50 * 60 * 1000
7// Past this the cache may already be cold (the Mac slept, the timer ran late),
8// so compacting would re-cache the whole context: do nothing instead.
9export const LATEST_MS = 58 * 60 * 1000
10
11type Armed = {
12 generation: number
13 timer: Timer
14 sessionId: string
15 armedAt: number
16}
17
18type State = {
19 generation: number
20 armed: Armed | null
21 currentTurnId: string | null
22 isCompacting: boolean
23}
24
25// Debug log only (--debug / --debug-file): never shown to the person.
26async function log($: EngineInterface, text: string) {
27 try {
28 await $.ui.log(`idle-compact: ${text}`, { to: 'debug' })
29 } catch {}
30}
31
32// Shown in the conversation and kept in the transcript, never sent to the model.
33async function notice($: EngineInterface, text: string) {
34 try {
35 await $.ui.log(text, { to: 'transcript' })
36 } catch {}
37}
38
39// Local wall-clock time, HH:MM.
40function clockTime(ms: number) {
41 const d = new Date(ms)
42 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
43}
44
45type CompactUsage = {
46 input_tokens?: number
47 output_tokens?: number
48 cache_read_input_tokens?: number
49 cache_creation_input_tokens?: number
50}
51
52// How much of the summary call's input came from the prompt cache. The host
53// leaves `usage` out when it has none (all zero, or a precomputed summary).
54export function cacheHitLine(result: unknown): string | null {
55 const usage = (result as { usage?: CompactUsage } | undefined)?.usage
56 if (usage === undefined) return null
57 const read = usage.cache_read_input_tokens ?? 0
58 const written = usage.cache_creation_input_tokens ?? 0
59 const uncached = usage.input_tokens ?? 0
60 const total = read + written + uncached
61 if (total === 0) return null
62 const n = (x: number) => x.toLocaleString('en-US')
63 // Integer math: (29 / 50) * 100 is 57.99..., which would floor to 57.
64 const percent = Math.floor((read * 100) / total)
65 return `compacted with a ${percent}% cache hit (${n(read)} read, ${n(written)} written, ${n(uncached)} uncached)`
66}
67
68function cancel(state: State) {
69 state.generation++
70 state.armed?.timer.cancel()
71 state.armed = null
72}
73
74async function arm($: EngineInterface, state: State) {
75 cancel(state)
76 const generation = state.generation
77 const armedAt = await $.clock.now()
78 const sessionId = await $.session.id()
79 if (state.generation !== generation) return
80 const timer = $.clock.after(IDLE_MS, () => void fire($, state, generation))
81 state.armed = { generation, timer, sessionId, armedAt }
82 // Issued before any further await, so it never announces a timer a turn has
83 // just cancelled.
84 await notice($, `compacts at ${clockTime(armedAt + IDLE_MS)} if nothing happens before then`)
85 await log($, `armed at ${new Date(armedAt).toISOString()}`)
86}
87
88async function fire($: EngineInterface, state: State, generation: number) {
89 const armed = state.armed
90 if (armed === null || armed.generation !== generation || state.isCompacting) return
91 state.armed = null
92 try {
93 const elapsed = (await $.clock.now()) - armed.armedAt
94 const minutes = (elapsed / 60000).toFixed(1)
95 if (elapsed < IDLE_MS || elapsed >= LATEST_MS) {
96 await log($, `fired after ${minutes} min: outside the window, skipped`)
97 return
98 }
99 if ((await $.session.id()) !== armed.sessionId) {
100 await log($, `fired after ${minutes} min: session changed, skipped`)
101 return
102 }
103 await log($, `fired after ${minutes} min: compacting`)
104 // Checked after the last await: a turn may have started in the meantime.
105 if (state.generation !== generation) return
106 state.isCompacting = true
107 const result = await $.session.compact()
108 await log($, 'compaction finished')
109 const line = cacheHitLine(result)
110 if (line !== null) await notice($, line)
111 } catch (error) {
112 // A running turn, DISABLE_COMPACT or a headless host: no retry, by design.
113 await log($, `compaction failed: ${error instanceof Error ? error.message : String(error)}`)
114 } finally {
115 state.isCompacting = false
116 }
117}
118
119export const register: Register = (on) => {
120 const state: State = { generation: 0, armed: null, currentTurnId: null, isCompacting: false }
121
122 on('turn.start', ($, e, next) => {
123 cancel(state)
124 state.currentTurnId = e.turnId
125 return next(e)
126 })
127
128 on('turn.complete', async ($, e, next) => {
129 const result = await next(e)
130 // Main loop only (subagent runs carry agentId and raise no turn.start),
131 // answered normally, and not a completion raised by our own compaction.
132 if (e.agentId !== undefined || e.reason !== 'answer' || state.isCompacting) return result
133 if (e.turnId !== state.currentTurnId) return result
134 state.currentTurnId = null
135 await arm($, state)
136 return result
137 })
138
139 // The person's /compact (or an automatic one) is activity too: the pending
140 // timer would only compact the fresh summary again.
141 on('session.compact', ($, e, next) => {
142 if (e.agentId === undefined && (e.trigger === 'manual' || e.trigger === 'auto')) cancel(state)
143 return next(e)
144 })
145
146 on('session.end', ($, e, next) => {
147 cancel(state)
148 state.currentTurnId = null
149 return next(e)
150 })
151}
152