2분 넘게 걸린 작업이 끝나거나 선택 창·권한 확인 창이 뜨면 맥 알림과 세션 이름 음성으로 알려 준다

English · 한국어
A set of Claude Code mods that change the terminal UI and notifications. They are plugins with hook modules, installable one by one from this marketplace.
The UI text, notifications and summaries are written in Korean.
| Mod | What it does |
|---|---|
work-progress | Collects multi-step work from every open session into a line above the prompt and a /progress pane. Each task's steps are drawn as a chain of nodes that opens into a vertical flow. It also keeps follow-ups left after a task is done, what a paused task is waiting for, and subagent records that ended without being closed. Each open task shows the time likely left, judged from how long its own steps and your recent tasks' steps took. /progress 요약 writes a summary of the last 24 hours. |
plain-korean | A Korean readability bundle. An output style for short, plain Korean answers (conclusion first, no made-up abbreviations, nothing referred to by number alone), a guard that flags answers that slip into English and corrects the next one, a nudge to re-explain when you type "뭔 말" or "짧게", and the claude-warm theme. Installing it also installs plain-summary, turn-divider and compact-tool-rows. |
usage-band | Shows 5-hour and weekly usage and the Claude status page state in one line above the prompt. |
long-turn-notify | Sends a macOS notification and plays a voice line when a turn ran longer than 2 minutes, or when a question or permission prompt is waiting. macOS only. |
session-nickname | Gives each new session a random name so other sessions can address it with SendMessage. The name reaches the session list with the first prompt. |
turn-divider | Draws a divider above each of your prompts to separate questions from answers. |
plain-summary | Adds a two-sentence plain-language summary under long, report-style answers, written by a small model. |
compact-tool-rows | Shortens long Bash commands in tool rows to the start of the first line. |
work-progress pane:
업무 3개 · ● 작업 중 1 · □ 남은 일 1 · ✓ 오늘 완료 1
── ● 작업 중 1 ─────────────────────────────────────────────
▸ Theme builder spec
●━●━◉─○─○─○ 2/6 · 약 2시간 남음 · 이 단계 1시간째 · 5분 전 · @quiet-otter
지금: Write the requirements
최근: Bash Run the mod's tests · 방금
└ ● Benchmark AI app builders 1/8 · 최근: WebSearch Lovable docs
└ ‖ Seed starter audit 3/4 · 보류로 끝남 · only saving the file is left
└ ✓ 서브에이전트 5개 끝남
── □ 남은 일 1 ─────────────────────────────────────────────
▸ Fix lost attachments · 1일 전
□ push
── ✓ 오늘 완료 1 ───────────────────────────────────────────
▸ Release notes draft · 2시간 전
Click a task to open its steps as a flow, with the time each step took and its evidence:
▾ Theme builder spec
● Read the brief · 40분 — notes in brief.md
┃
● Benchmark · 1시간 20분 — 3 reports
┃
◉ Write the requirements ← 지금
│
○ Design
From the marketplace:
claude plugin marketplace add wonjangcloud9/wj-mods
claude plugin install work-progress@wj-mods
Install other mods the same way with <mod>@wj-mods. They load in sessions started after installing.
The Korean readability bundle installs plain-summary, turn-divider and compact-tool-rows with it:
claude plugin install plain-korean@wj-mods
The output style and the theme are not switched on for you. Choose plain-korean as the output style in /config, and claude-warm in /theme if you want the colors too.
From a local clone, list the mod folders in CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json, separated by :. Folders listed this way do not pull in dependencies, so list the bundle's three mods as well:
git clone https://github.com/wonjangcloud9/wj-mods.git ~/wj-mods
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/Users/me/wj-mods/work-progress:/Users/me/wj-mods/usage-band"
}
}
long-turn-notify records its voice lines with the macOS say Yuna voice on first use and keeps them in ~/.claude/long-turn-notify/sounds/. Without that voice it shows the notification silently.plain-summary calls a small model once per long answer, which counts toward your usage.work-progress keeps its records in ~/.claude/work-progress/ on your machine.claude plugin test <mod folder>.MIT
hooks/register.ts 125 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { DONE_SOUND, MIN_DURATION_MS, SPOKEN_NAME_FILE, WAITING_SOUND, appleScript, buildNotice, playCommand, projectName, soundDir, speakCommand, spokenName, waitingNotice, type Ending, type Notice, type Sound, type Waiting } from './notice'
4
5const NOTIFY_TIMEOUT_MS = 5_000
6
7const LONG_LIVED_TASK_TYPES = new Set(['shell', 'monitor'])
8
9function willWakeSession(task: { type: string }): boolean {
10 return !LONG_LIVED_TASK_TYPES.has(task.type)
11}
12
13async function runQuietly($: EngineInterface, argv: readonly string[]): Promise<string | undefined> {
14 const run = await $.process.run(argv, { timeoutMs: NOTIFY_TIMEOUT_MS }).catch(() => undefined)
15 if (run?.exitCode !== 0) {
16 $.ui.log(`long-turn-notify: ${argv[0]} 실패 ${run?.stderr ?? ''}`.trim(), { to: 'debug' })
17 return undefined
18 }
19 return run.stdout
20}
21
22// 이름을 못 읽어도 녹음 음성은 그대로 재생한다.
23async function speakSessionName($: EngineInterface, sessionTitle: string): Promise<void> {
24 const dir = (await runQuietly($, ['mktemp', '-d', '-t', 'long-turn-notify']))?.trim()
25 if (!dir) return
26 const file = `${dir}/${SPOKEN_NAME_FILE}`
27 if ((await runQuietly($, speakCommand(file, spokenName(sessionTitle)))) !== undefined) {
28 await runQuietly($, playCommand(file))
29 }
30 await runQuietly($, ['rm', '-rf', dir])
31}
32
33// 처음 한 번만 녹음한다. 녹음하지 못하면 소리 없이 알림만 띄운다.
34async function soundFile($: EngineInterface, sound: Sound): Promise<string | undefined> {
35 const dir = soundDir((await $.env.get('HOME')) ?? '')
36 const file = `${dir}/${sound.file}`
37 if ((await runQuietly($, ['test', '-s', file])) !== undefined) return file
38 if ((await runQuietly($, ['mkdir', '-p', dir])) === undefined) return undefined
39 return (await runQuietly($, speakCommand(file, sound.text))) !== undefined ? file : undefined
40}
41
42async function playSound($: EngineInterface, sound: Sound, sessionTitle: string | undefined): Promise<void> {
43 if (sessionTitle) await speakSessionName($, sessionTitle)
44 const file = await soundFile($, sound)
45 if (file) await runQuietly($, playCommand(file))
46}
47
48async function notify($: EngineInterface, notice: Notice, sound: Sound, sessionTitle: string | undefined): Promise<void> {
49 await Promise.all([
50 runQuietly($, ['osascript', '-e', appleScript(notice)]),
51 playSound($, sound, sessionTitle),
52 ])
53}
54
55async function notifyWaiting($: EngineInterface, waiting: Waiting, detail: string, sessionTitle: string | undefined): Promise<void> {
56 const notice = waitingNotice(projectName(await $.session.cwd()), waiting, detail)
57 // 창이 뜨는 것을 늦추지 않도록 알림은 따로 띄운다.
58 $.clock.after(0, () => void notify($, notice, WAITING_SOUND, sessionTitle))
59}
60
61async function notifyDone($: EngineInterface, startedAt: number | undefined, ending: Ending, answer: string, sessionTitle: string | undefined): Promise<void> {
62 if (startedAt === undefined) return
63 const durationMs = (await $.clock.now()) - startedAt
64 if (durationMs < MIN_DURATION_MS) return
65
66 const notice = buildNotice(projectName(await $.session.cwd()), ending, durationMs, answer)
67 // 턴 마무리를 늦추지 않도록 알림은 턴이 끝난 뒤 띄운다.
68 $.clock.after(0, () => void notify($, notice, DONE_SOUND, sessionTitle))
69}
70
71export const register: Register = on => {
72 // 선택 창이 권한 확인 알림을 함께 일으켜도 음성은 한 번만 나오게 한다.
73 let isAsking = false
74 // 알림은 항상 프롬프트 뒤에 오므로, 프롬프트 때 받은 세션 이름이면 충분하다. /rename 도 다음 프롬프트에 반영된다.
75 let sessionTitle: string | undefined
76 // 걸린 시간은 사용자가 프롬프트를 보낸 때부터 잰다. 백그라운드 작업이 끝나 다시 깨어난 턴까지 한 작업으로 본다.
77 let startedAt: number | undefined
78
79 // 한 작업의 끝은 한 번만 알리도록 시작 시각을 꺼내면서 지운다.
80 const takeStartedAt = (): number | undefined => {
81 const began = startedAt
82 startedAt = undefined
83 return began
84 }
85
86 on('classic.UserPromptSubmit', async ($, e, next) => {
87 const result = await next(e)
88 sessionTitle = result.sessionTitle ?? e.session_title ?? sessionTitle
89 startedAt ??= await $.clock.now()
90 return result
91 })
92
93 // 응답이 멈춰도 백그라운드 에이전트가 남아 있으면 그것이 끝날 때 세션이 다시 깨어나므로 아직 명령을 기다리는 때가 아니다.
94 // 셸과 monitor 는 caffeinate·개발 서버처럼 끝나지 않는 것이 많아 기다리는 이유로 보지 않는다.
95 on('classic.Stop', async ($, e, next) => {
96 const result = await next(e)
97 if (e.agent_id === undefined && !e.background_tasks?.some(willWakeSession)) await notifyDone($, takeStartedAt(), 'answer', e.last_assistant_message ?? '', sessionTitle)
98 return result
99 })
100
101 // Stop 이 오지 않는 오류는 턴 끝에서 알린다. 직접 멈춘 턴은 사용자가 화면 앞에 있으니 알리지 않는다.
102 on('turn.complete', async ($, e, next) => {
103 const result = await next(e)
104 if (e.agentId !== undefined || e.reason === 'answer') return result
105 if (e.reason === 'aborted') startedAt = undefined
106 else await notifyDone($, takeStartedAt(), e.reason, e.answer, sessionTitle)
107 return result
108 })
109
110 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
111 await notifyWaiting($, 'question', e.questions[0]?.question ?? '', sessionTitle)
112 isAsking = true
113 try {
114 return await next(e)
115 } finally {
116 isAsking = false
117 }
118 })
119
120 on('classic.Notification', async ($, e, next) => {
121 if (e.notification_type === 'permission_prompt' && !isAsking) await notifyWaiting($, 'permission', e.message, sessionTitle)
122 return next(e)
123 })
124}
125hooks/notice.ts 80 lines1export const MIN_DURATION_MS = 2 * 60_000
2
3const SNIPPET_CHARS = 80
4
5export type Ending = 'answer' | 'error' | 'refusal'
6
7export type Notice = { title: string; subtitle: string; body: string }
8
9export function formatDuration(ms: number): string {
10 const totalSeconds = Math.round(ms / 1000)
11 const hours = Math.floor(totalSeconds / 3600)
12 const minutes = Math.floor((totalSeconds % 3600) / 60)
13 const seconds = totalSeconds % 60
14 if (hours > 0) return `${hours}시간 ${minutes}분`
15 return seconds > 0 ? `${minutes}분 ${seconds}초` : `${minutes}분`
16}
17
18// 알림 한 줄에 마크다운 기호가 그대로 보이지 않게 첫 문장만 꺼낸다.
19export function firstLine(answer: string): string {
20 const line = answer
21 .split('\n')
22 .map(l => l.replace(/^[#>\-*\s|]+/, '').replace(/[*`|]/g, '').trim())
23 .find(l => l.length > 0)
24 if (!line) return ''
25 return line.length > SNIPPET_CHARS ? `${line.slice(0, SNIPPET_CHARS - 1)}…` : line
26}
27
28export function buildNotice(project: string, ending: Ending, durationMs: number, answer: string): Notice {
29 const took = formatDuration(durationMs)
30 const body = ending === 'answer' ? `${took} 걸린 작업이 끝났습니다` : `${took} 만에 오류로 멈췄습니다`
31 return { title: `Claude Code · ${project}`, subtitle: body, body: firstLine(answer) || body }
32}
33
34// 작업 도중 선택 창이나 권한 확인 창이 떠서 사용자의 답을 기다리는 경우다.
35export type Waiting = 'question' | 'permission'
36
37export function waitingNotice(project: string, waiting: Waiting, detail: string): Notice {
38 const subtitle = waiting === 'question' ? '선택을 기다리고 있습니다' : '권한 확인을 기다리고 있습니다'
39 return { title: `Claude Code · ${project}`, subtitle, body: firstLine(detail) || subtitle }
40}
41
42function quote(text: string): string {
43 return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
44}
45
46export function appleScript(notice: Notice): string {
47 return `display notification ${quote(notice.body)} with title ${quote(notice.title)} subtitle ${quote(notice.subtitle)}`
48}
49
50// 알림 소리는 시스템 알림 음량을 따라 크기를 못 바꾸므로, 녹음한 음성을 따로 원하는 크기로 재생한다.
51// 녹음 파일은 배포하지 않고 처음 쓸 때 맥 say 로 만들어 ~/.claude/long-turn-notify/sounds 에 둔다.
52export type Sound = { file: string; text: string }
53export const DONE_SOUND: Sound = { file: 'commander.aiff', text: '명령을 내려주십시오 사령관님' }
54export const WAITING_SOUND: Sound = { file: 'choice.aiff', text: '선택이 필요합니다 사령관님' }
55export const SOUND_VOLUME = 3
56
57export function soundDir(home: string): string {
58 return `${home}/.claude/long-turn-notify/sounds`
59}
60
61export function playCommand(file: string): string[] {
62 return ['afplay', '-v', String(SOUND_VOLUME), file]
63}
64
65// 세션이 여럿 열려 있어도 어느 세션이 부르는지 알 수 있게, 녹음 음성 앞에 세션 이름을 같은 목소리로 읽는다.
66export const VOICE = 'Yuna'
67export const SPOKEN_NAME_FILE = 'name.aiff'
68
69export function spokenName(sessionTitle: string): string {
70 return `${sessionTitle.replace(/[-_]+/g, ' ').trim()} 세션`
71}
72
73export function speakCommand(outputFile: string, text: string): string[] {
74 return ['say', '-v', VOICE, '-o', outputFile, '--', text]
75}
76
77export function projectName(cwd: string): string {
78 return cwd.split('/').filter(Boolean).pop() ?? cwd
79}
80