Keep huge shell output out of the context window. A Bash result over a threshold is saved to a file and Claude gets its head and tail plus a slice tool to grep…

Keep huge shell output out of the context window. One npm test, cat package-lock.json, or verbose build can drop 50 KB into context, and it stays there until compaction. With this mod, a Bash result longer than a threshold is saved to a scratch file, and Claude gets the first and last 40 lines plus a note that names a slice tool it can call to grep or page the rest on demand.
Nothing is lost. The saved file holds the whole output; only the part in context is smaller.
/plugin marketplace add MDmubarak786/claude-mods
/plugin install big-output@modhub
Try it for one session without installing:
claude --plugin-dir ./mods/big-output
Nothing to do. When a Bash result is longer than 20,000 characters, a dim line in the transcript says it was saved, and Claude reads the head, the tail, and this note:
[big-output: this output is 1000 lines and 38 KB, so only its head and tail are shown. To read more, call the slice tool with id "1" and either grep="<regex>" or from=<line> and to=<line>. Lines are numbered from 1.]
Claude then calls slice as it needs: grep="error" with up to 5 lines of context, or from=100, to=200 for a page of at most 400 lines.
| Command | What it does |
|---|---|
/big-output | Show the threshold and the outputs saved this session. |
/big-output 50000 | Trim output longer than 50,000 characters. Remembered across sessions. |
From claude plugin validate ./mods/big-output:
hooks: session.start, command.run{command=big-output}, tool.call{tool=Bash}, tool.call{tool=mcp__big-output__slice}
calls: $.command.register, $.fs.read, $.fs.write (via save), $.process.run, $.store.get, $.store.set, $.tool.register, $.ui.log
$.process.run runs mktemp -d once at session start for the scratch directory. Nothing else is run.$.fs.write saves outputs there; $.fs.read reads them back for the slice tool. Both are capped at 4 MB per file, so an output beyond that is saved in part and the note says so.$.tool.register adds the slice tool Claude calls; it's loaded upfront so Claude sees its description without searching.Failure policy. This is a convenience, not a guard. If trimming fails, Claude gets the untrimmed output.
claude plugin validate --strict and claude plugin test pass. /big-output answered from a live claude -p session. A real oversized command in a session hasn't been exercised on screen by the author yet.stdout is trimmed. A huge stderr goes through as it is.mcp__big-output__slice.MIT, see the repository root.
hooks/register.ts 164 lines1// big-output: keep huge shell output out of the context window.
2//
3// /big-output show the threshold and the outputs saved this session
4// /big-output 50000 hold output longer than 50,000 characters (default 20,000)
5//
6// When a Bash result is longer than the threshold, the full output is saved to
7// a scratch file and Claude gets the first and last 40 lines plus a note that
8// names a `slice` tool it can call to grep or page the rest. Nothing is lost;
9// it just isn't all in context at once.
10
11const DEFAULT_THRESHOLD = 20_000
12const EDGE_LINES = 40
13const MAX_SAVED = 4 * 1024 * 1024 // $.fs.write's limit
14const MAX_SLICE_LINES = 400
15const MAX_GREP_LINES = 200
16
17type Saved = { id: string; path: string; lines: number; chars: number; command: string }
18
19let scratch = ''
20let threshold = DEFAULT_THRESHOLD
21let nextId = 1
22const saved = new Map<string, Saved>()
23
24function trimmed(stdout: string, s: Saved, cut: boolean): string {
25 const lines = stdout.split('\n')
26 if (lines.length <= EDGE_LINES * 2 + 1) {
27 // Few lines but very long ones: keep a character budget instead.
28 return stdout.slice(0, threshold / 2) + '\n\n' + marker(s, cut) + '\n'
29 }
30 const head = lines.slice(0, EDGE_LINES).join('\n')
31 const tail = lines.slice(-EDGE_LINES).join('\n')
32 return head + '\n\n' + marker(s, cut) + '\n\n' + tail
33}
34
35function marker(s: Saved, cut: boolean): string {
36 return (
37 '[big-output: this output is ' + s.lines + ' lines and ' + Math.round(s.chars / 1024) + ' KB, so only its head and tail are shown' +
38 (cut ? ', and only the first 4 MB was saved' : '') + '. To read more, call the slice tool with id "' + s.id +
39 '" and either grep="<regex>" or from=<line> and to=<line>. Lines are numbered from 1.]'
40 )
41}
42
43function numbered(lines: string[], from: number): string {
44 return lines.map((l, i) => String(from + i).padStart(6) + ' ' + l).join('\n')
45}
46
47async function save($, command: string, stdout: string): Promise<Saved | null> {
48 if (!scratch) return null
49 const id = String(nextId++)
50 const path = scratch + '/' + id + '.out'
51 const text = stdout.length > MAX_SAVED ? stdout.slice(0, MAX_SAVED) : stdout
52 await $.fs.write(path, text)
53 const s: Saved = { id, path, lines: stdout.split('\n').length, chars: stdout.length, command }
54 saved.set(id, s)
55 return s
56}
57
58async function trimLargeOutput($, e, next) {
59 const r = await next(e)
60 if (r.deny || !r.result || typeof r.result.stdout !== 'string' || r.result.stdout.length <= threshold) return r
61 const s = await save($, e.command, r.result.stdout)
62 if (!s) return r
63 const cut = r.result.stdout.length > MAX_SAVED
64 $.ui.log('saved ' + s.lines + ' lines of output as id ' + s.id + '; Claude sees the head and tail')
65 // A fresh { result } without ref or text, so the trimmed stdout is what Claude reads.
66 return { result: { ...r.result, stdout: trimmed(r.result.stdout, s, cut) } }
67}
68
69async function slice($, e) {
70 const s = saved.get(String(e.id))
71 if (!s) return { result: 'big-output: no saved output with id "' + e.id + '". Saved ids: ' + ([...saved.keys()].join(', ') || 'none') }
72 let text: string
73 try {
74 text = await $.fs.read(s.path)
75 } catch (error) {
76 return { result: 'big-output: could not read the saved output: ' + error }
77 }
78 const lines = text.split('\n')
79 if (typeof e.grep === 'string' && e.grep) {
80 let re: RegExp
81 try {
82 re = new RegExp(e.grep, 'i')
83 } catch {
84 re = new RegExp(e.grep.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i')
85 }
86 const context = Number.isInteger(e.context) && e.context > 0 ? Math.min(e.context, 5) : 0
87 const hits: number[] = []
88 lines.forEach((l, i) => {
89 if (re.test(l)) hits.push(i)
90 })
91 const shown = new Set<number>()
92 for (const h of hits) for (let i = Math.max(0, h - context); i <= Math.min(lines.length - 1, h + context); i++) shown.add(i)
93 const rows = [...shown].sort((a, b) => a - b).slice(0, MAX_GREP_LINES)
94 if (!rows.length) return { result: 'No line matches /' + e.grep + '/i in ' + s.lines + ' lines.' }
95 const out = rows.map((i) => String(i + 1).padStart(6) + ' ' + lines[i]).join('\n')
96 return { result: hits.length + ' matching line(s)' + (shown.size > rows.length ? ', first ' + rows.length + ' shown' : '') + ':\n' + out }
97 }
98 const from = Number.isInteger(e.from) && e.from >= 1 ? e.from : 1
99 const to = Number.isInteger(e.to) && e.to >= from ? Math.min(e.to, from + MAX_SLICE_LINES - 1) : Math.min(lines.length, from + MAX_SLICE_LINES - 1)
100 const part = lines.slice(from - 1, to)
101 return { result: 'Lines ' + from + ' to ' + Math.min(to, lines.length) + ' of ' + lines.length + ':\n' + numbered(part, from) }
102}
103
104export function register(on) {
105 on('session.start', async ($, e, next) => {
106 try {
107 const t = await $.store.get('threshold')
108 if (typeof t === 'number' && t >= 1000) threshold = t
109 } catch {
110 threshold = DEFAULT_THRESHOLD
111 }
112 try {
113 scratch = (await $.process.run(['mktemp', '-d'])).stdout.trim()
114 } catch (error) {
115 scratch = ''
116 $.ui.log('big-output: no scratch directory, so outputs will not be trimmed: ' + error)
117 }
118 try {
119 await $.tool.register({
120 name: 'slice',
121 description: 'Read part of a large shell output that big-output saved. Give the id from the [big-output: ...] note, then either grep (a regex, case-insensitive, with optional context lines) or a from/to line range.',
122 inputSchema: {
123 type: 'object',
124 properties: {
125 id: { type: 'string', description: 'The id from the big-output note' },
126 grep: { type: 'string', description: 'Regex to search for; returns matching lines with numbers' },
127 context: { type: 'number', description: 'Lines of context around each grep match, up to 5' },
128 from: { type: 'number', description: 'First line to return, from 1' },
129 to: { type: 'number', description: 'Last line to return; at most 400 lines per call' },
130 },
131 required: ['id'],
132 },
133 isDeferred: false,
134 })
135 } catch (error) {
136 $.ui.log('could not register the slice tool: ' + error)
137 }
138 try {
139 await $.command.register({ name: 'big-output', description: 'Threshold for trimming shell output, and what was saved', argumentHint: '[chars]' })
140 } catch (error) {
141 $.ui.log('could not register /big-output: ' + error)
142 }
143 return next(e)
144 })
145
146 on('command.run', { command: 'big-output' }, async ($, e) => {
147 const args = e.args.trim()
148 if (args) {
149 const n = Number(args)
150 if (!Number.isInteger(n) || n < 1000) return { text: 'Usage: /big-output <characters>, at least 1000.' }
151 threshold = n
152 await $.store.set('threshold', n)
153 return { text: 'Output longer than ' + n + ' characters is trimmed.' }
154 }
155 const list = [...saved.values()].map((s) => ' id ' + s.id + ': ' + s.lines + ' lines, ' + Math.round(s.chars / 1024) + ' KB, from: ' + s.command.slice(0, 60))
156 return { text: 'Trimming output longer than ' + threshold + ' characters. Saved this session:\n' + (list.join('\n') || ' nothing yet') }
157 }).catch(async () => ({ text: 'big-output: the command failed, so nothing changed.' }))
158
159 // Not a safety guard: if trimming fails, Claude gets the untrimmed result.
160 on('tool.call', { tool: 'Bash' }, trimLargeOutput).catch(async ($, e, next) => next(e))
161
162 on('tool.call', { tool: 'mcp__big-output__slice' }, slice).catch(async () => ({ result: 'big-output: the slice tool failed. Try a narrower range.' }))
163}
164