SLOPSHOPPER

agent-crabs

A pixel-art crab for every running subagent, dressed for its role, showing what it is working on.

newbandtimer
v0.1.1no licenseupdated 2026-10-16Arkboi-GG/claude-mods/mods/agent-crabs
A shopper browsing a rack in a slop shop
README

claude-mods

A small collection of Claude Code mods.

Install

/plugin install <mod> --marketplace Arkboi-GG/claude-mods

The bars stack above the prompt, so you can install any combination.

Mods

  • task-progress: the band above the prompt. While Claude works on a multi-step task it shows one block per step: the running step fills as "In Progress", finished steps turn green "Completed", and a turn that stops with steps still open turns amber "Paused". When Claude is idle it shows usage rings (context, 5-hour and 7-day limits) with a Compact button. It reads the task list Claude keeps while working, so it appears for multi-step tasks. If you also use the skins mod, run /skin band off once so its own usage band doesn't show twice.
  • repo-status: branch, ahead/behind, changed-file count and the open PR's check state (needs git; gh is optional).
  • stack-health: status dots for your Docker Compose services and any local ports you list (web=5173,api=3000).
  • preflight: a live pre-commit checklist. After Claude edits files it re-runs your typecheck/lint (auto-detected, or set checks) and shows pass/fail with an error count.
  • env-guard: a green LOCAL badge that turns red PROD when a tool call matches your production patterns, and toasts when it happens.
  • agent-crabs: a pixel-art crab for every running subagent, dressed for its role (beret, hard hat, postman cap and more), with the agent's name, what it is working on and a timer. Finished crabs cheer and leave after a few seconds; failed ones turn grey. Name an agent by starting the Agent call's description with Name: , e.g. Olivia: rewrite welcome email; map names to outfits with the outfits option.
