SLOPSHOPPER

proactive-compact-mod

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…

guardcommandtoaststatustooltimer
v0.2.0MITupdated 2026-10-16Apemb/proactive-compact-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · proactive-compact-mod
› 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 › /proactive-compact ⎿ proactive-compact-mod: Context: 97,400 tokens of 200,000 (49%). ⎿ proactive-compact-mod: Messages: enabled; compact request at 60%, reminder at 80%, floor 40%. ⎿ proactive-compact-mod: Last message sent: none. Compaction scheduled: no. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

proactive-compact-mod

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.

What it does

PieceRole
Compact requestAt 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.
ReminderThe same at reminderThreshold (80% by default), if the agent has not compacted. Jumping straight past this threshold sends the reminder only.
context_usage toolGives 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 toolSchedules 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-compactstatus (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.

Settings

Global settings live in /config, in the proactive-compact-mod rows:

SettingDefaultRole
enabledtrueSends the compact request and the reminder. The tools are offered either way.
requestThreshold60Raw context percentage that triggers the compact request.
reminderThreshold80Raw percentage that triggers the reminder.
compactFloor40Below it, compact_session refuses.
requestMessageprovided textThe compact request message.
reminderMessageemptyThe reminder message; when empty, the compact request message is reused.
messageSuffixemptyAppended to both messages.
compactInstructionsemptyPassed 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.

Install

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.

Organization-managed Claude Code

Where an organization manages Claude Code through managed settings:

  • hooks on 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;
  • under allowManagedHooksOnly, a plugin a person installs loads no hooks at all, and the mod does nothing;
  • a marketplace or plugin allowlist may refuse the install.

If /proactive-compact status does not answer, ask your administrator to list the mod in the managed settings.

Develop

Run the mod from a working copy:

  • for one session: claude --plugin-dir /path/to/proactive-compact-mod;
  • for every session: add the folder to 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/
FileContent
.claude-plugin/plugin.jsonManifest and settings (userConfig).
.claude-plugin/marketplace.jsonMakes the repository a marketplace, for /plugin install.
hooks/register.tsHooks: turn end, compaction, tools, command.
hooks/settings.tsSettings resolution (default, user, project, local) and message templating.
types/index.d.tsSession state contract ($.state).
tests/Behaviour tests.

.claude-plugin/types/ is written by Claude Code on every load and is not versioned.

Known limits

  • Subagents get neither the messages nor the tools: measuring their own context is still to be done.
  • A compactFloor above requestThreshold makes the compact request pointless, since the tool would refuse. The mod does not flag it yet.
  • The ⚠ before the status in the status bar is drawn by Claude Code; the API offers no way to change it.

Qualification

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):

  • the compact request arrived, then the reminder, once each;
  • the agent called compact_session at 31% (about 306k tokens); the compaction ran when the turn ended;
  • right after, context_usage reported 7% (about 65k tokens), Last message sent: none and Compaction scheduled: no: the cycle was rearmed.

Roadmap

  1. Warn when compactFloor is above requestThreshold, in /proactive-compact status and in context_usage: the compact request is then pointless, since compact_session would refuse.
  2. Subagents: let a subagent measure its own context and get the compact request and the reminder, so that a long-running agent can keep within a context budget (for instance 450k tokens targeted, 600k at most).
  3. Open question: during the trial, the compact request once arrived at 29% instead of 20%, after several hot reloads of the mod. Suspected cause, not established: a reload at the end of a turn misses that turn's turn.complete. Worth reproducing before relying on hot reload.

License

MIT

Source 3 files
hooks/register.ts 236 lines
1import 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.`
236
hooks/settings.ts 94 lines
1import 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}
94
types/index.d.ts 13 lines
1/** 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