SLOPSHOPPER

idle-compact

Come back to big chats for less. When a large chat (150k+ tokens) has been idle for 40 minutes, it is compacted while the prompt cache is still warm, so…

newtimer
★ 2v0.1.0MITupdated 2026-10-15Ryan-LT/claude-mods/idle-compact
A shopper browsing a rack in a slop shop
README

idle-compact

Come back to big chats for less. Claude Code's prompt cache expires after an hour. Return to a 300k-token chat after lunch and the first reply pays to reload all of it at full price.

idle-compact runs /compact on a large chat (150k+ tokens) after 40 minutes idle, while the cache is still warm. Compacting is cheap then, and the chat you come back to is small.

  • Safe: it only starts counting after a reply in the current process. A chat you reopen cold is never compacted, since that would cost full price.
  • Keeps what matters: the summary holds your last request, open tasks, decisions and their reasons, exact file paths, links, commands and error messages, so you can carry on without re-explaining.
  • Zero config: install it and forget it.

Install

Run this in a terminal claude session:

/plugin install idle-compact --marketplace Ryan-LT/claude-mods

Answer y to add the marketplace, then choose the user scope.

Source 1 files
hooks/register.ts 73 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3const MINUTE = 60_000
4/** Quiet this long after the last reply: compact before the prompt cache (1 hour) goes cold. */
5const IDLE = 40 * MINUTE
6/** A smaller chat is cheap to come back to as it is. */
7const MIN_TOKENS = 150_000
8const CHECK_EVERY = MINUTE
9const KEEP =
10  'Keep what was asked last and any open tasks, decisions and why they were made, exact file paths, links, ' +
11  'commands, and error messages, so the work can resume without re-explaining.'
12
13/**
14 * When this process last saw a main-thread reply end, or 0. Zero until a reply in this process,
15 * so a chat reopened cold (cache already gone) is never compacted: that would pay full price.
16 */
17let lastReplyAt = 0
18let isBusy = false
19let isCompacting = false
20let isTicking = false
21/** The /compact we start runs as a turn of its own here; its end is not a reply to arm on. */
22let isOwnTurnNext = false
23
24const check = async ($: EngineInterface) => {
25  if (!lastReplyAt || isBusy || isCompacting) return
26  if ((await $.clock.now()) - lastReplyAt < IDLE) return
27  // One try per idle stretch: the next reply arms it again.
28  lastReplyAt = 0
29  const { context } = await $.session.usage()
30  if ((context.tokens ?? 0) < MIN_TOKENS) return
31  isCompacting = true
32  try {
33    isOwnTurnNext = true
34    // In the desktop app and SDK, compaction runs as a /compact turn, not a direct call.
35    await $.session.compact({ instructions: KEEP }).catch(() => $.command.run({ command: 'compact', args: KEEP }))
36  } catch {
37    isOwnTurnNext = false
38    // A turn started meanwhile, or compaction isn't possible now: leave the chat as it is.
39  } finally {
40    isCompacting = false
41  }
42}
43
44const ensureTicker = ($: EngineInterface) => {
45  if (isTicking) return
46  isTicking = true
47  $.clock.every(CHECK_EVERY, () => void check($).catch(() => {}))
48}
49
50export const register: Register = on => {
51  on('turn.start', ($, e, next) => {
52    ensureTicker($)
53    isBusy = true
54
55    return next(e)
56  })
57
58  on('turn.complete', async ($, e, next) => {
59    const done = await next(e)
60    if (!e.agentId) {
61      isBusy = false
62      if (isOwnTurnNext) {
63        isOwnTurnNext = false
64        return done
65      }
66      lastReplyAt = await $.clock.now()
67      ensureTicker($)
68    }
69
70    return done
71  })
72}
73