SLOPSHOPPER

compact-large-idle-context

Compacts a large context once before the 1-hour prompt cache expires on an idle session

newprocesstimer
A shopper browsing a rack in a slop shop
README

compact-large-idle-context

A Claude Code mod (function hooks) that compacts a large context once, shortly before the 1-hour prompt cache expires on an idle session, so coming back to a long session does not pay for re-caching the whole conversation.

Behavior

  • When a main-conversation turn ends normally (reason: 'answer'), a compaction is reserved for 50 minutes later. Subagent turns, interrupted turns, refusals and turns ended by an error do not reserve.
  • There is only ever one reservation. Every normally completed turn restarts the 50 minutes.
  • The reservation is cancelled when a new turn starts, when a manual or automatic compaction of the main conversation happens, or when the session ends (including /clear).
  • When the timer fires, the compaction runs only if all of the following hold; otherwise nothing happens and nothing is retried:
  • At least 50 and less than 58 minutes of wall-clock time have passed since the turn ended. A timer delayed further (for example by the Mac sleeping) may already have missed the cache.
  • The session is the same one that made the reservation.
  • The context is at least 200,000 tokens.
  • No turn started while these checks ran.
  • The compaction runs once. It does not reserve another one by itself, and a failed or vetoed compaction is not retried.
  • Reservations, cancellations and skips go to the debug log only (claude --debug). The only line shown in the conversation is written when the compaction runs, for example:
  Compacted idle context of 250,000 tokens; summary cache hit 90.0% (read 180,000 / write 19,000 / uncached 1,000)

The hit rate is cache read / (cache read + cache write + uncached) of the summarizer's own request, and is left out when the compaction reports no usage.

herdr

Inside a herdr pane (HERDR_ENV=1 with HERDR_PANE_ID), the compaction's spinner would read as finished work and play herdr's done sound in a background workspace. To keep it quiet, the mod:

  1. reports the pane idle as herdr:claude (with the session id) right before compacting, which overrides herdr's screen detection;
  2. after the compaction, polls herdr agent explain --json every 250 ms until the screen reads idle, waits 1 more second, and gives up waiting after 10 seconds;
  3. hands the pane back with herdr pane release-agent, then reports the session again with herdr pane report-agent-session, because the release also forgets the session herdr resumes after a restart.

A turn starting while the pane is pinned hands it back at once. Every report carries a nanosecond --seq above the official hook's time.time_ns(), since herdr silently drops a report whose seq is not newer. HERDR_BIN_PATH is used when set, herdr from PATH otherwise. Failures of these commands go to the debug log only.

This relies on herdr internals (checked against herdr 0.9.3) and may break with a herdr update.

Development

claude plugin validate claude/mods/compact-large-idle-context
claude plugin test claude/mods/compact-large-idle-context

License

MIT

