SLOPSHOPPER

big-output

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…

newguardcommandtoolprocess
v0.1.0MITupdated 2026-10-09MDmubarak786/claude-mods/mods/big-output
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · big-output
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /big-output ⎿ big-output: Trimming output longer than 20000 characters. Saved this session: ⎿ big-output: nothing yet ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

big-output

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.

Install

/plugin marketplace add MDmubarak786/claude-mods
/plugin install big-output@modhub

Try it for one session without installing:

claude --plugin-dir ./mods/big-output

Use it

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.

CommandWhat it does
/big-outputShow the threshold and the outputs saved this session.
/big-output 50000Trim output longer than 50,000 characters. Remembered across sessions.

What it touches

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.
  • The threshold is the only thing saved between sessions.

Failure policy. This is a convenience, not a guard. If trimming fails, Claude gets the untrimmed output.

Tested with

  • Claude Code 2.1.295, 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.

Limitations

  • Only stdout is trimmed. A huge stderr goes through as it is.
  • Saved outputs live for the session, in a temp directory. After a restart the ids are gone.
  • Background commands and MCP tool outputs aren't covered.
  • The slice tool's name as Claude sees it is mcp__big-output__slice.

License

MIT, see the repository root.

Source 1 files
hooks/register.ts 164 lines
1// 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