SLOPSHOPPER

read-guard

Refuses the two Read calls that burn context for nothing: an image already read in this session, and a whole-file read of a large text file with no offset and…

newguard
v0.1.0MITupdated 2026-10-26qujck/harness/claude-code/read-guard
A shopper browsing a rack in a slop shop
README

read-guard

A Claude Code mod that refuses the two Read calls that put the most context into a session for nothing. It loads in every session on the box and acts on the session it runs in, subagent loops included, each loop judged on its own.

Why. Measured on the owner's box, 2026-10-23, 14:12–15:12 UTC, seven busy agents: 3.0M characters of tool output reached the models in the hour, 1.5M of it from twenty whole-file Reads. Eleven were PNG screenshots at 90k–180k characters each, three of them read twice by one agent from two checkouts of the same commit; one was a 400k-character generated script read whole and then read again as the slice that was wanted. That hour also held nine compactions, each a 40k–70k summary plus the re-reads it causes. The engine already answers a repeat Read of an unchanged text file with file_unchanged and pages a text file over its token cap, so the mod covers only what the engine leaves open.

The two rules, each a tool.call hook on Read that returns { deny } with the remedy in it:

  1. An image already in this context is not read again. An image (.png .jpg .jpeg .gif .webp) is remembered by name and byte size once it has actually been read, so the same screenshot under a worktree's path is the same image, and a re-saved one (another size) is new. The set is per loop (main conversation, or a subagent's id) and is forgotten when that loop compacts: the summary carries no image, so the next read is new again.
  2. A large text file is not read whole by accident. A Read of a file over 64 KiB (about 1,500 lines of code) with no offset, no limit and no allow_large: true is refused; the message says to pass the range, search first, or set allow_large when every line is truly wanted. PDFs and notebooks have their own paging and are left alone.

A path the mod cannot stat is the tool's to answer, and a failure inside the guard lets the read through and logs one line: it exists to save context, never to gate work. Every refusal is one line in the session log (read-guard: …), so "why did my Read fail" is answered there.

claude plugin test claude-code/read-guard runs its tests; scripts/check-mods-tests-pass.sh runs them on every PR.

Source 2 files
hooks/register.ts 48 lines
1import type { Register } from 'claude-code'
2import { judge } from './guard.ts'
3
4// What each loop has read: the main conversation under 'main', a subagent or teammate under its id.
5// A compaction of a loop forgets that loop's set, because the summary that replaces its transcript
6// carries no image, so the next read of one is new again. Module state, like waiting-agents' `told`:
7// a session is one process and a hot reload starts the module over, which only ever lets one extra
8// read through.
9type Seen = Map<string, Set<string>>
10
11export const register: Register = on => {
12  const seen: Seen = new Map()
13  const setFor = (agentId: string | undefined) => {
14    const id = agentId ?? 'main'
15    let s = seen.get(id)
16    if (!s) { s = new Set(); seen.set(id, s) }
17    return s
18  }
19
20  on('session.start', ($, e, next) => { seen.clear(); return next(e) })
21
22  on('tool.call', { tool: 'Read' }, async ($, e, next) => {
23    const stat = await $.fs.stat(e.file_path, { resolve: true }).catch(() => undefined)
24    // A path the guard cannot stat (missing, a permission the tool itself will report) is the tool's to answer.
25    if (!stat || stat.kind !== 'file') return next(e)
26    const loop = setFor(e.agentId)
27    const verdict = judge(e, stat.size, loop)
28    if ('deny' in verdict) {
29      $.ui.log(verdict.deny)
30      return { deny: verdict.deny }
31    }
32    const ran = await next(e)
33    // Remember an image only once it was actually read: a deny beneath, or an error, leaves it unseen.
34    if (verdict.key !== undefined && ran.deny === undefined && ran.isError !== true) loop.add(verdict.key)
35    return ran
36  }).catch(($, e, next) => {
37    // The guard failing must never cost a read: it exists to save context, not to gate work.
38    $.ui.log('read-guard: its guard failed; the read went through')
39    return next(e)
40  })
41
42  on('session.compact', async ($, e, next) => {
43    const result = await next(e)
44    if (!('skip' in result && result.skip)) seen.delete(e.agentId ?? 'main')
45    return result
46  })
47}
48
hooks/guard.ts 53 lines
1// The pure part of the read guard: which Read calls are refused, and with what words.
2//
3// Measured on the owner's box, 2026-10-23, 14:12–15:12 UTC, seven busy agents: 3.0M characters of tool
4// output reached the models in the hour, and 1.5M of it was twenty whole-file Reads. Eleven were PNG
5// screenshots at 90k–180k characters each, three of them read twice by the same agent from two
6// checkouts of one commit; one was a 400k-character generated script read whole and then read again as
7// the slice that was wanted. The engine already answers a repeat Read of an UNCHANGED TEXT file with
8// `file_unchanged` and auto-paginates a text file over its token cap, so this guard covers only what
9// the engine leaves open: images, which are never deduplicated, and a whole-file read that the cap
10// does not catch.
11
12// An image, by the extension the Read tool renders as one.
13export const IMAGE = /\.(png|jpe?g|gif|webp)$/i
14// The Read tool handles these on its own terms (pages, cells); the size rule does not apply.
15export const PAGED = /\.(pdf|ipynb)$/i
16// A text file this large read whole is about 1,500 lines of code or more; pass offset and limit.
17export const LARGE_BYTES = 64 * 1024
18
19export type ReadArgs = { file_path: string; offset?: number; limit?: number; allow_large?: boolean }
20
21export function basename(path: string): string {
22  return path.slice(path.lastIndexOf('/') + 1)
23}
24
25// The identity of an image for the repeat rule: its name and byte size. Two checkouts of one commit
26// hold the same file under different paths and mtimes (git stamps a checkout's mtime), and that pair
27// is exactly the repeat the rule exists to stop; a re-saved screenshot changes size and reads anew.
28export function imageKey(path: string, size: number): string {
29  return `${basename(path)}:${size}`
30}
31
32export type Verdict = { deny: string } | { allow: true; key?: string }
33
34// The verdict on one Read, given the file's size: `key` is set when the call is an image read that
35// should be remembered as seen. `seen` holds the keys this loop has read since its last compaction.
36export function judge(args: ReadArgs, size: number, seen: ReadonlySet<string>): Verdict {
37  const path = args.file_path
38  if (IMAGE.test(path)) {
39    const key = imageKey(path, size)
40    if (seen.has(key)) {
41      return { deny: `read-guard: ${basename(path)} (${size} bytes) is already in this context, read earlier this session; a repeat costs the same again and shows nothing new. Reason from the copy you have. It reads again after a compaction, or once the file changes.` }
42    }
43    return { allow: true, key }
44  }
45  if (PAGED.test(path)) return { allow: true }
46  const bounded = args.offset !== undefined || args.limit !== undefined || args.allow_large === true
47  if (!bounded && size > LARGE_BYTES) {
48    const kb = Math.round(size / 1024)
49    return { deny: `read-guard: ${basename(path)} is ${kb} KiB, and a Read with no offset and limit would put all of it in the context. Pass offset and limit for the part you need, or search it (grep -n) first; allow_large: true reads it whole when you truly need every line.` }
50  }
51  return { allow: true }
52}
53