Better compaction: a searchable history file, handoff-style summary instructions, your messages kept word for word, fresh instruction files, and compaction at…

A few mods for Claude Code that I use every day. They work in the terminal and in the desktop app.
From a shell:
claude plugin marketplace add dante32683/claude-code-mods
claude plugin install agent-crabs@dante32683-mods
Swap agent-crabs for any mod below. In the desktop app, click + next to the prompt box, then Plugins, add the marketplace dante32683/claude-code-mods, and pick the mods you want.
Mods load when a chat starts, so open a new chat after installing.
A little Clawd above the prompt for each subagent that's running, with what it's doing right now. The crab acts it out: reading a book, walking around with a magnifying glass while it searches, writing on paper while it edits, jogging while a command runs, holding a globe while it's on the web, and thinking in between. It throws confetti when the subagent finishes and sits under a rain cloud if it fails. /crabs-demo plays every animation.
The animated crabs show in the desktop app; the terminal gets a small text crab.
A pane with your plan's 5-hour and weekly usage, how fast you're using it, how much per hour or day you can spend and still make the reset, and what's filling the context window. While the pane is closed, the usage bars sit above the prompt. /meter shows or hides the pane. It saves the last reading to statusline-limits.json in your Claude folder.
Better compaction. Before each compaction it saves the whole conversation to a searchable file in ~/.claude/compact-history/ and tells Claude where it is, so details the summary drops can still be found. Your own messages are kept word for word, the summary is written as a handoff, instruction files are re-read afterward, and when a turn ends close to the auto-compact limit it compacts right away instead of mid-task. History older than 30 days is deleted. Options: turnEndPercent and verbatimUserTokens.
Tells Claude when other Claude chats are working in the same repo (same checkout or another worktree) and which files they've edited, so it can coordinate before work collides. Windows only for now.
/aside <task> hands a side task to a background worker while your chat keeps going. Unlike /btw, the worker has tools. It sees the last few messages for context, and its answer opens in a pane where you can send it to the chat or dismiss it. Haiku by default; /aside opus: <task> uses Opus.
Windows Git Bash only. It fixes a few ways Claude's Bash tool breaks there: very long commands get cut off or mangled, rg patterns starting with / get turned into Windows paths, sed -i doesn't say when nothing changed, and rm "${DIR:?}"/... keeps asking for approval.
agent-crabs takes its idea from hamzafer's agent-radar (MIT), and Clawd's shape from Codrops' article on reverse-engineering Claude's mascot animations. Clawd is Anthropic's mascot; this is a fan project, not affiliated with Anthropic. compact-plus's summary style is modeled on OpenAI's Codex CLI.
MIT
hooks/register.js 230 lines1// compact-plus: better compaction. The handoff-style summary instructions are
2// modeled on OpenAI's Codex CLI.
3//
4// session.compact writes the conversation about to be summarized to a searchable
5// history file, adds handoff instructions for the summarizer, and
6// picks the user's messages to keep word for word.
7// session.append appends those messages and a pointer to the history file to the
8// summary row as the engine stores it (the engine's own summary
9// message can't be edited from session.compact).
10// prompt.context checks instruction files against disk when the engine re-reads
11// them (after a compaction), so a stale CLAUDE.md or AGENTS.md copy
12// is swapped for the file as it is now; logged when it happens.
13// turn.complete compacts between turns, while the person reads, once the context
14// is near the auto-compact threshold. In a headless host (the
15// desktop app, the SDK, -p) that is a queued /compact.
16//
17// Everything fails open: on an error the engine's own behaviour runs unchanged.
18
19import * as lib from './lib.js'
20
21// Per-session state; a module reload starts it over.
22const state = { pending: null, turnEndArmed: true, threshold: null, pruned: false }
23const KEEP_DAYS = 30
24
25async function historyDir($) {
26 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
27 if (!home) return null
28 const id = await $.session.id()
29 return `${home.replace(/\\/g, '/')}/.claude/compact-history/${id}`
30}
31
32async function log($, dir, entry) {
33 try {
34 const file = `${dir}/log.jsonl`
35 const prior = (await $.fs.exists(file)) ? await $.fs.read(file) : ''
36 await $.fs.write(file, prior + JSON.stringify({ at: new Date(await $.clock.now()).toISOString(), ...entry }) + '\n')
37 } catch {}
38}
39
40// Deletes this mod's history folders for sessions that last compacted over
41// KEEP_DAYS ago (the age of each folder's log.jsonl), once per session.
42async function pruneOld($, dir) {
43 if (state.pruned) return
44 state.pruned = true
45 const root = dir.slice(0, dir.lastIndexOf('/'))
46 const cutoff = (await $.clock.now()) - KEEP_DAYS * 86400000
47 const isWindows = (await $.env.get('OS')) === 'Windows_NT'
48 for (const entry of await $.fs.list(root)) {
49 if (entry.kind !== 'dir' || !/^[0-9a-f-]{36}$/.test(entry.name)) continue
50 const folder = `${root}/${entry.name}`
51 if (folder === dir) continue
52 let mtime = 0
53 try {
54 mtime = (await $.fs.stat(`${folder}/log.jsonl`)).mtimeMs
55 } catch {
56 continue
57 }
58 if (mtime >= cutoff) continue
59 const argv = isWindows ? ['cmd', '/c', 'rd', '/s', '/q', folder.replace(/\//g, '\\')] : ['rm', '-rf', folder]
60 await $.process.run(argv, { timeoutMs: 20000 })
61 }
62}
63
64async function prepare($, e, verbatimTokens) {
65 const dir = await historyDir($)
66 const verbatim = lib.selectVerbatim(e.messages, verbatimTokens)
67 let file = null
68 if (dir) {
69 const now = await $.clock.now()
70 file = `${dir}/${lib.stamp(now)}-${e.trigger}.md`
71 const text = lib.renderHistory(e.messages, {
72 sessionId: await $.session.id(),
73 at: new Date(now).toISOString(),
74 trigger: e.trigger,
75 })
76 // A failed write (over the 4 MiB limit, say) costs the file alone.
77 await $.fs.write(file, text).catch(() => (file = null))
78 }
79 return { dir, file, verbatim }
80}
81
82// The auto-compact threshold changes only with the model or settings, so the
83// breakdown that reports it is asked for at most every ten minutes.
84async function autoCompactThreshold($) {
85 const now = await $.clock.now()
86 if (state.threshold && now - state.threshold.at < 600000) return state.threshold.value
87 const usage = await $.session.usage({ breakdown: 'summary' })
88 const b = usage.context.breakdown
89 const value = b && b.isAutoCompactEnabled && b.autoCompactThreshold ? b.autoCompactThreshold : 0
90 state.threshold = { value, at: now }
91 return value
92}
93
94async function isNearThreshold($, turnEndPercent) {
95 const { context } = await $.session.usage()
96 const threshold = context.tokens ? await autoCompactThreshold($) : 0
97 return threshold > 0 && context.tokens >= (threshold * turnEndPercent) / 100
98}
99
100async function compactIfNear($, turnEndPercent) {
101 state.turnEndArmed = false
102 let near = false
103 try {
104 near = await isNearThreshold($, turnEndPercent)
105 } finally {
106 if (!near) state.turnEndArmed = true
107 }
108 if (!near) return
109 const dir = await historyDir($)
110 if (dir) await log($, dir, { event: 'turn-end-compact', percent: turnEndPercent })
111 // A headless host (the desktop app, the SDK, -p) refuses $.session.compact:
112 // compaction there runs as a /compact command, which queues until the session is idle.
113 $.clock.after(1500, () => {
114 $.session.compact()
115 .catch(() => $.command.run({ command: 'compact' }))
116 .catch(async (err) => {
117 state.turnEndArmed = true
118 if (dir) await log($, dir, { event: 'turn-end-compact-failed', error: String(err && err.message ? err.message : err).slice(0, 300) })
119 })
120 })
121}
122
123export function register(on, options) {
124 const turnEndPercent = Number(options?.turnEndPercent ?? 90)
125 const verbatimTokens = Number(options?.verbatimUserTokens ?? 8000)
126
127 state.pending = null // blocks for the next summary row of the main conversation
128 state.turnEndArmed = true // one turn-end compaction per context window
129 state.threshold = null // { value, at }: the auto-compact threshold, cached
130 state.pruned = false
131
132 on('session.compact', async ($, e, next) => {
133 // A subagent's or fork's own compaction: the handoff instructions alone.
134 if (e.agentId) {
135 let down = e
136 try {
137 down = { ...e, instructions: lib.joinInstructions(e.instructions, lib.handoffInstructions(null)) }
138 } catch {}
139 return next(down)
140 }
141
142 let prep = null
143 let down = e
144 try {
145 prep = await prepare($, e, verbatimTokens)
146 const extra = lib.handoffInstructions(prep.verbatim[0]?.text)
147 down = { ...e, instructions: lib.joinInstructions(e.instructions, extra) }
148 } catch {
149 prep = null
150 down = e
151 }
152
153 const result = await next(down)
154
155 try {
156 if (result && Array.isArray(result.messages) && e.trigger !== 'precompute') state.turnEndArmed = true
157 if (result && Array.isArray(result.messages) && prep) {
158 const kept = new Set(result.messages.map((m) => m.handle).filter(Boolean))
159 const verbatim = prep.verbatim.filter((v) => !v.handle || !kept.has(v.handle))
160 const blocks = []
161 if (verbatim.length) blocks.push(lib.renderVerbatim(verbatim))
162 if (prep.file) blocks.push(lib.pointerText(prep.file, prep.dir))
163 state.pending = blocks.length ? blocks : null
164 if (prep.dir) {
165 await pruneOld($, prep.dir).catch(() => {})
166 await log($, prep.dir, {
167 event: 'compact',
168 trigger: e.trigger,
169 messages: e.messages.length,
170 kept: result.messages.length - 1,
171 verbatim: verbatim.length,
172 tokensBefore: result.tokensBefore,
173 tokensAfter: result.tokensAfter,
174 file: prep.file,
175 })
176 }
177 }
178 } catch {}
179 return result
180 })
181
182 on('session.append', { door: 'compaction' }, async ($, e, next) => {
183 if (!state.pending || e.agentId || e.message.type !== 'user' || e.message.role !== 'user') return next(e)
184 const blocks = state.pending
185 state.pending = null
186 const content = [...e.message.content, ...blocks.map((text) => ({ type: 'text', text }))]
187 return next({ ...e, message: { ...e.message, content } })
188 }).catch(($, e, next) => next(e))
189
190 on('prompt.context', async ($, e, next) => {
191 const result = await next(e)
192 try {
193 const files = result.instructionFiles ?? e.instructionFiles
194 if (!files || files.length === 0) return result
195 const refreshed = []
196 const fresh = await Promise.all(
197 files.map(async (f) => {
198 if (f.kind === 'memory' || f.kind === 'managed') return f
199 let raw
200 try {
201 raw = await $.fs.read(f.path)
202 } catch {
203 return f
204 }
205 // The engine expands @imports into the content; disk text would drop them.
206 if (typeof raw !== 'string' || /^@\S/m.test(raw)) return f
207 const text = lib.stripInstructionText(raw)
208 if (lib.sameText(text, f.content)) return f
209 refreshed.push(f.path)
210 return { ...f, content: text }
211 }),
212 )
213 if (refreshed.length === 0) return result
214 const dir = await historyDir($)
215 if (dir) await log($, dir, { event: 'instructions-refreshed', files: refreshed })
216 return { ...result, instructionFiles: fresh }
217 } catch {
218 return result
219 }
220 })
221
222 on('turn.complete', async ($, e, next) => {
223 const result = await next(e)
224 if (turnEndPercent > 0 && state.turnEndArmed && !e.agentId && e.reason === 'answer') {
225 compactIfNear($, turnEndPercent).catch(() => {})
226 }
227 return result
228 })
229}
230hooks/lib.js 176 lines1// compact-plus helpers: pure functions, no `$`, so the tests can call them directly.
2
3export const CHARS_PER_TOKEN = 4
4export const VERBATIM_OPEN = '<user-messages-verbatim>'
5export const VERBATIM_CLOSE = '</user-messages-verbatim>'
6const VERBATIM_SEP = '\n--- user message ---\n'
7const SUMMARY_START = 'This session is being continued from a previous conversation'
8const PER_MESSAGE_CHARS = 8000
9const TOOL_INPUT_CHARS = 2000
10const TOOL_RESULT_CHARS = 12000
11
12// User-role rows the engine or the app writes, not the person.
13const MACHINE_PREFIXES = [
14 '<system-reminder>',
15 '<task-notification>',
16 '<command-',
17 '<local-command',
18 '<side-sessions-event>',
19 '<ci-monitor-event>',
20 '<user-prompt-submit-hook>',
21 '[Request interrupted',
22 'Caveat:',
23 SUMMARY_START,
24]
25
26export function cutMiddle(text, max, note = 'the full text is in the history file') {
27 if (text.length <= max) return text
28 const head = Math.floor(max * 0.7)
29 const tail = max - head
30 return `${text.slice(0, head)}\n[... ${text.length - max} characters cut; ${note} ...]\n${text.slice(text.length - tail)}`
31}
32
33/** The person's own words in a user row, or '' when the row is machine text. */
34export function personText(text) {
35 const cleaned = (text ?? '').replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim()
36 if (!cleaned || MACHINE_PREFIXES.some((p) => cleaned.startsWith(p))) return ''
37 return cleaned
38}
39
40/** Messages carried in an earlier summary's verbatim block, oldest first. */
41export function carriedMessages(messages) {
42 for (let i = messages.length - 1; i >= 0; i--) {
43 const text = messages[i].role === 'user' ? messages[i].text ?? '' : ''
44 const start = text.lastIndexOf(VERBATIM_OPEN)
45 if (start < 0) continue
46 const end = text.indexOf(VERBATIM_CLOSE, start)
47 if (end < 0) continue
48 const body = text.slice(start + VERBATIM_OPEN.length, end)
49 return body
50 .split(VERBATIM_SEP)
51 .slice(1)
52 .map((t) => t.trim())
53 .filter(Boolean)
54 }
55 return []
56}
57
58/**
59 * The person's messages to keep word for word, oldest first: those carried from
60 * earlier summaries, then this window's, chosen newest first within the budget.
61 * Each entry keeps the engine's handle when it has one, so a message the
62 * engine keeps verbatim anyway can be left out later.
63 */
64export function selectVerbatim(messages, budgetTokens) {
65 if (!(budgetTokens > 0)) return []
66 const entries = carriedMessages(messages).map((text) => ({ text }))
67 for (const m of messages) {
68 if (m.role !== 'user') continue
69 const text = personText(m.text)
70 if (text) entries.push({ text, handle: m.handle })
71 }
72 let room = budgetTokens * CHARS_PER_TOKEN
73 const chosen = []
74 for (let i = entries.length - 1; i >= 0 && room > 200; i--) {
75 const capped = cutMiddle(entries[i].text, Math.min(PER_MESSAGE_CHARS, room))
76 chosen.push({ ...entries[i], text: capped })
77 room -= capped.length + VERBATIM_SEP.length
78 }
79 return chosen.reverse()
80}
81
82export function renderVerbatim(entries) {
83 const intro =
84 "The user's own messages, word for word, oldest first. compact-plus keeps them across compactions; where the summary above paraphrases one, the wording here is what the user said."
85 return `${VERBATIM_OPEN}\n${intro}\n${entries.map((e) => VERBATIM_SEP + e.text).join('\n')}\n${VERBATIM_CLOSE}`
86}
87
88export function pointerText(file, dir) {
89 return [
90 `compact-plus saved the conversation from before this compaction as searchable text: "${file}".`,
91 `Earlier parts of this session are in the same folder. For exact wording, paths, commands, errors or tool output the summary left out, search there before asking the user or redoing work: rg -n "<term>" "${dir}"`,
92 ].join('\n')
93}
94
95export function handoffInstructions(firstVerbatim) {
96 const lines = [
97 'Extra instructions from the compact-plus mod:',
98 '- Write this as a handoff to another model that will continue the work with nothing but your summary. Open section 1 with the current goal and where it stands, in two or three sentences.',
99 '- Keep exact identifiers: file paths, commands that worked, commit hashes, branch names, URLs, session and task ids, error text and numbers.',
100 "- Record each decision with its reason, and the user's corrections and preferences in the user's own words.",
101 '- Say what was verified (tests run, output checked) and what is assumed or untested.',
102 '- List open promises: what you said you would do, what waits on the user, and background work still running (subagents, other sessions, background commands) with how to check on it.',
103 '- Do not restate instruction files (CLAUDE.md, AGENTS.md, the memory index) or the system prompt; they are sent again after compaction.',
104 '- The most recent messages may be kept verbatim after your summary, and you may not see them. In "Current Work", describe the last state you can see, and word the next step so it still holds if those messages already started it.',
105 ]
106 if (firstVerbatim) {
107 const head = firstVerbatim.replace(/\s+/g, ' ').slice(0, 80)
108 lines.push(
109 `- The user's messages from the one beginning "${head}" onward are attached word for word after your summary. In section 6, give each of those one short line with its outcome instead of restating it.`,
110 )
111 }
112 return lines.join('\n')
113}
114
115export function joinInstructions(own, extra) {
116 return own && own.trim() ? `${own.trim()}\n\n${extra}` : extra
117}
118
119function toolInput(input) {
120 try {
121 return cutMiddle(JSON.stringify(input ?? {}), TOOL_INPUT_CHARS, 'see the transcript')
122 } catch {
123 return '(input not shown)'
124 }
125}
126
127/** The pre-compaction conversation as plain text, one `## [n] role` heading per message. */
128export function renderHistory(messages, meta) {
129 const out = [
130 `# Conversation before compaction`,
131 ``,
132 `Session ${meta.sessionId}, compacted ${meta.at} (${meta.trigger}), ${messages.length} messages. Written by the compact-plus mod.`,
133 `Each message starts with a "## [n] role" line; tool calls follow as "### tool <name>" with their input and result (long results cut; the .jsonl transcript has everything).`,
134 ``,
135 ]
136 messages.forEach((m, i) => {
137 const uses = m.toolUses ?? []
138 const text = (m.text ?? '').trim()
139 if (m.role === 'user' && !text && (m.toolResults?.length ?? 0) > 0) return
140 if (!text && uses.length === 0) return
141 out.push(`## [${i}] ${m.role}`, '')
142 if (text) out.push(text, '')
143 for (const u of uses) {
144 out.push(`### tool ${u.tool}${u.isError ? ' (error)' : ''}`, `input: ${toolInput(u.input)}`)
145 if (typeof u.text === 'string') out.push('result:', cutMiddle(u.text, TOOL_RESULT_CHARS, 'see the transcript'))
146 out.push('')
147 }
148 })
149 return out.join('\n')
150}
151
152/** Instruction-file text as the engine loads it: no frontmatter, no HTML comments. */
153export function stripInstructionText(raw) {
154 let text = raw.replace(/\r\n/g, '\n')
155 if (text.startsWith('---\n')) {
156 const end = text.indexOf('\n---', 4)
157 if (end > 0) text = text.slice(text.indexOf('\n', end + 1) + 1)
158 }
159 return text.replace(/<!--[\s\S]*?-->/g, '')
160}
161
162export function sameText(a, b) {
163 const norm = (s) =>
164 s
165 .replace(/\r\n/g, '\n')
166 .split('\n')
167 .map((l) => l.trimEnd())
168 .join('\n')
169 .trim()
170 return norm(a) === norm(b)
171}
172
173export function stamp(ms) {
174 return new Date(ms).toISOString().replace(/[:]/g, '').replace(/\.\d+Z$/, 'Z')
175}
176