SLOPSHOPPER

copy-band

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.

newbandrowscommandtoaststatus
★ 1v0.3.0MITupdated 2026-09-21yash-gadodia/claude-mods/copy-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · copy-band
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ 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 › /stash ⎿ copy-band: copy stash is empty — it fills as answers come in ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

Mods that keep an agent honest — plus a few that make the terminal fun.

claude-code · mod · function-hooks · typescript · macos

License: MIT Claude Code — mod test

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>

usage-band, wod-band and wod-timer above the prompt <sub>usage-band, wod-band and wod-timer in a live session.</sub>

Why

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.

What a mod is

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.

Install

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.

The mods

Discipline

ModWhat it does
scope-guardCounts 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-verifyAfter 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.
receiptThe 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-reviewA docked pane with each edited file's hunk and keep or revert buttons. Reverting runs git directly; no model turn.
merge-gateDenies 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-offloadRewrites 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.

Instruments

ModWhat it does
usage-bandThe 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-bandLiquid 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-bandClick-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-blinkWhen 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-switchSwitches the Claude in Chrome extension between named browser profiles using select_browser, which needs no approval click. /chromep maps them.

Fun

ModWhat it does
wod-bandA pixel-art athlete above the prompt who does a rep every turn. The session is an AMRAP of thrusters, burpees and pull-ups.
wod-timer3, 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.

Turning them off

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).

Configuration

Mods that touch your machine declare their settings in plugin.json userConfig, so they are editable through /config rather than by hand:

  • scope-guard — /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 — /merge-gate on|off. "merge x3" or "merge after each" in your message grants that many merges.
  • mini-offload — host (ssh alias, default mini), remotePath (the PATH export prefixed to every offloaded command). Per-repo overrides live at <repo>/.claude/mini-offload.json.
  • money-band — 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.
  • usage-band — sgdRate (default 1.30) for the S$ footer label; /usage-band sgd off hides it.
  • receipt — /receipt on|off|status.
  • done-blink — /done-blink on|off|status|<seconds> (default 180, max 900).
  • diff-review — /diff-review open|close|on|off.
  • deploy-verify — per-repo, at <repo>/.claude/deploy-verify.json: ``json { "url": "https://example.com", "matchFile": "VERSION" } ``
  • chrome-switch — ~/.claude/chrome-browsers.json, mapping labels to deviceIds.

Evidence, not decoration

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.

Tests

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.

Design rules

The ones that survived contact with real sessions:

  1. A deny always carries the fix. Blocking without saying what to do instead strands the model in a retry loop. Every refusal here names the next action.
  2. Never block when there is no way through. If the only outcomes are "denied" and "denied again", let it run and say something instead.
  3. A render hook that throws takes the whole mod down with it. Every band wraps its frame in try/catch and falls back to what was there.
  4. Hooks have ten seconds of their own time. 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.
  5. Bands yield. e.props.hasSurvey means the engine wants that slot; give it back.

Requirements

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.

License

MIT

Source 1 files
hooks/register.tsx 337 lines
1/* @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