SLOPSHOPPER

check-it

A list of the things to look at with your own eyes after Claude changes something you can see, each with a link and a tick.

newpanebandguardcommandtoast
v0.1.5MITupdated 2026-10-04davideiffert/check-it
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · check-it
│ ┃ Check it ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing left to look at. New items appear │ ┃ when Claude changes a page you can see. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ [ ask what to check ] ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /check-it │ ⎿ check-it: Check it list opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Check it
Nothing left to look at. New items appear when Claude changes a page you can see. [ ask what to check ]
README

Check it

Claude Code tells you what it changed. Check it tells you where to look.

After a long session, Claude says it's done. It changed six things. Which ones can you actually see, and where do you click? Check it keeps the list for you: every change you can see in a browser, with a link, what you should find there, and a box to tick. If something's off, one button tells Claude which one.

For the person checking the result, not the diff.

Build checks · Changelog · MIT license

Claude has just changed two pages of a demo bakery site. A line above the prompt reads "Check it: 2 left to look at". The list opens beside the conversation, one item is ticked, and it folds into a "checked (1)" row.

Claude Code 2.1.288, right after Claude changed the Saturday hours and the menu of a made-up bakery site from demo/.

Try it

Needs Claude Code 2.1.288 or later (claude --version shows yours).

claude plugin marketplace add davideiffert/check-it && claude plugin install check-it@check-it --config siteAddress=http://localhost:3000

Swap http://localhost:3000 for wherever your app or site runs right now, on this computer or online. The command only installs Check it. It doesn't start your app for you. Then start Claude Code as usual.

What it does

  • A line above the prompt says how many things are left to look at, with open list and ask what to check buttons. It stays hidden until the first item arrives.
  • The list shows each item: what to look at, what you should see, where it is (on this computer, test version or live), and a button to open it.
  • open link opens the page in your browser.
  • checked ticks it. Ticked items fold into one checked (N) row, where you can undo a tick.
  • not right puts Not right: <item> (<address>). at the end of your prompt, after anything you already typed. Add a sentence and send it. It never sends anything by itself.
  • dismiss removes the item.
  • /check-it opens the list. /check-it clear empties it.
  • ask what to check puts a request at the end of your prompt asking Claude what you should look at. You decide whether to send it.

How the list is filled

Claude fills it. The plugin gives Claude one tool for adding an item and one short instruction: when a change alters what someone sees or does on a page, add one item per change, with the address where it shows right now. Changes nobody can see (tidying code, tests, settings) get no items. Something every page shares, like the footer, gets one item.

New items wait until Claude's turn ends, so nothing shows up while Claude is still halfway through the change. That's a delay, not a check: the list doesn't confirm the change exists. If you stop a turn, its items are dropped. Only the main conversation adds items. Helper agents Claude starts cannot.

If Claude can't name an address where a change shows (nothing is running, or it isn't built yet), it adds nothing and says so in its reply, naming what it needs.

The list is Claude's word for it. The tick is yours.

The list is kept per project and survives restarts. It holds 50 items, and the oldest ticked ones go first.

Does it actually work?

Measured on two demo sites, a plain one and one with a build step: 51 recorded sessions (31 and 20) using Claude Opus and Sonnet, plus 3 interactive ones.

  • In the sessions where something was serving the site, Claude made 48 changes you could see. 47 could be seen by the end of the session, and Claude listed all 47. The other one was never built, so Claude did not list it and said why.
  • No item pointed somewhere the change could not be seen.
  • No item was added for an invisible change.
  • In 10 sessions the address was the public site, which the session could not publish to. Claude never listed a change there. It listed the local preview instead or, with none running, said what it needed.
  • In the 3 interactive sessions nothing was serving the site and no address was set. Each time, Claude asked to start a local preview, then added 4 items in all, each pointing at the right page. One address first showed as plain text because Claude wrote 127.0.0.1; since 0.1.2 that opens as a link.

Details are in docs/measurement-v0.md.

