Click-to-copy buttons above the prompt for every code block and quoted draft in the last answer, a durable stash of older ones, and a WhatsApp/Telegram wrap.

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 337 lines1/* @jsx h */
2import type { EngineInterface, Register } from 'claude-code'
3
4// Every code block and quoted draft in the last answer, as a pressable button above the prompt.
5//
6// A press runs pbcopy in the plugin's own environment: no model turn, no tokens, no waiting for a
7// reply that says "copied". Digits press from an empty composer, the mouse presses anywhere.
8//
9// The band only ever shows the last answer, so each block is also appended to a stash file that
10// outlives the session; /stash lists it and /stash 7 puts an old one back on the clipboard, which is
11// the part a clipboard manager cannot do (it holds what was copied, not what was offered).
12//
13// 0 toggles WhatsApp mode: the copy is wrapped in a triple-backtick fence, which Telegram renders
14// as a native click-to-copy block and WhatsApp as monospace.
15
16const MAX_BLOCKS = 6
17const STASH_KEEP = 60
18const LABEL_WIDTH = 20
19const WA_KEY = 'copy-band:wa'
20
21type Block = { label: string; text: string }
22
23let blocks: Block[] = []
24let wa = false
25let stashPath: string | undefined
26let stashed = 0
27
28const clean = (text: string) => text.replace(/\s+$/, '').replace(/^\n+/, '')
29
30// The first line says more than the language does, so the language is dropped unless the block had
31// no fence to announce it (a quoted draft) or no first line worth reading.
32const label = (text: string, lang: string) => {
33 const first = (text.split('\n').find((l) => l.trim()) ?? '').trim()
34 const head = lang === 'draft' || !first ? `${lang} ${first}`.trim() : first
35 return head.length > LABEL_WIDTH ? `${head.slice(0, LABEL_WIDTH - 1)}…` : head
36}
37
38// Fenced blocks of any language, plus runs of quoted lines, which is how a draft message arrives.
39export const parse = (answer: string): Block[] => {
40 const found: Block[] = []
41 const add = (raw: string, lang: string) => {
42 const text = clean(raw)
43 if (text.length < 3) return
44 if (found.some((b) => b.text === text)) return
45 found.push({ label: label(text, lang), text })
46 }
47
48 let fence: string | undefined
49 let lang = ''
50 let body: string[] = []
51 let quote: string[] = []
52 const flushQuote = () => {
53 if (quote.length) add(quote.join('\n'), 'draft')
54 quote = []
55 }
56
57 for (const line of answer.split('\n')) {
58 const open = /^\s*(`{3,}|~{3,})(.*)$/.exec(line)
59 if (fence !== undefined) {
60 const mark = open?.[1]
61 if (mark && mark[0] === fence[0] && mark.length >= fence.length && !(open?.[2] ?? '').trim()) {
62 add(body.join('\n'), lang)
63 fence = undefined
64 body = []
65 continue
66 }
67 body.push(line)
68 continue
69 }
70 if (open?.[1]) {
71 flushQuote()
72 fence = open[1]
73 lang = (open[2] ?? '').trim()
74 body = []
75 continue
76 }
77 if (/^\s*>\s?/.test(line)) {
78 quote.push(line.replace(/^\s*>\s?/, ''))
79 continue
80 }
81 flushQuote()
82 }
83 if (fence !== undefined) add(body.join('\n'), lang)
84 flushQuote()
85
86 return found.slice(0, MAX_BLOCKS)
87}
88
89const wrap = (text: string) => (wa ? `\`\`\`\n${text}\n\`\`\`` : text)
90
91const copy = async ($: EngineInterface, block: Block): Promise<void> => {
92 const payload = wrap(block.text)
93 const r = await $.process
94 .run(['pbcopy'], { stdin: payload, timeoutMs: 5000 })
95 .catch((err) => {
96 $.ui.toast(`copy failed: ${err}`, { timeoutMs: 6000 })
97 return undefined
98 })
99 if (!r || r.exitCode !== 0) return
100 $.ui.toast(`copied ${payload.length} chars${wa ? ' (whatsapp)' : ''} · ${block.label}`, { timeoutMs: 3000 })
101}
102
103// tee appends without a shell, so nothing here is quoted into one. One JSON object per line.
104const stash = async ($: EngineInterface, entries: Block[]): Promise<void> => {
105 if (!stashPath || !entries.length) return
106 const at = await $.clock.now().catch(() => Date.now())
107 const lines = entries.map((b) => JSON.stringify({ at, label: b.label, text: b.text })).join('\n')
108 await $.process.run(['tee', '-a', stashPath], { stdin: `${lines}\n`, timeoutMs: 5000 }).catch((err) =>
109 $.ui.log(`copy-band: stash write failed: ${err}`),
110 )
111}
112
113const readStash = async ($: EngineInterface): Promise<Block[]> => {
114 if (!stashPath) return []
115 const r = await $.process.run(['tail', '-n', String(STASH_KEEP), stashPath], { timeoutMs: 5000 }).catch(() => undefined)
116 if (!r || r.exitCode !== 0) return []
117 const out: Block[] = []
118 for (const line of r.stdout.split('\n')) {
119 if (!line.trim()) continue
120 try {
121 const entry = JSON.parse(line) as { label?: unknown; text?: unknown }
122 if (typeof entry.text === 'string' && typeof entry.label === 'string') out.push({ label: entry.label, text: entry.text })
123 } catch {
124 // A truncated line from a killed write is skipped rather than costing the whole stash.
125 }
126 }
127 return out.reverse()
128}
129
130// The blocks of one transcript message, memoised: a render hook runs on every frame the message is
131// drawn in, and parsing the same markdown each time would cost the scrollback its speed.
132const inlineCache = new Map<string, Block[]>()
133const inlineById = new Map<string, Block[]>()
134const blocksFor = (text: string): Block[] => {
135 const hit = inlineCache.get(text)
136 if (hit) return hit
137 const found = parse(text)
138 if (inlineCache.size > 200) {
139 inlineCache.clear()
140 inlineById.clear()
141 }
142 inlineCache.set(text, found)
143 inlineById.set(digest(text), found)
144 return found
145}
146
147// A stable address for a Button drawn in the transcript, where many messages draw a row each and a
148// repeated key would collide between them.
149const digest = (text: string) => {
150 let h = 0
151 for (let i = 0; i < text.length; i++) h = (Math.imul(31, h) + text.charCodeAt(i)) | 0
152 return (h >>> 0).toString(36)
153}
154
155// The Button a press names, by its key: the band's `copy:N`, the transcript's `inline:<digest>:N`.
156const blockAt = (element: string): Block | undefined => {
157 const band = /^copy:(\d+)$/.exec(element)
158 if (band) return blocks[Number(band[1])]
159 const inline = /^inline:([^:]+):(\d+)$/.exec(element)
160 if (inline) return inlineById.get(inline[1] ?? '')?.[Number(inline[2])]
161 return undefined
162}
163
164// The status line under the prompt outlives a collapsed band, so the count of what is on offer
165// stays on screen when the buttons do not.
166const status = ($: EngineInterface): void => {
167 if (!blocks.length) return $.ui.status(undefined)
168 $.ui.status(`copy · ${blocks.length} in the band (1-${blocks.length}) · ${Math.min(stashed, STASH_KEEP)} stashed (/stash)`)
169}
170
171// The first thing anyone does when a mod misbehaves is try to turn it off. `CLAUDE_MODS_DISABLE=all`,
172// or a comma list naming this mod, makes every hook here a pass-through and registers no command.
173const MOD = 'copy-band'
174let disabled = false
175const readDisabled = async ($: EngineInterface): Promise<boolean> => {
176 const raw = (await $.env.get('CLAUDE_MODS_DISABLE').catch(() => undefined)) ?? ''
177 disabled = raw
178 .split(',')
179 .map(v => v.trim())
180 .some(v => v === 'all' || v === MOD)
181 return disabled
182}
183
184export const register: Register = (on) => {
185 on('session.start', async ($, e, next) => {
186 const r = await next(e)
187 if (await readDisabled($)) return r
188 if ((await $.store.get(WA_KEY).catch(() => undefined)) === true) wa = true
189 const home = await $.env.get('HOME').catch(() => undefined)
190 if (home) {
191 const dir = `${home}/.claude/copy-stash`
192 const made = await $.process.run(['mkdir', '-p', dir], { timeoutMs: 5000 }).catch(() => undefined)
193 if (made?.exitCode === 0) {
194 stashPath = `${dir}/stash.jsonl`
195 stashed = (await readStash($)).length
196 }
197 }
198 await $.command
199 .register({
200 name: 'stash',
201 description: 'Copy a block from the stash: /stash lists, /stash 7 copies, /stash wa toggles the WhatsApp wrap (copy-band)',
202 argumentHint: '[n | wa [n]]',
203 immediate: true,
204 })
205 .catch((err) => $.ui.log(`copy-band: /stash not registered: ${err}`))
206 return r
207 })
208
209 on('command.run', { command: 'stash' }, async ($, e) => {
210 const args = e.args.trim().toLowerCase().split(/\s+/).filter(Boolean)
211 const toggle = args[0] === 'wa'
212 const rest = toggle ? args.slice(1) : args
213
214 if (toggle && !rest.length) {
215 wa = !wa
216 await $.store.set(WA_KEY, wa).catch(() => undefined)
217 $.ui.invalidate('ui.render')
218 return { text: `whatsapp wrap ${wa ? 'on' : 'off'} — copies are fenced with \`\`\`` }
219 }
220
221 const entries = await readStash($)
222 if (!entries.length) return { text: 'copy stash is empty — it fills as answers come in' }
223
224 const pick = rest[0]
225 if (pick === undefined) {
226 const list = entries
227 .slice(0, 20)
228 .map((b, i) => `${String(i + 1).padStart(2)}. ${b.label}${b.text.includes('\n') ? ` (${b.text.split('\n').length} lines)` : ''}`)
229 .join('\n')
230 return { text: `copy stash, newest first — /stash <n>${wa ? '' : ', /stash wa <n> to fence it'}\n${list}` }
231 }
232
233 const n = Number(pick)
234 const block = Number.isInteger(n) ? entries[n - 1] : undefined
235 if (!block) return { text: `copy-band: no stash entry ${pick} — /stash lists what there is` }
236
237 const was = wa
238 if (toggle) wa = true
239 await copy($, block)
240 wa = was
241 return { text: `copied: ${block.label}` }
242 })
243
244 on('turn.complete', async ($, e, next) => {
245 if (disabled) return next(e)
246 const r = await next(e)
247 // A subagent's turn carries an agentId and never reaches the person's screen.
248 if (e.agentId) return r
249 const found = parse(e.answer)
250 if (!found.length) {
251 if (blocks.length) {
252 blocks = []
253 status($)
254 $.ui.invalidate('ui.render')
255 }
256 return r
257 }
258 blocks = found
259 await stash($, found)
260 stashed += found.length
261 status($)
262 $.ui.invalidate('ui.render')
263 return r
264 })
265
266 // A press is answered here rather than in the Button closures: a closure handle belongs to one
267 // drawing, and a press that lands during a redraw is dropped by core, while the hook still sees
268 // the event and its key.
269 on('ui.press', { plugin: MOD }, async ($, e, next) => {
270 if (disabled) return next(e)
271 if (e.element === 'copy:wa') {
272 wa = !wa
273 await $.store.set(WA_KEY, wa).catch(() => undefined)
274 $.ui.invalidate('ui.render')
275 return { element: e.element }
276 }
277 const block = blockAt(e.element)
278 if (!block) return next(e)
279 await copy($, block)
280 return { element: e.element }
281 })
282
283 // A render hook that throws unmounts the module, so a bad frame falls back to the band as it was.
284 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
285 if (disabled) return next(e)
286 if (!blocks.length || e.props.hasSurvey || e.surface !== 'terminal') return next(e)
287 try {
288 const { Box, Text, Button } = await $.ui.resolve(e)
289 const rest = await next(e)
290 return (
291 <Box flexDirection="column">
292 <Box flexDirection="row" columnGap={2}>
293 <Text dimColor>copy</Text>
294 {blocks.map((block, i) => (
295 <Button key={`copy:${i}`} hotkey={String(i + 1)} label={block.label} onPress={() => undefined} />
296 ))}
297 <Button key="copy:wa" hotkey="0" dimColor={!wa} label={wa ? 'wa on' : 'wa'} onPress={() => undefined} />
298 </Box>
299 {rest}
300 </Box>
301 )
302 } catch (err) {
303 $.ui.log(`copy-band: render failed: ${err}`)
304 return next(e)
305 }
306 })
307
308 // The same buttons, drawn under the message that holds the code rather than above the prompt. The
309 // band only ever shows the last answer; this row stays where it was written, which is where a
310 // mouse goes looking for it. A hotkey is refused outside the band, so this row is mouse-only.
311 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
312 if (disabled) return next(e)
313 if (e.surface !== 'terminal') return next(e)
314 const found = blocksFor(e.props.text)
315 if (!found.length) return next(e)
316 try {
317 const { Box, Text, Button } = await $.ui.resolve(e)
318 const rest = await next(e)
319 const id = digest(e.props.text)
320 return (
321 <Box flexDirection="column">
322 {rest}
323 <Box flexDirection="row" columnGap={2}>
324 <Text dimColor>copy</Text>
325 {found.map((block, i) => (
326 <Button key={`inline:${id}:${i}`} dimColor label={block.label} onPress={() => undefined} />
327 ))}
328 </Box>
329 </Box>
330 )
331 } catch (err) {
332 $.ui.log(`copy-band: inline render failed: ${err}`)
333 return next(e)
334 }
335 })
336}
337