Stops Chrome actions when the Chrome profile is not the one paired with the active cswap account

Claude Code mods (plugins of function hooks) built from how I actually use Claude Code: two accounts rotated with cswap, many parallel sessions in herdr, and checking results rather than diffs.

Set up the claude-mods Claude Code plugins from https://github.com/abhibansal60/claude-mods.
1. Read README.md and each mod's hooks/register.tsx (or .ts) on GitHub so you know what each one runs.
2. Check what I have, read-only: `claude --version`, `which cswap herdr gh git`, `gh auth status`, and the plugins in ~/.claude/settings.json.
3. Tell me which mods fit my machine: twin-meter and browser-guard need cswap; herdr-fleet needs herdr; ship-state needs git and gh. Skip the ones whose tools are missing.
4. Ask me before changing anything. Then run `claude plugin marketplace add abhibansal60/claude-mods` and `claude plugin install <mod>@claude-mods --scope user` for each mod I pick.
5. Tell me to restart Claude Code, and how to see each mod: the one-line band above the prompt, `/ship-state`, `/herdr-fleet`, `/browser-guard`.
claude plugin marketplace add abhibansal60/claude-mods
claude plugin install on-me@claude-mods --scope user
claude plugin install twin-meter@claude-mods --scope user
claude plugin install ship-state@claude-mods --scope user
claude plugin install browser-guard@claude-mods --scope user
claude plugin install herdr-fleet@claude-mods --scope user
Then restart Claude Code. Update later with claude plugin marketplace update claude-mods.
Needs: twin-meter and browser-guard use cswap, herdr-fleet uses herdr, ship-state uses git and gh.
| Mod | Where | What it shows or does |
|---|---|---|
on-me | Band, left | One line: a yellow ON YOU tag and the first item from the last answer's Blocked on me part (or "answer my question"), +N for more; a cyan CLAUDE tag and the current tool call while Claude works. Nothing when nothing waits on you. |
twin-meter | Band, right edge | A magenta OTHER ACCOUNT tag with #2 5h 3% ↺ 45m · 7d 91% for your other cswap accounts (the status line already shows the active one). Turns into a cyan SWITCH tag when the active account passes 60% and another has room. |
ship-state | Pane, /ship-state | Each repo the session touched: branch, uncommitted files, unpushed commits, last CI run, commit statuses on HEAD (Vercel deploys) and PyPI against the local version. A toast when an answer says "done" but a repo is not shipped. |
browser-guard | Hook on Chrome tools | Pairs each cswap account with a Chrome browser on first use, then stops Chrome actions from the wrong profile. /browser-guard lists pairs, /browser-guard reset forgets them. |
herdr-fleet | Pane, /herdr-fleet | Every herdr agent with status, folder, title and last words, "◉ you are here" on this pane, a focus button, a toast when another agent is blocked. Stops Bash commands that would close this pane or herdr. |
Each mod is a plugin: .claude-plugin/plugin.json, hooks/hooks.json, hooks/register.tsx, a types/index.d.ts contract for its $.state, and pure parsers in hooks/parse.ts with tests in tests/.
claude plugin validate ./<mod>
claude plugin test ./<mod>
To work on a mod live, run claude --plugin-dir ./<mod>: saving a file reloads it.
hooks/register.ts 80 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { ACTS, CHROME, PICKERS, activeEmail, inUseBrowser, mismatch } from './parse'
4import type { Browser, Pairs } from './parse'
5
6// Pairs live in $.store so they last across sessions: account email → Chrome browser.
7async function getPairs($: EngineInterface): Promise<Pairs> {
8 return ((await $.store.get('pairs')) as Pairs | undefined) ?? {}
9}
10
11async function account($: EngineInterface) {
12 const { stdout } = await $.process.run(['cswap', 'status'], { timeoutMs: 10000 })
13 return activeEmail(stdout)
14}
15
16async function browserInUse($: EngineInterface): Promise<Browser | null> {
17 const listed = await $.tool.call({ tool: `${CHROME}list_connected_browsers` } as never)
18 return inUseBrowser((listed as { text?: string }).text ?? '')
19}
20
21// One look at the account and browser serves the calls of the next 30 s.
22let seen: { at: number; email: string | null; browser: Browser | null } | null = null
23
24async function look($: EngineInterface) {
25 const now = await $.clock.now()
26 if (!seen || now - seen.at > 30_000) {
27 seen = { at: now, email: await account($).catch(() => null), browser: await browserInUse($).catch(() => null) }
28 }
29 return seen
30}
31
32export const register: Register = on => {
33 on('session.start', async ($, e, next) => {
34 await $.command.register({ name: 'browser-guard', description: 'Show Chrome profile pairs; "/browser-guard reset" forgets them' })
35 return next(e)
36 })
37
38 on('command.run', { command: 'browser-guard' }, async ($, e) => {
39 if (e.args.trim() === 'reset') {
40 await $.store.delete('pairs')
41 return { text: 'browser-guard: pairs forgotten. The next Chrome action asks again.' }
42 }
43 const pairs = Object.entries(await getPairs($))
44 return {
45 text: pairs.length === 0 ? 'browser-guard: no pairs yet.' : pairs.map(([email, b]) => `${email} → ${b.name} (${b.deviceId})`).join('\n'),
46 }
47 })
48
49 on('tool.call', async ($, e, next) => {
50 const tool = String(e.tool)
51 const input = e as unknown as { url?: string; action?: string; command?: string }
52 // A browser pick or an account switch makes the last look stale.
53 if (PICKERS.test(tool) || (tool === 'Bash' && /\bcswap\b/.test(input.command ?? ''))) seen = null
54 if (!tool.startsWith(CHROME) || PICKERS.test(tool)) return next(e)
55
56 const { email, browser } = await look($)
57 if (!email || !browser) return next(e)
58
59 const pairs = await getPairs($)
60 const why = mismatch(email, browser, pairs)
61 if (why) return { deny: `browser-guard: ${why}` }
62
63 if (!pairs[email]) {
64 const answer = await $.ui.ask(`Chrome "${browser.name}" is about to act. Is it the Chrome profile for ${email}?`, [
65 'Yes, pair them',
66 'No, stop',
67 ])
68 if (!answer.startsWith('Yes')) {
69 return { deny: `browser-guard: the user says Chrome "${browser.name}" is not the profile for ${email}. Ask which profile to use.` }
70 }
71 await $.store.set('pairs', { ...pairs, [email]: { deviceId: browser.deviceId, name: browser.name } })
72 }
73
74 if (ACTS.test(tool)) {
75 $.ui.toast(`Chrome: ${browser.name} · ${email.split('@')[0]} · ${input.action ?? tool.slice(CHROME.length)}${input.url ? ` ${input.url}` : ''}`)
76 }
77 return next(e)
78 })
79}
80hooks/parse.ts 36 lines1export type Browser = { deviceId: string; name: string; inUse?: boolean }
2export type Pairs = Record<string, Browser>
3
4export const CHROME = 'mcp__claude-in-chrome__'
5// Calls that only pick or list browsers: never guarded.
6export const PICKERS = /__(list_connected_browsers|select_browser|switch_browser|tabs_context_mcp)$/
7// Calls that act on a page as the signed-in person: worth a toast.
8export const ACTS = /__(navigate|form_input|file_upload|upload_image|javascript_tool|shortcuts_execute)$|__computer$/
9
10// `cswap status`: "Status: Account-1 (a@b.com [a@b.com's Organization])"
11export function activeEmail(status: string): string | null {
12 return status.match(/Status: Account-\d+ \((\S+?)[\s)]/)?.[1] ?? null
13}
14
15export function inUseBrowser(listText: string): Browser | null {
16 try {
17 const list = JSON.parse(listText) as Browser[]
18 return list.find(b => b.inUse) ?? (list.length === 1 ? list[0]! : null)
19 } catch {
20 return null
21 }
22}
23
24// null when the browser fits the account, else why not.
25export function mismatch(email: string, browser: Browser, pairs: Pairs): string | null {
26 const paired = pairs[email]
27 if (paired && paired.deviceId !== browser.deviceId) {
28 return `Chrome "${browser.name}" is not the profile paired with ${email}. Call select_browser for "${paired.name}" (deviceId ${paired.deviceId}), or ask the user to open that Chrome profile.`
29 }
30 const owner = Object.entries(pairs).find(([other, b]) => other !== email && b.deviceId === browser.deviceId)
31 if (owner) {
32 return `Chrome "${browser.name}" is the profile for ${owner[0]}, but the active cswap account is ${email}. Ask the user which profile to use.`
33 }
34 return null
35}
36