SLOPSHOPPER

idle-compact

Compact once after 50 idle minutes, while the 1h prompt cache is still warm.

newtimer
A shopper browsing a rack in a slop shop
README

Idle Compact

A Claude Code function-hooks plugin (Mod) that compacts the conversation once after 50 idle minutes, while the 1h prompt cache is still warm.

In simple terms: If you leave a long session alone, coming back after the 1h cache TTL re-caches the whole context. This plugin compacts shortly before that happens, so the next turn starts from a small, cheap context. It never keeps the cache alive, and it does nothing more after that one compaction.

⚠️ Note: Function hooks are early access. Set CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 (for example in the env block of ~/.claude/settings.json). Written against Claude Code 2.1.280; the cache hit line was checked on 2.1.282.

How to Install (Plugin)

/plugin marketplace add takahirom/takahirom-claude-code-marketplace
/plugin install idle-compact@takahirom-claude-code-marketplace

Execution Flow

flowchart TD
    A[main turn.complete<br/>reason: answer] --> B[Arm one 50 min timer<br/>remember wall-clock time and session id]
    B --> C{What comes first?}
    C -->|turn.start / session.end / manual compact| D[Cancel timer]
    D -->|next main turn completes| A
    C -->|timer fires| E{elapsed 50–58 min,<br/>same session,<br/>still the latest timer?}
    E -->|No: e.g. Mac slept, cache cold| F[Do nothing]
    E -->|Yes| G[$.session.compact]
    G --> I[Show the cache hit of the summary call]
    I --> H[Done: no re-arm, no retry]
  • Only the main conversation arms the timer. Subagent completions (agentId), aborted or failed turns, and completions without a matching turn.start are ignored.
  • The timer callback checks the wall clock ($.clock.now()) again. A timer that runs late, for example after the Mac slept, does nothing once 58 minutes have passed, because by then the cache may already be cold.
  • The wall-clock anchor is the time turn.complete fired. Claude Code does not expose the cache's own last-activity time or TTL to plugins, so a session on the 5m TTL is not detected.
  • Each time the timer is armed, one line in the conversation says when the compaction will happen, for example idle-compact: compacts at 15:03 if nothing happens before then. It is kept in the transcript but never sent to the model. After the idle compaction, one more line gives how much of the summary call's input came from the prompt cache, for example idle-compact: compacted with a 95% cache hit (74,482 read, 326 written, 2,813 uncached); it is left out when Claude Code reports no usage for the compaction. Everything else goes to the debug log only (--debug / --debug-file).
  • A failed compaction, one refused because a turn is running or because DISABLE_COMPACT is set, is ignored silently and not retried.

How It Avoids Compacting Twice

The plugin holds at most one timer, and only the end of a turn you sent starts it.

  1. You finish a turn: a 50-minute timer starts, replacing any earlier one.
  2. You send another message: the timer is cancelled.
  3. 50 minutes pass with nothing: the timer fires once, compacts, and is gone.
  4. The compaction is not a turn you sent, so no new timer starts. Nothing is scheduled until you send something again.

A failed compaction is not retried, and running /compact yourself cancels the timer.

Development

claude plugin validate plugins/idle-compact
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test plugins/idle-compact

Types come from /plugin-types (written to .claude/types, not committed).

