SLOPSHOPPER

kaomoji-status

Dancing kaomoji above the prompt, one per harness worker: who is working, who is idle, who needs help.

newbandprocesstimer
A shopper browsing a rack in a slop shop
README

claude-code-mod

Mods for Claude Code: function-hook plugins, one per folder under mods/.

Mods

modwhat it does
kaomoji-statusOne row of dancing kaomoji above the prompt, one per harness worker.

Install

Type at the prompt of a terminal Claude Code session:

/plugin install kaomoji-status --marketplace geonhos/claude-code-mod

Answer y to add the marketplace, then pick a scope (user scope is first; press Enter).

kaomoji-status

Shows each harness (herdr) worker as a kaomoji in one row above the prompt:

analyst (。-ω-)zzZ  implementer ᕕ( ᐛ )ᕗ  reviewer (⊙_⊙)? 도와줘요!
  • idle: grey, slowly snoring (a done worker shows the same as idle); working: yellow, dancing; blocked: pink-red, asking for help.
  • Polls harness status every 2.5 s. If harness is not on Claude Code's PATH, set the absolute path in /config → kaomoji-status → harness path, then restart Claude Code (a config change reloads the mod, and its poll timer starts only at session start).
  • The row hides itself when harness is missing or no worker is live.

Local development

claude --plugin-dir mods/kaomoji-status        # hot-reloads on save
claude plugin test mods/kaomoji-status         # unit + wiring tests
claude plugin validate mods/kaomoji-status     # manifest, hooks, state contract
npx -y -p typescript@5.6.3 tsc -p mods/kaomoji-status

tsc needs the engine declarations in mods/<mod>/.claude-plugin/types/ (gitignored). An interactive claude --plugin-dir mods/<mod> session writes them. You can also copy plugin-authoring/types/claude-code.d.ts from the bundled skills folder to mods/<mod>/.claude-plugin/types/claude-code/index.d.ts.

Add a new mod as mods/<name>/ and give it one entry in .claude-plugin/marketplace.json.

License

MIT, see LICENSE.

