SLOPSHOPPER

pacman-band

Pac-Man runs above the prompt while Claude works, a ghost for each sub-agent still out, eaten as it answers

newbandtimeragents
v0.1.0no licenseupdated 2026-10-15Wenrong274/pacman-band
A shopper browsing a rack in a slop shop
README

pacman-band

Claude 工作時,prompt 正上方跑一行 Pac-Man:每個還沒回的 sub-agent 一隻鬼(紅、紫、青、橘,最多 4 隻),回完一個就有一隻變藍、被吃掉;Claude 停下來、鬼也吃完就收起來。

󰮯 · · · 󰊠 · · 󰊠 · · · · · · · · · · · · · · · · · · · · · · · · · · · · ·

先裝字型

一定要用 Nerd Font,否則小精靈和鬼會變成方塊:小精靈是 md-pac_man(U+F0BAF)、鬼是 U+F02A0。終端字型換成任一套 Nerd Font(例如 Maple Mono NF CN、Sarasa Term TC Nerd、FiraCode Nerd Font)。

安裝

需要 Claude Code 2.1.275 以上。

/plugin install pacman-band --marketplace Wenrong274/pacman-band

裝完開新 session 才會生效。

只想試一次:claude --plugin-dir <這個資料夾>。

跟 workbench 的關係

workbench 的 band 本身就有這條跑道,已裝 workbench 的人不用裝這個;兩個都裝會看到兩隻小精靈。

配色

深色主題用 Catppuccin Mocha,/config 的 theme 以 light 開頭時用 Latte。

還沒做

  • 背景跑的 AI CLI(codex、gemini…)不算鬼,只算 sub-agent。
  • 跟其他也畫在 prompt 上方的 plugin 一起裝時,這一行在上或在下由引擎的載入順序決定。

開發

改動要四項全綠:

claude plugin test .
claude plugin validate .
npx -y -p typescript@5 tsc -p . --noEmit
npx -y prettier@3.9.9 --check .

tsc 需要 .claude-plugin/types/:用 claude --plugin-dir . 載入一次就會產生(不進 git)。

