SLOPSHOPPER

read-ceiling-pager

A Read whose window would exceed Claude Code's token cap is clamped to the lines that fit, and the result says where to continue. Turns a failed round into a…

newguard
A shopper browsing a rack in a slop shop
README

read-ceiling-pager

Turns a Read that would fail the token cap into a partial read that says where to continue.

A Claude Code mod. The Read tool refuses a window whose content counts past the cap (25,000 tokens, or CLAUDE_CODE_FILE_READ_MAX_OUTPUT_TOKENS) with an error telling the model to use offset and limit. The engine already paginates a Read with no limit; an explicit window that is too big still fails, and every such failure costs a round: the call, the error, a smaller retry. In this harness that was 147 failed Reads across 20 sessions in one week (papercut, 2026-10-03), after a guard hook that already shapes the retries.

read-ceiling-pager runs before the Read:

  • A window the estimate says is over the cap is clamped to the largest prefix of it that fits. The model gets the engine's own numbered lines for that prefix, plus one context line: the file's line count, the lines returned, the lines of the window left, and the offset and limit that continue. The line is built from the engine's own record of the lines it returned, so it stays right when a hook beneath the mod shortens the window further; an explicit window that comes back short for any other reason gets the same line. Nothing the mod clamps comes back partial without saying so.
  • A window that fits, a Read with no limit, a PDF page range, a path the mod cannot stat or read, and a file over 4 MiB (the most a mod may read at once) pass through untouched, a refused PDF page range included.
  • A Read the engine refuses anyway (the estimate is an estimate; the engine counts exactly) gets a window to retry with, computed from the engine's own numbers in the error and no larger than the read-ceiling-chunk hook admits after a first refusal of the file (below). The count is also spread over the window's characters, so the advice stops before a line far longer than the rest. When the first line alone outweighs its share, the retry is that line alone: density varies from line to line, so only the engine refusing a one-line window shows a line is over the cap, and that refusal's note points at Grep or a Bash extract. So does a window whose lines average past 80% of the cap each, where the count leaves no retry window with room to spare.

How the estimate works

