Keeps Claude Code sessions light: a line above the prompt with context and usage, a heads-up and an automatic compaction before the window fills, a stop on…

A Claude Code mod that keeps sessions light, so you hit the context window and your usage limits later.
I build with Claude Code every day on a weekly limit. Most of the waste I found came from three places: a session that had grown heavy before I noticed, one whole read of a 15,826-line type definition file, and git status or a log printing hundreds of lines Claude never needed. This mod handles those three.
| What happens | |
|---|---|
| A line above the prompt | context 72% · 5h 18% · week 41%. It turns yellow once the context passes the heads-up line. A figure the engine does not report is left out instead of being shown as 0. |
| A heads-up, then compaction | At 80% context you get a one-time notice: a good moment to write a handoff and start fresh. At 90% the session is compacted once, told to keep decisions, open work, files being changed, checks to rerun and pitfalls. Both arm again after the context drops. |
| No whole reads of huge files | A Read without offset or limit on a text file over 1,000 lines is refused, with a hint to read a range or use Grep. Images, PDFs and notebooks pass. |
| Long shell output cut | Bash and PowerShell output over 400 lines reaches Claude as its first 150 and last 50 lines, with a note naming the lines left out and how to get them: rerun the command narrowed with grep, or ask for a line range. Output from a failed command, or that mentions an error, a failure, a traceback, a panic or an exception, is left whole, because the line that matters may be in the middle. |
Every threshold is a setting, and 0 turns that part off.
/plugin marketplace add naoanao/context-saver
/plugin install context-saver@context-saver
It is also listed in the shared-repo-guard marketplace, so if you already added that one: /plugin install context-saver@shared-repo-guard.
| Setting | Default | What it means |
|---|---|---|
| Heads-up at (%) | 80 | Context fill for the one-time notice. |
| Compact at (%) | 90 | Context fill for the one-time compaction. |
| Largest whole-file read (lines) | 1000 | Longer text files must be read in ranges. |
| Longest shell output (lines) | 400 | Longer output is cut to head and tail. |
$.session.usage()), the same ones the status line showssession.start and turn.complete to read usage, ui.render for the line above the prompt, tool.call on Read, Bash and PowerShell.13 tests (claude plugin test in this folder). Each guard was also checked the other way: with the refusal, the cut, or the once-only compaction removed, its test fails.
MIT license.
hooks/register.tsx 94 lines1import {
2 toLimit, bandText, initialState, decide, COMPACT_INSTRUCTIONS,
3 readVerdict, countLines, trimVerdict, trimmedText, withStdout,
4} from './logic.js'
5
6let warnAt = 80
7let compactAt = 90
8let maxReadLines = 1000
9let maxOutputLines = 400
10
11let state = initialState()
12let band = ''
13let high = false
14
15async function refresh($) {
16 let usage
17 try {
18 usage = await $.session.usage()
19 } catch {
20 return
21 }
22 const text = bandText(usage)
23 const d = decide(usage?.context?.percent, state, warnAt, compactAt)
24 state = d.state
25 if (d.warn) {
26 $.ui.toast('context-saver: context is ' + Math.round(usage.context.percent) + '% full. A good moment to write a handoff and start a new session.')
27 }
28 if (d.compact) {
29 $.ui.toast('context-saver: context is ' + Math.round(usage.context.percent) + '% full. Compacting now.')
30 try {
31 await $.session.compact({ instructions: COMPACT_INSTRUCTIONS })
32 } catch {
33 // the engine may refuse mid-turn; the next reading tries again
34 state = { ...state, compacted: false }
35 }
36 }
37 if (text !== band || d.high !== high) {
38 band = text
39 high = d.high
40 $.ui.invalidate('ui.render')
41 }
42}
43
44export function register(on, options) {
45 warnAt = toLimit(options?.warnAt, 80)
46 compactAt = toLimit(options?.compactAt, 90)
47 maxReadLines = toLimit(options?.maxReadLines, 1000)
48 maxOutputLines = toLimit(options?.maxOutputLines, 400)
49
50 on('session.start', async ($, e, next) => {
51 const r = await next(e)
52 state = initialState()
53 await refresh($)
54 return r
55 })
56
57 on('turn.complete', async ($, e, next) => {
58 const r = await next(e)
59 await refresh($)
60 return r
61 })
62
63 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
64 if (!band) return next(e)
65 const { Box, Text } = $.ui.resolve(e)
66 return (
67 <Box flexDirection="row">
68 <Text color={high ? 'yellow' : 'gray'}>{band}</Text>
69 </Box>
70 )
71 })
72
73 on('tool.call', { tool: 'Read' }, async ($, e, next) => {
74 if (maxReadLines > 0 && typeof e.offset !== 'number' && typeof e.limit !== 'number' && e.file_path) {
75 try {
76 if (await $.fs.exists(e.file_path)) {
77 const reason = readVerdict(e, countLines(await $.fs.read(e.file_path)), maxReadLines)
78 if (reason) return { deny: reason }
79 }
80 } catch {
81 // an unreadable file goes to the normal Read, which reports its own error
82 }
83 }
84 return next(e)
85 })
86
87 on('tool.call', { tool: ['Bash', 'PowerShell'] }, async ($, e, next) => {
88 const res = await next(e)
89 const v = trimVerdict(res?.result?.stdout, res?.isError, maxOutputLines)
90 if (!v) return res
91 return withStdout(res, trimmedText(v))
92 })
93}
94hooks/logic.js 103 lines1// Pure decisions, kept apart from the hooks so they can be tested directly.
2
3export const HEAD_LINES = 150
4export const TAIL_LINES = 50
5
6// A setting arrives as text; anything that is not a number >= 0 falls back to the default.
7export function toLimit(value, fallback) {
8 const n = Number(String(value ?? '').trim())
9 return String(value ?? '').trim() !== '' && Number.isFinite(n) && n >= 0 ? n : fallback
10}
11
12// The line above the prompt. A figure the engine does not report is left out, never shown as 0.
13export function bandText(usage) {
14 const parts = []
15 const c = usage?.context?.percent
16 if (typeof c === 'number') parts.push('context ' + Math.round(c) + '%')
17 for (const [kind, label] of [['five_hour', '5h'], ['seven_day', 'week']]) {
18 const w = (usage?.rateLimits || []).find((r) => r?.kind === kind)
19 if (typeof w?.percentUsed === 'number') parts.push(label + ' ' + Math.round(w.percentUsed) + '%')
20 }
21 return parts.join(' · ')
22}
23
24export function initialState() {
25 return { warned: false, compacted: false }
26}
27
28// What to do at this context fill. Each step fires once, and is armed again only after the fill
29// drops back under the heads-up line (a compaction or /clear), so a long session can use it again.
30export function decide(percent, state, warnAt, compactAt) {
31 const next = { ...state }
32 const out = { warn: false, compact: false, high: false, state: next }
33 if (typeof percent !== 'number') return out
34 const low = warnAt > 0 ? warnAt : compactAt
35 if (low > 0 && percent < low) {
36 next.warned = false
37 next.compacted = false
38 return out
39 }
40 out.high = true
41 if (compactAt > 0 && percent >= compactAt && !state.compacted) {
42 out.compact = true
43 next.compacted = true
44 next.warned = true
45 } else if (warnAt > 0 && percent >= warnAt && !state.warned) {
46 out.warn = true
47 next.warned = true
48 }
49 return out
50}
51
52export const COMPACT_INSTRUCTIONS =
53 'Keep: decisions made and why, work still open, files being changed, commands and checks that must be rerun, and pitfalls found. Drop: long tool output already acted on.'
54
55const NOT_TEXT = /\.(png|jpe?g|gif|webp|svg|bmp|ico|tiff?|avif|pdf|ipynb)$/i
56
57// Whether a Read should be refused. Ranges and non-text files always pass.
58export function readVerdict(args, lineCount, maxLines) {
59 if (!(maxLines > 0)) return null
60 if (typeof args?.offset === 'number' || typeof args?.limit === 'number') return null
61 if (NOT_TEXT.test(String(args?.file_path || ''))) return null
62 if (!(lineCount > maxLines)) return null
63 return (
64 'context-saver: this file has ' + lineCount + ' lines. Read a range with offset and limit, ' +
65 'or search for the part you need with Grep, instead of reading it whole.'
66 )
67}
68
69export function countLines(text) {
70 if (!text) return 0
71 return String(text).split(/\r?\n/).length
72}
73
74// Long output is cut to its head and tail. Output that failed, or that mentions an error or a
75// failing test, is left whole: the line that matters may be in the middle.
76export function trimVerdict(stdout, isError, maxLines) {
77 if (!(maxLines > 0) || isError || typeof stdout !== 'string' || !stdout) return null
78 if (/error|fail|traceback|panic|exception/i.test(stdout)) return null
79 const lines = stdout.split(/\r?\n/)
80 if (lines.length <= maxLines) return null
81 return {
82 head: lines.slice(0, HEAD_LINES).join('\n'),
83 tail: lines.slice(-TAIL_LINES).join('\n'),
84 omitted: lines.length - HEAD_LINES - TAIL_LINES,
85 total: lines.length,
86 }
87}
88
89export function trimmedText(v) {
90 const first = v.total - v.omitted - TAIL_LINES + 1
91 const note =
92 '[context-saver: lines ' + first + '-' + (v.total - TAIL_LINES) + ' of ' + v.total + ' left out. ' +
93 'If you need them, rerun the command narrowed with grep, or with sed -n or Select-Object for a line range]'
94 return v.head + '\n' + note + '\n' + v.tail
95}
96
97// The engine's tool result with stdout replaced. `ref` and `text` are dropped so the engine shows the
98// replaced result instead of the original one.
99export function withStdout(res, stdout) {
100 const { ref, text, ...rest } = res
101 return { ...rest, result: { ...res.result, stdout } }
102}
103