SLOPSHOPPER

mini-offload

Routes heavy Bash commands (test suites, builds, Docker) to a second machine over ssh, with the PATH export and working directory already right, and the commit…

newguardcommandtoastprocess
★ 1v0.3.0MITupdated 2026-09-21yash-gadodia/claude-mods/mini-offload
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mini-offload
› fix the failing auth test and add an audit log call ╭───────────────────────────────────────────╮ │ mini-offload │ ⏺ Read(src/auth.ts) │ mini-offload: running on mini (/work/app) │ ⎿ 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 › /mini ⎿ mini-offload: mini-offload is ask ⎿ mini-offload: always heavy commands go to the mini with no prompt ⎿ mini-offload: ask heavy commands prompt first (default) ⎿ mini-offload: off everything runs on this laptop ⎿ mini-offload: ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? 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.ts 308 lines
1import type { Register, EngineInterface } from 'claude-code'
2
3// The laptop orchestrates, the mini executes. Heavy work — test suites, builds, Docker stacks —
4// belongs on ssh mini, and every session otherwise has to be reminded of that. This mod rewrites
5// the Bash call instead: same command, wrapped with the non-interactive PATH export and a cd into
6// the matching directory on the mini.
7//
8// Per-repo override at <repo>/.claude/mini-offload.json:
9//   { "remoteRoot": "/Users/yash/dev/thing", "extra": ["^bun run gate"], "never": ["^bun run dev"] }
10// With no config the mini's path is assumed to match the laptop's, which is true for ~/dev.
11//
12// The mini runs a DIFFERENT checkout, which is the whole hazard: on 20-09-2026 it was 53 commits
13// behind ragtag-ragnarok's main and still answering `npm test` with a green, for code that was
14// nowhere on it. A gate that reports on the wrong tree is worse than no gate. So a git repo is
15// synced before anything is offloaded - HEAD is pushed to a scratch ref and the mini is reset onto
16// it - and when it cannot be synced the command stays on the laptop and says why. Uncommitted work
17// can never be sent, so a dirty tree always runs here.
18
19// The remote host and its PATH export are the only machine-specific things here; both come from
20// the plugin's userConfig so this works for anyone with an ssh alias to a second machine.
21let HOST = 'mini'
22let REMOTE_PATH = 'export PATH=/opt/homebrew/bin:$PATH'
23const MODE_KEY = 'mini-offload:mode'
24const TEN_MINUTES = 600000
25
26const HEAVY = [
27  /\b(bun|npm|pnpm|yarn)\s+(run\s+)?test\b/,
28  /\b(bun|npm|pnpm|yarn)\s+(run\s+)?build\b/,
29  /\b(vitest|jest|playwright|cypress)\b/,
30  /\b(pytest|tox|nox)\b/,
31  /\bgo\s+(test|build)\b/,
32  /\bcargo\s+(test|build|clippy)\b/,
33  /\bdocker\s+(compose|build)\b/,
34  /\bcolima\b/,
35  /\bturbo\s+run\b/,
36  /\bnext\s+build\b/,
37  /\btsc\s+(-b|--build)\b/,
38  /\bmake\s+(test|build|check|gate)\b/,
39]
40
41// Whether a directory exists on the mini, asked once per path per session: sending a command to a
42// path the mini does not have is worse than running it here, because `cd` fails and the rest is
43// skipped with only a confusing error to show for it.
44const remoteRootOk = new Map<string, boolean>()
45
46// Synced once per commit per remote path: a loop of test runs on one commit syncs on the first.
47const synced = new Set<string>()
48
49// A command that already leaves this machine, or that only makes sense here, is left alone.
50const NEVER = [
51  /\bssh\b/,
52  /\bmini\b/,
53  /\bgit\s+(push|pull|fetch|clone)\b/,
54  /\b(vercel|wrangler|railway|flyctl|netlify)\b/,
55  /\b(bun|npm|pnpm|yarn)\s+(run\s+)?dev\b/,
56  /--watch\b/,
57]
58
59type Config = { remoteRoot?: string; extra?: string[]; never?: string[] }
60type Mode = 'ask' | 'always' | 'off'
61
62let mode: Mode = 'ask'
63let interactive = false
64// answered once per session per command shape, so a loop of test runs asks once
65const declined = new Set<string>()
66
67const isMode = (value: unknown): value is Mode => value === 'ask' || value === 'always' || value === 'off'
68
69const readConfig = async ($: EngineInterface, root: string): Promise<Config> => {
70  const path = `${root}/.claude/mini-offload.json`
71  if (!(await $.fs.exists(path).catch(() => false))) return {}
72  try {
73    return JSON.parse(await $.fs.read(path)) as Config
74  } catch (err) {
75    $.ui.log(`mini-offload: ${path} is not valid JSON: ${err}`)
76    return {}
77  }
78}
79
80const matches = (command: string, extra: string[] = [], never: string[] = []) => {
81  if (NEVER.some(re => re.test(command))) return false
82  if (never.some(source => new RegExp(source).test(command))) return false
83  return HEAVY.some(re => re.test(command)) || extra.some(source => new RegExp(source).test(command))
84}
85
86// Single-quote the whole remote script, escaping any single quote inside it the shell way.
87const quote = (script: string) => `'${script.replace(/'/g, `'\\''`)}'`
88
89const remoteCommand = (command: string, remoteRoot: string) =>
90  `ssh ${HOST} ${quote(`${REMOTE_PATH}; cd ${quote(remoteRoot)} && ${command}`)}`
91
92// ssh itself failing (255, or a process that never started) is not an answer about the mini; it
93// throws, and the hook's catch handler decides by mode instead of quietly running here.
94const ssh = async ($: EngineInterface, args: string[], timeoutMs: number) => {
95  const r = await $.process.run(['ssh', ...args], { timeoutMs })
96  if (r.exitCode === 255) throw new Error(`ssh ${HOST}: ${r.stderr.trim() || 'exit 255'}`)
97  return r
98}
99
100const remoteHas = async ($: EngineInterface, remoteRoot: string): Promise<boolean> => {
101  const known = remoteRootOk.get(remoteRoot)
102  if (known !== undefined) return known
103  const r = await ssh($, ['-o', 'ConnectTimeout=5', '-o', 'BatchMode=yes', HOST, `test -d ${quote(remoteRoot)}`], 8000)
104  const ok = r.exitCode === 0
105  remoteRootOk.set(remoteRoot, ok)
106  return ok
107}
108
109const git = async ($: EngineInterface, cwd: string, args: string[]): Promise<string | undefined> => {
110  const r = await $.process.run(['git', '-C', cwd, ...args], { timeoutMs: 60000 }).catch(() => undefined)
111  return r?.exitCode === 0 ? r.stdout.trim() : undefined
112}
113
114type Sync = { ok: true; note?: string } | { ok: false; why: string }
115
116// Put the laptop's HEAD on the mini, or explain why the command must stay here. The scratch ref is
117// per branch and force-pushed: it is ours, nobody builds on it, and it keeps the sync to one delta.
118const syncToMini = async ($: EngineInterface, root: string, remoteRoot: string): Promise<Sync> => {
119  const sha = await git($, root, ['rev-parse', 'HEAD'])
120  if (!sha) return { ok: true, note: `${root} is not a git checkout, so nothing was synced - the mini ran whatever it already had there.` }
121
122  const key = `${remoteRoot}@${sha}`
123  if (synced.has(key)) return { ok: true }
124
125  const dirty = await git($, root, ['status', '--porcelain', '--untracked-files=no'])
126  if (dirty === undefined) return { ok: false, why: `git status failed in ${root}` }
127  if (dirty !== '') {
128    return {
129      ok: false,
130      why: 'the working tree has uncommitted changes. They cannot be sent to the mini, and a run there would quietly report on the last commit instead of the code being edited.',
131    }
132  }
133
134  const branch = (await git($, root, ['rev-parse', '--abbrev-ref', 'HEAD'])) ?? 'HEAD'
135  const ref = `_offload/${branch.replace(/[^A-Za-z0-9._\/-]/g, '-')}`
136  const pushed = await $.process
137    .run(['git', '-C', root, 'push', '--force', '--quiet', 'origin', `HEAD:refs/heads/${ref}`], { timeoutMs: 120000 })
138    .catch(() => undefined)
139  if (pushed?.exitCode !== 0) return { ok: false, why: `pushing HEAD to origin ${ref} failed, so the mini cannot be given this commit` }
140
141  // -uno: untracked files on the mini are its own business, tracked edits are not ours to discard.
142  const script = [
143    `cd ${quote(remoteRoot)}`,
144    `test -z "$(git status --porcelain -uno)" || { echo __DIRTY__; exit 3; }`,
145    `git fetch --quiet origin ${ref}`,
146    `git reset --hard --quiet FETCH_HEAD`,
147    'git rev-parse HEAD',
148  ].join(' && ')
149  const r = await ssh($, [HOST, quote(`${REMOTE_PATH}; ${script}`)], 180000)
150
151  if (r.stdout.includes('__DIRTY__')) {
152    return { ok: false, why: `${HOST}:${remoteRoot} has uncommitted tracked changes of its own, and resetting it onto this commit would throw them away` }
153  }
154  if (r.exitCode !== 0 || !r.stdout.includes(sha)) {
155    return { ok: false, why: `${HOST} could not be reset onto ${sha.slice(0, 7)}` }
156  }
157
158  synced.add(key)
159  return { ok: true, note: `mini-offload synced ${HOST}:${remoteRoot} to ${sha.slice(0, 7)} before running this.` }
160}
161
162// The first thing anyone does when a mod misbehaves is try to turn it off. `CLAUDE_MODS_DISABLE=all`,
163// or a comma list naming this mod, makes every hook here a pass-through and registers no command.
164const MOD = 'mini-offload'
165let disabled = false
166const readDisabled = async ($: EngineInterface): Promise<boolean> => {
167  const raw = (await $.env.get('CLAUDE_MODS_DISABLE').catch(() => undefined)) ?? ''
168  disabled = raw
169    .split(',')
170    .map(v => v.trim())
171    .some(v => v === 'all' || v === MOD)
172  return disabled
173}
174
175export const register: Register = (on, options) => {
176  if (typeof options.host === 'string' && options.host.trim()) HOST = options.host.trim()
177  if (typeof options.remotePath === 'string' && options.remotePath.trim()) REMOTE_PATH = options.remotePath.trim()
178
179  on('session.start', async ($, e, next) => {
180    const r = await next(e)
181    if (await readDisabled($)) return r
182    interactive = e.isInteractive
183    const saved = await $.store.get(MODE_KEY).catch(() => undefined)
184    if (isMode(saved)) mode = saved
185    await $.command
186      .register({
187        name: 'mini',
188        description: 'Where heavy commands run: always on the mini, ask each time, or off (mini-offload)',
189        argumentHint: '[always | ask | off | status]',
190        immediate: true,
191      })
192      .catch(err => $.ui.log(`mini-offload: /mini not registered: ${err}`))
193    return r
194  })
195
196  on('command.run', { command: 'mini' }, async ($, e) => {
197    const arg = e.args.trim().toLowerCase()
198    if (arg === 'status' || arg === '') {
199      return {
200        text: [
201          `mini-offload is ${mode}`,
202          '  always  heavy commands go to the mini with no prompt',
203          '  ask     heavy commands prompt first (default)',
204          '  off     everything runs on this laptop',
205          declined.size ? `\ndeclined this session: ${declined.size} command shape(s)` : '',
206        ].join('\n'),
207      }
208    }
209    if (!isMode(arg)) return { text: `mini: no mode called "${arg}" — use always, ask, off or status` }
210    mode = arg
211    declined.clear()
212    await $.store.set(MODE_KEY, mode).catch(err => $.ui.log(`mini-offload: store write failed: ${err}`))
213    return { text: `mini-offload set to ${mode}` }
214  })
215
216  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
217    if (disabled) return next(e)
218    if (mode === 'off' || e.run_in_background) return next(e)
219
220    const repo = await $.session.repo().catch(() => null)
221    const root = repo?.root ?? (await $.session.cwd())
222    const config = await readConfig($, root)
223    if (!matches(e.command, config.extra, config.never)) return next(e)
224
225    const key = e.command.trim().slice(0, 120)
226    if (declined.has(key)) return next(e)
227
228    const remoteRoot = config.remoteRoot ?? root
229    if (!(await remoteHas($, remoteRoot))) {
230      declined.add(key)
231      const r = await next(e)
232      if ('deny' in r) return r
233      return {
234        ...r,
235        context: [
236          ...(r.context ?? []),
237          `mini-offload left this on the laptop: ${HOST} has no directory ${remoteRoot}, so running it there would only have failed at cd.`,
238          `If this work belongs on the mini, get the code there and set {"remoteRoot": "<path on the mini>"} in ${root}/.claude/mini-offload.json.`,
239        ],
240      }
241    }
242
243    if (mode === 'ask') {
244      if (!interactive) return next(e)
245      const answer = await $.ui
246        .ask(`Run this on the mini instead of the laptop?\n  ${key}`, {
247          header: 'mini-offload',
248          options: ['Run on the mini', 'Run here', 'Always the mini this session', 'Stop asking this session'],
249        })
250        .catch(() => 'Run here')
251      if (answer === 'Run here') {
252        declined.add(key)
253        return next(e)
254      }
255      if (answer === 'Stop asking this session') {
256        mode = 'off'
257        return next(e)
258      }
259      if (answer === 'Always the mini this session') mode = 'always'
260    }
261
262    // The mini must be running THIS commit, or its answer is about other code.
263    const sync = await syncToMini($, root, remoteRoot)
264    if (!sync.ok) {
265      declined.add(key)
266      $.ui.toast(`mini-offload: staying on the laptop (${sync.why})`)
267      const local = await next(e)
268      if ('deny' in local) return local
269      return {
270        ...local,
271        context: [
272          ...(local.context ?? []),
273          `mini-offload ran this on the LAPTOP, not on ${HOST}: ${sync.why}`,
274        ],
275      }
276    }
277
278    const rewritten = remoteCommand(e.command, remoteRoot)
279    $.ui.toast(`mini-offload: running on ${HOST} (${remoteRoot})`)
280
281    const r = await next({ ...e, command: rewritten, timeout: Math.max(e.timeout ?? 0, TEN_MINUTES) })
282    if ('deny' in r) return r
283
284    const note =
285      r.isError && /Could not resolve hostname|Connection (refused|timed out)|Host key/i.test(r.text ?? '')
286        ? `mini-offload sent this to ${HOST} and ssh could not reach it. Re-run the command as written locally, and tell the user the mini is unreachable.`
287        : [
288            `mini-offload ran this on ${HOST} in ${remoteRoot}, not on the laptop.`,
289            sync.note ?? `${HOST} was already on this commit.`,
290            `The command actually run was: ${rewritten}`,
291          ].join(' ')
292    return { ...r, context: [...(r.context ?? []), note] }
293  }).catch(async ($, e, next) => {
294    // The hook threw or overran before deciding. Once the command is on its way (next.called) the
295    // replay is the answer; otherwise a heavy command must not slip onto the laptop unannounced.
296    if (next.called || disabled || mode === 'off' || e.run_in_background || !matches(e.command)) return next(e)
297    const why = `mini-offload failed to route this command to the mini (${next.error.message ?? next.error.kind})`
298    if (mode === 'ask') {
299      if (!interactive) return next(e)
300      const answer = await $.ui
301        .ask(`${why}. Run it on the laptop instead?\n  ${e.command.trim().slice(0, 120)}`, { header: 'mini-offload', options: ['Run here', 'Do not run it'] })
302        .catch(() => 'Do not run it')
303      if (answer === 'Run here') return next(e)
304    }
305    return { deny: `${why}; run it locally with /mini off or fix ssh` }
306  })
307}
308