Claude가 한 일을 전부 일러바치는 Claude Code mod: 상태줄(모델, effort, 요금제 한도, 세션 작업량), 대시보드 패널(/snitch, 에이전트 현황, 변경 파일별 변경 이유, 커밋별 누적, 전체화면이면 우측에 자동 표시)

한국어 | English
Claude가 한 일을 옆에서 계속 일러바치는 Claude Code mod입니다.

요구 사항: Claude Code v2.1.287 이상 (CLI, 데스크톱 앱 Code 탭), macOS, Linux, Windows
Claude Code에서 마켓플레이스를 추가한 뒤 설치합니다.
/plugin marketplace add yuntaengtaeng/snitch
/plugin install snitch@snitch
터미널에서는 한 줄로 설치할 수 있습니다.
claude plugin install snitch --marketplace yuntaengtaeng/snitch
설치 후 Claude Code를 다시 시작하면 적용됩니다.
git 저장소에서 Claude Code를 열고 평소처럼 작업을 요청합니다. 넓은 전체화면 레이아웃이면 우측에 패널이 자동으로 열리고, 좁은 창에서는 입력창 위 오른쪽 끝의 아이콘 줄이나 /snitch로 엽니다.

패널 맨 위에는 할 일, 결정, 에이전트, 링크 칸이 있고 그 아래에 변경 파일과 커밋이 있습니다. 칸이나 파일을 누르면 그 화면으로 이동하고 Esc로 돌아옵니다. 우측 사이드바는 접기로 입력창 위 아이콘 줄로 접을 수 있습니다.
![]()
좁은 창에서는 패널 대신 같은 아이콘 줄이 뜨고, 아이콘을 누르면 그 화면만 잠깐 열립니다. 화면 모드와 터미널 너비에 따른 패널 위치는 동작 방식을 참고하세요.

대화가 길어져도 잊히지 않도록 결정, 할 일, 링크를 패널 맨 위 칸에 모아 둡니다.
직접 답하기로 답하면 그 답이 내 요청으로 전송되고, Claude가 받아서 결정을 닫습니다. 일반 메시지로 답해도 닫힙니다.⧉를 누르면 전체 주소를 클립보드에 복사하고 토스트로 알립니다.보드 내용은 매 요청마다 Claude에게도 전달되어, Claude도 열린 결정과 할 일을 잊지 않습니다.

이 세션에서 Claude가 바꾼 파일을 git 기준 줄 수와 함께 보여 주고, 파일마다 왜 바꿨는지 한 줄을 붙입니다.
change_note 도구로 직접 남깁니다. 빠뜨린 파일만 응답이 끝날 때 Haiku가 요약합니다.이력 N이 보입니다.⚠ 다른 세션도 수정 중을 표시합니다.
변경 파일 행을 누르면 그 파일의 이유 이력과 diff를 변경 덩어리(hunk)별로 보여 줍니다. [ 수정 요청 ]을 누르고 이 부분은 기존 방식으로 수정해줘처럼 쓰면 [src/auth/login.ts 10-13줄] 이 부분은 기존 방식으로 수정해줘가 내 요청으로 전송됩니다. Esc로 목록으로 돌아갑니다.

에이전트 칸을 누르면 서브에이전트의 작업 이름, 상태(● 작업 중, ✓ 완료, ✕ 오류), 경과 시간, 마지막 툴 호출을 보여 줍니다. 끝난 서브에이전트는 다음 요청 때 목록에서 빠집니다.