Source 4 files
hooks/register.tsx 153 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { LiveStatus } from '../types'
5import { PacmanLane } from './logic/pacman-lane'
6import type { LaneCell } from './logic/pacman-lane'
7import { Palette } from './logic/palette'
8
9const isWorking = atom({ plugin: 'pacman-band', key: 'isWorking' } as const, false)
10const liveAgents = atom({ plugin: 'pacman-band', key: 'liveAgents' } as const, 0)
11const frame = atom({ plugin: 'pacman-band', key: 'frame' } as const, 0)
12const isLightTheme = atom({ plugin: 'pacman-band', key: 'isLightTheme' } as const, false)
13
14type $ = EngineInterface
15
16// Pac-Man steps a column each tick; the timer runs only while something is busy.
17const FRAME_MS = 125
18// How long an answered agent's ghost stays frightened at his mouth.
19const EAT_MS = 1_500
20const LIVE_STATUSES: readonly string[] = ['pending', 'running', 'waiting'] satisfies LiveStatus[]
21const RESET_REASONS = ['clear', 'resume']
22
23const lane = new PacmanLane()
24let timer: Timer | undefined
25let eatUntil = 0
26// Agents that answered: left out of the count even while the list still shows them running.
27const answered = new Set<string>()
28
29export const register: Register = on => {
30  on('session.start', async ($, e, next) => {
31    void refreshTheme($).catch(ignoreFailure)
32
33    return next(e)
34  })
35
36  on('config.set', { key: 'theme' }, async ($, e, next) => {
37    const set = await next(e)
38    await refreshTheme($).catch(ignoreFailure)
39
40    return set
41  })
42
43  on('session.end', async ($, e, next) => {
44    if (RESET_REASONS.includes(e.reason)) {
45      await update($, isWorking, () => false)
46      await update($, liveAgents, () => 0)
47      eatUntil = 0
48      answered.clear()
49      await ensureTimer($)
50    }
51
52    return next(e)
53  })
54
55  // turn.start carries the person's prompt: the main agent's turns only.
56  on('turn.start', async ($, e, next) => {
57    await update($, isWorking, () => true)
58    await ensureTimer($)
59
60    return next(e)
61  })
62
63  on('agent.spawn', async ($, e, next) => {
64    const spawned = await next(e)
65    await refreshAgents($).catch(ignoreFailure)
66
67    return spawned
68  })
69
70  on('turn.complete', async ($, e, next) => {
71    const completed = await next(e)
72    if (e.agentId === undefined) {
73      await update($, isWorking, () => false)
74    } else {
75      // Its ghost, eaten.
76      eatUntil = (await $.clock.now()) + EAT_MS
77      answered.add(e.agentId)
78      await refreshAgents($).catch(ignoreFailure)
79    }
80    await ensureTimer($)
81
82    return completed
83  })
84
85  // A row of its own above whatever the other plugins draw, so it sits beside any band.
86  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
87    if (e.props.hasSurvey || !(await isBusy($))) return next(e)
88    const { Box, Text } = $.ui.resolve(e)
89    const colors = new Palette(await read($, isLightTheme))
90    // A column spare: an icon the font draws two wide cannot wrap the row.
91    const cells = lane.cells(
92      Math.max(0, e.props.bodyColumns - 1),
93      await read($, frame),
94      await read($, liveAgents),
95      (await $.clock.now()) < eatUntil,
96    )
97    const below = await next(e)
98
99    return (
100      <Box flexDirection="column">
101        <Box>
102          {cells.map(cell => (
103            <Text bold={cell.kind !== 'dot' || undefined} color={laneColor(colors, cell)}>
104              {cell.text}
105            </Text>
106          ))}
107        </Box>
108        {below}
109      </Box>
110    )
111  })
112}
113
114async function isBusy($: $): Promise<boolean> {
115  return (await read($, isWorking)) || (await read($, liveAgents)) > 0 || (await $.clock.now()) < eatUntil
116}
117
118async function refreshAgents($: $) {
119  const live = (await $.agent.list()).filter(agent => LIVE_STATUSES.includes(agent.status) && !answered.has(agent.id)).length
120  await update($, liveAgents, () => live)
121  await ensureTimer($)
122}
123
124async function ensureTimer($: $) {
125  const busy = await isBusy($)
126  if (busy && timer === undefined) {
127    timer = $.clock.every(FRAME_MS, () => void tick($).catch(ignoreFailure))
128  } else if (!busy && timer !== undefined) {
129    timer.cancel()
130    timer = undefined
131  }
132}
133
134// Each tick asks afresh whether anything is still busy, so the timer stops by itself.
135async function tick($: $) {
136  await update($, frame, n => n + 1)
137  await ensureTimer($)
138}
139
140async function refreshTheme($: $) {
141  const theme = (await $.config.list()).find(row => row.key === 'theme')?.value
142  const isLight = typeof theme === 'string' && theme.startsWith('light')
143  if ((await read($, isLightTheme)) !== isLight) await update($, isLightTheme, () => isLight)
144}
145
146function laneColor(colors: Palette, cell: LaneCell): string {
147  if (cell.kind === 'pac') return colors.color('pac')
148  if (cell.kind === 'ghost') return colors.ghost(cell.index ?? 0)
149  return colors.color(cell.kind === 'scared' ? 'scared' : 'dim')
150}
151
152function ignoreFailure() {}
153
hooks/logic/pacman-lane.ts 36 lines
1// Swap these for what the terminal's font draws one column wide (the probe shows which).
2// Material Design's pac_man from the Nerd Font: ᗧ fell back to a font that drew it as ɑ.
3export const PAC_OPEN = '\u{f0baf}'
4export const PAC_SHUT = '●'
5export const GHOST = '\u{f02a0}'
6const DOT = '·'
7
8export type LaneCell = { text: string; kind: 'pac' | 'ghost' | 'scared' | 'dot' | 'empty'; index?: number }
9
10const MAX_GHOSTS = 4
11const GHOST_GAP = 3
12const DOT_EVERY = 2
13
14// The band while Claude works: Pac-Man crossing left to right a column a frame, eating the
15// dots before him, a ghost for each agent or job still out running ahead of him.
16export class PacmanLane {
17  // A cell a column. isEating puts a frightened ghost at his mouth, the others a column on.
18  cells(columns: number, frame: number, ghosts: number, isEating: boolean): LaneCell[] {
19    if (columns <= 0) return []
20    const at = frame % columns
21    // The dots lie on fixed columns, so they wait for him rather than move with him.
22    const cells: LaneCell[] = Array.from({ length: columns }, (_, column) =>
23      column > at && column % DOT_EVERY === 0 ? { text: DOT, kind: 'dot' } : { text: ' ', kind: 'empty' },
24    )
25    const place = (offset: number, cell: LaneCell) => {
26      cells[(at + offset) % columns] = cell
27    }
28    const shift = isEating ? 1 : 0
29    for (let i = 0; i < Math.min(ghosts, MAX_GHOSTS); i++) place(GHOST_GAP * (i + 1) + shift, { text: GHOST, kind: 'ghost', index: i })
30    if (isEating) place(1, { text: GHOST, kind: 'scared', index: 0 })
31    place(0, { text: frame % 2 === 0 ? PAC_OPEN : PAC_SHUT, kind: 'pac' })
32
33    return cells
34  }
35}
36
hooks/logic/palette.ts 28 lines
1export type Role = 'pac' | 'scared' | 'dim'
2
3// Catppuccin: Mocha on a dark theme, Latte on a light one.
4const MOCHA: Record<Role, string> = { pac: '#f9e2af', scared: '#89b4fa', dim: '#6c7086' }
5const LATTE: Record<Role, string> = { pac: '#df8e1d', scared: '#1e66f5', dim: '#9ca0b0' }
6// Blinky, Pinky, Inky and Clyde; Pinky in mauve, as workbench keeps pink for whose turn it is.
7const MOCHA_GHOSTS = ['#f38ba8', '#cba6f7', '#94e2d5', '#fab387']
8const LATTE_GHOSTS = ['#d20f39', '#8839ef', '#179299', '#fe640b']
9
10export class Palette {
11  private readonly colors: Record<Role, string>
12  private readonly ghosts: readonly string[]
13
14  constructor(isLight: boolean) {
15    this.colors = isLight ? LATTE : MOCHA
16    this.ghosts = isLight ? LATTE_GHOSTS : MOCHA_GHOSTS
17  }
18
19  color(role: Role): string {
20    return this.colors[role]
21  }
22
23  // The ghosts in turn, round again past the fourth.
24  ghost(index: number): string {
25    return this.ghosts[index % this.ghosts.length] ?? this.colors.pac
26  }
27}
28
types/index.d.ts 17 lines
1// A sub-agent's list status that still counts as out: a ghost.
2export type LiveStatus = 'pending' | 'running' | 'waiting'
3
4declare module 'claude-code' {
5  interface PluginState {
6    'pacman-band': {
7      // A main turn is running.
8      isWorking: boolean
9      // Sub-agents not yet answered: a ghost each.
10      liveAgents: number
11      frame: number
12      // The /config theme is a light one: Latte, not Mocha.
13      isLightTheme: boolean
14    }
15  }
16}
17