SLOPSHOPPER

compact-timing

Compacts the conversation at task boundaries instead of at a fixed token count: the model marks the session busy or at a boundary, and the plugin compacts at…

guardtoastprompttool
★ 2v0.6.0MITupdated 2026-10-21lbzepoqo/claude-plugins/plugins/compact-timing
A shopper browsing a rack in a slop shop
README

compact-timing

Claude Code compacts a conversation when it reaches a fixed token count. That can happen in the middle of a debugging session, when the details exist only in the conversation. It can also happen long after a task ended, when compacting would have lost nothing.

This plugin lets the model choose the moment. The model sets a mark when it finishes a task or starts a step that must not be compacted, and the plugin compacts at the end of a turn when the mark and the context size allow it.

How it works

The plugin gives the model a tool, mark, with two inputs: the state, and a focus that says what the summary must keep. The focus is passed to the compaction as its instructions.

MarkMeaningThe plugin compacts at the end of the turn when the context contains
boundaryA task is done and its state is saved (committed, or in a plan file)200k tokens or more
noneNo mark since the last compaction300k tokens or more
busyA step is in progress whose details exist only in the conversationnever

The token counts in the table are the defaults of boundaryTokens and softTokens (see Settings).

A boundary mark counts only at the end of the turn in which the model set it. The plugin drops the mark when that turn ends, also when it did not compact, so the mark cannot cause a compaction later in the middle of other work. When you interrupt the turn, the plugin drops the mark and does not compact. A busy mark stays until the model replaces it. Any compaction clears the mark. Each session has its own mark.

After a git commit, the plugin reminds the model to set the mark when the context has reached boundaryTokens, or when the mark is busy. A commit is the most common task boundary, and without the reminder the model often forgets the tool. Below boundaryTokens a boundary mark has no effect, so without a busy mark there is no reminder.

Switching to an unrelated topic without a commit gets no mark, and a plugin cannot compact before a prompt runs. So the first prompt that finds the context at boundaryTokens or more carries a note to the model: suggest /clear when the request is unrelated, a short handoff when it needs a few facts from the conversation, or set boundary at the next break when it continues the work. The note comes once, and again only after the context has fallen below boundaryTokens.

A busy mark that is never replaced stops the plugin from compacting. Claude Code's own automatic compaction still runs at autoCompactWindow. Keep that setting above 300k, for example:

{ "autoCompactWindow": 500000 }

Claude Code also compacts on its own: at autoCompactWindow, on a prompt that is too long, and while the session is idle before the prompt cache expires. These compactions do not wait for the mark. When the mark has a focus and the compaction has no instructions of its own, the plugin passes the focus as the instructions, so the summary still keeps what a busy step needs.

Install

claude plugin marketplace add lbzepoqo/claude-plugins
claude plugin install compact-timing@lbzepoqo

Restart Claude Code.

Settings

OptionDefaultMeaning
boundaryTokens200000Context size at which a boundary mark causes compaction
softTokens300000Context size at which the plugin compacts when there is no mark
contextNotetrueSend the note about a fresh session when the context reaches boundaryTokens

Change them with /plugin configure compact-timing@lbzepoqo in Claude Code, or from the shell:

echo '{"boundaryTokens": "150000"}' | claude plugin configure compact-timing@lbzepoqo --values-stdin

Limits

  • In a test, Opus, Sonnet and Haiku each set boundary with a useful focus after they committed a bug fix. None of them set busy while it searched for the bug. Without the commit reminder, Opus set no mark at all.
  • Headless sessions (claude -p, the SDK) cannot compact from a plugin yet. In these sessions the plugin shows a "compaction failed" message and nothing else changes.

Develop

