Compacts the conversation when the rules say the moment is right, judged against the conversation itself at the end of a turn.

Claude decides when to compact, and compacts. Every other plugin for this only suggests and leaves you to type /compact.
claude plugin marketplace add sergiopalacio/cc-strategic-compaction
claude plugin install cc-strategic-compaction@cc-strategic-compaction --config askFromPercent=40
/compaction opens the pane. Set askFromPercent before anything else: at its default of 0 the judge runs every turn from the first, spending a model call to answer a question whose answer is obviously no.
Not when the task is finished, and not when the window is full. Claude already watches the window.
The question is whether anything in the conversation exists only here. A measurement taken and never written down is lost on compaction. A file on disk is not, because it can be read again.
Two things can ask, and each has its own switch in the pane:
hooks/rules.ts plus your own.mcp__cc-strategic-compaction__compact, which the model calls on itself after writing something down.Nothing else can: no noun on $, no file to touch. A shell hook reaches this by reminding the model to call the tool. Turn the judge off and the agent's own instructions are the only thing deciding; turn the tool off and only the rules are.
A subagent still running blocks both. There is a 15 minute floor between compactions.
| key | default | what it does |
|---|---|---|
judgeEnabled | true | ask the judge at the end of each turn |
toolEnabled | true | register the tool the agent calls on itself |
askFromPercent | 0 | percent of the window below which nothing is judged |
compactWhen | "" | your rules, in your own words |
rulesMode | append | append adds yours to the packaged rules, override replaces them |
toolThreshold | 0 | compact after N tool calls of any kind. Unrelated to toolEnabled. 0 is off, and worth leaving off |
toolInterval | 25 | further calls between those compactions |
Switching toolEnabled off mid-session cannot unregister the tool, so a call is refused instead until the next session.
claude plugin validate .
claude plugin update cc-strategic-compaction
To work on it, clone the repo and point a marketplace at the clone (claude plugin marketplace add .) rather than at this one. The installed plugin is a copy in the cache, so editing the folder changes nothing until plugin update runs, and update compares versions rather than content: bump version in plugin.json or it will tell you it is already current. Then /reload-plugins.
claude plugin test .
test finds nothing today. Worth covering first: judged(), where the hold/ready asymmetry lives; the pane mounted on all four surfaces; and waitForGate against mock.clock.
Why it decides the way it does is in the code, near the thing it explains.
hooks/register.tsx 774 lines1/**
2 * Auto-compaction decided by judgement, not by counting.
3 *
4 * Two things decide. The judge: one question asked over the conversation itself by
5 * `$.model.fork`, so it sees what happened and the API serves that prefix from its
6 * cache. And the model, through the one tool registered here -- not the same question
7 * twice, because the judge is asked cold while the model knows what it just wrote.
8 * Nothing else can ask: no noun on `$`, no file to touch. A caller with neither is
9 * meant to remind the model to call the tool.
10 *
11 * `$.session.compact()` rejects while a turn runs, so nothing compacts at the moment
12 * it decides to: both paths ARM, and `turn.complete` fires. The judge is asked there,
13 * and again when work that was in flight finishes while nobody is typing.
14 *
15 * The two numeric triggers are off by default, kept for whoever wants them.
16 */
17import { atom, read, update } from 'claude-code'
18import type { EngineInterface, Register, Timer } from 'claude-code'
19
20import { headerSvg, themeOf } from './header'
21import { TOOL_INPUT } from '../prompts/tool'
22import type { CompactionCounts } from '../types'
23
24const PANE = 'compaction'
25const COMMAND = 'compaction'
26/** Registered as this; the model calls it by the full name in the matcher below. */
27const TOOL = 'compact'
28
29/** Counters the numeric triggers measure against. In `$.state` because a settings
30 * write reloads this module, and a reload would zero them. */
31const counts = atom({ plugin: 'cc-strategic-compaction', key: 'counts' } as const, {
32 tools: 0,
33 toolsAtLast: 0,
34})
35
36/** The last answer the judge gave. In `$.state` so a reload does not forget it. */
37const judgement = atom({ plugin: 'cc-strategic-compaction', key: 'judgement' } as const, null)
38
39/** What has been compacted this session, newest first. Also where the floor below
40 * reads the last compaction from: as a module variable it survived no reload, and
41 * saving a setting reloads this module, which quietly reopened the floor. */
42const history = atom({ plugin: 'cc-strategic-compaction', key: 'history' } as const, [])
43
44/** What the judge has cost so far. */
45const spend = atom({ plugin: 'cc-strategic-compaction', key: 'spend' } as const, {
46 calls: 0,
47 input: 0,
48 output: 0,
49 cached: 0,
50})
51
52/** What the model asked for mid-turn, from the tool: why, and what it says the
53 * summary must carry. Cleared by the `turn.complete` that acts on it. */
54let armed: { reason: string; keep: string } | null = null
55/** The re-check left running when a gate held the moment, or null when none is. */
56let waiting: Timer | null = null
57
58/**
59 * Compactions closer together than this are refused, whatever asked for one.
60 *
61 * It has to be long, because right after a compaction the context is a summary and
62 * a summary answers "does anything exist only here?" with no, by construction: the
63 * judge would say READY again on the very next turn. `askFromPercent` is the better
64 * brake, since the context has to climb back before anything is asked at all, but it
65 * is 0 by default and then this is the only one there is.
66 */
67const FLOOR_MS = 900_000
68/** How often the re-check asks whether the gate has cleared. */
69const POLL_MS = 15_000
70/** How long it keeps asking before letting the moment go. */
71const PATIENCE_MS = 600_000
72/** How many past compactions the pane can show before the oldest falls off. */
73const HISTORY = 8
74/** Under `$HOME`: where each compaction leaves the conversation it replaced. */
75const ARCHIVE = '.cc-strategic-compaction/compactions'
76
77type Limits = {
78 judgeEnabled: boolean
79 toolEnabled: boolean
80 compactWhen: string
81 rulesMode: string
82 askFromPercent: number
83 toolThreshold: number
84 toolInterval: number
85}
86
87type Facts = CompactionCounts & {
88 tokens: number | null
89 window: number | null
90 percent: number
91}
92
93/** Read fresh at the moment a decision is taken, never carried from an earlier one. */
94async function factsFor($: EngineInterface): Promise<Facts> {
95 const usage = await $.session.usage()
96 const current = await read($, counts)
97 return {
98 ...current,
99 tokens: usage.context?.tokens ?? null,
100 window: usage.context?.window ?? null,
101 percent: usage.context?.percent ?? 0,
102 }
103}
104
105/** The rules the judge is given: the packaged ones, the person's, or both. */
106/**
107 * A prompt, from `prompts/*.md` beside the plugin.
108 *
109 * Markdown rather than a string literal so that changing what the judge is asked
110 * is editing a document, not editing code. That is not taste: the only honest way
111 * to tune this is to measure it, and a loop that recompiles to change a sentence
112 * is a loop nobody runs. The cost is that a missing file fails at run time rather
113 * than at build time.
114 *
115 * It lives here, and not in a module of its own, because the validator follows `$`
116 * only into functions declared in the same file.
117 *
118 * Held for the life of the module, so a turn never pays for a read twice and a
119 * hot reload picks up an edit.
120 */
121const held = new Map<string, string>()
122
123async function prompt($: EngineInterface, name: string): Promise<string> {
124 const have = held.get(name)
125 if (have !== undefined) return have
126 const text = (await $.fs.read(`${$.plugin.root}/prompts/${name}.md`)).trim()
127 held.set(name, text)
128 return text
129}
130
131async function rulesFor($: EngineInterface, limits: Limits): Promise<string> {
132 const mine = limits.compactWhen.trim()
133 // Override with nothing of your own reads no file at all: there are no rules.
134 if (limits.rulesMode === 'override') return mine
135 const packaged = await prompt($, 'rules')
136 if (mine === '') return packaged
137 return `${packaged}\n\nAlso, from the person working here, and these win where they disagree with the above:\n${mine}`
138}
139
140/**
141 * The judge's answer, read strictly.
142 *
143 * Anything that is not plainly READY is a hold, a malformed answer included. The
144 * asymmetry in the rules decides this: a wrong hold costs a turn, a wrong compaction
145 * loses work nobody notices is gone, so the unparseable case takes the cheap side.
146 */
147export function judged(text: string): { isReady: boolean; line: string } {
148 const lines = text.trim().split('\n').map(one => one.trim()).filter(one => one !== '')
149 // The whole line, not a prefix: `/^READY\\b/` let "READY, I think" through, and
150 // a hedged verdict is exactly the case the asymmetry says to refuse.
151 const isReady = /^ready$/i.test(lines[0] ?? '')
152 const named = lines.find(one => /^keep:/i.test(one))
153 return { isReady, line: (named ?? '').replace(/^keep:\s*/i, '').trim() }
154}
155
156/** Token counts for a line with room for a number, not for six digits. */
157function tokens(n: number): string {
158 return n < 1000 ? String(n) : `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}K`
159}
160
161/** How long ago, for a line that needs the order of magnitude and nothing finer. */
162function ago(at: number, now: number): string {
163 const seconds = Math.max(0, Math.round((now - at) / 1000))
164 if (seconds < 60) return `${seconds}s ago`
165 const minutes = Math.round(seconds / 60)
166 return minutes < 60 ? `${minutes}m ago` : `${Math.round(minutes / 60)}h ago`
167}
168
169function nextTools(facts: Facts, limits: Limits): number | null {
170 if (limits.toolThreshold <= 0) return null
171 return facts.toolsAtLast === 0 ? limits.toolThreshold : facts.toolsAtLast + limits.toolInterval
172}
173
174/**
175 * What stops a compaction outright, whatever the rules would say.
176 *
177 * A veto belongs in code and not in the rules: wording in a prompt is advice the
178 * judge weighs against other advice, while this cannot be argued with, and a gate
179 * that holds is known before the call, so the call is never made and never billed.
180 */
181async function gated($: EngineInterface): Promise<string | null> {
182 // A list that cannot be read holds, as an unparseable verdict does in `judged`
183 // and as the rules say an honest "I cannot tell" should: not knowing whether
184 // work is in flight is the same as knowing it might be.
185 const agents = await $.agent.list().catch(() => null)
186 if (agents === null) return 'the agent list could not be read'
187 // A subagent still working is work in flight by definition, and its result has
188 // not reached this conversation yet. Summarising now summarises a gap.
189 const running = agents.filter(one => one.status === 'running').length
190 return running === 0 ? null : `${running} subagent${running === 1 ? '' : 's'} still running`
191}
192
193/** When the last compaction ran, or 0 before the first one of the session. */
194async function lastCompactAt($: EngineInterface): Promise<number> {
195 return (await read($, history))[0]?.at ?? 0
196}
197
198/** Whether the tool-call trigger has come round, when it is on at all. */
199function triggered(facts: Facts, limits: Limits): string | null {
200 const tools = nextTools(facts, limits)
201 if (tools !== null && facts.tools >= tools) return `${facts.tools} tool calls`
202 return null
203}
204
205/**
206 * Asks, and writes the answer down either way: a mod that never asks and one that
207 * keeps deciding not to compact look identical from outside, and the recorded hold
208 * is the only thing that tells them apart. Answers READY's brief, or null.
209 */
210async function judge($: EngineInterface, rules: string, at: number): Promise<string | null> {
211 const asked = await $.model.fork({
212 prompt: (await prompt($, 'judge')).replace('{{rules}}', rules),
213 })
214 if ('usage' in asked) {
215 const used = asked.usage
216 await update($, spend, s => ({
217 calls: s.calls + 1,
218 input: s.input + used.input_tokens + used.cache_creation_input_tokens,
219 output: s.output + used.output_tokens,
220 cached: s.cached + used.cache_read_input_tokens,
221 }))
222 }
223 if (!asked.isAnswered) {
224 $.ui.log(`compaction could not judge: ${asked.reason}`, { to: 'debug' })
225 return null
226 }
227 const verdict = judged(asked.text)
228 await update($, judgement, was => ({
229 at,
230 ...verdict,
231 holds: verdict.isReady ? 0 : (was?.holds ?? 0) + 1,
232 }))
233 return verdict.isReady ? verdict.line : null
234}
235
236/**
237 * Writes the conversation a compaction replaced, under `$HOME`.
238 *
239 * Not a backup: the session's own transcript keeps every message through a
240 * compaction, so nothing here is at risk of being lost today. What this adds is
241 * the boundary -- what was in context at the moment one ran, addressable without
242 * reading the whole session log to find where it fell -- and a copy that outlives
243 * `cleanupPeriodDays`, which sweeps transcripts after thirty days.
244 *
245 * Each file holds the messages since the one before it, so the set reconstructs
246 * the conversation without any file repeating another.
247 */
248async function archive($: EngineInterface, messages: readonly unknown[], at: number): Promise<void> {
249 const home = await $.env.get('HOME')
250 if (home === undefined) return
251 const id = await $.session.id()
252 const stamp = new Date(at).toISOString().replace(/[:.]/g, '-')
253 const body = messages.map(one => JSON.stringify(one)).join('\n')
254 await $.fs.write(`${home}/${ARCHIVE}/${id}/${stamp}.jsonl`, body)
255}
256
257/** `brief` is what the asker said the summary must carry, empty when none did. */
258async function compact(
259 $: EngineInterface,
260 facts: Facts,
261 reason: string,
262 brief: string,
263 now: number,
264): Promise<void> {
265 const since = now - (await lastCompactAt($))
266 if (since < FLOOR_MS) {
267 $.ui.log(
268 `compaction held off (${reason}): the last one was ${Math.round(since / 60_000)}m ago`,
269 { to: 'debug' },
270 )
271 return
272 }
273 // Read before, written after: once `compact` returns, these messages are no
274 // longer the conversation, and a veto must leave no file behind for a
275 // compaction that never happened.
276 const replaced = await $.session.messages({ as: 'api' })
277 const also = brief === '' ? '' : ` Above all keep this, which the next turns need: ${brief}`
278 const { skip } = await $.session.compact({
279 instructions: (await prompt($, 'summary')).replace('{{brief}}', also),
280 })
281 if (skip) {
282 $.ui.log(`compaction vetoed: ${skip}`, { to: 'debug' })
283 return
284 }
285 await archive($, replaced, now).catch(one => {
286 // A record that cannot be written is not a reason to undo a compaction that
287 // already ran.
288 $.ui.log(`compaction archived nothing: ${String(one)}`, { to: 'debug' })
289 })
290 await update($, history, past => [{ at: now, reason }, ...past].slice(0, HISTORY))
291 await update($, counts, c => ({ ...c, toolsAtLast: c.tools }))
292 $.ui.toast(`Compacted: ${reason}`)
293}
294
295/**
296 * Keeps a held moment alive instead of dropping it.
297 *
298 * The work a gate waits on usually finishes while nobody is typing, and that idle
299 * window is the cheapest moment there is to compact: no turn running, nothing in
300 * flight to summarise into a gap, nobody waiting on the answer. Dropping the moment
301 * spends the window for nothing and decides at the next message instead, on top of
302 * whatever has piled up since.
303 *
304 * Polling costs nothing by construction: a turn count and a list of agents, and no
305 * model call until there is a decision to make.
306 */
307function waitForGate(
308 $: EngineInterface,
309 limits: Limits,
310 turns: number,
311 now: number,
312 asked: { reason: string; keep: string } | null,
313): void {
314 waiting?.cancel()
315 const until = now + PATIENCE_MS
316 let timer: Timer | null = null
317 const stop = () => {
318 timer?.cancel()
319 if (waiting === timer) waiting = null
320 }
321 timer = $.clock.every(POLL_MS, async () => {
322 try {
323 const at = await $.clock.now()
324 if (at > until) {
325 stop()
326 $.ui.log('compaction stopped waiting: the work in flight is still running', { to: 'debug' })
327 return
328 }
329 // The person has spoken. The end of that turn decides the moment with what
330 // they just said in view, which is a better-informed decision than this one.
331 if ((await $.session.turns()) !== turns) return stop()
332 if ((await gated($)) !== null) return
333 stop()
334 // A request the gate held is honoured, not re-judged: the gate said nothing
335 // about whether the request was right.
336 if (asked !== null) {
337 await compact($, await factsFor($), asked.reason, asked.keep, at)
338 return
339 }
340 const brief = await judge($, await rulesFor($, limits), at)
341 if (brief === null) return
342 const why = 'the work in flight finished and nothing here is unwritten'
343 await compact($, await factsFor($), why, brief, at)
344 } catch {
345 // The dispatch this was armed from is long gone; a `$` that stops answering
346 // is the session having moved on, not a fault worth a line in the log.
347 stop()
348 }
349 })
350 waiting = timer
351}
352
353export const register: Register = (on, options) => {
354 const limits: Limits = {
355 // Both default on, so an absent option reads as true rather than as off.
356 judgeEnabled: options?.judgeEnabled !== false,
357 toolEnabled: options?.toolEnabled !== false,
358 compactWhen: String(options?.compactWhen ?? ''),
359 rulesMode: String(options?.rulesMode ?? 'append'),
360 askFromPercent: Number(options?.askFromPercent ?? 0),
361 toolThreshold: Number(options?.toolThreshold ?? 0),
362 toolInterval: Number(options?.toolInterval ?? 25),
363 }
364
365 on('session.start', async ($, e, next) => {
366 // Registering throws on a name the engine already owns, and a hook that throws is
367 // skipped whole, so anything after it would never run and say nothing about why.
368 try {
369 await $.command.register({
370 name: COMMAND,
371 description: 'Open the compaction pane: the rules, and what they would do now',
372 })
373 if (limits.toolEnabled) {
374 await $.tool.register({
375 name: TOOL,
376 description: await prompt($, 'tool'),
377 inputSchema: TOOL_INPUT,
378 })
379 }
380 } catch (error) {
381 $.ui.log(`compaction could not register its command and tool: ${String(error)}`)
382 }
383 return next(e)
384 })
385
386 /**
387 * The model arming a compaction on itself, which a shell hook or another session
388 * reaches by reminding it to call this rather than by signalling the mod directly.
389 *
390 * It is not the judge's question asked twice: the judge is asked cold, while the
391 * caller here knows it has just written the file. Different information, not a
392 * second opinion.
393 */
394 on('tool.call', { tool: 'mcp__cc-strategic-compaction__compact' }, async ($, e) => {
395 if (!limits.toolEnabled) return { deny: 'the compact tool is switched off' }
396 // Both refusals carry their reason: the caller is a model, and a tool that
397 // answers nothing teaches it nothing about when to call again.
398 const since = (await $.clock.now()) - (await lastCompactAt($))
399 if (since < FLOOR_MS) {
400 return {
401 result: `Not armed: the last compaction was ${Math.round(since / 60_000)}m ago, under the ${FLOOR_MS / 60_000}m floor between them.`,
402 }
403 }
404 const hold = await gated($)
405 if (hold !== null) {
406 return {
407 result:
408 `Not armed: ${hold}. Their results have not reached this conversation, so ` +
409 `a summary written now would summarise a gap. Call again once they have ` +
410 `finished and you have read what they returned.`,
411 }
412 }
413 const reason = e.reason.trim()
414 armed = armed ?? {
415 reason: reason === '' ? 'the agent asked' : reason,
416 keep: e.keep?.trim() ?? '',
417 }
418 return { result: 'Armed. This conversation compacts when the turn ends.' }
419 })
420
421 on('command.run', { command: COMMAND }, async $ => {
422 await $.ui.open({ id: PANE, title: 'Auto-compact' })
423 return {}
424 })
425
426 // No `.catch`: a failed hook is absent from the chain, and a counter that cannot
427 // be written is no reason to stop a tool call. Counted even while the trigger is
428 // off, so turning it on mid-session does not start from a pretended zero.
429 on('tool.call', async ($, e, next) => {
430 if (e.agentId === undefined) await update($, counts, c => ({ ...c, tools: c.tools + 1 }))
431 return next(e)
432 })
433
434 on('turn.complete', async ($, e, next) => {
435 const result = await next(e)
436
437 // Every hook sees subagents' turns too, and `agentId` is absent only on the main
438 // loop. Without this a review that spawns twenty subagents would try to compact
439 // twenty times, each while the main turn still runs, which is when compact()
440 // rejects.
441 if (e.agentId !== undefined) return result
442 // An interrupted turn is the person taking over. Summarising on top of that takes
443 // the conversation further from what they were about to do.
444 if (e.reason !== 'answer') return result
445
446 // A turn has ended, so whatever a held moment was waiting for is decided here
447 // instead, with this turn's own result in view.
448 waiting?.cancel()
449 waiting = null
450
451 const signal = armed
452 armed = null
453
454 const facts = await factsFor($)
455 const now = await $.clock.now()
456 const rules = await rulesFor($, limits)
457 // `askFromPercent` holds back the judge's call, not a request already made.
458 const asking =
459 limits.judgeEnabled && rules.trim() !== '' && facts.percent >= limits.askFromPercent
460 const fired = triggered(facts, limits)
461 const decided = signal ?? (fired === null ? null : { reason: fired, keep: '' })
462
463 if (decided === null && !asking) return result
464
465 // Ahead of both, and of the model call either would make. Work in flight is in
466 // flight whoever asked, so the tool does not get past this one either.
467 const hold = await gated($)
468 if (hold !== null) {
469 $.ui.log(`compaction held: ${hold}`, { to: 'debug' })
470 waitForGate($, limits, await $.session.turns(), now, decided)
471 return result
472 }
473
474 if (decided !== null) {
475 await compact($, facts, decided.reason, decided.keep, now)
476 return result
477 }
478 // The judge read the conversation to decide, so it is the best-placed thing in
479 // the session to say what the summary must carry. One call, both jobs.
480 const named = await judge($, rules, now)
481 if (named !== null) {
482 await compact($, facts, 'nothing here exists only in this conversation', named, now)
483 }
484 return result
485 })
486
487 // What the editor posts when you press ctrl+s. Data from code, so it is checked
488 // rather than trusted: the event's own doc says so.
489 on('ui.message', { element: 'rules' }, async ($, e, next) => {
490 const data = e.data as { kind?: unknown; text?: unknown } | null
491 if (!data || data.kind !== 'save' || typeof data.text !== 'string') return next(e)
492 const row = (await $.config.list()).find(one => one.key.endsWith('compactWhen'))
493 if (!row) {
494 $.ui.toast('compactWhen is not a settings row here')
495 return next(e)
496 }
497 const { deny } = await $.config.set({ key: row.key, value: data.text })
498 // The write reloads this module with the new options, which is what makes the
499 // saved rules take effect. Hand the instance its new baseline either way, so a
500 // refused save does not keep showing as unsaved.
501 $.ui.toast(deny ? `Refused: ${deny}` : 'Rules saved')
502 return { props: { text: data.text, saved: deny ? '' : data.text } }
503 })
504
505 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
506 // No surface has every element: mobile draws no field, vscode no Client, the
507 // terminal no Svg. Asking the table rather than assuming is what keeps the pane
508 // from drawing an element the surface cannot make.
509 const table = $.ui.resolve(e)
510 const { Box, Text } = table
511 const Button = 'Button' in table ? table.Button : null
512 const Input = 'Input' in table ? table.Input : null
513 const Select = 'Select' in table ? table.Select : null
514 const Client = 'Client' in table ? table.Client : null
515 const Svg = 'Svg' in table ? table.Svg : null
516
517 const rows = await $.config.list()
518 const facts = await factsFor($)
519 const last = await read($, judgement)
520 const past = await read($, history)
521 const spent = await read($, spend)
522 const now = await $.clock.now()
523 const mine = limits.compactWhen
524 const hasRules = (await rulesFor($, limits)).trim() !== ''
525 const judging = hasRules && facts.percent >= limits.askFromPercent
526 const toolsNext = nextTools(facts, limits)
527
528 /** The one-line state of play. Only a fault earns colour. */
529 const headline = !judging && !limits.toolEnabled
530 ? { text: 'Nothing can compact: judge and tool are both off', bad: true }
531 : !judging
532 ? { text: 'Only when the agent asks, through its tool', bad: !limits.toolEnabled }
533 : !limits.judgeEnabled
534 ? { text: 'Judge off', bad: true }
535 : !hasRules
536 ? { text: 'No rules, so the judge will never say yes', bad: true }
537 : facts.percent < limits.askFromPercent
538 ? { text: `Nothing judged below ${limits.askFromPercent}% of the window`, bad: true }
539 : limits.toolEnabled
540 ? { text: 'Judged every turn, and the agent can ask', bad: false }
541 : { text: 'Judged every turn', bad: false }
542
543 // A window with no token count yet says nothing worth a line of its own.
544 const size =
545 facts.tokens === null || facts.window === null
546 ? null
547 : `${tokens(facts.tokens)} of ${tokens(facts.window)}`
548
549 // The only line here that reports rather than configures, and the one that
550 // tells a mod which never asks from one which keeps deciding not to. A wait in
551 // progress displaces it, being the newer of the two facts.
552 const verdict = waiting !== null
553 ? { label: 'Waiting', detail: 'for the work in flight to finish' }
554 : last === null
555 ? { label: 'Not judged yet', detail: judging ? 'the first turn to end will' : '' }
556 : last.isReady
557 ? { label: 'Ready', detail: ago(last.at, now) }
558 : {
559 label: 'Holding',
560 detail:
561 last.holds > 1
562 ? `${last.holds} turns in a row · ${ago(last.at, now)}`
563 : ago(last.at, now),
564 }
565
566 // `most` because a percentage has a ceiling and a token count has none; without
567 // one, a stray 280 here saves clean and switches the mod off until someone reads
568 // the headline closely enough to notice why.
569 // The share is the number that matters: the judge is affordable only while the
570 // main thread's cache is serving the prefix it forks.
571 const billed = spent.input + spent.cached
572 const share = billed === 0 ? '' : ` · ${Math.round((spent.cached * 100) / billed)}% from cache`
573 const cost =
574 spent.calls === 0
575 ? 'not called yet'
576 : `${spent.calls} ${spent.calls === 1 ? 'call' : 'calls'} · ` +
577 `${tokens(spent.input)} in · ${tokens(spent.output)} out${share}`
578
579 // A Button rather than a Select: it is on every surface, and a two-state
580 // control that needs a menu to change is a menu, not a switch.
581 const toggle = (key: string, label: string, fallback: boolean, hint: string) => {
582 const owned = rows.find(one => one.key.endsWith(key))
583 // The live value, not `limits`: those were read when the module loaded, and
584 // the press that changes one is drawn before the reload that renews them, so
585 // a switch drawn from `limits` shows the old state and looks broken.
586 const isOn = typeof owned?.value === 'boolean' ? owned.value : fallback
587 return (
588 <Box key={key} flexDirection="row" gap={1}>
589 {/* Bold because it heads a section now; the fields under it stay dim. */}
590 <Box width={13}>
591 <Text bold>{label}</Text>
592 </Box>
593 {Button ? (
594 <Button
595 key={key}
596 label={isOn ? ' on ' : ' off'}
597 onPress={async () => {
598 if (!owned) {
599 $.ui.toast(`${label}: no settings row ends in ${key}`)
600 return
601 }
602 const { deny } = await $.config.set({ key: owned.key, value: !isOn })
603 $.ui.toast(deny ? `Refused: ${deny}` : `${label} ${isOn ? 'off' : 'on'}`)
604 }}
605 />
606 ) : (
607 <Text>{isOn ? 'on' : 'off'}</Text>
608 )}
609 <Text dimColor>{hint}</Text>
610 </Box>
611 )
612 }
613
614 const number = (key: string, label: string, hint: string, value: number, most?: number) => {
615 const owned = rows.find(one => one.key.endsWith(key))
616 const bad = (n: number) => !Number.isFinite(n) || n < 0 || (most !== undefined && n > most)
617 return (
618 <Box key={key} flexDirection="row" gap={1}>
619 <Box width={13}>
620 <Text dimColor>{label}</Text>
621 </Box>
622 {Input ? (
623 <Input
624 key={key}
625 value={String(value)}
626 placeholder={hint}
627 submitLabel="set"
628 onSubmit={async (text: string) => {
629 if (!owned) {
630 $.ui.toast(`${label}: no settings row ends in ${key}`)
631 return
632 }
633 const n = Number(text)
634 if (bad(n)) {
635 const range = most === undefined ? 'a number from 0' : `0 to ${most}`
636 $.ui.toast(`${label}: ${text} is not ${range}`)
637 return
638 }
639 const { deny } = await $.config.set({ key: owned.key, value: n })
640 $.ui.toast(deny ? `Refused: ${deny}` : `${label} saved`)
641 }}
642 />
643 ) : (
644 <Text>{String(value)}</Text>
645 )}
646 <Text dimColor>{hint}</Text>
647 </Box>
648 )
649 }
650
651 return (
652 <Box flexDirection="column" gap={1}>
653 {Svg ? (
654 <Svg
655 source={headerSvg(
656 {
657 tone: headline.bad ? 'warning' : 'good',
658 state: headline.text,
659 mode: limits.rulesMode === 'override' ? 'your rules only' : 'packaged + yours',
660 tokens: facts.tokens,
661 window: facts.window,
662 },
663 themeOf(rows.find(one => one.key === 'theme')?.value),
664 )}
665 alt={`${headline.text}. ${size ?? 'Context not measured yet'}.`}
666 width={420}
667 height={74}
668 />
669 ) : (
670 <Box flexDirection="row" gap={1}>
671 <Text color={headline.bad ? 'yellow' : undefined} dimColor={!headline.bad}>
672 {headline.text}
673 </Text>
674 {size && <Text dimColor>· context {size}</Text>}
675 </Box>
676 )}
677
678 <Box flexDirection="row" gap={1}>
679 <Text>{verdict.label}</Text>
680 {/* Truncated because the word limit in the prompt is a request, not a
681 guarantee, and one long answer would push the row off the pane. */}
682 {verdict.detail !== '' && (
683 <Text dimColor wrap="truncate-end">
684 {verdict.detail}
685 </Text>
686 )}
687 </Box>
688
689 <Box flexDirection="row" gap={1}>
690 <Text bold>Judge</Text>
691 <Text dimColor>{cost}</Text>
692 </Box>
693
694 <Box flexDirection="column">
695 <Text bold>Compacted</Text>
696 {past.length === 0 ? (
697 <Text dimColor>nothing this session</Text>
698 ) : (
699 past.map(one => (
700 <Box flexDirection="row" gap={1}>
701 <Box width={9}>
702 <Text dimColor>{ago(one.at, now)}</Text>
703 </Box>
704 <Text wrap="truncate-end">{one.reason}</Text>
705 </Box>
706 ))
707 )}
708 </Box>
709
710 {/* Each switch is its own section heading, and what it governs is
711 indented under it and gone when it is off: a field that cannot act is
712 worse than absent, because it reads as if it could. */}
713 <Box flexDirection="column" gap={1}>
714 {toggle('judgeEnabled', 'Judge', limits.judgeEnabled, 'asks at the end of every turn')}
715 {limits.judgeEnabled && (
716 <Box flexDirection="column" gap={1} paddingLeft={2}>
717 {number('askFromPercent', 'Judge from', '% of the window, 0 judges always', limits.askFromPercent, 100)}
718 <Box flexDirection="column" gap={1}>
719 <Box flexDirection="row" gap={1}>
720 <Text bold>Rules</Text>
721 {Select ? (
722 <Select
723 key="mode"
724 value={limits.rulesMode}
725 options={[
726 { value: 'append', label: 'added to the packaged ones' },
727 { value: 'override', label: 'instead of the packaged ones' },
728 ]}
729 onSelect={async (value: string) => {
730 const owned = rows.find(one => one.key.endsWith('rulesMode'))
731 if (!owned) return
732 const { deny } = await $.config.set({ key: owned.key, value })
733 $.ui.toast(deny ? `Refused: ${deny}` : `Rules are now ${value}`)
734 }}
735 />
736 ) : (
737 <Text dimColor>{limits.rulesMode}</Text>
738 )}
739 </Box>
740 {/* Framed, so an empty editor reads as a field and not as a
741 drawing fault. Sized to the text with a floor and a ceiling,
742 because a fixed height left a hole under one line. */}
743 <Box borderStyle="round" borderDimColor paddingX={1}>
744 {Client ? (
745 <Client
746 key="rules"
747 module="./editor.tsx"
748 props={{
749 text: mine,
750 saved: mine,
751 placeholder: 'when to compact, in your words',
752 }}
753 height={Math.min(14, Math.max(4, mine.split('\n').length + 2))}
754 width="100%"
755 />
756 ) : (
757 <Text dimColor>{mine === '' ? 'no rules of your own yet' : mine}</Text>
758 )}
759 </Box>
760 </Box>
761 </Box>
762 )}
763
764 {toggle('toolEnabled', 'MCP Tool', limits.toolEnabled, 'the agent can ask for a compaction')}
765
766 {/* Neither switch governs this one: it counts every tool the main loop
767 runs, so it sits beside them rather than under either. */}
768 {number('toolThreshold', 'After N calls', toolsNext === null ? 'tool calls, 0 is off' : `next at ${toolsNext}, now ${facts.tools}`, limits.toolThreshold)}
769 </Box>
770 </Box>
771 )
772 })
773}
774hooks/header.ts 86 lines1/**
2 * The pane's status header, drawn as SVG on the desktop surface.
3 *
4 * `Svg` is a desktop element and a leaf: it draws, it takes no input. So this is
5 * the display half only, and every control stays an element below it.
6 *
7 * Colours are the dataviz skill's status palette and ink tokens, not picked by eye.
8 * Status never carries meaning by colour alone, so the dot always has its label:
9 * `warning` measures 1.79 against a light surface, below the 3:1 bar, and the
10 * pairing is what makes that legal.
11 */
12
13/** Ink that differs by theme. The muted step is the one token identical in both. */
14const INK = {
15 dark: { primary: '#ffffff', secondary: '#c3c2b7', track: '#2c2c2a' },
16 light: { primary: '#0b0b0b', secondary: '#52514e', track: '#e1e0d9' },
17 /** Neither known: the mode-invariant step for everything, hierarchy by size. */
18 unknown: { primary: '#898781', secondary: '#898781', track: '#898781' },
19} as const
20
21const STATUS = { good: '#0ca30c', warning: '#fab219', critical: '#d03b3b' } as const
22const MUTED = '#898781'
23
24export type HeaderTone = 'good' | 'warning' | 'critical'
25export type HeaderTheme = keyof typeof INK
26
27export type Header = {
28 tone: HeaderTone
29 /** The state in words. Always drawn: a colour never carries this alone. */
30 state: string
31 /** Which rules are in force, as a chip on the right. */
32 mode: string
33 /** Context in use and the window, in tokens; null when not measured yet. */
34 tokens: number | null
35 window: number | null
36}
37
38const esc = (text: string): string =>
39 text.replace(/[<>&]/g, c => (c === '<' ? '<' : c === '>' ? '>' : '&'))
40
41const k = (n: number): string => `${Math.round(n / 1000)}K`
42
43const WIDTH = 420
44const HEIGHT = 74
45const PAD = 2
46
47/**
48 * The header as one SVG document.
49 * @param header what to say and how full the window is
50 * @param theme which ink to use; `unknown` degrades to the mode-invariant step
51 */
52export function headerSvg(header: Header, theme: HeaderTheme): string {
53 const ink = INK[theme]
54 const dot = STATUS[header.tone]
55 const share =
56 header.tokens !== null && header.window ? Math.min(1, header.tokens / header.window) : null
57 const trackWidth = WIDTH - PAD * 2
58 // 4px rounded ends anchored to the track, and never narrower than the cap, so a
59 // one-percent fill is still a mark rather than a sliver.
60 const fill = share === null ? 0 : Math.max(8, Math.round(trackWidth * share))
61 const size =
62 header.tokens !== null && header.window
63 ? `${k(header.tokens)} of ${k(header.window)} · ${Math.round((share ?? 0) * 100)}%`
64 : 'context not measured yet'
65
66 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${WIDTH} ${HEIGHT}" width="${WIDTH}" height="${HEIGHT}" font-family="system-ui,-apple-system,Segoe UI,sans-serif">
67 <circle cx="${PAD + 5}" cy="14" r="5" fill="${dot}"/>
68 <text x="${PAD + 18}" y="19" font-size="14" font-weight="600" fill="${ink.primary}">${esc(header.state)}</text>
69 <text x="${WIDTH - PAD}" y="18" font-size="11" text-anchor="end" fill="${MUTED}">${esc(header.mode)}</text>
70
71 <rect x="${PAD}" y="40" width="${trackWidth}" height="4" rx="2" fill="${ink.track}"/>
72 ${share === null ? '' : `<rect x="${PAD}" y="40" width="${fill}" height="4" rx="2" fill="${ink.secondary}"/>`}
73
74 <text x="${PAD}" y="64" font-size="11" fill="${MUTED}">${esc(size)}</text>
75</svg>`
76}
77
78/** Which ink to use, from the `theme` row of the settings menu. */
79export function themeOf(value: unknown): HeaderTheme {
80 const name = typeof value === 'string' ? value : ''
81 if (name.includes('light')) return 'light'
82 if (name.includes('dark')) return 'dark'
83 // `auto` and a custom theme say nothing this module can read.
84 return 'unknown'
85}
86prompts/tool.ts 16 lines1/**
2 * The tool's arguments. Code, not prose: a JSON schema has no business being a
3 * markdown file, so only the description beside it moved to `tool.md`.
4 */
5export const TOOL_INPUT = {
6 type: 'object',
7 properties: {
8 reason: { type: 'string', description: 'Why now, in a few words.' },
9 keep: {
10 type: 'string',
11 description: 'What the summary must carry forward for the work to continue.',
12 },
13 },
14 required: ['reason'],
15}
16hooks/editor.tsx 174 lines1/**
2 * A multi-line text editor, because the engine has no textarea.
3 *
4 * `Input` is one line and submits on Enter, which is the wrong control for a set of
5 * rules written in paragraphs. A `Client` draws with the same elements as the host
6 * tree, so nothing here is HTML, but it owns two things the host does not: the raw
7 * keyboard while focused, and local state that survives the host's redraws. Those
8 * are exactly what a text buffer needs.
9 *
10 * Click the region to type in it. Escape hands the keyboard back and never reaches
11 * `onKey`, so there is no way to trap a person inside the editor.
12 */
13import type { ClientModule, ClientKeyEvent } from 'claude-code'
14
15type Props = { text: string; saved: string; placeholder: string }
16type State = {
17 lines: string[]
18 /** The caret, as a line index and a column within that line. */
19 row: number
20 col: number
21 /** The first line drawn, so a buffer taller than the region scrolls. */
22 top: number
23}
24
25function start(text: string): State {
26 const lines = text.split('\n')
27 return { lines, row: lines.length - 1, col: (lines.at(-1) ?? '').length, top: 0 }
28}
29
30/** The state after one key. Returns null for a key this editor does not handle. */
31function typed(state: State, key: ClientKeyEvent): State | null {
32 const { lines, row, col } = state
33 const line = lines[row] ?? ''
34 const at = (r: number, text: string) => lines.map((l, i) => (i === r ? text : l))
35
36 if (key.ctrl || key.meta) return null // a modified key is never text; onKey takes ctrl+s
37
38 switch (key.key) {
39 case 'left':
40 if (col > 0) return { ...state, col: col - 1 }
41 if (row > 0) return { ...state, row: row - 1, col: (lines[row - 1] ?? '').length }
42 return state
43 case 'right':
44 if (col < line.length) return { ...state, col: col + 1 }
45 if (row < lines.length - 1) return { ...state, row: row + 1, col: 0 }
46 return state
47 case 'up':
48 if (row === 0) return { ...state, col: 0 }
49 return { ...state, row: row - 1, col: Math.min(col, (lines[row - 1] ?? '').length) }
50 case 'down':
51 if (row === lines.length - 1) return { ...state, col: line.length }
52 return { ...state, row: row + 1, col: Math.min(col, (lines[row + 1] ?? '').length) }
53 case 'home':
54 return { ...state, col: 0 }
55 case 'end':
56 return { ...state, col: line.length }
57 case 'return': {
58 // Enter inserts a line here. It does not submit: that is the whole point of
59 // not using an Input.
60 const before = line.slice(0, col)
61 const after = line.slice(col)
62 const next = [...lines.slice(0, row), before, after, ...lines.slice(row + 1)]
63 return { ...state, lines: next, row: row + 1, col: 0 }
64 }
65 case 'backspace': {
66 if (col > 0) {
67 return { ...state, lines: at(row, line.slice(0, col - 1) + line.slice(col)), col: col - 1 }
68 }
69 if (row === 0) return state
70 const previous = lines[row - 1] ?? ''
71 const next = [...lines.slice(0, row - 1), previous + line, ...lines.slice(row + 1)]
72 return { ...state, lines: next, row: row - 1, col: previous.length }
73 }
74 case 'delete': {
75 if (col < line.length) {
76 return { ...state, lines: at(row, line.slice(0, col) + line.slice(col + 1)) }
77 }
78 if (row === lines.length - 1) return state
79 const next = [...lines.slice(0, row), line + (lines[row + 1] ?? ''), ...lines.slice(row + 2)]
80 return { ...state, lines: next }
81 }
82 case 'tab':
83 return { ...state, lines: at(row, line.slice(0, col) + ' ' + line.slice(col)), col: col + 2 }
84 default:
85 // One printable character. Anything longer is a named key this editor
86 // does not handle, and letting it through would write "pagedown" into
87 // the buffer.
88 if (key.key.length !== 1) return null
89 return { ...state, lines: at(row, line.slice(0, col) + key.key + line.slice(col)), col: col + 1 }
90 }
91}
92
93/** Scroll so the caret is inside the visible window. */
94function scrolled(state: State, rows: number): State {
95 const room = Math.max(1, rows)
96 if (state.row < state.top) return { ...state, top: state.row }
97 if (state.row >= state.top + room) return { ...state, top: state.row - room + 1 }
98 return state
99}
100
101export const Editor: ClientModule<Props, State> = (props, surface) => {
102 const { Box, Text } = surface.elements
103 const state = surface.state ?? start(props.text)
104
105 // Set once, while there is no state yet: a listener set on every call would
106 // replace itself each frame for nothing.
107 if (surface.state === undefined) {
108 surface.onKey(key => {
109 const current = surface.state ?? start(props.text)
110 if ((key.ctrl || key.meta) && key.key === 's') {
111 surface.post({ kind: 'save', text: current.lines.join('\n') })
112 return
113 }
114 const next = typed(current, key)
115 if (next !== null) surface.setState(scrolled(next, surface.rows - 1))
116 })
117 }
118
119 const text = state.lines.join('\n')
120 const isDirty = text !== props.saved
121 const isEmpty = text === ''
122 const room = Math.max(1, surface.rows - 1)
123 const visible = state.lines.slice(state.top, state.top + room)
124 const lines = state.lines.length
125 const count = `${lines} ${lines === 1 ? 'line' : 'lines'}`
126
127 // Nothing written yet: say what to write rather than show one blank row that
128 // reads as a drawing fault.
129 const body = isEmpty
130 ? [
131 Box({
132 flexDirection: 'row',
133 children: [
134 Text({ inverse: true, children: [' '] }),
135 Text({ dimColor: true, children: [props.placeholder] }),
136 ],
137 }),
138 ]
139 : visible.map((line, i) => {
140 const index = state.top + i
141 if (index !== state.row) {
142 return Text({ children: [line === '' ? ' ' : line], wrap: 'truncate-end' })
143 }
144 // The caret line, in three pieces so the cell under the caret can be
145 // inverted. An inverted space is the caret; inverting a block character
146 // too drew a stray mark that read as a glitch.
147 const before = line.slice(0, state.col)
148 const on = line.slice(state.col, state.col + 1) || ' '
149 const after = line.slice(state.col + 1)
150 return Box({
151 flexDirection: 'row',
152 children: [
153 Text({ children: [before] }),
154 Text({ inverse: true, children: [on] }),
155 Text({ children: [after] }),
156 ],
157 })
158 })
159
160 return Box({
161 flexDirection: 'column',
162 children: [
163 ...body,
164 Text({
165 dimColor: !isDirty,
166 color: isDirty ? 'yellow' : undefined,
167 children: [isDirty ? `${count} · unsaved · ctrl+s` : `${count} · click to type`],
168 }),
169 ],
170 })
171}
172
173export default Editor
174types/index.d.ts 87 lines1/**
2 * The tool `cc-strategic-compaction` registers, and the values its pane draws from.
3 *
4 * It adds no noun to `$`. The one way in is the tool below, so that a compaction is
5 * always something the model asked for in its own turn, where it knows what it has
6 * just written down.
7 *
8 * Self-contained on purpose: no import, no reference. A plugin that reads this never
9 * copies the file; `/plugin-types` rolls every enabled plugin's contract into
10 * `.claude/types`.
11 */
12
13/** The counters the numeric triggers are measured against. */
14export type CompactionCounts = {
15 /** Tool calls on the main loop this session. */
16 tools: number
17 /** Tool count as it stood at the last compaction; 0 before the first. */
18 toolsAtLast: number
19}
20
21/**
22 * What the judge's forks have cost, summed over the session. Tokens and not money:
23 * the engine reports no price, and a table of them here would go stale unseen.
24 */
25export type CompactionSpend = {
26 /** Forks made, the failed ones counted: those are billed too. */
27 calls: number
28 /** Input tokens billed at full rate or more: uncached, plus cache writes. */
29 input: number
30 output: number
31 /**
32 * Input tokens the prompt cache served, billed at a fraction.
33 *
34 * Worth watching rather than merely recording: the judge is affordable only
35 * because it forks a prefix the main thread already paid to cache. Near zero and
36 * every call is buying the whole transcript again.
37 */
38 cached: number
39}
40
41/** One compaction that ran. The list holds them newest first. */
42export type CompactionRecord = {
43 /** Epoch milliseconds. */
44 at: number
45 /** Why it ran: the judge's conclusion, the model's own words, or a trigger. */
46 reason: string
47}
48
49/** The last answer the judge gave, kept so the pane can show why it stands. */
50export type CompactionJudgement = {
51 /** Epoch milliseconds. */
52 at: number
53 /** Whether that answer let a compaction through. */
54 isReady: boolean
55 /** What the summary must keep; empty on a hold, which names nothing. */
56 line: string
57 /** Holds in a row, reset by a ready. One says nothing; twenty say the rules
58 * never let anything through, which is the failure worth seeing. */
59 holds: number
60}
61
62/**
63 * What the model passes when it arms a compaction on itself. Declaring it here is
64 * what adds the name to the engine's tool union, so a `tool.call` matcher narrows
65 * on it and the hook reads the arguments typed.
66 */
67export type CompactionToolInput = {
68 /** Why now, in a few words. */
69 reason: string
70 /** What the summary must carry forward for the work to continue. */
71 keep?: string
72}
73
74declare module 'claude-code' {
75 interface McpToolInputs {
76 'mcp__cc-strategic-compaction__compact': CompactionToolInput
77 }
78 interface PluginState {
79 'cc-strategic-compaction': {
80 counts: CompactionCounts
81 judgement: CompactionJudgement | null
82 history: CompactionRecord[]
83 spend: CompactionSpend
84 }
85 }
86}
87