SLOPSHOPPER

idle-compact

Compacts an idle interactive session shortly before its prompt cache expires, so coming back later doesn't re-read the whole context uncached.

newstatustimer
★ 1v0.5.0MITupdated 2026-10-09davidar/claude-idle-compact
A shopper browsing a rack in a slop shop
README

idle-compact

A Claude Code mod that compacts an idle session shortly before its prompt cache expires. You come back to a small, warm context instead of a big, cold one.

Needs Claude Code 2.1.287 or later, the first release with mods on by default.

Why

You often step away from a session without knowing you'll be gone for more than an hour. The main conversation's prompt cache lives for 1 hour on a Claude subscription. When you come back after that, the next request re-sends the whole context and writes it to the cache again at full price. For a 367k-token session, that is a lot of usage spent on saying "ok, where were we".

Running /compact before you leave would fix this, but only if you knew you were leaving.

idle-compact does it for you. After 50 minutes with no activity, it compacts the conversation while the cache is still warm:

Cold return, no compactionidle-compact at 50 min
Summary passnonereads 367k from cache at about 0.1× input price, writes a few thousand output tokens
First request backre-caches 367k at 2× input price (1-hour cache write)re-caches the ~12k summary, plus the system prompt and tools
Roughly, in input-token equivalents~730k~100k, plus twice the system prompt and tools

Cache reads are even cheaper than 0.1× on some current models, which makes the gap bigger. The cost is that the conversation is now a summary: see Caveats.

What it does

  • After every main-loop turn, it arms a timer. The next turn, /clear or exit cancels it. A slash command that doesn't call the model leaves it running, because it doesn't refresh the cache.
  • When the timer fires, it checks how big the conversation is, as /context's Messages row counts it. Below 40k tokens it doesn't compact, because a cold re-read is cheap and not worth losing detail over, and leaves a line in the transcript saying so (not compacted: the conversation is 12k tokens, under the 40k floor). The system prompt and tool definitions don't count: compaction can't shrink them, and they get re-cached on your return either way.
  • Otherwise it runs the same compaction /compact runs. The instructions tell the summariser that the user stepped away, and what to keep: open tasks, decisions and their reasons, follow-ups with dates, file paths, and anything the user asked to remember.
  • It leaves a line in the transcript and a pinned status line under the prompt:
  ● idle-compact: compacted at 19:13 after 50 min idle (367k tokens → 12k summary, 99% read from cache)

The first figure is the whole context before. The second is the summary alone: the context you come back to is that plus the system prompt and tool definitions, which compaction can't shrink. The percentage is how much of the summary request's input the prompt cache served, which is the saving. The status line goes away when the next turn starts.

  • If the timer fires more than 10 minutes late, which happens when the machine was asleep, the cache has already expired. It doesn't compact then, and leaves a line in the transcript saying so.
  • A reload of the plugin (an update, a change to its options) cancels the timer, so it keeps the time the last turn ended and re-arms for whatever is left. The compaction still comes 50 minutes after the turn, not 50 minutes after the reload. If that time has already passed, it does nothing.
  • It compacts once per idle stretch. The compaction itself isn't a turn, so nothing re-arms the timer until the next turn. A background agent reporting back counts as a turn, so a session can compact again after one, if the context has grown past the floor by then.

What the hook does

The plugin is one hooks module, hooks/register.ts, and nothing else: no skills, commands, agents or MCP servers. It handles four events:

EventWhat the hook does
turn.completeFor the main conversation only, starts the idle timer ($.clock.after) and notes the time.
turn.startCancels the timer, forgets the time and clears the status line.
session.endCancels the timer, forgets the time and clears the status line.
session.startWhen the plugin is loaded or reloaded, restarts the timer for what's left of it, if anything.

It lets every event continue unchanged. It doesn't hook your prompts or Claude's tool calls.

In prose: the hook starts a timer when a turn of the main conversation completes, and cancels it when the next turn starts or when the session ends. If the timer runs out, the hook compacts the conversation, unless the timer fired too late for the cache to still be warm. When the plugin is reloaded, the hook restarts the timer for the time that was left.

The only things it reads are the session's context size and its /context breakdown ($.session.usage), the session's id ($.session.id), and the one value it keeps: the session id and the time of its last turn, in the plugin state Claude Code holds for the running session ($.state). It reads no environment variables, settings or files.

When the timer fires it asks for that breakdown counted properly, which has Claude Code make the same token-count requests /context makes (they cost no tokens), and then calls $.session.compact, which makes the same model request /compact makes, on your own account. Those are the only things it sends anywhere. It makes no network requests of its own, starts no processes, and reads and writes no files. It writes one line to the transcript ($.ui.log) and one to the status line ($.ui.status).

