/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

/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◆ in blue for a background agentThe 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.
claude plugin marketplace add Reasonofmoon/bitgame-mods
claude plugin install game-party@bitgame-mods
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칸 이상)에서 자동으로 열립니다.
hooks/register.tsx 133 lines1import { 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}
133hooks/palette.ts 124 lines1// 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}
124types/index.d.ts 26 lines1/** 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