서브에이전트의 상태·경과 시간·도구 호출 수·토큰을 사이드 패널에 실시간으로 보여준다

새 백엔드 프로젝트를 시작할 때 가져다 쓰는 Claude Code 기본 세팅입니다.
에이전트, 스킬, 커맨드를 이 저장소에서 만들고 검증한 뒤, install.sh로 전역(~/.claude)이나 프로젝트(.claude/)에 심링크를 걸어 씁니다. 다루는 범위는 백엔드(REST API, 서비스, 영속성)이고, 특정 프로젝트나 스택에 묶이지 않게 작성합니다.
my-harness/
├── agents/ # 서브에이전트
├── skills/ # 스택별 구현 패턴
├── commands/ # 슬래시 커맨드
├── rules/ # 규칙 (경로별 지침)
├── hooks/ # 훅 (폴더 하나 = 훅 하나, install.sh가 settings에 병합)
├── mods/ # Claude Code 모드 (폴더 하나 = 모드 하나, install.sh가 전역 settings에 등록)
├── plugins/ # 외부 플러그인 목록 (파일 하나 = 플러그인 하나, install.sh가 claude plugin install로 설치)
├── prompts/ # 복사해 쓰는 프롬프트 (설치 대상 아님)
├── docs/ # 플러그인 사용법, guide/ AI 사용 가이드 (설치 대상 아님)
├── playground/ # 방식별 비교 실험 (설치 대상 아님)
├── install.sh
└── CLAUDE.md
기능 개발 — feature-pm이 나머지 넷을 순서대로 부립니다.
| 이름 | 하는 일 |
|---|---|
feature-pm | 요구사항 분해, 스택 확정, 워커 조율 (메인 세션에서 직접 호출) |
backend-designer | API와 DB 스키마를 함께 설계 |
backend-impl | 설계를 코드로 구현 |
boundary-verifier | 설계와 구현이 서로 맞는지 검증 |
test-writer | 단위·통합 테스트 작성 |
진단 — 코드를 직접 고치지 않고 원인과 개선안만 알려 줍니다.
| 이름 | 하는 일 |
|---|---|
architecture-expert | 구조·의존성 진단, 기술 선택 비교 |
code-reviewer | 가독성, 네이밍, 에러 처리, 중복 리뷰 |
debugger | 버그 재현, 근본 원인 특정 |
performance-optimizer | 병목, N+1, 복잡도 진단 |
security-auditor | 인증·인가, 인젝션 등 보안 점검 |
| 이름 | 하는 일 |
|---|---|
spring-boot | Spring Boot 3.x 구현 패턴 |
nestjs | NestJS 구현 패턴 |
screen-spec-analyzer | 화면 캡처나 화면정의서에서 API 응답 필드 추출 |
| 이름 | 하는 일 |
|---|---|
/commit | 변경을 논리 단위로 나눠 커밋 |
/push | 보호 브랜치와 강제 푸시를 확인하고 푸시 |
/pre-pr | PR 올리기 전 코드 리뷰 (테스트·린트·진단 후 BLOCK/READY 판정) |
/post-pr | PR의 CI 결과와 리뷰 코멘트 대응, 머지 후 브랜치 정리 |
| 이름 | 하는 일 |
|---|---|
next-steps | 턴이 끝나면 다음 할 일을 최대 4개 제안, 체크박스로 골라 입력창에 채움 (mods/README.md) |
context-bar | 입력창 위에 컨텍스트 창 사용량을 카테고리별 색 막대와 비율로 표시 (mods/README.md) |
agent-monitor | 서브에이전트의 상태·경과 시간·도구 호출 수·토큰을 사이드 패널에 실시간 표시 (mods/README.md) |
프롬프트를 복사해 {빈칸}만 채워 씁니다.
| 상황 | 프롬프트 |
|---|---|
| 기능 개발 (설계 → 구현 → 테스트) | prompts/feature-development.md |
| 커밋·배포 전 코드 리뷰 | prompts/code-review.md |
| 화면 캡처로 API 응답 설계 | prompts/screen-spec.md |
{증상 — 무엇이 언제부터 어떻게}. 순서대로 진행해줘.
1) debugger로 재현 경로를 잡고 문제 구간을 file:line으로 특정
2) performance-optimizer로 {관찰 지점}을 포함해 병목을 우선순위로 진단
3) architecture-expert 관점에서 구조적 개선안을 트레이드오프로 비교
셋 다 진단만 하고 코드는 고치지 마. 마지막에 안 A/B/C로 정리해줘.
./install.sh # 대화형: 대상·유형·항목을 골라 설치
./install.sh list # 설치 상태 확인
./install.sh uninstall # 우리 심링크만 제거
스크립트에서 쓰거나 세부 옵션(--type, --project, --force, --dry-run 등)이 필요하면 ./install.sh -h를 본다.
hooks/register.tsx 217 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { AgentRecord, AgentStatus } from '../types'
5import {
6 addTokens,
7 addToolCall,
8 columnsFor,
9 counts,
10 DEFAULT_KEEP,
11 descriptionWidth,
12 finish,
13 header,
14 HELP,
15 isTracked,
16 normalizeKeep,
17 order,
18 parseKeep,
19 reconcile,
20 rowText,
21 startRecord,
22 trim,
23 typeWidth,
24 visible,
25} from './agents'
26
27const PANE = 'agent-monitor'
28const TITLE = '서브에이전트'
29
30const records = atom({ plugin: 'agent-monitor', key: 'records' } as const, [] as AgentRecord[])
31// 틱이 갱신하는 현재 시각. 렌더는 이 값으로 경과 시간을 계산한다.
32const now = atom({ plugin: 'agent-monitor', key: 'now' } as const, 0)
33const keep = atom({ plugin: 'agent-monitor', key: 'keep' } as const, DEFAULT_KEEP)
34const auto = atom({ plugin: 'agent-monitor', key: 'auto' } as const, true)
35const autoOpened = atom({ plugin: 'agent-monitor', key: 'autoOpened' } as const, false)
36
37const STORE_KEEP = 'keep'
38const STORE_AUTO = 'auto'
39
40const SYMBOL: Record<AgentStatus, string> = { running: '●', completed: '✓', failed: '✗', killed: '■' }
41
42function symbolStyle(status: AgentStatus) {
43 if (status === 'running') return { color: 'suggestion' }
44 if (status === 'completed') return { color: 'success' }
45 if (status === 'failed') return { color: 'error' }
46 return { dimColor: true }
47}
48
49// 모듈 변수라 다시 로드되면 사라진다. session.start에서 필요하면 다시 시작한다.
50let tick: Timer | undefined
51
52async function guard($: EngineInterface, work: () => Promise<void>) {
53 try {
54 await work()
55 } catch (error) {
56 $.ui.log(`agent-monitor: ${String(error)}`)
57 }
58}
59
60const hasRunning = (list: AgentRecord[]) => list.some(r => r.status === 'running')
61
62function stopTick() {
63 tick?.cancel()
64 tick = undefined
65}
66
67// 엔진 목록으로 놓친 종료를 맞추고 현재 시각을 올린다. 목록을 못 읽어도 시간은 흐른다.
68async function refresh($: EngineInterface) {
69 const at = await $.clock.now()
70 const listed = await $.agent.list().catch(() => undefined)
71 if (listed) {
72 const limit = await read($, keep)
73 await update($, records, list => trim(reconcile(list, listed, at), limit))
74 }
75 await update($, now, () => at)
76 if (!hasRunning(await read($, records))) {
77 stopTick()
78 // 위 읽기와 멈춤 사이에 뜬 에이전트는 틱이 돌고 있다고 보고 새로 시작하지 않았다.
79 if (hasRunning(await read($, records))) startTick($)
80 }
81}
82
83function startTick($: EngineInterface) {
84 if (tick) return
85 tick = $.clock.every(1_000, () => {
86 void guard($, () => refresh($))
87 })
88}
89
90export const register: Register = on => {
91 on('session.start', async ($, e, next) => {
92 const storedKeep = normalizeKeep(await $.store.get(STORE_KEEP))
93 await update($, keep, () => storedKeep)
94 const storedAuto = await $.store.get(STORE_AUTO)
95 await update($, auto, () => storedAuto !== false)
96 await $.command.register({
97 name: 'agent-monitor',
98 description: '서브에이전트 패널: 인자 없으면 열기, keep <N>, clear, auto on|off',
99 argumentHint: '[keep|clear|auto]',
100 })
101 if (hasRunning(await read($, records))) startTick($)
102 return next(e)
103 })
104
105 // /clear·resume에는 session.start가 오지 않는다. 대화가 바뀌었으니 기록을 버린다.
106 on('session.end', async ($, e, next) => {
107 if (e.reason === 'clear' || e.reason === 'resume') {
108 stopTick()
109 await update($, records, () => [])
110 await update($, autoOpened, () => false)
111 }
112 return next(e)
113 })
114
115 on('agent.spawn', async ($, e, next) => {
116 const result = await next(e)
117 if (result.deny !== undefined || result.agentId === undefined) return result
118 const agentId = result.agentId
119 await guard($, async () => {
120 const at = await $.clock.now()
121 await update($, records, list => [...list, startRecord(e, agentId, at)])
122 await update($, now, () => at)
123 startTick($)
124 if ((await read($, auto)) && !(await read($, autoOpened))) {
125 await update($, autoOpened, () => true)
126 await $.ui.open({ id: PANE, title: TITLE })
127 }
128 })
129 return result
130 })
131
132 on('tool.call', async ($, e, next) => {
133 const agentId = e.agentId
134 if (agentId !== undefined && isTracked(await read($, records), agentId)) {
135 await guard($, () => update($, records, list => addToolCall(list, agentId)))
136 }
137 return next(e)
138 })
139
140 on('turn.step', async function* ($, e, next) {
141 const result = yield* next(e)
142 const agentId = e.agentId
143 const usage = result.usage
144 if (agentId !== undefined && usage && isTracked(await read($, records), agentId)) {
145 await guard($, () => update($, records, list => addTokens(list, agentId, usage)))
146 }
147 return result
148 })
149
150 on('turn.complete', async ($, e, next) => {
151 const result = await next(e)
152 const agentId = e.agentId
153 if (agentId !== undefined && isTracked(await read($, records), agentId)) {
154 await guard($, async () => {
155 const at = await $.clock.now()
156 const limit = await read($, keep)
157 await update($, records, list => trim(finish(list, agentId, e.reason, at), limit))
158 await update($, now, () => at)
159 })
160 }
161 return result
162 })
163
164 on('command.run', { command: 'agent-monitor' }, async ($, e) => {
165 const [sub = '', ...rest] = e.args.trim().toLowerCase().split(/\s+/)
166
167 if (sub === '' && rest.length === 0) {
168 await $.ui.open({ id: PANE, title: TITLE })
169 const { running, ended } = counts(await read($, records))
170 return { text: `서브에이전트 패널을 열었어요 (실행 중 ${running} · 완료 ${ended})` }
171 }
172 if (sub === 'keep') {
173 const parsed = parseKeep(rest.join(' '))
174 if (typeof parsed === 'string') return { text: parsed }
175 await update($, keep, () => parsed)
176 await $.store.set(STORE_KEEP, parsed)
177 await update($, records, list => trim(list, parsed))
178 return { text: `끝난 에이전트를 최근 ${parsed}개까지 남겨요.` }
179 }
180 if (sub === 'clear' && rest.length === 0) {
181 await update($, records, list => list.filter(r => r.status === 'running'))
182 return { text: '끝난 기록을 지웠어요.' }
183 }
184 if (sub === 'auto' && rest.length === 1 && (rest[0] === 'on' || rest[0] === 'off')) {
185 const isOn = rest[0] === 'on'
186 await update($, auto, () => isOn)
187 await $.store.set(STORE_AUTO, isOn)
188 return { text: isOn ? '패널 자동 열기를 켰어요.' : '패널 자동 열기를 껐어요.' }
189 }
190 return { text: HELP }
191 })
192
193 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
194 const { Box, Text } = $.ui.resolve(e)
195 const list = order(await read($, records))
196 if (list.length === 0) return <Text dimColor>아직 실행된 서브에이전트가 없어요</Text>
197
198 const at = await read($, now)
199 const cols = columnsFor(e.props.bodyColumns, typeWidth(list), descriptionWidth(list))
200 // 머리줄과 빈 줄 두 행을 뺀 만큼 그린다.
201 const { shown, hidden } = visible(list, e.props.scroll.bodyRows - 2)
202 return (
203 <Box flexDirection="column">
204 <Text key="header" bold>{header(list)}</Text>
205 <Text> </Text>
206 {shown.map(r => (
207 <Box key={`agent-${r.id}`} flexDirection="row">
208 <Text {...symbolStyle(r.status)}>{SYMBOL[r.status]}</Text>
209 <Text dimColor={r.status === 'killed'}>{` ${rowText(r, at, cols)}`}</Text>
210 </Box>
211 ))}
212 {hidden > 0 && <Text dimColor>{`… 외 ${hidden}개`}</Text>}
213 </Box>
214 )
215 })
216}
217hooks/agents.ts 213 lines1import type { AgentRecord, AgentStatus } from '../types'
2
3export type SpawnInfo = { subagentType: string; description: string; parentAgentId?: string; workflow?: unknown }
4export type Usage = {
5 input_tokens: number
6 output_tokens: number
7 cache_read_input_tokens: number
8 cache_creation_input_tokens: number
9}
10export type ListedAgent = { id: string; status: string }
11export type EndReason = 'answer' | 'aborted' | 'error' | 'refusal'
12
13const REASON_STATUS: Record<EndReason, AgentStatus> = {
14 answer: 'completed',
15 aborted: 'killed',
16 error: 'failed',
17 refusal: 'failed',
18}
19
20// 엔진 상태 중 끝난 것. pending·running·waiting·idle은 실행 중으로 본다.
21const ENDED_STATUS: Record<string, AgentStatus> = { completed: 'completed', failed: 'failed', killed: 'killed' }
22
23export function startRecord(input: SpawnInfo, agentId: string, now: number): AgentRecord {
24 return {
25 id: agentId,
26 type: input.subagentType,
27 description: input.description,
28 status: 'running',
29 startedAt: now,
30 toolCalls: 0,
31 tokens: 0,
32 ...(input.parentAgentId ? { parentId: input.parentAgentId } : {}),
33 ...(input.workflow ? { isUnlisted: true as const } : {}),
34 }
35}
36
37export function isTracked(records: AgentRecord[], agentId: string | undefined): boolean {
38 return agentId !== undefined && records.some(r => r.id === agentId)
39}
40
41function patch(records: AgentRecord[], agentId: string, fn: (r: AgentRecord) => AgentRecord): AgentRecord[] {
42 if (!isTracked(records, agentId)) return records
43 return records.map(r => (r.id === agentId ? fn(r) : r))
44}
45
46export function addToolCall(records: AgentRecord[], agentId: string): AgentRecord[] {
47 return patch(records, agentId, r => ({ ...r, toolCalls: r.toolCalls + 1 }))
48}
49
50export function usageTokens(usage: Usage): number {
51 return usage.input_tokens + usage.output_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
52}
53
54export function addTokens(records: AgentRecord[], agentId: string, usage: Usage): AgentRecord[] {
55 return patch(records, agentId, r => ({ ...r, tokens: r.tokens + usageTokens(usage) }))
56}
57
58export function finish(records: AgentRecord[], agentId: string, reason: EndReason, now: number): AgentRecord[] {
59 return patch(records, agentId, r => (r.status === 'running' ? { ...r, status: REASON_STATUS[reason], endedAt: now } : r))
60}
61
62// now는 목록을 받기 직전의 시각이다. 그 뒤에 시작한 기록은 목록에 아직 없을 수 있다.
63export function reconcile(records: AgentRecord[], listed: ListedAgent[], now: number): AgentRecord[] {
64 const byId = new Map(listed.map(a => [a.id, a.status]))
65 return records.map(r => {
66 if (r.status !== 'running') return r
67 const status = byId.get(r.id)
68 if (status === undefined) {
69 const isGone = !r.isUnlisted && r.startedAt < now
70 return isGone ? { ...r, status: 'completed', endedAt: now } : r
71 }
72 const ended = ENDED_STATUS[status]
73 return ended ? { ...r, status: ended, endedAt: now } : r
74 })
75}
76
77const isRunning = (r: AgentRecord) => r.status === 'running'
78const byStart = (a: AgentRecord, b: AgentRecord) => a.startedAt - b.startedAt
79const byEndDesc = (a: AgentRecord, b: AgentRecord) => (b.endedAt ?? 0) - (a.endedAt ?? 0)
80
81export function trim(records: AgentRecord[], keep: number): AgentRecord[] {
82 const ended = records.filter(r => !isRunning(r)).sort(byEndDesc).slice(0, keep)
83 return [...records.filter(isRunning), ...ended]
84}
85
86export function order(records: AgentRecord[]): AgentRecord[] {
87 return [...records.filter(isRunning).sort(byStart), ...records.filter(r => !isRunning(r)).sort(byEndDesc)]
88}
89
90export function counts(records: AgentRecord[]): { running: number; ended: number } {
91 const running = records.filter(isRunning).length
92 return { running, ended: records.length - running }
93}
94
95export const DEFAULT_KEEP = 10
96export const TIME_W = 7
97export const CALLS_W = 5
98export const TOKENS_W = 5
99export const MIN_DESC = 8
100const MAX_TYPE_W = 20
101const MAX_DESC_W = 30
102const GAP = ' '
103
104export const HELP = [
105 '사용법:',
106 '/agent-monitor — 패널 열기',
107 '/agent-monitor keep <1~50> — 끝난 에이전트 보관 개수',
108 '/agent-monitor clear — 끝난 기록 지우기',
109 '/agent-monitor auto on|off — 첫 에이전트가 뜰 때 패널 자동 열기',
110].join('\n')
111
112const pad2 = (n: number) => String(n).padStart(2, '0')
113
114export function formatDuration(ms: number): string {
115 const s = Math.max(0, Math.floor(ms / 1_000))
116 if (s < 60) return `${s}s`
117 const m = Math.floor(s / 60)
118 if (m < 60) return `${m}m ${pad2(s % 60)}s`
119 return `${Math.floor(m / 60)}h ${pad2(m % 60)}m`
120}
121
122export function formatTokens(n: number): string {
123 if (n < 1_000) return String(n)
124 if (n < 100_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, '')}k`
125 if (n < 999_500) return `${Math.round(n / 1_000)}k`
126 return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
127}
128
129const isWide = (ch: string) => /[\u1100-\u11ff\u3130-\u318f\uac00-\ud7a3]/.test(ch)
130
131export function displayWidth(text: string): number {
132 return [...text].reduce((n, ch) => n + (isWide(ch) ? 2 : 1), 0)
133}
134
135export function fit(text: string, width: number): string {
136 if (width <= 0) return ''
137 const w = displayWidth(text)
138 if (w <= width) return text + ' '.repeat(width - w)
139 let out = ''
140 let used = 0
141 for (const ch of text) {
142 const cw = isWide(ch) ? 2 : 1
143 if (used + cw > width - 1) break
144 out += ch
145 used += cw
146 }
147 return out + '…' + ' '.repeat(width - 1 - used)
148}
149
150export function padStart(text: string, width: number): string {
151 return ' '.repeat(Math.max(0, width - displayWidth(text))) + text
152}
153
154export type Columns = { typeW: number; description: number; showCalls: boolean; showTokens: boolean }
155
156export function typeWidth(records: AgentRecord[]): number {
157 return Math.min(MAX_TYPE_W, Math.max(0, ...records.map(r => displayWidth(r.type))))
158}
159
160export function descriptionWidth(records: AgentRecord[]): number {
161 return Math.min(MAX_DESC_W, Math.max(0, ...records.map(r => displayWidth(r.description))))
162}
163
164// 행: 기호 + 공백(2) · 타입 · 설명 · 시간 [· 호출 수] [· 토큰], 열 사이는 공백 두 칸.
165export function columnsFor(width: number, typeW: number, descW: number): Columns {
166 const base = 2 + typeW + GAP.length + GAP.length + TIME_W
167 const options: [boolean, boolean][] = [
168 [true, true],
169 [true, false],
170 [false, false],
171 ]
172 for (const [showCalls, showTokens] of options) {
173 const room =
174 width - base - (showCalls ? GAP.length + CALLS_W : 0) - (showTokens ? GAP.length + TOKENS_W : 0)
175 if (room >= Math.min(MIN_DESC, descW)) return { typeW, description: Math.min(descW, room), showCalls, showTokens }
176 }
177 return { typeW, description: Math.max(0, Math.min(descW, width - base)), showCalls: false, showTokens: false }
178}
179
180export function rowText(record: AgentRecord, now: number, cols: Columns): string {
181 const elapsed = (record.endedAt ?? now) - record.startedAt
182 const parts = [fit(record.type, cols.typeW), fit(record.description, cols.description), padStart(formatDuration(elapsed), TIME_W)]
183 if (cols.showCalls) parts.push(padStart(`${record.toolCalls}회`, CALLS_W))
184 if (cols.showTokens) parts.push(padStart(formatTokens(record.tokens), TOKENS_W))
185 return parts.join(GAP)
186}
187
188export function header(records: AgentRecord[]): string {
189 const { running, ended } = counts(records)
190 const parts = ['서브에이전트']
191 if (running > 0) parts.push(`실행 중 ${running}`)
192 if (ended > 0) parts.push(`완료 ${ended}`)
193 return parts.join(' · ')
194}
195
196export function visible(records: AgentRecord[], maxRows: number): { shown: AgentRecord[]; hidden: number } {
197 if (records.length <= maxRows) return { shown: records, hidden: 0 }
198 const shown = records.slice(0, Math.max(0, maxRows - 1))
199 return { shown, hidden: records.length - shown.length }
200}
201
202export function parseKeep(text: string): number | string {
203 const value = text.trim()
204 if (!/^\d+$/.test(value)) return HELP
205 const n = Number(value)
206 if (n < 1 || n > 50) return '보관 개수는 1~50 사이 정수예요.'
207 return n
208}
209
210export function normalizeKeep(stored: unknown): number {
211 return typeof stored === 'number' && Number.isInteger(stored) && stored >= 1 && stored <= 50 ? stored : DEFAULT_KEEP
212}
213types/index.d.ts 28 lines1export type AgentStatus = 'running' | 'completed' | 'failed' | 'killed'
2
3export type AgentRecord = {
4 id: string
5 type: string
6 description: string
7 status: AgentStatus
8 startedAt: number
9 endedAt?: number
10 toolCalls: number
11 tokens: number
12 parentId?: string
13 // 워크플로 에이전트: $.agent.list()에 나오지 않으므로 목록 보정에서 '사라짐'으로 보지 않는다.
14 isUnlisted?: true
15}
16
17declare module 'claude-code' {
18 interface PluginState {
19 'agent-monitor': {
20 records: AgentRecord[]
21 now: number
22 keep: number
23 auto: boolean
24 autoOpened: boolean
25 }
26 }
27}
28