Gives Claude a compact_context tool: it decides when to compact, the mod compacts once the turn ends, only past the idle-compaction size, reminds it past 400k…

A Claude Code mod that lets Claude decide when to compact its own context.
Claude Code has no built-in way for the model to compact: /compact is a local command the Skill tool refuses, and model-scheduled prompts skip slash commands. This mod adds a tool, compact_context, that Claude calls at a natural breakpoint; the mod compacts once that turn ends.
CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS, held to at least 100k, else 200k (the engine's own rule in 2.1.296). Below that the call is refused and Claude is told the current size and the threshold.$.session.compact mid-turn, so the tool only queues the request; it runs on turn.complete. If a new turn is already running, it retries 5× at 2 s, then waits for the next turn end.focus argument becomes the summary instructions (what /compact <text> would pass).next names the work that remains. Once the compaction is done (or auto-compaction got there first) the mod submits it as a prompt (“Compacted at your request. Carry on: …”, read as the plugin's message), so Claude picks the work back up from the summary instead of the session sitting idle. Without next, or after a skipped or failed compaction, the person speaks next. A request that had to wait past a later turn drops its next, as that turn may have changed course.DISABLE_COMPACT refuses the tool.compact_context (a phase finished and verified). It is Claude's judgment, not an order: until such a point comes, it carries on. The note rides on the next prompt or tool result, whichever comes first, and comes back only after every further 100k, not on every prompt. No note while a compaction is queued, on /compact, in a subagent, or under DISABLE_COMPACT; after a compaction the next crossing reminds again.Status line shows compact queued … / compacting …; a toast reports before → after tokens. Lines are logged to the transcript and to claude --debug.
claude --plugin-dir /path/to/self-compact
Or every session: add the folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json.
claude plugin validate ./self-compact
claude plugin test ./self-compacthooks/register.ts 256 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// Lets Claude decide when to compact: it calls compact_context at a natural
4// breakpoint, and the compaction runs once that turn ends (the engine refuses
5// $.session.compact while a turn runs). Only accepted past the idle-compaction
6// size, so a small context is never summarized away.
7
8const NAME = 'compact_context'
9const TOOL = 'mcp__self-compact__compact_context'
10
11// The engine's idle-compaction size (2.1.296): CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS
12// held to at least 100k, else 200k. Its server-side flag (tengu_sunny_locket
13// minTokens) can't be read from a mod, so only the env override is followed.
14const IDLE_MIN_DEFAULT = 200_000
15const IDLE_MIN_FLOOR = 100_000
16
17// A compaction asked for right at turn end can still meet a turn in flight
18// (a queued prompt starting); retry a few times, then wait for the next turn end.
19const RETRY_MS = 2_000
20const MAX_RETRIES = 5
21
22// Past this size Claude is reminded to look for a breakpoint to compact at (its
23// judgment, not an order): once on crossing it, then each time the context grew
24// another step, at a prompt or after a tool call, whichever comes first. Not on
25// every prompt (2026-10-20). 2026-10-20: a session ran to 440k with nobody calling the tool.
26const REMIND_TOKENS = 400_000
27const REMIND_STEP = 100_000
28
29const SCHEMA = {
30 type: 'object',
31 properties: {
32 focus: {
33 type: 'string',
34 description:
35 'What the summary must keep in detail: open tasks, file paths, decisions and why, unresolved errors, the latest user instructions.',
36 },
37 reason: {
38 type: 'string',
39 description: 'One line: why this is a good breakpoint.',
40 },
41 next: {
42 type: 'string',
43 description:
44 'The work that remains, as an instruction to yourself (e.g. "run the full suite, then commit"). Sent as a prompt once the compaction is done, so you carry on by yourself; without it the session waits for the person. Leave it out only when the work is finished.',
45 },
46 },
47 additionalProperties: false,
48}
49
50type Pending = { instructions?: string; reason?: string; next?: string; tokens: number }
51
52let pending: Pending | undefined
53let isCompacting = false
54// The context size at the last reminder; cleared once it is back under the line.
55let remindedAt: number | undefined
56
57const fmt = (n: number | undefined): string =>
58 n === undefined ? 'unknown' : `${Math.round(n / 1000)}k`
59
60// The engine's "not between turns" rejections (2.1.296): worth a retry. Anything
61// else (summarizer failure, thin client) is not retried, as each try costs a model call.
62export const isBusy = (message: string): boolean =>
63 /turn is running|turn is in flight|compacts between turns/i.test(message)
64
65async function idleMinTokens($: EngineInterface): Promise<number> {
66 const raw = (await $.env.get('CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS'))?.trim()
67 const n = raw ? Number(raw) : NaN
68
69 return Number.isFinite(n) ? Math.max(IDLE_MIN_FLOOR, n) : IDLE_MIN_DEFAULT
70}
71
72async function isCompactDisabled($: EngineInterface): Promise<boolean> {
73 const raw = (await $.env.get('DISABLE_COMPACT'))?.trim().toLowerCase()
74
75 return raw !== undefined && !['', '0', 'false', 'no', 'off'].includes(raw)
76}
77
78function describe(min: number): string {
79 return [
80 'Compact (summarize) this conversation once the current turn ends, freeing context for the work ahead.',
81 'Call it at a natural breakpoint: a subtask is done and verified, and what comes next does not need the raw detail of what came before (long tool outputs, dead ends, superseded drafts).',
82 `Accepted only when the context holds more than ${fmt(min)} tokens (the idle-compaction size); below that it is refused, so carry on without compacting.`,
83 'After calling it, finish your reply: compaction runs after this turn. Pass `next` whenever work remains: the next turn then starts on it from the summary; without it the session waits for the person.',
84 ].join(' ')
85}
86
87// Starts the turn after the compaction on the work Claude said remains. Without
88// it the session sat idle until the person typed (2026-10-20).
89async function carryOn($: EngineInterface, job: Pending): Promise<void> {
90 if (!job.next) return
91 try {
92 await $.prompt.submit({ text: `Compacted at your request. Carry on: ${job.next}` })
93 $.ui.log(`carrying on after the compaction: ${job.next}`, { to: 'debug' })
94 } catch (err) {
95 $.ui.log(`could not carry on after the compaction: ${err instanceof Error ? err.message : String(err)}`)
96 }
97}
98
99// What Claude reads past the reminder size, or undefined: on crossing it, then
100// only once the context grew a step past the last reminder.
101async function reminder($: EngineInterface): Promise<string | undefined> {
102 if (pending !== undefined || (await isCompactDisabled($))) return undefined
103
104 // Never below the size the tool accepts, or the reminder points at a refusal.
105 const min = Math.max(REMIND_TOKENS, await idleMinTokens($))
106 const tokens = (await $.session.usage()).context.tokens
107 if (tokens === undefined || !(tokens > min)) {
108 remindedAt = undefined
109 return undefined
110 }
111 if (remindedAt !== undefined && tokens < remindedAt + REMIND_STEP) return undefined
112
113 remindedAt = tokens
114 $.ui.log(`reminded Claude to compact at ${fmt(tokens)} tokens`, { to: 'debug' })
115 return [
116 `The context holds ${fmt(tokens)} tokens, past ${fmt(min)}.`,
117 `Watch for a good breakpoint to call ${TOOL} with a specific focus (and next, when work remains): a phase finished and verified, and what comes next does not need the raw detail so far.`,
118 'It is your call, not an order: until such a point comes, carry on.',
119 ].join(' ')
120}
121
122function schedule($: EngineInterface, job: Pending, attempt: number): void {
123 $.clock.after(attempt === 0 ? 0 : RETRY_MS, () => void compact($, job, attempt))
124}
125
126async function compact($: EngineInterface, job: Pending, attempt: number): Promise<void> {
127 if (pending !== job || isCompacting) return
128 isCompacting = true
129
130 try {
131 // Re-check: auto-compaction may have run since the tool call.
132 const min = await idleMinTokens($)
133 const tokens = (await $.session.usage()).context.tokens
134 if (tokens !== undefined && !(tokens > min)) {
135 pending = undefined
136 $.ui.status(undefined)
137 $.ui.log(`dropped, the context is already ${fmt(tokens)} tokens (needs > ${fmt(min)})`)
138 await carryOn($, job)
139 return
140 }
141
142 $.ui.status('compacting at Claude’s request…')
143 const done = await $.session.compact(job.instructions ? { instructions: job.instructions } : {})
144 pending = undefined
145 $.ui.status(undefined)
146
147 if (done.skip !== undefined) {
148 $.ui.log(`compaction skipped: ${done.skip}`)
149 return
150 }
151 const line = `compacted at Claude’s request: ${fmt(done.tokensBefore ?? job.tokens)} → ${fmt(done.tokensAfter)} tokens`
152 $.ui.toast(line)
153 $.ui.log(line, { to: 'debug' })
154 await carryOn($, job)
155 } catch (err) {
156 const message = err instanceof Error ? err.message : String(err)
157 const busy = isBusy(message)
158
159 if (busy && attempt < MAX_RETRIES) {
160 $.ui.log(`busy, retry ${attempt + 1}/${MAX_RETRIES}: ${message}`, { to: 'debug' })
161 isCompacting = false
162 schedule($, job, attempt + 1)
163 return
164 }
165 if (busy) {
166 // Keep it queued: the next turn end tries again. That turn may have
167 // changed course, so `next` is stale by then.
168 job.next = undefined
169 $.ui.status('compact queued (waiting for an idle moment)')
170 $.ui.log(`still busy after ${MAX_RETRIES} retries; trying again when the next turn ends, without carrying on`)
171 return
172 }
173 pending = undefined
174 $.ui.status(undefined)
175 $.ui.log(`compaction failed: ${message}`)
176 } finally {
177 isCompacting = false
178 }
179}
180
181export const register: Register = on => {
182 on('session.start', async ($, e, next) => {
183 const min = await idleMinTokens($)
184 await $.tool.register({ name: NAME, description: describe(min), inputSchema: SCHEMA, isDeferred: false })
185 $.ui.log(`${TOOL} registered, threshold > ${fmt(min)} tokens`, { to: 'debug' })
186
187 return next(e)
188 })
189
190 on('tool.call', { tool: TOOL }, async ($, e) => {
191 if (e.agentId !== undefined) {
192 return { deny: 'compact_context compacts the main conversation; a subagent cannot call it.' }
193 }
194 if (await isCompactDisabled($)) {
195 return { deny: 'Compaction is switched off in this session (DISABLE_COMPACT).' }
196 }
197
198 const min = await idleMinTokens($)
199 const tokens = (await $.session.usage()).context.tokens
200 if (tokens === undefined || !(tokens > min)) {
201 $.ui.log(`refused at ${fmt(tokens)} tokens (needs > ${fmt(min)})`, { to: 'debug' })
202 return {
203 deny: `Not compacting: the context holds ${fmt(tokens)} tokens and this tool needs more than ${fmt(min)} (the idle-compaction size). Carry on; auto-compaction still runs at its usual threshold.`,
204 }
205 }
206
207 const focus = typeof e.focus === 'string' ? e.focus.trim() : ''
208 const reason = typeof e.reason === 'string' ? e.reason.trim() : ''
209 const next = typeof e.next === 'string' ? e.next.trim() : ''
210 pending = { instructions: focus || undefined, reason: reason || undefined, next: next || undefined, tokens }
211 $.ui.status(`compact queued (${fmt(tokens)} tokens)`)
212 $.ui.log(`Claude queued a compaction at ${fmt(tokens)} tokens${reason ? ` (${reason})` : ''}`)
213
214 const after = next
215 ? `then your next turn starts from the summary on: ${next}`
216 : 'then the session waits for the person. If work remains, call again and pass next'
217 // `next` often ends a sentence itself; don't stop it twice.
218 const stop = /[.!?。!?]$/.test(after) ? '' : '.'
219 return {
220 result: `Compaction queued: it runs once this turn ends (context ${fmt(tokens)} tokens > ${fmt(min)}), ${after}${stop} Finish your reply now.`,
221 }
222 }).catch(() => ({ deny: 'self-compact failed while checking the context; not compacting.' }))
223
224 on('prompt.submit', async ($, e, next) => {
225 // /compact is already the compaction; a reminder would land in its summary.
226 if (e.command?.name === 'compact') return next(e)
227
228 const text = await reminder($).catch(() => undefined)
229 return next(text ? { ...e, context: [...(e.context ?? []), text] } : e)
230 })
231
232 on('tool.call', async ($, e, next) => {
233 const done = await next(e)
234 if (e.agentId !== undefined || done.deny !== undefined) return done
235
236 const text = await reminder($).catch(() => undefined)
237 return text ? { ...done, context: [...(done.context ?? []), text] } : done
238 })
239
240 on('turn.complete', async ($, e, next) => {
241 const answered = await next(e)
242 if (e.agentId !== undefined || pending === undefined) return answered
243
244 if (e.reason !== 'answer') {
245 // Interrupted or failed turn: the person takes over, so drop the request.
246 $.ui.log(`turn ended (${e.reason}); the queued compaction is dropped`)
247 pending = undefined
248 $.ui.status(undefined)
249 return answered
250 }
251
252 schedule($, pending, 0)
253 return answered
254 })
255}
256