Keeps oversized images out of the conversation: an image over 7800 px is read through a resized copy, and browser viewports over the limit are refused.

The API refuses images over about 8000 px on a side, and a refused image breaks the turn. This mod keeps them out:
/tmp/jr-image-guard. A toast says what happened.Resizing uses ImageMagick when it is installed, sips otherwise (macOS).
/config)| Option | Default | |
|---|---|---|
| Largest side (px) | 7800 | Images larger than this on either side are resized |
/plugin install jr-image-guard --marketplace jrlprost/claude-code-mods
Runs sips (and magick when present) on images Claude is about to read. Writes resized copies to /tmp/jr-image-guard. Sends nothing over the network.
hooks/register.ts 45 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { IMAGE, isTooLarge, parseSips, resizedPath } from './image'
4
5const DIR = '/tmp/jr-image-guard'
6
7async function sizeOf($: EngineInterface, file: string) {
8 const { exitCode, stdout } = await $.process.run(['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', file])
9 return exitCode === 0 ? parseSips(stdout) : null
10}
11
12/** A copy that fits in `max` on both axes: ImageMagick when present, sips otherwise. */
13async function shrink($: EngineInterface, file: string, max: number) {
14 await $.process.run(['mkdir', '-p', DIR])
15 const out = resizedPath(DIR, file, await $.clock.now())
16 const magick = await $.process.run(['sh', '-c', 'command -v magick >/dev/null && magick "$0" -resize "$1x$1>" "$2"', file, String(max), out])
17 if (magick.exitCode === 0) return out
18 const sips = await $.process.run(['sips', '-Z', String(max), file, '--out', out])
19 return sips.exitCode === 0 ? out : null
20}
21
22export const register: Register = (on, options) => {
23 const max = Number(options.maxPixels ?? 7800)
24
25 // Images over the limit break the conversation: Claude reads a resized copy instead.
26 on('tool.call', { tool: 'Read' }, async ($, e, next) => {
27 if (!IMAGE.test(e.file_path)) return next(e)
28 const size = await sizeOf($, e.file_path)
29 if (!isTooLarge(size, max)) return next(e)
30 const copy = await shrink($, e.file_path, max)
31 if (!copy) return { deny: `jr-image-guard: ${e.file_path} is ${size![0]}×${size![1]} px, over ${max} px, and could not be resized.` }
32 $.ui.toast(`jr image guard · ${size![0]}×${size![1]} px → resized to fit ${max} px`)
33 return next({ ...e, file_path: copy })
34 }).catch(($, e, next) => (next.called ? next(e) : { deny: 'jr-image-guard: the size check failed, so the image was not read.' }))
35
36 // A browser viewport over the limit makes screenshots that break the conversation.
37 on('tool.call', { tool: 'mcp__playwright__browser_resize' }, ($, e, next) => {
38 const { width, height } = e as unknown as { width?: number; height?: number }
39 if ((width ?? 0) > max || (height ?? 0) > max) {
40 return { deny: `jr-image-guard: a ${width}×${height} viewport is over ${max} px; screenshots that size break the conversation. Use ${max} px or less.` }
41 }
42 return next(e)
43 })
44}
45hooks/image.ts 13 lines1export const IMAGE = /\.(png|jpe?g|webp|gif|tiff?|heic|bmp)$/i
2
3/** `sips -g pixelWidth -g pixelHeight` output → [width, height], or null. */
4export const parseSips = (stdout: string): [number, number] | null => {
5 const w = stdout.match(/pixelWidth:\s*(\d+)/)
6 const h = stdout.match(/pixelHeight:\s*(\d+)/)
7 return w && h ? [Number(w[1]), Number(h[1])] : null
8}
9
10export const isTooLarge = (size: [number, number] | null, max: number) => !!size && (size[0] > max || size[1] > max)
11
12export const resizedPath = (dir: string, file: string, stamp: number) => `${dir}/${stamp}-${file.split('/').pop() ?? 'image.png'}`
13