SLOPSHOPPER

herdr-fleet

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

newpaneguardcommandtoastprocess
v0.2.0MITupdated 2026-10-05abhibansal60/claude-mods/herdr-fleet
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · herdr-fleet
│ ┃ herdr fleet ✕ › fix the failing auth test and add an audit log call │ ┃ No herdr agents found. │ ⏺ 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 │ │ › /herdr-fleet │ ⎿ herdr-fleet: herdr fleet pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · herdr fleet
No herdr agents found.
README

claude-mods

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.

on-me and twin-meter in the band above the prompt, ship-state in the side pane, on a real repo

Start here: paste this to your agent

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`.

Install

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.

The mods

ModWhereWhat it shows or does
on-meBand, leftOne 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-meterBand, right edgeA 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-statePane, /ship-stateEach 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-guardHook on Chrome toolsPairs 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-fleetPane, /herdr-fleetEvery 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.

Develop

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.

Source 3 files
hooks/register.tsx 104 lines
1import { 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}
104
hooks/parse.ts 58 lines
1import 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}
58
types/index.d.ts 19 lines
1export 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