SLOPSHOPPER

smartcompact

A Claude Code mod that compacts the conversation at a good moment, asked for by the session after a nudge or chosen by a small judge model, and then tells…

newcommandstatuspromptmodeltimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · smartcompact
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /smartcompact-prompt ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ smartcompact: error 08:53
README

smartcompact

A Claude Code mod that compacts the conversation at a good moment and then tells Claude to keep working. The repo README covers install and requirements.

claude plugin marketplace add ambervdberg/smartcompact
claude plugin install smartcompact@smartcompact

What it does

During a turn above the token floor, the plugin adds a hidden note after a tool result of the main conversation. The note asks Claude to compact at the next good moment: start no new subagents, let the running ones finish, then end the answer with the compact tag. See The nudge.

After each turn of the main conversation:

  1. When the answer ends with the compact tag, the session asked for a compaction itself. The judge is not asked. See When the session asks.
  2. It checks the context size. Below the token floor it does nothing.
  3. It waits while subagents or teammates still run.
  4. It asks a small judge model whether this is a good moment to compact. A good moment is a finished piece of work, for example when Claude offers to push or to start the next task. The turn never waits for the judge.
  5. On a yes it checks that no new turn and no new subagent started meanwhile. Then it waits until the prompt box is empty and no dialog is open, and runs a compaction with the instructions Keep the current goal, the next step and open decisions.
  6. After its own compaction it sends the continue prompt, so Claude goes on with the work. It never sends one after a compaction it did not start, such as /compact typed by hand or Claude Code's own auto-compact.

If you start a new turn before the compaction runs, the plugin drops it. The next finished turn is judged again. A dialog that stays open for two minutes also drops it.

Token floor

The floor (minTokens, 60k by default) counts only the tokens added since a starting point, not the whole context:

  • The starting point is the first turn of the session, the first turn after a /clear, or the first turn after a compaction.
  • That first turn itself is never judged.
  • So a new session does not count its startup context, and a compaction cannot follow right after another one.
  • A resumed or respawned session keeps its saved count. A session that started before the count was saved counts from 0.

A line under the prompt box shows what it is doing, for example smartcompact: judge in 48k. It sits next to Claude Code's own notices. A custom statusLine script does not show it.

The status line during one compaction: judge in 15k, nudged, compacting, continued

StatusMeaning
judge in 48kBelow the token floor. The judge is asked once the conversation has grown 48k more tokens. A new session shows the full floor until its first turn ends.
judge after next turnThe floor is reached. The judge is asked when the next turn ends.
waiting for subagentsSubagents still run, so the judge was not asked.
nudged 14:02The session was asked to compact at the next good moment.
judging...The judge model is asked.
keep going 14:02The judge said this is no good moment to compact.
will compact 14:02The judge said yes or the session asked. The compaction follows when the session is free.
yes cancelled 14:02The judge said yes, but a new turn or subagent started meanwhile.
request ignored 14:02The session asked, but subagents still ran or the loop guard held. The log says why.
waiting for empty promptA compaction waits until you empty the prompt box.
waiting for idleA compaction waits for a dialog to close or for the session to be free.
compacting..., compacted 14:03The compaction runs or has finished.
compact skipped 14:03Claude Code skipped the compaction, for example because a hook blocked it.
continued 14:03The continue prompt was sent.
dropped 14:03A new turn started or the session stayed busy, so the compaction was dropped.
error 14:02The judge or a session's request failed. The log says why.

When the session asks

The plugin adds a short rule to the context of each conversation, as a block named smartcompact. Claude Code renders it with the first message and again after each compaction or /clear. It tells Claude to end its answer with <smartcompact>the prompt for the next piece</smartcompact> when a piece of work is done, the next piece of the same task can start right away and no longer needs the details so far, and then to stop. It leaves the tag out when it waits for other sessions or agents, or when all work is done. It also leaves it out when it waits for the user, unless smartcompact asked it to compact. It never writes an empty tag. Subagents get the block too, so it ends with Only the main session does this, never a subagent.

Only a tag at the very end of a main answer counts. Whitespace may follow it. A tag earlier in the text and a tag from a subagent do nothing. An empty or blank tag is ignored too, and the turn goes to the judge. When the main answer ends with the tag, the judge is not asked and the first match below runs:

  1. Subagents still run: the request is ignored and logged. A finishing subagent wakes the session, so it never hangs.
  2. Loop guard: the request is ignored and logged as loop guard when this turn came right after a continue prompt the plugin sent without a compaction. Without it a session that tags again at once loops. The guard holds in two cases:
  3. The previous continue prompt followed a compaction Claude Code skipped. This holds at any token count.
  4. The previous continue prompt was sent below the floor, and this turn is below the floor too.
  5. Below the token floor: nothing is compacted and the continue prompt is sent at once. The first turn of a new session and the first turn after a compaction always count as below the floor.
  6. Else the compaction waits for an empty prompt box and a closed dialog, as after a judge yes. The instructions are Keep the current goal, open decisions and what this next step needs: <text>. When Claude Code skips the compaction, the continue prompt is still sent.

The text in the tag fills {next} in the continue prompt.

The nudge

Above the token floor, a tool result of the main conversation can be followed by a hidden note. The note reads:

smartcompact: this conversation grew 64k tokens since smartcompact started counting. Plan a compaction at the next good moment. Start no new subagents and let the running ones finish. Then end your answer with <smartcompact>the prompt to start the next piece with</smartcompact>. Do this also when you would wait for the user, and put your question in the tag. Leave the tag out when the user must confirm an action that is hard to undo, such as a push, a delete or a publish.

