SLOPSHOPPER

image-shrinker

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.

newguardprocess
v0.3.1MITupdated 2026-10-11varunmoka7/image-shrinker
A shopper browsing a rack in a slop shop
README

image-shrinker

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.

Why you might want it

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.

Install

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.

How to use

There are no commands. It works on its own whenever Claude reads an image file (png, jpg, gif, tiff, bmp, webp, heic):

  • Images up to 1200 pixels wide, 1568 pixels tall and about 1.15 million pixels in total are read as they are. Those are the sizes Claude's image reader takes without shrinking again itself, so the number Claude is told stays correct.
  • Larger images are read from a smaller copy, and Claude is told the original size, the new size and the number to multiply by to get back to the original image's pixels.
  • heic and heif photos are always converted to png, because Claude cannot show them otherwise.
  • To skip this mod for one image, tell Claude: "read the original image at full size" (it does this by reading with offset: 1). Claude may still shrink very large images itself.

What it touches

  • Network: none.
  • Files read: the image Claude is about to read.
  • Files written: the smaller copies, in /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.
  • Model calls: none. The mod adds no cost of its own; Claude itself costs what it normally does.
  • On by default once installed.

Turn it off / uninstall

claude plugin uninstall image-shrinker@image-shrinker

You can delete /tmp/claude-shrunk at any time.

Known limits

  • macOS only (see above).
  • svg and pdf files are not shrunk.
  • If the resize fails for any reason, Claude reads the original.
  • The mod respects your permission settings. It shrinks an image only when your settings let Claude read both the original file and the smaller copy in /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.
  • If you find it never shrinks anything, Claude is probably not allowed to read /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/**)".

License

MIT

Source 1 files
hooks/register.ts 77 lines
1import 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