Source 3 files
hooks/register.tsx 80 lines
1import { atom, read, update } from 'claude-code'
2import type { Register, Timer } from 'claude-code'
3
4import type { KaomojiWorker } from '../types'
5import { buildRow, isActive, parseStatus } from './faces'
6
7const POLL_MS = 2500
8const FRAME_MS = 250
9const RUN_TIMEOUT_MS = 2000
10
11const workers = atom({ plugin: 'kaomoji-status', key: 'workers' } as const, [] as KaomojiWorker[])
12const frame = atom({ plugin: 'kaomoji-status', key: 'frame' } as const, 0)
13
14export const register: Register = (on, options) => {
15  const configured = typeof options.harnessPath === 'string' ? options.harnessPath.trim() : ''
16  const harness = configured === '' ? 'harness' : configured
17  let isPolling = false
18  let ticker: Timer | undefined
19
20  on('session.start', async ($, e, next) => {
21    const poll = async (): Promise<void> => {
22      if (isPolling) return
23      isPolling = true
24      try {
25        let list: KaomojiWorker[] = []
26        try {
27          const ran = await $.process.run([harness, 'status'], { timeoutMs: RUN_TIMEOUT_MS })
28          list = ran.exitCode === 0 ? parseStatus(ran.stdout) : []
29        } catch {
30          list = []
31        }
32        await update($, workers, () => list)
33        if (isActive(list)) {
34          ticker ??= $.clock.every(FRAME_MS, () => {
35            void update($, frame, n => n + 1).catch(() => {})
36          })
37        } else {
38          ticker?.cancel()
39          ticker = undefined
40        }
41      } catch {
42        // poll never rejects: a failed state write just waits for the next tick
43      } finally {
44        isPolling = false
45      }
46    }
47
48    void poll()
49    $.clock.every(POLL_MS, () => {
50      void poll()
51    })
52
53    return next(e)
54  })
55
56  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
57    if (e.props.hasSurvey) return next(e)
58    const list = await read($, workers)
59    if (list.length === 0) return next(e)
60    const segments = buildRow(list, await read($, frame), e.props.bodyColumns)
61    const { Box, Text } = $.ui.resolve(e)
62
63    return (
64      <Box key="kaomoji-status">
65        <Text wrap="truncate-end">
66          {segments.map(segment =>
67            segment.color === undefined ? (
68              segment.text
69            ) : (
70              <Text color={segment.color} bold={segment.bold === true}>
71                {segment.text}
72              </Text>
73            ),
74          )}
75        </Text>
76      </Box>
77    )
78  })
79}
80
hooks/faces.ts 134 lines
1import type { KaomojiWorker } from '../types'
2
3export type Worker = KaomojiWorker
4
5export type Face = { text: string; color: string; bold?: boolean }
6
7export type Segment = { text: string; color?: string; bold?: boolean }
8
9const ZERO_WIDTH: ReadonlyArray<readonly [number, number]> = [
10  [0x0300, 0x036f],
11  [0x1ab0, 0x1aff],
12  [0x200b, 0x200f],
13  [0x20d0, 0x20ff],
14  [0xfe00, 0xfe0f],
15  [0xfe20, 0xfe2f],
16]
17
18const WIDE: ReadonlyArray<readonly [number, number]> = [
19  [0x1100, 0x115f],
20  [0x2e80, 0x303e],
21  [0x3041, 0x33ff],
22  [0x3400, 0x4dbf],
23  [0x4e00, 0x9fff],
24  [0xa000, 0xa4cf],
25  [0xac00, 0xd7a3],
26  [0xf900, 0xfaff],
27  [0xfe30, 0xfe4f],
28  [0xff00, 0xff60],
29  [0xffe0, 0xffe6],
30  [0x1f300, 0x1f64f],
31  [0x1f900, 0x1f9ff],
32]
33
34const inRanges = (code: number, ranges: ReadonlyArray<readonly [number, number]>): boolean =>
35  ranges.some(([lo, hi]) => code >= lo && code <= hi)
36
37export function cellWidth(text: string): number {
38  let width = 0
39  for (const char of text) {
40    const code = char.codePointAt(0) ?? 0
41    if (inRanges(code, ZERO_WIDTH)) continue
42    width += inRanges(code, WIDE) ? 2 : 1
43  }
44  return width
45}
46
47const NAME = /^[\w.-]+$/
48const STATE = /^[\w-]+$/
49
50export function parseStatus(stdout: string): Worker[] {
51  const workers: Worker[] = []
52  for (const line of stdout.split('\n')) {
53    const fields = line.trim().split(/\s+/)
54    if (fields.length !== 3) continue
55    const [name = '', state = ''] = fields
56    if (!NAME.test(name) || !STATE.test(state)) continue
57    workers.push({ name, state })
58  }
59  return workers
60}
61
62const IDLE_FRAMES = ['(。-ω-)zzZ', '(。-ω-)zZ'] as const
63const WORKING_FRAMES = ['(ง •̀_•́)ง', 'ᕕ( ᐛ )ᕗ', '└( ・ω・)┐', '┌(・ω・ )┘'] as const
64const BLOCKED_FRAMES = ['(⊙_⊙)? 도와줘요!', '(⊙_⊙)! 도와줘요!'] as const
65
66const GREY = 'gray'
67const YELLOW = 'yellow'
68const PINK_RED = '#ff5f87'
69
70const isResting = (state: string): boolean => state === 'idle' || state === 'done'
71
72function framesFor(state: string): readonly string[] {
73  if (isResting(state)) return IDLE_FRAMES
74  if (state === 'working') return WORKING_FRAMES
75  if (state === 'blocked') return BLOCKED_FRAMES
76  return [`(・_・) ${state}`]
77}
78
79export function faceFor(state: string, frame: number): Face {
80  const tick = Math.max(0, Math.floor(frame))
81  if (state === 'working') {
82    return { text: WORKING_FRAMES[tick % 4] ?? WORKING_FRAMES[0], color: YELLOW }
83  }
84  if (state === 'blocked') {
85    return { text: BLOCKED_FRAMES[Math.floor(tick / 2) % 2] ?? BLOCKED_FRAMES[0], color: PINK_RED, bold: true }
86  }
87  if (isResting(state)) {
88    return { text: IDLE_FRAMES[Math.floor(tick / 4) % 2] ?? IDLE_FRAMES[0], color: GREY }
89  }
90  return { text: `(・_・) ${state}`, color: GREY }
91}
92
93export function isActive(workers: readonly Worker[]): boolean {
94  return workers.some(worker => worker.state === 'working' || worker.state === 'blocked')
95}
96
97type Labels = 'full' | 'initial' | 'none'
98
99function labelFor(name: string, labels: Labels): string {
100  if (labels === 'full') return name
101  if (labels === 'initial') return name.slice(0, 1).toUpperCase()
102  return ''
103}
104
105function widestFace(state: string): number {
106  return Math.max(...framesFor(state).map(cellWidth))
107}
108
109function rowWidth(workers: readonly Worker[], labels: Labels): number {
110  let width = 0
111  workers.forEach((worker, index) => {
112    if (index > 0) width += 2
113    const label = labelFor(worker.name, labels)
114    if (label !== '') width += cellWidth(label) + 1
115    width += widestFace(worker.state)
116  })
117  return width
118}
119
120export function buildRow(workers: readonly Worker[], frame: number, columns: number): Segment[] {
121  const labels: Labels =
122    rowWidth(workers, 'full') <= columns ? 'full' : rowWidth(workers, 'initial') <= columns ? 'initial' : 'none'
123  const segments: Segment[] = []
124  workers.forEach((worker, index) => {
125    if (index > 0) segments.push({ text: '  ' })
126    const label = labelFor(worker.name, labels)
127    if (label !== '') segments.push({ text: `${label} ` })
128    const face = faceFor(worker.state, frame)
129    const text = face.text + ' '.repeat(Math.max(0, widestFace(worker.state) - cellWidth(face.text)))
130    segments.push(face.bold === true ? { text, color: face.color, bold: true } : { text, color: face.color })
131  })
132  return segments
133}
134
types/index.d.ts 8 lines
1export type KaomojiWorker = { name: string; state: string }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'kaomoji-status': { workers: KaomojiWorker[]; frame: number }
6  }
7}
8