SLOPSHOPPER

output-flood

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.

newcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · output-flood
› 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 › /output-flood ⎿ output-flood: on · limit 20 KB · no result over it yet ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

output-flood

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.

What it does

  1. After each batch of tool calls resolves, and before the next model request, the mod measures every Bash result in characters, as the model reads it. The measure comes after every mod that rewrote a result, so a result another mod shrank (such as bash-diet) counts at its shrunk size, whichever order the plugins load in. Subagent calls are measured the same way.
  2. A result over the limit (20 KB by default, about 5000 tokens) is a finding. The model reads this note before its next request:

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.

  1. No advice is ever a pipe into 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.
  2. At the same moment one line reaches the transcript, the finding alone, without the instruction the model reads:

output-flood: 30 KB of output from "pytest tests/ -v", over 20 KB

  1. While the sidebar is open, that finding goes there instead, as an entry in its stream: the size on the first line (yellow under twice the limit, red at or above it, with 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.
  2. One command is reported once per session, known by its first 60 characters. Its size still counts towards the total /output-flood prints.

Command

/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

Install

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.

After installing

  1. Restart Claude Code.

What it can reach

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.

  1. Reads: each Bash command's text, and the length of its result as the model reads it; it never parses the output itself
  2. Runs: nothing
  3. Sends: a note to the model before its next request, and one line to the transcript; nothing leaves the machine
  4. Persists: in $.store, the on/off setting and the limit
  5. Hostile input: only the length of the output is measured; the command text reaches the note cut to 60 characters and is never run

Limits

  • The output is already in the context when the note is written. The mod cannot take it back; the note is for the next command.
  • A failed command (a non-zero exit the engine reports as an error) is measured from its error text, because a failing test run is the largest output of all. The error text stays as it is. Claude Code cuts that text at 10,000 characters, so a failed command passes a limit of 20 KB only when you set a lower one.
  • A backgrounded command is not measured: its result carries a task id, not the output.
  • The advice is matched on the command text. A command hidden behind a script or a make target gets the general advice.
  • The size is counted in characters, not tokens. A line of ASCII is about four characters per token, and other text more.

Development

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

Source 2 files
hooks/register.ts 149 lines
1import 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}
149
hooks/flood.ts 113 lines
1/** 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