SLOPSHOPPER

smart-compact

Lets Claude compact its own context at a step boundary it picks, with its own instructions, then resume the work.

newguardtoasttooltimer
v0.1.2no licenseupdated 2026-10-03Gharib89/claude-mods/plugins/smart-compact
A shopper browsing a rack in a slop shop
README

claude-mods

Claude Code mods (function-hook plugins), installed through one marketplace.

Mods

  • smart-compact: lets Claude compact its own context at a step boundary it picks, with its own instructions, then resume the work on its own. Reminders arrive at 60% and 80% of the auto-compact point. Interactive sessions only: a -p or SDK session cannot compact on request yet, so there the mod only adds its keep-list to automatic compactions.
  • gh-pane: /gh-pane, or the Open gh-pane button above the prompt (Hide gh-pane while the pane is up, each followed by the Nerd Font GitHub mark, which /plugin configure can blank where the terminal font has none), docks a pane of the session repo's open issues and PRs in run order: spec trees from sub-issues, blocked-by edges, claims and their ship runs, planning maps whose tickets close without a PR, and the needs-triage and needs-info lists. Its buttons fill the prompt with /ship N, /triage N, a map's next ticket (/wayfinder MAP N), a request to release a stale claim, or a request to close an issue whose sub-issues are all closed (all done), and never send it. Two band buttons act on this session's own state instead: when a main-loop answer carries the merge-gate text and a PR link, merge PR #N (hotkey m) sends the merge reply as your own prompt, and once that PR reads as merged over REST, clear + /ship N (hotkey n) clears the session and runs the first ready row's command (one that does not start with / shows no button), and goes once the next turn ends. When a ship run merges on its own on a clean gate, its answer's merged line (Merged on a clean gate: with the PR link after it on that line) takes the same read, so clear + /ship N shows with no merge button. A hotkey fires only once the band holds focus (ctrl+x then tab, or a click), never from the prompt, where m types a letter; a click always works. A /clear keeps the open pane's last read of the same folder on screen until the next one lands. It reads the repo through git and gh api (REST only), so gh must be installed and signed in. The label names (the map label included), PR cap, ship worktree layout, the band button's icon, the text each button fills, the merge-gate text, the merged text and the merge reply are options in /plugin configure gh-pane@claude-mods.

Install