What it doesn't do

  • Experiment, not actively maintained. I built this to learn how Claude Code mods work and don't use it day to day. Mods are an early access feature, and their API changes between releases. Built and tested on Claude Code 2.1.288. Earlier versions are not supported, and later ones may break it.
  • Browser only. If it doesn't have a web address, it doesn't get listed. That rules out phone apps, desktop apps, emails and files.
  • A link gets you to the page, not to the moment. If a change only shows after you log in, submit a form or open a menu, the link lands you nearby and the "what you should see" line tells you the step.
  • Claude has to know the address. Tell it once with the siteAddress setting, the one setting worth filling in. Without it, and with nothing running, Claude often has no address to give you and the list stays empty. Set it with --config as above, or later in /config under check-it.
  • A sidebar only in fullscreen mode. With /tui fullscreen the list docks beside the conversation. On the normal screen it opens above the prompt instead.
  • Clicks and keys. Opening the list from an empty prompt gives it the keyboard. While the prompt holds text, click the buttons instead.
  • Colors. The plugin sets no colors. Claude Code paints the list's background itself, and in its default dark theme that is a fixed gray. If it clashes with your terminal, pick the ANSI colors theme in /theme.
  • The desktop app is untested. There, open link is a plain link, and it only works for https: and http://localhost addresses. Other addresses show as text.

What it can touch

Almost nothing outside its own list. No network access, no model calls of its own, no files. It runs one command, and only when you press open link in the terminal: your system's own opener (open on macOS, xdg-open on Linux), with an http:// or https:// address the plugin has already checked, to show the page in your browser. It adds one tool, one short section of Claude's instructions, the line above the prompt, the list and the /check-it command.

Install options

From the marketplace, as above. To try a local copy without installing:

git clone https://github.com/davideiffert/check-it
claude --plugin-dir ./check-it

Contributing

Issues and pull requests are welcome. Before sending a change, run:

claude plugin validate .
claude plugin test .

For the type check, start Claude Code once with --plugin-dir . (it writes the type declarations into .claude-plugin/types), then run npx -p typescript@5 tsc -p ..

License

MIT. Made by David Eiffert.

