Compacts an idle session just before its prompt cache expires, so returning does not re-write the full context

A Claude Code mod that compacts an idle session just before its 1-hour prompt cache expires, so coming back doesn't re-write the full context.
/plugin marketplace add benredmond/idle-compact
/plugin install idle-compact@idle-compact
/reload-plugins
/compact once after 55 minutes idle, then waits for your next turn. It never compacts twice in a row.◔ 12m), then ✓ 120k→12k once compacted.Configure with /plugin configure idle-compact@idle-compact (ttlMinutes 60, marginMinutes 5, minContextTokens 40000, instructions empty for a plain /compact).
Not useful if your prompt cache lasts 5 minutes.
MIT
hooks/register.ts 150 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { IdleCompactState } from '../types'
5
6const MINUTE = 60_000
7const TICK_MS = 30_000
8const MAX_ATTEMPTS = 3
9
10const idle = atom({ plugin: 'idle-compact', key: 'idle' } as const, {
11 lastMainRequestAt: null,
12 turnRunning: false,
13 latched: null,
14} as IdleCompactState)
15
16const minutes = (ms: number) => (ms < MINUTE ? '<1m' : `${Math.ceil(ms / MINUTE)}m`)
17const kTokens = (n: number) => `${Math.round(n / 1000)}k`
18
19// The countdown's glyph fills as compaction nears: ○ ◔ ◑ ◕.
20const PIE = ['○', '◔', '◑', '◕']
21const countdown = (dueInMs: number, windowMs: number) =>
22 `${PIE[Math.min(PIE.length - 1, Math.floor(((windowMs - dueInMs) / windowMs) * PIE.length))]} ${minutes(dueInMs)}`
23
24type Config = {
25 ttlMs: number
26 marginMs: number
27 minContextTokens: number
28 instructions: string | undefined
29}
30
31let isCompacting = false
32let isHeadless = false
33let failedAttempts = 0
34let headlessCounts: { tokensBefore?: number; tokensAfter?: number } | undefined
35
36async function latch($: EngineInterface, status: string) {
37 await update($, idle, s => ({ ...s, latched: status }))
38 $.ui.status(status)
39}
40
41// Headless sessions (-p, the SDK, the desktop app) refuse $.session.compact; there
42// /compact runs as a command instead.
43async function compact($: EngineInterface, cfg: Config) {
44 if (!isHeadless) return $.session.compact({ instructions: cfg.instructions })
45 await $.command.run({ command: 'compact', args: cfg.instructions ?? '' })
46 return undefined
47}
48
49// One tick both draws the countdown and, once due, compacts. The status line shows
50// only what idle-compact will do or did; reasons go to the debug log.
51async function tick($: EngineInterface, cfg: Config) {
52 const s = await read($, idle)
53 if (s.turnRunning || s.lastMainRequestAt === null) return $.ui.status(undefined)
54 if (s.latched !== null) return $.ui.status(s.latched)
55
56 const idleMs = (await $.clock.now()) - s.lastMainRequestAt
57 // Cache already cold (e.g. the machine slept): compacting now saves nothing.
58 if (idleMs >= cfg.ttlMs) return $.ui.status(undefined)
59 if (((await $.session.usage()).context.tokens ?? 0) < cfg.minContextTokens) return $.ui.status(undefined)
60
61 const windowMs = cfg.ttlMs - cfg.marginMs
62 const dueInMs = windowMs - idleMs
63 if (dueInMs > 0) return $.ui.status(countdown(dueInMs, windowMs))
64 if (isCompacting) return
65
66 isCompacting = true
67 headlessCounts = undefined
68 $.ui.status('⟳')
69 try {
70 const result = await compact($, cfg)
71 if (result?.skip !== undefined) {
72 // Vetoed by another hook: latch anyway so the veto is not retried every tick.
73 $.ui.log(`idle-compact: compact skipped: ${result.skip}`, { to: 'debug' })
74 await latch($, '⊘')
75 return
76 }
77 // Our own compact() skips our session.compact hook, so latch here too.
78 const counts = result ?? headlessCounts
79 const after = counts?.tokensAfter
80 await latch($, after === undefined ? '✓' : `✓ ${kTokens(counts?.tokensBefore ?? 0)}→${kTokens(after)}`)
81 } catch (err) {
82 // compact() rejects while a turn runs or the summary request fails: retry
83 // on the next ticks, then give up for this idle stretch.
84 failedAttempts += 1
85 const reason = (err instanceof Error ? err.message : String(err)).replace(/^idle-compact: /, '')
86 $.ui.log(`idle-compact: compact failed (${failedAttempts}/${MAX_ATTEMPTS}): ${reason}`, { to: 'debug' })
87 if (failedAttempts >= MAX_ATTEMPTS) await latch($, '✗')
88 } finally {
89 isCompacting = false
90 }
91}
92
93export const register: Register = (on, options) => {
94 const cfg: Config = {
95 ttlMs: Number(options.ttlMinutes ?? 60) * MINUTE,
96 marginMs: Number(options.marginMinutes ?? 5) * MINUTE,
97 minContextTokens: Number(options.minContextTokens ?? 40_000),
98 instructions: String(options.instructions ?? '').trim() || undefined,
99 }
100
101 let ticker: Timer | undefined
102
103 on('session.start', async ($, e, next) => {
104 isHeadless = !e.isInteractive
105 ticker?.cancel()
106 ticker = $.clock.every(TICK_MS, () => void tick($, cfg))
107 void tick($, cfg)
108 return next(e)
109 })
110
111 on('turn.start', async ($, e, next) => {
112 failedAttempts = 0
113 await update($, idle, s => ({ ...s, turnRunning: true, latched: null }))
114 $.ui.status(undefined)
115 return next(e)
116 })
117
118 // Every main-loop request renews the cache; anchor the TTL at request start.
119 on('turn.step', async function* ($, e, next) {
120 if (e.agentId === undefined) {
121 const now = await $.clock.now()
122 await update($, idle, s => ({ ...s, lastMainRequestAt: now }))
123 }
124 return yield* next(e)
125 })
126
127 on('turn.complete', async ($, e, next) => {
128 const result = await next(e)
129 if (e.agentId === undefined) {
130 await update($, idle, s => ({ ...s, turnRunning: false }))
131 void tick($, cfg)
132 }
133 return result
134 })
135
136 // A main-conversation compaction between turns (manual /compact) latches until a
137 // real turn runs; one mid-turn (auto) is followed by more work, so it does not.
138 on('session.compact', async ($, e, next) => {
139 const result = await next(e)
140 if (e.agentId !== undefined || e.trigger === 'precompute' || result.skip !== undefined) return result
141 if (isCompacting) {
142 // Our own headless /compact: tick() latches, with the counts only this hook sees.
143 headlessCounts = result
144 } else if (!(await read($, idle)).turnRunning) {
145 await latch($, '✓')
146 }
147 return result
148 })
149}
150types/index.d.ts 13 lines1export type IdleCompactState = {
2 lastMainRequestAt: number | null
3 turnRunning: boolean
4 /** Status held once this idle stretch is done (compacted, vetoed, gave up); cleared by the next turn. */
5 latched: string | null
6}
7
8declare module 'claude-code' {
9 interface PluginState {
10 'idle-compact': { idle: IdleCompactState }
11 }
12}
13