SLOPSHOPPER

GAME MODE · Party

/party: each subagent of the session with ACTIVE, CLEAR or FAIL, a bar of its tool calls and its job; opens by itself when one starts on a wide terminal

newpaneguardcommandagents
★ 2v0.2.0MITupdated 2026-10-06Reasonofmoon/bitgame-mods/plugins/game-party
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · game-party
│ ┃ PARTY ✕ › fix the failing auth test and add an audit log call │ ┃ PARTY 0명 · 진행 0 │ ┃ 아직 소환한 동료가 없습니다. Claude가 ⏺ Read(src/auth.ts) │ ┃ subagent를 보내면 여기 나타납니다. ⎿ 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 │ │ › /party │ ⎿ game-party: PARTY · 아직 소환한 동료가 없습니다 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · PARTY
PARTY 0명 · 진행 0 아직 소환한 동료가 없습니다. Claude가 subagent를 보내면 여기 나타납니다.
README

GAME MODE · Party

/party shows the session's subagents as a party:

PARTY  3명 · 진행 1
◆ Explore       ACTIVE  ████░░░░░░  8  로그인 흐름 조사
◆ reviewer      CLEAR   ██████████ 21  보안 검토
◆ test-runner   FAIL    ██░░░░░░░░  4  테스트 고치기
  • ACTIVE while the subagent runs, CLEAR when it handed its report back, FAIL when its run or its Agent call failed
  • the bar is its tool calls against the busiest member's; ◆ in blue for a background agent
  • the job is the description the Agent call gave

The pane opens by itself when the session's first subagent starts, on a terminal 144 columns wide or more (narrower, it waits; /party opens it at any width). In fullscreen it docks beside the transcript.

Part of the GAME MODE pack. Requires Claude Code 2.1.287+; built and tested on 2.1.291.

Install

claude plugin marketplace add Reasonofmoon/bitgame-mods
claude plugin install game-party@bitgame-mods

Settings

intensity (casual)off · casual · hardcore (adds failed calls ✗n and run time)
palette (nes)nes · gameboy · amber · theme
autoOpen (true)open the pane when the first subagent starts

한국어

이번 세션의 서브에이전트를 파티로 보여줍니다(진행 중·완료·실패, 도구 호출 막대, 맡은 일). 첫 서브에이전트가 시작되면 넓은 터미널(144칸 이상)에서 자동으로 열립니다.

