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…

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
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:
Keep the current goal, the next step and open decisions./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.
The floor (minTokens, 60k by default) counts only the tokens added since a starting point, not the whole context:
/clear, or the first turn after a compaction.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.

| Status | Meaning |
|---|---|
judge in 48k | Below 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 turn | The floor is reached. The judge is asked when the next turn ends. |
waiting for subagents | Subagents still run, so the judge was not asked. |
nudged 14:02 | The session was asked to compact at the next good moment. |
judging... | The judge model is asked. |
keep going 14:02 | The judge said this is no good moment to compact. |
will compact 14:02 | The judge said yes or the session asked. The compaction follows when the session is free. |
yes cancelled 14:02 | The judge said yes, but a new turn or subagent started meanwhile. |
request ignored 14:02 | The session asked, but subagents still ran or the loop guard held. The log says why. |
waiting for empty prompt | A compaction waits until you empty the prompt box. |
waiting for idle | A compaction waits for a dialog to close or for the session to be free. |
compacting..., compacted 14:03 | The compaction runs or has finished. |
compact skipped 14:03 | Claude Code skipped the compaction, for example because a hook blocked it. |
continued 14:03 | The continue prompt was sent. |
dropped 14:03 | A new turn started or the session stayed busy, so the compaction was dropped. |
error 14:02 | The judge or a session's request failed. The log says why. |
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:
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: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.
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.
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.
| Option | Default | What it does |
|---|---|---|
minTokens | 60000 | The token floor: tokens added before the judge is asked or a session's request compacts. |
claudeModel | haiku | Model alias or id for the judge. It runs on your own Claude login and needs no setup. |
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.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.
| Placeholder | Replaced 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. |
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 ## .
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.
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 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./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.
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.
hooks/register.ts 184 lines1import 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}
184hooks/compact-request.ts 37 lines1const 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}
37hooks/compact-nudge.ts 118 lines1import 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}
118hooks/compact-rule.ts 20 lines1import 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}
20hooks/context-floor.ts 68 lines1/** 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}
68hooks/engine.ts 58 lines1import 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};
58hooks/error-message.ts 5 lines1/** 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}
5hooks/event-log.ts 44 lines1import 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}
44hooks/floor-start.ts 22 lines1import 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}
22hooks/floor-store.ts 63 lines1import 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}
63hooks/pending-compaction.ts 243 lines1import 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}
243hooks/plugin-settings.ts 15 lines1import 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