SLOPSHOPPER

money-band

A line above the prompt with liquid assets, CPF, debt and month-to-date spend, read from a pair of SQLite databases over ssh, and the emergency fund's fill in…

newbandspinnercommandstatusprocess
★ 1v0.4.1MITupdated 2026-09-21yash-gadodia/claude-mods/money-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · money-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 › /money ⎿ money-band: money-band could not read the mini: the databases answered nothing money · mini unreachable: the databases answered nothing ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
money · mini unreachable: the databases answered nothing ⟨Claude Code's own drawing⟩
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 304 lines
1/* @jsx h */
2import type { Register, EngineInterface } from 'claude-code'
3
4// The numbers Happy already tracks on the mini, on one line above the prompt, costing no tokens.
5// Every figure is the database's own: latest balance per active account, and this month's
6// transactions. Nothing is estimated here — a number that looks wrong is wrong in the source.
7//
8// ssh is slow, so the fetch never happens inside the render hook: session.start primes it and a
9// one-shot timer re-arms itself after each fetch settles, so two fetches never overlap and nothing
10// runs while the band is off. /money on, off, or refresh.
11
12// Host and database paths come from userConfig: this mod reads a Happy-shaped SQLite pair
13// (accounts/balances, transactions) over ssh, and nothing about that is specific to one machine.
14let HOST = 'mini'
15let NETWORTH_DB = '$HOME/.openclaw/data/networth.db'
16let FINANCE_DB = '$HOME/.openclaw/data/finance.db'
17let EF_ACCOUNT = 'UOB One'
18let EF_TARGET = 30000
19const REFRESH_MS = 15 * 60 * 1000
20const SHOWN_KEY = 'money-band:shown'
21
22// Markers fence each result, because the mini's shell profile wraps the output in terminal escapes
23// and a naive strip of those once ate a whole account.
24const script = () => `export PATH=/opt/homebrew/bin:$PATH
25N="${NETWORTH_DB}"
26F="${FINANCE_DB}"
27printf 'NW<'
28sqlite3 -separator '|' "$N" "WITH latest AS (SELECT account_id, MAX(snapshot_date) d FROM balances GROUP BY account_id), cur AS (SELECT a.type, a.is_liability, b.balance_sgd, b.snapshot_date FROM accounts a JOIN latest l ON l.account_id = a.id JOIN balances b ON b.account_id = a.id AND b.snapshot_date = l.d WHERE a.active = 1) SELECT IFNULL(SUM(CASE WHEN is_liability = 0 AND type IN ('bank','crypto','investment') THEN balance_sgd END), 0) || '|' || IFNULL(SUM(CASE WHEN is_liability = 0 AND type = 'property' THEN balance_sgd END), 0) || '|' || IFNULL(SUM(CASE WHEN is_liability = 0 AND type = 'cpf' THEN balance_sgd END), 0) || '|' || IFNULL(SUM(CASE WHEN is_liability = 1 THEN balance_sgd END), 0) || '|' || IFNULL(MAX(snapshot_date), '') FROM cur;" | tr -d '\\n'
29printf '>NW\\n'
30printf 'FIN<'
31sqlite3 -separator '|' "$F" "SELECT IFNULL(SUM(CASE WHEN flow = 'spend' THEN amount END), 0) || '|' || COUNT(*) || '|' || IFNULL(MAX(txn_date), '') || '|' || IFNULL((SELECT ROUND(amount) || ' ' || IFNULL(merchant, '?') FROM transactions WHERE txn_date >= date('now', 'start of month') ORDER BY amount DESC LIMIT 1), '') FROM transactions WHERE txn_date >= date('now', 'start of month');" | tr -d '\\n'
32printf '>FIN\\n'
33printf 'EF<'
34sqlite3 "$N" "SELECT IFNULL((SELECT b.balance_sgd FROM accounts a JOIN balances b ON b.account_id = a.id WHERE a.active = 1 AND instr(lower(a.name), lower('${EF_ACCOUNT.replace(/'/g, "''")}')) > 0 AND b.snapshot_date = (SELECT MAX(snapshot_date) FROM balances WHERE account_id = a.id) ORDER BY b.balance_sgd DESC LIMIT 1), '');" | tr -d '\\n'
35printf '>EF\\n'`
36
37type Money = {
38  liquid: number
39  property: number
40  cpf: number
41  debt: number
42  asOf: string
43  spend: number
44  txns: number
45  lastTxn: string
46  biggest: string
47  ef?: number
48  at: number
49}
50
51// The name is spliced into a shell string that ssh runs on the host, so only characters that cannot
52// open a subshell or close the quoting are allowed; anything else keeps the default and is logged.
53export const efName = (value: unknown): string | undefined => {
54  if (typeof value !== 'string') return undefined
55  const name = value.trim()
56  return /^[\w .&'-]+$/.test(name) ? name : undefined
57}
58
59const net = (m: Money) => m.liquid + m.property + m.cpf - m.debt
60
61let money: Money | undefined
62let error: string | undefined
63let shown = true
64let timer: { cancel: () => void } | undefined
65let epoch = 0
66let refreshFailed = false
67let efRejected: string | undefined
68
69const num = (value: string | undefined) => {
70  const n = Number(value)
71  return Number.isFinite(n) ? n : 0
72}
73
74const fetchMoney = async ($: EngineInterface): Promise<void> => {
75  const r = await $.process.run(['ssh', '-o', 'ConnectTimeout=8', '-o', 'BatchMode=yes', HOST, script()], { timeoutMs: 20000 }).catch(err => {
76    error = `${err}`
77    return undefined
78  })
79  if (!r) return
80  if (r.exitCode !== 0) {
81    error = (r.stderr.trim().split('\n')[0] ?? `ssh ${HOST} exited ${r.exitCode}`).slice(0, 80)
82    return
83  }
84  const between = (marker: string) => {
85    const open = r.stdout.indexOf(`${marker}<`)
86    const close = r.stdout.indexOf(`>${marker}`, open)
87    return open === -1 || close === -1 ? undefined : r.stdout.slice(open + marker.length + 1, close).split('|')
88  }
89  const networth = between('NW')
90  const finance = between('FIN')
91  const efRaw = between('EF')?.[0] ?? ''
92  if (!networth || networth.length < 5 || !finance || finance.length < 4) {
93    error = 'the databases answered nothing'
94    return
95  }
96  error = undefined
97  money = {
98    liquid: num(networth[0]),
99    property: num(networth[1]),
100    cpf: num(networth[2]),
101    debt: num(networth[3]),
102    asOf: networth[4] ?? '',
103    spend: num(finance[0]),
104    txns: Math.round(num(finance[1])),
105    lastTxn: finance[2] ?? '',
106    biggest: finance[3] ?? '',
107    ef: efRaw === '' ? undefined : num(efRaw),
108    at: await $.clock.now(),
109  }
110}
111
112// Each arm takes a token; a callback whose token has moved on is stale (the band was turned off, or
113// re-armed by /money, or the module was reloaded) and does nothing, so timers never fork.
114const arm = ($: EngineInterface): void => {
115  timer?.cancel()
116  const token = ++epoch
117  timer = $.clock.after(REFRESH_MS, () => {
118    if (token !== epoch || !shown) return
119    void refresh($, token)
120  })
121}
122
123// One failed refresh is logged once and the chain goes on; a fetch that rejected is not a reason to
124// stop reading for the rest of the session.
125const refresh = async ($: EngineInterface, token: number): Promise<void> => {
126  try {
127    await fetchMoney($)
128    await $.ui.invalidate('ui.render')
129    refreshFailed = false
130  } catch (err) {
131    if (!refreshFailed) $.ui.log(`money-band: refresh failed, trying again in ${REFRESH_MS / 60000}m: ${err}`)
132    refreshFailed = true
133  }
134  if (token === epoch && shown) arm($)
135}
136
137const disarm = (): void => {
138  epoch++
139  timer?.cancel()
140  timer = undefined
141}
142
143const sgd = (amount: number) => {
144  const abs = Math.abs(amount)
145  if (abs >= 1000000) return `${amount < 0 ? '-' : ''}${(abs / 1000000).toFixed(2)}m`
146  if (abs >= 1000) return `${amount < 0 ? '-' : ''}${(abs / 1000).toFixed(abs >= 100000 ? 0 : 1)}k`
147  return `${amount < 0 ? '-' : ''}${Math.round(abs)}`
148}
149
150const daysSince = (date: string, now: number) => {
151  const then = Date.parse(`${date}T00:00:00Z`)
152  return Number.isFinite(then) ? Math.floor((now - then) / 86400000) : undefined
153}
154
155// The first thing anyone does when a mod misbehaves is try to turn it off. `CLAUDE_MODS_DISABLE=all`,
156// or a comma list naming this mod, makes every hook here a pass-through and registers no command.
157const MOD = 'money-band'
158let disabled = false
159const readDisabled = async ($: EngineInterface): Promise<boolean> => {
160  const raw = (await $.env.get('CLAUDE_MODS_DISABLE').catch(() => undefined)) ?? ''
161  disabled = raw
162    .split(',')
163    .map(v => v.trim())
164    .some(v => v === 'all' || v === MOD)
165  return disabled
166}
167
168export const register: Register = (on, options) => {
169  if (typeof options.host === 'string' && options.host.trim()) HOST = options.host.trim()
170  if (typeof options.networthDb === 'string' && options.networthDb.trim()) NETWORTH_DB = options.networthDb.trim()
171  if (typeof options.financeDb === 'string' && options.financeDb.trim()) FINANCE_DB = options.financeDb.trim()
172  const account = efName(options.efAccount)
173  if (account) EF_ACCOUNT = account
174  else if (typeof options.efAccount === 'string' && options.efAccount.trim()) efRejected = options.efAccount
175  const target = Number(options.efTarget)
176  if (Number.isFinite(target) && target > 0) EF_TARGET = target
177
178  on('session.start', async ($, e, next) => {
179    const r = await next(e)
180    if (await readDisabled($)) return r
181    if ((await $.store.get(SHOWN_KEY).catch(() => undefined)) === false) shown = false
182    await $.command
183      .register({
184        name: 'money',
185        description: 'The money line above the prompt: liquid, CPF, debt, spend this month (money-band)',
186        argumentHint: '[on | off | refresh]',
187        immediate: true,
188      })
189      .catch(err => $.ui.log(`money-band: /money not registered: ${err}`))
190    if (efRejected) $.ui.log(`money-band: efAccount "${efRejected}" has characters a shell reads; using "${EF_ACCOUNT}" (letters, digits, space . & ' - only)`)
191    if (!shown) return r
192    // primed without blocking the session, and re-armed from each fetch from then on
193    void refresh($, ++epoch)
194    return r
195  })
196
197  on('command.run', { command: 'money' }, async ($, e) => {
198    const arg = e.args.trim().toLowerCase()
199    if (arg === 'off') {
200      shown = false
201      disarm()
202      await $.store.set(SHOWN_KEY, false).catch(() => undefined)
203      $.ui.invalidate('ui.render')
204      return { text: 'money band off' }
205    }
206    if (arg === 'on' || arg === 'refresh' || arg === '') {
207      shown = true
208      await $.store.set(SHOWN_KEY, true).catch(() => undefined)
209      $.ui.status('reading the mini…')
210      await fetchMoney($)
211      $.ui.status(undefined)
212      arm($)
213      $.ui.invalidate('ui.render')
214      if (error) return { text: `money-band could not read the mini: ${error}` }
215      if (!money) return { text: 'money-band got no figures back' }
216      const stale = daysSince(money.asOf, money.at)
217      return {
218        text: [
219          `net worth         S$${Math.round(net(money)).toLocaleString('en-SG')}`,
220          '',
221          `liquid            S$${Math.round(money.liquid).toLocaleString('en-SG')}   bank, crypto, IBKR`,
222          `property          S$${Math.round(money.property).toLocaleString('en-SG')}`,
223          `CPF               S$${Math.round(money.cpf).toLocaleString('en-SG')}`,
224          `debt             -S$${Math.round(money.debt).toLocaleString('en-SG')}`,
225          '',
226          `spend this month  S$${Math.round(money.spend).toLocaleString('en-SG')} over ${money.txns} transactions`,
227          money.biggest ? `largest           S$${money.biggest}` : '',
228          '',
229          `balances as of ${money.asOf}${stale !== undefined && stale > 30 ? ` — ${stale} days old` : ''}, last transaction ${money.lastTxn}`,
230          'Read straight from networth.db and finance.db on the mini; nothing here is estimated, so a figure that looks wrong is wrong at the source.',
231        ].filter(Boolean).join('\n'),
232      }
233    }
234    return { text: `money: no such argument "${arg}" — use on, off or refresh` }
235  })
236
237  // The emergency fund's fill as a footer mode label beside `focus`: labels there are plain strings,
238  // so the figure carries no colour.
239  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
240    if (disabled || !shown || money?.ef === undefined) return next(e)
241    const label = `EF ${Math.round((money.ef / EF_TARGET) * 100)}%`
242    return next({ ...e, props: { ...e.props, modes: [...e.props.modes, label] } })
243  })
244
245  // A render hook that throws unmounts the module and takes the whole mod with it, so a bad frame
246  // falls back to the band as it was rather than costing the session its money line.
247  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
248    if (disabled) return next(e)
249    if (!shown || e.props.hasSurvey || e.surface !== 'terminal') return next(e)
250    try {
251      const { Box, Text } = await $.ui.resolve(e)
252      const rest = await next(e)
253
254      if (!money) {
255        return (
256          <Box flexDirection="column">
257            <Text dimColor>{error ? `money · ${HOST} unreachable: ${error}` : `money · reading ${HOST}…`}</Text>
258            {rest}
259          </Box>
260        )
261      }
262
263      const stale = daysSince(money.asOf, money.at)
264      const netText = `S$${sgd(net(money))}`
265      // One string measured against the band's columns, shedding the least useful figure first,
266      // because a row of separate Text nodes wraps into stacked fragments at fifty columns.
267      const tail: [string, number][] = [
268        ['money', 5],
269        [`net ${netText}`, 0],
270        [`liquid S$${sgd(money.liquid)}`, 0],
271        [`cpf S$${sgd(money.cpf)}`, 4],
272        [`debt S$${sgd(money.debt)}`, 3],
273        [`spent this month S$${sgd(money.spend)}`, 2],
274        [stale !== undefined && stale > 30 ? `· balances ${stale}d old` : `· ${money.asOf}`, 1],
275      ]
276      const columns = e.props.bodyColumns || 80
277      const width = (list: [string, number][]) => list.reduce((n, [t]) => n + t.length, 0) + 2 * (list.length - 1)
278      let kept = tail
279      while (width(kept) > columns) {
280        const drop = kept.filter(([, d]) => d > 0).reduce((a, b) => (a[1] < b[1] ? a : b), ['', Infinity] as [string, number])
281        if (!Number.isFinite(drop[1])) break
282        kept = kept.filter(x => x !== drop)
283      }
284      const at = kept.findIndex(([t]) => t.startsWith('net '))
285      const before = kept.slice(0, at).map(([t]) => t).join('  ')
286      const after = kept.slice(at + 1).map(([t]) => t).join('  ')
287      return (
288        <Box flexDirection="column">
289          <Text wrap="truncate-end" dimColor>
290            {before ? `${before}  ` : ''}
291            {'net '}
292            <Text bold color={net(money) >= 0 ? 'green' : 'red'}>{netText}</Text>
293            {after ? `  ${after}` : ''}
294          </Text>
295          {rest}
296        </Box>
297      )
298    } catch (err) {
299      $.ui.log(`money-band: render failed, falling back: ${err}`)
300      return next(e)
301    }
302  })
303}
304