SLOPSHOPPER

chrome-switch

Switches the Claude in Chrome extension between named browser profiles with select_browser, which needs no approval click, and picks the default before the…

newguardcommandtoastprocess
★ 1v0.3.0MITupdated 2026-09-21yash-gadodia/claude-mods/chrome-switch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · chrome-switch
› 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 › /chromep ⎿ chrome-switch: chrome-switch: no browsers connected — is the extension running in any profile? ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? 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 216 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3// Switching the Claude in Chrome extension between profiles without the approval click.
4//
5// Every profile with the extension is already connected at once. What costs a click is
6// switch_browser, which broadcasts a pairing request to all of them and waits; its sibling
7// select_browser takes a deviceId and, in its own words, selects "without broadcasting a pairing
8// request". The model reaches for the first one only because it cannot tell which browser is which.
9//
10// It cannot tell because the extension's own names do not survive the round trip: measured on
11// 17-09-2026, list_connected_browsers called the same three devices Browser 1/2/3 while
12// select_browser echoed Browser 2/3/4 for those same ids, and a name set through switch_browser was
13// reported by neither. So a name is not a key here. The deviceId is, and the map from a label to a
14// deviceId is kept here, in a file a person can read and edit:
15//
16//   ~/.claude/chrome-browsers.json  { "default": "volty", "browsers": { "volty": "<deviceId>" } }
17//
18// A deviceId that is no longer connected is reported rather than silently skipped, because the
19// recovery (/chromep pick, /chromep map) is a person's call and a wrong browser is worse than none.
20
21const SERVER = 'claude-in-chrome'
22const PREFIX = 'mcp__claude-in-chrome__'
23// The three that steer the choice rather than act on a page; selecting underneath them would race.
24const STEERING = new Set([`${PREFIX}list_connected_browsers`, `${PREFIX}select_browser`, `${PREFIX}switch_browser`])
25
26type BrowserMap = { default?: string; browsers: Record<string, string> }
27
28let mapPath: string | undefined
29let selected: string | undefined
30let tried = false
31
32const text = (r: { content?: unknown }): string => {
33  const blocks = Array.isArray(r.content) ? r.content : []
34  return blocks
35    .map((b) => (b && typeof b === 'object' && 'text' in b && typeof b.text === 'string' ? b.text : ''))
36    .join('\n')
37}
38
39export const readMap = async ($: EngineInterface): Promise<BrowserMap> => {
40  if (!mapPath) return { browsers: {} }
41  const r = await $.process.run(['cat', mapPath], { timeoutMs: 5000 }).catch(() => undefined)
42  if (!r || r.exitCode !== 0) return { browsers: {} }
43  try {
44    const parsed: unknown = JSON.parse(r.stdout)
45    if (!parsed || typeof parsed !== 'object') return { browsers: {} }
46    const raw = (parsed as { browsers?: unknown }).browsers
47    const browsers: Record<string, string> = {}
48    if (raw && typeof raw === 'object') {
49      for (const [k, v] of Object.entries(raw)) if (typeof v === 'string') browsers[k.toLowerCase()] = v
50    }
51    const fallback = (parsed as { default?: unknown }).default
52    return { default: typeof fallback === 'string' ? fallback.toLowerCase() : undefined, browsers }
53  } catch {
54    return { browsers: {} }
55  }
56}
57
58const writeMap = async ($: EngineInterface, map: BrowserMap): Promise<boolean> => {
59  if (!mapPath) return false
60  const r = await $.process
61    .run(['tee', mapPath], { stdin: `${JSON.stringify(map, null, 2)}\n`, timeoutMs: 5000 })
62    .catch((err) => {
63      $.ui.log(`chrome-switch: map write failed: ${err}`)
64      return undefined
65    })
66  return r?.exitCode === 0
67}
68
69const connected = async ($: EngineInterface): Promise<string[]> => {
70  const r = await $.mcp.call(SERVER, 'list_connected_browsers').catch(() => undefined)
71  if (!r) return []
72  const match = /\[[\s\S]*?\]/.exec(text(r))
73  if (!match) return []
74  try {
75    const parsed: unknown = JSON.parse(match[0])
76    if (!Array.isArray(parsed)) return []
77    return parsed.flatMap((b) =>
78      b && typeof b === 'object' && 'deviceId' in b && typeof b.deviceId === 'string' ? [b.deviceId] : [],
79    )
80  } catch {
81    return []
82  }
83}
84
85const select = async ($: EngineInterface, deviceId: string): Promise<boolean> => {
86  const r = await $.mcp.call(SERVER, 'select_browser', { deviceId }).catch((err) => {
87    $.ui.log(`chrome-switch: select_browser failed: ${err}`)
88    return undefined
89  })
90  if (!r || r.isError) return false
91  selected = deviceId
92  return true
93}
94
95const short = (deviceId: string) => deviceId.slice(0, 8)
96
97// The first thing anyone does when a mod misbehaves is try to turn it off. `CLAUDE_MODS_DISABLE=all`,
98// or a comma list naming this mod, makes every hook here a pass-through and registers no command.
99const MOD = 'chrome-switch'
100let disabled = false
101const readDisabled = async ($: EngineInterface): Promise<boolean> => {
102  const raw = (await $.env.get('CLAUDE_MODS_DISABLE').catch(() => undefined)) ?? ''
103  disabled = raw
104    .split(',')
105    .map(v => v.trim())
106    .some(v => v === 'all' || v === MOD)
107  return disabled
108}
109
110export const register: Register = (on) => {
111  on('session.start', async ($, e, next) => {
112    const r = await next(e)
113    if (await readDisabled($)) return r
114    const home = await $.env.get('HOME').catch(() => undefined)
115    if (home) mapPath = `${home}/.claude/chrome-browsers.json`
116    await $.command
117      .register({
118        name: 'chromep',
119        description: 'Switch the Chrome profile Claude drives, with no approval click: /chromep volty (chrome-switch)',
120        argumentHint: '[label | pick <n> | map <label> | default <label>]',
121        immediate: true,
122      })
123      .catch((err) => $.ui.log(`chrome-switch: /chromep not registered: ${err}`))
124    return r
125  })
126
127  on('command.run', { command: 'chromep' }, async ($, e) => {
128    const args = e.args.trim().split(/\s+/).filter(Boolean)
129    const verb = args[0]?.toLowerCase()
130    const map = await readMap($)
131
132    if (verb === 'map') {
133      const wanted = args.slice(1).join(' ').toLowerCase()
134      if (!wanted) return { text: 'chrome-switch: /chromep map <label> names the browser that is selected now' }
135      if (!selected) return { text: 'chrome-switch: nothing selected yet this session — /chromep pick <n> first, then map it' }
136      map.browsers[wanted] = selected
137      if (!(await writeMap($, map))) return { text: `chrome-switch: could not write ${mapPath}` }
138      return { text: `${wanted} → ${short(selected)}, saved to ${mapPath}` }
139    }
140
141    if (verb === 'default') {
142      const wanted = args.slice(1).join(' ').toLowerCase()
143      if (!wanted) return { text: `chrome default is ${map.default ?? 'unset'} — /chromep default <label> to set it` }
144      if (!map.browsers[wanted]) return { text: `chrome-switch: no label "${wanted}" in the map — /chromep lists it` }
145      map.default = wanted
146      if (!(await writeMap($, map))) return { text: `chrome-switch: could not write ${mapPath}` }
147      return { text: `chrome default is now ${wanted} — selected before the first browser call of a session` }
148    }
149
150    const live = await connected($)
151    if (!live.length) return { text: 'chrome-switch: no browsers connected — is the extension running in any profile?' }
152
153    if (verb === 'pick') {
154      const n = Number(args[1])
155      const deviceId = Number.isInteger(n) ? live[n - 1] : undefined
156      if (!deviceId) return { text: `chrome-switch: pick 1..${live.length}` }
157      if (!(await select($, deviceId))) return { text: `chrome-switch: could not select ${short(deviceId)}` }
158      return { text: `selected ${short(deviceId)} — act in the browser to see which profile it is, then /chromep map <label>` }
159    }
160
161    const wanted = args.join(' ').toLowerCase()
162    if (!wanted) {
163      const labelled = new Map(Object.entries(map.browsers).map(([k, v]) => [v, k]))
164      const rows = live
165        .map((id, i) => `  ${i + 1}. ${labelled.get(id) ?? '(unmapped)'}  ${short(id)}${id === selected ? '  ← selected' : ''}${labelled.get(id) === map.default ? '  (default)' : ''}`)
166        .join('\n')
167      const missing = Object.entries(map.browsers)
168        .filter(([, id]) => !live.includes(id))
169        .map(([label, id]) => `  ${label} (${short(id)}) is mapped but not connected — /chromep pick <n> then /chromep map ${label}`)
170        .join('\n')
171      return { text: `connected chrome profiles — /chromep <label> switches, no approval click\n${rows}${missing ? `\n${missing}` : ''}` }
172    }
173
174    const deviceId = map.browsers[wanted]
175    if (!deviceId) return { text: `chrome-switch: no label "${wanted}" in the map — /chromep lists what there is` }
176    if (!live.includes(deviceId)) {
177      return { text: `chrome-switch: ${wanted} (${short(deviceId)}) is not connected — open that Chrome profile, or re-map it with /chromep pick <n> then /chromep map ${wanted}` }
178    }
179    if (!(await select($, deviceId))) return { text: `chrome-switch: could not select ${wanted}` }
180    $.ui.toast(`chrome: ${wanted}`, { timeoutMs: 3000 })
181    return { text: `driving ${wanted}` }
182  })
183
184  // Matched on the server's prefix, so no other tool call passes through here at all.
185  on('tool.call', { tool: /^mcp__claude-in-chrome__/ }, async ($, e, next) => {
186    if (disabled) return next(e)
187    // e.tool is McpToolName, a template the typings snapshot cannot narrow further for a server
188    // it does not declare; widened, the names compare.
189    const tool: string = e.tool
190
191    // The list tool's own instructions send the model to switch_browser, which is the click. Naming
192    // the map underneath the result points it at select_browser instead.
193    if (tool === `${PREFIX}list_connected_browsers`) {
194      const r = await next(e)
195      if ('deny' in r && r.deny !== undefined) return r
196      const map = await readMap($)
197      const known = Object.entries(map.browsers)
198      if (!known.length) return r
199      return {
200        ...r,
201        context: [
202          ...(r.context ?? []),
203          `chrome-switch: these deviceIds are known — ${known.map(([l, id]) => `${l} = ${id}`).join(', ')}${map.default ? `; the default is ${map.default}` : ''}${selected ? `; ${short(selected)} is already selected this session` : ''}. Call select_browser with the right deviceId rather than switch_browser, which broadcasts a pairing request the person has to click.`,
204        ],
205      }
206    }
207
208    if (STEERING.has(tool) || tried || selected) return next(e)
209    tried = true
210    const map = await readMap($)
211    const deviceId = map.default ? map.browsers[map.default] : undefined
212    if (deviceId && (await select($, deviceId))) $.ui.toast(`chrome: ${map.default}`, { timeoutMs: 3000 })
213    return next(e)
214  })
215}
216