서브에이전트마다 카드를 쌓아 실행 상태·모델·토큰·소요 시간을 보여주고, 끝난 서브에이전트를 세션별 JSONL로 기록한다 (scasella/claude-flightdeck 참고)

~/.claude/는 실제 폴더로 두고, 관리 대상 항목만 그 안으로 심링크합니다. Claude Code는 항상 ~/.claude/ 아래 고정 경로에서 읽으므로, 저장소를 어디에 clone하든 심링크가 위치 차이를 흡수합니다.
https://github.com/1Dohyeon/claude-sync 읽고 설치해줘
이후는 Claude가 SETTING_GUIDE.md를 따라 처리합니다. 저장소를 둘 위치와 기존 설정을 옮겨도 되는지만 답해 주면 됩니다.
ls -l ~/.claude로 확인합니다. -> 뒤가 저장소 경로면 연결된 것입니다.git remote set-url origin <자기 저장소 주소>로 원격만 바꿉니다.~/claude-backup/으로 옮긴 뒤 링크합니다. 다른 도구가 관리하던 심링크는 덮어쓰기 전에 원래 경로를 알려주고 승인을 받습니다.git·ln·mv·mkdir·ls·find 명령을 씁니다. 기존 settings.json의 permissions.deny에 걸려 있으면 중간에 멈춥니다./planning-dev의 깊은 분석에서 과거 유사 태스크를 찾으려면 kiwipiepy·scikit-learn이 필요합니다. 없으면 그 검색만 건너뜁니다.~/.claude/~/.claude/ # 실제 폴더
├── agents/ commands/ hooks/ rules/ skills/ templates/ # → claude-sync로 심링크
├── CLAUDE.md settings.json # → claude-sync로 심링크
├── CLAUDE.local.md settings.local.json # → claude-sync로 심링크 (gitignore 대상)
└── sessions/ projects/ plugins/ history.jsonl ... # Claude Code 런타임, git이 모름
기기 전용 오버라이드 2개(CLAUDE.local.md, settings.local.json)도 저장소를 거쳐 연결하지만, gitignore 대상이라 내용은 커밋되지 않습니다. 공유되는 것은 "그 자리에 파일이 있다"는 구조뿐이고, 내용은 기기마다 다릅니다. 편집 지점이 저장소 폴더 하나로 통일되는 것이 이 방식의 이점입니다.
단, gitignore 대상이므로
git clean -dfx를 실행하면 이 두 파일은 지워집니다.
~/plans/는 계획 문서를 둘 수 있는 곳 가운데 하나입니다. 경로는 rules/environments.md의 PLANS_PATH로 바꿀 수 있으며, ~/.claude/와 claude-sync 밖의 별개 위치입니다.
claude-sync/ # 설정 저장소
├── rules/ # 항상 적용되는 규율 (세션 시작 시 자동 로드)
├── skills/ # 상황별 절차 (필요할 때만 로드, `/이름`으로 직접 호출)
├── templates/ # 문서 작성 시 참고할 템플릿
├── hooks/ # settings.json에 등록된 훅 스크립트
├── agents/ # 커스텀 서브에이전트 정의
├── commands/ # 커스텀 슬래시 커맨드
├── CLAUDE.md # Claude 응답·행동 규칙
├── settings.json # Claude Code 앱 설정: 테마·권한·훅 등록 등
├── CLAUDE.local.md # 기기 전용 규칙 ┐ gitignore: 커밋 안 됨,
├── settings.local.json # 기기 전용 앱 설정 ┘ 기기마다 내용이 다름
├── SETTING_GUIDE.md # Claude가 읽고 실행하는 세팅 절차 (심링크 대상 아님)
└── README.md # 이 문서 (심링크 대상 아님)
Claude는 세션을 시작할 때 CLAUDE.md, rules/ 등을 컨텍스트에 주입합니다. 따라서 어떤 작업에서든 공통으로 지켜야 하는 규칙은 rules/에 담습니다.
그 외에 작업 종류가 갈리는 경우(개발이면 개발, 리서치면 리서치 등)에는 skills/를 활용하도록 CLAUDE.md에서 라우팅합니다.
| 요청 유형 | 호출되는 skill |
|---|---|
| 코드 작성·수정 | /development |
| 요청사항 분석·설계 | /planning-dev |
| 커밋 전 변경 리뷰 | /diff-review |
| 브랜치 전체 리뷰(PR 전) | /branch-review |
| PR 리뷰 | /pr-review |
| git 작업(worktree·commit·push 등) | /git-workflow |
| 조사·리서치·자료 종합 | /research |
| 논문·긴 기술 문서 정독 | /paper-reading |
| 세션 작업 일기 | /worklog |
개발 태스크가 들어왔을 때의 예시입니다. 요청에 따라 일부 단계만 거치기도 합니다. 단계와 축을 나눠 두는 목적은, 각 지점에서 할 일과 하지 말 일을 좁혀 한 번에 과하게 파고드는 것(overthink)을 막는 데 있습니다.
flowchart TD
A["세션 시작<br/>rules 자동 주입"] --> B["요청"]
B --> C{"CLAUDE.md 라우팅"}
C -->|"요청사항 분석·설계"| D["planning-dev<br/>분석 → 문서 위치 선택 → 세 문서"]
C -->|"코드 작성·수정"| E["development<br/>저장소 규칙 우선, TDD"]
D -.->|"요청하거나 보강할 때"| DD["deep-dive<br/>과거 유사 태스크, 서브에이전트 분석"]
D --> E
E --> F["검증<br/>테스트·린트·실행, tasks.md 갱신"]
F --> G{"리뷰 요청?"}
G -->|"예"| H["diff-review / branch-review / pr-review<br/>공통부는 review-common"]
G -->|"아니오"| I["완료<br/>끝났는지 사용자에게 확인"]
H --> I
I --> J["PLANS_PATH에 둔 문서는<br/>done/으로 이동"]
rules/는 자동으로 주입되고, 계획 문서는 필요할 때 /planning-dev의 "계획 문서 찾기" 순서로 찾아 읽습니다./planning-dev가 목적, 유형, 규모, 할 일을 보고합니다. 깊은 분석은 요청하거나 계획 문서를 보강할 때만 합니다.requirements.md·design.md·tasks.md를 모두 쓴 뒤 한 번에 확인받습니다./development가 저장소 규칙을 우선하며 TDD로 작업하고, 작업 브랜치는 /git-workflow대로 만듭니다.tasks.md를 갱신합니다./diff-review, PR을 올리기 전 브랜치 전체면 /branch-review, 올라온 PR이면 /pr-review입니다. 축 구성과 그렇게 나눈 이유는 skills/review-common/README.md에 있습니다.tasks.md를 완료로 바꾸고, $PLANS_PATH에 둔 문서는 done/으로 옮깁니다.hooks/register.tsx 219 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { AgentCard } from '../types'
5import { applyStep, cardLines, expandHome, finishCard, markAsk, newCard, recordOf, recordPath, settleCall, shouldAutoOpen, summaryLine } from './view'
6
7const PANE = 'subagents-status'
8const cards = atom({ plugin: 'subagents-status', key: 'cards' } as const, [])
9
10const getCards = async ($: EngineInterface): Promise<AgentCard[]> => (await read($, cards)) ?? []
11
12const openPane = ($: EngineInterface) => $.ui.open({ id: PANE, title: 'Subagents', columns: 48 })
13const isPanePlaced = async ($: EngineInterface) => (await $.ui.panes()).some(p => p.id === PANE && p.isPlaced)
14
15// 실행 중인 카드가 있는 동안만 1초마다 다시 그려 경과 시간이 흐르게 한다.
16let ticker: Timer | null = null
17const syncTicker = async ($: EngineInterface) => {
18 const isRunning = (await getCards($)).some(c => c.status === 'running')
19 if (isRunning && !ticker) ticker = $.clock.every(1000, () => $.ui.invalidate('ui.render'))
20 if (!isRunning && ticker) {
21 ticker.cancel()
22 ticker = null
23 }
24}
25
26// 기록 실패가 패널을 멈추게 하지 않도록 오류는 삼킨다.
27const recordCard = async ($: EngineInterface, recordDir: string, card: AgentCard) => {
28 if (!recordDir) return
29 try {
30 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? ''
31 const [sessionId, cwd, repo] = await Promise.all([$.session.id(), $.session.cwd(), $.session.repo()])
32 const path = recordPath(expandHome(recordDir, home), card.endedAt ?? 0, sessionId)
33 const before = (await $.fs.exists(path)) ? String(await $.fs.read(path)) : ''
34 await $.fs.write(path, `${before}${JSON.stringify(recordOf(card, { sessionId, cwd, remote: repo?.remote ?? null }))}\n`)
35 } catch {
36 // 권한, 디스크 등
37 }
38}
39
40const updateCard = async ($: EngineInterface, id: string, fn: (c: AgentCard) => AgentCard) => {
41 await update($, cards, list => (list ?? []).map(c => (c.id === id ? fn(c) : c)))
42 $.ui.invalidate('ui.render')
43}
44
45export const register: Register = (on, options) => {
46 const recordDir = typeof options.recordDir === 'string' ? options.recordDir : ''
47 // tool.check의 agentId는 v2.1.290부터라, 그보다 먼저 도는 tool.call에서 호출 id로 서브에이전트를 기억해 둔다.
48 const callAgent = new Map<string, string>()
49 const asked = new Set<string>()
50 // 사용자가 닫은 패널은 새 서브에이전트가 시작될 때까지 다시 자동으로 열지 않는다.
51 let isDismissed = false
52 // 엔진의 패널에는 접기가 없어, 맨 윗줄의 [-]/[+] 버튼으로 카드를 접고 편다.
53 let isCollapsed = false
54
55 // 사용자의 프롬프트에서 연 패널은 사용자가 연 것으로 쳐서 폭과 상관없이 배치된다(110칸부터 옆, 그 아래는 프롬프트 위).
56 on('prompt.submit', async ($, e, next) => {
57 try {
58 if (shouldAutoOpen({ isPlaced: await isPanePlaced($), isDismissed })) await openPane($)
59 } catch {
60 // 패널을 못 열어도 프롬프트는 그대로 보낸다
61 }
62 return next(e)
63 })
64
65 on('ui.close', async ($, e, next) => {
66 if (e.id === PANE && e.origin.kind === 'person') isDismissed = true
67 return next(e)
68 })
69
70 // 승인 대기와 거부는 호출이 끝나야 정해지므로, 서브에이전트 호출을 감싸 결과를 카드에 반영한다.
71 on('tool.call', async ($, e, next) => {
72 const id = e.agentId
73 if (!id) return next(e)
74 callAgent.set(e.tool_use_id, id)
75 let isDenied = false
76 try {
77 const ran = await next(e)
78 isDenied = ran.deny !== undefined
79 return ran
80 } finally {
81 const wasAsked = asked.delete(e.tool_use_id)
82 callAgent.delete(e.tool_use_id)
83 if (wasAsked || isDenied) await updateCard($, id, c => settleCall(c, { wasAsked, isDenied })).catch(() => undefined)
84 }
85 })
86
87 on('tool.check', async ($, e, next) => {
88 const verdict = await next(e)
89 const id = e.agentId ?? (e.tool_use_id ? callAgent.get(e.tool_use_id) : undefined)
90 if (id && e.tool_use_id && verdict.decision === 'ask') {
91 asked.add(e.tool_use_id)
92 await updateCard($, id, markAsk).catch(() => undefined)
93 }
94 return verdict
95 })
96
97 on('session.start', async ($, e, next) => {
98 await $.command.register({ name: PANE, description: '서브에이전트 카드 패널을 열거나 닫는다' })
99 return next(e)
100 })
101
102 on('session.end', async ($, e, next) => {
103 if (e.reason === 'clear') {
104 await update($, cards, () => [])
105 await syncTicker($)
106 }
107 return next(e)
108 })
109
110 on('command.run', { command: PANE }, async $ => {
111 if (await isPanePlaced($)) {
112 await $.ui.close({ id: PANE })
113 isDismissed = true
114 return { text: '서브에이전트 패널을 닫았습니다.' }
115 }
116 isDismissed = false
117 const opened = await openPane($)
118 return { text: opened.isPlaced ? '서브에이전트 패널을 열었습니다.' : `패널을 아직 그리지 못했습니다: ${opened.reason}` }
119 })
120
121 on('agent.spawn', async ($, e, next) => {
122 const started = await next(e)
123 if (started.deny !== undefined || !started.agentId) return started
124 const id = started.agentId
125 // 서브에이전트는 이미 시작됐으므로, 카드 쪽 실패가 시작 결과를 바꾸지 않게 한다.
126 try {
127 const at = await $.clock.now()
128 await update($, cards, list => {
129 const prev = list ?? []
130 return [...prev, newCard({ id, index: prev.length + 1, type: e.name ?? e.subagentType, model: started.model, description: e.description, at })]
131 })
132 // 이미 열려 있으면 그대로이고, 좁은 터미널에서는 배치를 기다리다 다음 프롬프트에서 다시 연다.
133 isDismissed = false
134 await openPane($).catch(() => undefined)
135 await syncTicker($)
136 $.ui.invalidate('ui.render')
137 } catch {
138 // 카드 없이 진행
139 }
140 return started
141 })
142
143 on('turn.step', async function* ($, e, next) {
144 const result = yield* next(e)
145 const id = e.agentId
146 if (id && (await getCards($)).some(c => c.id === id)) {
147 await update($, cards, list => (list ?? []).map(c => (c.id === id ? applyStep({ ...c, model: c.model || e.model }, result.usage, result.stopReason) : c)))
148 }
149 return result
150 })
151
152 on('turn.complete', async ($, e, next) => {
153 const done = await next(e)
154 const id = e.agentId
155 const card = id ? (await getCards($)).find(c => c.id === id) : undefined
156 if (card) {
157 const finished = finishCard(card, e.reason, await $.clock.now())
158 await update($, cards, list => (list ?? []).map(c => (c.id === id ? finished : c)))
159 await recordCard($, recordDir, finished)
160 await syncTicker($)
161 $.ui.invalidate('ui.render')
162 }
163 return done
164 })
165
166 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
167 const { Box, Button, Text } = $.ui.resolve(e)
168 const [list, now] = await Promise.all([getCards($), $.clock.now()])
169 const width = Math.max(24, e.props.bodyColumns)
170 const toggle = () => {
171 isCollapsed = !isCollapsed
172 $.ui.invalidate('ui.render')
173 }
174 return (
175 <Box flexDirection="column" width={width}>
176 <Box key="summary">
177 <Button key="toggle" label={isCollapsed ? '[+]' : '[-]'} plain onPress={toggle} />
178 <Text dimColor> {summaryLine(list)}</Text>
179 </Box>
180 {!isCollapsed && list.map(c => {
181 const [title, kind, usage, state] = cardLines(c, now)
182 const color =
183 c.status === 'failed'
184 ? 'error'
185 : c.status === 'done'
186 ? 'success'
187 : c.status === 'stopped'
188 ? 'inactive'
189 : (c.waiting ?? 0) > 0
190 ? 'warning'
191 : 'suggestion'
192 return (
193 <Box
194 key={`card-${c.id}`}
195 flexDirection="column"
196 borderStyle="round"
197 borderColor={c.lastStop === 'max_tokens' ? 'error' : 'suggestion'}
198 borderDimColor={c.status !== 'running'}
199 paddingX={1}
200 width={width}
201 >
202 <Text bold wrap="truncate">
203 {title}
204 </Text>
205 <Text dimColor wrap="truncate">
206 {kind}
207 </Text>
208 <Text dimColor wrap="truncate">
209 {usage}
210 </Text>
211 <Text color={color}>{state}</Text>
212 </Box>
213 )
214 })}
215 </Box>
216 )
217 })
218}
219hooks/view.ts 110 lines1import type { AgentCard, AgentRecord, AgentStatus } from '../types'
2
3type Usage = { input_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number; output_tokens?: number } | null
4
5export const newCard = (x: { id: string; index: number; type: string; model: string; description: string; at: number }): AgentCard => ({
6 id: x.id,
7 index: x.index,
8 type: x.type,
9 model: x.model,
10 description: x.description,
11 status: 'running',
12 spawnedAt: x.at,
13 endedAt: null,
14 ctx: 0,
15 out: 0,
16 steps: 0,
17 lastStop: null,
18 waiting: 0,
19 denied: 0,
20})
21
22// 사용량을 못 받은 요청이 컨텍스트를 0으로 보이게 하지 않도록 ctx는 값이 있을 때만 바꾼다.
23export const applyStep = (c: AgentCard, usage: Usage, stopReason: string | null): AgentCard => {
24 const u = usage ?? {}
25 const ctx = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
26 return { ...c, steps: c.steps + 1, ctx: ctx > 0 ? ctx : c.ctx, out: c.out + (u.output_tokens ?? 0), lastStop: stopReason }
27}
28
29// 이 기능 전에 저장된 카드에는 waiting·denied가 없을 수 있어 0으로 읽는다.
30export const markAsk = (c: AgentCard): AgentCard => ({ ...c, waiting: (c.waiting ?? 0) + 1 })
31
32export const settleCall = (c: AgentCard, x: { wasAsked: boolean; isDenied: boolean }): AgentCard => ({
33 ...c,
34 waiting: x.wasAsked ? Math.max(0, (c.waiting ?? 0) - 1) : (c.waiting ?? 0),
35 denied: (c.denied ?? 0) + (x.isDenied ? 1 : 0),
36})
37
38export const finishCard =(c: AgentCard, reason: string, at: number): AgentCard => {
39 const status: AgentStatus = reason === 'answer' ? 'done' : reason === 'aborted' ? 'stopped' : 'failed'
40 return { ...c, status, endedAt: at }
41}
42
43export const prettyModel = (id: string): string => {
44 if (!id) return '?'
45 const m = /claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?!\d)/i.exec(id)
46 if (!m?.[1]) return id
47 const family = m[1].charAt(0).toUpperCase() + m[1].slice(1).toLowerCase()
48 return `${family} ${m[2]}${m[3] ? `.${m[3]}` : ''}`
49}
50
51export const formatTokens = (n: number): string =>
52 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}m` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
53
54const timer = (ms: number) => {
55 const s = Math.max(0, Math.floor(ms / 1000))
56 const m = Math.floor(s / 60)
57 return m < 60 ? `${m}:${String(s % 60).padStart(2, '0')}` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
58}
59
60const GLYPH: Record<AgentStatus, string> = { running: '◐', done: '✓', failed: '✗', stopped: '■' }
61
62export const cardLines = (c: AgentCard, now: number): string[] => {
63 const usage = c.steps > 0 ? `ctx ${formatTokens(c.ctx)} · out ${formatTokens(c.out)} · ${c.steps} st` : 'starting…'
64 const isWaiting = c.status === 'running' && (c.waiting ?? 0) > 0
65 return [
66 `${c.index}: ${c.description || c.type}`,
67 `${c.type} · ${prettyModel(c.model)}`,
68 (c.denied ?? 0) > 0 ? `${usage} · 거부 ${c.denied}` : usage,
69 `${isWaiting ? '⏸ 승인 대기' : `${GLYPH[c.status]} ${c.status}`} ${timer((c.endedAt ?? now) - c.spawnedAt)}`,
70 ]
71}
72
73export const summaryLine = (cards: AgentCard[]): string => {
74 if (cards.length === 0) return '서브에이전트 없음'
75 return (Object.keys(GLYPH) as AgentStatus[])
76 .map(s => [s, cards.filter(c => c.status === s).length] as const)
77 .filter(([, n]) => n > 0)
78 .map(([s, n]) => `${GLYPH[s]} ${n}`)
79 .join(' · ')
80}
81
82// 열렸지만 좁아서 그려지지 않고 기다리는 패널도 다시 열어야 보이므로, 열림이 아니라 그려짐으로 판정한다.
83export const shouldAutoOpen = (x: { isPlaced: boolean; isDismissed: boolean }): boolean => !x.isPlaced && !x.isDismissed
84
85// fs는 ~를 상대 경로로 읽으므로 설정값의 ~는 여기서 홈 디렉터리로 바꾼다.
86export const expandHome = (dir: string, home: string): string => (/^~(?=$|[\\/])/.test(dir) ? home + dir.slice(1) : dir)
87
88// 세션마다 자기 파일에만 쓰므로 여러 세션이 동시에 돌아도 서로의 줄을 덮어쓰지 않는다.
89export const recordPath = (dir: string, at: number, sessionId: string): string => {
90 const d = new Date(at)
91 return `${dir}/${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, '0')}/${sessionId}.jsonl`
92}
93
94export const recordOf = (c: AgentCard, x: { sessionId: string; cwd: string; remote: string | null }): AgentRecord => ({
95 endedAt: new Date(c.endedAt ?? 0).toISOString(),
96 sessionId: x.sessionId,
97 cwd: x.cwd,
98 remote: x.remote,
99 agentId: c.id,
100 type: c.type,
101 description: c.description,
102 model: c.model,
103 status: c.status,
104 durationMs: (c.endedAt ?? 0) - c.spawnedAt,
105 steps: c.steps,
106 ctx: c.ctx,
107 out: c.out,
108 lastStop: c.lastStop,
109})
110types/index.d.ts 48 lines1export type AgentStatus = 'running' | 'done' | 'failed' | 'stopped'
2
3export type AgentCard = {
4 id: string
5 /** 이 세션에서 몇 번째로 시작한 서브에이전트인지, 1부터 */
6 index: number
7 type: string
8 model: string
9 description: string
10 status: AgentStatus
11 spawnedAt: number
12 endedAt: number | null
13 /** 마지막 요청의 입력 전체(입력 + 캐시 읽기 + 캐시 쓰기) */
14 ctx: number
15 /** 요청마다 출력 토큰을 더한 값 */
16 out: number
17 steps: number
18 lastStop: string | null
19 /** 권한 승인을 물었고 아직 결정되지 않은 도구 호출 수 */
20 waiting: number
21 /** 거부된 도구 호출 수 */
22 denied: number
23}
24
25/** 끝난 서브에이전트 하나, 세션 기록 파일의 한 줄 */
26export type AgentRecord = {
27 endedAt: string
28 sessionId: string
29 cwd: string
30 remote: string | null
31 agentId: string
32 type: string
33 description: string
34 model: string
35 status: AgentStatus
36 durationMs: number
37 steps: number
38 ctx: number
39 out: number
40 lastStop: string | null
41}
42
43declare module 'claude-code' {
44 interface PluginState {
45 'subagents-status': { cards: AgentCard[] }
46 }
47}
48