After a nudge, a question for you goes into the tag. After the compaction the shipped continue prompt asks Claude to decide it. Your own continue prompt can ask Claude to write that choice to the choices file. A yes or no on an action that is hard to undo stays a normal stop for you to answer.

The number is the tokens added since the floor started counting, in thousands. The first note comes with the first tool result above the floor. The next one comes each time the conversation grows another half floor, at least 10k. The count starts over after a compaction, a /clear or a new session. There is no note for subagents and none while the floor waits for its first turn. The status line shows nudged 14:02 and the log has a nudge-sent line. A refused note is logged as nudge-error.

Options

Set them with /plugin configure smartcompact@smartcompact in a session, or in settings.json:

{ "pluginConfigs": { "smartcompact@smartcompact": { "options": { "minTokens": 80000 } } } }

A plugin loaded with --plugin-dir uses the key smartcompact instead of smartcompact@smartcompact.

OptionDefaultWhat it does
minTokens60000The token floor: tokens added before the judge is asked or a session's request compacts.
claudeModelhaikuModel alias or id for the judge. It runs on your own Claude login and needs no setup.

Continue prompt

The plugin sends a continue prompt after its own compaction and after a tag below the token floor. It reads the text from a file each time it sends it, so an edit works at once:

  • ~/.claude/smartcompact/continue-prompt.md when it exists. This is your own copy. An empty file turns the continue prompt off.
  • Else continue-prompt.md in the plugin folder, the shipped default.

Run /smartcompact-prompt to copy the default to your own file. It prints the path and says when it just made the file. A file that exists stays as it is.

When nothing was compacted, a first line Context was compacted automatically. is left out. Any other first line stays.

The plugin fills these placeholders when it sends the prompt, each time they occur.

PlaceholderReplaced with
{next}The text in the session's tag. After a judge yes: Continue where you left off. If the next step is clear, do it. Keep it in your own file, else the session's prompt for the next piece is lost.
{choicesFile}The absolute path of the choices file, in the platform's own spelling.
{choicesHeading}One line `## <ISO time> \<cwd> \<session id>`, with the time of sending.

Choices file

Your own continue prompt can ask Claude to write down the choices it made for you in one central file, ~/.claude/smartcompact/choices.md. It sits in the same folder as the log, so SMARTCOMPACT_LOG moves it too. For example:

Append your choices to {choicesFile}. Create the file if it is missing and never overwrite earlier text. Start with this line:
{choicesHeading}
Then write one bullet per choice you made for me, with what you chose and why.

A filled heading looks like ## 2026-10-04T10:00:00.000Z | C:/work | session-1. Each continue adds one block that starts with this line, followed by one bullet per choice. A dashboard can split the file on lines that start with ## .

Log

Each decision is written as one JSON line to ~/.claude/smartcompact/log.jsonl (SMARTCOMPACT_LOG picks another file). It stays on your machine and keeps only its newest 2 MB. The log keeps no text from the conversation. A line holds the event, the time and details such as token counts, the judge's short reason or an error message.

What it reads, sends and writes

The mod keeps no text from your conversation and sends nothing to the author or to any server of its own. The only call that leaves the session is the judge, on your own Claude login. It needs no account, key or setup.

Sends to the judge. After a turn above the token floor, the judge model gets:

  • the last prompt of the person, its first 2000 characters
  • the end of the last answer, its last 4000 characters
  • a one-line summary of the last 40 tool calls since that prompt, 150 characters each

The judge runs through the engine's own model call on your Claude login (claudeModel, Haiku by default). Nothing else leaves the session.

Submits as a prompt. Only the continue prompt, after its own compaction or after a tag below the token floor. It is the text of your own continue-prompt.md or the shipped default, with the placeholders filled: the text of the session's tag, the path of the choices file and a heading line with the time, folder and session id.

Changes in the session.

  • prompt.context adds one block named smartcompact with the rule from When the session asks. It changes no other block.
  • session.append adds the nudge as a hidden row to the main conversation. A subagent gets none.
  • command.run handles only its own command, /smartcompact-prompt.
  • It runs /compact with the instructions shown in What it does.

Writes two files, both in its own folder:

  • ~/.claude/smartcompact/log.jsonl, or the file SMARTCOMPACT_LOG names. One line per decision, with the token count and for a judge call the judge's short reason. It keeps no text from the conversation. See Log.
  • ~/.claude/smartcompact/continue-prompt.md, only when you run /smartcompact-prompt and the file does not exist. It is a copy of the shipped default, for you to edit.

It edits no settings, instructions, hooks, build or start-up file. It reads only its own files, the session's messages and token count, the prompt box and the list of running subagents.

Development

claude plugin validate .
claude plugin test .
npx -p typescript tsc -p .

Claude Code writes the API types to .claude-plugin/types/ when it loads the plugin from a folder. tsc needs them there. The tests cannot read the disk, so tests/fake-engine.ts holds a copy of continue-prompt.md. Keep the two the same.

