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…

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:
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.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.
claude --plugin-dir ./read-ceiling-pager
Or from a marketplace once it is published there:
/plugin install read-ceiling-pager@hov
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.
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.
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.
hooks/register.ts 345 lines1import 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