Come back to big chats for less. When a large chat (150k+ tokens) has been idle for 40 minutes, it is compacted while the prompt cache is still warm, so…

Come back to big chats for less. Claude Code's prompt cache expires after an hour. Return to a 300k-token chat after lunch and the first reply pays to reload all of it at full price.
idle-compact runs /compact on a large chat (150k+ tokens) after 40 minutes idle, while the cache is still warm. Compacting is cheap then, and the chat you come back to is small.
Run this in a terminal claude session:
/plugin install idle-compact --marketplace Ryan-LT/claude-mods
Answer y to add the marketplace, then choose the user scope.
hooks/register.ts 73 lines1import type { EngineInterface, Register } from 'claude-code'
2
3const MINUTE = 60_000
4/** Quiet this long after the last reply: compact before the prompt cache (1 hour) goes cold. */
5const IDLE = 40 * MINUTE
6/** A smaller chat is cheap to come back to as it is. */
7const MIN_TOKENS = 150_000
8const CHECK_EVERY = MINUTE
9const KEEP =
10 'Keep what was asked last and any open tasks, decisions and why they were made, exact file paths, links, ' +
11 'commands, and error messages, so the work can resume without re-explaining.'
12
13/**
14 * When this process last saw a main-thread reply end, or 0. Zero until a reply in this process,
15 * so a chat reopened cold (cache already gone) is never compacted: that would pay full price.
16 */
17let lastReplyAt = 0
18let isBusy = false
19let isCompacting = false
20let isTicking = false
21/** The /compact we start runs as a turn of its own here; its end is not a reply to arm on. */
22let isOwnTurnNext = false
23
24const check = async ($: EngineInterface) => {
25 if (!lastReplyAt || isBusy || isCompacting) return
26 if ((await $.clock.now()) - lastReplyAt < IDLE) return
27 // One try per idle stretch: the next reply arms it again.
28 lastReplyAt = 0
29 const { context } = await $.session.usage()
30 if ((context.tokens ?? 0) < MIN_TOKENS) return
31 isCompacting = true
32 try {
33 isOwnTurnNext = true
34 // In the desktop app and SDK, compaction runs as a /compact turn, not a direct call.
35 await $.session.compact({ instructions: KEEP }).catch(() => $.command.run({ command: 'compact', args: KEEP }))
36 } catch {
37 isOwnTurnNext = false
38 // A turn started meanwhile, or compaction isn't possible now: leave the chat as it is.
39 } finally {
40 isCompacting = false
41 }
42}
43
44const ensureTicker = ($: EngineInterface) => {
45 if (isTicking) return
46 isTicking = true
47 $.clock.every(CHECK_EVERY, () => void check($).catch(() => {}))
48}
49
50export const register: Register = on => {
51 on('turn.start', ($, e, next) => {
52 ensureTicker($)
53 isBusy = true
54
55 return next(e)
56 })
57
58 on('turn.complete', async ($, e, next) => {
59 const done = await next(e)
60 if (!e.agentId) {
61 isBusy = false
62 if (isOwnTurnNext) {
63 isOwnTurnNext = false
64 return done
65 }
66 lastReplyAt = await $.clock.now()
67 ensureTicker($)
68 }
69
70 return done
71 })
72}
73