Source 28 files
hooks/register.ts 184 lines
1import type { EngineInterface, Register, TurnCompleteInput } from 'claude-code';
2import { readCompactRequest } from './compact-request.ts';
3import { CompactNudge } from './compact-nudge.ts';
4import { withCompactRule } from './compact-rule.ts';
5import { ContextFloor } from './context-floor.ts';
6import type { Engine } from './engine.ts';
7import { messageOf } from './error-message.ts';
8import { logEvent } from './event-log.ts';
9import { startFloor } from './floor-start.ts';
10import { saveFloorState } from './floor-store.ts';
11import { PendingCompaction } from './pending-compaction.ts';
12import { settingsFrom } from './plugin-settings.ts';
13import { PROMPT_COMMAND, createOverrideIfMissing } from './prompt-command.ts';
14import { RequestFollowing } from './request-following.ts';
15import { showJudgeCountdown } from './status-line.ts';
16import { TurnCounter } from './turn-counter.ts';
17import { TurnJudging } from './turn-judging.ts';
18
19/** Wires the engine events to the judging, the nudge, the pending compaction and the turn count. */
20export const register: Register = (on, options) => {
21  const settings = settingsFrom(options);
22  const turns = new TurnCounter();
23  const floor = new ContextFloor();
24  const compaction = new PendingCompaction(turns, floor);
25  const judging = new TurnJudging(settings, turns, floor, compaction);
26  const requests = new RequestFollowing(settings, turns, floor, compaction);
27  const nudge = new CompactNudge(settings, floor);
28
29  // Without the countdown the line stays empty until the first turn ends.
30  on('session.start', async ($, e, next) => {
31    const engine = engineOf($);
32
33    await startFloor(engine, floor);
34    nudge.startCountOver();
35
36    const tokens = (await engine.usage()).context.tokens ?? 0;
37
38    showJudgeCountdown(engine, settings.minTokens - floor.addedTokens(tokens));
39    await registerPromptCommand(engine);
40
41    return next(e);
42  });
43
44  // Every main loop turn counts: typed, queued, from a task notification or from this plugin.
45  on('turn.start', ($, e, next) => {
46    turns.noteTurnStarted();
47    void compaction.drop(engineOf($), 'new turn started');
48
49    return next(e);
50  });
51
52  // The judge or the tag's request runs after the turn has settled, so the turn never waits on it.
53  on('turn.complete', async ($, e, next) => {
54    const turnsAtEnd = turns.count();
55    const completed = await next(e);
56
57    if (isMainLoopAnswer(e)) {
58      const turn = { answer: e.answer, turnsAtEnd };
59      const request = readCompactRequest(e.answer);
60
61      void (request === null
62        ? judging.judgeTurn(engineOf($), turn)
63        : requests.followRequest(engineOf($), turn, request));
64    }
65
66    return completed;
67  });
68
69  // Awaited, so the nudge row is stored before the engine sends its next request. The nudge itself comes in by
70  // door `note` and never fires this hook again.
71  on('session.append', { door: 'tool-result' }, async ($, e, next) => {
72    const stored = await next(e);
73
74    if (e.agentId === undefined) {
75      await nudge.nudgeIfDue(engineOf($));
76    }
77
78    return stored;
79  });
80
81  on('prompt.edit', async ($, e, next) => {
82    const edited = await next(e);
83
84    compaction.retryAfterEdit(engineOf($), edited.text);
85
86    return edited;
87  });
88
89  // The engine renders the blocks again after a compaction or /clear, so the session always has the rule.
90  on('prompt.context', async (_$, e, next) => {
91    const context = await next(e);
92
93    return { ...context, blocks: withCompactRule(context.blocks) };
94  });
95
96  on('command.run', { command: PROMPT_COMMAND.name }, async ($) => ({
97    text: await createOverrideIfMissing(engineOf($)),
98  }));
99
100  // A /compact typed by hand or Claude's own auto-compact already did the work.
101  on('session.compact', async ($, e, next) => {
102    if (e.agentId === undefined && (e.trigger === 'manual' || e.trigger === 'auto')) {
103      const engine = engineOf($);
104      const tokens = (await engine.usage()).context.tokens ?? 0;
105
106      logEvent(engine, 'compact-other', { trigger: e.trigger, tokens });
107      await compaction.drop(engine, `${e.trigger} compact`);
108    }
109
110    const compacted = await next(e);
111
112    if (e.agentId === undefined) {
113      floor.restartAfterCompaction();
114      await saveFloorState(engineOf($), floor);
115    }
116
117    return compacted;
118  });
119
120  // A /clear fires no session.start, so the floor starts over here.
121  on('session.end', async ($, e, next) => {
122    await compaction.drop(engineOf($), `session ${e.reason}`);
123
124    if (e.reason === 'clear') {
125      floor.startNewSession();
126      nudge.startCountOver();
127    }
128
129    return next(e);
130  });
131};
132
133// A registered command lasts one session, so each start registers it again. A refusal must not stop the start.
134async function registerPromptCommand(engine: Engine): Promise<void> {
135  try {
136    await engine.registerCommand(PROMPT_COMMAND);
137  } catch (error) {
138    logEvent(engine, 'command-error', { message: messageOf(error) });
139  }
140}
141
142// A subagent's turn, an interrupt or an API error is no moment to judge or to follow a tag.
143function isMainLoopAnswer(e: TurnCompleteInput): boolean {
144  return e.agentId === undefined && e.reason === 'answer';
145}
146
147/** Binds every engine call the plugin makes. Each one is spelled `$.noun.event(...)`, as the engine requires. */
148function engineOf($: EngineInterface): Engine {
149  return {
150    pluginRoot: () => $.plugin.root,
151    now: () => $.clock.now(),
152    after: (ms, fn) => $.clock.after(ms, fn),
153    // The engine puts the plugin name in front by itself.
154    status: (text) => $.ui.status(text),
155    sessionId: () => $.session.id(),
156    cwd: () => $.session.cwd(),
157    usage: () => $.session.usage(),
158    messages: () => $.session.messages(),
159    agents: () => $.agent.list(),
160    readPrompt: () => $.prompt.read(),
161    fillPrompt: (input) => $.prompt.fill(input),
162    submitPrompt: (input) => $.prompt.submit(input),
163    appendNote: (text) => $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } }),
164    compact: (args) => $.session.compact(args),
165    registerCommand: (command) => $.command.register(command),
166    complete: (request) => $.model.complete(request),
167    fs: {
168      exists: (path) => $.fs.exists(path),
169      read: (path) => $.fs.read(path),
170      write: (path, text) => $.fs.write(path, text),
171    },
172    store: {
173      get: (key) => $.store.get(key),
174      set: (key, value) => $.store.set(key, value),
175      delete: (key) => $.store.delete(key),
176    },
177    env: {
178      logFile: () => $.env.get('SMARTCOMPACT_LOG'),
179      userProfile: () => $.env.get('USERPROFILE'),
180      home: () => $.env.get('HOME'),
181    },
182  };
183}
184
hooks/compact-request.ts 37 lines
1const OPEN_TAG = '<smartcompact>';
2const CLOSE_TAG = '</smartcompact>';
3
4/** A session's own request to compact: the prompt it wants for the next piece of work. */
5export type CompactRequest = {
6  next: string;
7};
8
9/**
10 * Reads the compact tag that ends an answer. Whitespace may follow it.
11 * With two tags only the last one counts, and only when it ends the answer.
12 */
13export function readCompactRequest(answer: string): CompactRequest | null {
14  const text = answer.trimEnd();
15
16  // A tag earlier in the text, such as one the session quotes, asks nothing.
17  if (!text.endsWith(CLOSE_TAG)) {
18    return null;
19  }
20
21  const body = text.slice(0, -CLOSE_TAG.length);
22  const start = body.lastIndexOf(OPEN_TAG);
23
24  if (start === -1) {
25    return null;
26  }
27
28  const next = body.slice(start + OPEN_TAG.length).trim();
29
30  // A tag without a prompt has nothing to continue with, so the turn is judged like an untagged one.
31  if (next === '') {
32    return null;
33  }
34
35  return { next };
36}
37
hooks/compact-nudge.ts 118 lines
1import type { ContextFloor } from './context-floor.ts';
2import type { Engine } from './engine.ts';
3import { messageOf } from './error-message.ts';
4import { logEvent } from './event-log.ts';
5import type { Settings } from './plugin-settings.ts';
6import { showTimedStatus } from './status-line.ts';
7
8// A floor of 0 for a live test would else nudge after every tool call.
9const MIN_STEP_TOKENS = 10_000;
10
11/** How every nudge row starts. The judge input skips rows that start this way. */
12export const NUDGE_TEXT_START = 'smartcompact: ';
13
14/** The row that asks the session to pick a moment to compact, with the added tokens in thousands. */
15export function nudgeText(addedTokens: number): string {
16  const added = `${Math.floor(addedTokens / 1000)}k`;
17
18  return [
19    `${NUDGE_TEXT_START}this conversation grew ${added} tokens since smartcompact started counting.`,
20    'Plan a compaction at the next good moment.',
21    'Start no new subagents and let the running ones finish.',
22    'Then end your answer with <smartcompact>the prompt to start the next piece with</smartcompact>.',
23    'Do this also when you would wait for the user, and put your question in the tag.',
24    'Leave the tag out when the user must confirm an action that is hard to undo,',
25    'such as a push, a delete or a publish.',
26  ].join(' ');
27}
28
29/**
30 * Asks the main session to compact once the floor is passed, and again after each further step.
31 * The session picks the moment itself, so a session that always runs subagents still compacts.
32 */
33export class CompactNudge {
34  #settings: Settings;
35  #floor: ContextFloor;
36  /** The added tokens at the last nudge since the count started over, undefined before the first. */
37  #lastNudgeAt: number | undefined;
38  /** The floor baseline at the last nudge. A different baseline means the floor restarted. */
39  #baselineAtNudge: number | undefined;
40  #nudges = 0;
41  #isChecking = false;
42
43  constructor(settings: Settings, floor: ContextFloor) {
44    this.#settings = settings;
45    this.#floor = floor;
46  }
47
48  /** Runs after a main tool result is stored. It never throws: a failure is logged as `nudge-error`. */
49  async nudgeIfDue(engine: Engine): Promise<void> {
50    // Parallel tool calls store several results at once. One check is enough for all of them.
51    if (this.#isChecking) {
52      return;
53    }
54
55    this.#isChecking = true;
56
57    try {
58      await this.#nudgeIfDue(engine);
59    } catch (error) {
60      logEvent(engine, 'nudge-error', { message: messageOf(error) });
61    } finally {
62      this.#isChecking = false;
63    }
64  }
65
66  async #nudgeIfDue(engine: Engine): Promise<void> {
67    const tokens = (await engine.usage()).context.tokens ?? 0;
68    const added = this.#floor.addedTokens(tokens);
69
70    if (!this.#isDue(added)) {
71      return;
72    }
73
74    const appended = await engine.appendNote(nudgeText(added));
75
76    if (appended.deny !== undefined) {
77      logEvent(engine, 'nudge-error', { message: appended.deny });
78
79      return;
80    }
81
82    this.#lastNudgeAt = added;
83    this.#baselineAtNudge = this.#floor.baseline();
84    this.#nudges += 1;
85    logEvent(engine, 'nudge-sent', { tokens, added, nudge: this.#nudges });
86    await showTimedStatus(engine, 'nudged');
87  }
88
89  #isDue(added: number): boolean {
90    // A compaction or /clear restarts the floor, so the nudges count from 1 again.
91    if (this.#floor.state().waitingFor !== null || this.#hasBaselineMoved()) {
92      this.startCountOver();
93    }
94
95    if (this.#floor.state().waitingFor !== null || added < this.#settings.minTokens) {
96      return false;
97    }
98
99    return this.#lastNudgeAt === undefined || added - this.#lastNudgeAt >= this.#step();
100  }
101
102  // The floor takes a new baseline on its first turn after a compaction, a /clear or a new session.
103  #hasBaselineMoved(): boolean {
104    return this.#baselineAtNudge !== undefined && this.#floor.baseline() !== this.#baselineAtNudge;
105  }
106
107  #step(): number {
108    return Math.max(Math.floor(this.#settings.minTokens / 2), MIN_STEP_TOKENS);
109  }
110
111  /** Forgets the last nudge, so the next one is number 1. */
112  startCountOver(): void {
113    this.#lastNudgeAt = undefined;
114    this.#baselineAtNudge = undefined;
115    this.#nudges = 0;
116  }
117}
118
hooks/compact-rule.ts 20 lines
1import type { PromptContextBlock } from 'claude-code';
2
3const RULE_BLOCK_NAME = 'smartcompact';
4
5// No "fresh context" (it sounds like /clear) and no summary by the plugin: it only starts /compact.
6const RULE_TEXT = [
7  'When you finish a piece of work and can start the next piece of the same task right away, and that piece no longer needs the details so far, end your answer with:',
8  '<smartcompact>the prompt to start the next piece with</smartcompact>',
9  'Then stop. The smartcompact plugin runs `/compact` and sends that prompt afterwards.',
10  'Leave the tag out when you wait for other sessions or agents, or when all work is done. Leave it out when you wait for the user too, unless smartcompact asked you to compact.',
11  // Sessions copied the tag pattern and wrote an empty tag to mean "no compaction".
12  'Never write an empty tag.',
13  'Only the main session does this, never a subagent.',
14].join('\n');
15
16/** The context blocks with the smartcompact rule last. A block of that name from below is replaced. */
17export function withCompactRule(blocks: readonly PromptContextBlock[]): PromptContextBlock[] {
18  return [...blocks.filter((block) => block.name !== RULE_BLOCK_NAME), { name: RULE_BLOCK_NAME, text: RULE_TEXT }];
19}
20
hooks/context-floor.ts 68 lines
1/** Why a first turn is not judged. The text goes into the log as the skip reason. */
2export type FirstTurnReason = 'first turn of session' | 'first turn after compaction';
3
4/** What the floor keeps between restarts. */
5export type FloorState = {
6  baseline: number;
7  waitingFor: FirstTurnReason | null;
8};
9
10/**
11 * Measures the tokens a conversation added since its first turn, or since the first turn after its last compaction.
12 * A resumed session from before the floor was saved counts from 0, so it keeps what it already holds.
13 */
14export class ContextFloor {
15  #baseline = 0;
16  #waitingFor: FirstTurnReason | null = null;
17
18  /**
19   * Returns why the turn is skipped when it is the awaited first turn, and makes its size the new baseline.
20   * Every other turn changes nothing and gets undefined.
21   */
22  takeFirstTurn(tokens: number): FirstTurnReason | undefined {
23    if (this.#waitingFor === null) {
24      return undefined;
25    }
26
27    const reason = this.#waitingFor;
28
29    this.#baseline = tokens;
30    this.#waitingFor = null;
31
32    return reason;
33  }
34
35  /** A new session already holds its startup context, so the first turn's size becomes the baseline. */
36  startNewSession(): void {
37    this.#waitingFor = 'first turn of session';
38  }
39
40  /** After any compaction the next turn's size becomes the baseline. */
41  restartAfterCompaction(): void {
42    this.#waitingFor = 'first turn after compaction';
43  }
44
45  /** Takes over a state saved for the same session. */
46  restore(state: FloorState): void {
47    this.#baseline = state.baseline;
48    this.#waitingFor = state.waitingFor;
49  }
50
51  state(): FloorState {
52    return { baseline: this.#baseline, waitingFor: this.#waitingFor };
53  }
54
55  baseline(): number {
56    return this.#baseline;
57  }
58
59  /** Zero while the first turn is awaited. Never below zero: after a compaction the context can drop under the baseline. */
60  addedTokens(tokens: number): number {
61    if (this.#waitingFor !== null) {
62      return 0;
63    }
64
65    return Math.max(0, tokens - this.#baseline);
66  }
67}
68
hooks/engine.ts 58 lines
1import type {
2  AgentInfo,
3  CommandSpec,
4  ModelCompleteRequest,
5  ModelCompleteResult,
6  PromptBox,
7  PromptFillArgs,
8  PromptFilled,
9  PromptSubmitArgs,
10  PromptSubmitResult,
11  SessionAppendResult,
12  SessionCompactArgs,
13  SessionCompactResult,
14  SessionMessage,
15  SessionUsage,
16  TimerCall,
17} from 'claude-code';
18
19/**
20 * The engine calls this plugin makes. The engine refuses a module that passes `$` around,
21 * so register.ts binds each call from `$` and the other files get this.
22 */
23export type Engine = {
24  /** The plugin's folder, where its shipped files sit. */
25  pluginRoot: () => string;
26  now: () => Promise<number>;
27  after: TimerCall;
28  status: (text: string | undefined) => void;
29  sessionId: () => Promise<string>;
30  cwd: () => Promise<string>;
31  usage: () => Promise<SessionUsage>;
32  messages: () => Promise<SessionMessage[]>;
33  agents: () => Promise<AgentInfo[]>;
34  readPrompt: () => Promise<PromptBox>;
35  fillPrompt: (input: PromptFillArgs) => Promise<PromptFilled>;
36  submitPrompt: (input: PromptSubmitArgs) => Promise<PromptSubmitResult>;
37  /** Adds a user row the model reads and the person does not see as typed. */
38  appendNote: (text: string) => Promise<SessionAppendResult>;
39  compact: (args: SessionCompactArgs) => Promise<SessionCompactResult>;
40  registerCommand: (command: CommandSpec) => Promise<{ command: string }>;
41  complete: (request: ModelCompleteRequest) => Promise<ModelCompleteResult>;
42  fs: {
43    exists: (path: string) => Promise<boolean>;
44    read: (path: string) => Promise<string>;
45    write: (path: string, text: string) => Promise<void>;
46  };
47  store: {
48    get: (key: string) => Promise<unknown>;
49    set: (key: string, value: unknown) => Promise<void>;
50    delete: (key: string) => Promise<void>;
51  };
52  env: {
53    logFile: () => Promise<string | undefined>;
54    userProfile: () => Promise<string | undefined>;
55    home: () => Promise<string | undefined>;
56  };
57};
58
hooks/error-message.ts 5 lines
1/** A short message for the log, from anything a promise rejected with. */
2export function messageOf(error: unknown): string {
3  return (error instanceof Error ? error.message : String(error)).slice(0, 200);
4}
5
hooks/event-log.ts 44 lines
1import type { Engine } from './engine.ts';
2import { logFilePath } from './log-paths.ts';
3
4export type EventDetails = Record<string, unknown>;
5
6// `$.fs` has no append and reads at most 4 MiB, so a long log keeps only its newest part.
7const MAX_LOG_CHARS = 2_000_000;
8const KEPT_LOG_CHARS = 1_000_000;
9
10// One write at a time, so two events of one session never overwrite each other.
11let pendingWrite: Promise<void> = Promise.resolve();
12
13/** Adds one JSON line to the log file in the background. It never throws. */
14export function logEvent(engine: Engine, event: string, details: EventDetails = {}): void {
15  pendingWrite = pendingWrite.then(() => appendLine(engine, event, details)).catch(() => undefined);
16}
17
18async function appendLine(engine: Engine, event: string, details: EventDetails): Promise<void> {
19  const path = await logFilePath(engine);
20  const line = JSON.stringify(await lineFor(engine, event, details));
21  const existing = (await engine.fs.exists(path)) ? await engine.fs.read(path) : '';
22
23  await engine.fs.write(path, `${newestPartOf(existing)}${line}\n`);
24}
25
26/** The fixed fields come first and the event details follow, as in the wrapper's log. */
27async function lineFor(engine: Engine, event: string, details: EventDetails): Promise<EventDetails> {
28  return {
29    time: new Date(await engine.now()).toISOString(),
30    event,
31    session: await engine.sessionId(),
32    cwd: await engine.cwd(),
33    ...details,
34  };
35}
36
37function newestPartOf(log: string): string {
38  if (log.length <= MAX_LOG_CHARS) {
39    return log;
40  }
41
42  return log.slice(log.indexOf('\n', log.length - KEPT_LOG_CHARS) + 1);
43}
44
hooks/floor-start.ts 22 lines
1import type { ContextFloor } from './context-floor.ts';
2import type { Engine } from './engine.ts';
3import { loadFloorState } from './floor-store.ts';
4
5/**
6 * Sets the floor for a starting session. Saved state for this session id wins, so a resume keeps its count.
7 * Without it an empty conversation is a new session and anything else counts from 0.
8 */
9export async function startFloor(engine: Engine, floor: ContextFloor): Promise<void> {
10  const saved = await loadFloorState(engine, await engine.sessionId());
11
12  if (saved !== undefined) {
13    floor.restore(saved);
14
15    return;
16  }
17
18  if ((await engine.messages()).length === 0) {
19    floor.startNewSession();
20  }
21}
22
hooks/floor-store.ts 63 lines
1import type { ContextFloor, FirstTurnReason, FloorState } from './context-floor.ts';
2import type { Engine } from './engine.ts';
3
4// One store key holds every session's floor state, keyed by session id.
5const STORE_KEY = 'floors';
6const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
7
8type SavedFloor = FloorState & { savedAt: number };
9
10type SavedFloors = Record<string, SavedFloor>;
11
12/** Reads the state saved for this session id. Returns undefined when none is saved. */
13export async function loadFloorState(engine: Engine, sessionId: string): Promise<FloorState | undefined> {
14  const saved = (await readSavedFloors(engine))[sessionId];
15
16  if (saved === undefined) {
17    return undefined;
18  }
19
20  return { baseline: saved.baseline, waitingFor: saved.waitingFor };
21}
22
23/** Saves the floor under the current session id and drops the entries older than 30 days. */
24export async function saveFloorState(engine: Engine, floor: ContextFloor): Promise<void> {
25  const now = await engine.now();
26  const sessionId = await engine.sessionId();
27  const floors = withoutOldEntries(await readSavedFloors(engine), now);
28
29  floors[sessionId] = { ...floor.state(), savedAt: now };
30
31  await engine.store.set(STORE_KEY, floors);
32}
33
34async function readSavedFloors(engine: Engine): Promise<SavedFloors> {
35  const value = await engine.store.get(STORE_KEY);
36
37  if (typeof value !== 'object' || value === null) {
38    return {};
39  }
40
41  return Object.fromEntries(Object.entries(value).filter(([, entry]) => isSavedFloor(entry)));
42}
43
44function withoutOldEntries(floors: SavedFloors, now: number): SavedFloors {
45  return Object.fromEntries(Object.entries(floors).filter(([, saved]) => now - saved.savedAt <= MAX_AGE_MS));
46}
47
48function isSavedFloor(entry: unknown): entry is SavedFloor {
49  const saved = entry as Partial<SavedFloor> | null;
50
51  return (
52    typeof saved === 'object' &&
53    saved !== null &&
54    typeof saved.baseline === 'number' &&
55    typeof saved.savedAt === 'number' &&
56    isWaitingFor(saved.waitingFor)
57  );
58}
59
60function isWaitingFor(value: unknown): value is FirstTurnReason | null {
61  return value === null || value === 'first turn of session' || value === 'first turn after compaction';
62}
63
hooks/pending-compaction.ts 243 lines
1import type { Timer } from 'claude-code';
2import type { Engine } from './engine.ts';
3import { composerBlocker } from './composer-blocker.ts';
4import type { ContextFloor } from './context-floor.ts';
5import { messageOf } from './error-message.ts';
6import { submitContinuePrompt } from './continue-prompt-submit.ts';
7import { logEvent } from './event-log.ts';
8import { saveFloorState } from './floor-store.ts';
9import { showTimedStatus } from './status-line.ts';
10import type { TurnCounter } from './turn-counter.ts';
11
12// No engine event says that a dialog closed, so a busy session is retried on a clock: two minutes at most.
13const RETRY_MS = 3000;
14const MAX_RETRIES = 40;
15
16type WaitReason = 'prompt has text' | 'dialog open' | 'session busy';
17
18/** A compaction this plugin will run: after a judge yes, or because the session asked for it with its tag. */
19export type CompactionRequest = {
20  /** Logged with `compact-typed`: the judge's reason, or `session asked`. */
21  reason: string;
22  tokens: number;
23  /** The turn count when the asking turn ended. A higher count when it is held means a new turn took over. */
24  turnsAtEnd: number;
25  /** What the summarizer is told to keep. */
26  instructions: string;
27  /** The text for `{next}` in the continue prompt. Empty gets the fallback text. */
28  next: string;
29  /** True when the session asked. It has stopped and waits, so a skipped compaction still sends the prompt. */
30  continueWhenSkipped: boolean;
31  /** Logged when a retry from the clock or an edit fails, the same event the asking path logs. */
32  errorEvent: 'judge-error' | 'request-error';
33  /** Gets the turn count at the send of a continue prompt after a skipped compaction. It feeds the loop guard. */
34  onContinuedAfterSkip?: (turnsAtSend: number) => void;
35};
36
37/** A request that waits for its compaction. */
38type HeldRequest = CompactionRequest & {
39  heldAt: number;
40  retries: number;
41  loggedWait?: WaitReason;
42  timer?: Timer;
43};
44
45/**
46 * Holds a compaction request until the prompt box is empty and no dialog is open, then compacts and continues.
47 * A new turn drops the request. The next finished turn is judged or read for a tag again.
48 */
49export class PendingCompaction {
50  #turns: TurnCounter;
51  #floor: ContextFloor;
52  #held: HeldRequest | undefined;
53
54  constructor(turns: TurnCounter, floor: ContextFloor) {
55    this.#turns = turns;
56    this.#floor = floor;
57  }
58
59  /** Holds the request and tries to compact right away. */
60  async holdAndTry(engine: Engine, request: CompactionRequest): Promise<void> {
61    if (this.#turns.hasChangedSince(request.turnsAtEnd)) {
62      await this.#dropBeforeHold(engine);
63
64      return;
65    }
66
67    this.#take()?.timer?.cancel();
68    this.#held = { ...request, heldAt: await engine.now(), retries: 0 };
69
70    await this.#attempt(engine);
71  }
72
73  /** Tries again when an edit left the prompt box empty. */
74  retryAfterEdit(engine: Engine, draft: string): void {
75    if (this.#held !== undefined && draft.trim() === '') {
76      void this.#attemptAndLogError(engine);
77    }
78  }
79
80  /** Forgets a held request and logs why. Does nothing when no request is held. */
81  async drop(engine: Engine, reason: string): Promise<void> {
82    const held = this.#take();
83
84    if (held === undefined) {
85      return;
86    }
87
88    held.timer?.cancel();
89    logEvent(engine, 'compact-dropped', { reason, waitedMs: (await engine.now()) - held.heldAt });
90    await showTimedStatus(engine, 'dropped');
91  }
92
93  // The new turn's drop ran while the request was still on its way here, so it found nothing to drop.
94  async #dropBeforeHold(engine: Engine): Promise<void> {
95    logEvent(engine, 'compact-dropped', { reason: 'new turn started', waitedMs: 0 });
96    await showTimedStatus(engine, 'dropped');
97  }
98
99  // A retry from the clock or an edit has no caller that catches, so a failure is logged here.
100  async #attemptAndLogError(engine: Engine): Promise<void> {
101    const held = this.#held;
102
103    if (held === undefined) {
104      return;
105    }
106
107    try {
108      await this.#attempt(engine);
109    } catch (error) {
110      logEvent(engine, held.errorEvent, { message: messageOf(error) });
111      await showTimedStatus(engine, 'error');
112    }
113  }
114
115  async #attempt(engine: Engine): Promise<void> {
116    const held = this.#held;
117
118    if (held === undefined) {
119      return;
120    }
121
122    const blocker = await composerBlocker(engine);
123
124    // Dropped or replaced while the prompt box was read.
125    if (this.#held !== held) {
126      return;
127    }
128
129    if (blocker !== undefined) {
130      await this.#wait(engine, held, blocker);
131
132      return;
133    }
134
135    this.#take();
136    held.timer?.cancel();
137    await this.#compact(engine, held);
138  }
139
140  async #compact(engine: Engine, held: HeldRequest): Promise<void> {
141    const turnsAtCompact = this.#turns.count();
142
143    engine.status('compacting...');
144
145    try {
146      const result = await engine.compact({ instructions: held.instructions });
147
148      if (result.skip !== undefined) {
149        logEvent(engine, 'compact-failed', { message: result.skip });
150        await showTimedStatus(engine, 'compact skipped');
151
152        if (held.continueWhenSkipped) {
153          await this.#continueAfterSkip(engine, held, turnsAtCompact);
154        }
155
156        return;
157      }
158    } catch (error) {
159      await this.#holdAgainWhenStillIdle(engine, held, turnsAtCompact, error);
160
161      return;
162    }
163
164    const waited = await waitedMs(engine, held);
165
166    logEvent(engine, 'compact-typed', { reason: held.reason, tokens: held.tokens, waitedMs: waited });
167    // The engine does not run this plugin's own session.compact hook for this call, so the floor restarts here.
168    this.#floor.restartAfterCompaction();
169    await saveFloorState(engine, this.#floor);
170    await showTimedStatus(engine, 'compacted');
171    await submitContinuePrompt(engine, this.#turns, turnsAtCompact, { next: held.next, isAfterCompaction: true });
172  }
173
174  // Nothing was compacted, so the prompt goes out without the compacted line and the send is reported.
175  async #continueAfterSkip(engine: Engine, held: HeldRequest, turnsAtCompact: number): Promise<void> {
176    const sent = await submitContinuePrompt(engine, this.#turns, turnsAtCompact, {
177      next: held.next,
178      isAfterCompaction: false,
179    });
180
181    if (sent) {
182      held.onContinuedAfterSkip?.(turnsAtCompact);
183    }
184  }
185
186  // The engine refuses a compaction while a turn runs. A turn that started is a drop, anything else is a wait.
187  async #holdAgainWhenStillIdle(
188    engine: Engine,
189    held: HeldRequest,
190    turnsAtCompact: number,
191    error: unknown,
192  ): Promise<void> {
193    if (this.#turns.hasChangedSince(turnsAtCompact)) {
194      logEvent(engine, 'compact-dropped', { reason: 'new turn started', waitedMs: await waitedMs(engine, held) });
195
196      return;
197    }
198
199    // Logged once per wait. The retries stay quiet.
200    if (held.loggedWait !== 'session busy') {
201      logEvent(engine, 'compact-failed', { message: messageOf(error) });
202    }
203
204    this.#held = held;
205    await this.#wait(engine, held, 'session busy');
206  }
207
208  /** Shows and logs why the request waits. A full prompt box waits for an edit, the rest is retried on the clock. */
209  async #wait(engine: Engine, held: HeldRequest, reason: WaitReason): Promise<void> {
210    if (held.loggedWait !== reason) {
211      held.loggedWait = reason;
212      logEvent(engine, 'compact-waiting', { reason });
213    }
214
215    engine.status(reason === 'prompt has text' ? 'waiting for empty prompt' : 'waiting for idle');
216    held.timer?.cancel();
217
218    if (reason === 'prompt has text') {
219      return;
220    }
221
222    if (held.retries >= MAX_RETRIES) {
223      await this.drop(engine, 'not idle in time');
224
225      return;
226    }
227
228    held.retries += 1;
229    held.timer = engine.after(RETRY_MS, () => void this.#attemptAndLogError(engine));
230  }
231
232  #take(): HeldRequest | undefined {
233    const held = this.#held;
234    this.#held = undefined;
235
236    return held;
237  }
238}
239
240async function waitedMs(engine: Engine, held: HeldRequest): Promise<number> {
241  return (await engine.now()) - held.heldAt;
242}
243
hooks/plugin-settings.ts 15 lines
1import type { PluginOptions } from 'claude-code';
2
3export type Settings = {
4  minTokens: number;
5  claudeModel: string;
6};
7
8/** Reads the userConfig values. The engine fills in the plugin.json defaults first. */
9export function settingsFrom(options: PluginOptions): Settings {
10  return {
11    minTokens: Number(options['minTokens']),
12    claudeModel: String(options['claudeModel']),
13  };
14}
15