Characters divided by 3.5 per token, with the 8-character line-number prefix the Read tool adds to every line and a long line counted whole, as the engine counts it (one 450,000-character line came to 300,008 tokens at limit: 1, probed 2026-10-04), aiming at 90% of the cap. For typical code and English prose 3.5 is a conservative figure, so the clamp errs toward a slightly smaller page than would fit, at the cost of one more continuation. Dense text (minified JSON, numeric data, CJK) can run below the 3.15 characters per token the 90% aim tolerates (that probe's repeated ab ran at 1.5); there a window the estimate passes can still be refused, and the refusal path above names the window that fits. A file whose whole size plus prefixes fits is not read by the mod before the call; a bigger one is read once per Read that needs the measure. A refused Read the mod let through unread is read once after the refusal, to count the lines the engine actually returned before it gives a window.

Install

claude --plugin-dir ./read-ceiling-pager

Or from a marketplace once it is published there:

/plugin install read-ceiling-pager@hov

Options

None. The cap follows the engine's own CLAUDE_CODE_FILE_READ_MAX_OUTPUT_TOKENS when that is set; this harness sets it to 50,000.

What it can touch

claude plugin validate ./read-ceiling-pager prints the authority:

hooks: tool.call{tool=Read}
calls: $.env.get, $.fs.read, $.fs.stat

It reads the file a Read names (and only when the file is big enough to matter), reads one environment variable by literal name, runs no programs, makes no network calls, calls no model and draws nothing. It changes one thing about a Read call: its limit, downward, and it says so in the result.

Alongside the read-ceiling-chunk hook

The harness's read-ceiling-chunk.js PreToolUse hook denies a retry that would fail again, naming the window that fits, from the failures already recorded. It leaves a first attempt alone on purpose, and its header says why it denies rather than rewrites: a limited Read returns numbered lines and nothing else, so a silently clamped window hands the model a partial file it cannot tell is partial, and the first attempt's error is what teaches the agent the ceiling exists. This mod clamps the first attempt, but not silently: its context line comes back with the result, naming the file's length, the lines returned and the call that continues, which tells the model about the ceiling without a failed round.

The two work together. A clamped Read that succeeds never feeds the hook's store. One the engine refuses anyway is an ordinary failure: the hook records it and guards the retry as before. The mod's note names a retry window no larger than the one the hook admits after one refusal (its 0.8 headroom on the error's own count, at most half the window that failed, and never more than 1,000 lines), so following the note after a first refusal is not denied. After repeated refusals of the same file the hook's window can be smaller still; then it denies once, and its denial names the window. When the hook runs an oversize call it has denied three times at its own window, the result is shorter than the mod asked for; the mod's line comes from the engine's record of the lines returned, so it names those lines and where to continue.

Source 1 files
hooks/register.ts 345 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3/**
4 * read-ceiling-pager: a Read window that would exceed the token cap is clamped
5 * to the lines that fit, and the result says where to continue.
6 *
7 * Claude Code refuses a Read whose content counts past its cap (25,000 tokens
8 * unless CLAUDE_CODE_FILE_READ_MAX_OUTPUT_TOKENS says otherwise) with an error
9 * that tells the model to use offset and limit. A whole-file Read is already
10 * paginated by the engine; an explicit window that is too big is not, and the
11 * failures measured in this harness were all explicit windows (the guard hook
12 * read-ceiling-chunk.js carries the survey). Each one costs a round: the call,
13 * the error, a smaller retry.
14 *
15 * Before such a Read runs, this mod reads the file itself, estimates the
16 * requested window's tokens (characters plus the line-number prefix the Read
17 * tool adds, a long line counted whole as the engine counts it), and when the
18 * estimate is over the cap rewrites `limit` to the largest prefix of the window
19 * that fits. The result the model gets is the engine's own numbered lines for
20 * that prefix, plus one context line naming the lines returned, the lines left,
21 * and the offset and limit that continue. That line is built from the engine's
22 * record of the lines it returned, so it stays right when a hook beneath this
23 * one shortens the window further, and an explicit window that comes back short
24 * for any other reason gets the same line. A window that fits, a Read with no
25 * limit, a PDF page range, a file the mod cannot stat or read, and a file too
26 * large to scan are passed through untouched.
27 *
28 * The engine counts tokens exactly where this mod estimates, so a clamped Read
29 * can still be refused. Then, and when an untouched Read is refused, the mod
30 * reads the engine's own numbers out of the error (tokens counted, cap) and
31 * adds a window to retry with: the one the harness's read-ceiling-chunk.js
32 * hook admits after a first refusal of the file, so following it is not denied
33 * then. After more refusals of the same file that hook's window can be smaller;
34 * it denies once and names it. The count is also spread over the window's
35 * characters, so the advice stops before a line far longer than the rest. When
36 * the first line alone outweighs its share, the advice is that line alone:
37 * density varies from line to line, so only the engine refusing a one-line
38 * window shows a line is over the cap.
39 */
40
41/** Claude Code's default Read cap, in tokens. */
42export const DEFAULT_CAP = 25_000
43/** Characters per token the estimate assumes: conservative for typical code and prose; dense text (minified JSON, CJK) can run below it, and the refusal path covers that. */
44export const CHARS_PER_TOKEN = 3.5
45/** The estimate aims this far under the cap; the engine's count is exact and this is not. */
46export const HEADROOM = 0.9
47/** Characters the Read tool adds per line for its line-number prefix. */
48export const LINE_OVERHEAD = 8
49/** read-ceiling-chunk.js aims a retry at this fraction of the cap (its FIT_HEADROOM); advice above its window is denied there. */
50export const RETRY_HEADROOM = 0.8
51/** $.fs.read rejects a file over 4 MiB, so files past this are left to the engine. */
52export const MAX_FILE_BYTES = 4 * 1024 * 1024
53/** The Read tool's own page when no limit is given. */
54export const DEFAULT_PAGE = 2_000
55
56const CEILING = /\((\d+) tokens\) exceeds maximum allowed tokens \((\d+)\)/
57
58/**
59 * What one line costs in characters once Read has numbered it. The engine counts
60 * a long line whole: one 450,000-character line came to 300,008 tokens at
61 * limit 1 (probed 2026-10-04).
62 */
63export function lineCost(line: string): number {
64  return line.length + LINE_OVERHEAD
65}
66
67/** The estimated tokens of these lines as Read would return them. */
68export function estimateTokens(lines: readonly string[]): number {
69  let chars = 0
70  for (const line of lines) chars += lineCost(line)
71  return Math.ceil(chars / CHARS_PER_TOKEN)
72}
73
74/**
75 * The file's lines as read-ceiling-chunk.js counts them: a trailing newline ends
76 * the last line. The Read tool also numbers the empty line after it, which costs
77 * only its prefix; the retry advice has to agree with that hook's count.
78 */
79export function splitLines(text: string): string[] {
80  const lines = text.split('\n')
81  if (lines.length > 1 && text.endsWith('\n')) lines.pop()
82  return lines
83}
84
85/**
86 * How many lines from index `start` fit under `budget` tokens, at most `want`.
87 * 0 when the first line alone does not fit.
88 */
89export function fit(lines: readonly string[], start: number, want: number, budget: number): number {
90  return fitChars(lines, start, want, budget * CHARS_PER_TOKEN)
91}
92
93/** How many lines from index `start` fit in `limitChars` characters as Read returns them, at most `want`. */
94function fitChars(lines: readonly string[], start: number, want: number, limitChars: number): number {
95  let chars = 0
96  let n = 0
97  while (n < want && start + n < lines.length) {
98    const cost = lineCost(lines[start + n] ?? '')
99    if (chars + cost > limitChars) break
100    chars += cost
101    n += 1
102  }
103  return n
104}
105
106/**
107 * From the engine's own cap error for a window of `windowLines` lines: the window
108 * to retry with, and the cap and count it named. null when the text is not that
109 * error. The window is the one read-ceiling-chunk.js admits after one refusal
110 * (its rungFor with one failure on record): the error's own prediction at
111 * RETRY_HEADROOM, and no more than half the window that failed or half the
112 * default page. After more refusals of the same file that hook's window can be
113 * smaller still; it then denies once and names it.
114 *
115 * Given the window's own lines, the count is also spread over their characters:
116 * a line far longer than the rest is priced at its share, so the advice stops
117 * before it. When the first line alone outweighs its share, `alone` is set and
118 * the window is that one line (none when the window above admits none): the
119 * error counts the whole window, and a low-density first line beside denser
120 * ones can still fit by itself.
121 */
122export function fitFromError(
123  text: string,
124  windowLines: number,
125  window?: readonly string[],
126): { lines: number; cap: number; tokens: number; alone?: true } | null {
127  const m = CEILING.exec(text)
128  if (!m) return null
129  const tokens = Number(m[1])
130  const cap = Number(m[2])
131  if (!(tokens > 0) || !(cap > 0) || !(windowLines > 0)) return null
132  const predicted = Math.floor((RETRY_HEADROOM * cap * windowLines) / tokens)
133  const lines = Math.min(predicted, Math.floor(windowLines / 2), Math.floor(DEFAULT_PAGE / 2))
134  if (window === undefined || window.length === 0) return { lines, cap, tokens }
135  let chars = 0
136  for (const line of window) chars += lineCost(line)
137  const priced = fitChars(window, 0, window.length, (RETRY_HEADROOM * cap * chars) / tokens)
138  if (priced > 0) return { lines: Math.min(lines, priced), cap, tokens }
139  return { lines: Math.min(lines, 1), cap, tokens, alone: true }
140}
141
142function positiveInt(value: unknown): number | null {
143  const n = Number(value)
144  return Number.isInteger(n) && n > 0 ? n : null
145}
146
147/** The cap the engine enforces: its env override when set and positive, else the default. */
148async function capOf($: EngineInterface): Promise<number> {
149  try {
150    const raw = await $.env.get('CLAUDE_CODE_FILE_READ_MAX_OUTPUT_TOKENS')
151    const n = Number(raw)
152    return raw !== undefined && Number.isFinite(n) && n > 0 ? Math.floor(n) : DEFAULT_CAP
153  } catch {
154    return DEFAULT_CAP
155  }
156}
157
158/** What this mod decided for one Read: the window the engine will see, and the clamp if any. */
159export type Plan = {
160  /** The lines the engine reads if the call runs as asked (for reading its error later). */
161  window: number
162  /** The limit to rewrite to; absent when the call passes through untouched. */
163  clamp?: number
164  offset: number
165  limit: number
166  /** The call named its own limit and no PDF pages: a result shorter than that window gets a note. */
167  explicit: boolean
168  /** A PDF page range: offset and limit do not apply to it. */
169  paged: boolean
170  /** The file's lines, when the mod read them before the call. */
171  lines?: readonly string[]
172  total: number
173  cap: number
174  file: string
175}
176
177async function planFor(
178  $: EngineInterface,
179  call: { file_path: string; offset?: number; limit?: number; pages?: string },
180): Promise<Plan> {
181  const file = call.file_path
182  const limit = positiveInt(call.limit)
183  const offset = positiveInt(call.offset) ?? 1
184  const paged = call.pages !== undefined
185  const explicit = limit !== null && !paged
186  const pass: Plan = {
187    window: limit ?? DEFAULT_PAGE,
188    offset,
189    limit: limit ?? DEFAULT_PAGE,
190    explicit,
191    paged,
192    total: 0,
193    cap: DEFAULT_CAP,
194    file,
195  }
196  // No limit: the engine paginates a whole-file read itself. A page range is a PDF.
197  if (limit === null || !explicit) return pass
198
199  let size: number
200  try {
201    const st = await $.fs.stat(file)
202    if (st.kind !== 'file' || st.size > MAX_FILE_BYTES) return pass
203    size = st.size
204  } catch {
205    return pass
206  }
207  const cap = await capOf($)
208  pass.cap = cap
209  const budget = Math.floor(cap * HEADROOM)
210  // The whole file plus every line's prefix fits: nothing to clamp, no need to read it.
211  if (size + limit * LINE_OVERHEAD <= budget * CHARS_PER_TOKEN) return pass
212
213  let text: string
214  try {
215    text = await $.fs.read(file)
216  } catch {
217    return pass
218  }
219  const lines = splitLines(text)
220  const start = offset - 1
221  pass.lines = lines
222  pass.total = lines.length
223  if (start >= lines.length) return pass // past the last line: nothing to clamp
224  const want = Math.min(limit, lines.length - start)
225  pass.window = want
226  if (estimateTokens(lines.slice(start, start + want)) <= budget) return pass
227  const n = fit(lines, start, want, budget)
228  if (n === 0 || n >= want) return pass // one line alone is over the cap, or the estimate disagrees with itself
229  return { ...pass, clamp: n }
230}
231
232/**
233 * The lines the engine read for a refused call. A plan that read the file has
234 * them; one that passed early (a file small enough to skip, no limit, a failed
235 * stat) knows only what was asked, which can be far more than the file holds, so
236 * the file is read now, after the refusal. null when it cannot be read.
237 */
238async function windowRead($: EngineInterface, plan: Plan): Promise<readonly string[] | null> {
239  let lines = plan.lines
240  if (lines === undefined) {
241    try {
242      const st = await $.fs.stat(plan.file)
243      if (st.kind !== 'file' || st.size > MAX_FILE_BYTES) return null
244      lines = splitLines(await $.fs.read(plan.file))
245    } catch {
246      return null
247    }
248  }
249  const start = plan.offset - 1
250  return lines.slice(start, start + (plan.clamp ?? plan.limit))
251}
252
253/** The lines a Read returned, from the engine's own record; null when the result is not a text record. */
254export function returnedOf(result: unknown): { start: number; count: number; total: number } | null {
255  if (typeof result !== 'object' || result === null) return null
256  const r = result as { type?: unknown; file?: { startLine?: unknown; numLines?: unknown; totalLines?: unknown } | null }
257  if (r.type !== 'text' || typeof r.file !== 'object' || r.file === null) return null
258  const { startLine, numLines, totalLines } = r.file
259  const whole = (n: unknown): n is number => typeof n === 'number' && Number.isInteger(n) && n >= 0
260  return whole(startLine) && whole(numLines) && whole(totalLines) ? { start: startLine, count: numLines, total: totalLines } : null
261}
262
263function withContext<R extends { context?: readonly string[] }>(ran: R, line: string): R {
264  return { ...ran, context: [...(ran.context ?? []), line] }
265}
266
267/** The words the model reads for a failed call: core's `text`, else a string result. */
268export function failureTextOf(ran: { text?: string; result?: unknown }): string {
269  if (typeof ran.text === 'string') return ran.text
270  return typeof ran.result === 'string' ? ran.result : ''
271}
272
273export const register: Register = on => {
274  on('tool.call', { tool: 'Read' }, async ($, e, next) => {
275    const plan = await planFor($, e)
276    const ran = await next(plan.clamp === undefined ? e : { ...e, limit: plan.clamp })
277    if (ran.deny !== undefined) return ran
278
279    if (ran.isError !== true) {
280      if (!plan.explicit) return ran
281      // The engine's record says which lines came back, whoever shortened the window: this
282      // mod's clamp, or a hook beneath it (read-ceiling-chunk.js rewrites an ignored oversize call).
283      // Without that record, the mod's own clamp is the best account of what ran.
284      const got =
285        returnedOf(ran.result) ?? (plan.clamp === undefined ? null : { start: plan.offset, count: plan.clamp, total: plan.total })
286      if (got === null || got.count === 0) return ran
287      const end = Math.min(plan.offset + plan.limit - 1, got.total)
288      const last = got.start + got.count - 1
289      if (last >= end) return ran
290      const left = end - last
291      const why = got.count === plan.clamp ? `would exceed the ${plan.cap}-token Read cap` : 'was shortened before it ran'
292      return withContext(
293        ran,
294        `read-ceiling-pager: ${plan.file} has ${got.total} lines. The ${plan.limit}-line window from line ${plan.offset} ` +
295          `${why}, so this result is lines ${got.start} to ${last} ` +
296          `(${got.count} of the ${end - plan.offset + 1} that window held). ${left} of them remain: continue with ` +
297          `offset ${last + 1}, limit ${Math.min(got.count, left)}.`,
298      )
299    }
300
301    // The engine refused the window anyway: its error names the exact count, so say what fits.
302    // Offset and limit do not apply to a PDF page range.
303    if (plan.paged) return ran
304    const text = failureTextOf(ran)
305    if (!CEILING.test(text)) return ran
306    const read = await windowRead($, plan)
307    const ranLines = read === null ? (plan.clamp ?? plan.window) : read.length
308    const found = fitFromError(text, ranLines, read ?? undefined)
309    if (!found) return ran
310    const clamped = plan.clamp === undefined ? '' : ` (already clamped from ${plan.limit} by read-ceiling-pager)`
311    const counted =
312      `read-ceiling-pager: ${ranLines} line${ranLines === 1 ? '' : 's'} from line ${plan.offset} came to ${found.tokens} tokens ` +
313      `against a ${found.cap}-token cap${clamped}`
314    if (found.lines <= 0 && ranLines > 1) {
315      // Only an average: a single line here may still fit, but no retry window keeps the headroom.
316      return withContext(
317        ran,
318        `${counted}: about ${Math.ceil(found.tokens / ranLines)} tokens a line on average, more than ` +
319          `${Math.round(RETRY_HEADROOM * 100)}% of the cap, so the count leaves no retry window with room to spare. ` +
320          `Search the file with Grep, or extract a byte range with a Bash command, instead of another Read.`,
321      )
322    }
323    if (found.lines <= 0) {
324      return withContext(
325        ran,
326        `${counted}; no window of whole lines fits here. Search the file with Grep, or extract a byte range with a Bash ` +
327          `command, instead of another Read.`,
328      )
329    }
330    if (found.alone) {
331      return withContext(
332        ran,
333        `${counted}. At that window's average density line ${plan.offset} alone would pass ` +
334          `${Math.round(RETRY_HEADROOM * 100)}% of the cap, but density varies from line to line, so try it alone: ` +
335          `retry with offset ${plan.offset}, limit 1, then continue from offset ${plan.offset + 1}.`,
336      )
337    }
338    return withContext(
339      ran,
340      `${counted}. A window of ${found.lines} line${found.lines === 1 ? '' : 's'} fits with room to spare: ` +
341        `retry with offset ${plan.offset}, limit ${found.lines}, then continue from offset ${plan.offset + found.lines}.`,
342    )
343  })
344}
345