SLOPSHOPPER

change-monitor

Pane that shows what Claude added, changed or deleted since the last Clear

newpaneguardcommandstatusprocess
v0.3.0no licenseupdated 2026-10-23ByeongbumSeo/claude-change-monitor
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · change-monitor
│ ┃ Changes ✕ › fix the failing auth test and add an audit log call │ ┃ 0 files +0 -0 [ Clear ] [ Off ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ No changes since the last Clear. ⎿ Read 6 lines │ ⏺ 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 │ │ › /changes │ ⎿ change-monitor: Changes pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Changes
0 files +0 -0 [ Clear ] [ Off ] No changes since the last Clear.
README

change-monitor

English

Claude Code에서 이번 요청으로 바뀐 부분만 보여주는 패널(Mod)입니다.

왜 만들었나

git diff는 마지막 커밋이 기준이라, 커밋 없이 요청을 여러 번 이어가면 A → A' → A''의 변경이 한데 섞여 "방금 요청에서 뭐가 바뀌었는지" 알기 어렵습니다. change-monitor는 Clear를 누른 시점을 기준으로 Claude가 추가·수정·삭제한 파일과 diff를 보여줍니다. Clear → 요청 → 확인을 반복하면 요청 단위로 변경을 볼 수 있습니다.

설치

/plugin install change-monitor --marketplace ByeongbumSeo/claude-change-monitor

사용

  • /changes: 패널 열기 / /changes clear 또는 패널의 Clear: 지금 상태를 새 기준으로
  • 패널의 Off: 이 세션에서만 추적을 멈춥니다. 다시 켜려면 /changes
  • 모든 세션에서 끄려면: claude plugin disable change-monitor
  • Claude의 Edit·Write·Bash로 바뀐 파일을 추적합니다. 에디터 등 외부에서 고친 내용은 diff에서 제외합니다.

English

A Claude Code pane (mod) that shows only what the current request changed.

Why: git diff compares against the last commit, so across several uncommitted requests (A → A' → A'') the changes pile up and you can't tell what the latest request did. change-monitor uses the moment you press Clear as its baseline and shows the files Claude added, modified or deleted since then, with diffs.

Install: /plugin install change-monitor --marketplace ByeongbumSeo/claude-change-monitor

Use: /changes opens the pane; /changes clear or the Clear button makes the current state the new baseline. Off stops tracking in this session only; /changes turns it back on. To turn it off in every session: claude plugin disable change-monitor. Tracks Claude's Edit, Write and Bash calls; edits made outside Claude are kept out of the diff.

Source 3 files
hooks/register.tsx 334 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Entry } from '../types'
5import { merge3, unifiedDiff } from './diff'
6
7const PANE = 'change-monitor'
8const TITLE = 'Changes'
9const MAX_BYTES = 1_000_000
10// Caps on how many dirty files one Bash call inspects.
11const MAX_PRE_READ = 100
12const MAX_NEW = 200
13
14const entries = atom({ plugin: 'change-monitor', key: 'entries' } as const, {})
15const collapsed = atom({ plugin: 'change-monitor', key: 'collapsed' } as const, {})
16const root = atom({ plugin: 'change-monitor', key: 'root' } as const, '')
17const status = atom({ plugin: 'change-monitor', key: 'status' } as const, 'on')
18
19type $ = EngineInterface
20
21const NOTICE_MS = 5000
22
23let isGit = false
24let offTimer: ReturnType<$['clock']['after']> | undefined
25// Pre-Bash reads, reused while a file's mtime and size stay the same.
26const readCache = new Map<string, { mtimeMs: number; size: number; text: string }>()
27
28/** The file's text, null when absent, undefined when it cannot be tracked (dir, too large, unreadable). */
29async function snapshot($: $, path: string): Promise<string | null | undefined> {
30  const stat = await $.fs.stat(path).catch(() => undefined)
31  if (stat === undefined) return null
32  if (stat.kind !== 'file' || stat.size > MAX_BYTES) return undefined
33  const hit = readCache.get(path)
34  if (hit && hit.mtimeMs === stat.mtimeMs && hit.size === stat.size) return hit.text
35  const text = await $.fs.read(path).catch(() => undefined)
36  if (text !== undefined) readCache.set(path, { mtimeMs: stat.mtimeMs, size: stat.size, text })
37  return text
38}
39
40/** Starts tracking `path` with `baseline` unless it is tracked already. */
41const track = ($: $, path: string, baseline: string | null, current: string | null) =>
42  update($, entries, map => (path in map ? map : { ...map, [path]: { baseline, current } }))
43
44async function refresh($: $, path: string) {
45  const now = await snapshot($, path)
46  if (now === undefined) return
47  await update($, entries, map => {
48    const entry = map[path]
49    return entry === undefined || entry.current === now ? map : { ...map, [path]: { ...entry, current: now } }
50  })
51}
52
53/**
54 * Folds an edit made outside Claude since its last tool call into the baseline,
55 * so the diff keeps showing Claude's changes alone; marks the entry mixed when
56 * the two cannot be separated.
57 */
58async function absorbOutside($: $, path: string) {
59  const now = await snapshot($, path)
60  if (now === undefined) return
61  await update($, entries, map => {
62    const entry = map[path]
63    if (entry === undefined || entry.current === now) return map
64    const { baseline, current } = entry
65    const merged =
66      baseline !== null && current !== null && now !== null ? merge3(current, baseline, now) : undefined
67    const next: Entry =
68      merged === undefined
69        ? { baseline, current: now, isMixed: true }
70        : { baseline: merged, current: now, isMixed: entry.isMixed }
71    return { ...map, [path]: next }
72  })
73}
74
75async function absolute($: $, path: string) {
76  if (path.startsWith('/')) return path
77  return `${(await $.session.cwd()).replace(/\/$/, '')}/${path}`
78}
79
80/** Absolute paths `git status` lists as changed or untracked. */
81async function dirtyPaths($: $, top: string): Promise<string[]> {
82  const run = await $.process.run(
83    ['git', '-c', 'core.quotepath=off', 'status', '--porcelain=v1', '-z', '-uall'],
84    { cwd: top, timeoutMs: 5000 },
85  )
86  if (run.exitCode !== 0) return []
87  const parts = run.stdout.split('\0')
88  const paths: string[] = []
89  for (let k = 0; k < parts.length; k++) {
90    const part = parts[k]!
91    if (part.length < 4) continue
92    paths.push(`${top}/${part.slice(3)}`)
93    // A rename or copy is followed by its original path.
94    if (part[0] === 'R' || part[0] === 'C') k++
95  }
96  return paths
97}
98
99async function headContent($: $, top: string, path: string): Promise<string | null> {
100  const rel = path.slice(top.length + 1)
101  const run = await $.process.run(['git', 'show', `HEAD:${rel}`], { cwd: top, timeoutMs: 5000 })
102  return run.exitCode === 0 && !run.isStdoutTruncated ? run.stdout : null
103}
104
105async function beforeBash($: $, top: string) {
106  const tracked = await read($, entries)
107  for (const path of Object.keys(tracked)) await absorbOutside($, path)
108  const before = new Map<string, string | null>()
109  const skipped = new Set<string>()
110  for (const path of await dirtyPaths($, top)) {
111    if (path in tracked) continue
112    const text = before.size < MAX_PRE_READ ? await snapshot($, path) : undefined
113    if (text === undefined) skipped.add(path)
114    else before.set(path, text)
115  }
116  return { before, skipped }
117}
118
119async function afterBash($: $, top: string, pre: { before: Map<string, string | null>; skipped: Set<string> }) {
120  const tracked = await read($, entries)
121  for (const path of Object.keys(tracked)) await refresh($, path)
122
123  let fresh = 0
124  const candidates = new Set([...pre.before.keys(), ...(await dirtyPaths($, top))])
125  for (const path of candidates) {
126    if (path in tracked || pre.skipped.has(path)) continue
127    const isNew = !pre.before.has(path)
128    if (isNew && ++fresh > MAX_NEW) continue
129    const now = await snapshot($, path)
130    if (now === undefined) continue
131    const baseline = isNew ? await headContent($, top, path) : pre.before.get(path)!
132    if (baseline !== now) await track($, path, baseline, now)
133  }
134}
135
136function pathOf(e: object): string | undefined {
137  const input = e as Record<string, unknown>
138  const path = input.file_path ?? input.notebook_path
139  return typeof path === 'string' && path !== '' ? path : undefined
140}
141
142const kindOf = (entry: Entry): 'A' | 'M' | 'D' | undefined =>
143  entry.baseline === entry.current ? undefined
144  : entry.baseline === null ? 'A'
145  : entry.current === null ? 'D'
146  : 'M'
147
148const COLOR = { A: 'success', M: 'warning', D: 'error' } as const
149
150const clear = ($: $) =>
151  Promise.all([update($, entries, () => ({})), update($, collapsed, () => ({}))])
152
153const OFF_STATUS = 'change-monitor off · /changes to turn on'
154
155/** Stops tracking in this session and shows the off notice, which closes after NOTICE_MS. */
156async function turnOff($: $) {
157  if ((await read($, status)) === 'off') return
158  await Promise.all([update($, status, () => 'off'), clear($)])
159  $.ui.status(OFF_STATUS)
160  offTimer?.cancel()
161  offTimer = $.clock.after(NOTICE_MS, () => void closeNotice($))
162}
163
164async function closeNotice($: $) {
165  offTimer?.cancel()
166  offTimer = undefined
167  if ((await read($, status)) === 'off') await $.ui.close({ id: PANE })
168}
169
170/** Opens the pane; says why when the surface does not draw it now. */
171async function openPane($: $) {
172  const opened = await $.ui.open({ id: PANE, title: TITLE })
173  return opened.isPlaced ? 'Changes pane opened.' : `Changes pane is open but not drawn: ${opened.reason}`
174}
175
176/** Resumes tracking in this session; the current state is the baseline. */
177async function turnOn($: $) {
178  offTimer?.cancel()
179  offTimer = undefined
180  await Promise.all([update($, status, () => 'on'), clear($)])
181  $.ui.status(undefined)
182  return openPane($)
183}
184
185export const register: Register = on => {
186  on('session.start', async ($, e, next) => {
187    const cwd = await $.session.cwd()
188    const top = await $.process
189      .run(['git', 'rev-parse', '--show-toplevel'], { cwd, timeoutMs: 5000 })
190      .catch(() => undefined)
191    isGit = top?.exitCode === 0
192    await update($, root, () => (isGit ? top!.stdout.trim() : cwd))
193
194    await $.command.register({
195      name: 'changes',
196      description: 'Open the Changes pane; turns tracking back on when off (/changes clear | on | off)',
197      argumentHint: '[clear|on|off]',
198    })
199
200    // On/off is this session's own state, kept across a reload of the mod.
201    if ((await read($, status)) === 'off') $.ui.status(OFF_STATUS)
202    else void $.ui.open({ id: PANE, title: TITLE })
203
204    return next(e)
205  })
206
207  on('command.run', { command: 'changes' }, async ($, e) => {
208    const arg = e.args.trim()
209    if (arg === 'off') {
210      await turnOff($)
211      await $.ui.open({ id: PANE, title: TITLE })
212      return { text: 'Change tracking turned off for this session. Turn it back on with /changes' }
213    }
214    if (arg === 'on' || (await read($, status)) === 'off') {
215      const pane = await turnOn($)
216      return { text: `Change tracking turned on: the current state is the baseline. ${pane}` }
217    }
218    if (arg === 'clear') {
219      await clear($)
220      return { text: 'Changes cleared: the current state is the new baseline.' }
221    }
222    return { text: await openPane($) }
223  })
224
225  on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit'] }, async ($, e, next) => {
226    if ((await read($, status)) !== 'on') return next(e)
227    const raw = pathOf(e)
228    let path: string | undefined
229    try {
230      if (raw !== undefined) {
231        path = await absolute($, raw)
232        if (path in (await read($, entries))) await absorbOutside($, path)
233        else {
234          const before = await snapshot($, path)
235          if (before !== undefined) await track($, path, before, before)
236        }
237      }
238    } catch {
239      path = undefined
240    }
241
242    const ran = await next(e)
243    if (path !== undefined) await refresh($, path).catch(() => {})
244    return ran
245  })
246
247  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
248    if ((await read($, status)) !== 'on') return next(e)
249    const top = await read($, root)
250    const pre = isGit ? await beforeBash($, top).catch(() => undefined) : undefined
251
252    const ran = await next(e)
253    if (pre !== undefined) await afterBash($, top, pre).catch(() => {})
254    return ran
255  })
256
257  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
258    const { Box, Text, Button, Code } = $.ui.resolve(e)
259    const [map, folded, top, state] = await Promise.all([
260      read($, entries),
261      read($, collapsed),
262      read($, root),
263      read($, status),
264    ])
265
266    if (state === 'off')
267      return (
268        <Box flexDirection="column" gap={1}>
269          <Text weight="bold">change-monitor is off for this session.</Text>
270          <Text>
271            To turn it back on: <Text color="suggestion">/changes</Text>
272          </Text>
273          <Text dimColor>Closes in {NOTICE_MS / 1000} seconds.</Text>
274          <Button key="close-notice" hotkey="x" onPress={() => void closeNotice($)}>
275            Close
276          </Button>
277        </Box>
278      )
279
280    const files = Object.entries(map)
281      .flatMap(([path, entry]) => {
282        const kind = kindOf(entry)
283        if (kind === undefined) return []
284        const rel = path.startsWith(`${top}/`) ? path.slice(top.length + 1) : path
285        return [{ path, rel, kind, isMixed: entry.isMixed === true, diff: unifiedDiff(entry.baseline, entry.current) }]
286      })
287      .sort((a, b) => a.rel.localeCompare(b.rel))
288
289    const added = files.reduce((sum, f) => sum + f.diff.added, 0)
290    const removed = files.reduce((sum, f) => sum + f.diff.removed, 0)
291
292    return (
293      <Box flexDirection="column" gap={1}>
294        <Box flexDirection="row" justifyContent="space-between">
295          <Text weight="bold">
296            {files.length} files <Text color="diffAdded">+{added}</Text>{' '}
297            <Text color="diffRemoved">-{removed}</Text>
298          </Text>
299          <Box flexDirection="row" gap={1}>
300            <Button key="clear" hotkey="c" onPress={() => void clear($)}>
301              Clear
302            </Button>
303            <Button key="off" hotkey="o" onPress={() => void turnOff($)}>
304              Off
305            </Button>
306          </Box>
307        </Box>
308        {files.length === 0 && <Text dimColor>No changes since the last Clear.</Text>}
309        {files.map(f => (
310          <Box key={`file:${f.path}`} flexDirection="column">
311            <Button
312              key={`toggle:${f.path}`}
313              plain
314              onPress={() => void update($, collapsed, c => ({ ...c, [f.path]: !c[f.path] }))}
315            >
316              <Text color={COLOR[f.kind]} weight="bold">
317                {f.kind}
318              </Text>{' '}
319              {folded[f.path] ? '▸' : '▾'} {f.rel}{' '}
320              <Text dimColor>
321                +{f.diff.added} -{f.diff.removed}
322              </Text>
323              {f.isMixed && <Text color="warning"> ⚠ mixed with outside edits</Text>}
324            </Button>
325            {!folded[f.path] && f.diff.text !== '' && (
326              <Code source={f.diff.text} format="diff" path={f.rel} />
327            )}
328          </Box>
329        ))}
330      </Box>
331    )
332  })
333}
334
hooks/diff.ts 161 lines
1export type Diff = { text: string; added: number; removed: number }
2
3type Op = { t: ' ' | '-' | '+'; line: string }
4
5// LCS beyond this many cells falls back to "all removed, then all added".
6const MAX_CELLS = 1_000_000
7
8const linesOf = (text: string | null): string[] =>
9  text === null || text === '' ? [] : text.replace(/\n$/, '').split('\n')
10
11function ops(a: string[], b: string[]): Op[] {
12  let head = 0
13  while (head < a.length && head < b.length && a[head] === b[head]) head++
14  let tail = 0
15  while (
16    tail < a.length - head &&
17    tail < b.length - head &&
18    a[a.length - 1 - tail] === b[b.length - 1 - tail]
19  )
20    tail++
21
22  const A = a.slice(head, a.length - tail)
23  const B = b.slice(head, b.length - tail)
24  const mid: Op[] = []
25
26  if (A.length * B.length <= MAX_CELLS) {
27    const n = A.length
28    const m = B.length
29    const w = m + 1
30    const lcs = new Uint32Array((n + 1) * w)
31    for (let i = n - 1; i >= 0; i--)
32      for (let j = m - 1; j >= 0; j--)
33        lcs[i * w + j] =
34          A[i] === B[j]
35            ? lcs[(i + 1) * w + j + 1]! + 1
36            : Math.max(lcs[(i + 1) * w + j]!, lcs[i * w + j + 1]!)
37    let i = 0
38    let j = 0
39    while (i < n && j < m) {
40      if (A[i] === B[j]) {
41        mid.push({ t: ' ', line: A[i]! })
42        i++
43        j++
44      } else if (lcs[(i + 1) * w + j]! >= lcs[i * w + j + 1]!) mid.push({ t: '-', line: A[i++]! })
45      else mid.push({ t: '+', line: B[j++]! })
46    }
47    while (i < n) mid.push({ t: '-', line: A[i++]! })
48    while (j < m) mid.push({ t: '+', line: B[j++]! })
49  } else {
50    for (const line of A) mid.push({ t: '-', line })
51    for (const line of B) mid.push({ t: '+', line })
52  }
53
54  const same = (line: string): Op => ({ t: ' ', line })
55  return [...a.slice(0, head).map(same), ...mid, ...a.slice(a.length - tail).map(same)]
56}
57
58/** A replaced run of the base's lines: [start, end) becomes `lines`. */
59type Chunk = { start: number; end: number; lines: string[] }
60
61function chunks(base: string[], other: string[]): Chunk[] {
62  const out: Chunk[] = []
63  let at = 0
64  let open: Chunk | undefined
65  for (const op of ops(base, other)) {
66    if (op.t === ' ') {
67      if (open) out.push(open)
68      open = undefined
69      at++
70      continue
71    }
72    open ??= { start: at, end: at, lines: [] }
73    if (op.t === '-') open.end = ++at
74    else open.lines.push(op.line)
75  }
76  if (open) out.push(open)
77  return out
78}
79
80const sameChunk = (a: Chunk, b: Chunk) =>
81  a.start === b.start && a.end === b.end && a.lines.join('\n') === b.lines.join('\n')
82
83/**
84 * Three-way merge of `ours` and `theirs` from their common `base`, or undefined
85 * on a conflict (both sides touching the same or adjacent lines differently).
86 */
87export function merge3(base: string, ours: string, theirs: string): string | undefined {
88  const lines = linesOf(base)
89  const all = [
90    ...chunks(lines, linesOf(ours)),
91    ...chunks(lines, linesOf(theirs)),
92  ].sort((a, b) => a.start - b.start || a.end - b.end)
93
94  const picked: Chunk[] = []
95  for (const chunk of all) {
96    const last = picked[picked.length - 1]
97    if (last && chunk.start <= last.end) {
98      if (sameChunk(last, chunk)) continue
99      return undefined
100    }
101    picked.push(chunk)
102  }
103
104  const out: string[] = []
105  let at = 0
106  for (const chunk of picked) {
107    out.push(...lines.slice(at, chunk.start), ...chunk.lines)
108    at = chunk.end
109  }
110  out.push(...lines.slice(at))
111  return out.length ? out.join('\n') + '\n' : ''
112}
113
114/** Unified diff hunks (no file header) between two texts; null means the file is absent. */
115export function unifiedDiff(before: string | null, after: string | null, context = 3): Diff {
116  const list = ops(linesOf(before), linesOf(after))
117
118  // Lines of a and b consumed before each op.
119  const aBefore: number[] = []
120  const bBefore: number[] = []
121  let ai = 0
122  let bi = 0
123  for (const op of list) {
124    aBefore.push(ai)
125    bBefore.push(bi)
126    if (op.t !== '+') ai++
127    if (op.t !== '-') bi++
128  }
129
130  const changed = list.flatMap((op, k) => (op.t === ' ' ? [] : [k]))
131  const out: string[] = []
132  let added = 0
133  let removed = 0
134
135  let g = 0
136  while (g < changed.length) {
137    let last = g
138    while (last + 1 < changed.length && changed[last + 1]! - changed[last]! <= 2 * context) last++
139    const start = Math.max(0, changed[g]! - context)
140    const end = Math.min(list.length, changed[last]! + context + 1)
141
142    const body: string[] = []
143    let aCount = 0
144    let bCount = 0
145    for (let k = start; k < end; k++) {
146      const op = list[k]!
147      body.push(op.t + op.line)
148      if (op.t !== '+') aCount++
149      if (op.t !== '-') bCount++
150      if (op.t === '+') added++
151      if (op.t === '-') removed++
152    }
153    const aStart = aCount ? aBefore[start]! + 1 : aBefore[start]!
154    const bStart = bCount ? bBefore[start]! + 1 : bBefore[start]!
155    out.push(`@@ -${aStart},${aCount} +${bStart},${bCount} @@`, ...body)
156    g = last + 1
157  }
158
159  return { text: out.join('\n'), added, removed }
160}
161
types/index.d.ts 18 lines
1/**
2 * A tracked file: its content at the baseline (Clear) and as Claude's last tool
3 * call left it; null = absent. `isMixed` when an outside edit could not be
4 * separated from Claude's.
5 */
6export type Entry = { baseline: string | null; current: string | null; isMixed?: boolean }
7
8declare module 'claude-code' {
9  interface PluginState {
10    'change-monitor': {
11      entries: Record<string, Entry>
12      collapsed: Record<string, boolean>
13      root: string
14      status: 'on' | 'off'
15    }
16  }
17}
18