Source 2 files
hooks/register.ts 189 lines
1import type { EngineInterface, ModelUsage, Register, Timer } from 'claude-code'
2import {
3  explainArgv,
4  type HerdrPane,
5  herdrPaneOf,
6  nextSeq,
7  pinArgv,
8  releaseArgv,
9  screenStateOf,
10  sessionArgv,
11  SETTLE_LIMIT_MS,
12  SETTLE_MARGIN_MS,
13  SETTLE_POLL_MS,
14} from './herdr'
15
16const DELAY_MS = 50 * 60 * 1000
17const LATEST_MS = 58 * 60 * 1000
18const MIN_TOKENS = 200_000
19
20type Reservation = { timer: Timer; endedAt: number; sessionId: string; generation: number }
21
22let reservation: Reservation | null = null
23let generation = 0
24let isTurnRunning = false
25let isCompacting = false
26let lastSeq = 0n
27let pinned: HerdrPane | null = null
28
29const formatCount = (n: number) => String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
30
31const formatMinutes = (ms: number) => {
32  const seconds = Math.floor(ms / 1000)
33  return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
34}
35
36const describeCompaction = (tokens: number, usage?: ModelUsage) => {
37  const head = `Compacted idle context of ${formatCount(tokens)} tokens`
38  if (!usage) return head
39  const { cache_read_input_tokens: read, cache_creation_input_tokens: write, input_tokens: uncached } = usage
40  const total = read + write + uncached
41  if (total === 0) return head
42  const rate = ((read / total) * 100).toFixed(1)
43  return `${head}; summary cache hit ${rate}% (read ${formatCount(read)} / write ${formatCount(write)} / uncached ${formatCount(uncached)})`
44}
45
46const isStale = (mine: Reservation) => generation !== mine.generation || isTurnRunning
47
48function debug($: EngineInterface, text: string) {
49  return $.ui.log(`compact-large-idle-context: ${text}`, { to: 'debug' })
50}
51
52function cancel($: EngineInterface, why: string) {
53  generation++
54  if (!reservation) return
55  reservation.timer.cancel()
56  reservation = null
57  debug($, `reservation cancelled (${why})`)
58}
59
60async function seq($: EngineInterface) {
61  lastSeq = nextSeq(await $.clock.now(), lastSeq)
62  return lastSeq
63}
64
65async function herdr($: EngineInterface, argv: string[]) {
66  try {
67    const result = await $.process.run(argv, { timeoutMs: 5000 })
68    if (result.exitCode === 0) return result
69    await debug($, `herdr ${argv[2]} exited ${result.exitCode}: ${result.stderr.trim()}`)
70  } catch (error) {
71    await debug($, `herdr ${argv[2]} failed: ${error instanceof Error ? error.message : String(error)}`)
72  }
73  return undefined
74}
75
76async function pinHerdrIdle($: EngineInterface) {
77  const pane = herdrPaneOf({
78    herdr: await $.env.get('HERDR_ENV'),
79    pane: await $.env.get('HERDR_PANE_ID'),
80    bin: await $.env.get('HERDR_BIN_PATH'),
81  })
82  if (!pane) return
83  if (!(await herdr($, pinArgv(pane, await seq($), await $.session.id())))) return
84  pinned = pane
85  await debug($, 'herdr pane pinned idle')
86}
87
88async function waitForScreenIdle($: EngineInterface, pane: HerdrPane) {
89  const startedAt = await $.clock.now()
90  while ((await $.clock.now()) - startedAt < SETTLE_LIMIT_MS) {
91    const result = await herdr($, explainArgv(pane))
92    if (result && screenStateOf(result.stdout) === 'idle') {
93      await $.clock.sleep(SETTLE_MARGIN_MS)
94      return
95    }
96    await $.clock.sleep(SETTLE_POLL_MS)
97  }
98  await debug($, 'herdr screen did not settle idle; handing the pane back anyway')
99}
100
101async function unpinHerdr($: EngineInterface, settle: boolean) {
102  const pane = pinned
103  if (!pane) return
104  if (settle) await waitForScreenIdle($, pane)
105  if (pinned !== pane) return
106  pinned = null
107  await herdr($, releaseArgv(pane, await seq($)))
108  await herdr($, sessionArgv(pane, await seq($), await $.session.id()))
109  await debug($, 'herdr pane handed back')
110}
111
112async function fire($: EngineInterface, mine: Reservation) {
113  if (reservation !== mine) return
114  reservation = null
115  try {
116    const now = await $.clock.now()
117    if (isStale(mine)) return debug($, 'skipped (a turn started during the check)')
118    const elapsed = now - mine.endedAt
119    if (elapsed < DELAY_MS || elapsed >= LATEST_MS) {
120      return debug($, `skipped (${formatMinutes(elapsed)} since the turn ended)`)
121    }
122    const sessionId = await $.session.id()
123    if (isStale(mine)) return debug($, 'skipped (a turn started during the check)')
124    if (sessionId !== mine.sessionId) return debug($, 'skipped (the session changed)')
125    const { context } = await $.session.usage()
126    if (isStale(mine)) return debug($, 'skipped (a turn started during the check)')
127    const tokens = context.tokens ?? 0
128    if (tokens < MIN_TOKENS) return debug($, `skipped (context is ${formatCount(tokens)} tokens)`)
129    isCompacting = true
130    await pinHerdrIdle($)
131    const result = await $.session.compact().finally(() => {
132      isCompacting = false
133      void unpinHerdr($, true)
134    })
135    if (result.skip !== undefined) return debug($, `skipped (compaction vetoed: ${result.skip})`)
136    await $.ui.log(describeCompaction(result.tokensBefore ?? tokens, result.usage))
137  } catch (error) {
138    await debug($, `compaction failed: ${error instanceof Error ? error.message : String(error)}`)
139  } finally {
140    isCompacting = false
141  }
142}
143
144async function reserve($: EngineInterface) {
145  cancel($, 'rescheduling')
146  const expected = generation
147  const endedAt = await $.clock.now()
148  const sessionId = await $.session.id()
149  if (generation !== expected || isTurnRunning) return
150  const mine: Reservation = { timer: { cancel: () => {} }, endedAt, sessionId, generation }
151  mine.timer = $.clock.after(DELAY_MS, () => void fire($, mine))
152  reservation = mine
153  await debug($, `compaction reserved in ${formatMinutes(DELAY_MS)}`)
154}
155
156export const register: Register = on => {
157  on('turn.start', ($, e, next) => {
158    isTurnRunning = true
159    cancel($, 'a turn started')
160    void unpinHerdr($, false)
161    return next(e)
162  })
163
164  on('turn.complete', async ($, e, next) => {
165    const completed = await next(e)
166    if (e.agentId !== undefined) return completed
167    isTurnRunning = false
168    if (isCompacting) return completed
169    if (e.reason !== 'answer' || e.isAborted) {
170      cancel($, `the turn ended with ${e.reason}`)
171      return completed
172    }
173    await reserve($)
174    return completed
175  })
176
177  on('session.compact', ($, e, next) => {
178    if (e.agentId === undefined && (e.trigger === 'manual' || e.trigger === 'auto')) {
179      cancel($, `${e.trigger} compaction`)
180    }
181    return next(e)
182  })
183
184  on('session.end', ($, e, next) => {
185    cancel($, `session ended (${e.reason})`)
186    return next(e)
187  })
188}
189
hooks/herdr.ts 49 lines
1const SOURCE = 'herdr:claude'
2const AGENT = 'claude'
3
4export const SETTLE_POLL_MS = 250
5export const SETTLE_MARGIN_MS = 1000
6export const SETTLE_LIMIT_MS = 10_000
7
8export type HerdrPane = { bin: string; pane: string }
9
10export const herdrPaneOf = (env: {
11  herdr?: string
12  pane?: string
13  bin?: string
14}): HerdrPane | undefined => {
15  if (env.herdr !== '1' || !env.pane) return undefined
16  return { bin: env.bin || 'herdr', pane: env.pane }
17}
18
19export const nextSeq = (nowMs: number, last: bigint) => {
20  const now = BigInt(nowMs) * 1_000_000n
21  return now > last ? now : last + 1n
22}
23
24const identity = ['--source', SOURCE, '--agent', AGENT]
25
26export const pinArgv = ({ bin, pane }: HerdrPane, seq: bigint, sessionId: string) => [
27  bin, 'pane', 'report-agent', pane, ...identity,
28  '--state', 'idle', '--seq', String(seq), '--agent-session-id', sessionId,
29]
30
31export const releaseArgv = ({ bin, pane }: HerdrPane, seq: bigint) => [
32  bin, 'pane', 'release-agent', pane, ...identity, '--seq', String(seq),
33]
34
35export const sessionArgv = ({ bin, pane }: HerdrPane, seq: bigint, sessionId: string) => [
36  bin, 'pane', 'report-agent-session', pane, ...identity,
37  '--seq', String(seq), '--agent-session-id', sessionId,
38]
39
40export const explainArgv = ({ bin, pane }: HerdrPane) => [bin, 'agent', 'explain', pane, '--json']
41
42export const screenStateOf = (stdout: string) => {
43  try {
44    return (JSON.parse(stdout) as { state?: unknown }).state
45  } catch {
46    return undefined
47  }
48}
49