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

Mods for Claude Code: function-hook plugins, one per folder under mods/.
| mod | what it does |
|---|---|
| kaomoji-status | One row of dancing kaomoji above the prompt, one per harness worker. |
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).
Shows each harness (herdr) worker as a kaomoji in one row above the prompt:
analyst (。-ω-)zzZ implementer ᕕ( ᐛ )ᕗ reviewer (⊙_⊙)? 도와줘요!
done worker shows the same as idle); working: yellow, dancing; blocked: pink-red, asking for help.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).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.
MIT, see LICENSE.
hooks/register.tsx 80 lines1import { 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}
80hooks/faces.ts 134 lines1import 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}
134types/index.d.ts 8 lines1export type KaomojiWorker = { name: string; state: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'kaomoji-status': { workers: KaomojiWorker[]; frame: number }
6 }
7}
8