Source 3 files
hooks/register.tsx 133 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Member } from '../types'
5import { bar, clock, intensityOf, paletteOf } from './palette'
6
7// GAME MODE · PARTY
8//
9// /party shows the session's subagents as a party:
10//   PARTY · 3명 · 진행 1
11//   ◆ Explore       ACTIVE  ████░░░░░░  8  로그인 흐름 조사
12//   ◆ code-review   CLEAR   ██████████ 21  보안 검토
13//   ◆ test-runner   FAIL    ██░░░░░░░░  4  테스트 고치기
14// The bar is each member's tool calls against the busiest member's. The pane opens by
15// itself when the session's first subagent starts, on a terminal 144 columns wide or more.
16
17const PANE = 'party'
18const members = atom({ plugin: 'game-party', key: 'members' } as const, [] as Member[])
19
20export const register: Register = (on, options) => {
21  const intensity = intensityOf(options.intensity)
22  const pal = paletteOf(options.palette)
23  const autoOpen = options.autoOpen !== false
24
25  on('session.start', async ($, e, next) => {
26    await $.command.register({ name: 'party', description: 'GAME MODE party: the subagents of this session' })
27    return next(e)
28  })
29
30  on('agent.spawn', async ($, e, next) => {
31    const started = await next(e)
32    if (started.agentId === undefined) return started
33    const member: Member = {
34      id: started.agentId,
35      toolUseId: e.tool_use_id,
36      name: e.name ?? e.subagentType,
37      job: e.description,
38      status: 'active',
39      calls: 0,
40      fails: 0,
41      startedAt: await $.clock.now(),
42      durationMs: null,
43      isBackground: e.background,
44    }
45    const before = await read($, members)
46    await update($, members, list => [...list, member].slice(-30))
47    if (autoOpen && intensity !== 'off' && before.length === 0) void $.ui.open({ id: PANE, title: 'PARTY' }).catch(() => undefined)
48    return started
49  }).catch(($, e, next) => next(e))
50
51  on('tool.call', async ($, e, next) => {
52    const ran = await next(e)
53    const failed = ran.deny !== undefined || ran.isError === true
54    const agentId = e.agentId
55    if (agentId !== undefined) {
56      await update($, members, list => list.map(m => (m.id === agentId ? { ...m, calls: m.calls + 1, fails: m.fails + (failed ? 1 : 0) } : m)))
57    } else if (String(e.tool) === 'Agent' && failed) {
58      // The call that started a member failed: the member failed with it.
59      const now = await $.clock.now()
60      await update($, members, list =>
61        list.map(m => (m.toolUseId === e.tool_use_id && m.status === 'active' ? { ...m, status: 'fail', durationMs: now - m.startedAt } : m)),
62      )
63    }
64    return ran
65  }).catch(($, e, next) => next(e))
66
67  // A member's loop ends: it handed its report back, or it failed.
68  on('turn.complete', async ($, e, next) => {
69    const agentId = e.agentId
70    if (agentId !== undefined) {
71      const status = e.reason === 'answer' ? 'clear' : 'fail'
72      await update($, members, list =>
73        list.map(m => (m.id === agentId && m.status === 'active' ? { ...m, status, durationMs: e.durationMs } : m)),
74      )
75    }
76    return next(e)
77  }).catch(($, e, next) => next(e))
78
79  on('command.run', { command: 'party' }, async ($) => {
80    if (intensity !== 'off') await $.ui.open({ id: PANE, title: 'PARTY' })
81    return { text: partyText(await read($, members)) }
82  })
83
84  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
85    const { Box, Text } = $.ui.resolve(e)
86    const list = await read($, members)
87    const active = list.filter(m => m.status === 'active').length
88    const most = Math.max(10, ...list.map(m => m.calls))
89    const nameWidth = Math.min(16, Math.max(6, ...list.map(m => m.name.length)))
90    return (
91      <Box flexDirection="column">
92        <Box flexDirection="row" gap={2}>
93          <Text color={pal.title} bold>
94            PARTY
95          </Text>
96          <Text color={pal.text}>{`${list.length}명 · 진행 ${active}`}</Text>
97        </Box>
98        {list.length === 0 && <Text color={pal.dim}>아직 소환한 동료가 없습니다. Claude가 subagent를 보내면 여기 나타납니다.</Text>}
99        {list.map(m => (
100          <Box key={m.id} flexDirection="row" gap={1}>
101            <Text color={m.isBackground ? pal.info : pal.title}>◆</Text>
102            <Text color={pal.text} bold>
103              {m.name.length > nameWidth ? m.name.slice(0, nameWidth - 1) + '…' : m.name.padEnd(nameWidth)}
104            </Text>
105            <Text color={m.status === 'active' ? pal.info : m.status === 'clear' ? pal.ok : pal.bad} bold>
106              {STATUS[m.status]}
107            </Text>
108            <Text color={pal.ok}>{bar((m.calls / most) * 100, 10)}</Text>
109            <Text color={pal.text}>{String(m.calls).padStart(3)}</Text>
110            {intensity === 'hardcore' && <Text color={m.fails > 0 ? pal.bad : pal.dim}>{`✗${m.fails}`}</Text>}
111            {intensity === 'hardcore' && m.durationMs !== null && <Text color={pal.dim}>{clock(m.durationMs)}</Text>}
112            <Box flexGrow={1} flexShrink={1}>
113              <Text color={pal.dim} wrap="truncate-end">
114                {m.job}
115              </Text>
116            </Box>
117          </Box>
118        ))}
119      </Box>
120    )
121  })
122}
123
124const STATUS: Record<Member['status'], string> = { active: 'ACTIVE', clear: 'CLEAR ', fail: 'FAIL  ' }
125
126export function partyText(list: readonly Member[]): string {
127  if (list.length === 0) return 'PARTY · 아직 소환한 동료가 없습니다'
128  return [
129    `PARTY · ${list.length}명 · 진행 ${list.filter(m => m.status === 'active').length}`,
130    ...list.map(m => `◆ ${m.name} ${STATUS[m.status].trim()} · 행동 ${m.calls} · ${m.job}`),
131  ].join('\n')
132}
133
hooks/palette.ts 124 lines
1// GAME MODE shared look (copied into each GAME MODE plugin: a hooks module
2// may only import files of its own plugin). Change every copy together;
3// scripts/check.sh fails when the copies differ.
4//
5// nes / gameboy / amber are raw colors made for dark terminals, taken from
6// the design boards. `theme` uses Claude Code's theme keys, so it follows the
7// person's theme: pick it on a light background. Its `win` is empty: windows
8// draw with no fill there.
9
10export type PaletteName = 'nes' | 'gameboy' | 'amber' | 'theme'
11
12export type Palette = {
13  /** Headings, gold, the level. */
14  title: string
15  ok: string
16  warn: string
17  bad: string
18  info: string
19  dim: string
20  text: string
21  /** A window's border. */
22  frame: string
23  /** A window's fill; '' for none. */
24  win: string
25  /** The bright inner edge of a window, and a highlighted badge's text. */
26  edge: string
27  /** A bar's empty cells. */
28  off: string
29}
30
31const PALETTES: Record<PaletteName, Palette> = {
32  nes: {
33    title: '#f8d830',
34    ok: '#58d854',
35    warn: '#ff8a6a',
36    bad: '#f83800',
37    info: '#3cbcfc',
38    dim: '#b8b8f0',
39    text: '#fcfcfc',
40    frame: '#5060d8',
41    win: '#1c2a8a',
42    edge: '#fcfcfc',
43    off: '#2c3a98',
44  },
45  gameboy: {
46    title: '#e0f8d0',
47    ok: '#9bbc0f',
48    warn: '#e0f8d0',
49    bad: '#f8f8a0',
50    info: '#c4e890',
51    dim: '#8bac0f',
52    text: '#e0f8d0',
53    frame: '#9bbc0f',
54    win: '#1e4a1e',
55    edge: '#e0f8d0',
56    off: '#306230',
57  },
58  amber: {
59    title: '#ffd060',
60    ok: '#ffb000',
61    warn: '#ff7040',
62    bad: '#ff5a2a',
63    info: '#ffe0a0',
64    dim: '#c89040',
65    text: '#ffd9a0',
66    frame: '#ffb000',
67    win: '#2a1700',
68    edge: '#ffe0a0',
69    off: '#4a2a00',
70  },
71  theme: {
72    title: 'claude',
73    ok: 'success',
74    warn: 'warning',
75    bad: 'error',
76    info: 'suggestion',
77    dim: 'subtle',
78    text: 'text',
79    frame: 'promptBorder',
80    win: '',
81    edge: 'text',
82    off: 'subtle',
83  },
84}
85
86export function paletteOf(name: unknown): Palette {
87  return name === 'gameboy' || name === 'amber' || name === 'theme' ? PALETTES[name] : PALETTES.nes
88}
89
90export type Intensity = 'off' | 'casual' | 'hardcore'
91
92export function intensityOf(value: unknown): Intensity {
93  return value === 'off' || value === 'hardcore' ? value : 'casual'
94}
95
96/** A bar of `cells` blocks, `percent` of them full. */
97export function bar(percent: number, cells: number): string {
98  const full = Math.max(0, Math.min(cells, Math.round((percent / 100) * cells)))
99  return '█'.repeat(full) + '░'.repeat(cells - full)
100}
101
102/** Box props for a window in this palette: a double border and, where the palette has one, a fill. */
103export function windowProps(pal: Palette): { borderStyle: 'double'; borderColor: string; backgroundColor?: string; paddingX: number } {
104  return pal.win === ''
105    ? { borderStyle: 'double', borderColor: pal.frame, paddingX: 1 }
106    : { borderStyle: 'double', borderColor: pal.frame, backgroundColor: pal.win, paddingX: 1 }
107}
108
109/** `m:ss`, or `h:mm:ss` past an hour. */
110export function clock(ms: number): string {
111  const s = Math.max(0, Math.round(ms / 1000))
112  const h = Math.floor(s / 3600)
113  const m = Math.floor((s % 3600) / 60)
114  const sec = s % 60
115  const two = (n: number) => String(n).padStart(2, '0')
116  return h > 0 ? `${h}:${two(m)}:${two(sec)}` : `${m}:${two(sec)}`
117}
118
119/** A cost in the chosen currency: `$1.23`, or `₩1,722` at `krwPerUsd`. */
120export function money(usd: number, currency: 'usd' | 'krw', krwPerUsd: number): string {
121  if (currency === 'krw') return '₩' + String(Math.round(usd * krwPerUsd)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
122  return '$' + usd.toFixed(2)
123}
124
types/index.d.ts 26 lines
1/** One subagent of the session, as the party pane shows it. */
2export type Member = {
3  /** The agent's id: its loop's `agentId` on tool.call and turn.complete. */
4  id: string
5  /** The Agent tool call that started it. */
6  toolUseId: string
7  /** Its name, or its agent type when it has none. */
8  name: string
9  /** What it was sent to do (the call's description). */
10  job: string
11  status: 'active' | 'clear' | 'fail'
12  /** Its tool calls, and how many of them failed. */
13  calls: number
14  fails: number
15  /** When it started, and how long it ran once it ended (ms). */
16  startedAt: number
17  durationMs: number | null
18  isBackground: boolean
19}
20
21declare module 'claude-code' {
22  interface PluginState {
23    'game-party': { members: Member[] }
24  }
25}
26