SLOPSHOPPER

agent-monitor

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

newpaneguardcommandtimeragents
v0.1.0no licenseupdated 2026-10-11urban6/my-harness/mods/agent-monitor
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-monitor
│ ┃ 서브에이전트 ✕ › fix the failing auth test and add an audit log call │ ┃ 아직 실행된 서브에이전트가 없어요 │ ⏺ Read(src/auth.ts) │ ⎿ 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 │ │ › /agent-monitor │ ⎿ agent-monitor: 서브에이전트 패널을 열었어요 (실행 중 0 · 완료 0) │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 서브에이전트
아직 실행된 서브에이전트가 없어요
README

my-harness

새 백엔드 프로젝트를 시작할 때 가져다 쓰는 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-designerAPI와 DB 스키마를 함께 설계
backend-impl설계를 코드로 구현
boundary-verifier설계와 구현이 서로 맞는지 검증
test-writer단위·통합 테스트 작성

진단 — 코드를 직접 고치지 않고 원인과 개선안만 알려 줍니다.

이름하는 일
architecture-expert구조·의존성 진단, 기술 선택 비교
code-reviewer가독성, 네이밍, 에러 처리, 중복 리뷰
debugger버그 재현, 근본 원인 특정
performance-optimizer병목, N+1, 복잡도 진단
security-auditor인증·인가, 인젝션 등 보안 점검

스킬

이름하는 일
spring-bootSpring Boot 3.x 구현 패턴
nestjsNestJS 구현 패턴
screen-spec-analyzer화면 캡처나 화면정의서에서 API 응답 필드 추출

커맨드

이름하는 일
/commit변경을 논리 단위로 나눠 커밋
/push보호 브랜치와 강제 푸시를 확인하고 푸시
/pre-prPR 올리기 전 코드 리뷰 (테스트·린트·진단 후 BLOCK/READY 판정)
/post-prPR의 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로 정리해줘.

플러그인

AI 사용 가이드

설치

./install.sh            # 대화형: 대상·유형·항목을 골라 설치
./install.sh list       # 설치 상태 확인
./install.sh uninstall  # 우리 심링크만 제거

스크립트에서 쓰거나 세부 옵션(--type, --project, --force, --dry-run 등)이 필요하면 ./install.sh -h를 본다.

Source 3 files
hooks/register.tsx 217 lines
1import { 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}
217
hooks/agents.ts 213 lines
1import 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}
213
types/index.d.ts 28 lines
1export 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