Source 1 files
hooks/idle-compact.ts 152 lines
1import type { EngineInterface, Register, Timer } from 'claude-code'
2
3// Compact once after the main conversation has been idle for 50 minutes, while
4// the 1h prompt cache is still warm. One one-shot timer at most; no polling,
5// no keep-alive, and nothing is re-armed after the compaction.
6export const IDLE_MS = 50 * 60 * 1000
7// Past this the cache may already be cold (the Mac slept, the timer ran late),
8// so compacting would re-cache the whole context: do nothing instead.
9export const LATEST_MS = 58 * 60 * 1000
10
11type Armed = {
12  generation: number
13  timer: Timer
14  sessionId: string
15  armedAt: number
16}
17
18type State = {
19  generation: number
20  armed: Armed | null
21  currentTurnId: string | null
22  isCompacting: boolean
23}
24
25// Debug log only (--debug / --debug-file): never shown to the person.
26async function log($: EngineInterface, text: string) {
27  try {
28    await $.ui.log(`idle-compact: ${text}`, { to: 'debug' })
29  } catch {}
30}
31
32// Shown in the conversation and kept in the transcript, never sent to the model.
33async function notice($: EngineInterface, text: string) {
34  try {
35    await $.ui.log(text, { to: 'transcript' })
36  } catch {}
37}
38
39// Local wall-clock time, HH:MM.
40function clockTime(ms: number) {
41  const d = new Date(ms)
42  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
43}
44
45type CompactUsage = {
46  input_tokens?: number
47  output_tokens?: number
48  cache_read_input_tokens?: number
49  cache_creation_input_tokens?: number
50}
51
52// How much of the summary call's input came from the prompt cache. The host
53// leaves `usage` out when it has none (all zero, or a precomputed summary).
54export function cacheHitLine(result: unknown): string | null {
55  const usage = (result as { usage?: CompactUsage } | undefined)?.usage
56  if (usage === undefined) return null
57  const read = usage.cache_read_input_tokens ?? 0
58  const written = usage.cache_creation_input_tokens ?? 0
59  const uncached = usage.input_tokens ?? 0
60  const total = read + written + uncached
61  if (total === 0) return null
62  const n = (x: number) => x.toLocaleString('en-US')
63  // Integer math: (29 / 50) * 100 is 57.99..., which would floor to 57.
64  const percent = Math.floor((read * 100) / total)
65  return `compacted with a ${percent}% cache hit (${n(read)} read, ${n(written)} written, ${n(uncached)} uncached)`
66}
67
68function cancel(state: State) {
69  state.generation++
70  state.armed?.timer.cancel()
71  state.armed = null
72}
73
74async function arm($: EngineInterface, state: State) {
75  cancel(state)
76  const generation = state.generation
77  const armedAt = await $.clock.now()
78  const sessionId = await $.session.id()
79  if (state.generation !== generation) return
80  const timer = $.clock.after(IDLE_MS, () => void fire($, state, generation))
81  state.armed = { generation, timer, sessionId, armedAt }
82  // Issued before any further await, so it never announces a timer a turn has
83  // just cancelled.
84  await notice($, `compacts at ${clockTime(armedAt + IDLE_MS)} if nothing happens before then`)
85  await log($, `armed at ${new Date(armedAt).toISOString()}`)
86}
87
88async function fire($: EngineInterface, state: State, generation: number) {
89  const armed = state.armed
90  if (armed === null || armed.generation !== generation || state.isCompacting) return
91  state.armed = null
92  try {
93    const elapsed = (await $.clock.now()) - armed.armedAt
94    const minutes = (elapsed / 60000).toFixed(1)
95    if (elapsed < IDLE_MS || elapsed >= LATEST_MS) {
96      await log($, `fired after ${minutes} min: outside the window, skipped`)
97      return
98    }
99    if ((await $.session.id()) !== armed.sessionId) {
100      await log($, `fired after ${minutes} min: session changed, skipped`)
101      return
102    }
103    await log($, `fired after ${minutes} min: compacting`)
104    // Checked after the last await: a turn may have started in the meantime.
105    if (state.generation !== generation) return
106    state.isCompacting = true
107    const result = await $.session.compact()
108    await log($, 'compaction finished')
109    const line = cacheHitLine(result)
110    if (line !== null) await notice($, line)
111  } catch (error) {
112    // A running turn, DISABLE_COMPACT or a headless host: no retry, by design.
113    await log($, `compaction failed: ${error instanceof Error ? error.message : String(error)}`)
114  } finally {
115    state.isCompacting = false
116  }
117}
118
119export const register: Register = (on) => {
120  const state: State = { generation: 0, armed: null, currentTurnId: null, isCompacting: false }
121
122  on('turn.start', ($, e, next) => {
123    cancel(state)
124    state.currentTurnId = e.turnId
125    return next(e)
126  })
127
128  on('turn.complete', async ($, e, next) => {
129    const result = await next(e)
130    // Main loop only (subagent runs carry agentId and raise no turn.start),
131    // answered normally, and not a completion raised by our own compaction.
132    if (e.agentId !== undefined || e.reason !== 'answer' || state.isCompacting) return result
133    if (e.turnId !== state.currentTurnId) return result
134    state.currentTurnId = null
135    await arm($, state)
136    return result
137  })
138
139  // The person's /compact (or an automatic one) is activity too: the pending
140  // timer would only compact the fresh summary again.
141  on('session.compact', ($, e, next) => {
142    if (e.agentId === undefined && (e.trigger === 'manual' || e.trigger === 'auto')) cancel(state)
143    return next(e)
144  })
145
146  on('session.end', ($, e, next) => {
147    cancel(state)
148    state.currentTurnId = null
149    return next(e)
150  })
151}
152