5h and 7d limits of both cswap accounts in the band above the prompt

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.tsx 74 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Account, Meter } from '../types'
5import { otherText, parseCswap, switchTo } from './parse'
6
7const meter = atom({ plugin: 'twin-meter', key: 'meter' } as const, null)
8
9let lastRun = 0
10
11async function refresh($: EngineInterface) {
12 lastRun = await $.clock.now()
13 const listed = await $.process.run(['cswap', 'list'], { timeoutMs: 20000 }).catch(() => null)
14 if (!listed || listed.exitCode !== 0) return
15 const { stdout } = listed
16 const accounts = parseCswap(stdout)
17 if (accounts.length === 0) return
18 await update($, meter, (): Meter => ({ accounts }))
19}
20
21export const register: Register = on => {
22 on('session.start', async ($, e, next) => {
23 void refresh($)
24 $.clock.every(120_000, () => refresh($))
25 return next(e)
26 })
27
28 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
29 const ran = await next(e)
30 if (/\bcswap\b/.test(e.command)) void refresh($)
31 return ran
32 })
33
34 on('turn.complete', async ($, e, next) => {
35 if (!e.agentId && (await $.clock.now()) - lastRun > 60_000) void refresh($)
36 return next(e)
37 })
38
39 // One row shared with on-me: whatever the band holds on the left, the other accounts on the right.
40 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
41 const below = await next(e)
42 const m = await read($, meter)
43 if (e.props.hasSurvey || !m) return below
44
45 const { Box, Text } = $.ui.resolve(e)
46 const to = switchTo(m.accounts)
47 const others = otherText(m.accounts)
48 if (!others && !to) return below
49
50 // The status line shows this session's account; the tag says this is the other one.
51 const mine = to ? (
52 <Text key="twin-meter">
53 <Text backgroundColor="cyan" color="black" bold>{' SWITCH '}</Text>
54 <Text color="cyan">{` to #${to.n} (5h ${to.five}%): cswap switch ${to.n}`}</Text>
55 </Text>
56 ) : (
57 <Text key="twin-meter">
58 <Text backgroundColor="magenta" color="black" bold>{' OTHER ACCOUNT '}</Text>
59 <Text color="magenta">{` ${others}`}</Text>
60 </Text>
61 )
62 if (!below) return mine
63 return (
64 // The engine refuses another hook's tree under a sized Box, so `below` sits bare in a plain row.
65 <Box flexDirection="row" gap={3}>
66 {below}
67 <Box flexGrow={1} />
68 {/* Room for the band's own [-] control at the right edge. */}
69 <Box flexShrink={0} paddingRight={4}>{mine}</Box>
70 </Box>
71 )
72 })
73}
74hooks/parse.ts 42 lines1import type { Account } from '../types'
2
3// Parses `cswap list`:
4// 1: a@b.com [a@b.com's Organization] (active)
5// ├ 5h: 87% resets 22:19 in 3h 42m
6// └ 7d: 10% resets Oct 5 13:29 in 2d 18h
7export function parseCswap(text: string): Account[] {
8 const accounts: Account[] = []
9 for (const block of text.split(/\n(?=\s*\d+: )/)) {
10 const head = block.match(/^\s*(\d+): (\S+)/m)
11 const five = block.match(/5h:\s*(\d+)%(?:.*?\bin\s+(\d+[dhm])(?:\s+\d+[hm])?)?/)
12 const week = block.match(/7d:\s*(\d+)%/)
13 if (!head || !five || !week) continue
14 const email = head[2]!
15 accounts.push({
16 n: Number(head[1]),
17 email,
18 isActive: /\(active\)/.test(block.split('\n').find(l => l.includes(email)) ?? ''),
19 five: Number(five[1]),
20 fiveReset: five[2] ?? null,
21 week: Number(week[1]),
22 })
23 }
24 return accounts
25}
26
27// The account to switch to, when the active one is getting full and another has room.
28export function switchTo(accounts: Account[]): Account | null {
29 const active = accounts.find(a => a.isActive)
30 if (!active || active.five < 60) return null
31 const best = accounts.filter(a => !a.isActive && a.week < 95).sort((a, b) => a.five - b.five)[0]
32 return best && best.five <= active.five - 20 ? best : null
33}
34
35// The status line already shows the active account, so the band shows only the others.
36export function otherText(accounts: Account[]): string {
37 return accounts
38 .filter(a => !a.isActive)
39 .map(a => `#${a.n} 5h ${a.five}%${a.fiveReset ? ` ↺ ${a.fiveReset}` : ''} · 7d ${a.week}%`)
40 .join(' ')
41}
42types/index.d.ts 20 lines1export type Account = {
2 n: number
3 email: string
4 isActive: boolean
5 five: number
6 // time until the 5h window resets, like "3h"
7 fiveReset: string | null
8 week: number
9}
10
11export type Meter = {
12 accounts: Account[]
13}
14
15declare module 'claude-code' {
16 interface PluginState {
17 'twin-meter': { meter: Meter | null }
18 }
19}
20