SLOPSHOPPER

snitch

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

newpanebandspinnerrowsguard
★ 2v0.0.5MITupdated 2026-10-13yuntaengtaeng/snitch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · snitch
│ ┃ snitch ✕ › fix the failing auth test and add an audit log call │ ┃ snitch 5h ▰▰▰▰▱▱ 69% 접기 │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ╭──────────────────╮ ╭───────────────────── ⏺ Update(src/auth.ts) │ ┃ │ tile-name-todos │ │ tile-name-decisions ⎿ Added 2 lines, removed 1 line │ ┃ │ tile-count-todos │ │ tile-count-decisions ⏺ Bash(bun test) │ ┃ ╰──────────────────╯ ╰───────────────────── ⎿ 3 pass, 1 fail │ ┃ ────────────────────────────────────────── │ ┃ ──────────── ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ 변경 파일 0 ✻ Worked for 42s · done 4:20 PM │ ┃ 아직 바뀐 파일 없음 │ ┃ › /snitch │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ snitch: 남은 한도 5h 69%

Draws

Pane · snitch
snitch 5h ▰▰▰▰▱▱ 69% 접기 ╭──────────────────╮ ╭──────────────────────╮ ╭──────────── │ tile-name-todos │ │ tile-name-decisions │ │ tile-name-a │ tile-count-todos │ │ tile-count-decisions │ │ tile-count- ╰──────────────────╯ ╰──────────────────────╯ ╰──────────── ────────────────────────────────────────────────────── 변경 파일 0 아직 바뀐 파일 없음
README

snitch

한국어 | English

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

snitch 적용 화면

  • 무엇을 왜 고쳤나: 바뀐 파일마다 고친 이유 한 줄, 요청별 이력, 파일 diff와 그 자리에서 수정 요청
  • 무엇이 남았나: 내가 정해야 할 결정, 할 일, 에이전트, Claude가 만든 링크를 패널 맨 위 칸에 개수로 표시
  • 지금 어떤 상태인가: 서브에이전트 현황, 남은 사용 한도와 초기화 시각, 다른 세션과 같은 파일 수정 경고

요구 사항: 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가 할 일로 등록하고, 진행 중인 항목과 완료 수를 보여 줍니다.
  • 링크: PR, 배포 주소, 아티팩트처럼 Claude가 이번 세션에 만든 결과물의 링크만 모읍니다. 이름이나 ⧉를 누르면 전체 주소를 클립보드에 복사하고 토스트로 알립니다.

보드 내용은 매 요청마다 Claude에게도 전달되어, Claude도 열린 결정과 할 일을 잊지 않습니다.

변경 파일과 이유

변경 이유

이 세션에서 Claude가 바꾼 파일을 git 기준 줄 수와 함께 보여 주고, 파일마다 왜 바꿨는지 한 줄을 붙입니다.

  • 이유는 코드를 고친 Claude가 change_note 도구로 직접 남깁니다. 빠뜨린 파일만 응답이 끝날 때 Haiku가 요약합니다.
  • 같은 파일을 여러 요청에 걸쳐 고치면 이유가 요청 순서대로 쌓이고, 패널에는 최근 이유와 이력 N이 보입니다.
  • Claude가 커밋하면 파일이 커밋 구역으로 옮겨 갑니다.
  • 같은 폴더의 다른 Claude Code 세션이 최근 30분 안에 같은 파일을 고쳤으면 ⚠ 다른 세션도 수정 중을 표시합니다.

파일 상세와 수정 요청

파일 상세

변경 파일 행을 누르면 그 파일의 이유 이력과 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.statusLinetrue상태줄
ENABLE.workFilestrue대시보드 패널
ENABLE.boardtrue기억할 거리 보드
ENABLE.changeNotestrueClaude가 직접 남기는 변경 이유
ENABLE.changeReasonstrue메모가 빠진 파일의 Haiku 요약
PANE_AUTO_OPENtrue전체화면에서 패널 자동 열기
PANE_DOCK_COLUMNS56우측 패널 너비
PANE_FILE_LINKSfalse파일 상세의 파일 이름을 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를 통해 모델로 전송됩니다. 그 밖의 네트워크 접근은 하지 않습니다.

제한 사항

  • Claude가 Edit, Write, MultiEdit, NotebookEdit, Bash로 바꾼 파일만 추적합니다. 사용자가 직접 편집한 파일은 포함되지 않습니다.
  • 커밋 구역에는 Claude가 실행한 git commit만 올라갑니다. 사용자가 직접 커밋한 파일은 HEAD와 같아지면 변경 파일에서 빠집니다.
  • 결정과 할 일은 Claude가 도구로 남기므로, 모델이 빠뜨리면 보드에 올라오지 않습니다.
  • 변경 이유 이력과 보드는 세션 안에서만 유지되며, /clear, /resume 때 비웁니다.
  • git 저장소가 아닌 폴더에서는 대시보드 패널이 동작하지 않습니다.
  • 다른 세션 경고는 같은 PC, 같은 폴더의 세션끼리만 동작합니다.
  • 입력창에 포커스가 있을 때는 패널 버튼을 처음 누를 때 두 번 눌러야 할 수 있습니다.
  • mod API는 초기 단계로, Claude Code 업데이트에 따라 동작이 달라질 수 있습니다.

개발

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에 정리했습니다.

참고한 프로젝트

  • pinboard (MIT): 기억할 거리 보드의 도구 규칙, 질문을 결정으로 남기게 하는 Stop 훅, 링크 수집 규칙

라이선스

MIT

Source 39 files
hooks/register.tsx 21 lines
1import 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}
21
hooks/app.ts 182 lines
1import { 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}
182
hooks/board/hooks.tsx 47 lines
1import 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}
47
hooks/pane/hooks.tsx 138 lines
1import 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}
138
hooks/prompt-hooks.ts 17 lines
1import 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}
17
hooks/reasons/hooks.ts 26 lines
1import 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}
26
hooks/session-hooks.ts 179 lines
1import 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}
179
hooks/tool-activity-hooks.ts 40 lines
1import 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}
40
hooks/agents/tracker.ts 75 lines
1import 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}
75
hooks/board/tracker.ts 85 lines
1import 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}
85
hooks/board/view.ts 65 lines
1import 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}
65
hooks/config.ts 84 lines
1/** 기능별 사용 여부 */
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