Auto-compacts an idle Claude Code conversation shortly before the prompt cache expires, so the next summarization request warms the cache instead of…

A Claude Code plugin that compacts an idle conversation shortly before the prompt cache expires, so the next summarization request reads the cheap warm cache instead of reprocessing the full history.
A long conversation builds up a prompt cache, and any request that hits the cache re-reads history at cache rates instead of re-tokenizing everything. Anthropic's API offers two cache lifetimes: a five-minute TTL, and a one-hour TTL that keeps the cache warm through longer breaks but charges a higher rate for cache writes (see prompt caching). On the one-hour lifetime - a Claude subscription within plan usage, or an explicit promptCacheTtl of one hour - a break longer than an hour lets the cache expire, and the next compaction-style summarization request then reprocesses the whole history as one large cold request. Auto-compacting a little ahead of that hour keeps the summarization on the warm cache instead.
At a Claude Code prompt:
/plugin install auto-compact --marketplace u6vu06Ou/auto-compact
(Answer y to add the marketplace, then pick the user scope.)
/compact), then a second toast with the result.~/.claude/state/auto-compact/<sessionId>.json - one file per session, so several windows can coexist.statusline/auto-compact-statusline.js prints compacted 14:32 (local time of the last auto-compact) and nothing before the first one. No countdown - just when it last happened. Point your status line at it in ~/.claude/settings.json:
"statusLine": { "type": "command", "command": "node /path/to/auto-compact/statusline/auto-compact-statusline.js" }
Already have a status line script? require it and append its output:
const { statusLine, homeDir } = require('/path/to/auto-compact/statusline/auto-compact-statusline.js')
const seg = statusLine(homeDir(process.env), input.session_id) // '' until the first compact
Claude Code 2.1.290+ (October 2026) also compacts idle conversations on its own, but only once a conversation is large (about 200K tokens by default; CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS changes that, "idleCompaction": false turns it off). This mod also covers shorter conversations. Both can be on; whichever fires first compacts, and the other then has little left to do.
| Env var | Default | Meaning | ||||
|---|---|---|---|---|---|---|
AUTO_COMPACT_IDLE_MIN | 55 | Idle minutes after the main loop's last API request before compacting | ||||
AUTO_COMPACT_PAID_MODELS | `claude\ | opus\ | sonnet\ | fable\ | haiku` | Regex deciding which model names count as Anthropic on a custom endpoint |
Make it yours: the idle time is the env var above. There is no "only compact above N tokens of context" threshold - if you want one, ask your AI to add it; it is a few lines.
session.compact()); on older builds the attempt fails and the failure is toasted.MIT - see LICENSE.
Claude Code 外掛:對話閒置約 55 分鐘、在提示快取過期前自動壓縮對話,讓下一次摘要走便宜的快取,而不是把整份歷史重算一遍。
為什麼是 55:Anthropic 提示快取的存活時間有兩種——5 分鐘和 1 小時,較長的是 1 小時;跑訂閱用量(或明確設一小時 TTL)的對話,安靜超過一小時快取就過期,下一次就要整份重算。提前一點壓縮,摘要就能讀到快取。
安裝(在 Claude Code 對話框輸入):
/plugin install auto-compact --marketplace u6vu06Ou/auto-compact
行為:每次主對話打 API 就重新計時(打字、subagent 回報喚醒主 agent、排程喚醒都算;subagent 自己的請求不算);安靜屆滿(預設 55 分鐘)就跳提示並執行壓縮(跟 /compact 同一個機制),再回報結果;正好有一輪在跑被略過時也會說明;每次結果寫進 ~/.claude/state/auto-compact/<sessionId>.json(一個視窗一個檔,多窗共存);只壓互動視窗,無頭/排程跑的不動;自訂端點只在模型名像 Claude 時才啟用(正則可調)。
環境變數:
| 變數 | 預設 | 意義 | ||||
|---|---|---|---|---|---|---|
AUTO_COMPACT_IDLE_MIN | 55 | 主對話最後一次 API 後安靜這麼久才壓縮 | ||||
AUTO_COMPACT_PAID_MODELS | `claude\ | opus\ | sonnet\ | fable\ | haiku` | 判定自訂端點上哪些模型名算 Anthropic |
看上次什麼時候壓縮:statusline/auto-compact-statusline.js 會在狀態列印出 compacted 14:32(上次自動壓縮的時間),還沒壓過就什麼都不印。沒有倒數。設定方式見上面英文段。
想自己調:閒置時間用上面的環境變數。「context 超過多大才壓」這個門檻沒有做,想要的話叫你的 AI 加,幾行就好。
跟官方內建的差別:Claude Code 2.1.290 起(2026 年 10 月)自己也會在閒置時壓縮,但只壓很長的對話(預設約 20 萬 token,CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS 可改,"idleCompaction": false 可關)。這個外掛連短對話也壓。兩個可以同時開,誰先到誰壓。
限制:需要 hooks 能呼叫壓縮的 harness 版本;state 檔寫不進去不影響壓縮。授權 MIT。
hooks/register.ts 131 lines1import type { EngineInterface, Register, Timer } from 'claude-code'
2
3// Idle minutes after the main loop's last API request before auto-compacting.
4// The API offers a five-minute and a one-hour cache lifetime, and the one-hour
5// one is the longer-lived cache: compacting a little ahead of an hour of
6// inactivity lets the summarization request re-read the cheap cache instead of
7// reprocessing the full history, and 55 minutes leaves a small margin under
8// that hour. Set AUTO_COMPACT_IDLE_MIN to change it.
9const DEFAULT_IDLE_MIN = 55
10
11// Model names that count as running on Anthropic. A custom endpoint or gateway
12// can forward requests anywhere; compacting a conversation that runs on
13// another provider buys nothing, so a custom endpoint only arms when the
14// current model name looks like a Claude model. Set AUTO_COMPACT_PAID_MODELS
15// to a different regex to widen or narrow this.
16const DEFAULT_PAID_MODELS = 'claude|opus|sonnet|fable|haiku'
17
18let eligible = false
19let isCompacting = false
20let timer: Timer | undefined
21
22function disarm() {
23 timer?.cancel()
24 timer = undefined
25}
26
27// Env and timing are read once at session start, so arming the clock stays a
28// plain synchronous call.
29let cache: { minutes: number; idleMs: number; paidModels: RegExp } | undefined
30
31async function load($: EngineInterface) {
32 const min = Number(await $.env.get('AUTO_COMPACT_IDLE_MIN'))
33 const src = (await $.env.get('AUTO_COMPACT_PAID_MODELS')) ?? DEFAULT_PAID_MODELS
34 let paidModels: RegExp
35 try {
36 paidModels = new RegExp(src, 'i')
37 } catch {
38 paidModels = new RegExp(DEFAULT_PAID_MODELS, 'i')
39 }
40 const minutes = min > 0 ? min : DEFAULT_IDLE_MIN
41 return { minutes, idleMs: minutes * 60_000, paidModels }
42}
43
44// The result of each attempt is written to ~/.claude/state/auto-compact/<sessionId>.json
45// so a status line script can show when the conversation was last compacted
46// (several windows may be open at once, hence one file per session). The file
47// holds the most recent auto-compact that actually ran: while no compact has
48// run yet, a skipped or failed attempt is recorded, and afterwards a later
49// skip or failure leaves the last good record in place, so the display does
50// not blink to nothing. A write that fails never affects the compact itself.
51async function record($: EngineInterface, status: 'ok' | 'skip' | 'fail', detail?: string) {
52 try {
53 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
54 if (!home) return
55 const id = await $.session.id()
56 const at = await $.clock.now()
57 const path = `${home.replace(/\\/g, '/')}/.claude/state/auto-compact/${id}.json`
58 if (status !== 'ok') {
59 // Keep the last good record: a skip or a failure is not an auto-compact,
60 // and failures are already surfaced in a toast, so nothing is swallowed.
61 try {
62 const prior = JSON.parse((await $.fs.read(path)) as string) as { at?: unknown; status?: unknown }
63 if (prior && prior.status === 'ok' && prior.at) return
64 } catch {
65 // No record yet - write this one.
66 }
67 }
68 await $.fs.write(path, JSON.stringify({ at, status, detail: detail ?? null }))
69 } catch {
70 // The status line only displays; a failed read or write is not worth blocking on.
71 }
72}
73
74function arm($: EngineInterface) {
75 if (!cache) return
76 timer = $.clock.after(cache.idleMs, async () => {
77 timer = undefined
78 const baseUrl = (await $.env.get('ANTHROPIC_BASE_URL')) ?? ''
79 const isDirect = baseUrl === '' || baseUrl.includes('api.anthropic.com')
80 if (!cache) return
81 if (!isDirect && !cache.paidModels.test(await $.session.model())) return
82 isCompacting = true
83 try {
84 $.ui.toast(`Idle ${cache.minutes} min: auto-compacting while the prompt cache is still warm...`)
85 const done = await $.session.compact()
86 $.ui.toast('skip' in done ? `Auto-compact skipped: ${done.skip}` : 'Auto-compact complete')
87 await record($, 'skip' in done ? 'skip' : 'ok', 'skip' in done ? String(done.skip) : undefined)
88 } catch (err) {
89 // A turn was already running (compact is refused while a turn runs):
90 // that turn itself renews the cache. Any other reason is reported too,
91 // not quietly swallowed.
92 const msg = err instanceof Error ? err.message : String(err)
93 $.ui.toast(`Auto-compact did not run: ${msg}`)
94 await record($, 'fail', msg)
95 } finally {
96 isCompacting = false
97 }
98 })
99}
100
101export const register: Register = on => {
102 eligible = false
103 isCompacting = false
104 cache = undefined
105 disarm()
106
107 on('session.start', async ($, e, next) => {
108 const result = await next(e)
109 // Only sessions a person is watching get toasts; scheduled or headless
110 // runs are left alone.
111 eligible = e.isInteractive
112 cache = await load($)
113 disarm()
114 return result
115 })
116
117 // The clock restarts at every main-loop API request (typed input, a subagent
118 // report waking the main agent, a scheduled wake-up - all count), because
119 // the cache is renewed by requests, not by typing. A subagent's own requests
120 // don't count: those renew the subagent's own cache.
121 on('turn.step', async function* ($, e, next) {
122 if (e.agentId !== undefined || !eligible || isCompacting) return yield* next(e)
123 disarm()
124 try {
125 return yield* next(e)
126 } finally {
127 arm($)
128 }
129 })
130}
131