Measures how much context each Bash command spent and tells the model, past a size limit, which narrower command would have answered the same question.

The model runs pytest tests/ -v or find . for one answer, and tens of KB of output land in the context and stay there for the rest of the session. The next requests carry that weight, and the model does not know what it cost. This mod measures how much of your context each Bash command spent. When a command's output passes a size limit, it tells the model what it cost and which narrower command would have answered the same question.
output-flood: "pytest tests/ -v" returned 30 KB of output, over the 20 KB limit, and all of it is now in the context. Next time run the one test or file this turn needs, and let the runner report only failures (pytest -x -q, go test -run, cargo test <name>, jest -t).
The advice follows the kind of command: a test runner, git log/diff/show, a filesystem walk (find, ls, du, tree), a package install, a file or JSON read, container logs. A command of no known kind is told to send its output to a file and read the range it needs.
tail or head. A long run whose output is cut at the end hides the failure that scrolled past; every suggestion narrows what the command produces instead.output-flood: 30 KB of output from "pytest tests/ -v", over 20 KB
over N KB faint) and the advice faint under it. The transcript stays clean. With the sidebar closed, or without that mod installed, the transcript line is written as above./output-flood prints./output-flood on or off, the limit, and what this session flooded /output-flood on | off on by default /output-flood limit 50 a result over 50 KB is reported; 1 to 1000, 20 by default, stored across sessions
claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install output-flood@kilimcininkoroglu-mods
Function hooks are early access. Claude Code 2.1.288 and later load them by default, so there is nothing to switch on.
Validated with claude plugin validate on Claude Code 2.1.283:
❯ ./register.ts hooks: session.start, command.run{command=output-flood}, classic.PostToolBatch ❯ ./register.ts calls: $.command.register, $.sidebar.set (via toPerson), $.store.get (via readLimit, readSettings), $.store.set (via runCommand, setLimit), $.ui.log (via toPerson)
Reach L1, reads the session.
make target gets the general advice.make install # eslint, typescript-eslint, typescript make lint # complexity limit 10, the build fails above it make typecheck # needs .claude/types/ from /plugin-types make validate make test # claude plugin test
hooks/register.ts 149 lines1import type { EngineInterface, Register } from 'claude-code'
2import { DEFAULT_LIMIT_KB, limitOf, limitText, logText, noteText, sectionKey, shownCommand, sidebarLines, sizeOf, statusText } from './flood.ts'
3
4const ENABLED_KEY = 'enabled'
5const LIMIT_KEY = 'limit'
6
7const USAGE = 'expects nothing (the status), on, off or limit <kb>'
8
9/** The characters of one KB, as the limit counts them. */
10const KB = 1024
11
12/**
13 * The on/off setting, the limit in KB, the commands already reported, so one repeat is quiet, and every
14 * result over the limit, repeats included: how many and their size together, so the status counts the
15 * same results it sizes.
16 */
17type State = { enabled: boolean; limitKb: number; noted: Set<string>; floods: number; total: number }
18
19/**
20 * The finding the person reads: an entry in the shared sidebar's stream while it is open, else the
21 * transcript line. The model's note is another channel and does not repeat this text.
22 */
23async function toPerson($: EngineInterface, command: string, chars: number, limit: number): Promise<void> {
24 try {
25 const taken = await $.sidebar.set({
26 consumer: 'output-flood',
27 key: sectionKey(shownCommand(command)),
28 title: 'output over the limit',
29 lines: sidebarLines(command, chars, limit),
30 until: 'stream',
31 })
32 if (taken) return
33 } catch {
34 // The sidebar mod is not installed.
35 }
36 $.ui.log(logText(command, chars, limit))
37}
38
39/** One call of a batch, as `classic.PostToolBatch` hands it. */
40type BatchCall = { tool_name: string; tool_input: unknown; tool_response?: unknown }
41
42/**
43 * The size of a Bash result as the model reads it, or undefined for a backgrounded call. A result a
44 * mod rewrote, and a failed one, arrive as the text the model reads; an untouched one as the tool's
45 * record with its two streams.
46 */
47function sizeOfResponse(response: unknown): number | undefined {
48 if (typeof response === 'string') return response.length
49 if (typeof response !== 'object' || response === null) return undefined
50 const out = response as { stdout?: unknown; stderr?: unknown; backgroundTaskId?: unknown }
51 if (out.backgroundTaskId !== undefined) return undefined
52 return sizeOf(typeof out.stdout === 'string' ? out.stdout : '', typeof out.stderr === 'string' ? out.stderr : '')
53}
54
55/** The command of a Bash call, or undefined for another tool's call. */
56function bashCommand(call: BatchCall): string | undefined {
57 if (call.tool_name !== 'Bash') return undefined
58 const command = (call.tool_input as { command?: unknown } | undefined)?.command
59 return typeof command === 'string' ? command : undefined
60}
61
62/** Measures one call of a batch and answers the model's note, if it flooded the context. */
63async function measure($: EngineInterface, state: State, call: BatchCall): Promise<string | undefined> {
64 const command = bashCommand(call)
65 const chars = command === undefined ? undefined : sizeOfResponse(call.tool_response)
66 if (command === undefined || chars === undefined || chars <= state.limitKb * KB) return undefined
67 state.floods += 1
68 state.total += chars
69 const key = shownCommand(command)
70 if (state.noted.has(key)) return undefined
71 state.noted.add(key)
72 // The note goes to the model, the line to the person: neither reads the other's channel.
73 await toPerson($, command, chars, state.limitKb)
74 return noteText(command, chars, state.limitKb)
75}
76
77/** Writes the limit the person set; it holds across sessions, because it lives in $.store. */
78async function setLimit($: EngineInterface, state: State, arg: string): Promise<string> {
79 const limit = limitOf(arg)
80 if (limit === undefined) return limitText(undefined)
81 state.limitKb = limit
82 await $.store.set(LIMIT_KEY, limit)
83 return limitText(limit)
84}
85
86/** The stored limit, or the default when nothing is stored and when the stored value is not one. */
87async function readLimit($: EngineInterface): Promise<number> {
88 const stored = await $.store.get(LIMIT_KEY)
89 return typeof stored === 'number' && limitOf(String(stored)) !== undefined ? stored : DEFAULT_LIMIT_KB
90}
91
92/**
93 * Reads the on/off setting and the limit from the store, which every window shares, so a change made in
94 * another window applies here at the next hook that acts on it.
95 */
96async function readSettings($: EngineInterface, state: State): Promise<void> {
97 state.enabled = (await $.store.get(ENABLED_KEY)) !== false
98 state.limitKb = await readLimit($)
99}
100
101async function runCommand($: EngineInterface, state: State, args: string): Promise<string> {
102 const arg = args.trim()
103 await readSettings($, state)
104 if (arg === 'on' || arg === 'off') {
105 state.enabled = arg === 'on'
106 await $.store.set(ENABLED_KEY, state.enabled)
107 return state.enabled ? 'on: a Bash result over the limit is reported' : 'off: results are not measured'
108 }
109 if (arg.startsWith('limit')) return setLimit($, state, arg.slice(5).trim())
110 if (arg !== '' && arg !== 'status') return USAGE
111 return statusText(state.enabled, state.limitKb, state.floods, state.total)
112}
113
114export const register: Register = on => {
115 const state: State = { enabled: true, limitKb: DEFAULT_LIMIT_KB, noted: new Set(), floods: 0, total: 0 }
116
117 on('session.start', async ($, e, next) => {
118 const r = await next(e)
119 await readSettings($, state)
120 await $.command.register({
121 name: 'output-flood',
122 description: 'A Bash result over a size limit: status, on, off, limit <kb> (output-flood)',
123 argumentHint: '[on | off | limit <kb>]',
124 immediate: true,
125 })
126 return r
127 })
128
129 // The engine prints the plugin name in front of command text and log lines, so the texts do not repeat it.
130 on('command.run', { command: 'output-flood' }, async ($, e) => ({ text: await runCommand($, state, String(e.args ?? '')) }))
131
132 // Measured after the whole batch, not in `tool.call`: there a mod loaded ahead of this one (bash-diet)
133 // rewrites the result after it was measured, so the note named a size the model never read. Here every
134 // result is what the model reads, whichever order the plugins load in. A failed run is measured too,
135 // because a failing test run exits non-zero and is the largest output of all.
136 on('classic.PostToolBatch', async ($, e, next) => {
137 const r = await next(e)
138 if (!e.tool_calls.some(call => bashCommand(call) !== undefined)) return r
139 await readSettings($, state)
140 if (!state.enabled) return r
141 const notes: string[] = []
142 for (const call of e.tool_calls) {
143 const note = await measure($, state, call)
144 if (note !== undefined) notes.push(note)
145 }
146 return notes.length === 0 ? r : { ...r, additionalContext: [...(r.additionalContext ?? []), ...notes] }
147 })
148}
149hooks/flood.ts 113 lines1/** How large a Bash result was, and the narrower command that would have answered the same question. */
2
3/** The size a result must pass to be reported, in KB, until the person sets another one. */
4export const DEFAULT_LIMIT_KB = 20
5
6/** The band `/output-flood limit <kb>` takes; a value outside it is refused, never clamped. */
7export const MIN_LIMIT_KB = 1
8export const MAX_LIMIT_KB = 1000
9
10/** The characters of one KB, as this mod counts a result. */
11const KB = 1024
12
13/** How much of the command the texts name, so one long command does not fill the note. */
14const MAX_COMMAND = 60
15
16/** The size of one Bash result in characters: both streams, as the model read them. */
17export function sizeOf(stdout: string, stderr: string): number {
18 return stdout.length + stderr.length
19}
20
21/** The size in KB, one decimal under 10 KB. */
22export function fmtKb(chars: number): string {
23 const kb = chars / KB
24 return kb < 10 ? `${kb.toFixed(1)} KB` : `${Math.round(kb)} KB`
25}
26
27/** The command as the texts name it: one line, cut. */
28export function shownCommand(command: string): string {
29 const one = command.replace(/\s+/g, ' ').trim()
30 return one.length <= MAX_COMMAND ? one : `${one.slice(0, MAX_COMMAND - 1)}…`
31}
32
33/** The limit a `/output-flood limit <word>` argument names, or undefined when it is not one. */
34export function limitOf(arg: string): number | undefined {
35 if (!/^\d{1,4}$/.test(arg)) return undefined
36 const n = Number(arg)
37 return n >= MIN_LIMIT_KB && n <= MAX_LIMIT_KB ? n : undefined
38}
39
40/** The answer of `/output-flood limit <kb>`, or of an argument it cannot read. */
41export function limitText(limit: number | undefined): string {
42 if (limit === undefined) return `limit expects a whole number of KB from ${MIN_LIMIT_KB} to ${MAX_LIMIT_KB}`
43 return `limit ${limit} KB: a result over ${limit} KB is reported`
44}
45
46/**
47 * The narrower command per kind of flood, matched on the command itself. Each names what to ask instead,
48 * never a pipe into `tail` or `head`: a long run whose output is cut at the end hides the failure that
49 * scrolled past, and the person reads the whole stream by choice.
50 */
51const ADVICE: readonly { when: RegExp; text: string }[] = [
52 { when: /\b(pytest|jest|vitest|go test|cargo test|phpunit|mvn test|gradle test)\b/, text: 'run the one test or file this turn needs, and let the runner report only failures (pytest -x -q, go test -run, cargo test <name>, jest -t)' },
53 { when: /\bgit (log|diff|show)\b/, text: 'bound it: a path, -n, --stat, or --name-only, and read the one hunk you need' },
54 { when: /\b(find|ls|du|tree)\b/, text: 'bound the walk: -maxdepth, a path, or -name, and count instead of listing when a count answers it' },
55 { when: /\b(npm|yarn|pnpm|bun) (install|ci|run build)\b/, text: 'run it with its quiet flag (npm ci --silent, npm run build -- --silent) and read its error file' },
56 { when: /\b(cat|Read|jq|curl)\b/, text: 'read the part you need: a line range, a jq path, or the fields alone' },
57 { when: /\b(docker|kubectl) logs\b/, text: 'bound it: --since and --tail as the command\'s own flags, or one container' },
58]
59
60/** The advice for one command: the first kind it matches, else the general one. */
61export function adviceFor(command: string): string {
62 const row = ADVICE.find(a => a.when.test(command))
63 return row?.text ?? 'send the output to a file (> /tmp/run.log 2>&1) and read the range you need from it, or narrow the command itself'
64}
65
66/** The note the model reads after its own flood: what it cost, and what to run instead next time. */
67export function noteText(command: string, chars: number, limit: number): string {
68 return `output-flood: "${shownCommand(command)}" returned ${fmtKb(chars)} of output, over the ${limit} KB limit, and all of it is now in the context. Next time ${adviceFor(command)}.`
69}
70
71/** The transcript line the person reads: the finding alone, without the instruction. The engine adds the mod name. */
72export function logText(command: string, chars: number, limit: number): string {
73 return `${fmtKb(chars)} of output from "${shownCommand(command)}", over ${limit} KB`
74}
75
76/** How the sidebar colours a line or a part of one. */
77type Tone = 'ok' | 'warn' | 'error' | 'dim'
78export type Part = { text: string; kind?: Tone }
79/** A line; `parts` colour pieces of it, and `text` holds the whole line for a sidebar that draws no parts. */
80export type Line = { text: string; kind?: Tone; parts?: Part[] }
81
82const part = (text: string, kind: Tone | undefined): Part => (kind === undefined ? { text } : { text, kind })
83
84/** A line made of parts, its `text` their texts joined. */
85const partsLine = (parts: Part[]): Line => ({ text: parts.map(p => p.text).join(''), parts })
86
87/** The size's colour: yellow under twice the limit, red at or past it. */
88export function sizeTone(chars: number, limit: number): 'warn' | 'error' {
89 return chars >= 2 * limit * KB ? 'error' : 'warn'
90}
91
92/**
93 * The sidebar lines of one finding: the size on the first line, coloured by how far it passed the limit,
94 * with the limit faint; the advice faint under it.
95 */
96export function sidebarLines(command: string, chars: number, limit: number): Line[] {
97 return [
98 partsLine([part(fmtKb(chars), sizeTone(chars, limit)), part(` of output from "${shownCommand(command)}", `, undefined), part(`over ${limit} KB`, 'dim')]),
99 { text: adviceFor(command), kind: 'dim' },
100 ]
101}
102
103/** A sidebar section key: the subject cut to what the sidebar takes. */
104export function sectionKey(text: string): string {
105 return text.replace(/[^A-Za-z0-9._:-]+/g, '-').slice(0, 64) || 'flood'
106}
107
108/** The `/output-flood` answer: the setting, the limit, and what this session has flooded. */
109export function statusText(enabled: boolean, limit: number, floods: number, total: number): string {
110 const seen = floods === 0 ? 'no result over it yet' : `${floods} result(s) over it, ${fmtKb(total)} in all`
111 return `${enabled ? 'on' : 'off'} · limit ${limit} KB · ${seen}`
112}
113