SLOPSHOPPER

compact-plus

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

newpromptprocesstimer
v0.1.0MITupdated 2026-10-21dante32683/claude-code-mods/compact-plus
A shopper browsing a rack in a slop shop
README

claude-code-mods

A few mods for Claude Code that I use every day. They work in the terminal and in the desktop app.

agent-crabs

Install

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.

Mods

agent-crabs

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.

usage-context

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.

compact-plus

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.

repo-peers

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

/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.

bash-fixes

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.

Credits

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.

License

MIT

Source 2 files
hooks/register.js 230 lines
1// 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}
230
hooks/lib.js 176 lines
1// 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