SLOPSHOPPER

Smart Compact

Compacts between turns when the context is full enough, sooner after a commit, PR, approved plan or finished todo list, or when the prompt cache is about to go…

newguardtoasttimer
A shopper browsing a rack in a slop shop
README

🧩 Claude Code Mods

Live panes, prompt bands, status line entries, toasts, and hooks for Claude Code, each shipped as a plugin.

Release CI License: MIT

🚀 Install

/plugin marketplace add tartinerlabs/claude-code-mods
/plugin install <mod> --marketplace tartinerlabs/claude-code-mods

🧰 Mods

| | Mod | Where it shows | In one line | | :-: | --- | --- | --- | | ⏸ | auto-resume | Status line, toast | Picks a turn back up once a usage limit resets | | 🏷 | auto-session-name | Session title | Names each session from its first prompt | | ⏱ | cache-clock | Band above the prompt | Counts down to the prompt cache going cold | | 📊 | limit-watch | Band above the prompt, toasts | Shows the 5-hour and weekly usage limits | | 🛫 | pr-pilot | Band above the prompt, toasts | Shows the branch's PR checks and merges it with /merge | | 🗜 | smart-compact | Toast | Compacts between turns, sooner at checkpoints or when the cache is cooling | | ⛅ | token-weather | Band above the prompt | Forecasts how full the context window is |


⏸ auto-resume

When a turn stops on a usage limit, waits until the limit resets and sends continue. Typing a prompt yourself or running /auto-resume cancel stops the wait.

⏸ auto-resume in 1h 12m                                    ← status line
Usage limit reset, resuming                                 ← toast

<sub>📁 plugins/auto-resume</sub>

🏷 auto-session-name

Names each session from its first prompt using Haiku and can give it a prompt bar colour, so it is easy to find with claude -r after a crash. Sessions already named with -n or /rename are left alone. Set the color option to red, blue, green, yellow, purple, orange, pink or cyan to colour named sessions; default leaves the colour alone.

> fix the flaky retry test in the upload worker
                                    → session titled "Fix flaky upload retry test"

<sub>📁 plugins/auto-session-name</sub>

⏱ cache-clock

Draws a countdown above the prompt to when the prompt cache goes cold, with the last request's hit rate. Once cold, it shows how much the next turn re-writes. Set the ttl option to 1h when extended caching is on.

cache ● 3:42 left · 96% hit                                 ← green, yellow under a minute
cache ○ cold · next turn re-writes 84.3k                    ← red

<sub>📁 plugins/cache-clock</sub>

📊 limit-watch

Draws the 5-hour and weekly usage limits above the prompt with live reset countdowns, green under 60%, yellow to 80% and red past it. Toasts once a window passes 80% and again at 95%. Any other window the API reports, such as a gateway spend limit, shows under its own name.

5h 42% · 2h17m  7d 81% · 3d4h                               ← band
7d limit at 81%, resets in 3d4h                             ← toast

<sub>📁 plugins/limit-watch</sub>

🛫 pr-pilot

Draws the current branch's open pull request above the prompt with its checks and whether it can merge, refreshed each minute and after every turn. Toasts when a run of checks finishes. /merge merges it with gh and deletes the branch, but only once every check has passed and nothing blocks it. On a gh-stack layer the band shows its place in the stack, and /merge merges the whole stack with gh stack merge once every unmerged layer has passed its checks and has no unpushed commits, then switches to the trunk and deletes every layer's branch, local and remote, once all of them have merged. Set the method option to merge (default), squash or rebase. Needs the GitHub CLI, signed in.

PR #42  ◐ 3/5 checks  waiting                                ← band
PR #42  ✗ 1 failing: SonarCloud  blocked                     ← red
PR #42  ✓ 5/5 checks  ready · /merge                         ← green
PR #42  stack 2/3  ✓ 5/5 checks  ready · /merge              ← a gh-stack layer
PR #42 checks passed, ready to /merge                        ← toast

<sub>📁 plugins/pr-pilot</sub>

🗜 smart-compact

Compacts the conversation between turns, never during one, weighing several factors against the context's fill. Past the threshold option (60, 70 default, or 80%) it compacts about ten seconds after a turn ends. It compacts sooner when compacting is cheapest or most worth it:

  • Checkpoints, 20 points lower: a turn that committed, opened or merged a PR (gh pr, gh stack), had a plan approved, finished its todo list, or switched branch or worktree. The summary is told what to keep for each, such as the approved plan verbatim. A checkpoint counts only until the next turn starts. Set checkpoints to off to ignore them.
  • Cache cooling, 20 points lower in the last minute before the prompt cache goes cold, so the summary still reads the conversation from cache; 10 lower once it is cold, so the next turn re-writes a short prefix instead of a long one.
  • 5-hour limit, 10 points lower while it is at 80% or more.

Nothing takes it under the floor option (30, 40 default, or 50%), and it tries at most once per idle stretch. Set ttl to 1h when extended prompt caching is on.

Compacted at 52%: committed                                  ← toast
Compacted at 55%: cache cooling                              ← toast
Compacted at 74%: context high, 5h limit at 86%              ← toast

