Band: what waits on you and what Claude is doing now

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 71 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { OnMe } from '../types'
5import { endsWithQuestion, parseBlocked } from './parse'
6
7const state = atom({ plugin: 'on-me', key: 'state' } as const, { items: [], doing: '' })
8
9// Markdown marks read as noise in a one-line band.
10const plain = (text: string) => text.replace(/[`*_]/g, '').replace(/\s+/g, ' ').trim()
11
12const short = (text: string, n: number) => (text.length > n ? `${text.slice(0, n - 1)}…` : text)
13
14export const register: Register = on => {
15 on('tool.call', async ($, e, next) => {
16 if (!e.agentId) {
17 const input = e as unknown as { description?: string; command?: string; file_path?: string }
18 const doing = input.description ?? `${String(e.tool)} ${input.file_path ?? input.command ?? ''}`
19 await update($, state, (s: OnMe) => ({ ...s, doing: short(doing.trim(), 60) }))
20 }
21 return next(e)
22 })
23
24 on('turn.complete', async ($, e, next) => {
25 if (!e.agentId) {
26 const blocked = parseBlocked(e.answer)
27 const asks = endsWithQuestion(e.answer) ? ['answer my question'] : []
28 await update($, state, (s: OnMe) => ({
29 doing: '',
30 items: blocked === null ? [...new Set([...s.items.filter(i => i !== 'answer my question'), ...asks])] : [...blocked, ...asks],
31 }))
32 }
33 return next(e)
34 })
35
36 // One row shared with twin-meter: on-me on the left, whatever the band holds below it on the right.
37 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
38 const below = await next(e)
39 if (e.props.hasSurvey) return below
40 const s = await read($, state)
41 const { Box, Text } = $.ui.resolve(e)
42
43 const first = plain(s.items[0] ?? '')
44 const more = s.items.length > 1 ? ` +${s.items.length - 1}` : ''
45 // A colored tag sets the band apart from transcript rows and logs.
46 const mine =
47 e.props.isWorking && s.doing ? (
48 <Text key="on-me" wrap="truncate-end">
49 <Text backgroundColor="cyan" color="black" bold>{' CLAUDE '}</Text>
50 <Text color="cyan">{` ${plain(s.doing)}`}</Text>
51 </Text>
52 ) : first ? (
53 <Text key="on-me" wrap="truncate-end">
54 <Text backgroundColor="yellow" color="black" bold>{' ON YOU '}</Text>
55 <Text color="yellow">{` ${first}`}</Text>
56 <Text dimColor>{more}</Text>
57 </Text>
58 ) : null
59
60 if (!mine || !below) return mine ?? below
61 return (
62 // The engine refuses another hook's tree under a sized Box, so `below` sits bare in a plain row.
63 <Box flexDirection="row" gap={3}>
64 <Box flexShrink={1}>{mine}</Box>
65 <Box flexGrow={1} />
66 {below}
67 </Box>
68 )
69 })
70}
71hooks/parse.ts 32 lines1const LABEL = /^\s*(#{1,6}\s*|\*\*|__)?blocked on me\b/i
2const NONE = /^(nothing|none|no\b|n\/a|-|—)/i
3const NEXT_PART = /^\s*(#{1,6}\s|\*\*(changed|found)\b|(changed|found):)/i
4
5// Reads the "Blocked on me" part of an answer: an inline value
6// ("**Blocked on me:** pick the ideas") or the bullets below its heading.
7// null when the answer has no such part.
8export function parseBlocked(answer: string): string[] | null {
9 const lines = answer.split('\n')
10 // Only a heading or label that starts the line, never the words inside a sentence.
11 const at = lines.findIndex(l => LABEL.test(l))
12 if (at === -1) return null
13 const inline = lines[at]!.replace(LABEL, '').replace(/^[\s:*]+/, '').trim()
14 const items: string[] = []
15 if (inline) items.push(inline)
16 for (const line of lines.slice(at + 1)) {
17 if (NEXT_PART.test(line)) break
18 if (!line.trim()) {
19 if (items.length > 0) break
20 continue
21 }
22 items.push(line.replace(/^\s*([-*•]|\d+[.)])\s*/, '').trim())
23 }
24 return items.filter(i => i && !NONE.test(i)).map(i => i.replace(/\*\*/g, ''))
25}
26
27// The answer ends by asking something: the person must reply.
28export function endsWithQuestion(answer: string): boolean {
29 const last = answer.trim().split('\n').pop() ?? ''
30 return /\?\s*\**\s*$/.test(last)
31}
32types/index.d.ts 13 lines1export type OnMe = {
2 // What waits on the person, from the last answer's "Blocked on me" part
3 items: string[]
4 // What Claude does now; empty between turns
5 doing: string
6}
7
8declare module 'claude-code' {
9 interface PluginState {
10 'on-me': { state: OnMe }
11 }
12}
13