It assumes a 1-hour cache

idle-compact is for sessions with a 1-hour prompt cache, which is what a Claude subscription gets within its usage limits. It doesn't check: it waits 50 minutes, which leaves 10 minutes of margin. A 367k-token summary pass took under 2 minutes.

On a 5-minute cache (an API key, Bedrock, Vertex or Foundry by default) it has nothing to offer, because the cache is long cold by the time it fires. Don't install it there, unless you also set "promptCacheTtl": "1h" in your settings.

Install

It's in the Anthropic Directory, which Claude Code has built in. Find Idle Compact under /plugin, or run:

/plugin install idle-compact@anthropic-plugin-directory

Each version there is reviewed by Anthropic before it goes live, so it can trail this repo. To get releases as soon as they're pushed, add this repo as a marketplace and install from it instead:

/plugin marketplace add davidar/claude-idle-compact
/plugin install idle-compact@idle-compact

Install one or the other, not both.

To try it without installing, run claude --plugin-dir /path/to/claude-idle-compact.

If you set CLAUDE_CODE_ENABLE_FUNCTION_HOOKS during the mods early access, you can remove it. Claude Code ignores it now.

Configure

Set these with /plugin configure idle-compact@anthropic-plugin-directory (or idle-compact@idle-compact if you installed from this repo), or in /config:

