SLOPSHOPPER

idle-compact

Compacts an idle session just before its prompt cache expires, so returning does not re-write the full context

newstatustimer
v0.2.1MITupdated 2026-10-03benredmond/idle-compact/idle-compact
A shopper browsing a rack in a slop shop
README

idle-compact

A Claude Code mod that compacts an idle session just before its 1-hour prompt cache expires, so coming back doesn't re-write the full context.

/plugin marketplace add benredmond/idle-compact
/plugin install idle-compact@idle-compact
/reload-plugins
  • Runs a plain /compact once after 55 minutes idle, then waits for your next turn. It never compacts twice in a row.
  • Skips sessions under 40k tokens, running turns, and caches that have already expired.
  • Shows a countdown in the status line (◔ 12m), then ✓ 120k→12k once compacted.

Configure with /plugin configure idle-compact@idle-compact (ttlMinutes 60, marginMinutes 5, minContextTokens 40000, instructions empty for a plain /compact).

Not useful if your prompt cache lasts 5 minutes.

MIT

Source 2 files
hooks/register.ts 150 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { IdleCompactState } from '../types'
5
6const MINUTE = 60_000
7const TICK_MS = 30_000
8const MAX_ATTEMPTS = 3
9
10const idle = atom({ plugin: 'idle-compact', key: 'idle' } as const, {
11  lastMainRequestAt: null,
12  turnRunning: false,
13  latched: null,
14} as IdleCompactState)
15
16const minutes = (ms: number) => (ms < MINUTE ? '<1m' : `${Math.ceil(ms / MINUTE)}m`)
17const kTokens = (n: number) => `${Math.round(n / 1000)}k`
18
19// The countdown's glyph fills as compaction nears: ○ ◔ ◑ ◕.
20const PIE = ['○', '◔', '◑', '◕']
21const countdown = (dueInMs: number, windowMs: number) =>
22  `${PIE[Math.min(PIE.length - 1, Math.floor(((windowMs - dueInMs) / windowMs) * PIE.length))]} ${minutes(dueInMs)}`
23
24type Config = {
25  ttlMs: number
26  marginMs: number
27  minContextTokens: number
28  instructions: string | undefined
29}
30
31let isCompacting = false
32let isHeadless = false
33let failedAttempts = 0
34let headlessCounts: { tokensBefore?: number; tokensAfter?: number } | undefined
35
36async function latch($: EngineInterface, status: string) {
37  await update($, idle, s => ({ ...s, latched: status }))
38  $.ui.status(status)
39}
40
41// Headless sessions (-p, the SDK, the desktop app) refuse $.session.compact; there
42// /compact runs as a command instead.
43async function compact($: EngineInterface, cfg: Config) {
44  if (!isHeadless) return $.session.compact({ instructions: cfg.instructions })
45  await $.command.run({ command: 'compact', args: cfg.instructions ?? '' })
46  return undefined
47}
48
49// One tick both draws the countdown and, once due, compacts. The status line shows
50// only what idle-compact will do or did; reasons go to the debug log.
51async function tick($: EngineInterface, cfg: Config) {
52  const s = await read($, idle)
53  if (s.turnRunning || s.lastMainRequestAt === null) return $.ui.status(undefined)
54  if (s.latched !== null) return $.ui.status(s.latched)
55
56  const idleMs = (await $.clock.now()) - s.lastMainRequestAt
57  // Cache already cold (e.g. the machine slept): compacting now saves nothing.
58  if (idleMs >= cfg.ttlMs) return $.ui.status(undefined)
59  if (((await $.session.usage()).context.tokens ?? 0) < cfg.minContextTokens) return $.ui.status(undefined)
60
61  const windowMs = cfg.ttlMs - cfg.marginMs
62  const dueInMs = windowMs - idleMs
63  if (dueInMs > 0) return $.ui.status(countdown(dueInMs, windowMs))
64  if (isCompacting) return
65
66  isCompacting = true
67  headlessCounts = undefined
68  $.ui.status('⟳')
69  try {
70    const result = await compact($, cfg)
71    if (result?.skip !== undefined) {
72      // Vetoed by another hook: latch anyway so the veto is not retried every tick.
73      $.ui.log(`idle-compact: compact skipped: ${result.skip}`, { to: 'debug' })
74      await latch($, '⊘')
75      return
76    }
77    // Our own compact() skips our session.compact hook, so latch here too.
78    const counts = result ?? headlessCounts
79    const after = counts?.tokensAfter
80    await latch($, after === undefined ? '✓' : `✓ ${kTokens(counts?.tokensBefore ?? 0)}→${kTokens(after)}`)
81  } catch (err) {
82    // compact() rejects while a turn runs or the summary request fails: retry
83    // on the next ticks, then give up for this idle stretch.
84    failedAttempts += 1
85    const reason = (err instanceof Error ? err.message : String(err)).replace(/^idle-compact: /, '')
86    $.ui.log(`idle-compact: compact failed (${failedAttempts}/${MAX_ATTEMPTS}): ${reason}`, { to: 'debug' })
87    if (failedAttempts >= MAX_ATTEMPTS) await latch($, '✗')
88  } finally {
89    isCompacting = false
90  }
91}
92
93export const register: Register = (on, options) => {
94  const cfg: Config = {
95    ttlMs: Number(options.ttlMinutes ?? 60) * MINUTE,
96    marginMs: Number(options.marginMinutes ?? 5) * MINUTE,
97    minContextTokens: Number(options.minContextTokens ?? 40_000),
98    instructions: String(options.instructions ?? '').trim() || undefined,
99  }
100
101  let ticker: Timer | undefined
102
103  on('session.start', async ($, e, next) => {
104    isHeadless = !e.isInteractive
105    ticker?.cancel()
106    ticker = $.clock.every(TICK_MS, () => void tick($, cfg))
107    void tick($, cfg)
108    return next(e)
109  })
110
111  on('turn.start', async ($, e, next) => {
112    failedAttempts = 0
113    await update($, idle, s => ({ ...s, turnRunning: true, latched: null }))
114    $.ui.status(undefined)
115    return next(e)
116  })
117
118  // Every main-loop request renews the cache; anchor the TTL at request start.
119  on('turn.step', async function* ($, e, next) {
120    if (e.agentId === undefined) {
121      const now = await $.clock.now()
122      await update($, idle, s => ({ ...s, lastMainRequestAt: now }))
123    }
124    return yield* next(e)
125  })
126
127  on('turn.complete', async ($, e, next) => {
128    const result = await next(e)
129    if (e.agentId === undefined) {
130      await update($, idle, s => ({ ...s, turnRunning: false }))
131      void tick($, cfg)
132    }
133    return result
134  })
135
136  // A main-conversation compaction between turns (manual /compact) latches until a
137  // real turn runs; one mid-turn (auto) is followed by more work, so it does not.
138  on('session.compact', async ($, e, next) => {
139    const result = await next(e)
140    if (e.agentId !== undefined || e.trigger === 'precompute' || result.skip !== undefined) return result
141    if (isCompacting) {
142      // Our own headless /compact: tick() latches, with the counts only this hook sees.
143      headlessCounts = result
144    } else if (!(await read($, idle)).turnRunning) {
145      await latch($, '✓')
146    }
147    return result
148  })
149}
150
types/index.d.ts 13 lines
1export type IdleCompactState = {
2  lastMainRequestAt: number | null
3  turnRunning: boolean
4  /** Status held once this idle stretch is done (compacted, vetoed, gave up); cleared by the next turn. */
5  latched: string | null
6}
7
8declare module 'claude-code' {
9  interface PluginState {
10    'idle-compact': { idle: IdleCompactState }
11  }
12}
13