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…

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.
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.
| Mark | Meaning | The plugin compacts at the end of the turn when the context contains |
|---|---|---|
boundary | A task is done and its state is saved (committed, or in a plan file) | 200k tokens or more |
| none | No mark since the last compaction | 300k tokens or more |
busy | A step is in progress whose details exist only in the conversation | never |
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.
claude plugin marketplace add lbzepoqo/claude-plugins
claude plugin install compact-timing@lbzepoqo
Restart Claude Code.
| Option | Default | Meaning |
|---|---|---|
boundaryTokens | 200000 | Context size at which a boundary mark causes compaction |
softTokens | 300000 | Context size at which the plugin compacts when there is no mark |
contextNote | true | Send 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
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.claude -p, the SDK) cannot compact from a plugin yet. In these sessions the plugin shows a "compaction failed" message and nothing else changes.claude plugin validate .
claude plugin test .hooks/register.ts 217 lines1import { 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}
217types/index.d.ts 9 lines1declare module 'claude-code' {
2 interface PluginState {
3 'compact-timing': {
4 mark: { state: 'busy' | 'boundary'; focus: string } | null
5 noted: boolean
6 }
7 }
8}
9