Shrinks big screenshots before Claude reads them, so long sessions last longer, and tells Claude the scale so clicks still land in the right place.

A Claude Code mod that shrinks big image files before Claude opens them, and tells Claude how much it shrank them. It only affects image files Claude opens with its Read tool. Screenshots that a browser tool hands straight to Claude are not changed.
Every image Claude reads stays in the conversation and is sent again with every later message. A few full-size screenshots can fill Claude's memory for the rest of the session. Shrinking them keeps long sessions going longer.
Example: Claude saves a screenshot of your app to a file after every step and reads each file back. Without this mod, each 2400 pixel wide frame piles up. With it, Claude sees a 1200 pixel wide copy and is told "multiply positions by 2.00" to get back to the original image's pixels. ("Coordinates" are the two numbers, across and down, that say where something is in a picture.) Turning those into clicks on a page depends on the screen: on a Retina screen the image has two pixels for every page pixel, so the number is not a click position by itself.
Paste these two lines into a terminal (not into Claude):
claude plugin marketplace add varunmoka7/image-shrinker
claude plugin install image-shrinker@image-shrinker
Needs Claude Code 2.1.287 or newer (mods are on by default from that version).
Mac only. The mod uses sips, a tool that ships with macOS. On Linux and Windows it does nothing: Claude reads your images as usual, with no errors.
There are no commands. It works on its own whenever Claude reads an image file (png, jpg, gif, tiff, bmp, webp, heic):
offset: 1). Claude may still shrink very large images itself./tmp/claude-shrunk. Your original is never changed. Each read gets its own copy, readable only by you, and it is deleted as soon as Claude has read it.claude plugin uninstall image-shrinker@image-shrinker
You can delete /tmp/claude-shrunk at any time.
/tmp/claude-shrunk without asking you. If either would be blocked or would ask you first, the mod steps aside and Claude reads the original in the usual way, so you keep your protection but get no saving./tmp/claude-shrunk on its own. To allow it, add this line to the permissions.allow list in your Claude Code settings file (~/.claude/settings.json): "Read(//tmp/claude-shrunk/**)".MIT
hooks/register.ts 77 lines1import type { EngineInterface, Register } from 'claude-code'
2
3const MAX_WIDTH = 1200
4// The API downscales any image whose long edge is over 1568px, which would make the scale note wrong.
5const MAX_HEIGHT = 1568
6// It also downscales anything over about 1568 visual tokens (width x height / 750), roughly 1.15 megapixels.
7const MAX_PIXELS = 1_150_000
8// Where the downscaled copies go, private to the user; the original is never touched.
9const DIR = '/tmp/claude-shrunk'
10// sips reads these; anything else (svg, pdf) is passed through.
11const IMAGE = /\.(png|jpe?g|gif|tiff?|bmp|webp|heic|heif)$/i
12// The API cannot show these, so they are always converted, whatever their size.
13const CONVERT = /\.(heic|heif)$/i
14// A JPEG stays a JPEG: PNG would make a photo several times bigger.
15const JPEG = /\.jpe?g$/i
16
17// The pixel size of an image, or undefined when sips cannot read it or is missing (sips ships only with macOS).
18async function sizeOf($: EngineInterface, path: string) {
19 const ran = await $.process.run(['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', path]).catch(() => undefined)
20 if (ran === undefined) return undefined
21 const width = Number(/pixelWidth:\s*(\d+)/.exec(ran.stdout)?.[1])
22 const height = Number(/pixelHeight:\s*(\d+)/.exec(ran.stdout)?.[1])
23
24 return ran.exitCode === 0 && width > 0 && height > 0 ? { width, height } : undefined
25}
26
27// Makes a private copy for this one read. Undefined means "read the original": no sips, not shrinkable, or anything went wrong.
28async function shrink($: EngineInterface, path: string) {
29 const size = await sizeOf($, path)
30 if (size === undefined) return undefined
31 const scale = Math.min(MAX_WIDTH / size.width, MAX_HEIGHT / size.height, Math.sqrt(MAX_PIXELS / (size.width * size.height)), 1)
32 if (scale === 1 && !CONVERT.test(path)) return undefined
33
34 // Every read gets its own copy, so two reads of one path (or two sessions) never share a file.
35 const unique = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
36 const format = JPEG.test(path) ? 'jpeg' : 'png'
37 const copy = `${DIR}/${unique}${path.replace(/[^A-Za-z0-9.]+/g, '_').slice(-80)}.${format === 'jpeg' ? 'jpg' : 'png'}`
38 // The copy is outside the project, so check it would be read without a prompt too; otherwise every screenshot would ask.
39 const copyVerdict = await $.tool.check({ tool: 'Read', input: { file_path: copy } }).catch(() => undefined)
40 if (copyVerdict?.decision !== 'allow') return undefined
41 // Round down so the final size is never over a cap.
42 const width = Math.max(1, Math.floor(size.width * scale))
43 const height = Math.max(1, Math.floor(size.height * scale))
44 // Folder 0700 and copy 0600: other users on the Mac cannot read them. chmod fails on a folder someone else made.
45 const dir = await $.process.run(['mkdir', '-p', '-m', '700', DIR])
46 if (dir.exitCode !== 0 || (await $.process.run(['chmod', '700', DIR])).exitCode !== 0) return undefined
47 const made = await $.process.run(['sips', '-z', String(height), String(width), path, '-s', 'format', format, '--out', copy])
48 if (made.exitCode === 0 && (await $.process.run(['chmod', '600', copy])).exitCode === 0) return { copy, size, scale, width, height }
49 await $.process.run(['rm', '-f', copy])
50
51 return undefined
52}
53
54export const register: Register = on => {
55 on('tool.call', { tool: 'Read' }, async ($, e, next) => {
56 // An offset on an image asks for the original.
57 if (!IMAGE.test(e.file_path) || e.offset !== undefined) return next(e)
58 // Ask the permission rules about the real file first. Only an "allow" is shrunk; an ask or deny goes the normal way on the original path.
59 const verdict = await $.tool.check({ tool: 'Read', input: { file_path: e.file_path } }).catch(() => undefined)
60 if (verdict?.decision !== 'allow') return next(e)
61 const small = await shrink($, e.file_path).catch(() => undefined)
62 if (small === undefined) return next(e)
63 const { copy, size, width, height } = small
64
65 const ran = await next({ ...e, file_path: copy }).finally(() => $.process.run(['rm', '-f', copy]).catch(() => undefined))
66 if (ran.deny !== undefined || (width === size.width && height === size.height)) return ran
67
68 return {
69 ...ran,
70 context: [
71 ...(ran.context ?? []),
72 `image-shrinker: ${e.file_path} is ${size.width}x${size.height}px; this view is ${width}x${height}px. To get original image pixels, multiply positions read off this view by ${(size.width / width).toFixed(2)}. That is not the same as page or screen click positions (a Retina screen has two image pixels per page pixel). To see the original, Read it again with offset: 1.`,
73 ],
74 }
75 })
76}
77