<sub>📁 plugins/smart-compact</sub>

⛅ token-weather

Draws a forecast of the context window above the prompt, with tokens used, a sparkline of the last 12 turns and the change since the last turn.

☁  Cloudy  38% of context  76.4k / 200k   last turns ▁▂▂▃▄▄▅▆  ▲ +4.1k last turn

| ☀ Clear | ☁ Cloudy | ☂ Showers | ☇ Storm | ↯ Compact soon | | :-: | :-: | :-: | :-: | :-: | | under 25% | under 50% | under 75% | under 90% | 90% and up |

<sub>📁 plugins/token-weather</sub>


MIT © Tartiner Labs

Source 2 files
hooks/register.ts 250 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Checkpoint, SmartCompactTurn } from '../types'
5
6const TTL_MS = { '5m': 5 * 60_000, '1h': 60 * 60_000 } as const
7const TICK_MS = 15_000
8// Compact this close to the cache going cold, so the summary still reads it warm.
9const COOLING_MS = 60_000
10// Leave the person a moment to type a follow-up before compacting under them.
11const SETTLE_MS = 10_000
12
13const CHECKPOINTS: Record<Checkpoint, { reason: string; instructions: string }> = {
14  commit: {
15    reason: 'committed',
16    instructions:
17      'The work so far was just committed. Say in a line or two what the commit holds, and keep open follow-ups, decisions and anything not yet committed.',
18  },
19  pr: {
20    reason: 'PR opened',
21    instructions:
22      'A pull request was just opened. Keep its number, branch and what reviewers should know; summarise the rest briefly.',
23  },
24  merge: {
25    reason: 'PR merged',
26    instructions: 'A pull request was just merged. Keep its number and any follow-ups; summarise the rest briefly.',
27  },
28  plan: {
29    reason: 'plan approved',
30    instructions: 'A plan was just approved. Keep the approved plan verbatim and summarise how it was reached in a few lines.',
31  },
32  todos: {
33    reason: 'todos done',
34    instructions: 'Every task on the todo list is done. Summarise what was done briefly and keep anything left open.',
35  },
36  switch: {
37    reason: 'switched task',
38    instructions:
39      'The session moved to another branch or worktree. Summarise the earlier work briefly and keep what the new task needs.',
40  },
41}
42
43// Held by the host, so a hot reload mid-turn still knows a turn is running.
44const turn = atom({ plugin: 'smart-compact', key: 'turn' } as const, { isBusy: false, endedAt: null } as SmartCompactTurn)
45// The last checkpoint the current or just-ended turn reached.
46const checkpoint = atom({ plugin: 'smart-compact', key: 'checkpoint' } as const, null as Checkpoint | null)
47// Task ids made with TaskCreate and not yet completed or deleted.
48const openTasks = atom({ plugin: 'smart-compact', key: 'openTasks' } as const, [] as readonly string[])
49
50export type Factors = {
51  percent: number
52  idleMs: number
53  ttlMs: number
54  fiveHourPercent?: number
55  checkpoint?: Checkpoint | null
56}
57
58export type Limits = { threshold: number; floor: number }
59
60export type Decision = { compact: boolean; threshold: number; reasons: string[] }
61
62export const register: Register = (on, options) => {
63  const ttlMs = options.ttl === '1h' ? TTL_MS['1h'] : TTL_MS['5m']
64  const limits: Limits = {
65    threshold: Number(options.threshold ?? 70),
66    floor: Number(options.floor ?? 40),
67  }
68  const isWatchingCheckpoints = options.checkpoints !== 'off'
69  let isCompacting = false
70
71  on('session.start', async ($, e, next) => {
72    const result = await next(e)
73
74    $.clock.every(TICK_MS, async () => {
75      const state = await read($, turn)
76
77      if (isCompacting || state.isBusy || state.endedAt === null) {
78        return
79      }
80
81      const idleMs = (await $.clock.now()) - state.endedAt
82
83      if (idleMs < SETTLE_MS) {
84        return
85      }
86
87      const { context, rateLimits } = await $.session.usage()
88
89      // No reading until the first response after a start or a compaction.
90      if (context.percent === undefined) {
91        return
92      }
93
94      const reached = await read($, checkpoint)
95      const fiveHour = rateLimits.find(limit => limit.kind === 'five_hour')
96      const decision = decide(
97        { percent: context.percent, idleMs, ttlMs, fiveHourPercent: fiveHour?.percentUsed, checkpoint: reached },
98        limits,
99      )
100
101      if (!decision.compact) {
102        return
103      }
104
105      isCompacting = true
106
107      try {
108        const instructions = reached ? CHECKPOINTS[reached].instructions : undefined
109        const compacted = await $.session.compact(instructions ? { instructions } : undefined)
110
111        if (!('skip' in compacted && compacted.skip !== undefined)) {
112          $.ui.toast(`Compacted at ${context.percent}%: ${decision.reasons.join(', ')}`)
113        }
114      } catch {
115        // A turn started between the check and the call; try again once it ends.
116      } finally {
117        isCompacting = false
118        // One try per idle stretch: a skip waits for the next turn to end.
119        await update($, turn, state => ({ ...state, endedAt: null }))
120        await update($, checkpoint, () => null)
121      }
122    })
123
124    return result
125  })
126
127  on('turn.start', async ($, e, next) => {
128    await update($, turn, state => ({ ...state, isBusy: true }))
129    // A checkpoint counts for the idle stretch after its own turn alone.
130    await update($, checkpoint, () => null)
131
132    return next(e)
133  })
134
135  on('turn.complete', async ($, e, next) => {
136    const result = await next(e)
137
138    // Main loop only: a subagent's turn ending leaves the session busy.
139    if (!e.agentId) {
140      const endedAt = await $.clock.now()
141      await update($, turn, () => ({ isBusy: false, endedAt }))
142    }
143
144    return result
145  })
146
147  if (!isWatchingCheckpoints) {
148    return
149  }
150
151  on('tool.call', async ($, e, next) => {
152    const ran = await next(e)
153
154    // A subagent's commits and plans are its own; a failed call reaches nothing.
155    if (e.agentId || ran.deny !== undefined || ran.isError === true) {
156      return ran
157    }
158
159    let reached: Checkpoint | null = null
160
161    if (e.tool === 'Bash') {
162      reached = bashCheckpoint(e.command)
163    } else if (e.tool === 'ExitPlanMode') {
164      reached = 'plan'
165    } else if (e.tool === 'EnterWorktree' || e.tool === 'ExitWorktree') {
166      reached = 'switch'
167    } else if (e.tool === 'TodoWrite') {
168      reached = areAllDone(e.todos) ? 'todos' : null
169    } else if (e.tool === 'TaskCreate') {
170      const created = ran.result as { task?: { id?: string } }
171      const id = created.task?.id
172
173      if (id) {
174        await update($, openTasks, ids => [...ids, id])
175      }
176    } else if (e.tool === 'TaskUpdate' && (e.status === 'completed' || e.status === 'deleted')) {
177      const before = await read($, openTasks)
178      const after = before.filter(id => id !== e.taskId)
179      await update($, openTasks, () => after)
180      reached = e.status === 'completed' && before.length > 0 && after.length === 0 ? 'todos' : null
181    }
182
183    if (reached) {
184      await update($, checkpoint, () => reached)
185    }
186
187    return ran
188  }).catch(($, e, next) => next(e)) // Only watches: a fault here must never cost the call.
189}
190
191export function bashCheckpoint(command: string): Checkpoint | null {
192  if (/\bgh\s+(?:pr|stack)\s+merge\b/.test(command)) {
193    return 'merge'
194  }
195
196  if (/\bgh\s+(?:pr\s+create|stack\s+submit)\b/.test(command)) {
197    return 'pr'
198  }
199
200  if (/\bgit\s+(?:-C\s+\S+\s+)?commit\b/.test(command)) {
201    return 'commit'
202  }
203
204  // `git checkout -- file` and `git checkout .` restore files; they do not move branch.
205  if (/\bgit\s+(?:-C\s+\S+\s+)?(?:switch|checkout)\s+(?!--(?:\s|$)|\.(?:\s|$))\S/.test(command)) {
206    return 'switch'
207  }
208
209  return null
210}
211
212export function areAllDone(todos: readonly { status: string }[]) {
213  return todos.length > 0 && todos.every(todo => todo.status === 'completed')
214}
215
216export function decide(factors: Factors, limits: Limits): Decision {
217  const { percent, idleMs, ttlMs, fiveHourPercent, checkpoint: reached } = factors
218  const reasons: string[] = []
219  let threshold = limits.threshold
220
221  if (reached) {
222    // A finished piece of work: the summary loses the least that matters now.
223    threshold -= 20
224    reasons.push(CHECKPOINTS[reached].reason)
225  }
226
227  if (idleMs >= ttlMs) {
228    // Cold already: the next turn re-writes the whole prefix, so make it a short one.
229    threshold -= 10
230    reasons.push('cache cold')
231  } else if (ttlMs - idleMs <= COOLING_MS) {
232    // About to go cold: the summary reads the prefix from cache while it still can.
233    threshold -= 20
234    reasons.push('cache cooling')
235  }
236
237  if (fiveHourPercent !== undefined && fiveHourPercent >= 80) {
238    threshold -= 10
239    reasons.push(`5h limit at ${fiveHourPercent}%`)
240  }
241
242  threshold = Math.max(threshold, limits.floor)
243
244  if (percent >= limits.threshold) {
245    reasons.unshift('context high')
246  }
247
248  return { compact: percent >= threshold, threshold, reasons }
249}
250
types/index.d.ts 10 lines
1export type SmartCompactTurn = { isBusy: boolean; endedAt: number | null }
2
3export type Checkpoint = 'commit' | 'pr' | 'merge' | 'plan' | 'todos' | 'switch'
4
5declare module 'claude-code' {
6  interface PluginState {
7    'smart-compact': { turn: SmartCompactTurn; checkpoint: Checkpoint | null; openTasks: readonly string[] }
8  }
9}
10