SLOPSHOPPER

context-keeper

Live context-window panel: what fills it, checkpoints before you compact or clear, and nudges before you hit the wall.

newpanecommandtoaststatusmodel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-keeper
│ ┃ Context ✕ › fix the failing auth test and add an audit ╭─────────────────────╮ │ ┃ ● context-keeper 49% full · 102.6k left │ context-keeper │ │ ┃ ████████████████████░░░░░░░░░░░░░░░░░░░░ ⏺ Read(src/auth.ts) │ Writing checkpoint… │ │ ┃ 97.4k of 200.0k tokens ⎿ Read 6 lines ╰─────────────────────╯ │ ┃ ⏺ Update(src/auth.ts) ╭────────────────────────────────────────────╮ │ ┃ What fills it ⎿ Added 2 lines, re│ context-keeper │ │ ┃ No breakdown yet. It fills in after the ⏺ Bash(bun test) │ Checkpoint saved: │ │ ┃ first turn. ⎿ 3 pass, 1│ .claude/checkpoints/2025-10-09T08-53-20-handoff.md │ │ ┃ ╰────────────────────────────────────────────╯ │ ┃ Actions ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ [ Checkpoint ] [ Checkpoint + compact ] [ Re │ ┃ Compact, keeping:: e.g. the auth refactor an ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ Checkpoints › /ctx │ ┃ ✓ 1:53:20 AM handoff at 49% load ⎿ context-keeper: Context panel opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Context
● context-keeper 49% full · 102.6k left ████████████████████░░░░░░░░░░░░░░░░░░░░ 97.4k of 200.0k tokens What fills it No breakdown yet. It fills in after the first turn. Actions [ Checkpoint ] [ Checkpoint + compact ] [ Refresh ] Compact, keeping:: e.g. the auth refactor and failing tests Checkpoints ✓ 1:53:20 AM handoff at 49% load
README

🧩 claude-mods

Panels, guardrails and quality-of-life mods for Claude Code: see what fills your context, how fast you burn your plan, and what the agent is running right now, and block the commands you never want run.

CI Claude Code TypeScript Mods License: MIT

<img src="docs/demo-guardrails.gif" alt="guardrails blocking an agent's oc delete all --all before it runs" width="900">

<sub>If a mod saves you from one bad approve, a ⭐ on this repo helps other people find it.</sub>


What is this?

Claude Code is a black box while it works. You can't see the context window filling up until it compacts away the decision you made an hour ago. You find out you've burned your 5-hour limit when it stops answering. You approve a command, tab away, and come back ten minutes later to find it has been waiting on a second approval the whole time. And most people discover /rewind only after the edit they wanted to undo.

claude-mods is a set of small plugins that open that box. Each mod is a standalone Claude Code plugin built on function hooks, the in-process plugin API: it sees every prompt, tool call and turn as it happens, and it can draw panels, toasts and status lines inside Claude Code itself. Install all eleven or only the ones you want.

They are deliberately boring about tokens. Nine of the eleven never call a model. The two that do (prompt-coach's review and context-keeper's handoff note) say so and are easy to turn off.


✨ Features

🧩 mod-managerOne panel (/mods) to install, turn on or off, update, or remove each mod, with presets (Safety only, Essentials, Zero tokens, Everything). Shows when a mod has a newer version. Install this first and pick the rest
🚦 quickbarOne line above the prompt: live context % and a button for every claude-mods panel you have installed. Start here
🧠 context-keeperWhat fills your context window, tips to trim it, and checkpoints: a handoff note (goal, decisions, files, TODOs) saved to .claude/checkpoints/ so /clear and /compact stop losing the thread. Archives the raw transcript before every compaction, for zero tokens
📈 usage-meter5-hour and 7-day plan-limit bars with reset countdown, burn rate and "full in ~2.3h", session cost, cache hit ratio (and why it's low), tokens-per-turn sparkline (drawn as charts in the desktop app)
🔔 notifyNotification inbox plus native OS notifications (Windows, macOS, Linux) when a long turn ends, a subagent or background task finishes, Claude asks you something, or an approval has been waiting 15s
👀 activityWhat Claude is doing this second: thinking, running $ npm test for 0m42s, waiting for YOUR approval, which subagents run, and its todo plan with progress
🛡️ guardrailsClickable safety rules with presets (Safe defaults, Locked to project, Read-only review): block rm -rf, mass kubectl/oc deletes, terraform destroy, force-push, destructive git, .env and key files, sudo, installs, network; keep Claude inside the project; add your own patterns. Checks scripts the agent wrote before they run, and keeps a weekly log of what it blocked
✍️ prompt-coachBefore a vague prompt is sent, a small fast model suggests a sharper one. You pick Send improved / Send mine / Edit. Never rewrites silently, never touches "yes" or "continue"
🧰 toolboxEvery tool Claude can use (built-in and MCP, grouped by server) in plain language, how often each was used, and suggestions for the project you're in
⌨️ command-hubThe built-in commands you're probably missing, each with when to use it; a searchable list of everything installed; a form to create your own slash command; tips when your prompt matches one ("undo that" → /rewind)
📝 changesEvery file Claude created or edited this session with +/- counts from git, plus one-click why?, summarize all and self-review
🔁 loop-breakerNotices Claude going in circles (the same command failing 3×, the same code rewritten 3× in a turn), tells you, and tells Claude to stop and rethink

🎬 See it in action

activity: what Claude is running right now, and the moment it's waiting on you

<img src="docs/demo-activity.gif" alt="activity panel showing thinking, waiting for approval, then completed tool calls" width="900">

context-keeper + usage-meter: what fills the window, a checkpoint saved to disk, plan limits

<img src="docs/demo-context.gif" alt="context panel, writing a checkpoint, then the usage meter" width="900">

prompt-coach: a vague prompt, a suggested rewrite, you choose

<img src="docs/demo-coach.gif" alt="prompt coach suggesting a sharper prompt and sending it" width="900">

command-hub: the built-in commands worth knowing, explained

<img src="docs/demo-commands.gif" alt="command hub listing essential slash commands" width="900">


🏗️ How it works

              You ──prompt──▶ ┌──────────────────────────────┐
                              │        Claude Code engine     │
                              │                               │
                              │  prompt.submit   tool.call    │
                              │  turn.start      turn.complete│
                              │  session.compact ui.render    │
                              └──────┬─────────────────▲──────┘
                       events, in    │                 │  allow · deny · rewrite
                       order, live   ▼                 │  panes · toasts · status
                              ┌──────────────────────────────┐
                              │  claude-mods (one plugin each)│
                              │                               │
                              │  guardrails ── may deny ──────┤
                              │  prompt-coach ─ may ask you ──┤
                              │  activity · changes · notify  │
                              │  usage-meter · context-keeper │──▶ .claude/checkpoints/
                              │  toolbox · command-hub ...    │──▶ OS notifications
                              └──────────────────────────────┘

What a mod sees. Each mod registers hooks on engine events. A tool.call hook sits in front of every tool the agent runs (Bash, Edit, MCP tools, subagents) and sees its real arguments before anything executes. That is how activity can show the exact command and guardrails can refuse it.

What it can change. A hook either passes the event on, rewrites it, or answers it itself. Guardrails answers with a denial the model reads ("blocked by the user's guardrails, don't work around it"). Prompt-coach never rewrites on its own: it asks you first, in Claude Code's own question dialog.

What it draws. Panels are ui.render hooks returning a small element tree (Box, Text, Button, Input) that Claude Code draws natively in the terminal and the desktop app. State lives in the engine ($.state), so panels survive a hot reload and redraw only when their data changes.

What it costs. Nothing, for nine of the mods. They read numbers the engine already has ($.session.usage(), $.tool.list(), $.command.list()). The two model calls are opt-out and listed under Honest limits.


🚀 Quick Start

Prerequisites: Claude Code 2.1.286 or newer (claude update). Panels dock beside the chat in the desktop app or a terminal ≥ 144 columns wide, and open inline in narrower terminals.

1. Add the marketplace and the manager

From any terminal:

claude plugin marketplace add mishgoldenberg/claude-mods
claude plugin install mod-manager@claude-mods

(In the Claude Code CLI you can also use /plugin marketplace add mishgoldenberg/claude-mods and the /plugin menu.)

2. Pick your mods

Restart Claude Code. The first session says hello once ("claude-mods ready: /mods to pick your set"). Type /mods: the manager lists every mod with what it does, whether it uses tokens, and whether an update is available. Install, turn on or off, update, or remove each one with a click, or apply a preset:

PresetWhat you get
Safety onlyguardrails, notify
Essentialsguardrails, activity, notify, context-keeper, usage-meter
Zero tokensevery mod that never calls a model
Everythingall eleven

Presets turn off what they don't list and never remove anything. Changes apply in your next session.

3. Use the quickbar

With quickbar installed, a one-line bar sits above the prompt: live context % and a button for every installed panel. /quickbar (or its ×) hides it; /quickbar again brings it back.

Or just ask Claude: "Install the claude-mods plugins from github.com/mishgoldenberg/claude-mods. Follow its INSTALL-FOR-CLAUDE.md."

VS Code

The VS Code extension has no /plugin menu, so install from a terminal with the two commands in step 1, then use /mods inside VS Code. Mods need the extension to run Claude Code 2.1.286 or newer.

for m in quickbar context-keeper usage-meter notify activity guardrails          prompt-coach toolbox command-hub changes loop-breaker; do
  claude plugin install "$m@claude-mods"
done

Try one without installing

git clone https://github.com/mishgoldenberg/claude-mods.git
claude --plugin-dir claude-mods/plugins/activity --plugin-dir claude-mods/plugins/guardrails

💬 Commands

CommandModWhat it does
/modsmod-managerInstall, turn on or off, update, or remove mods; apply a preset
/quickbarquickbarShow or hide the launcher above the prompt
/ctxcontext-keeperOpen the context panel
/checkpointcontext-keeperSave a handoff note of this session now
/resume-checkpointcontext-keeperLoad the latest checkpoint into the prompt box (use after /clear)
/meterusage-meterOpen plan limits, burn rate and cost
/notificationsnotifyOpen the notification inbox
`/mute [on\off]`notifyMute or unmute notifications
/activityactivityOpen the live activity panel
/guardguardrailsOpen the rules panel
`/guard-preset <safe\locked\review\off>`guardrailsApply a preset
/guard-log [json]guardrailsWhat guardrails blocked in the last 7 days
`/coach [on\off]`prompt-coachTurn the prompt coach on or off
/toolstoolboxOpen the tools panel and project suggestions
/cmdscommand-hubOpen the command hub
/new-commandcommand-hubCreate your own slash command
/changeschangesOpen the changed-files panel

⚙️ Configuration Reference

Options appear in /config once a mod is installed, or go in ~/.claude/settings.json under pluginConfigs.<mod>.

context-keeper

OptionDefaultDescription
warnAt70Toast a tip when context passes this %
checkpointAt85Write a handoff note automatically at this % (0 = off)

usage-meter

OptionDefaultDescription
warnAt80Toast when a plan window passes this % (a second toast always fires at 95%)

notify

OptionDefaultDescription
minSeconds30Only notify for turns at least this long
osNotifytrueAlso raise a native OS notification
approvalWaitSeconds15Ping when an approval has waited this long (-1 = off)

activity · prompt-coach · loop-breaker

ModOptionDefaultDescription
activityautoOpenfalseDock the panel on session start when there is room
prompt-coachenabledtrueReview prompts before sending
prompt-coachmodelclaude-haiku-4-5Reviewer model; small keeps the delay near a second
prompt-coachminChars8Never review prompts shorter than this
loop-breakerfailLimit3Same failing command this many times in a row
loop-breakereditLimit3Times the same code is rewritten in one turn (separate edits to different parts of a file never count)

guardrails rules

RuleBlocksIn preset
no-rm-rfrm -rf, rm -fr, Remove-Item -Recurse -Force, rmdir /sSafe · Locked
no-mass-deletekubectl/oc delete --all or -A, delete namespace/project, helm uninstall, terraform destroySafe · Locked · Review
no-force-pushgit push --force / -f (--force-with-lease allowed)Safe · Locked
no-history-rewritegit reset --hard, git clean -f, git checkout -- ., branch -D, stash drop, fetch/pull --prune, remote pruneSafe · Locked
protect-secretsreading or writing .env*, *.pem, *.key, id_rsa, credentials filesSafe · Locked · Review
no-sudosudo, su -, runasSafe · Locked · Review
no-installsnpm i, pip install, cargo add, brew/apt/winget install …Review
no-networkWebFetch, WebSearch, curl, wget, Invoke-WebRequest—
jail-writesWrite/Edit outside the project folder—
jail-allany file tool outside the project, cd out of itLocked
read-onlyall edits; shell limited to look-only commandsReview

Guardrails turns on Safe defaults the first time it loads. /guard-preset off turns everything off.

Whatever the preset, while any rule is on:

  • Scripts are checked before they run. A file the agent creates with Write in this session (and edits after that) is remembered, and when a shell command runs it (bash cleanup.sh, python tools/x.py, ./run, node a.mjs), its code is checked against your enabled rules and patterns first. os.system("rm -rf …") inside a Python file counts.
  • The agent can't edit guardrails' own settings. Write/Edit on them, and the obvious shell writes (sed -i, >, tee, cp, mv, rm), are denied with the same "ask the user" message.
  • Blocks are logged. Every block is kept for 30 days on this machine. The panel shows the last 7 days by rule, /guard-log prints a summary (/guard-log json for the raw list), and clear wipes it. Commands are cut to 200 characters.

🔒 Is it safe to install?

Mods run inside Claude Code with your permissions and no sandbox, so this is the right question to ask about any mod, including these. What these ones do:

  • No network. No mod makes a web request, and there is no telemetry. Nothing leaves your machine except the two opt-in model calls below, which go through your own Claude Code session like any prompt.
  • Processes they start, all of them: git diff --numstat (changes), the claude plugin CLI when you open /mods or click in it (mod-manager; only Install, Update and Check for updates go online, through Claude Code's own plugin installer), your OS notification tool (notify), and gh --version to see whether the GitHub CLI exists (toolbox).
  • Files they write: checkpoints and pre-compaction archives under .claude/checkpoints/ (context-keeper), and a new slash command file when you use the form (command-hub). Settings, and guardrails' 30-day block log, live in Claude Code's own plugin store under ~/.claude. Nothing else.
  • Model calls: prompt-coach and context-keeper's handoff note, both listed below and both easy to turn off.

Every mod is a few hundred lines of TypeScript in plugins/<mod>/hooks/register.tsx. Read the ones you install.


⚠️ Honest limits

  • Guardrails is a seatbelt, not a sandbox. Rules are pattern checks on the commands and paths the agent passes to tools. Scripts the agent wrote this session are checked before they run, but a script that arrives another way (downloaded, generated by a shell command, already in the repo) or an obfuscated command gets through, and so can a shell trick that rewrites guardrails' settings. Quoted prose (commit messages, text written to files) is ignored so that mentioning rm -rf doesn't block you, except when the text is handed to a shell (bash -c, | sh, powershell -Command), which is checked. For hard guarantees use Claude Code's permission rules and sandboxing; use guardrails to catch the honest mistakes.
  • prompt-coach costs a little. A reviewed prompt waits about a second for a small model, and uses a few hundred tokens. When you send the improved version, the chat still shows what you typed; a dim "prompt-coach sent the improved version" line below it shows what was actually sent. Short replies are never reviewed. /coach off turns it off.
  • context-keeper's handoff note costs a little. It asks the model for a summary over the already-cached conversation, so it is mostly cache reads. The pre-compaction archive costs nothing.
  • Function hooks are early access. A Claude Code update can break a mod. CI validates every mod against the engine's own validator, and issues are welcome.

🗂️ Project Structure

claude-mods/
├── .claude-plugin/
│   └── marketplace.json        The marketplace: one entry per mod
├── plugins/
│   ├── activity/
│   │   ├── .claude-plugin/
│   │   │   └── plugin.json     Name, description, userConfig options
│   │   ├── hooks/
│   │   │   ├── hooks.json      Points at the hooks module
│   │   │   └── register.tsx    The mod: hooks, commands, panel
│   │   ├── tests/
│   │   │   └── surfaces.test.tsx   The panel draws on every surface (claude plugin test)
│   │   └── types/
│   │       └── index.d.ts      Its $.state contract
│   ├── guardrails/             …same shape for every mod
│   └── …
├── docs/
│   └── design.md               The shared design spec and kit every mod copies
├── tests/
│   ├── guardrails-rules.test.mjs   Every rule against real commands
│   └── design-kit.test.mjs     Every mod's kit matches docs/design.md
├── INSTALL-FOR-CLAUDE.md       Steps Claude follows when asked to install
└── tsconfig.json               Type-checks all mods against the engine API

🔧 Extending it

Add a guardrail rule. Append an entry to RULES in plugins/guardrails/hooks/register.tsx (an id, a title, and a test that returns the offending text) and add cases to tests/guardrails-rules.test.mjs. The panel and presets pick it up automatically.

Add an essential command. One line in ESSENTIALS in plugins/command-hub/hooks/register.tsx. It only shows when that command exists in the user's Claude Code.

Write a new mod. Copy plugins/changes (a small mod with a panel) or plugins/loop-breaker (no UI), rename it everywhere, and add it to marketplace.json. Or ask Claude Code to "make a mod that …": it has a built-in skill for exactly this, with hot reload.


🤝 Contributing

Issues and pull requests are welcome. Start with CONTRIBUTING.md, which has the mod checklist.

npm install
npm run typecheck
npm test
claude plugin validate plugins/<mod>
claude plugin test plugins/<mod>

Every mod follows one design spec, docs/design.md: theme colors only (Claude orange as the single accent), the same pane header, glyphs, hotkeys and empty states, so the set feels like one product in any theme.

Two house rules worth knowing before you write anything, because the validator enforces both:

  • Helpers that take $ must be top-level function declarations. A closure inside register that receives $ is rejected.
  • Every $.state key is declared in the mod's types/index.d.ts, under the mod's name.

Found a way around guardrails, or another security problem? Please report it privately; see SECURITY.md.


📄 License

MIT © 2026 Michael Goldenberg

Source 2 files
hooks/register.tsx 358 lines
1import { atom, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { Checkpoint, ContextSnapshot } from '../types'
5
6// ── claude-mods kit v1 (docs/design.md): identical in every mod ──
7const TONE = { accent: 'claude', ok: 'success', warn: 'warning', bad: 'error', dim: 'inactive' } as const
8const GLYPH = { on: '●', off: '○', warn: '▲', ok: '✓', fail: '✗' } as const
9type Kit = Pick<ElementTable, 'Box' | 'Text'>
10
11/** Pane header: state glyph, mod name, one-line live status. */
12function header({ Box, Text }: Kit, glyph: string, tone: string, name: string, status: string) {
13  return (
14    <Box gap={1}>
15      <Text color={tone}>{glyph}</Text>
16      <Text bold>{name}</Text>
17      <Text dimColor wrap="truncate-end">{status}</Text>
18    </Box>
19  )
20}
21
22/** A section: a dim label (with optional small controls beside it), then its rows. */
23function section({ Box, Text }: Kit, label: string, rows: RenderChildren, aside?: RenderChildren) {
24  return (
25    <Box flexDirection="column">
26      <Box gap={1}>
27        <Text dimColor>{label}</Text>
28        {aside}
29      </Box>
30      {rows}
31    </Box>
32  )
33}
34
35/** A number right-aligned in a fixed-width cell. */
36function num({ Box, Text }: Kit, value: string, width: number, color?: string) {
37  return (
38    <Box width={width} flexShrink={0} justifyContent="flex-end">
39      <Text color={color}>{value}</Text>
40    </Box>
41  )
42}
43
44/** Empty state: what will show up here, and how to get it. */
45function empty({ Text }: Kit, text: string) {
46  return <Text dimColor>{text}</Text>
47}
48// ── end kit ──
49
50const PANE = 'context-keeper'
51const snapshot = atom({ plugin: 'context-keeper', key: 'snapshot' } as const, null)
52const checkpoints = atom({ plugin: 'context-keeper', key: 'checkpoints' } as const, [])
53const busy = atom({ plugin: 'context-keeper', key: 'busy' } as const, null)
54
55const HANDOFF_PROMPT = `Write a compact handoff note for this session so a fresh session can continue with no loss.
56Use these markdown sections, terse bullets, no preamble:
57## Goal
58## Decisions made (and why)
59## Files touched (path: what changed)
60## Current state (what works, what is broken)
61## Open TODOs / next steps
62## Gotchas (things that were tried and failed)`
63
64const TIPS: Record<string, string> = {
65  'MCP tools': 'Disable MCP servers you are not using this session.',
66  'Memory files': 'Trim CLAUDE.md / memory files, or split rarely-needed parts into skills.',
67  'Custom agents': 'Remove agent definitions you never call.',
68  Skills: 'Skills are cheap until loaded; uninstall ones you never use.',
69  Messages: 'Most of the window is conversation: checkpoint, then /compact or /clear.',
70}
71
72/** Rows of the breakdown that are room, not content. */
73const NOT_CONTENT = /^(free space|autocompact buffer)$/i
74
75const bar = (percent: number, width: number) => {
76  const filled = Math.max(0, Math.min(width, Math.round((percent / 100) * width)))
77
78  return '█'.repeat(filled) + '░'.repeat(width - filled)
79}
80
81const k = (n: number) =>
82  n >= 1_000_000 ? `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)
83
84const stamp = (ms: number) => new Date(ms).toISOString().replace(/[:.]/g, '-').slice(0, 19)
85
86const join = (...parts: string[]) => parts.join('/').replace(/\\/g, '/').replace(/\/+/g, '/')
87
88async function refresh($: EngineInterface): Promise<ContextSnapshot | null> {
89  const usage = await $.session.usage({ breakdown: 'summary' })
90  const ctx = usage.context
91  const breakdown = ctx.breakdown
92  const next: ContextSnapshot = {
93    percent: ctx.percent ?? breakdown?.percentage ?? 0,
94    tokens: ctx.tokens ?? breakdown?.totalTokens ?? 0,
95    window: ctx.window,
96    categories: (breakdown?.categories ?? [])
97      .filter(c => c.tokens > 0 && !c.isDeferred && !NOT_CONTENT.test(c.name))
98      .map(c => ({ name: c.name, tokens: c.tokens }))
99      .sort((a, b) => b.tokens - a.tokens),
100    memoryFiles: (breakdown?.memoryFiles ?? [])
101      .map(f => ({ path: f.path, tokens: f.tokens }))
102      .sort((a, b) => b.tokens - a.tokens)
103      .slice(0, 5),
104    updatedAt: await $.clock.now(),
105  }
106  await update($, snapshot, () => next)
107
108  return next
109}
110
111async function remember($: EngineInterface, cp: Checkpoint) {
112  await update($, checkpoints, list => [cp, ...list].slice(0, 10))
113  const saved = ((await $.store.get('checkpoints')) as Checkpoint[] | undefined) ?? []
114  await $.store.set('checkpoints', [cp, ...saved].slice(0, 30))
115}
116
117/** Asks the model (over the cached transcript) for a handoff note and saves it. */
118async function writeHandoff($: EngineInterface, why: string) {
119  if ((await read($, busy)) !== null) {
120    $.ui.toast('Already writing a checkpoint, one moment…')
121    return
122  }
123  if ((await $.session.turns()) === 0) {
124    $.ui.toast('Nothing to checkpoint yet: a checkpoint summarizes the conversation, and this session has none. Chat first, then try again.', { timeoutMs: 8000 })
125    return
126  }
127  await update($, busy, () => 'Writing handoff note…')
128  $.ui.toast('Writing checkpoint…')
129  try {
130    const result = await $.model.fork({ prompt: HANDOFF_PROMPT })
131    if (!result.isAnswered) {
132      const why: Record<string, string> = {
133        'nothing-to-fork': 'there is no conversation to summarize yet (right after /clear, too)',
134        aborted: 'it was interrupted',
135        'empty-reply': 'the model returned nothing; try again',
136        'api-error': 'the API returned an error; try again in a moment',
137      }
138      $.ui.toast(`Checkpoint not saved: ${why[result.reason] ?? result.reason}.`, { timeoutMs: 8000 })
139      return
140    }
141    const now = await $.clock.now()
142    const percent = (await read($, snapshot))?.percent ?? 0
143    const path = join(await $.session.root(), '.claude', 'checkpoints', `${stamp(now)}-handoff.md`)
144    const header = `<!-- context-keeper handoff · ${why} · context ${Math.round(percent)}% · session ${await $.session.id()} -->\n\n`
145    await $.fs.write(path, header + result.text)
146    await remember($, { path, at: now, percent, kind: 'handoff' })
147    $.ui.toast(`Checkpoint saved: .claude/checkpoints/${stamp(now)}-handoff.md`)
148  } catch (error) {
149    $.ui.toast(`Checkpoint failed: ${String(error).slice(0, 80)}`)
150  } finally {
151    await update($, busy, () => null)
152  }
153}
154
155/** Zero-token archive: dumps the raw conversation text to disk before it is compacted away. */
156async function archive($: EngineInterface, messages: readonly { role: string; text: string; toolUses: readonly { tool: string }[] }[]) {
157  const now = await $.clock.now()
158  const lines = messages.map(m => {
159    const tools = m.toolUses.length > 0 ? `\n_tools: ${m.toolUses.map(t => t.tool).join(', ')}_` : ''
160
161    return `### ${m.role}\n\n${m.text.trim()}${tools}\n`
162  })
163  const path = join(await $.session.root(), '.claude', 'checkpoints', `${stamp(now)}-archive.md`)
164  await $.fs.write(path, `# Transcript archived before compaction\n\n${lines.join('\n')}`)
165  await remember($, { path, at: now, percent: (await read($, snapshot))?.percent ?? 0, kind: 'archive' })
166}
167
168export const register: Register = (on, options) => {
169  const warnAt = Number(options.warnAt ?? 70)
170  const checkpointAt = Number(options.checkpointAt ?? 85)
171  let warned = false
172  let checkpointed = false
173
174  on('session.start', async ($, e, next) => {
175    await $.command.register({ name: 'ctx', description: 'Open the context-keeper panel' })
176    await $.command.register({ name: 'checkpoint', description: 'Save a handoff note of this session to .claude/checkpoints' })
177    await $.command.register({ name: 'resume-checkpoint', description: 'Load the latest checkpoint into the prompt box (use after /clear)' })
178    const saved = ((await $.store.get('checkpoints')) as Checkpoint[] | undefined) ?? []
179    const root = (await $.session.root()).replace(/\\/g, '/')
180    await update($, checkpoints, () => saved.filter(cp => cp.path.startsWith(root)).slice(0, 10))
181
182    return next(e)
183  })
184
185  on('command.run', { command: 'ctx' }, async $ => {
186    await refresh($)
187    await $.ui.open({ id: PANE, title: 'Context' })
188
189    return { text: 'Context panel opened.' }
190  })
191
192  on('command.run', { command: 'checkpoint' }, async $ => {
193    await refresh($)
194    await writeHandoff($, 'manual')
195
196    return { text: 'Checkpoint written (see .claude/checkpoints).' }
197  })
198
199  on('command.run', { command: 'resume-checkpoint' }, async $ => {
200    const latest = (await read($, checkpoints)).find(cp => cp.kind === 'handoff')
201    if (latest === undefined) return { text: 'No checkpoint yet. Run /checkpoint first.' }
202    await $.prompt.fill({ text: `Read ${latest.path} and continue the work from where it left off. Confirm the next step before acting.` })
203
204    return { text: `Prompt box loaded with ${latest.path}` }
205  })
206
207  on('turn.complete', async ($, e, next) => {
208    const result = await next(e)
209    if (e.agentId !== undefined) return result
210    const snap = await refresh($)
211    if (snap === null) return result
212
213    if (snap.percent >= warnAt && !warned) {
214      warned = true
215      const top = snap.categories[0]
216      $.ui.toast(`Context ${Math.round(snap.percent)}% full${top ? ` (biggest: ${top.name} ${k(top.tokens)})` : ''}. /ctx for details.`, { timeoutMs: 8000 })
217    }
218    if (checkpointAt > 0 && snap.percent >= checkpointAt && !checkpointed) {
219      checkpointed = true
220      void writeHandoff($, `auto at ${checkpointAt}%`)
221      void $.prompt.suggest({ text: '/compact' })
222    }
223    $.ui.status(snap.percent >= warnAt ? `ctx ${Math.round(snap.percent)}%` : undefined)
224
225    return result
226  })
227
228  on('session.compact', async ($, e, next) => {
229    if (e.agentId === undefined && e.messages.length > 0) {
230      try {
231        await archive($, e.messages)
232      } catch (error) {
233        $.ui.log(`context-keeper: archive failed: ${String(error)}`, { to: 'debug' })
234      }
235    }
236    const result = await next(e)
237    warned = false
238    checkpointed = false
239    void refresh($)
240
241    return result
242  })
243
244  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
245    const els = $.ui.resolve(e)
246    const { Box, Text, Button } = els
247    const Input = 'Input' in els ? els.Input : undefined
248    const snap = await read($, snapshot)
249    const cps = await read($, checkpoints)
250    const working = await read($, busy)
251    const width = Math.max(10, Math.min(40, (e.props.bodyColumns ?? 40) - 12))
252
253    const kit = { Box, Text }
254
255    if (snap === null) {
256      return (
257        <Box flexDirection="column" gap={1}>
258          {header(kit, GLYPH.off, TONE.dim, 'context-keeper', 'no reading yet')}
259          {empty(kit, 'The context reading updates after every turn. Measure now to see what fills the window already.')}
260          <Box>
261            <Button key="refresh" hotkey="r" variant="primary" label="Measure now" onPress={() => void refresh($)} />
262          </Box>
263        </Box>
264      )
265    }
266
267    const pct = Math.round(snap.percent)
268    const tone = snap.percent >= checkpointAt ? TONE.bad : snap.percent >= warnAt ? TONE.warn : TONE.accent
269    const glyph = snap.percent >= warnAt ? GLYPH.warn : GLYPH.on
270    const tips = snap.categories.map(c => TIPS[c.name]).filter((t): t is string => t !== undefined).slice(0, 2)
271
272    return (
273      <Box flexDirection="column" gap={1}>
274        <Box flexDirection="column">
275          {header(kit, glyph, tone, 'context-keeper', `${pct}% full · ${k(Math.max(0, snap.window - snap.tokens))} left`)}
276          <Text color={tone}>{bar(snap.percent, width)}</Text>
277          <Text dimColor wrap="truncate-end">
278            {k(snap.tokens)} of {k(snap.window)} tokens
279          </Text>
280        </Box>
281
282        {section(
283          kit,
284          'What fills it',
285          <Box flexDirection="column">
286            {snap.categories.length === 0 && empty(kit, 'No breakdown yet. It fills in after the first turn.')}
287            {snap.categories.slice(0, 7).map(c => (
288              <Box gap={1}>
289                <Box flexGrow={1} flexShrink={1}>
290                  <Text wrap="truncate-end">{c.name}</Text>
291                </Box>
292                {num(kit, k(c.tokens), 7)}
293                <Text dimColor>{bar((c.tokens / Math.max(1, snap.window)) * 100, 12)}</Text>
294              </Box>
295            ))}
296            {snap.memoryFiles.length > 0 && (
297              <Text dimColor wrap="truncate-end">
298                Largest memory file: {snap.memoryFiles[0]?.path.split(/[\/]/).at(-1)} ({k(snap.memoryFiles[0]?.tokens ?? 0)})
299              </Text>
300            )}
301          </Box>,
302        )}
303
304        {tips.length > 0 &&
305          section(
306            kit,
307            'Tips',
308            tips.map(t => <Text dimColor>• {t}</Text>),
309          )}
310
311        {section(
312          kit,
313          'Actions',
314          <Box flexDirection="column">
315            {working !== null && <Text color={TONE.accent}>{GLYPH.on} {working}</Text>}
316            <Box gap={1} flexWrap="wrap">
317              <Button key="checkpoint" hotkey="c" variant="primary" label="Checkpoint" onPress={() => void writeHandoff($, 'manual')} />
318              <Button key="compact" hotkey="k" label="Checkpoint + compact" onPress={() => void writeHandoff($, 'before compact').then(() => $.command.run({ command: 'compact' }))} />
319              <Button key="refresh" hotkey="r" label="Refresh" onPress={() => void refresh($)} />
320            </Box>
321            {Input !== undefined && (
322              <Input
323                key="focus"
324                label="Compact, keeping:"
325                placeholder="e.g. the auth refactor and failing tests"
326                submitLabel="Compact"
327                onSubmit={(value: string) => void $.command.run({ command: 'compact', args: value })}
328              />
329            )}
330          </Box>,
331        )}
332
333        {section(
334          kit,
335          'Checkpoints',
336          <Box flexDirection="column">
337            {cps.length === 0 && empty(kit, 'None yet. A checkpoint is a handoff note (goal, decisions, files, TODOs) you can reload after /clear.')}
338            {cps.slice(0, 4).map((cp, i) => (
339              <Box gap={1}>
340                <Text color={TONE.ok}>{GLYPH.ok}</Text>
341                <Text dimColor wrap="truncate-end">
342                  {new Date(cp.at).toLocaleTimeString()} {cp.kind} at {Math.round(cp.percent)}%
343                </Text>
344                <Button
345                  key={`load-${i}`}
346                  plain
347                  label="load"
348                  onPress={() => void $.prompt.fill({ text: `Read ${cp.path} and continue the work from where it left off. Confirm the next step before acting.` })}
349                />
350              </Box>
351            ))}
352          </Box>,
353        )}
354      </Box>
355    )
356  })
357}
358
types/index.d.ts 21 lines
1export type ContextSnapshot = {
2  percent: number
3  tokens: number
4  window: number
5  categories: { name: string; tokens: number }[]
6  memoryFiles: { path: string; tokens: number }[]
7  updatedAt: number
8}
9
10export type Checkpoint = { path: string; at: number; percent: number; kind: 'handoff' | 'archive' }
11
12declare module 'claude-code' {
13  interface PluginState {
14    'context-keeper': {
15      snapshot: ContextSnapshot | null
16      checkpoints: Checkpoint[]
17      busy: string | null
18    }
19  }
20}
21