Trims long Bash output to its start, end and error lines, and saves the full output to a file

A Claude Code mod that shortens long Bash output before Claude reads it. Test runs, installs and builds can put thousands of lines into the context, and Claude re-reads them on every later turn of the session.
When a command prints more than 150 lines or 8,000 characters, Claude gets:
Long lines are cut at 300 characters. The full output goes to claude-trimmed/ in your temp folder.
Some commands are left whole, because Claude ran them to see exactly that text: cat, head, tail, sed, awk, grep, rg, jq, diff, git diff, git show, git log and git blame. Reading the saved file back is left whole too.
When a command fails, Claude still sees the trimmed output as an error. The debug log notes this as the mod refusing the call after it ran. The command did run, and nothing is undone.
Only Bash output is trimmed. Output from MCP tools isn't touched.
/plugin install output-trimmer --marketplace noash-xrc/claude-tools
Answer y to add the marketplace, then pick a scope.
claude plugin marketplace update noash-tools
claude plugin update output-trimmer@noash-tools
Restart Claude Code to load the new version.
MIT
hooks/register.ts 68 lines1import { atom, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4const MAX_LINES = 150
5const MAX_CHARS = 8000
6const HEAD = 30
7const TAIL = 60
8const MAX_SIGNALS = 40
9const LINE_CHARS = 300
10const DIR = 'claude-trimmed'
11// savings-meter reads this
12const trimmedAtom = atom({ plugin: 'output-trimmer', key: 'trimmed' } as const, 0)
13const SIGNAL = /error|fail|warn|exception|traceback|panic|assert|denied|not found|✗|✖/i
14// Claude asked for exactly this text (a file, a diff, a search), so trimming it would hide what it came for.
15const READS = /^\s*(cat|type|head|tail|sed|awk|less|more|grep|rg|jq|diff|git\s+(diff|show|log|blame))\b/
16
17// Keeps the first and last lines plus any line that looks like an error; undefined when it's short enough already.
18export const trim = (text: string): string | undefined => {
19 const lines = text.split('\n')
20 if (lines.length <= MAX_LINES && text.length <= MAX_CHARS) return undefined
21 const keep = lines.map((_, i) => i < HEAD || i >= lines.length - TAIL)
22 let signals = 0
23 lines.forEach((line, i) => {
24 if (!keep[i] && signals < MAX_SIGNALS && SIGNAL.test(line)) keep[i] = !!++signals
25 })
26 const out: string[] = []
27 let gap = 0
28 lines.forEach((line, i) => {
29 if (!keep[i]) return void gap++
30 if (gap) out.push(`... ${gap} lines trimmed ...`)
31 gap = 0
32 out.push(line.length > LINE_CHARS ? `${line.slice(0, LINE_CHARS)}...` : line)
33 })
34 if (gap) out.push(`... ${gap} lines trimmed ...`)
35 const trimmed = out.join('\n')
36 return trimmed.length < text.length ? trimmed : undefined
37}
38
39const save = async ($: EngineInterface, id: string, text: string, kept: number) => {
40 await update($, trimmedAtom, n => n + Math.round((text.length - kept) / 4))
41 const tmp = (await $.env.get('TEMP')) ?? (await $.env.get('TMPDIR')) ?? '/tmp'
42 const path = `${tmp.replace(/\\/g, '/')}/${DIR}/${id}.txt`
43 await $.fs.write(path, text)
44 return `[output-trimmer kept the start, the end and the error lines. Full output: ${path}]`
45}
46
47export const register: Register = on => {
48 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
49 const ran = await next(e)
50 if (ran.deny !== undefined || READS.test(e.command) || e.command.includes(DIR)) return ran
51 const id = e.tool_use_id ?? String(await $.clock.now())
52
53 // A failed command's output only reaches Claude as error text; a deny after the run delivers the trimmed text the same way.
54 if (ran.isError) {
55 const short = ran.text === undefined ? undefined : trim(ran.text)
56 return short === undefined || ran.text === undefined ? ran : { deny: `${await save($, id, ran.text, short.length)}\n${short}` }
57 }
58
59 const r = ran.result
60 if (r.isImage || r.backgroundTaskId || r.persistedOutputPath) return ran
61 const stdout = trim(r.stdout)
62 const stderr = trim(r.stderr)
63 if (stdout === undefined && stderr === undefined) return ran
64 const note = await save($, id, `${r.stdout}\n${r.stderr}`, (stdout ?? r.stdout).length + (stderr ?? r.stderr).length)
65 return { result: { ...r, stdout: `${note}\n${stdout ?? r.stdout}`, stderr: stderr ?? r.stderr }, context: ran.context }
66 }).catch(($, e, next) => next(e))
67}
68types/index.d.ts 8 lines1// tokens cut from Bash output this session, at about 4 characters a token
2declare module 'claude-code' {
3 interface PluginState {
4 'output-trimmer': { trimmed: number }
5 }
6}
7export type Contract = never
8