SLOPSHOPPER

self-compact

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…

newguardtoaststatusprompttool
v0.2.1no licenseupdated 2026-10-21StevenLi-phoenix/claude-code-self-compact-plugin
A shopper browsing a rack in a slop shop
README

self-compact

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.

How it behaves

  • Size gate. Only accepted when the live context holds more than the idle-compaction size: 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.
  • Deferred. The engine refuses $.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.
  • Re-checked. Right before compacting, the size is read again; if auto-compaction already shrank the context, the request is dropped.
  • Focus. The tool's focus argument becomes the summary instructions (what /compact <text> would pass).
  • Carrying on. 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.
  • Main loop only. A subagent cannot call it, and a subagent's turn end does not fire it. An interrupted (Esc) or failed turn drops the request.
  • DISABLE_COMPACT refuses the tool.
  • Reminder past 400k. When the context crosses 400k tokens (or the idle-compaction size, if that is higher), Claude gets a note: the size, and to watch for a good breakpoint to call 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.

Run

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.

Test

claude plugin validate ./self-compact
claude plugin test ./self-compact
Source 1 files
hooks/register.ts 256 lines
1import 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