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…

Part of claude-code-mods.
A Claude Code mod. It does two jobs:
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.
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.<ready-for-compaction/>".<ready-for-compaction/>, the mod runs a normal compaction (no custom instructions).Scope:
Rules:
| Setting | Default | Meaning |
|---|---|---|
thresholdTokens | 300000 | Context size at which the agent is asked to flush. |
Change it in the config menu. The mod reloads with the new value.
| Event | Use |
|---|---|
session.start | Set the first text of the line. |
session.measure | Update the line. Ask the agent at the threshold. |
turn.complete | Look for <ready-for-compaction/>. Start compaction. |
ui.render (AbovePrompt) | Draw the line. |
.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
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.
claude plugin validate .
claude plugin test .
To load the mod in a terminal session, run claude --plugin-dir <path to this folder>.
hooks/register.tsx 96 lines1import { 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}
96types/index.d.ts 8 lines1export type Line = string
2
3declare module 'claude-code' {
4 interface PluginState {
5 'polite-compaction': { line: Line }
6 }
7}
8