SLOPSHOPPER

polite-compaction

Shows context tokens and session cost in a gray line above the prompt. At a token threshold, asks the agent to flush notes, then compacts when it says it is…

newbandtimer
v0.2.1no licenseupdated 2026-10-03dtinth/claude-code-mods/polite-compaction
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · polite-compaction
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ctx 97k / 200k (49%) · $0.42 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
ctx 97k / 200k (49%) · $0.42
README

polite-compaction

Part of claude-code-mods.

A Claude Code mod. It does two jobs:

  1. It shows the context size and the session cost in a gray line above the prompt.
  2. It helps the agent keep its notes through compaction.

Why

Auto-compact can run in the middle of a task. The summary then loses small details. This mod asks the agent to save what it needs, in its own words and to a place of its own choice, before the conversation is compacted.

What you see

ctx 123k / 1000k (12%) · $4.56
  • ctx: tokens in the context now, the size of the context window, and the percentage used.
  • $: the cost of the session so far, the same figure /cost shows.
  • The line is gray. It is hidden while a survey shows.

How the flush works

  1. After each turn, the mod reads the context size.
  2. When the context reaches the threshold (default 300k tokens), the mod sends this prompt: "Context window reached 300k tokens. The conversation will be compacted soon. If there is anything to flush, please do so now, so you can continue well after compaction. When you are ready, end your reply with <ready-for-compaction/>".
  3. The agent saves what it needs. The prompt does not name a place.
  4. When a reply ends with <ready-for-compaction/>, the mod runs a normal compaction (no custom instructions).

Scope:

  • The mod works on the main session only. It does not measure or ask sub-agents. Their context is separate, and they do not get the flush prompt.
  • The mod acts only between turns. A turn is one run of the model, from a prompt to the final answer, and it can include many tool calls. The mod reads the context size when the turn ends. It does not inject the prompt in the middle of a turn. The prompt goes in as a new turn, after the session is idle.
  • A long turn can pass the threshold, and also the auto-compact point, before the mod gets a chance to ask. Keep the threshold well below auto-compact.

Rules:

  • The mod asks one time for each crossing of the threshold. It arms again when the context falls below the threshold.
  • If you interrupt the turn, or the turn ends in an error, the mod does not ask again until after the next compaction.
  • Compaction cannot start while a turn runs. The mod tries again every 500 ms, up to 20 times.
  • Set the threshold below the auto-compact point. If auto-compact runs first, the flush does not happen.

Settings

SettingDefaultMeaning
thresholdTokens300000Context size at which the agent is asked to flush.

Change it in the config menu. The mod reloads with the new value.

Hooks used

EventUse
session.startSet the first text of the line.
session.measureUpdate the line. Ask the agent at the threshold.
turn.completeLook for <ready-for-compaction/>. Start compaction.
ui.render (AbovePrompt)Draw the line.

Files

.claude-plugin/plugin.json   manifest and the threshold setting
hooks/hooks.json             names the hooks module
hooks/register.tsx           the mod
hooks/register.test.ts       test
types/index.d.ts             type of the value the line keeps in $.state

Install

This repository is a Claude Code marketplace named dtinth-mods. In Claude Code, run:

/plugin marketplace add dtinth/claude-code-mods
/plugin install polite-compaction@dtinth-mods

From a shell, use claude plugin marketplace add dtinth/claude-code-mods and claude plugin install polite-compaction@dtinth-mods. The source can also be a local path to a clone of the repository.

Develop

claude plugin validate .
claude plugin test .

To load the mod in a terminal session, run claude --plugin-dir <path to this folder>.

Limits

  • The mod needs a Claude Code build that has function hooks (a mod API). That API is in early access and can change.
  • The test uses a stand-in for compaction. A real compaction is checked by hand.
Source 2 files
hooks/register.tsx 96 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4const line = atom({ plugin: 'polite-compaction', key: 'line' } as const, '')
5
6const READY = '<ready-for-compaction/>'
7const RETRIES = 20
8const RETRY_MS = 500
9
10const kilo = (n: number) => `${Math.round(n / 1000)}k`
11
12const statusText = (tokens?: number, window?: number, usd?: number) => {
13  const ctx =
14    tokens === undefined
15      ? 'ctx –'
16      : `ctx ${kilo(tokens)}${window ? ` / ${kilo(window)} (${Math.round((tokens / window) * 100)}%)` : ''}`
17  return usd === undefined ? ctx : `${ctx} · $${usd.toFixed(2)}`
18}
19
20export const register: Register = (on, options) => {
21  const threshold = Number(options.thresholdTokens)
22
23  // 'armed': may ask when the threshold is crossed. 'asking': the agent was
24  // asked and has not said ready. 'compacting': the compaction is on its way.
25  // Asked once per crossing: it arms again when the context falls below.
26  let phase: 'armed' | 'asking' | 'compacting' | 'spent' = 'armed'
27
28  on('session.start', async ($, e, next) => {
29    const { context, cost } = await $.session.usage()
30    await update($, line, () => statusText(context.tokens, context.window, cost?.usd))
31    return next(e)
32  })
33
34  on('session.measure', async ($, e, next) => {
35    const { tokens, window } = e.context
36    await update($, line, () => statusText(tokens, window, e.cost?.usd))
37
38    if (tokens !== undefined && tokens < threshold) phase = 'armed'
39
40    if (tokens !== undefined && tokens >= threshold && phase === 'armed') {
41      phase = 'asking'
42      void $.prompt.submit({
43        text:
44          `Context window reached ${kilo(tokens)} tokens. The conversation will be compacted soon. ` +
45          `If there is anything to flush, please do so now, so you can continue well after compaction. ` +
46          `When you are ready, end your reply with ${READY}`,
47      })
48    }
49
50    return next(e)
51  })
52
53  on('turn.complete', async ($, e, next) => {
54    if (e.agentId !== undefined || phase !== 'asking') return next(e)
55
56    if (e.isAborted || e.reason === 'error') {
57      phase = 'spent'
58      return next(e)
59    }
60
61    if (e.answer.slice(-200).includes(READY)) {
62      phase = 'compacting'
63      // compact() rejects while a turn runs, and this turn ends after this
64      // hook returns, so try again shortly.
65      const attempt = (left: number): void => {
66        $.clock.after(RETRY_MS, async () => {
67          try {
68            const { skip } = await $.session.compact()
69            if (skip !== undefined) {
70              phase = 'spent'
71            } else {
72              phase = 'armed'
73              const { context, cost } = await $.session.usage()
74              await update($, line, () => statusText(context.tokens, context.window, cost?.usd))
75            }
76          } catch {
77            if (left > 0) attempt(left - 1)
78            else phase = 'spent'
79          }
80        })
81      }
82      attempt(RETRIES)
83    }
84
85    return next(e)
86  })
87
88  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
89    const text = await read($, line)
90    if (e.props.hasSurvey || text === '') return next(e)
91
92    const { Text } = $.ui.resolve(e)
93    return <Text dimColor>{text}</Text>
94  })
95}
96
types/index.d.ts 8 lines
1export type Line = string
2
3declare module 'claude-code' {
4  interface PluginState {
5    'polite-compaction': { line: Line }
6  }
7}
8