After every Edit, Write, NotebookEdit or sed -i, a docked pane shows that file's uncommitted hunk with [keep] and [revert] buttons; a press dismisses or runs…

Mods that keep an agent honest — plus a few that make the terminal fun.
claude-code · mod · function-hooks · typescript · macos
usage max volty opus-5 5h 34% 7d 12% ctx 41% 82k $1.23
scope: 3/4 files
▸
<sub>Thirteen mods, each drawing or guarding its own slice of the session. Above: usage-band and scope-guard.</sub>
<sub>usage-band, wod-band and wod-timer in a live session.</sub>
Claude Code will tell you a deploy worked because git push exited 0. It will turn a one-line fix into a nine-file refactor and never mention it. Written rules in CLAUDE.md help until the model forgets them, and you find out on the deploy that breaks.
These are the same rules, moved out of prose and into the engine — where they hold whether or not the model remembers.
A mod is a Claude Code plugin whose behaviour lives in a TypeScript hooks module — register(on, options) wiring handlers onto engine events (tool.call, ui.render, turn.complete) rather than markdown the model reads. A mod can deny a tool call, rewrite it in flight, draw above the prompt, or put evidence in front of the model that it cannot argue with.
Every mod here is source you can read in one sitting. None of them phone home: there is no $.http.fetch anywhere in this repo.
Function hooks are behind a flag. Set it first, in your shell profile or settings.json env:
export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1
Then, in Claude Code:
/plugin marketplace add yash-gadodia/claude-mods
/plugin install scope-guard@claude-mods
Install only what you want — each mod is independent. Update with claude plugin update <name>@claude-mods.
| Mod | What it does |
|---|---|
| scope-guard | Counts the distinct files one turn edits. At the threshold it stops and makes the goal get restated, so a small ask cannot quietly become a refactor. /scope sets it. |
| deploy-verify | After a deploy command succeeds, waits for the GitHub Actions run it started, then curls the live URL with cache-busting and puts the verdict in the model's context. A deploy cannot be claimed without evidence. |
| receipt | The turn footer becomes a receipt: edits, runs and curls, with a warning when edits ran nothing. Destructive commands are never folded into a tool group, a claim of "fixed" with no run puts "unverified claim pending" in the spinner, and Tab suggests running the tests. |
| diff-review | A docked pane with each edited file's hunk and keep or revert buttons. Reverting runs git directly; no model turn. |
| merge-gate | Denies gh pr merge, a git merge on trunk, or a push to main unless the latest human message contains the word merge. Ship, push and deploy do not count. /merge-gate toggles it. |
| mini-offload | Rewrites heavy Bash commands (test suites, builds, Docker) to run on a second machine over ssh — syncing the commit there first, because the remote checkout is the real hazard. /mini sets always, ask, or off. |
| Mod | What it does |
|---|---|
| usage-band | The 5-hour and 7-day limit windows, this session's context fill and cost, above the prompt. Nudges you to /clear when the window gets expensive. |
| money-band | Liquid assets, CPF, debt and month-to-date spend, read from a pair of SQLite databases over ssh. Every figure is the database's own; nothing is estimated. |
| copy-band | Click-to-copy buttons above the prompt for every code block and quoted draft in the last answer, plus a durable stash of older ones. Copying runs pbcopy directly — no model turn. |
| done-blink | When a turn lands, the iTerm2 tab blinks orange every half second until you send the next prompt or three minutes pass, so a finished session is obvious from any other tab. Works inside tmux with no passthrough config: the escape goes to the tmux client's tty. /done-blink 60 sets the ceiling. |
| chrome-switch | Switches the Claude in Chrome extension between named browser profiles using select_browser, which needs no approval click. /chromep maps them. |
| Mod | What it does |
|---|---|
| wod-band | A pixel-art athlete above the prompt who does a rep every turn. The session is an AMRAP of thrusters, burpees and pull-ups. |
| wod-timer | 3, 2, 1, GO in the spinner when you submit, a running gym clock while Claude works, and a whiteboard split in the footer when the turn lands: turn, time, AMRAP total, PR. /wod-timer voice on reads long splits aloud. |
Every mod checks one environment variable before doing anything:
CLAUDE_MODS_DISABLE=all # every mod in this repo becomes a pass-through
CLAUDE_MODS_DISABLE=scope-guard # just that one
CLAUDE_MODS_DISABLE=wod-band,wod-timer
A disabled mod registers no command and every hook falls straight through to next(e).
Mods that touch your machine declare their settings in plugin.json userConfig, so they are editable through /config rather than by hand:
/scope <n> sets the file threshold. /scope judge on|off (default on) lets a one-shot Haiku call decide at the threshold whether the next edit is still inside the goal you stated first; a yes raises the ceiling by one for that turn, a no or a failed call falls back to asking. /scope off disables the guard./merge-gate on|off. "merge x3" or "merge after each" in your message grants that many merges.host (ssh alias, default mini), remotePath (the PATH export prefixed to every offloaded command). Per-repo overrides live at <repo>/.claude/mini-offload.json.host, networthDb, financeDb. Expects SQLite databases with accounts/balances and transactions tables. efAccount (default UOB One) and efTarget (default 30000) feed the EF 41% footer label.sgdRate (default 1.30) for the S$ footer label; /usage-band sgd off hides it./receipt on|off|status./done-blink on|off|status|<seconds> (default 180, max 900)./diff-review open|close|on|off.<repo>/.claude/deploy-verify.json: ``json { "url": "https://example.com", "matchFile": "VERSION" } ``~/.claude/chrome-browsers.json, mapping labels to deviceIds.scope-guard and deploy-verify also write a block into the model's own context (prompt.context), replacing their previous copy rather than accumulating:
# deployVerify
Last live deploy check, 2 minutes ago:
VERIFIED live: https://example.com served "v3.10.10"
This is the only evidence about the live site in this session. Do not describe the deploy as
verified unless a line above starts with VERIFIED, and do not re-state an older claim over it.
A band above the prompt is for you. A context block is for the model — and it cannot be talked around. Repeated advisories are hashed and suppressed for a cooldown so this costs context once, not once per tool call; verdicts themselves are never throttled, because a verdict is evidence.
npm install
npm test
npm test typechecks every mod, runs its suite under claude plugin test (the official kit, claude-code/testing, with a mocked clock, store and process table), and checks each mod's footprint: the hooks, $ calls and env reads that claude plugin validate reports, pinned in <mod>/FOOTPRINT. A mod that starts calling $.http.fetch fails the build instead of a README sentence going stale. scripts/footprint.sh --write re-pins after a deliberate change.
The interesting half of deploy-verify's suite is the clean baseline: commands that mention a deploy without being one — echo "git push", grep -r "wrangler deploy", git push --dry-run, a commit message quoting make deploy, a heredoc containing one. A false positive curls a live URL nothing was pushed to and then reports a verdict about it, which is worse than not checking at all.
The ones that survived contact with real sessions:
try/catch and falls back to what was there.next(e) and $ calls are free; $.clock.sleep is not. Past the budget, or on a throw, the engine skips the hook silently unless it declares .catch — so every guard here catches and denies, and slow work belongs on a timer.e.props.hasSurvey means the engine wants that slot; give it back.Claude Code 2.1.271+ with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1. macOS — copy-band shells out to pbcopy, and mini-offload/money-band assume ssh and a Homebrew path on the remote.
MIT
hooks/register.tsx 292 lines1/* @jsx h */
2import type { EngineInterface, Register } from 'claude-code'
3
4// After each Edit, Write, NotebookEdit or `sed -i` lands, the file's uncommitted hunk sits in a
5// docked pane with [keep] and [revert]: a review the model never sees and a revert that costs no
6// turn. The pane lists the files touched this turn, newest first, and empties at the next turn.
7//
8// A press is answered in `ui.press` by the Button's key, not its closure: a closure belongs to one
9// drawing and a press during a redraw is dropped, while the hook still sees the key.
10
11const MOD = 'diff-review'
12const PANE_ID = 'diff-review'
13const PANE_TITLE = 'diff'
14const ENABLED_KEY = 'diff-review:enabled'
15const AUTO_OPEN_MIN_COLUMNS = 144
16const CODE_MAX_CHARS = 10000
17const GIT_TIMEOUT_MS = 5000
18
19type Hunk = { id: number; path: string; rel: string; diff: string; kind: 'tracked' | 'untracked' | 'new' }
20
21let hunks: Hunk[] = []
22let nextId = 1
23let enabled = true
24let interactive = false
25let cwd = ''
26let columns: number | undefined
27let paneOpen = false
28let paneDrawn = false
29let closedByPerson = false
30let armed: { id: number; timer: { cancel: () => void } } | undefined
31const ARM_MS = 10000
32
33const TOKEN = /'[^']*'|"[^"]*"|\S+/g
34const SED_VALUED = ['-e', '-f', '--expression', '--file']
35
36// The files a `sed -i` writes: every segment of the command that starts with sed and carries -i,
37// its flags dropped, the script dropped unless -e or -f named it.
38export const sedTargets = (command: string): string[] => {
39 const out: string[] = []
40 for (const seg of command.split(/&&|\|\||;|\||\n/)) {
41 const tokens = (seg.match(TOKEN) ?? []).filter(t => t !== "''" && t !== '""')
42 const at = tokens.findIndex(t => t === 'sed')
43 if (at < 0 || !tokens.slice(at + 1).some(t => /^(-i|--in-place)/.test(t))) continue
44 const rest: string[] = []
45 let scripted = false
46 for (let i = at + 1; i < tokens.length; i++) {
47 const t = tokens[i] ?? ''
48 if (SED_VALUED.includes(t)) {
49 scripted = true
50 i++
51 } else if (t.startsWith('-')) continue
52 else rest.push(t.replace(/^(['"])(.*)\1$/, '$2'))
53 }
54 out.push(...(scripted ? rest : rest.slice(1)).filter(t => t !== '' && !/[*?$]/.test(t)))
55 }
56 return out
57}
58
59const absolute = (path: string) => (path.startsWith('/') ? path : `${cwd}/${path}`)
60const relative = (path: string) => (cwd && path.startsWith(`${cwd}/`) ? path.slice(cwd.length + 1) : path)
61
62const addedDiff = (rel: string, text: string) => {
63 const lines = text.replace(/\n$/, '').split('\n')
64 return [`--- /dev/null`, `+++ b/${rel}`, `@@ -0,0 +1,${lines.length} @@`, ...lines.map(l => `+${l}`)].join('\n')
65}
66
67// A Code source holds at most 10000 characters; a hunk past that, or past the rows the pane has,
68// keeps its head and says how much was cut.
69export const fit = (diff: string, maxLines: number, maxChars = CODE_MAX_CHARS): string => {
70 const lines = diff.split('\n')
71 const kept: string[] = []
72 let size = 0
73 for (const line of lines) {
74 const note = `… (${lines.length - kept.length - 1} more lines)`
75 if (kept.length >= maxLines || size + line.length + 1 + note.length + 1 > maxChars) break
76 kept.push(line)
77 size += line.length + 1
78 }
79 const dropped = lines.length - kept.length
80 return dropped > 0 ? [...kept, `… (${dropped} more lines)`].join('\n') : diff
81}
82
83const git = ($: EngineInterface, args: string[]) => $.process.run(['git', ...args], { timeoutMs: GIT_TIMEOUT_MS })
84
85// An untracked file has no index to diff against: one the tool created shows as added and may be
86// deleted; one that was already there (an .env, a gitignored file) shows as added with no revert.
87const hunkOf = async ($: EngineInterface, path: string, existed: boolean): Promise<Hunk | undefined> => {
88 const rel = relative(path)
89 const tracked = await git($, ['ls-files', '--error-unmatch', '--', path])
90 if (tracked.exitCode === 0) {
91 const run = await git($, ['diff', '--unified=3', '--', path])
92 if (run.exitCode !== 0 || !run.stdout.trim()) return undefined
93 return { id: nextId++, path, rel, diff: run.stdout.replace(/\n$/, ''), kind: 'tracked' }
94 }
95 if (tracked.exitCode !== 1) return undefined
96 const text = await $.fs.read(path).catch(() => undefined)
97 if (typeof text !== 'string') return undefined
98 return { id: nextId++, path, rel, diff: addedDiff(rel, text), kind: existed ? 'untracked' : 'new' }
99}
100
101const canOpen = () => enabled && interactive && !paneOpen && !closedByPerson && columns !== undefined && columns >= AUTO_OPEN_MIN_COLUMNS
102
103const open = async ($: EngineInterface) => {
104 paneOpen = true
105 paneDrawn = false
106 await $.ui.open({ id: PANE_ID, title: PANE_TITLE }).catch(err => {
107 paneOpen = false
108 $.ui.log(`diff-review: pane did not open: ${err}`)
109 })
110}
111
112const track = async ($: EngineInterface, paths: string[], existed: boolean[]) => {
113 for (const [i, path] of paths.entries()) {
114 const hunk = await hunkOf($, path, existed[i] === true)
115 hunks = hunks.filter(h => h.path !== path)
116 if (hunk) hunks.unshift(hunk)
117 }
118 if (canOpen()) await open($)
119 $.ui.invalidate('ui.render')
120}
121
122const drop = (id: number) => {
123 hunks = hunks.filter(h => h.id !== id)
124 if (armed?.id === id) disarm()
125}
126
127const disarm = () => {
128 armed?.timer.cancel()
129 armed = undefined
130}
131
132// Only the captured hunk is undone, never the file's other uncommitted changes. A file the tool
133// created is deleted on a second press within ten seconds: no dialog waits inside the hook, so a
134// dropped hook can never leave an rm behind.
135const revert = async ($: EngineInterface, hunk: Hunk) => {
136 if (hunk.kind === 'new') {
137 if (armed?.id !== hunk.id) {
138 disarm()
139 armed = { id: hunk.id, timer: $.clock.after(ARM_MS, () => { armed = undefined; $.ui.invalidate('ui.render') }) }
140 return $.ui.toast(`${hunk.rel} did not exist before this turn: press revert again within 10s to delete it`, { timeoutMs: ARM_MS })
141 }
142 disarm()
143 const rm = await $.process.run(['rm', '--', hunk.path], { timeoutMs: GIT_TIMEOUT_MS })
144 if (rm.exitCode !== 0) return $.ui.toast(`diff-review: delete failed: ${rm.stderr.trim()}`, { timeoutMs: 6000 })
145 drop(hunk.id)
146 return $.ui.toast(`deleted ${hunk.rel}`)
147 }
148 const run = await $.process.run(['git', 'apply', '-R', '--unidiff-zero', '--'], { stdin: `${hunk.diff}\n`, timeoutMs: GIT_TIMEOUT_MS })
149 if (run.exitCode !== 0) return $.ui.toast('hunk no longer applies; nothing changed', { timeoutMs: 6000 })
150 drop(hunk.id)
151 $.ui.toast(`reverted ${hunk.rel}`)
152}
153
154// The first thing anyone does when a mod misbehaves is try to turn it off. `CLAUDE_MODS_DISABLE=all`,
155// or a comma list naming this mod, makes every hook here a pass-through and registers no command.
156let disabled = false
157const readDisabled = async ($: EngineInterface): Promise<boolean> => {
158 const raw = (await $.env.get('CLAUDE_MODS_DISABLE').catch(() => undefined)) ?? ''
159 disabled = raw
160 .split(',')
161 .map(v => v.trim())
162 .some(v => v === 'all' || v === MOD)
163 return disabled
164}
165
166export const register: Register = on => {
167 on('session.start', async ($, e, next) => {
168 const r = await next(e)
169 if (await readDisabled($)) return r
170 interactive = e.isInteractive
171 cwd = e.cwd
172 enabled = (await $.store.get(ENABLED_KEY).catch(() => undefined)) !== false
173 await $.command
174 .register({
175 name: 'diff-review',
176 description: 'The diff pane: each edited file\'s hunk with [keep] and [revert] (diff-review)',
177 argumentHint: '[open | close | on | off | status]',
178 immediate: true,
179 })
180 .catch(err => $.ui.log(`diff-review: /diff-review not registered: ${err}`))
181 return r
182 })
183
184 on('command.run', { command: 'diff-review' }, async ($, e) => {
185 const arg = e.args.trim().toLowerCase()
186 if (arg === 'open') {
187 closedByPerson = false
188 await open($)
189 return { text: paneOpen ? `diff-review pane open, ${hunks.length} file(s) this turn` : 'diff-review: pane did not open' }
190 }
191 if (arg === 'close') {
192 await $.ui.close({ id: PANE_ID }).catch(() => undefined)
193 paneOpen = false
194 return { text: 'diff-review pane closed' }
195 }
196 if (arg === 'on' || arg === 'off') {
197 enabled = arg === 'on'
198 await $.store.set(ENABLED_KEY, enabled).catch(err => $.ui.log(`diff-review: store write failed: ${err}`))
199 if (!enabled && paneOpen) {
200 await $.ui.close({ id: PANE_ID }).catch(() => undefined)
201 paneOpen = false
202 }
203 return { text: `diff-review ${arg}` }
204 }
205 if (arg === '' || arg === 'status') {
206 return { text: `diff-review is ${enabled ? 'on' : 'off'}; pane ${paneOpen ? 'open' : 'closed'}; ${hunks.length} file(s) this turn` }
207 }
208 return { text: `diff-review: "${arg}" is not open, close, on, off, or status` }
209 })
210
211 on('ui.render', { component: 'PromptHint' }, ($, e, next) => {
212 if (e.surface === 'terminal') columns = e.viewport?.columns ?? columns
213 return next(e)
214 })
215
216 // An open the plugin made on its own waits undrawn on a narrow terminal; a prompt is the person's
217 // input, so an open answering it is placed. Not awaited: a refused open cannot delay the turn.
218 on('prompt.submit', ($, e, next) => {
219 if (!disabled && paneOpen && !paneDrawn) void $.ui.open({ id: PANE_ID, title: PANE_TITLE }).catch(() => undefined)
220 return next(e)
221 })
222
223 on('turn.start', ($, e, next) => {
224 if (disabled || !hunks.length) return next(e)
225 hunks = []
226 $.ui.invalidate('ui.render')
227 return next(e)
228 })
229
230 on('ui.close', { id: PANE_ID }, ($, e, next) => {
231 paneOpen = false
232 if (e.origin.kind === 'person') closedByPerson = true
233 return next(e)
234 })
235
236 on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit', 'Bash'] }, async ($, e, next) => {
237 const off = disabled || !enabled || e.agentId !== undefined
238 const paths = off
239 ? []
240 : e.tool === 'Bash' ? sedTargets(e.command).map(absolute) : e.tool === 'NotebookEdit' ? [e.notebook_path] : [e.file_path]
241 const existed = await Promise.all(paths.map(p => $.fs.exists(p).catch(() => true)))
242 const r = await next(e)
243 if (!paths.length || r.deny !== undefined || r.isError === true) return r
244 try {
245 await track($, paths, existed)
246 } catch (err) {
247 $.ui.log(`diff-review: diff not read: ${err}`)
248 }
249 return r
250 })
251
252 on('ui.press', { plugin: MOD }, async ($, e, next) => {
253 if (disabled) return next(e)
254 const m = /^(keep|revert):(\d+)$/.exec(e.element)
255 const hunk = m ? hunks.find(h => h.id === Number(m[2])) : undefined
256 if (!m || !hunk) return next(e)
257 if (m[1] === 'keep') drop(hunk.id)
258 else await revert($, hunk)
259 $.ui.invalidate('ui.render')
260 return { element: e.element }
261 })
262
263 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
264 if (disabled || e.requestId !== PANE_ID) return next(e)
265 paneDrawn = true
266 const { Box, Text, Button, Code } = await $.ui.resolve(e)
267 const width = Math.max(1, e.props.bodyColumns)
268 const rows = Math.max(3, e.props.scroll.bodyRows - 2)
269 if (!hunks.length) return <Text dimColor>no edits this turn</Text>
270 return (
271 <Box flexDirection="column">
272 {hunks.map(hunk => (
273 <Box key={`hunk:${hunk.id}`} flexDirection="column" marginTop={1}>
274 <Box flexDirection="row" columnGap={1}>
275 <Text bold wrap="truncate-end">
276 {hunk.rel.length > width ? hunk.rel.slice(0, width) : hunk.rel}
277 </Text>
278 <Button key={`keep:${hunk.id}`} label="keep" onPress={() => undefined} />
279 {hunk.kind === 'untracked' ? (
280 <Text dimColor>untracked, existed before: no revert</Text>
281 ) : (
282 <Button key={`revert:${hunk.id}`} label={armed?.id === hunk.id ? 'revert again to delete' : hunk.kind === 'new' ? 'delete' : 'revert'} onPress={() => undefined} />
283 )}
284 </Box>
285 <Code source={fit(hunk.diff, rows)} format="diff" path={hunk.path} />
286 </Box>
287 ))}
288 </Box>
289 )
290 })
291}
292