Pane: every herdr agent, its status and last words, with this pane marked

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 104 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Agent } from '../types'
5import { killsMe, lastSaid, parseAgents, projectDir } from './parse'
6
7const PANE = 'herdr-fleet'
8const agents = atom({ plugin: 'herdr-fleet', key: 'agents' } as const, [])
9
10const ICON: Record<Agent['status'], string> = { working: '▶', blocked: '⏳', done: '⏳', idle: '·', unknown: '?' }
11const COLOR: Record<Agent['status'], string | undefined> = { working: 'cyan', blocked: 'red', done: 'yellow', idle: undefined, unknown: undefined }
12
13async function refresh($: EngineInterface) {
14 const me = (await $.env.get('HERDR_PANE_ID')) ?? ''
15 const config = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? ''}/.claude`
16 const listed = await $.process.run(['herdr', 'agent', 'list'], { timeoutMs: 10000 }).catch(() => null)
17 if (!listed || listed.exitCode !== 0) return
18 let found: ReturnType<typeof parseAgents>
19 try {
20 found = parseAgents(listed.stdout, me)
21 } catch {
22 return // herdr printed something other than the JSON we know; keep the last list
23 }
24 const old = await read($, agents)
25 const next: Agent[] = []
26 for (const a of found) {
27 const before = old.find((o: Agent) => o.pane === a.pane)
28 let said = before?.said ?? ''
29 // Read the log again only when the agent changed state.
30 if (a.sessionId && (!before || before.seq !== a.seq)) {
31 const log = `${config}/projects/${projectDir(a.cwd)}/${a.sessionId}.jsonl`
32 const tail = await $.process.run(['tail', '-c', '300000', log]).catch(() => null)
33 if (tail?.exitCode === 0) said = lastSaid(tail.stdout)
34 }
35 if (before && before.status !== 'blocked' && a.status === 'blocked' && !a.isMe) {
36 $.ui.toast(`herdr: ${a.name || a.title} needs you (blocked)`)
37 }
38 const { sessionId: _, ...rest } = a
39 next.push({ ...rest, said })
40 }
41 await update($, agents, () => next)
42}
43
44async function focus($: EngineInterface, pane: string) {
45 await $.process.run(['herdr', 'agent', 'focus', pane]).catch(() => null)
46}
47
48export const register: Register = on => {
49 on('session.start', async ($, e, next) => {
50 await $.command.register({ name: 'herdr-fleet', description: 'Show every herdr agent and its status in a pane' })
51 if (await $.env.get('HERDR_PANE_ID')) {
52 void refresh($)
53 $.clock.every(20_000, () => refresh($))
54 }
55 return next(e)
56 })
57
58 on('command.run', { command: 'herdr-fleet' }, async $ => {
59 await $.ui.open({ id: PANE, title: 'herdr fleet' })
60 void refresh($)
61 return { text: 'herdr fleet pane opened.' }
62 })
63
64 // Never close or kill the pane this session runs in.
65 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
66 const pane = (await $.env.get('HERDR_PANE_ID')) ?? ''
67 const tab = (await $.env.get('HERDR_TAB_ID')) ?? ''
68 if (pane && killsMe(e.command, pane, tab)) {
69 return { deny: `herdr-fleet: this command would close this session's own herdr pane (${pane}) or herdr itself. Target other panes by id, or ask the user.` }
70 }
71 return next(e)
72 })
73
74 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
75 const { Box, Button, Text } = $.ui.resolve(e)
76 const list = await read($, agents)
77 const width = Math.max(20, e.props.bodyColumns - 4)
78 const waiting = list.filter((a: Agent) => !a.isMe && (a.status === 'blocked' || a.status === 'done')).length
79 return (
80 <Box flexDirection="column">
81 {list.length === 0 && <Text dimColor>No herdr agents found.</Text>}
82 {waiting > 0 && <Text color="yellow">⏳ {waiting} waiting on you</Text>}
83 {list.map((a: Agent) => (
84 <Box key={a.pane} flexDirection="column" marginTop={1}>
85 <Box flexDirection="row" gap={1}>
86 <Text color={COLOR[a.status]} bold={a.isMe}>
87 {ICON[a.status]} {a.name || a.title.slice(0, 30)} <Text dimColor>{a.status}</Text>
88 {a.isMe ? ' ◉ you are here' : ''}
89 </Text>
90 {!a.isMe && <Button key={`focus-${a.pane}`} label="focus" plain onPress={() => focus($, a.pane)} />}
91 </Box>
92 <Text dimColor wrap="truncate">
93 {a.cwd.replace(/^\/home\/[^/]+/, '~')} · {a.title}
94 </Text>
95 {a.said && (
96 <Text wrap="truncate-end">“{a.said.slice(0, width * 2)}”</Text>
97 )}
98 </Box>
99 ))}
100 </Box>
101 )
102 })
103}
104hooks/parse.ts 58 lines1import type { Agent } from '../types'
2
3type Raw = {
4 pane_id: string
5 tab_id: string
6 name?: string | null
7 agent_status: string
8 cwd: string
9 terminal_title_stripped?: string
10 state_change_seq?: number
11 agent_session?: { value?: string }
12}
13
14export function parseAgents(json: string, me: string): (Omit<Agent, 'said'> & { sessionId: string | null })[] {
15 const raw = (JSON.parse(json) as { result: { agents: Raw[] } }).result.agents
16 return raw.map(a => ({
17 pane: a.pane_id,
18 tab: a.tab_id,
19 name: a.name ?? '',
20 status: (['idle', 'working', 'blocked', 'done'].includes(a.agent_status) ? a.agent_status : 'unknown') as Agent['status'],
21 cwd: a.cwd,
22 title: a.terminal_title_stripped ?? '',
23 isMe: a.pane_id === me,
24 seq: a.state_change_seq ?? 0,
25 sessionId: a.agent_session?.value ?? null,
26 }))
27}
28
29// ~/.claude/projects names a folder after the cwd with / and . made -.
30export const projectDir = (cwd: string) => cwd.replace(/[/.]/g, '-')
31
32// The last assistant text in the tail of a session log.
33export function lastSaid(tail: string): string {
34 const lines = tail.split('\n')
35 for (let i = lines.length - 1; i >= 0; i--) {
36 if (!lines[i]!.includes('"type":"assistant"')) continue
37 try {
38 const row = JSON.parse(lines[i]!) as { message?: { content?: { type: string; text?: string }[] } }
39 const text = row.message?.content?.filter(c => c.type === 'text').map(c => c.text).join(' ').trim()
40 if (text) return text.replace(/\s+/g, ' ')
41 } catch {
42 // the tail's first line is cut mid-row
43 }
44 }
45 return ''
46}
47
48// A Bash command that would close or kill the pane, tab or herdr itself.
49export function killsMe(command: string, pane: string, tab: string): boolean {
50 if (/\b(pkill|killall)\b[^|;&]*herdr|\bherdr\s+server\s+stop\b/.test(command)) return true
51 if (!/\bherdr\s+(tab|pane|agent|workspace)\s+(close|kill|stop|remove)\b/.test(command)) return false
52 // The agent's own env vars name its pane and tab; expand them so `herdr pane close "$HERDR_PANE_ID"` counts.
53 const cmd = command.replace(/\$\{?HERDR_PANE_ID\}?/g, pane).replace(/\$\{?HERDR_TAB_ID\}?/g, tab)
54 // Whole ids only: closing w1:p12 must not count as closing w1:p1.
55 const names = (id: string) => new RegExp(`(^|[\\s"'=])${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([\\s"';&|]|$)`).test(cmd)
56 return [pane, tab, pane.split(':')[0]!].some(id => id && names(id))
57}
58types/index.d.ts 19 lines1export type Agent = {
2 pane: string
3 tab: string
4 name: string
5 status: 'idle' | 'working' | 'blocked' | 'done' | 'unknown'
6 cwd: string
7 title: string
8 isMe: boolean
9 // The last text the agent's Claude wrote, when its session log is found
10 said: string
11 seq: number
12}
13
14declare module 'claude-code' {
15 interface PluginState {
16 'herdr-fleet': { agents: Agent[] }
17 }
18}
19