OptionDefaultMeaning
idleMinutes50Minutes idle before compacting.
minTokens40000Leave conversations smaller than this alone. It counts the conversation only (/context's Messages row), not the system prompt and tools.

Caveats

  • Compaction is lossy. You come back to a summary, not the full transcript. If you'd rather pay for a cold re-read than lose detail, raise minTokens or disable the plugin for that session.
  • The transcript records it as a manual compaction. It goes through the same path as /compact, so the transcript JSONL marks it with trigger: "manual". To find one afterwards, look for the "compacted at …" line, which is written to the transcript and shown again after --resume.
  • It doesn't know your cache TTL. A mod can't read the TTL Claude Code chose, so idle-compact assumes 1 hour. A subscription past its usage limits drops to a 5-minute cache, and there the compaction runs on a cold cache: it costs about what the cold return would have, and saves nothing. The line it leaves shows this as a low "read from cache" percentage.
  • A mod runs with your permissions. claude plugin validate . on a clone shows what the module hooks, calls and reads, without running it.

Develop

claude plugin test .                               # tests (mocked clock and usage)
claude plugin validate .claude-plugin/plugin.json  # what the module hooks, calls and reads
npx -p typescript tsc -p .                         # type-check

The types come from Claude Code itself. It writes .claude-plugin/types/ (gitignored) the first time it loads the mod from this folder, stamped with its version. The root tsconfig.json extends the tsconfig.json in there. On a fresh clone, run the mod once, for example claude --plugin-dir . -p ok, before type-checking.

tests/live.sh checks what the mocked tests can't: it runs throwaway Haiku sessions in tmux with a one-minute delay and watches for the compaction after a plain turn, after a slash command, and with a background agent running. It takes about four minutes.

To poke at it by hand, use a throwaway session with a one-minute delay. A copy loaded with --plugin-dir replaces the installed one for that session, and takes its options from idle-compact@inline:

claude --plugin-dir . --model haiku --settings \
  '{"pluginConfigs":{"idle-compact@inline":{"options":{"idleMinutes":1,"minTokens":100}}}}'

Related

cache-tax goes at the same problem from the other side. Before you step away, you run /keepwarm, and it keeps the cache warm with a small fork over the transcript every 50 idle minutes. If you come back to a cold cache anyway, it stops your first send and shows what the rewrite will cost. The trade-off:

  • cache-tax loses no detail, but costs something every hour you're away, and you have to arm it.
  • idle-compact is automatic and costs something once, but you come back to a summary.

License

MIT

Source 2 files
hooks/register.ts 202 lines
1import type { EngineInterface, ModelUsage, PluginOptions, Register, SessionContextUsage, Timer } from 'claude-code'
2
3// How long before a 1-hour cache lapses to compact. A 367k-token summary pass took about 2 minutes.
4// Also how late the timer may fire and still compact.
5const MARGIN_MS = 10 * 60_000
6const HOUR_MS = 60 * 60_000
7// Below this many conversation tokens a cold re-read is cheap; not worth losing detail. The system
8// prompt and tools don't count: compaction leaves them in place, and they are re-cached either way.
9const DEFAULT_MIN_TOKENS = 40_000
10
11// When a session's last main-loop turn ended (declared in types/index.d.ts). Held by the host for the
12// process, so it outlives a reload of this module.
13const LAST_TURN = { plugin: 'idle-compact', key: 'lastTurn' } as const
14
15function nonNegative(raw: unknown): number | undefined {
16  if (raw === undefined || raw === '') return undefined
17  const n = Number(raw)
18  return Number.isFinite(n) && n >= 0 ? n : undefined
19}
20
21const hhmm = (ms: number) => new Date(ms).toTimeString().slice(0, 5)
22const inThousands = (n: number) => `${Math.round(n / 1000)}k`
23// Small figures as they are: a 100-token floor isn't "0k".
24const approx = (n: number) => (n < 1000 ? `${n}` : inThousands(n))
25
26/** How much of the summary request's input the prompt cache served: the saving this mod exists for. */
27function cacheShare(usage: ModelUsage | undefined) {
28  if (!usage) return ''
29  const input = usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
30  return input ? `, ${Math.floor((usage.cache_read_input_tokens * 100) / input)}% read from cache` : ''
31}
32
33/**
34 * What compaction can shrink: the conversation, as /context's Messages row counts it. The whole
35 * context when there is no breakdown.
36 *
37 * The row is a residual: the last response's input less the other rows. That needs the `full`
38 * breakdown, which counts the other rows with the token-count API; the `summary` one estimates
39 * them, and was seen putting the tools at twice their size, which left 2 tokens of conversation.
40 */
41function conversationTokens(context: SessionContextUsage) {
42  const row = context.breakdown?.categories.find((c) => c.kind === 'used' && c.name === 'Messages')
43  return row?.tokens ?? context.tokens
44}
45
46/** Idle time before compacting. It assumes the 1-hour cache: the mod is no use on a 5-minute one. */
47function idleDelayMs(options: PluginOptions) {
48  const minutes = nonNegative(options.idleMinutes)
49  return minutes ? minutes * 60_000 : HOUR_MS - MARGIN_MS
50}
51
52function minTokens(options: PluginOptions) {
53  return nonNegative(options.minTokens) ?? DEFAULT_MIN_TOKENS
54}
55
56const instructions = (at: string, idleMin: number) =>
57  `This compaction ran automatically at ${at}, after ${idleMin} minutes idle, before the prompt cache expired. ` +
58  'Open the summary by saying so. Keep open tasks, decisions and their reasons, pending follow-ups with dates, ' +
59  'file paths touched, and anything the user asked to be remembered.'
60
61/** Per-activation state: the armed timer and what the module has put on screen. */
62type State = {
63  timer?: Timer
64  // Bumped on every disarm, so an arm still awaiting its reads can tell it was overtaken.
65  generation: number
66  hasStatus: boolean
67}
68
69function disarm(state: State) {
70  state.generation++
71  state.timer?.cancel()
72  state.timer = undefined
73}
74
75/** A reload while a turn runs, or after the session ended, must not re-arm. */
76function forget($: EngineInterface) {
77  return $.state.set(LAST_TURN, null).catch(() => {})
78}
79
80function clearStatus($: EngineInterface, state: State) {
81  if (state.hasStatus) $.ui.status(undefined)
82  state.hasStatus = false
83}
84
85async function compactIfWorthIt($: EngineInterface, state: State, idleSince: number, latest: number, floor: number) {
86  const fired = state.generation
87  try {
88    const { context } = await $.session.usage({ breakdown: 'full' })
89    // Unknown after a compaction until the next response: nothing to shrink then either.
90    if (context.tokens === undefined) return
91    const conversation = conversationTokens(context) ?? 0
92    if (conversation < floor) {
93      $.ui.log(`not compacted: the conversation is ${approx(conversation)} tokens, under the ${approx(floor)} floor`)
94      return
95    }
96    const before = context.tokens ?? 0
97    const now = await $.clock.now()
98    const at = hhmm(now)
99    const idleMin = Math.round((now - idleSince) / 60_000)
100    // A timer that fires late (the machine slept) finds the cache cold: compacting now would cost
101    // what the cold return costs, and lose detail on top.
102    if (now > latest) {
103      $.ui.log(`not compacted: the timer fired ${idleMin} min after the last turn, too late for a warm cache`)
104      return
105    }
106    const result = await $.session.compact({ instructions: instructions(at, idleMin) })
107    if (result.messages === undefined) return
108    // tokensBefore is the whole context; tokensAfter is only the summary, without the system prompt and tools.
109    const summary = result.tokensAfter === undefined ? '' : ` → ${inThousands(result.tokensAfter)} summary`
110    const sizes = `${inThousands(result.tokensBefore ?? before)} tokens${summary}${cacheShare(result.usage)}`
111    const line = `compacted at ${at} after ${idleMin} min idle (${sizes})`
112    $.ui.log(line)
113    // A turn started while the summary ran has already cleared the status; don't pin a stale one.
114    if (fired !== state.generation) return
115    $.ui.status(line)
116    state.hasStatus = true
117  } catch {
118    // compact rejects while a turn runs; the next turn.complete re-arms the timer
119  }
120}
121
122/** Starts the timer: due `ms` from now, it measures the idle time from `idleSince`. */
123function schedule($: EngineInterface, state: State, idleSince: number, ms: number, delay: number, floor: number) {
124  state.timer = $.clock.after(ms, () => {
125    state.timer = undefined
126    void compactIfWorthIt($, state, idleSince, idleSince + delay + MARGIN_MS, floor)
127  })
128}
129
130async function arm($: EngineInterface, state: State, options: PluginOptions) {
131  disarm(state)
132  const armed = state.generation
133  const delay = idleDelayMs(options)
134  const idleSince = await $.clock.now()
135  const sessionId = await $.session.id()
136  if (armed !== state.generation) return
137  schedule($, state, idleSince, delay, delay, minTokens(options))
138  // Kept for a reload of the module, which cancels the timer.
139  await $.state.set(LAST_TURN, { sessionId, at: idleSince })
140  // A turn.start that disarmed meanwhile may have cleared it before this write landed.
141  if (armed !== state.generation) await forget($)
142}
143
144/** After a reload: re-arms for what is left of the delay since this session's last turn, if any. */
145async function rearm($: EngineInterface, state: State, options: PluginOptions) {
146  disarm(state)
147  const armed = state.generation
148  const delay = idleDelayMs(options)
149  const { value: last } = await $.state.get(LAST_TURN)
150  const sessionId = await $.session.id()
151  const now = await $.clock.now()
152  if (armed !== state.generation || !last || last.sessionId !== sessionId) return
153  // Past the deadline the timer has fired already, or the session was resumed long after: either way
154  // there's nothing to do now.
155  const left = last.at + delay - now
156  if (left > 0) schedule($, state, last.at, left, delay, minTokens(options))
157}
158
159/**
160 * Arms a timer at the end of every main-loop turn and cancels it when the next turn starts, so it
161 * runs from the last model request: the last time the cache was refreshed. A prompt that starts no
162 * turn, such as a local slash command, doesn't touch the cache and so doesn't touch the timer.
163 * When it fires on time and the context is big enough, compacts while the cache is still warm and
164 * leaves a note in the transcript and the status line. The time of the last turn is kept in
165 * `$.state`, so a reload of the module, which cancels the timer, re-arms it for the time left.
166 */
167export const register: Register = (on, options) => {
168  // A reloaded module can't tell whether the one before it pinned a status: assume it did.
169  const state: State = { generation: 0, hasStatus: true }
170
171  on('turn.start', async ($, e, next) => {
172    disarm(state)
173    clearStatus($, state)
174    await forget($)
175    return next(e)
176  })
177
178  on('turn.complete', async ($, e, next) => {
179    const result = await next(e)
180    if (e.agentId === undefined) {
181      // never let arming the timer break the turn
182      await arm($, state, options).catch(() => {})
183    }
184    return result
185  })
186
187  on('session.end', async ($, e, next) => {
188    disarm(state)
189    clearStatus($, state)
190    await forget($)
191    return next(e)
192  })
193
194  // Also fires when the module is reloaded (/reload-plugins, an update, an options change), which
195  // cancels the timer: pick up where the last turn left off.
196  on('session.start', async ($, e, next) => {
197    const result = await next(e)
198    await rearm($, state, options).catch(() => {})
199    return result
200  })
201}
202
types/index.d.ts 12 lines
1// idle-compact's contract: the one value it keeps in `$.state`.
2
3/** When a session's last main-loop turn ended, kept so a reload of the module can re-arm its timer. */
4export type IdleCompactLastTurn = { sessionId: string; at: number }
5
6declare module 'claude-code' {
7  interface PluginState {
8    // null while a turn runs and after the session ends
9    'idle-compact': { lastTurn: IdleCompactLastTurn | null }
10  }
11}
12