Source 3 files
hooks/register.tsx 284 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Held, Item, Project, Shown } from '../types'
5import {
6  ASK_TEXT,
7  PLACE_WORDS,
8  add,
9  clear,
10  dismiss,
11  empty,
12  leftLine,
13  notRightText,
14  safeHref,
15  setStatus,
16  storeKey,
17  toItem,
18  openableHref,
19} from './list'
20
21const TOOL = 'mcp__check-it__add'
22const PANE = 'check-it'
23
24const shown = atom({ plugin: 'check-it', key: 'shown' } as const, { root: '', ...empty() } as Shown)
25const showChecked = atom({ plugin: 'check-it', key: 'showChecked' } as const, false)
26const pending = atom({ plugin: 'check-it', key: 'pending' } as const, [] as Held[])
27
28export const DESCRIPTION = [
29  "Adds one item to the person's Check it list: something they should open and look at with their own eyes",
30  'because you changed what a web page shows or does. Call it once per visible change, only after the edit is saved',
31  'and the change can be seen at `where`, never before you make it. Do not call it for changes nobody can see (refactors, tests, config, scripts,',
32  'comments). Reuse the same `id` when the same thing changes again.',
33].join(' ')
34
35const INPUT_SCHEMA = {
36  type: 'object',
37  properties: {
38    id: {
39      type: 'string',
40      description: 'A short stable id for this thing, e.g. "contact-hours". Reuse it when the same thing changes again.',
41    },
42    label: {
43      type: 'string',
44      description: 'What to look at, in plain words a non-developer uses, no file names. e.g. "Opening hours on the contact page".',
45    },
46    expect: {
47      type: 'string',
48      description: 'One line: what the person should see there now. e.g. "Saturday now says 9 to 1".',
49    },
50    where: {
51      type: 'string',
52      description: 'The full web address where the change can be seen now, starting with http:// or https://, with the page path. For a page on this computer write localhost, not 127.0.0.1.',
53    },
54    place: {
55      type: 'string',
56      enum: ['local', 'preview', 'live'],
57      description: 'local: on this computer only. preview: a test version online. live: the public site.',
58    },
59  },
60  required: ['id', 'label', 'expect', 'where', 'place'],
61}
62
63export const sectionText = (siteAddress: string) =>
64  [
65    '# Check it list',
66    'The person keeps a list of things to look at after you change something they can see. When your work changes',
67    'what a web page shows or how it behaves for a visitor (words, layout, colors, images, links, buttons, forms,',
68    `new or removed pages), call ${TOOL} once for each distinct visible change, after your edits are saved and before`,
69    'your final reply (never before making the change), with the',
70    'address of the page where that change can be seen right now. Several changes on one page that a person would',
71    'check at a glance can be one item; changes on different pages are separate items. A change to something every',
72    'page shares (the top bar, the footer, the colors) is one item, linked to one page that shows it.',
73    'Do not add items for changes with no visible effect. If a change cannot be seen yet (not built, not deployed,',
74    'nothing serving it), do not add it. When you made a visible change but cannot name an address where it can be',
75    'seen right now, say so in one sentence in your reply and name what you need: the site address (the person sets',
76    'it in /config under check-it) or how the site is served.',
77    ...(siteAddress === '' ? [] : [`The site can be seen at ${siteAddress}. Build links from it.`]),
78  ].join('\n')
79
80type $ = EngineInterface
81
82const stored = async ($: $, root: string): Promise<Project> =>
83  ((await $.store.get(storeKey(root))) as Project | undefined) ?? empty()
84
85/**
86 * Runs list writes one at a time, so two presses (or a press and a turn ending)
87 * never read the same list and overwrite each other.
88 */
89let queue: Promise<unknown> = Promise.resolve()
90const serial = <T,>(work: () => Promise<T>): Promise<T> => {
91  const run = queue.then(work, work)
92  queue = run.catch(() => undefined)
93  return run
94}
95
96/** Changes the list of one project, and the screen when it shows that project. */
97const change = ($: $, root: string, fn: (p: Project) => Project) =>
98  serial(async () => {
99    const next = fn(await stored($, root))
100    await $.store.set(storeKey(root), next)
101    await update($, shown, now => (now.root === root || now.root === '' ? { root, ...next } : now))
102    return next
103  })
104
105/** Puts the current project's list on screen. */
106const show = ($: $) =>
107  serial(async () => {
108    const root = await $.session.root()
109    const next = await stored($, root)
110    await update($, shown, () => ({ root, ...next }))
111  })
112
113/** Adds text after whatever the person has typed, with a space between, and never sends it. */
114const appendToPrompt = async ($: $, text: string) => {
115  const draft = (await $.prompt.read()).text
116  const gap = draft === '' || /\s$/.test(draft) ? '' : ' '
117  return $.prompt.fill({ text: gap + text, mode: 'append' })
118}
119
120/** Opened by the person (a command or a press), so it takes the keys: Tab walks its buttons, Esc closes it. */
121// The terminal draws a Link as a hyperlink with no hover or focus, so there the mod opens the
122// page itself, with the system's own opener and an address safeHref already passed. No shell.
123const openInBrowser = async ($: $, href: string) => {
124  for (const opener of ['open', 'xdg-open']) {
125    try {
126      const ran = await $.process.run([opener, href], { timeoutMs: 10000 })
127      if (ran.exitCode === 0) return
128    } catch {
129      // this opener is not on this computer; try the next
130    }
131  }
132  $.ui.toast(`Could not open the browser. The page is at ${href}`)
133}
134
135const openPane = ($: $) => show($).then(() => openOnly($))
136const openOnly = ($: $) => $.ui.open({ id: PANE, title: 'Check it', columns: 34, focus: true, closeOnEscape: true })
137
138export const register: Register = (on, options) => {
139  const siteAddress = typeof options.siteAddress === 'string' ? options.siteAddress.trim() : ''
140
141  on('session.start', async ($, e, next) => {
142    await $.tool.register({ name: 'add', description: DESCRIPTION, inputSchema: INPUT_SCHEMA })
143    await $.command.register({
144      name: 'check-it',
145      description: 'Open the Check it list (/check-it clear empties it)',
146    })
147    await show($)
148    return next(e)
149  })
150
151  on('tool.describe', { tool: TOOL }, async ($, e, next) => ({ ...(await next(e)), isDeferred: false }))
152
153  on('tool.check', { tool: TOOL }, () => ({ decision: 'allow' }))
154
155  on('tool.call', { tool: TOOL }, async ($, e) => {
156    // Only the main conversation adds items: a subagent's turn can be stopped or
157    // still running when the main turn ends, and its items would show anyway.
158    if (e.agentId !== undefined) {
159      return { result: 'Not added: only the main conversation can add to the Check it list. Say in your answer what the person should look at.' }
160    }
161    const item = toItem(e as Record<string, unknown>, await $.clock.now())
162    if ('error' in item) return { result: `Not added: ${item.error}` }
163    // Held until the turn ends, and kept with the project it was added in.
164    const root = await $.session.root()
165    await update($, pending, held => [...held.filter(one => one.root !== root || one.item.id !== item.id), { root, item }])
166    const note = openableHref(item.where) === undefined
167      ? ' Note: that is not an http:// or https:// address, so it shows as plain text.'
168      : ''
169    return { result: `Added "${item.label}" to the Check it list.${note}` }
170  })
171
172  on('turn.complete', async ($, e, next) => {
173    if (e.agentId === undefined) {
174      const held = await read($, pending)
175      if (held.length > 0) {
176        await update($, pending, () => [])
177        if (e.reason !== 'aborted' && !e.isAborted) {
178          for (const root of new Set(held.map(one => one.root))) {
179            const items = held.filter(one => one.root === root).map(one => one.item)
180            await change($, root, p => items.reduce(add, p))
181          }
182        }
183      }
184    }
185    return next(e)
186  })
187
188  on('prompt.compose', async ($, e, next) => {
189    const composed = await next(e)
190    return {
191      sections: [...composed.sections, { id: 'check-it:guide', text: sectionText(siteAddress), scope: 'session' }],
192    }
193  })
194
195  on('command.run', { command: 'check-it' }, async ($, e) => {
196    if (e.args.trim() === 'clear') {
197      const root = await $.session.root()
198      await change($, root, clear)
199      await update($, pending, held => held.filter(one => one.root !== root))
200      return { text: 'Check it list cleared.' }
201    }
202    await openPane($)
203    return { text: 'Check it list opened.' }
204  })
205
206  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
207    const p = await read($, shown)
208    if (e.props.hasSurvey || !p.ever) return next(e)
209    const { Box, Button, Text } = $.ui.resolve(e)
210    return (
211      <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
212        <Text>{leftLine(p)}</Text>
213        <Button key="open" label="open list" onPress={() => openPane($)} />
214        <Button key="ask" label="ask what to check" onPress={() => appendToPrompt($, ASK_TEXT)} />
215      </Box>
216    )
217  })
218
219  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
220    const { Box, Button, Link, Text } = $.ui.resolve(e)
221    const p = await read($, shown)
222    // Every press acts on the project this list belongs to, even after a change of directory.
223    const root = p.root
224    const isShowingChecked = await read($, showChecked)
225    const open = p.items.filter(one => one.status === 'open').reverse()
226    const checked = p.items.filter(one => one.status === 'checked').reverse()
227
228    const whereOf = (item: Item) => {
229      // The terminal button opens any http(s) page; a Link elsewhere takes only what safeHref passes.
230      const href = e.surface === 'terminal' ? openableHref(item.where) : safeHref(item.where)
231      if (href === undefined) return <Text dimColor wrap="truncate-end">{item.where}</Text>
232      return e.surface === 'terminal'
233        ? <Button key={`open:${item.id}`} label="open link" onPress={() => openInBrowser($, href)} />
234        : <Link href={href} label="open link" />
235    }
236
237    return (
238      <Box flexDirection="column" rowGap={1}>
239        <Text dimColor>
240          {open.length === 0 ? 'Nothing left to look at. New items appear when Claude changes a page you can see.' : `${open.length} to look at. Claude says:`}
241        </Text>
242        {open.map(item => (
243          <Box key={`item:${item.id}`} flexDirection="column">
244            <Text bold>{item.label}</Text>
245            {item.expect !== '' && <Text>{item.expect}</Text>}
246            <Box flexDirection="row" columnGap={1}>
247              <Text dimColor>{PLACE_WORDS[item.place]}</Text>
248              {whereOf(item)}
249            </Box>
250            <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
251              <Button key={`check:${item.id}`} label="checked" onPress={() => change($, root, q => setStatus(q, item.id, 'checked'))} />
252              <Button
253                key={`bad:${item.id}`}
254                label="not right"
255                onPress={() => appendToPrompt($, notRightText(item))}
256              />
257              <Button key={`dismiss:${item.id}`} label="dismiss" dimColor onPress={() => change($, root, q => dismiss(q, item.id))} />
258            </Box>
259          </Box>
260        ))}
261        {checked.length > 0 && (
262          <Box key="checked" flexDirection="column">
263            <Button
264              key="toggle-checked"
265              plain
266              dimColor
267              label={`${isShowingChecked ? 'v' : '>'} checked (${checked.length})`}
268              onPress={() => update($, showChecked, shown => !shown)}
269            />
270            {isShowingChecked &&
271              checked.map(item => (
272                <Box key={`done:${item.id}`} flexDirection="row" columnGap={1}>
273                  <Text dimColor wrap="truncate-end">{item.label}</Text>
274                  <Button key={`undo:${item.id}`} label="undo" dimColor onPress={() => change($, root, q => setStatus(q, item.id, 'open'))} />
275                </Box>
276              ))}
277          </Box>
278        )}
279        <Button key="ask" label="ask what to check" dimColor onPress={() => appendToPrompt($, ASK_TEXT)} />
280      </Box>
281    )
282  })
283}
284
hooks/list.ts 121 lines
1import type { Item, Place, Project } from '../types'
2
3export const CAP = 50
4export const MAX_WHERE = 2048
5export const PLACES: readonly Place[] = ['local', 'preview', 'live']
6
7export const empty = (): Project => ({ items: [], ever: false })
8
9export const PLACE_WORDS: Record<Place, string> = {
10  local: 'on this computer',
11  preview: 'test version',
12  live: 'live',
13}
14
15const LOOPBACK = ['127.0.0.1', '[::1]', '0.0.0.0']
16
17export const storeKey = (root: string) => `project:${root}`
18
19/** The href a Link may take, or undefined when `where` must show as text. */
20export const safeHref = (where: string): string | undefined => {
21  let url: URL
22  try {
23    url = new URL(where.trim())
24  } catch {
25    return undefined
26  }
27  // A loopback address is this computer: open it as localhost, the one http: host a Link takes.
28  if (url.protocol === 'http:' && LOOPBACK.includes(url.hostname)) url.hostname = 'localhost'
29  const isLocal = url.protocol === 'http:' && url.hostname === 'localhost'
30  if (url.protocol !== 'https:' && !isLocal) return undefined
31  if (url.username !== '' || url.password !== '') return undefined
32  const href = url.href
33  if (href.length > 2048 || href.includes('@') || !/^[\x21-\x7e]+$/.test(href)) return undefined
34  return href
35}
36
37/**
38 * The address the terminal's `open link` button may hand to the system opener, or undefined.
39 * Wider than a Link takes: any http: or https: page, so a dev server on another machine, a
40 * local network address or a .test domain opens too.
41 */
42export const openableHref = (where: string): string | undefined => {
43  const safe = safeHref(where)
44  if (safe !== undefined) return safe
45  let url: URL
46  try {
47    url = new URL(where.trim())
48  } catch {
49    return undefined
50  }
51  if (url.protocol !== 'http:' && url.protocol !== 'https:') return undefined
52  if (url.username !== '' || url.password !== '') return undefined
53  const href = url.href
54  if (href.length > 2048 || href.includes('@') || !/^[\x21-\x7e]+$/.test(href)) return undefined
55  return href
56}
57
58export type AddInput = {
59  id?: unknown
60  label?: unknown
61  expect?: unknown
62  where?: unknown
63  place?: unknown
64}
65
66const text = (value: unknown, max: number) =>
67  typeof value === 'string' ? value.replace(/\s+/g, ' ').trim().slice(0, max) : ''
68
69const slug = (value: string) =>
70  value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 48)
71
72/** Turns the model's tool input into an item, or a reason it cannot be one. */
73export const toItem = (input: AddInput, now: number): Item | { error: string } => {
74  const label = text(input.label, 80)
75  // An address is never shortened or reworded: a cut URL can lead somewhere else.
76  const where = typeof input.where === 'string' ? input.where.trim() : ''
77  if (label === '') return { error: 'label is required.' }
78  if (where === '') return { error: 'where is required: the web address where the change can be seen.' }
79  if (where.length > MAX_WHERE) return { error: `where is longer than ${MAX_WHERE} characters. Give the page's address without extra query text.` }
80  const id = slug(text(input.id, 64)) || slug(label) || 'item'
81  const place = PLACES.find(one => one === input.place) ?? (safeHref(where)?.startsWith('http://localhost') ? 'local' : 'live')
82  return { id, label, expect: text(input.expect, 160), where, place, addedAt: now, status: 'open' }
83}
84
85/** Adds an item, replacing one with the same id (the thing changed again). */
86export const add = (project: Project, item: Item): Project =>
87  cap({ items: [...project.items.filter(one => one.id !== item.id), item], ever: true })
88
89/** Keeps at most CAP items: oldest checked go first, then oldest open. */
90export const cap = (project: Project): Project => {
91  const items = [...project.items]
92  while (items.length > CAP) {
93    const checked = items.findIndex(one => one.status === 'checked')
94    items.splice(checked === -1 ? 0 : checked, 1)
95  }
96  return { ...project, items }
97}
98
99export const setStatus = (project: Project, id: string, status: Item['status']): Project => ({
100  ...project,
101  items: project.items.map(one => (one.id === id ? { ...one, status } : one)),
102})
103
104export const dismiss = (project: Project, id: string): Project => ({
105  ...project,
106  items: project.items.filter(one => one.id !== id),
107})
108
109export const clear = (project: Project): Project => ({ ...project, items: [] })
110
111export const notRightText = (item: Item) => `Not right: ${item.label} (${item.where}). `
112
113export const ASK_TEXT =
114  'What should I look at to check the changes you made? Add each one to my Check it list. '
115
116export const leftLine = (project: Project) => {
117  const left = project.items.filter(one => one.status === 'open').length
118  if (left === 0) return 'Check it: nothing left to look at'
119  return `Check it: ${left} left to look at`
120}
121
types/index.d.ts 26 lines
1export type Place = 'local' | 'preview' | 'live'
2
3export type Item = {
4  id: string
5  label: string
6  expect: string
7  where: string
8  place: Place
9  addedAt: number
10  status: 'open' | 'checked'
11}
12
13export type Project = { items: Item[]; ever: boolean }
14
15/** The list on screen and the project root it belongs to. */
16export type Shown = Project & { root: string }
17
18/** An item Claude added this turn, and the project root it was added in. */
19export type Held = { root: string; item: Item }
20
21declare module 'claude-code' {
22  interface PluginState {
23    'check-it': { shown: Shown; showChecked: boolean; pending: Held[] }
24  }
25}
26