claude plugin marketplace add Gharib89/claude-mods
claude plugin install smart-compact@claude-mods
claude plugin install gh-pane@claude-mods
Source 1 files
hooks/register.ts 150 lines
1import type { Register } from 'claude-code'
2
3const TOOL = 'compact_now'
4const TOOL_ID = `mcp__smart-compact__${TOOL}`
5
6// Every compaction, the threshold's included, keeps what long work needs to resume.
7const KEEP = [
8  'The summary must keep, verbatim, whatever of these the work has: the task and its goal;',
9  'the plan and which steps are done; the current step and the next one; the branch, worktree and PR;',
10  'every file being changed; decisions and open findings not yet written down;',
11  'and the path of any run file or progress file the work keeps.',
12].join(' ')
13
14type Pending = { instructions: string; resume: string }
15
16const withKeep = (instructions: string | undefined) => [instructions, KEEP].filter(Boolean).join('\n\n')
17
18// Shares of the auto-compact point at which Claude is told, once each, to plan a compact; highest first.
19const LEVELS = [
20  {
21    at: 80,
22    say: (pct: number) =>
23      `smart-compact: context is at ${pct}% of the auto-compact point, so auto-compaction will soon cut in mid-step. Finish the step in progress; at the very next step boundary, before starting anything new, call compact_now with \`instructions\` and \`resume\`, then end your turn.`,
24  },
25  {
26    at: 60,
27    say: (pct: number) =>
28      `smart-compact: context is at ${pct}% of the auto-compact point. Do not compact yet: keep working and note where the next step boundary falls. compact_now becomes due at 80%.`,
29  },
30] as const
31
32export const register: Register = on => {
33  // A module variable: a reload between the tool call and the turn's end drops the request, and the work then
34  // waits for the next prompt.
35  let pending: Pending | undefined
36  // The highest level already said; back to 0 after every compaction, and once the context drops below the lowest.
37  let warned = 0
38  let compactAt: number | undefined
39  // $.session.compact rejects in a -p or SDK session (Claude Code 2.1.288), so there the mod offers no tool and
40  // asks for none; the keep-list still rides every compaction.
41  let headless = false
42
43  on('session.start', async ($, e, next) => {
44    headless = !e.isInteractive
45    if (headless) return next(e)
46    await $.tool.register({
47      name: TOOL,
48      description: [
49        'Compact your own context at a step boundary you choose, then resume on your own.',
50        'Call it between the steps of long work (after a check passes, before the next step starts), so the summary lands on finished work.',
51        'Reminders arrive as the context fills: at 60% of the auto-compact point a heads-up, at 80% a request to call this at the very next step boundary.',
52        'After the call, end your turn: compaction runs when the turn ends, then `resume` is submitted as the next prompt.',
53      ].join(' '),
54      inputSchema: {
55        type: 'object',
56        properties: {
57          instructions: {
58            type: 'string',
59            description:
60              'What this summary must keep for the next step: the names, numbers, paths and open decisions this work depends on. The task, plan, step status, branch, PR, changed files and run file are kept already.',
61          },
62          resume: {
63            type: 'string',
64            description:
65              'The prompt that restarts the work after compaction, self-contained: the step to continue at and where the work keeps its record (e.g. "Continue the migration at step 4; progress is in notes/migration.md").',
66          },
67        },
68        required: ['instructions', 'resume'],
69      },
70    })
71    return next(e)
72  })
73
74  on('tool.call', { tool: TOOL_ID }, (_, e) => {
75    if (e.agentId !== undefined) {
76      return { deny: 'compact_now compacts the main conversation only, so a subagent has no use for it; carry on with your task.' }
77    }
78    // The engine does not hold a plugin tool's input to its schema's `required` (2.1.288): a model calling the
79    // deferred tool before loading its schema sends `{}`, and a compaction with no resume stalls the work. An empty
80    // `instructions` is fine: withKeep still sends KEEP.
81    const { instructions, resume } = e as unknown as Record<string, unknown>
82    if (typeof instructions !== 'string' || typeof resume !== 'string' || resume.trim() === '') {
83      return {
84        deny: 'compact_now needs both `instructions` and `resume`, with `resume` non-empty; nothing was queued. Call it again with `instructions` (what the summary must keep for this work) and `resume` (the self-contained prompt that restarts the work at the next step).',
85      }
86    }
87    pending = { instructions, resume }
88    return { result: 'Compaction queued. End your turn now with a one-line status.' }
89  })
90
91  // Every main-loop tool result: how full the context is against the auto-compact point, not the model's window.
92  on('tool.call', async ($, e, next) => {
93    const ran = await next(e)
94    if (headless || e.agentId !== undefined || e.tool === TOOL_ID || ran.deny !== undefined) return ran
95    // context.percent is against the model's window (1M on Opus 5.5); the breakdown carries the auto-compact
96    // point, cached until a compaction that reaches the session.compact hook below (seen on Claude Code 2.1.288).
97    if (compactAt === undefined) {
98      const { context } = await $.session.usage({ breakdown: 'summary' })
99      compactAt = context.breakdown?.autoCompactThreshold ?? context.window
100    }
101    const { tokens } = (await $.session.usage()).context
102    if (tokens === undefined) return ran
103    // tokens is the last response's input, which excludes this result; Claude's next step reads it too. A third of
104    // its characters: the usual quarter ran 35% short on a dense file (16.4K tokens from 43.7K characters, 2.1.288).
105    const pct = Math.round((100 * (tokens + (ran.text?.length ?? 0) / 3)) / compactAt)
106    const level = LEVELS.find(l => pct >= l.at)
107    if (level === undefined) {
108      warned = 0
109      return ran
110    }
111    if (level.at <= warned) return ran
112    warned = level.at
113    $.ui.toast(`smart-compact: context at ${pct}% of the auto-compact point`)
114    return { ...ran, context: [...(ran.context ?? []), level.say(pct)] }
115  })
116
117  on('turn.complete', async ($, e, next) => {
118    const done = await next(e)
119    const request = pending
120    if (e.agentId !== undefined || request === undefined) return done
121    pending = undefined
122    if (e.isAborted) {
123      $.ui.toast('smart-compact: turn interrupted, compaction dropped')
124      return done
125    }
126    // $.session.compact refuses while a turn runs, so it starts once this dispatch has returned (Claude Code 2.1.288).
127    $.clock.after(0, () => {
128      void (async () => {
129        const compacted = await $.session.compact({ instructions: withKeep(request.instructions) })
130        if (compacted.skip !== undefined) {
131          $.ui.toast(`smart-compact: compaction skipped (${compacted.skip}); not resuming`)
132          return
133        }
134        warned = 0
135        // Without asUser the prompt shows as "The smart-compact plugin sent a message", which is honest (2.1.288).
136        await $.prompt.submit({ text: request.resume })
137      })().catch(err => $.ui.toast(`smart-compact: ${String(err)}`))
138    })
139    return done
140  })
141
142  // A plugin's own $.session.compact skips its own session.compact hook (Claude Code 2.1.288), so the queued path
143  // above adds KEEP and re-arms the reminders itself.
144  on('session.compact', ($, e, next) => {
145    compactAt = undefined
146    warned = 0
147    return next({ ...e, instructions: withKeep(e.instructions) })
148  })
149}
150