SLOPSHOPPER

work-alerts

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

newguardcommandtoastprocess
v0.2.0no licenseupdated 2026-10-06parkyountaek/claude-mods/work-alerts
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · work-alerts
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /alerts ⎿ work-alerts: 작업 알림 켜짐 · 알림음 켜짐 · macOS 알림 창 켜짐 · 60초 넘게 걸린 답변만 알림 · 서브에이전트: 알림 창만 ⎿ work-alerts: 바꾸려면 /config 에서 work-alerts 항목을 고르세요. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

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 메뉴에 항목으로 나옵니다. 메뉴에서 바꾸면 그 자리에서 바로 적용됩니다.

  • token-usage: 입력창 위 사용량 줄, 사용 한도 초기화 시간, 서브에이전트 줄 수(3/5/8/끄기), 서브에이전트가 지금 보는 것, 색 테마
  • work-alerts: 작업 알림, 알림음, macOS 알림 창, 긴 작업 기준(30초/60초/2분/5분), 서브에이전트 완료 알림(알림 창만/알림 창과 소리/끄기)
  • compact-handoff: 할 일 진행 줄, 압축 때 이어가기 메모

/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" } }

root 계정에서도 쓰기

sudo sh ~/.claude/mods/install-root.sh
  • root 설정에 이 폴더의 mod 경로를 추가합니다. 기존 설정은 먼저 백업하고, 이미 등록돼 있으면 아무것도 바꾸지 않습니다.
  • 파일을 복사하지 않으므로 mod를 고치면 root에도 바로 적용됩니다.
  • 테마는 theme.json 하나를 함께 씁니다.
  • root 계정에서는 소리와 macOS 알림이 나오지 않을 수 있습니다. 알림 창은 그대로 뜹니다.

개발

claude plugin validate <mod>
claude plugin test <mod>
Source 1 files
hooks/register.ts 157 lines
1import 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