Opus 5.5 high | 남은 한도 5h 38% 15:40 초기화, 7d 69% 10/15 초기화 | 할 일 2/5 | 결정 2개
메인 대화의 모델과 effort, 요금제 남은 한도와 초기화 시각, 할 일 진행률과 답하지 않은 결정 수를 프롬프트 아래 한 줄로 보여 줍니다. 한도는 구독 요금제에서만, 할 일과 결정은 있을 때만 표시됩니다.
| 명령 | 동작 |
|---|---|
/snitch | 대시보드 패널 열기, 닫기 |
/snitch text | 패널 대신 텍스트로 출력 |
/snitch why | 커밋하지 않은 파일의 변경 이유 다시 요약 |
/snitch why <파일> | 그 파일의 요청별 변경 이유 이력 출력 |
/changes는 /snitch의 별칭입니다.
hooks/config.ts에서 바꿉니다. 자주 쓰는 설정만 적었고, 나머지는 파일 안 주석에 있습니다.
| 설정 | 기본값 | 설명 |
|---|---|---|
ENABLE.statusLine | true | 상태줄 |
ENABLE.workFiles | true | 대시보드 패널 |
ENABLE.board | true | 기억할 거리 보드 |
ENABLE.changeNotes | true | Claude가 직접 남기는 변경 이유 |
ENABLE.changeReasons | true | 메모가 빠진 파일의 Haiku 요약 |
PANE_AUTO_OPEN | true | 전체화면에서 패널 자동 열기 |
PANE_DOCK_COLUMNS | 56 | 우측 패널 너비 |
PANE_FILE_LINKS | false | 파일 상세의 파일 이름을 file:// 링크로 표시 |
REASON_MODEL | 'haiku' | 변경 이유를 요약할 모델 |
mod는 샌드박스 없이 Claude Code와 같은 권한으로 실행됩니다.
| 권한 | 용도 |
|---|---|
git 실행 | 변경량과 diff 읽기 (diff, show, log, rev-parse, ls-files), Haiku 요약의 비교 기준 저장 (hash-object -w, 참조되지 않는 객체라 git gc 때 정리) |
| 파일 읽기 | 새 파일의 줄 수와 내용 |
| 도구 등록 | change_note, board 도구와 사용 안내, 지금 보드 내용을 시스템 프롬프트에 추가 |
| 프롬프트 전송 | 결정 답과 수정 요청, 내가 버튼이나 입력칸을 눌렀을 때만 내 요청으로 전송 |
| 클립보드 쓰기 | 보드의 링크를 눌렀을 때만 그 주소를 복사 |
| 응답 되돌리기 | Claude가 질문으로 끝냈는데 결정을 남기지 않았으면 한 번 돌려보냄 |
| 모델 호출 | 메모가 빠진 파일의 변경 이유 요약 (REASON_MODEL, 요금제 한도에 포함) |
| 플러그인 저장소 | 다른 세션 경고용 파일별 수정 시각 (60분 뒤 삭제) |
Haiku 요약 때 사용자 요청, Claude의 답변, 바뀐 파일의 diff가 Claude Code를 통해 모델로 전송됩니다. 그 밖의 네트워크 접근은 하지 않습니다.
git commit만 올라갑니다. 사용자가 직접 커밋한 파일은 HEAD와 같아지면 변경 파일에서 빠집니다./clear, /resume 때 비웁니다.claude plugin validate .
claude plugin test .
claude --plugin-dir .
'claude-code' 타입은 Claude Code가 mod를 로드할 때 .claude-plugin/types/에 만듭니다. 클론한 뒤 claude --plugin-dir .로 한 번 실행하면 에디터의 타입 오류가 사라집니다. Git Bash에서 scripts/try.sh를 실행하면 깨끗한 시험 저장소에서 snitch를 띄우며, 확인할 시나리오는 docs/testing.md에 있습니다. 내부 동작은 docs/how-it-works.md에 정리했습니다.
hooks/register.tsx 21 lines1import type { On } from 'claude-code'
2
3import { createApp } from './app'
4import { boardPromptSection, registerBoardHooks } from './board/hooks'
5import { registerPaneHooks } from './pane/hooks'
6import { registerPromptHooks } from './prompt-hooks'
7import { notePromptSection, registerReasonHooks } from './reasons/hooks'
8import { registerSessionHooks } from './session-hooks'
9import { registerToolActivityHooks } from './tool-activity-hooks'
10
11/** snitch: 기능별 상태를 만들고 Claude Code 이벤트를 상태줄, 작업 파일, 변경 이유, 보드, 에이전트, 대시보드 패널에 연결 */
12export function register(on: On) {
13 const app = createApp()
14 registerSessionHooks(on, app)
15 registerPromptHooks(on, [notePromptSection, boardPromptSection(app)])
16 registerReasonHooks(on, app)
17 registerBoardHooks(on, app)
18 registerToolActivityHooks(on, app)
19 registerPaneHooks(on, app)
20}
21hooks/app.ts 182 lines1import { createAgents } from './agents/tracker'
2import { createBoard } from './board/tracker'
3import { boardStatusParts, isBoardEmpty } from './board/view'
4import * as C from './config'
5import { createFileDetail } from './file-detail/tracker'
6import type { Host } from './host'
7import { answerKey } from './pane/board-section'
8import { createPaneController } from './pane/controller'
9import { dashboardRows, type DashboardView } from './pane/dashboard'
10import { askKey } from './pane/detail'
11import { createNavigation, type Screen } from './pane/navigation'
12import { createReasons } from './reasons/tracker'
13import { createStatusLine } from './status-line/tracker'
14import { createWorkFiles } from './work-files/tracker'
15
16export type App = ReturnType<typeof createApp>
17
18/** 기능별 상태를 서로 연결해 만든 snitch 한 벌, 훅 등록 모듈들이 함께 씀 */
19export function createApp() {
20 let host: Host | null = null
21 /** 다음 턴이 사용자가 직접 보낸 요청으로 시작하는지 여부 */
22 let isNextTurnUserRequest = false
23
24 const getHost = () => host
25
26 /** 사용자 요청으로 보낸 글을 변경 이유의 근거로 남기고 다음 턴을 새 요청으로 표시 */
27 const noteUserRequest = (text: string) => {
28 reasons.noteRequest(text)
29 isNextTurnUserRequest = true
30 }
31
32 const pane = createPaneController({
33 getHost,
34 rows: () => dashboardRows(dashboard()),
35 allowsAutoOpen: () => C.ENABLE.workFiles && C.PANE_AUTO_OPEN && workFiles.view().repoRoot !== null,
36 })
37
38 /** 패널이 열렸거나 아이콘 줄이 보이면 다시 그리기 */
39 const redraw = () => {
40 if (pane.isOpen() || !pane.isWide() || nav.view().isFolded) host?.invalidate()
41 }
42
43 const workFiles = createWorkFiles({
44 getHost,
45 onChange: () => {
46 fileDetail.refresh()
47 redraw()
48 },
49 onCleaned: paths => reasons.forget(paths),
50 })
51 const reasons = createReasons({
52 getHost,
53 changeSince: (rel, blob) => workFiles.changeSince(rel, blob),
54 snapshot: rel => workFiles.snapshot(rel),
55 onChange: redraw,
56 })
57 const fileDetail = createFileDetail({
58 getHost,
59 changeOf: rel => workFiles.changeOf(rel),
60 onSent: noteUserRequest,
61 onChange: redraw,
62 })
63 const agents = createAgents({ getHost, onChange: redraw })
64 const statusLine = createStatusLine({
65 getHost,
66 boardParts: () => (C.ENABLE.board ? boardStatusParts(board.view().board) : []),
67 })
68 const board = createBoard({
69 getHost,
70 onSent: noteUserRequest,
71 /** 패널과 함께 상태줄 갱신 */
72 onChange: () => {
73 redraw()
74 statusLine.update()
75 },
76 })
77
78 /** 접으면 패널을 닫고 아이콘 줄로, 펼치면 다시 열기, 화면만 옮기면 새 높이로 다시 열고 맨 위로 */
79 const nav = createNavigation({
80 onChange: (next, prev) => {
81 if (next.isFolded && !prev.isFolded) {
82 if (pane.isOpen()) pane.close()
83 host?.invalidate()
84 } else if (!next.isFolded && prev.isFolded) {
85 pane.openFocused()
86 host?.invalidate()
87 } else if (pane.isOpen()) {
88 redraw()
89 pane.show()
90 }
91 },
92 })
93
94 const dashboard = (): DashboardView => ({
95 agents: agents.view(),
96 board: board.view(),
97 files: workFiles.view(),
98 reasons: reasons.view(),
99 limits: statusLine.limits(),
100 nav: nav.view(),
101 })
102
103 return {
104 workFiles,
105 reasons,
106 fileDetail,
107 agents,
108 statusLine,
109 board,
110 pane,
111 nav,
112 dashboard,
113 redraw,
114 getHost,
115 noteUserRequest,
116
117 /** session.start 에서 만든 Host 연결 */
118 attachHost(next: Host) {
119 host = next
120 },
121
122 /** 이번 턴이 사용자가 직접 보낸 요청으로 시작했는지 꺼내고 표시 지우기 */
123 takeUserRequest(): boolean {
124 const was = isNextTurnUserRequest
125 isNextTurnUserRequest = false
126 return was
127 },
128
129 /** 이름이나 경로 끝부분으로 찾은 변경 파일의 레포 기준 경로 */
130 findFile(query: string): string | null {
131 const paths = [...new Set([...workFiles.view().current.map(f => f.path), ...reasons.view().history.keys()])]
132 return paths.find(p => p === query) ?? paths.find(p => p.endsWith(`/${query}`)) ?? null
133 },
134
135 /** 패널을 파일 상세 화면으로 전환 */
136 openFile(path: string) {
137 fileDetail.open(path)
138 pane.show(C.PANE_MAX_ROWS)
139 },
140
141 /** 파일 상세 화면에서 목록으로 복귀 */
142 backToList() {
143 fileDetail.close()
144 redraw()
145 pane.show()
146 },
147
148 /** 좁은 창의 아이콘 줄에서 그 화면만 잠깐 열기 */
149 openQuick(screen: Screen) {
150 fileDetail.close()
151 nav.quick(screen)
152 pane.openFocused()
153 host?.invalidate()
154 },
155
156 /** 잠깐 연 화면을 닫고 아이콘 줄로 복귀 */
157 closeQuick() {
158 fileDetail.close()
159 nav.reset()
160 pane.close()
161 host?.invalidate()
162 },
163
164 /** 수정 요청 입력칸을 열고 포커스 이동 */
165 askPart(index: number) {
166 fileDetail.ask(index)
167 if (fileDetail.view().asking === index) pane.focus(askKey(index))
168 },
169
170 /** 결정의 직접 답하기 입력칸을 열고 포커스 이동 */
171 toggleAnswering(id: string) {
172 board.toggleAnswering(id)
173 if (board.view().answering === id) pane.focus(answerKey(id))
174 },
175
176 /** 빈 보드에 첫 항목이 생기면 패널 열기 */
177 openIfFirstPin(wasEmpty: boolean) {
178 if (wasEmpty && !isBoardEmpty(board.view().board)) pane.openUnasked()
179 },
180 }
181}
182hooks/board/hooks.tsx 47 lines1import type { On } from 'claude-code'
2
3import type { App } from '../app'
4import * as C from '../config'
5import type { PromptSection } from '../prompt-hooks'
6import { asksUser, parseUpdate, updateSummary } from './board'
7import { BOARD_PROMPT, NUDGE } from './tool'
8import { isBoardEmpty } from './view'
9
10/** 보드 도구가 제공될 때만 붙이는 사용 규칙과 지금 보드 */
11export function boardPromptSection(app: App): PromptSection {
12 return tools => (tools.includes(C.BOARD_TOOL_ID) ? { id: 'snitch:board', text: `${BOARD_PROMPT}\n${app.board.describe()}` } : null)
13}
14
15/** 보드 도구 호출 반영과 표시, 질문으로 끝난 응답 되돌리기 */
16export function registerBoardHooks(on: On, app: App) {
17 const { board } = app
18
19 /** 보드 도구 호출 반영, 서브에이전트는 거부 */
20 on('tool.call', { tool: C.BOARD_TOOL_ID }, async ($, e, next) => {
21 if (e.agentId) return { deny: '보드는 메인 대화만 고칠 수 있음' }
22 const wasEmpty = isBoardEmpty(board.view().board)
23 const result = board.apply(parseUpdate(e as unknown as Record<string, unknown>))
24 app.openIfFirstPin(wasEmpty)
25 return { result }
26 })
27
28 /** 질문으로 끝난 응답인데 열린 결정이 없으면 한 번 돌려보내 결정으로 남기게 함 */
29 on('classic.Stop', async ($, e, next) => {
30 const ran = await next(e)
31 if (!C.ENABLE.board || ran.block || e.stop_hook_active) return ran
32 if (!asksUser(e.last_assistant_message ?? '') || board.hasOpenDecision()) return ran
33 return { ...ran, block: NUDGE }
34 })
35
36 /** 보드 도구 호출은 대화창에 흐린 한 줄로만 표시 */
37 on('ui.render', { component: 'ToolUse', props: { tool: C.BOARD_TOOL_ID } }, async ($, e) => {
38 const { Text } = $.ui.resolve(e)
39 const change = parseUpdate((e.props.input ?? {}) as Record<string, unknown>)
40 return <Text dimColor>{updateSummary(change)}</Text>
41 })
42
43 on('ui.render', { component: 'ToolResult', props: { tool: C.BOARD_TOOL_ID } }, async ($, e, next) =>
44 e.props.isErrored ? next(e) : $.ui.resolve(e).Text({ children: [''] }),
45 )
46}
47hooks/pane/hooks.tsx 138 lines1import type { On } from 'claude-code'
2
3import type { App } from '../app'
4import * as C from '../config'
5import { fileUrl } from '../paths'
6import { historyText } from '../reasons/history'
7import { countsOf, dashboardText, renderDashboard } from './dashboard'
8import { renderFileDetail } from './detail'
9import { closeOutcome, quickBarMode } from './navigation'
10import { renderQuickBar } from './quick-bar'
11import { isWideViewport } from './screens'
12
13/** /snitch 명령, 패널 자동 열기, 입력창 위 아이콘 줄, 패널 닫기와 그리기 */
14export function registerPaneHooks(on: On, app: App) {
15 const { workFiles, reasons, fileDetail, board, pane, nav } = app
16
17 /** /snitch, /changes 로 패널 열기와 닫기, text 는 텍스트 출력, why 는 변경 이유 다시 요약 */
18 on('command.run', { command: [C.DASHBOARD_COMMAND, C.CHANGES_COMMAND] }, async ($, e, next) => {
19 const host = app.getHost()
20 if (!workFiles.view().repoRoot || !host) {
21 return { text: '이 폴더는 git 저장소가 아니라서 작업 파일을 추적하지 않아요.' }
22 }
23 await workFiles.refresh()
24 const arg = e.args.trim()
25
26 if (arg.startsWith('why ')) {
27 const query = arg.slice(4).trim()
28 const path = app.findFile(query)
29 if (path === null) return { text: `변경 파일에서 ${query} 찾지 못함` }
30 return { text: historyText(path, reasons.view().history.get(path) ?? []) }
31 }
32
33 if (arg === 'why') {
34 const paths = workFiles.view().current.map(f => f.path)
35 if (paths.length === 0) return { text: '이유를 정리할 변경 파일 없음' }
36 reasons.explainNow(paths)
37 return { text: `변경 파일 ${paths.length}개의 이유 다시 정리 중` }
38 }
39
40 if (arg === 'text') {
41 return { text: dashboardText(app.dashboard()) }
42 }
43
44 if (nav.view().isFolded) {
45 nav.unfold('home')
46 return {}
47 }
48 if ((await pane.toggle()) === 'opened') return {}
49 if (pane.isDocked()) nav.fold()
50 host.invalidate()
51 return { text: '대시보드 패널 닫음' }
52 }).catch(() => ({ text: '대시보드 불러오기 실패, 잠시 후 다시 시도' }))
53
54 /** 넓은 창 여부 기록, 넓으면 세션당 한 번 패널 자동 열기 */
55 on('ui.render', { component: 'PromptHint' }, ($, e, next) => {
56 pane.sawViewport(isWideViewport(e.viewport, C.PANE_AUTO_OPEN_MIN_COLUMNS))
57 pane.autoOpen()
58 return next(e)
59 })
60
61 /** 접혔거나 좁은 창이면 입력창 위 오른쪽 끝에 아이콘 줄, 줄이 넓으면 화면 이름까지 */
62 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
63 const mode = quickBarMode({
64 hasRepo: C.ENABLE.workFiles && workFiles.view().repoRoot !== null,
65 hasSurvey: e.props.hasSurvey,
66 isPaneOpen: pane.isOpen(),
67 isFolded: nav.view().isFolded,
68 isWide: isWideViewport(e.viewport, C.PANE_AUTO_OPEN_MIN_COLUMNS),
69 })
70 if (mode === 'none') return next(e)
71 return renderQuickBar($.ui.resolve(e), countsOf(app.dashboard()), mode === 'folded' ? nav.unfold : app.openQuick, {
72 withHome: mode === 'folded',
73 withLabels: e.props.bodyColumns >= C.QUICK_LABEL_MIN_COLUMNS,
74 })
75 })
76
77 /** 패널 닫기 처리, 파일 상세면 목록으로, 다른 화면이면 홈으로, 사이드바면 아이콘 줄로 접기 */
78 on('ui.close', { id: C.PANE_ID }, ($, e, next) => {
79 const outcome = closeOutcome(nav.view(), {
80 isPerson: e.origin.kind === 'person',
81 isDetailOpen: fileDetail.view().path !== null,
82 isDocked: pane.isDocked(),
83 })
84 if (outcome === 'back-to-list') {
85 app.backToList()
86 return { value: undefined }
87 }
88 if (outcome === 'home') {
89 nav.home()
90 return { value: undefined }
91 }
92 fileDetail.close()
93 pane.closed()
94 if (outcome === 'fold') nav.fold()
95 else if (nav.view().isQuick) nav.reset()
96 app.getHost()?.invalidate()
97 return next(e)
98 })
99
100 /** 대시보드 패널 그리기, 파일을 골랐으면 파일 상세 */
101 on('ui.render', { component: 'Pane' }, ($, e, next) => {
102 if (e.requestId !== C.PANE_ID) return next(e)
103 const isDocked = e.props.placement === 'dock'
104 pane.sawPlacement(isDocked)
105 const width = Math.max(20, e.props.bodyColumns)
106 const detail = fileDetail.view()
107 const { path } = detail
108 if (path === null) {
109 return renderDashboard(
110 $.ui.resolve(e),
111 app.dashboard(),
112 width,
113 {
114 openFile: app.openFile,
115 answer: (id, value) => board.answer(id, value),
116 toggleAnswering: app.toggleAnswering,
117 clearLinks: () => board.clearLinks(),
118 copyLink: (pin, surface) => board.copyLink(pin, surface),
119 go: screen => nav.go(screen),
120 fold: () => nav.fold(),
121 },
122 isDocked,
123 )
124 }
125
126 const { repoRoot, current } = workFiles.view()
127 return renderFileDetail(
128 $.ui.resolve(e),
129 { ...detail, path },
130 reasons.view().history.get(path) ?? [],
131 current.find(f => f.path === path),
132 repoRoot && C.PANE_FILE_LINKS ? fileUrl(repoRoot, path) : undefined,
133 width,
134 { back: app.backToList, ask: app.askPart, send: (index, text) => fileDetail.send(index, text) },
135 )
136 })
137}
138hooks/prompt-hooks.ts 17 lines1import type { On } from 'claude-code'
2
3/** 시스템 프롬프트 끝에 붙일 구역, 제공된 도구 이름을 보고 붙일지 정함, 붙이지 않으면 null */
4export type PromptSection = (tools: readonly string[]) => { id: string; text: string } | null
5
6/** 기능들이 내놓은 구역을 시스템 프롬프트 끝에 한 번에 추가 */
7export function registerPromptHooks(on: On, sections: readonly PromptSection[]) {
8 on('prompt.compose', async ($, e, next) => {
9 const r = await next(e)
10 const added = sections.flatMap(section => {
11 const s = section(e.tools)
12 return s === null ? [] : [{ ...s, scope: 'session' as const }]
13 })
14 return { sections: [...r.sections, ...added] }
15 })
16}
17hooks/reasons/hooks.ts 26 lines1import type { On } from 'claude-code'
2
3import type { App } from '../app'
4import * as C from '../config'
5import type { PromptSection } from '../prompt-hooks'
6import { NOTE_PROMPT, parseNotes } from './note-tool'
7
8/** 변경 메모 도구가 제공될 때만 붙이는 안내 */
9export const notePromptSection: PromptSection = tools =>
10 tools.includes(C.NOTE_TOOL_ID) ? { id: 'snitch:change-note', text: NOTE_PROMPT } : null
11
12/** Claude 가 남긴 변경 메모를 변경 이유로 반영 */
13export function registerReasonHooks(on: On, app: App) {
14 const { workFiles, reasons } = app
15
16 /** 변경 메모 도구 호출에 응답하고 메모를 변경 이유로 반영 */
17 on('tool.call', { tool: C.NOTE_TOOL_ID }, async ($, e, next) => {
18 const notes = parseNotes(e as unknown as Record<string, unknown>).flatMap(n => {
19 const rel = workFiles.repoPathOf(n.path)
20 return rel === null ? [] : [{ rel, reason: n.reason }]
21 })
22 const count = reasons.noted(notes)
23 return { result: count > 0 ? `변경 메모 ${count}개 기록` : '기록할 변경 메모 없음, 레포 안 파일 경로와 이유 확인 필요' }
24 })
25}
26hooks/session-hooks.ts 179 lines1import type { EngineInterface, On } from 'claude-code'
2
3import type { App } from './app'
4import { BOARD_TOOL_SPEC } from './board/tool'
5import * as C from './config'
6import type { Host } from './host'
7import { isUserRequest } from './prompt-origin'
8import { NOTE_TOOL_SPEC } from './reasons/note-tool'
9
10/** session.start 의 $ 로 만든 Host */
11function createHost($: EngineInterface): Host {
12 return {
13 run: argv => $.process.run(argv, { timeoutMs: 10_000 }),
14 readFile: path => $.fs.read(path),
15 storeGet: key => $.store.get(key),
16 storeSet: (key, value) => $.store.set(key, value),
17 storeDelete: key => $.store.delete(key),
18 storeKeys: () => $.store.keys(),
19 now: () => $.clock.now(),
20 status: text => $.ui.status(text),
21 invalidate: () => $.ui.invalidate('ui.render'),
22 openPane: (rows, focus) =>
23 $.ui.open({
24 id: C.PANE_ID,
25 title: C.PANE_TITLE,
26 closeOnEscape: true,
27 rows,
28 columns: C.PANE_DOCK_COLUMNS,
29 ...(focus ? { focus } : {}),
30 }),
31 closePane: () => $.ui.close({ id: C.PANE_ID }),
32 scrollPaneTop: async () => {
33 await $.ui.scroll({ in: C.PANE_ID, to: 'start' })
34 },
35 focusPane: async key => {
36 await $.ui.focus({ requestId: C.PANE_ID, key })
37 },
38 submitPrompt: async text => {
39 await $.prompt.submit({ text, asUser: true })
40 },
41 copy: async (text, surface) => (await $.ui.copy({ text, surface })).isCopied,
42 toast: text => $.ui.toast(text),
43 paneState: async () => {
44 const pane = (await $.ui.panes()).find(p => p.id === C.PANE_ID)
45 return {
46 isOpen: pane !== undefined,
47 isPlaced: pane?.isPlaced ?? false,
48 isShown: pane?.isShown ?? false,
49 }
50 },
51 every: (ms, fn) => $.clock.every(ms, fn),
52 complete: async (prompt, system) => {
53 const r = await $.model.complete({
54 model: C.REASON_MODEL,
55 prompt,
56 system,
57 effort: 'low',
58 timeoutMs: C.REASON_TIMEOUT_SEC * 1000,
59 })
60 return r.isAnswered ? r.text : null
61 },
62 listAgents: async () => (await $.agent.list()).map(a => ({ id: a.id, status: a.status })),
63 }
64}
65
66/** 세션 시작과 초기화, 요청과 턴의 흐름, 요금제 한도를 각 기능에 전달 */
67export function registerSessionHooks(on: On, app: App) {
68 const { workFiles, reasons, fileDetail, agents, statusLine, board, pane, nav } = app
69 let timers: { cancel: () => void }[] = []
70
71 on('session.start', async ($, e, next) => {
72 const host = createHost($)
73 app.attachHost(host)
74 const sid = await $.session.id().catch(() => '')
75 pane.reset()
76 nav.reset()
77
78 statusLine.start()
79 reasons.clear()
80 fileDetail.close()
81 board.clear()
82 await agents.clear()
83 await workFiles.start(e.cwd, sid)
84 /** 저장소 확인 뒤 아이콘 줄 다시 그리기 */
85 host.invalidate()
86
87 timers.forEach(t => t.cancel())
88 timers = [
89 host.every(C.CONFLICT_POLL_SEC * 1000, () => {
90 if (pane.isOpen() && workFiles.hasTracked()) void workFiles.refresh().catch(() => undefined)
91 }),
92 host.every(C.AGENT_TICK_SEC * 1000, () => {
93 if (pane.isOpen()) void agents.tick().catch(() => undefined)
94 }),
95 ]
96
97 if (C.ENABLE.workFiles && C.ENABLE.changeNotes) {
98 await $.tool.register(NOTE_TOOL_SPEC).catch(() => undefined)
99 }
100 if (C.ENABLE.board) {
101 await $.tool.register(BOARD_TOOL_SPEC).catch(() => undefined)
102 }
103
104 if (C.ENABLE.workFiles) {
105 for (const name of [C.DASHBOARD_COMMAND, C.CHANGES_COMMAND]) {
106 await $.command
107 .register({
108 name,
109 description: '에이전트 현황과 변경 파일 대시보드, text 는 텍스트 출력, why 는 변경 이유 다시 요약, why 파일은 그 파일의 이유 이력',
110 argumentHint: '[text|why [파일]]',
111 immediate: true,
112 })
113 .catch(() => undefined)
114 }
115 }
116
117 statusLine.update()
118 return next(e)
119 })
120
121 /** /clear, /resume 시 작업 파일, 변경 이유, 에이전트 초기화 */
122 on('command.run', { command: ['clear', 'resume'] }, async ($, e, next) => {
123 const r = await next(e)
124 await workFiles.clear()
125 reasons.clear()
126 fileDetail.close()
127 board.clear()
128 await agents.clear()
129 app.redraw()
130 statusLine.update()
131 return r
132 })
133
134 /** 메인 대화 요청의 모델과 effort 를 상태줄에 반영 */
135 on('turn.step', async function* ($, e, next) {
136 if (e.agentId === undefined) {
137 statusLine.stepped(e.model, e.effort === undefined ? null : String(e.effort))
138 }
139 return yield* next(e)
140 })
141
142 /** 사용자가 직접 보낸 요청이면 변경 이유의 근거로 기록, 아이콘 줄로 잠깐 연 패널은 닫기 */
143 on('prompt.submit', async ($, e, next) => {
144 if (isUserRequest(e.origin.kind)) {
145 app.noteUserRequest(e.text)
146 if (nav.view().isQuick) app.closeQuick()
147 }
148 return next(e)
149 })
150
151 /** 메인 대화 턴 시작, 새 요청이면 끝난 서브에이전트 정리, 사용자가 직접 커밋한 파일 반영 */
152 on('turn.start', async ($, e, next) => {
153 if (workFiles.hasTracked()) void workFiles.refresh().catch(() => undefined)
154 await agents.turnStarted(app.takeUserRequest())
155 return next(e)
156 })
157
158 on('agent.spawn', async ($, e, next) => {
159 const r = await next(e)
160 if (r.agentId) {
161 await agents.spawned({ id: r.agentId, name: e.name ?? e.subagentType, task: e.description })
162 }
163 return r
164 })
165
166 on('turn.complete', async ($, e, next) => {
167 const r = await next(e)
168 await agents.turnEnded(e.agentId, e.reason)
169 if (e.agentId === undefined) reasons.turnEnded(e.answer)
170 return r
171 })
172
173 on('session.measure', async ($, e, next) => {
174 statusLine.measured(e.rateLimits.map(r => ({ kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt })))
175 app.redraw()
176 return next(e)
177 })
178}
179hooks/tool-activity-hooks.ts 40 lines1import type { On } from 'claude-code'
2
3import { MAIN_ID } from './agents/roster'
4import type { App } from './app'
5import { makesSomething } from './board/board'
6import { isBoardEmpty } from './board/view'
7import * as C from './config'
8import { changedPaths } from './work-files/edits'
9
10/** 툴 호출마다 에이전트 활동 기록, 만든 결과물 링크 수집, 바뀐 파일 추적, Bash 뒤 커밋 감지 */
11export function registerToolActivityHooks(on: On, app: App) {
12 const { agents, board, workFiles, reasons } = app
13
14 on('tool.call', async ($, e, next) => {
15 const r = await next(e)
16 if (r.deny !== undefined) return r
17
18 const input = e as unknown as Record<string, unknown>
19 if (e.tool !== C.NOTE_TOOL_ID && e.tool !== C.BOARD_TOOL_ID) agents.acted(e.agentId, e.tool, input)
20
21 const madeText = 'text' in r && r.isError !== true ? r.text : undefined
22 if (C.ENABLE.board && !e.agentId && madeText && makesSomething(e.tool, input)) {
23 const wasEmpty = isBoardEmpty(board.view().board)
24 board.captureLinks(madeText)
25 app.openIfFirstPin(wasEmpty)
26 }
27
28 for (const path of changedPaths(e.tool, input, r.result, r.isError === true)) {
29 const rel = workFiles.track(path, e.agentId ?? MAIN_ID)
30 if (rel !== null) reasons.noteEdit(rel)
31 }
32
33 if (e.tool === 'Bash') {
34 const command = typeof input['command'] === 'string' ? input['command'] : ''
35 void workFiles.afterBash(command).catch(() => undefined)
36 }
37 return r
38 })
39}
40hooks/agents/tracker.ts 75 lines1import type { Host } from '../host'
2import * as R from './roster'
3
4export type AgentsView = {
5 agents: R.AgentEntry[]
6 /** 경과 시간 계산 기준 시각(ms) */
7 now: number
8 /** 에이전트 id 의 작업 이름, 메인 대화나 모르는 id 면 빈 문자열 */
9 taskOf: (id: string) => string
10}
11
12/** 메인 대화와 서브에이전트의 상태, 마지막 활동 추적 */
13export function createAgents(deps: { getHost: () => Host | null; onChange: () => void }) {
14 let roster: R.AgentEntry[] = []
15 /** 목록에서 빠진 뒤에도 파일 표시에 쓰는 서브에이전트별 작업 이름 */
16 const tasks = new Map<string, string>()
17 let now = 0
18
19 async function syncClock() {
20 const host = deps.getHost()
21 if (host) now = await host.now()
22 }
23
24 return {
25 async clear() {
26 await syncClock()
27 roster = [R.mainAgent(now)]
28 tasks.clear()
29 },
30
31 /** 메인 대화 턴 시작, 사용자가 입력한 새 요청이면 끝난 서브에이전트 정리 */
32 async turnStarted(isNewRequest: boolean) {
33 await syncClock()
34 if (isNewRequest) roster = R.withoutFinished(roster)
35 roster = R.withState(roster, R.MAIN_ID, 'working', now)
36 deps.onChange()
37 },
38
39 /** turn.complete 의 agentId 와 reason 에 따른 상태 갱신 */
40 async turnEnded(agentId: string | undefined, reason: string) {
41 await syncClock()
42 const state = R.stateAfterTurn(reason, agentId === undefined)
43 roster = R.withState(roster, agentId ?? R.MAIN_ID, state, now)
44 deps.onChange()
45 },
46
47 async spawned(spawn: { id: string; name: string; task: string }) {
48 await syncClock()
49 roster = R.withSpawned(roster, spawn, now)
50 tasks.set(spawn.id, spawn.task)
51 deps.onChange()
52 },
53
54 acted(agentId: string | undefined, tool: string, input: Record<string, unknown>) {
55 roster = R.withActivity(roster, agentId ?? R.MAIN_ID, R.activityOf(tool, input))
56 deps.onChange()
57 },
58
59 /** 작업 중인 에이전트가 있으면 $.agent.list() 로 상태를 맞추고 경과 시간 갱신 */
60 async tick() {
61 const host = deps.getHost()
62 if (!host || !R.needsTick(roster)) return
63 await syncClock()
64 roster = R.withListed(roster, await host.listAgents().catch(() => []), now)
65 deps.onChange()
66 },
67
68 view: (): AgentsView => ({
69 agents: R.visibleAgents(roster),
70 now,
71 taskOf: id => tasks.get(id) ?? '',
72 }),
73 }
74}
75hooks/board/tracker.ts 85 lines1import type { RenderSurface } from 'claude-code'
2
3import type { Host } from '../host'
4import * as B from './board'
5
6export type BoardView = {
7 board: B.Board
8 /** 직접 답하기 입력칸을 연 결정 id */
9 answering: string | null
10}
11
12/** 기억할 거리 보드 상태, 결정에 답하면 사용자 요청으로 전송 */
13export function createBoard(deps: {
14 getHost: () => Host | null
15 /** 결정 답을 보내기 직전, 변경 이유의 근거 요청으로 기록 */
16 onSent: (text: string) => void
17 /** 화면 갱신 요청 */
18 onChange: () => void
19}) {
20 let board: B.Board = B.EMPTY_BOARD
21 let answering: string | null = null
22
23 function set(next: B.Board) {
24 board = next
25 if (answering !== null && !board.decisions.some(d => d.id === answering)) answering = null
26 deps.onChange()
27 }
28
29 return {
30 /** Claude 의 board 도구 호출 반영, 반영 뒤 보드 설명 반환 */
31 apply(change: B.BoardUpdate): string {
32 set(B.applyUpdate(board, change))
33 return B.describeBoard(board)
34 },
35
36 /** 무언가를 만든 툴 결과의 링크 추가, 링크가 너무 많으면 만든 결과물이 아니라고 보고 무시 */
37 captureLinks(text: string) {
38 const pins = B.urlPins(text)
39 if (pins.length > 0 && pins.length <= 3) set(B.addLinks(board, pins))
40 },
41
42 clearLinks() {
43 set({ ...board, links: [] })
44 },
45
46 /** 링크 주소를 버튼을 누른 화면의 클립보드에 복사하고 결과를 토스트로 알림 */
47 copyLink(pin: B.Pin, surface: RenderSurface) {
48 const host = deps.getHost()
49 if (!host) return
50 void host
51 .copy(pin.href, surface)
52 .catch(() => false)
53 .then(isCopied => host.toast(isCopied ? `주소 복사함, ${pin.label}` : '주소 복사 실패'))
54 },
55
56 /** 직접 답하기 입력칸 열기, 같은 결정이면 닫기 */
57 toggleAnswering(id: string) {
58 answering = answering === id ? null : id
59 deps.onChange()
60 },
61
62 /** 결정에 답을 사용자 요청으로 전송, Claude 가 닫을 때까지 답한 상태로 표시 */
63 answer(id: string, value: string) {
64 const host = deps.getHost()
65 const decision = board.decisions.find(d => d.id === id)
66 if (!host || !decision || !value.trim()) return
67 const text = B.answerText(decision, value)
68 deps.onSent(text)
69 answering = null
70 set(B.markAnswered(board, id, value.trim()))
71 void host.submitPrompt(text).catch(() => undefined)
72 },
73
74 hasOpenDecision: () => board.decisions.length > 0,
75 describe: () => B.describeBoard(board),
76
77 clear() {
78 board = B.EMPTY_BOARD
79 answering = null
80 },
81
82 view: (): BoardView => ({ board, answering }),
83 }
84}
85hooks/board/view.ts 65 lines1import type { Board, Decision, Todo } from './board'
2
3/** 보드 구역의 진행 막대, 텍스트 버전, 줄 수 */
4
5export const TODO_ICON: Record<Todo['state'], string> = { active: '▸', open: '○', done: '✓' }
6
7/** 완료 비율 막대, segments 칸 */
8export function progressBar(done: number, total: number, segments: number): string {
9 const filled = total === 0 ? 0 : Math.round((done / total) * segments)
10 return '▰'.repeat(filled) + '▱'.repeat(segments - filled)
11}
12
13/** 완료되지 않은 할 일, 진행 중이 맨 앞 */
14export function openTodos(todos: readonly Todo[]): Todo[] {
15 return [...todos.filter(t => t.state === 'active'), ...todos.filter(t => t.state === 'open')]
16}
17
18export function doneCount(todos: readonly Todo[]): number {
19 return todos.filter(t => t.state === 'done').length
20}
21
22/** 결정 하나의 답 상태 문구, 답하지 않았으면 빈 문자열 */
23export function answeredText(d: Decision): string {
24 return d.answered ? `답변 보냄, ${d.answered}` : ''
25}
26
27/** 상태줄용 보드 요약, 할 일 진행률과 답하지 않은 결정 수, 둘 다 없으면 빈 배열 */
28export function boardStatusParts(board: Board): string[] {
29 const parts: string[] = []
30 if (board.todos.length > 0) parts.push(`할 일 ${doneCount(board.todos)}/${board.todos.length}`)
31 const pending = board.decisions.filter(d => !d.answered).length
32 if (pending > 0) parts.push(`결정 ${pending}개`)
33 return parts
34}
35
36export function isBoardEmpty(board: Board): boolean {
37 return board.todos.length + board.decisions.length + board.links.length === 0
38}
39
40/** /snitch text 용 보드 텍스트, 비었으면 빈 문자열 */
41export function boardText(board: Board): string {
42 const lines: string[] = []
43 if (board.decisions.length > 0) {
44 lines.push(`결정 필요 ${board.decisions.length}`)
45 for (const d of board.decisions) {
46 lines.push(` ? ${d.text}`)
47 if (d.options.length > 0) lines.push(` 선택지: ${d.options.join(', ')}`)
48 if (d.answered) lines.push(` ${answeredText(d)}`)
49 }
50 }
51 if (board.todos.length > 0) {
52 if (lines.length > 0) lines.push('')
53 lines.push(`할 일 ${doneCount(board.todos)}/${board.todos.length}`)
54 for (const t of openTodos(board.todos)) lines.push(` ${TODO_ICON[t.state]} ${t.text}`)
55 const done = doneCount(board.todos)
56 if (done > 0) lines.push(` ✓ ${done}개 완료`)
57 }
58 if (board.links.length > 0) {
59 if (lines.length > 0) lines.push('')
60 lines.push(`링크 ${board.links.length}`)
61 for (const p of board.links) lines.push(` ${p.label} ${p.href}`)
62 }
63 return lines.join('\n')
64}
65hooks/config.ts 84 lines1/** 기능별 사용 여부 */
2export const ENABLE = {
3 /** 상태줄 */
4 statusLine: true,
5 /** 상태줄 맨 앞의 모델 · effort */
6 modelEffort: true,
7 /** 작업 파일 패널과 /changes 명령 */
8 workFiles: true,
9 /** 코드를 고친 Claude 가 change_note 도구로 직접 남기는 변경 이유 */
10 changeNotes: true,
11 /** 메모가 없는 파일의 변경 이유를 턴 끝에 REASON_MODEL 로 요약 */
12 changeReasons: true,
13 /** 할 일, 결정, 링크를 모으는 기억할 거리 보드 */
14 board: true,
15}
16
17export const PANE_ID = 'k-work-files'
18export const PANE_TITLE = 'snitch'
19export const CHANGES_COMMAND = 'changes'
20export const DASHBOARD_COMMAND = 'snitch'
21
22/** 변경 메모 도구가 모델에게 보이는 전체 이름 */
23export const NOTE_TOOL_ID = 'mcp__snitch__change_note'
24
25/** 보드 도구가 모델에게 보이는 전체 이름 */
26export const BOARD_TOOL_ID = 'mcp__snitch__board'
27
28/** 작업 파일로 추적할 편집 툴 */
29export const EDIT_TOOLS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit'] as const
30
31/** 전체화면 레이아웃에서 세션마다 패널을 자동으로 열지 여부 */
32export const PANE_AUTO_OPEN = true
33
34/** 패널을 열 때 요청하는 최대 높이(줄) */
35export const PANE_MAX_ROWS = 30
36
37/** 작업 중인 에이전트가 있을 때 경과 시간과 상태를 갱신하는 주기(초) */
38export const AGENT_TICK_SEC = 5
39
40/** 대시보드 제목 줄의 한도 막대 칸 수 */
41export const LIMIT_BAR_SEGMENTS = 6
42
43/** 패널 제목 줄에 한도 초기화 시각을 붙이는 최소 너비(칸) */
44export const RESET_MIN_COLUMNS = 50
45
46/** 파일 이름을 file:// 링크로 표시할지 여부, 링크를 지원하지 않는 터미널에서는 주소가 그대로 보임 */
47export const PANE_FILE_LINKS = false
48
49/** 사이드바 패널의 기본 너비(칸) */
50export const PANE_DOCK_COLUMNS = 56
51
52/** 명령 없이 연 패널을 엔진이 대화 옆에 배치하는 최소 화면 너비(칸), 이보다 좁으면 입력창 위 아이콘 줄 */
53export const PANE_AUTO_OPEN_MIN_COLUMNS = 144
54
55/** 아이콘 줄에 화면 이름까지 붙이는 최소 줄 너비(칸) */
56export const QUICK_LABEL_MIN_COLUMNS = 72
57
58/** 좁은 창에서 아이콘 줄로 잠깐 연 패널 높이(줄) */
59export const PANE_QUICK_ROWS = 12
60
61/** 다른 세션의 수정 기록을 유효하게 보는 시간(분) */
62export const CONFLICT_TTL_MIN = 30
63
64/** 패널이 열려 있을 때 다른 세션의 수정 기록을 다시 읽는 주기(초) */
65export const CONFLICT_POLL_SEC = 20
66
67/** 패널 배경색. null 이면 테마 배경색 */
68export const PANE_BACKGROUND: string | null = null
69
70/** 변경 이유를 요약할 모델 (별칭 또는 모델 ID) */
71export const REASON_MODEL = 'haiku'
72
73/** 변경 이유 요약 요청에 넣을 diff 최대 글자 수 (파일 전체 합) */
74export const REASON_DIFF_CHARS = 12_000
75
76/** 변경 이유 한 줄의 최대 글자 수 */
77export const REASON_MAX_CHARS = 30
78
79/** 파일 상세 화면에 그릴 diff 최대 글자 수, 넘는 뒤쪽 hunk 는 생략 */
80export const DETAIL_DIFF_CHARS = 20_000
81
82/** 변경 이유 요약 요청의 제한 시간(초) */
83export const REASON_TIMEOUT_SEC = 30
84