SLOPSHOPPER

cc-strategic-compaction

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

newpaneguardcommandtoasttool
★ 3v0.25.0no licenseupdated 2026-10-08sergiopalacio/cc-strategic-compaction
A shopper browsing a rack in a slop shop
README

Claude Strategic Compaction Mod

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.

When it fires

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:

  • Judge — at the end of a turn, from the rules in hooks/rules.ts plus your own.
  • MCP Tool — 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.

Settings

keydefaultwhat it does
judgeEnabledtrueask the judge at the end of each turn
toolEnabledtrueregister the tool the agent calls on itself
askFromPercent0percent of the window below which nothing is judged
compactWhen""your rules, in your own words
rulesModeappendappend adds yours to the packaged rules, override replaces them
toolThreshold0compact after N tool calls of any kind. Unrelated to toolEnabled. 0 is off, and worth leaving off
toolInterval25further calls between those compactions

Switching toolEnabled off mid-session cannot unregister the tool, so a call is refused instead until the next session.

Development

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.

Source 5 files
hooks/register.tsx 774 lines
1/**
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}
774
hooks/header.ts 86 lines
1/**
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 === '<' ? '&lt;' : c === '>' ? '&gt;' : '&amp;'))
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}
86
prompts/tool.ts 16 lines
1/**
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}
16
hooks/editor.tsx 174 lines
1/**
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
174
types/index.d.ts 87 lines
1/**
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