긴 작업 완료, 검사 실패, 서브에이전트 완료, 컨텍스트·사용 한도 경고를 알림 창·소리·macOS 알림으로 알려줍니다

Claude Code mod 모음입니다. mod는 함수 훅으로 만든 플러그인입니다.
| mod | 하는 일 | 명령 |
|---|---|---|
token-usage | 입력창 위 한 줄에 컨텍스트(대화 기억) 사용량, 5시간·7일 사용 한도, 메인 모델과 effort를 보여줍니다. 실행 중인 서브에이전트는 ①②③ 번호와 색으로 구분해 한 줄씩 나오고, 각 줄에는 모델, 걸린 시간(⏱), ctx, 지금 보는 파일이 붙습니다. 이름을 누르면 그 서브에이전트의 작업 내용이 창으로 열립니다(👁 표시, Esc로 닫기). | /token-usage 펼치기/접기, /token-usage theme |
work-alerts | 오래 걸린 작업이 끝났을 때, 테스트·검사가 실패했을 때, 한도가 찼을 때 알림 창과 소리로 알려줍니다. | /task-alert 소리 켜기/끄기, /alerts 현재 설정, /alerts test |
compact-handoff | 할 일이 있을 때 진행 상황(▶ 2/5 …)을 보여줍니다. 대화가 압축돼도 작업을 이어가도록 메모도 남깁니다. | /work, /handoff |
/config세 mod의 설정은 모두 Claude Code의 /config 메뉴에 항목으로 나옵니다. 메뉴에서 바꾸면 그 자리에서 바로 적용됩니다.
/task-alert, /work band off 같은 명령도 같은 항목을 바꿉니다. 그래서 메뉴와 명령의 설정이 어긋나지 않습니다.
Claude Code 안에서 하나씩 설치합니다.
/plugin install token-usage --marketplace parkyountaek/claude-mods
/plugin install work-alerts --marketplace parkyountaek/claude-mods
/plugin install compact-handoff --marketplace parkyountaek/claude-mods
폴더를 직접 받아 쓰고 고치고 싶다면 다음처럼 합니다.
git clone https://github.com/parkyountaek/claude-mods ~/.claude/mods
그다음 ~/.claude/settings.json의 env에 경로를 등록합니다.
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/token-usage:~/.claude/mods/work-alerts:~/.claude/mods/compact-handoff" } }
sudo sh ~/.claude/mods/install-root.sh
theme.json 하나를 함께 씁니다.claude plugin validate <mod>
claude plugin test <mod>hooks/register.ts 157 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// Settings come from /config (this plugin's userConfig); a change there reloads the module.
4type Prefs = { isOn: boolean; hasSound: boolean; hasDesktop: boolean; longTurnSec: number; subagent: string }
5const LONG_TURN: Record<string, number> = { '30초': 30, '60초': 60, '2분': 120, '5분': 300 }
6
7export const prefsFrom = (o: Readonly<Record<string, unknown>>): Prefs => ({
8 isOn: o.enabled !== false,
9 hasSound: o.sound !== false,
10 hasDesktop: o.desktop !== false,
11 longTurnSec: LONG_TURN[String(o.longTurn)] ?? 60,
12 subagent: typeof o.subagent === 'string' ? o.subagent : '알림 창만',
13})
14
15const SOUND = {
16 done: '/System/Library/Sounds/Glass.aiff',
17 fail: '/System/Library/Sounds/Basso.aiff',
18 warn: '/System/Library/Sounds/Funk.aiff',
19} as const
20
21// Commands whose failure is worth an alert: tests, lint, type checks, builds.
22const CHECK = /\b(pytest|ruff|mypy|tsc|jest|vitest|npm (run )?test|cargo test|go test|make)\b/
23
24export const duration = (ms: number): string => {
25 const s = Math.round(ms / 1000)
26 if (s < 60) return `${s}초`
27 const m = Math.floor(s / 60)
28 return s % 60 ? `${m}분 ${s % 60}초` : `${m}분`
29}
30
31// The line a test runner ends on ("== 2 failed, 120 passed in 3.1s ==", "Found 3 errors").
32export const failSummary = (out: string): string | undefined => {
33 const lines = out.split('\n').map(l => l.trim()).filter(Boolean)
34 const hit = [...lines]
35 .reverse()
36 .find(l => /\b(failed|error|errors|FAILED)\b/.test(l) && l.length < 200)
37 return hit?.replace(/^=+\s*|\s*=+$/g, '')
38}
39
40// Which threshold a value has newly crossed upward, if any.
41export const crossed = (before: number, now: number, levels: readonly number[]): number | undefined =>
42 [...levels].reverse().find(l => before < l && now >= l)
43
44const quote = (s: string) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
45
46let prefs: Prefs = prefsFrom({})
47// Several alerts at once (parallel subagents finishing) play one sound.
48let lastSoundAt = 0
49
50async function alert($: EngineInterface, text: string, kind: keyof typeof SOUND, isDesktop = false, hasSound = true) {
51 if (!prefs.isOn) return
52 $.ui.toast(text, { timeoutMs: kind === 'fail' ? 8000 : 5000 })
53 const now = await $.clock.now()
54 if (prefs.hasSound && hasSound && now - lastSoundAt > 2000) {
55 lastSoundAt = now
56 void $.process.run(['afplay', SOUND[kind]]).catch(() => undefined)
57 }
58 if (prefs.hasDesktop && isDesktop) {
59 const script = `display notification "${quote(text)}" with title "Claude Code"`
60 void $.process.run(['osascript', '-e', script]).catch(() => undefined)
61 }
62}
63
64const flag = (x: boolean) => (x ? '켜짐' : '꺼짐')
65const describe = (p: Prefs) =>
66 `작업 알림 ${flag(p.isOn)} · 알림음 ${flag(p.hasSound)} · macOS 알림 창 ${flag(p.hasDesktop)} · ${p.longTurnSec}초 넘게 걸린 답변만 알림 · 서브에이전트: ${p.subagent}`
67
68// Commands write through /config, so the menu and the commands never disagree.
69async function setOption($: EngineInterface, field: string, value: boolean | string): Promise<boolean> {
70 const r = await $.config.set({ key: `work-alerts.${field}`, value }).catch(() => ({ deny: 'error' }))
71 return !('deny' in r && r.deny !== undefined)
72}
73
74export const register: Register = (on, options) => {
75 prefs = prefsFrom(options)
76 // undefined until the first reading, so a session that starts above a line does not alert.
77 let lastContext: number | undefined
78 const lastLimit = new Map<string, number>()
79
80 on('session.start', async ($, e, next) => {
81 await $.command.register({
82 name: 'alerts',
83 description: '작업 알림 설정 보기 (바꾸기: /config 의 work-alerts, 시험: /alerts test)',
84 argumentHint: '[test]',
85 })
86 await $.command.register({ name: 'task-alert', description: '작업 알림음 켜기/끄기 (on|off, 비우면 전환)', argumentHint: '[on|off]' })
87 return next(e)
88 })
89
90 // /task-alert flips the alert sound; toasts and desktop notices are untouched.
91 on('command.run', { command: 'task-alert' }, async ($, e) => {
92 const a = e.args.trim()
93 const hasSound = a === 'on' ? true : a === 'off' ? false : !prefs.hasSound
94 if (!(await setOption($, 'sound', hasSound))) prefs = { ...prefs, hasSound }
95 return { text: hasSound ? '🔔 알림음 켬' : '🔕 알림음 끔 (알림 창은 그대로 뜹니다)' }
96 })
97
98 on('command.run', { command: 'alerts' }, async ($, e) => {
99 if (e.args.trim() === 'test') {
100 await alert($, '🔔 알림 테스트입니다', 'done', true)
101 return { text: prefs.isOn ? '시험 알림을 보냈습니다.' : '작업 알림이 꺼져 있어 시험 알림도 나오지 않습니다. /config 에서 켜세요.' }
102 }
103 return { text: `${describe(prefs)}\n바꾸려면 /config 에서 work-alerts 항목을 고르세요.` }
104 })
105
106 on('turn.complete', async ($, e, next) => {
107 const done = await next(e)
108 const took = duration(e.durationMs)
109 if (e.agentId !== undefined) {
110 const info = (await $.agent.list()).find(a => a.id === e.agentId)
111 const name = info ? `${info.type}${info.description ? ` · ${info.description}` : ''}` : '서브에이전트'
112 if (prefs.subagent === '끄기') return done
113 const hasSound = prefs.subagent === '알림 창과 소리'
114 if (e.reason === 'error') await alert($, `🤖✗ ${name} 실패 (${took})`, 'fail', false, hasSound)
115 else if (!e.isAborted) await alert($, `🤖✓ ${name} 끝남 (${took})`, 'done', false, hasSound)
116 return done
117 }
118 if (e.reason === 'error') await alert($, `✗ 오류로 멈춤 (${took})`, 'fail', true)
119 else if (e.reason === 'refusal') await alert($, `⚠ 모델이 답변을 거절함`, 'warn', true)
120 else if (!e.isAborted && e.durationMs >= prefs.longTurnSec * 1000) {
121 await alert($, `✓ 작업 끝 (${took}) — 확인해 주세요`, 'done', true)
122 }
123 return done
124 })
125
126 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
127 const ran = await next(e)
128 if (ran.deny === undefined && ran.isError === true && CHECK.test(e.command)) {
129 const tool = CHECK.exec(e.command)?.[1] ?? 'check'
130 const out = ran.result as { stdout?: string; stderr?: string } | undefined
131 const why = failSummary(`${out?.stdout ?? ''}\n${out?.stderr ?? ''}`)
132 await alert($, `🧪✗ ${tool} 실패${why ? `: ${why}` : ''}`, 'fail')
133 }
134 return ran
135 }).catch(($, e, next) => next(e))
136
137 on('session.measure', async ($, e, next) => {
138 const pct = e.context.percent
139 if (pct !== undefined) {
140 const hit = lastContext === undefined ? undefined : crossed(lastContext, pct, [60, 85])
141 if (hit === 85) await alert($, `● 컨텍스트 ${pct}% — 곧 자동 압축됩니다`, 'warn')
142 else if (hit === 60) await alert($, `◕ 컨텍스트 ${pct}% 사용 중`, 'warn')
143 lastContext = pct
144 }
145 for (const limit of e.rateLimits) {
146 const before = lastLimit.get(limit.kind)
147 const hit = before === undefined ? undefined : crossed(before, limit.percentUsed, [80, 95])
148 if (hit !== undefined) {
149 const label = limit.kind === 'five_hour' ? '5시간' : limit.kind === 'seven_day' ? '7일' : limit.kind
150 await alert($, `⏳ ${label} 사용 한도 ${limit.percentUsed}%`, 'warn', hit === 95)
151 }
152 lastLimit.set(limit.kind, limit.percentUsed)
153 }
154 return next(e)
155 })
156}
157