Compacts a large context once before the 1-hour prompt cache expires on an idle session

A Claude Code mod (function hooks) that compacts a large context once, shortly before the 1-hour prompt cache expires on an idle session, so coming back to a long session does not pay for re-caching the whole conversation.
reason: 'answer'), a compaction is reserved for 50 minutes later. Subagent turns, interrupted turns, refusals and turns ended by an error do not reserve./clear).claude --debug). The only line shown in the conversation is written when the compaction runs, for example: Compacted idle context of 250,000 tokens; summary cache hit 90.0% (read 180,000 / write 19,000 / uncached 1,000)
The hit rate is cache read / (cache read + cache write + uncached) of the summarizer's own request, and is left out when the compaction reports no usage.
Inside a herdr pane (HERDR_ENV=1 with HERDR_PANE_ID), the compaction's spinner would read as finished work and play herdr's done sound in a background workspace. To keep it quiet, the mod:
idle as herdr:claude (with the session id) right before compacting, which overrides herdr's screen detection;herdr agent explain --json every 250 ms until the screen reads idle, waits 1 more second, and gives up waiting after 10 seconds;herdr pane release-agent, then reports the session again with herdr pane report-agent-session, because the release also forgets the session herdr resumes after a restart.A turn starting while the pane is pinned hands it back at once. Every report carries a nanosecond --seq above the official hook's time.time_ns(), since herdr silently drops a report whose seq is not newer. HERDR_BIN_PATH is used when set, herdr from PATH otherwise. Failures of these commands go to the debug log only.
This relies on herdr internals (checked against herdr 0.9.3) and may break with a herdr update.
claude plugin validate claude/mods/compact-large-idle-context
claude plugin test claude/mods/compact-large-idle-context
MIT
hooks/register.ts 189 lines1import type { EngineInterface, ModelUsage, Register, Timer } from 'claude-code'
2import {
3 explainArgv,
4 type HerdrPane,
5 herdrPaneOf,
6 nextSeq,
7 pinArgv,
8 releaseArgv,
9 screenStateOf,
10 sessionArgv,
11 SETTLE_LIMIT_MS,
12 SETTLE_MARGIN_MS,
13 SETTLE_POLL_MS,
14} from './herdr'
15
16const DELAY_MS = 50 * 60 * 1000
17const LATEST_MS = 58 * 60 * 1000
18const MIN_TOKENS = 200_000
19
20type Reservation = { timer: Timer; endedAt: number; sessionId: string; generation: number }
21
22let reservation: Reservation | null = null
23let generation = 0
24let isTurnRunning = false
25let isCompacting = false
26let lastSeq = 0n
27let pinned: HerdrPane | null = null
28
29const formatCount = (n: number) => String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
30
31const formatMinutes = (ms: number) => {
32 const seconds = Math.floor(ms / 1000)
33 return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
34}
35
36const describeCompaction = (tokens: number, usage?: ModelUsage) => {
37 const head = `Compacted idle context of ${formatCount(tokens)} tokens`
38 if (!usage) return head
39 const { cache_read_input_tokens: read, cache_creation_input_tokens: write, input_tokens: uncached } = usage
40 const total = read + write + uncached
41 if (total === 0) return head
42 const rate = ((read / total) * 100).toFixed(1)
43 return `${head}; summary cache hit ${rate}% (read ${formatCount(read)} / write ${formatCount(write)} / uncached ${formatCount(uncached)})`
44}
45
46const isStale = (mine: Reservation) => generation !== mine.generation || isTurnRunning
47
48function debug($: EngineInterface, text: string) {
49 return $.ui.log(`compact-large-idle-context: ${text}`, { to: 'debug' })
50}
51
52function cancel($: EngineInterface, why: string) {
53 generation++
54 if (!reservation) return
55 reservation.timer.cancel()
56 reservation = null
57 debug($, `reservation cancelled (${why})`)
58}
59
60async function seq($: EngineInterface) {
61 lastSeq = nextSeq(await $.clock.now(), lastSeq)
62 return lastSeq
63}
64
65async function herdr($: EngineInterface, argv: string[]) {
66 try {
67 const result = await $.process.run(argv, { timeoutMs: 5000 })
68 if (result.exitCode === 0) return result
69 await debug($, `herdr ${argv[2]} exited ${result.exitCode}: ${result.stderr.trim()}`)
70 } catch (error) {
71 await debug($, `herdr ${argv[2]} failed: ${error instanceof Error ? error.message : String(error)}`)
72 }
73 return undefined
74}
75
76async function pinHerdrIdle($: EngineInterface) {
77 const pane = herdrPaneOf({
78 herdr: await $.env.get('HERDR_ENV'),
79 pane: await $.env.get('HERDR_PANE_ID'),
80 bin: await $.env.get('HERDR_BIN_PATH'),
81 })
82 if (!pane) return
83 if (!(await herdr($, pinArgv(pane, await seq($), await $.session.id())))) return
84 pinned = pane
85 await debug($, 'herdr pane pinned idle')
86}
87
88async function waitForScreenIdle($: EngineInterface, pane: HerdrPane) {
89 const startedAt = await $.clock.now()
90 while ((await $.clock.now()) - startedAt < SETTLE_LIMIT_MS) {
91 const result = await herdr($, explainArgv(pane))
92 if (result && screenStateOf(result.stdout) === 'idle') {
93 await $.clock.sleep(SETTLE_MARGIN_MS)
94 return
95 }
96 await $.clock.sleep(SETTLE_POLL_MS)
97 }
98 await debug($, 'herdr screen did not settle idle; handing the pane back anyway')
99}
100
101async function unpinHerdr($: EngineInterface, settle: boolean) {
102 const pane = pinned
103 if (!pane) return
104 if (settle) await waitForScreenIdle($, pane)
105 if (pinned !== pane) return
106 pinned = null
107 await herdr($, releaseArgv(pane, await seq($)))
108 await herdr($, sessionArgv(pane, await seq($), await $.session.id()))
109 await debug($, 'herdr pane handed back')
110}
111
112async function fire($: EngineInterface, mine: Reservation) {
113 if (reservation !== mine) return
114 reservation = null
115 try {
116 const now = await $.clock.now()
117 if (isStale(mine)) return debug($, 'skipped (a turn started during the check)')
118 const elapsed = now - mine.endedAt
119 if (elapsed < DELAY_MS || elapsed >= LATEST_MS) {
120 return debug($, `skipped (${formatMinutes(elapsed)} since the turn ended)`)
121 }
122 const sessionId = await $.session.id()
123 if (isStale(mine)) return debug($, 'skipped (a turn started during the check)')
124 if (sessionId !== mine.sessionId) return debug($, 'skipped (the session changed)')
125 const { context } = await $.session.usage()
126 if (isStale(mine)) return debug($, 'skipped (a turn started during the check)')
127 const tokens = context.tokens ?? 0
128 if (tokens < MIN_TOKENS) return debug($, `skipped (context is ${formatCount(tokens)} tokens)`)
129 isCompacting = true
130 await pinHerdrIdle($)
131 const result = await $.session.compact().finally(() => {
132 isCompacting = false
133 void unpinHerdr($, true)
134 })
135 if (result.skip !== undefined) return debug($, `skipped (compaction vetoed: ${result.skip})`)
136 await $.ui.log(describeCompaction(result.tokensBefore ?? tokens, result.usage))
137 } catch (error) {
138 await debug($, `compaction failed: ${error instanceof Error ? error.message : String(error)}`)
139 } finally {
140 isCompacting = false
141 }
142}
143
144async function reserve($: EngineInterface) {
145 cancel($, 'rescheduling')
146 const expected = generation
147 const endedAt = await $.clock.now()
148 const sessionId = await $.session.id()
149 if (generation !== expected || isTurnRunning) return
150 const mine: Reservation = { timer: { cancel: () => {} }, endedAt, sessionId, generation }
151 mine.timer = $.clock.after(DELAY_MS, () => void fire($, mine))
152 reservation = mine
153 await debug($, `compaction reserved in ${formatMinutes(DELAY_MS)}`)
154}
155
156export const register: Register = on => {
157 on('turn.start', ($, e, next) => {
158 isTurnRunning = true
159 cancel($, 'a turn started')
160 void unpinHerdr($, false)
161 return next(e)
162 })
163
164 on('turn.complete', async ($, e, next) => {
165 const completed = await next(e)
166 if (e.agentId !== undefined) return completed
167 isTurnRunning = false
168 if (isCompacting) return completed
169 if (e.reason !== 'answer' || e.isAborted) {
170 cancel($, `the turn ended with ${e.reason}`)
171 return completed
172 }
173 await reserve($)
174 return completed
175 })
176
177 on('session.compact', ($, e, next) => {
178 if (e.agentId === undefined && (e.trigger === 'manual' || e.trigger === 'auto')) {
179 cancel($, `${e.trigger} compaction`)
180 }
181 return next(e)
182 })
183
184 on('session.end', ($, e, next) => {
185 cancel($, `session ended (${e.reason})`)
186 return next(e)
187 })
188}
189hooks/herdr.ts 49 lines1const SOURCE = 'herdr:claude'
2const AGENT = 'claude'
3
4export const SETTLE_POLL_MS = 250
5export const SETTLE_MARGIN_MS = 1000
6export const SETTLE_LIMIT_MS = 10_000
7
8export type HerdrPane = { bin: string; pane: string }
9
10export const herdrPaneOf = (env: {
11 herdr?: string
12 pane?: string
13 bin?: string
14}): HerdrPane | undefined => {
15 if (env.herdr !== '1' || !env.pane) return undefined
16 return { bin: env.bin || 'herdr', pane: env.pane }
17}
18
19export const nextSeq = (nowMs: number, last: bigint) => {
20 const now = BigInt(nowMs) * 1_000_000n
21 return now > last ? now : last + 1n
22}
23
24const identity = ['--source', SOURCE, '--agent', AGENT]
25
26export const pinArgv = ({ bin, pane }: HerdrPane, seq: bigint, sessionId: string) => [
27 bin, 'pane', 'report-agent', pane, ...identity,
28 '--state', 'idle', '--seq', String(seq), '--agent-session-id', sessionId,
29]
30
31export const releaseArgv = ({ bin, pane }: HerdrPane, seq: bigint) => [
32 bin, 'pane', 'release-agent', pane, ...identity, '--seq', String(seq),
33]
34
35export const sessionArgv = ({ bin, pane }: HerdrPane, seq: bigint, sessionId: string) => [
36 bin, 'pane', 'report-agent-session', pane, ...identity,
37 '--seq', String(seq), '--agent-session-id', sessionId,
38]
39
40export const explainArgv = ({ bin, pane }: HerdrPane) => [bin, 'agent', 'explain', pane, '--json']
41
42export const screenStateOf = (stdout: string) => {
43 try {
44 return (JSON.parse(stdout) as { state?: unknown }).state
45 } catch {
46 return undefined
47 }
48}
49