Lets the agent prepare and choose its own compaction instead of suffering auto-compact: asks it to save its memories at a context threshold, and gives it tools…

A Claude Code mod that lets the agent prepare and choose its own compaction, instead of having auto-compact land on it when the context is full.
Without it, a long session ends up auto-compacted in the middle of the work, before the agent has saved its memories: whatever was not written down is lost in the summary. The mod warns the agent early enough, gives it a way to measure its context, and lets it trigger the compaction itself when it judges the moment right.
| Piece | Role |
|---|---|
| Compact request | At the end of a main-session turn, if the raw context fill reaches requestThreshold (60% by default), the mod sends a message asking the agent to update its memories, then call compact_session. Once per cycle. |
| Reminder | The same at reminderThreshold (80% by default), if the agent has not compacted. Jumping straight past this threshold sends the reminder only. |
context_usage tool | Gives the agent the tokens used, the window, the percentage, the thresholds and which message was last sent. With detail: true, the breakdown by category and the auto-compact threshold. |
compact_session tool | Schedules the compaction, run when the current turn ends: Claude Code refuses to compact during a turn. It takes instructions, what the summary must keep. Refused below compactFloor (40% by default), cancelled if the turn is interrupted. |
/proactive-compact | status (default), on, off, settings. |
Any successful compaction, the agent's, a /compact or auto-compact, rearms the compact request and the reminder. Subagent turns are ignored, and both tools refuse subagents: they only read the main session.
The agent sees the tools as mcp__proactive-compact-mod__context_usage and mcp__proactive-compact-mod__compact_session.
Global settings live in /config, in the proactive-compact-mod rows:
| Setting | Default | Role |
|---|---|---|
enabled | true | Sends the compact request and the reminder. The tools are offered either way. |
requestThreshold | 60 | Raw context percentage that triggers the compact request. |
reminderThreshold | 80 | Raw percentage that triggers the reminder. |
compactFloor | 40 | Below it, compact_session refuses. |
requestMessage | provided text | The compact request message. |
reminderMessage | empty | The reminder message; when empty, the compact request message is reused. |
messageSuffix | empty | Appended to both messages. |
compactInstructions | empty | Passed to the summary, before the agent's own. |
Messages accept the variables {percent}, {threshold}, {floor} and {tool}.
Per-project override. In the repository's .claude/settings.json (shared) or .claude/settings.local.json (personal), which wins:
{
"pluginConfigs": {
"proactive-compact-mod": {
"options": {
"requestThreshold": 50,
"messageSuffix": "Also update docs/task-log.md."
}
}
}
}
Overrides are read again on every use: a change takes effect without reloading the mod. /proactive-compact shows where each value comes from.
Early-access API. The mod is a Claude Code function-hooks plugin, an API that is still in early access and changes between releases. It was written and tested against Claude Code 2.1.301; an update of Claude Code may break it until the mod is updated in turn.
At the prompt of a terminal session:
/plugin install proactive-compact-mod --marketplace Apemb/proactive-compact-mod
Answer y to add the marketplace, then choose a scope (user scope: every session). From a shell:
claude plugin install proactive-compact-mod --marketplace Apemb/proactive-compact-mod
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with Apemb/proactive-compact-mod, then Add plugin.
Check that it runs with /proactive-compact status.
Where an organization manages Claude Code through managed settings:
session.compact do not run in a plugin a person installs, only in one the organization lists. The mod still works: its own compact_session rearms the cycle directly, and after a /compact or an auto-compact the cycle is rearmed at the next turn end, once the context is back under requestThreshold;allowManagedHooksOnly, a plugin a person installs loads no hooks at all, and the mod does nothing;If /proactive-compact status does not answer, ask your administrator to list the mod in the managed settings.
Run the mod from a working copy:
claude --plugin-dir /path/to/proactive-compact-mod;CLAUDE_CODE_PLUGIN_DIRS, in the env block of ~/.claude/settings.json: ``json { "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/proactive-compact-mod" } } ``In an interactive session the folder is watched: saving a file reloads the mod.
claude plugin validate . # what the module hooks and calls, and what the engine would refuse
claude plugin test . # tests/*.test.ts against the engine
tsc -p . # once the mod has loaded and Claude Code has written .claude-plugin/types/
| File | Content |
|---|---|
.claude-plugin/plugin.json | Manifest and settings (userConfig). |
.claude-plugin/marketplace.json | Makes the repository a marketplace, for /plugin install. |
hooks/register.ts | Hooks: turn end, compaction, tools, command. |
hooks/settings.ts | Settings resolution (default, user, project, local) and message templating. |
types/index.d.ts | Session state contract ($.state). |
tests/ | Behaviour tests. |
.claude-plugin/types/ is written by Claude Code on every load and is not versioned.
compactFloor above requestThreshold makes the compact request pointless, since the tool would refuse. The mod does not flag it yet.⚠ before the status in the status bar is drawn by Claude Code; the API offers no way to change it.Qualified in a real interactive session on 2026-10-15 (Claude Code 2.1.301), with test thresholds of 20% (compact request), 30% (reminder) and 15% (floor):
compact_session at 31% (about 306k tokens); the compaction ran when the turn ended;context_usage reported 7% (about 65k tokens), Last message sent: none and Compaction scheduled: no: the cycle was rearmed.compactFloor is above requestThreshold, in /proactive-compact status and in context_usage: the compact request is then pointless, since compact_session would refuse.turn.complete. Worth reproducing before relying on hot reload.hooks/register.ts 236 lines1import type { EngineInterface, PluginOptions, Register } from 'claude-code'
2
3import type { PendingCompact } from '../types'
4import {
5 messageForLevel,
6 NAME,
7 resolveSettings,
8 summaryInstructions,
9 type Settings,
10 type Source,
11} from './settings'
12
13const LEVEL = { plugin: 'proactive-compact-mod', key: 'level' } as const
14const PENDING = { plugin: 'proactive-compact-mod', key: 'pending' } as const
15
16const COMPACT_TOOL = `mcp__${NAME}__compact_session`
17
18// Compacting fails while a turn runs: retry until the session is idle, for
19// about two minutes at most.
20const MAX_ATTEMPTS = 40
21const RETRY_DELAY_MS = 3000
22
23const LEVEL_NAMES = ['none', 'compact request', 'reminder'] as const
24const NUMERIC: readonly (keyof Settings)[] = ['enabled', 'requestThreshold', 'reminderThreshold', 'compactFloor']
25
26export const register: Register = (on, options) => {
27 on('session.start', async ($, e, next) => {
28 await Promise.all([
29 $.tool.register({
30 name: 'context_usage',
31 description:
32 'Reports how full the main session context is: tokens, window, percentage, compact request and reminder thresholds, compact floor. With `detail: true`, adds the estimated breakdown by category and the auto-compact threshold.',
33 inputSchema: {
34 type: 'object',
35 properties: { detail: { type: 'boolean', description: 'Adds the breakdown by category.' } },
36 },
37 isDeferred: false,
38 }),
39 $.tool.register({
40 name: 'compact_session',
41 description:
42 'Schedules a compaction of the main session, run as soon as the current turn ends. Call it once memories and tracking documents are up to date, at a quiet moment. Refused below the configured floor. After calling it, end your turn without any other tool call.',
43 inputSchema: {
44 type: 'object',
45 properties: {
46 instructions: {
47 type: 'string',
48 description: 'What the summary must keep or stress, as after /compact.',
49 },
50 },
51 },
52 isDeferred: false,
53 }),
54 $.command.register({
55 name: 'proactive-compact',
56 description: 'Proactive compaction: status, on, off, settings',
57 argumentHint: '[status|on|off|settings]',
58 }),
59 ])
60 return next(e)
61 })
62
63 on('tool.call', { tool: 'mcp__proactive-compact-mod__context_usage' }, async ($, e) => {
64 if (e.agentId !== undefined) return { deny: 'Main session only.' }
65 const detail = (e as { detail?: unknown }).detail === true
66 return { result: await describeContext($, options, detail) }
67 })
68
69 on('tool.call', { tool: 'mcp__proactive-compact-mod__compact_session' }, async ($, e) => {
70 if (e.agentId !== undefined) return { deny: 'Main session only.' }
71 const { values } = await resolveSettings($, options)
72 const percent = (await $.session.usage()).context.percent ?? 0
73 if (percent < values.compactFloor) {
74 return {
75 deny: `Compaction refused: the context is at ${percent}%, below the ${values.compactFloor}% floor.`,
76 }
77 }
78 const raw = (e as { instructions?: unknown }).instructions
79 const instructions = typeof raw === 'string' && raw.trim() !== '' ? raw : null
80 await $.state.set(PENDING, { instructions })
81 $.ui.status('compaction scheduled')
82 return {
83 result:
84 'Compaction scheduled: it will run as soon as this turn ends. End your turn now, in one sentence, without any other tool call.',
85 }
86 })
87
88 on('turn.complete', async ($, e, next) => {
89 const answer = await next(e)
90 if (e.agentId !== undefined) return answer
91
92 const { value: pending = null } = await $.state.get(PENDING)
93 if (pending !== null) {
94 if (e.reason === 'answer') {
95 $.clock.after(300, () => void compact($, options, pending, 0))
96 } else {
97 await $.state.set(PENDING, null)
98 $.ui.status(undefined)
99 $.ui.toast('Compaction cancelled: the turn was interrupted.')
100 }
101 return answer
102 }
103
104 if (e.reason === 'answer') await sendIfDue($, options)
105 return answer
106 })
107
108 // A compaction from elsewhere (/compact, auto-compact) also rearms the messages.
109 on('session.compact', async ($, e, next) => {
110 const result = await next(e)
111 if (e.agentId === undefined && e.trigger !== 'precompute' && !('skip' in result && result.skip)) {
112 await reset($)
113 }
114 return result
115 })
116
117 on('command.run', { command: 'proactive-compact' }, async ($, e) => {
118 const verb = e.args.trim().toLowerCase()
119 if (verb === 'on' || verb === 'off') return { text: await toggle($, options, verb === 'on') }
120 if (verb === 'settings') return { text: SETTINGS_HELP }
121 if (verb === '' || verb === 'status') return { text: await describeContext($, options, false) }
122 return { text: `Unknown argument "${verb}". Expected: status, on, off or settings.` }
123 })
124}
125
126async function sendIfDue($: EngineInterface, options: PluginOptions): Promise<void> {
127 const { values } = await resolveSettings($, options)
128 const percent = (await $.session.usage()).context.percent ?? 0
129 const { value: level = 0 } = await $.state.get(LEVEL)
130
131 if (percent < values.requestThreshold) {
132 if (level > 0) await reset($)
133 return
134 }
135 if (!values.enabled) return
136
137 const target = percent >= values.reminderThreshold && level < 2 ? 2 : level < 1 ? 1 : 0
138 if (target === 0) return
139
140 await $.state.set(LEVEL, target)
141 $.ui.status(`${target === 2 ? 'reminder sent' : 'compact requested'} at ${percent}%`)
142 const text = messageForLevel(values, target, percent, COMPACT_TOOL)
143 // The prompt starts a turn of its own as soon as the session is idle.
144 $.clock.after(0, () => void $.prompt.submit({ text }))
145}
146
147async function compact(
148 $: EngineInterface,
149 options: PluginOptions,
150 pending: PendingCompact,
151 attempt: number,
152): Promise<void> {
153 const { values } = await resolveSettings($, options)
154 try {
155 const result = await $.session.compact({
156 instructions: summaryInstructions(values, pending.instructions),
157 })
158 if ('skip' in result && result.skip) {
159 await $.state.set(PENDING, null)
160 $.ui.status(undefined)
161 $.ui.toast(`Compaction refused: ${result.skip}`)
162 return
163 }
164 await reset($)
165 } catch {
166 if (attempt + 1 < MAX_ATTEMPTS) {
167 $.clock.after(RETRY_DELAY_MS, () => void compact($, options, pending, attempt + 1))
168 return
169 }
170 await $.state.set(PENDING, null)
171 $.ui.status(undefined)
172 $.ui.toast('Compaction abandoned: the session was not idle long enough.')
173 }
174}
175
176async function reset($: EngineInterface): Promise<void> {
177 await Promise.all([$.state.set(LEVEL, 0), $.state.set(PENDING, null)])
178 $.ui.status(undefined)
179}
180
181async function toggle($: EngineInterface, options: PluginOptions, enabled: boolean): Promise<string> {
182 const row = (await $.config.list()).find(
183 r => r.key === `${NAME}.enabled` || (r.key.startsWith(`${NAME}@`) && r.key.endsWith('.enabled')),
184 )
185 if (row === undefined) return 'The "enabled" setting was not found in /config.'
186 const { deny } = await $.config.set({ key: row.key, value: enabled })
187 if (deny !== undefined) return `Refused: ${deny}`
188 const { sources } = await resolveSettings($, options)
189 const warning =
190 sources.enabled === 'project' || sources.enabled === 'local'
191 ? ` Note: the ${sources.enabled} settings set "enabled" and take precedence here.`
192 : ''
193 return `Compact requests ${enabled ? 'enabled' : 'disabled'} for all projects.${warning}`
194}
195
196async function describeContext($: EngineInterface, options: PluginOptions, detail: boolean): Promise<string> {
197 const [{ values, sources }, usage, level, pending] = await Promise.all([
198 resolveSettings($, options),
199 $.session.usage(detail ? { breakdown: 'summary' } : undefined),
200 $.state.get(LEVEL),
201 $.state.get(PENDING),
202 ])
203 const { tokens, window, percent } = usage.context
204 const from = (key: keyof Settings) => (sources[key] === 'default' ? '' : ` (${sources[key]})`)
205 const lines = [
206 `Context: ${tokens === undefined ? 'not measured yet' : `${count(tokens)} tokens of ${count(window)} (${percent ?? 0}%)`}.`,
207 `Messages: ${values.enabled ? 'enabled' : 'disabled'}${from('enabled')}; compact request at ${values.requestThreshold}%${from('requestThreshold')}, reminder at ${values.reminderThreshold}%${from('reminderThreshold')}, floor ${values.compactFloor}%${from('compactFloor')}.`,
208 `Last message sent: ${LEVEL_NAMES[level.value ?? 0] ?? 'none'}. Compaction scheduled: ${pending.value ? 'yes' : 'no'}.`,
209 ]
210 const overridden = (Object.entries(sources) as [keyof Settings, Source][])
211 .filter(([key, source]) => (source === 'project' || source === 'local') && !NUMERIC.includes(key))
212 .map(([key, source]) => `${key} (${source})`)
213 if (overridden.length > 0) lines.push(`Overridden texts: ${overridden.join(', ')}.`)
214
215 const breakdown = usage.context.breakdown
216 if (breakdown !== undefined) {
217 const auto = breakdown.isAutoCompactEnabled
218 ? `auto-compact around ${count(breakdown.autoCompactThreshold ?? breakdown.maxTokens)} tokens`
219 : 'auto-compact disabled'
220 lines.push(`Estimated breakdown (${auto}):`)
221 for (const c of breakdown.categories.filter(c => !c.isDeferred && c.tokens > 0)) {
222 lines.push(`- ${c.name}: ${count(c.tokens)}`)
223 }
224 }
225 return lines.join('\n')
226}
227
228function count(n: number): string {
229 return n.toLocaleString('en-US')
230}
231
232const SETTINGS_HELP = `Global settings: /config, rows "${NAME}" (thresholds, floor, messages, suffix, instructions).
233Per-project override: in the repository's .claude/settings.json (shared) or .claude/settings.local.json (personal), for example:
234{ "pluginConfigs": { "${NAME}": { "options": { "requestThreshold": 50, "messageSuffix": "Also update docs/…" } } } }
235Message variables: {percent}, {threshold}, {floor}, {tool}. An empty reminder message reuses the compact request message.`
236hooks/settings.ts 94 lines1import type { EngineInterface, PluginOptions } from 'claude-code'
2
3export const NAME = 'proactive-compact-mod'
4
5export type Settings = {
6 enabled: boolean
7 requestThreshold: number
8 reminderThreshold: number
9 compactFloor: number
10 requestMessage: string
11 reminderMessage: string
12 messageSuffix: string
13 compactInstructions: string
14}
15
16export type Source = 'default' | 'user' | 'project' | 'local'
17
18export type ResolvedSettings = { values: Settings; sources: Record<keyof Settings, Source> }
19
20const PERCENTAGES = ['requestThreshold', 'reminderThreshold', 'compactFloor'] as const
21const TEXTS = ['requestMessage', 'reminderMessage', 'messageSuffix', 'compactInstructions'] as const
22
23export const KEYS: readonly (keyof Settings)[] = ['enabled', ...PERCENTAGES, ...TEXTS]
24
25// The options handed over at load already carry the manifest's defaults; only
26// the project overrides are read again on every use, so that editing the
27// repository's settings file takes effect without reloading the mod.
28export async function resolveSettings(
29 $: EngineInterface,
30 options: PluginOptions,
31): Promise<ResolvedSettings> {
32 const [user, project, local] = await Promise.all([
33 readOverrides($, 'user'),
34 readOverrides($, 'project'),
35 readOverrides($, 'local'),
36 ])
37 const values = {} as Record<keyof Settings, unknown>
38 const sources = {} as Record<keyof Settings, Source>
39 for (const key of KEYS) {
40 values[key] = options[key]
41 sources[key] = key in user ? 'user' : 'default'
42 for (const [layer, source] of [[project, 'project'], [local, 'local']] as const) {
43 const value = validate(key, layer[key])
44 if (value !== undefined) {
45 values[key] = value
46 sources[key] = source
47 }
48 }
49 }
50 return { values: values as Settings, sources }
51}
52
53async function readOverrides(
54 $: EngineInterface,
55 source: 'user' | 'project' | 'local',
56): Promise<Record<string, unknown>> {
57 const settings = (await $.settings.read({ source })) as {
58 pluginConfigs?: Record<string, { options?: Record<string, unknown> }>
59 }
60 const configs = settings.pluginConfigs ?? {}
61 const key = Object.keys(configs).find(name => name === NAME || name.startsWith(`${NAME}@`))
62 return (key && configs[key]?.options) || {}
63}
64
65function validate(key: keyof Settings, value: unknown): unknown {
66 if (value === undefined) return undefined
67 if (key === 'enabled') return typeof value === 'boolean' ? value : undefined
68 if ((PERCENTAGES as readonly string[]).includes(key)) {
69 const number = Number(value)
70 return Number.isFinite(number) && number >= 0 && number <= 100 ? number : undefined
71 }
72 return typeof value === 'string' ? value : undefined
73}
74
75export function messageForLevel(settings: Settings, level: 1 | 2, percent: number, tool: string): string {
76 const template =
77 level === 2 && settings.reminderMessage.trim() !== ''
78 ? settings.reminderMessage
79 : settings.requestMessage
80 const threshold = level === 2 ? settings.reminderThreshold : settings.requestThreshold
81 const text = template
82 .replaceAll('{percent}', String(percent))
83 .replaceAll('{threshold}', String(threshold))
84 .replaceAll('{floor}', String(settings.compactFloor))
85 .replaceAll('{tool}', tool)
86 const suffix = settings.messageSuffix.trim()
87 return suffix === '' ? text : `${text}\n\n${suffix}`
88}
89
90export function summaryInstructions(settings: Settings, fromAgent: string | null): string | undefined {
91 const parts = [settings.compactInstructions.trim(), fromAgent?.trim() ?? ''].filter(p => p !== '')
92 return parts.length === 0 ? undefined : parts.join('\n\n')
93}
94types/index.d.ts 13 lines1/** A compaction the agent asked for, run when its turn ends. */
2export type PendingCompact = { instructions: string | null }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'proactive-compact-mod': {
7 /** Last message sent since the last compaction: 0 none, 1 compact request, 2 reminder. */
8 level: number
9 pending: PendingCompact | null
10 }
11 }
12}
13