Source 2 files
hooks/register.tsx 254 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Crab } from '../types'
5
6const crabsAtom = atom({ plugin: 'agent-crabs', key: 'crabs' } as const, {} as Record<string, Crab>)
7const tickAtom = atom({ plugin: 'agent-crabs', key: 'tick' } as const, 0)
8
9const ORANGE = '#fb923c'
10
11// Pixel art. The crab is a 16 x 11 grid; every letter is a colour in PALETTE, a dot is empty.
12const W = 16
13const H = 11
14const SCALE = 2
15
16const PALETTE: Record<string, string> = {
17  o: '#e2724a', // body
18  d: '#b4512f', // legs
19  c: '#f4977a', // claws
20  w: '#ffffff',
21  k: '#1a1a1a',
22  R: '#dc2626',
23  B: '#3b82f6',
24  b: '#3b82f6',
25  g: '#9ca3af',
26  N: '#1e3a8a',
27  K: '#1f2937',
28  Y: '#facc15',
29  y: '#facc15',
30  h: '#94a3b8',
31  l: '#67e8f9',
32  T: '#b08968',
33}
34
35// The grey a failed crab turns.
36const GREY: Record<string, string> = { o: '#6b7280', d: '#4b5563', c: '#9ca3af' }
37
38type Rows = Record<number, string>
39
40const BODY: Rows = {
41  4: '..oooooooooooo..',
42  5: '.oowkooooowkooo.',
43  6: '.oowwooooowwooo.',
44  7: '.oooooooooooooo.',
45  8: '..oooooooooooo..',
46}
47
48const LEGS: Rows[] = [
49  { 9: '..d.d.d..d.d.d..', 10: '.d.d.d....d.d.d.' },
50  { 9: '.d.d.d....d.d.d.', 10: '..d.d.d..d.d.d..' },
51]
52
53const CLAW_LEFT_UP: Rows = { 1: 'c.c.............', 2: 'ccc.............', 3: '.c..............', 4: '.c..............' }
54const CLAW_RIGHT_UP: Rows = { 1: '.............c.c', 2: '.............ccc', 3: '..............c.', 4: '..............c.' }
55const CLAW_LEFT_DOWN: Rows = { 5: 'c.c.............', 6: 'ccc.............', 7: '.c..............' }
56const CLAW_RIGHT_DOWN: Rows = { 5: '.............c.c', 6: '.............ccc', 7: '..............c.' }
57
58const OUTFITS: Record<string, Rows> = {
59  plain: {},
60  beret: { 1: '........R.......', 2: '.....RRRRRR.....', 3: '....RRRRRRRR....' },
61  hoodie: { 2: '.....BBBBBB.....', 3: '....BBBBBBBB....', 7: '.....gggggg.....', 8: '.....gggggg.....' },
62  postman: { 2: '.....NNNNNN.....', 3: '...NNNNNNNNNN...', 7: '......wwww......', 8: '......wddw......' },
63  grad: { 2: '..KKKKKKKKKKKK..', 3: '.....KKKKKK.Y...', 4: '............Y...' },
64  headset: { 2: '.....hhhhhh.....', 3: '....h......h....', 4: '...h........h...', 5: '...h........h...' },
65  magnifier: {
66    2: '......TTTT......',
67    3: '.....TTTTTT.....',
68    6: '.......ll.......',
69    7: '......l..l......',
70    8: '......l..l......',
71    9: '.......ll.......',
72    10: '..........T.....',
73  },
74  megaphone: { 7: '...........yy...', 8: '...........yyy..', 9: '............yyy.' },
75  hardhat: { 1: '.......YY.......', 2: '.....YYYYYY.....', 3: '...YYYYYYYYYY...', 7: '..........bb....', 8: '..........bw....' },
76  explorer: { 1: '......TTTT......', 2: '.....TTTTTT.....', 3: '...TTTTTTTTTT...' },
77}
78
79// Agent name (lowercase) or agent type to outfit. Anything else gets a plain crab.
80const DEFAULT_OUTFITS: Record<string, string> = {
81  olivia: 'beret',
82  jack: 'hoodie',
83  achilles: 'postman',
84  dexter: 'grad',
85  erica: 'headset',
86  paul: 'magnifier',
87  joanne: 'megaphone',
88  simon: 'hardhat',
89  explore: 'explorer',
90  plan: 'hardhat',
91}
92
93// One crab as an SVG: claws alternate with the frame, both raised when it has finished.
94function crabSvg(outfit: string, frame: number, state: Crab['state']): string {
95  const grid: string[][] = Array.from({ length: H }, () => Array(W).fill('.'))
96  const paint = (rows: Rows) => {
97    for (const [y, row] of Object.entries(rows)) {
98      for (let x = 0; x < W; x++) if (row[x] && row[x] !== '.') grid[Number(y)][x] = row[x]
99    }
100  }
101  paint(BODY)
102  paint(LEGS[frame % 2])
103  if (state === 'done') {
104    paint(CLAW_LEFT_UP)
105    paint(CLAW_RIGHT_UP)
106  } else if (frame % 2 === 0) {
107    paint(CLAW_LEFT_UP)
108    paint(CLAW_RIGHT_DOWN)
109  } else {
110    paint(CLAW_LEFT_DOWN)
111    paint(CLAW_RIGHT_UP)
112  }
113  paint(OUTFITS[outfit] ?? OUTFITS.plain)
114
115  const colour = (ch: string) => (state === 'failed' && GREY[ch] ? GREY[ch] : PALETTE[ch])
116  let rects = ''
117  for (let y = 0; y < H; y++) {
118    let x = 0
119    while (x < W) {
120      const ch = grid[y][x]
121      if (ch === '.' || !PALETTE[ch]) {
122        x++
123        continue
124      }
125      let run = 1
126      while (x + run < W && grid[y][x + run] === ch) run++
127      rects += `<rect x="${x}" y="${y}" width="${run}" height="1" fill="${colour(ch)}"/>`
128      x += run
129    }
130  }
131  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W * SCALE}" height="${H * SCALE}" viewBox="0 0 ${W} ${H}" shape-rendering="crispEdges">${rects}</svg>`
132}
133
134const mmss = (ms: number) => {
135  const s = Math.max(0, Math.floor(ms / 1000))
136  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
137}
138
139// 'Olivia: rewrite welcome email' -> name Olivia, task 'rewrite welcome email'.
140// The brain's router puts the agent's name in front of every named dispatch.
141const NAMED = /^([A-Za-z][\w.-]{0,24}):\s+(.+)$/
142
143export const register: Register = (on, options) => {
144  const lingerMs = Math.max(1, Number(options.lingerSeconds ?? 6)) * 1000
145  const maxShown = Math.max(1, Number(options.maxShown ?? 4))
146
147  const outfits: Record<string, string> = { ...DEFAULT_OUTFITS }
148  for (const pair of String(options.outfits ?? '').split(',')) {
149    const [name, outfit] = pair.split('=').map(s => s.trim())
150    if (name && outfit) outfits[name.toLowerCase()] = outfit
151  }
152
153  const describe = (a: { id: string; description: string; type: string }) => {
154    const m = a.type === 'general-purpose' ? NAMED.exec(a.description) : null
155    const name = m ? m[1] : a.type === 'general-purpose' ? '' : a.type
156    const task = m ? m[2] : a.description
157    const outfit = outfits[name.toLowerCase()] ?? outfits[a.type.toLowerCase()] ?? 'plain'
158    return { id: a.id, name, outfit, task }
159  }
160
161  // Crabs that have already left. The engine keeps a finished agent in its list for a while,
162  // so without this a crab would come back each time it was cleared away.
163  const gone = new Set<string>()
164
165  on('session.start', async ($, e, next) => {
166    const poll = async () => {
167      try {
168        const list = await $.agent.list()
169        const now = await $.clock.now()
170        const prev = await read($, crabsAtom)
171        const out: Record<string, Crab> = {}
172        const seen = new Set<string>()
173        for (const a of list) {
174          if (a.type === 'teammate') continue
175          seen.add(a.id)
176          const state: Crab['state'] =
177            a.status === 'completed' ? 'done' : a.status === 'failed' || a.status === 'killed' ? 'failed' : 'running'
178          if (gone.has(a.id)) {
179            // Only an agent that runs again (resumed) brings its crab back.
180            if (state !== 'running') continue
181            gone.delete(a.id)
182          }
183          const old = prev[a.id]
184          const base: Crab = old ?? { ...describe(a), startedAt: now, state }
185          out[a.id] = { ...base, state, endedAt: state === 'running' ? undefined : (old?.endedAt ?? now) }
186        }
187        // An agent the engine has dropped from the list has finished.
188        for (const [id, c] of Object.entries(prev)) {
189          if (!seen.has(id)) out[id] = c.state === 'running' ? { ...c, state: 'done', endedAt: now } : c
190        }
191        for (const [id, c] of Object.entries(out)) {
192          if (c.endedAt !== undefined && now - c.endedAt > (c.state === 'failed' ? lingerMs * 3 : lingerMs)) {
193            delete out[id]
194            gone.add(id)
195          }
196        }
197        await update($, crabsAtom, p => (JSON.stringify(p) === JSON.stringify(out) ? p : out))
198        // Once a second while there are crabs, so claws move and the timers count.
199        if (Object.keys(out).length > 0) await update($, tickAtom, n => n + 1)
200      } catch {
201        // the agent list is not ready: keep what is on screen
202      }
203    }
204    $.clock.every(1000, poll)
205    return next(e)
206  })
207
208  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
209    const below = await next(e)
210    if (e.props.hasSurvey) return below
211    const crabs = Object.values(await read($, crabsAtom)).sort((a, b) => a.startedAt - b.startedAt)
212    const tick = await read($, tickAtom)
213    if (crabs.length === 0) return below
214
215    const now = await $.clock.now()
216    const { Box, Text, Svg } = $.ui.resolve(e)
217    const shown = crabs.slice(-maxShown)
218    const hidden = crabs.length - shown.length
219
220    const rows = shown.map(c => {
221      const label = c.name || 'agent'
222      const stamp = mmss((c.endedAt ?? now) - c.startedAt)
223      const status =
224        c.state === 'running' ? <Text dimColor>{stamp}</Text> : c.state === 'done' ? <Text color="#22c55e">✓ {stamp}</Text> : <Text color="#ef4444">✗ failed</Text>
225      return (
226        <Box paddingX={1} alignItems="center" gap={1}>
227          <Box flexShrink={0}>
228            <Svg source={crabSvg(c.outfit, tick, c.state)} alt={`${label} crab`} />
229          </Box>
230          <Box flexShrink={0}>
231            <Text bold color={ORANGE}>{label}</Text>
232          </Box>
233          <Box flexGrow={1} flexShrink={1} minWidth={0}>
234            <Text dimColor wrap="truncate-end">{c.task}</Text>
235          </Box>
236          <Box flexShrink={0}>{status}</Box>
237        </Box>
238      )
239    })
240
241    const zone = (
242      <Box key="agent-crabs" flexDirection="column">
243        {rows}
244        {hidden > 0 ? (
245          <Box paddingX={1}>
246            <Text dimColor>+{hidden} more</Text>
247          </Box>
248        ) : null}
249      </Box>
250    )
251    return below ? <Box flexDirection="column">{zone}{below}</Box> : zone
252  })
253}
254
types/index.d.ts 16 lines
1export type Crab = {
2  id: string
3  name: string
4  outfit: string
5  task: string
6  startedAt: number
7  endedAt?: number
8  state: 'running' | 'done' | 'failed'
9}
10
11declare module 'claude-code' {
12  interface PluginState {
13    'agent-crabs': { crabs: Record<string, Crab>; tick: number }
14  }
15}
16