claude plugin validate .
claude plugin test .
Source 2 files
hooks/register.ts 217 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4export type Mark = { state: 'busy' | 'boundary'; focus: string }
5
6// The last mark the model set in this session. Values are held per session, so sessions do not see
7// each other's marks.
8const mark = atom({ plugin: 'compact-timing', key: 'mark' } as const, null as Mark | null)
9// Whether this period already had the context note; cleared once the context falls below boundaryTokens.
10const noted = atom({ plugin: 'compact-timing', key: 'noted' } as const, false)
11
12/** The tool input as the model sends it, or null when the state is not one of the two. */
13export const parseMark = (input: { state?: unknown; focus?: unknown }): Mark | null => {
14  const state = typeof input.state === 'string' ? input.state.trim().toLowerCase() : ''
15  if (state !== 'busy' && state !== 'boundary') return null
16  return { state, focus: typeof input.focus === 'string' ? input.focus.trim() : '' }
17}
18
19export type Limits = { boundaryTokens: number; softTokens: number }
20
21/**
22 * Whether to compact at the end of a turn. A boundary allows it from `boundaryTokens`, no mark from
23 * `softTokens`, and a busy mark never: autoCompactWindow stays the hard limit for a stale busy mark.
24 */
25export const shouldCompact = (current: Mark | null, tokens: number | undefined, limits: Limits): boolean => {
26  if (tokens === undefined || current?.state === 'busy') return false
27  return tokens >= (current?.state === 'boundary' ? limits.boundaryTokens : limits.softTokens)
28}
29
30const thousands = (tokens: number) => `${Math.round(tokens / 1000)}k`
31
32/** The tool description: what the model reads about the marks. */
33export const ruleText = (limits: Limits) =>
34  [
35    'Sets the compaction mark for this conversation. The compact-timing plugin compacts at task boundaries instead of at a fixed token count.',
36    `- \`boundary\`: call after a task or milestone whose state is saved outside the conversation (committed, or in a plan or notes file). The plugin compacts at the end of that turn when the context contains ${thousands(limits.boundaryTokens)} tokens or more. Either way the mark is dropped when the turn ends.`,
37    '- `busy`: call before a step whose details exist only in the conversation, such as a debugging sequence or reading test output. The plugin does not compact while the mark is `busy`. Call with `boundary` when the step is done.',
38    '- `focus`: what the summary must keep: the next step, open decisions, file paths.',
39    `Without a mark the plugin compacts at ${thousands(limits.softTokens)} tokens. Any compaction clears the mark.`,
40  ].join('\n')
41
42const markSchema = {
43  type: 'object',
44  properties: {
45    state: { type: 'string', enum: ['boundary', 'busy'] },
46    focus: { type: 'string', description: 'What the summary must keep: the next step, open decisions, file paths.' },
47  },
48  required: ['state'],
49}
50
51/** A `git commit`, also after global options such as `-C <dir>`, `-c <key=value>` or `--no-pager`. */
52export const isCommit = (command: string) =>
53  /\bgit(?:\s+(?:-[Cc]\s+(?:"[^"]*"|'[^']*'|\S+)|--?[\w-]+(?:=\S+)?))*\s+commit(?![\w-])/.test(command)
54
55/**
56 * Read right after a commit's result. The tool description alone is often missed, so the reminder
57 * comes at the moment a boundary is most likely.
58 */
59export const commitReminder = (toolName: string, current: Mark | null) =>
60  current?.state === 'busy'
61    ? `compact-timing: the mark is \`busy\`. If the step whose details exist only in the conversation is done, call ${toolName} with \`boundary\` and a focus: the next step and open decisions.`
62    : [
63        `compact-timing: this commit saved work outside the conversation. If a task or milestone is done, call ${toolName} with \`boundary\` and a focus: the next step and open decisions.`,
64        'If the work continues and its details exist only in the conversation, call it with `busy` instead.',
65      ].join(' ')
66
67/**
68 * Whether a commit gets the reminder: when a boundary mark can compact at this size, or when a busy
69 * mark is set, since the reminder is what makes the model replace it.
70 */
71export const shouldRemind = (current: Mark | null, tokens: number | undefined, limits: Limits): boolean =>
72  current?.state === 'busy' || (tokens !== undefined && tokens >= limits.boundaryTokens)
73
74/**
75 * At the end of a main turn: the mark the next turn starts with, and whether to compact now. A boundary
76 * counts only at the end of the turn that set it, also when the turn was interrupted, since an interrupt
77 * often means the boundary no longer holds. An interrupted turn never compacts.
78 */
79export const turnEnd = (
80  current: Mark | null,
81  tokens: number | undefined,
82  isAborted: boolean,
83  limits: Limits,
84): { next: Mark | null; compact: boolean } => ({
85  next: current?.state === 'boundary' ? null : current,
86  compact: !isAborted && shouldCompact(current, tokens, limits),
87})
88
89/**
90 * The instructions for a compaction Claude Code starts itself (idle, hard limit, prompt too long):
91 * the mark's focus when nothing else was given. Only a `busy` mark outlives its turn, so this keeps
92 * the details of an unfinished step in the summary.
93 */
94export const engineInstructions = (trigger: string, instructions: string | undefined, current: Mark | null) =>
95  trigger === 'auto' && !instructions?.trim() && current?.focus ? current.focus : instructions
96
97/**
98 * Whether a prompt gets the context note: once when the context reaches `boundaryTokens`, and again only
99 * after it fell below that (a compaction or a fresh session). Returns the new `noted` flag with the decision.
100 */
101export const contextNoteDue = (tokens: number | undefined, noted: boolean, limits: Limits): { due: boolean; noted: boolean } => {
102  if (tokens === undefined) return { due: false, noted }
103  if (tokens < limits.boundaryTokens) return { due: false, noted: false }
104  return { due: !noted, noted: true }
105}
106
107/**
108 * Sent with the prompt that finds the context large. A topic switch without a commit gets no boundary mark,
109 * and a plugin cannot compact before the prompt runs, so the model is asked to suggest a fresh session.
110 */
111export const contextNote = (tokens: number, toolName: string) =>
112  [
113    'compact-timing: an automatic note the user does not see.',
114    `- The context holds ${thousands(tokens)} tokens. From now on, when a request starts new work: if it continues the current work, call ${toolName} with \`boundary\` at the next natural break; if it is unrelated and needs nothing from this conversation, suggest a fresh session (/clear or a new terminal); if it is new but needs a few facts from here, suggest a short handoff and then a fresh session. If the current request already starts new work, apply this now. Suggest it at most once; the user decides.`,
115  ].join('\n')
116
117/** A positive number from the settings, else the default: an empty or misspelled value must not compact every turn. */
118export const limitOption = (value: unknown, fallback: number): number => {
119  const parsed = Number(value)
120  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
121}
122
123export const register: Register = (on, options) => {
124  const limits: Limits = {
125    boundaryTokens: limitOption(options.boundaryTokens, 200_000),
126    softTokens: limitOption(options.softTokens, 300_000),
127  }
128  let markTool = 'mcp__compact-timing__mark'
129  const isContextNoteOn = options.contextNote !== false
130
131  on('session.start', async ($, e, next) => {
132    const registered = await $.tool.register({ name: 'mark', description: ruleText(limits), inputSchema: markSchema, isDeferred: false })
133    markTool = registered.tool
134    return next(e)
135  })
136
137  on('tool.call', async ($, e, next) => {
138    if (e.tool === markTool) {
139      if (e.agentId !== undefined) return { result: 'Only the main conversation sets the compaction mark.' }
140      const parsed = parseMark(e as unknown as { state?: unknown; focus?: unknown })
141      if (!parsed) return { result: 'The mark was not set: state must be `boundary` or `busy`.' }
142      await update($, mark, () => parsed)
143      return { result: `The mark is \`${parsed.state}\`.` }
144    }
145    const result = await next(e)
146    if (e.tool !== 'Bash' || e.agentId !== undefined || result.deny !== undefined || result.isError) return result
147    const { command } = e as unknown as { command: string }
148    if (!isCommit(command)) return result
149    const [current, tokens] = await Promise.all([
150      read($, mark).catch(() => null),
151      $.session.usage().then(usage => usage.context.tokens).catch(() => undefined),
152    ])
153    if (!shouldRemind(current, tokens, limits)) return result
154    return { ...result, context: [...(result.context ?? []), commitReminder(markTool, current)] }
155  })
156
157  on('turn.complete', async ($, e, next) => {
158    const result = await next(e)
159    if (e.agentId !== undefined) return result
160    try {
161      const tokens = await $.session.usage().then(usage => usage.context.tokens)
162      let current = null as Mark | null
163      let decision = { next: null as Mark | null, compact: false }
164      // One read-modify-write, so a mark set meanwhile is neither lost nor judged by the old value.
165      await update($, mark, previous => {
166        current = previous
167        decision = turnEnd(previous, tokens, e.isAborted === true, limits)
168        return decision.next
169      })
170      if (!decision.compact) return result
171      const outcome = await $.session.compact(current?.focus ? { instructions: current.focus } : {})
172      if ('skip' in outcome && outcome.skip !== undefined) $.ui.toast(`compact-timing: compaction skipped (${outcome.skip})`)
173    } catch (error) {
174      $.ui.toast(`compact-timing: end of turn failed (${error instanceof Error ? error.message : String(error)})`)
175    }
176    return result
177  })
178
179  // Only a request the user typed (or sent from a remote or SDK host) gets the note: a task notification,
180  // a scheduled prompt or a slash command would use it up without the user's next topic seeing it.
181  on('prompt.submit', async ($, e, next) => {
182    const isUserRequest = ['composer', 'bridge', 'sdk'].includes(e.origin.kind) && e.command === undefined && e.isMeta === undefined
183    if (!isContextNoteOn || !isUserRequest) return next(e)
184    const [previous, tokens] = await Promise.all([
185      read($, noted).catch(() => undefined),
186      $.session.usage().then(usage => usage.context.tokens).catch(() => undefined),
187    ])
188    if (previous === undefined) return next(e)
189    const decision = contextNoteDue(tokens, previous, limits)
190    if (decision.noted !== previous) {
191      const isStored = await update($, noted, () => decision.noted).then(() => true, () => false)
192      // A note whose flag was not stored would come again with every prompt.
193      if (!isStored) return next(e)
194    }
195    if (!decision.due || tokens === undefined) return next(e)
196    return next({ ...e, context: [...(e.context ?? []), contextNote(tokens, markTool)] })
197  })
198
199  // Any compaction that replaces the conversation, also the automatic one at the hard limit, starts the
200  // next period without a mark. A precompute installs nothing, so the mark stays.
201  on('session.compact', async ($, e, next) => {
202    if (e.agentId === undefined && e.trigger === 'auto') {
203      const current = await read($, mark).catch(() => null)
204      const instructions = engineInstructions(e.trigger, e.instructions, current)
205      if (instructions !== e.instructions) e = { ...e, instructions }
206    }
207    const result = await next(e)
208    if (e.agentId === undefined && e.trigger !== 'precompute' && result.skip === undefined) {
209      await update($, mark, () => null).catch(() => undefined)
210      // The usage figure stays at the pre-compaction size until the next response, so the note is held
211      // until a prompt reads a size below boundaryTokens.
212      await update($, noted, () => true).catch(() => undefined)
213    }
214    return result
215  })
216}
217
types/index.d.ts 9 lines
1declare module 'claude-code' {
2  interface PluginState {
3    'compact-timing': {
4      mark: { state: 'busy' | 'boundary'; focus: string } | null
5      noted: boolean
6    }
7  }
8}
9