SLOPSHOPPER

long-turn-notify

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

newguardprocesstimer
v0.1.0MITupdated 2026-10-16wonjangcloud9/wj-mods/long-turn-notify
A shopper browsing a rack in a slop shop
README

wj-mods

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.

ModWhat it does
work-progressCollects 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-koreanA 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-bandShows 5-hour and weekly usage and the Claude status page state in one line above the prompt.
long-turn-notifySends 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-nicknameGives 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-dividerDraws a divider above each of your prompts to separate questions from answers.
plain-summaryAdds a two-sentence plain-language summary under long, report-style answers, written by a small model.
compact-tool-rowsShortens 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

Install

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

Notes

  • Tested with Claude Code 2.1.301.
  • 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.
  • Run a mod's tests with claude plugin test <mod folder>.

License

MIT

Source 2 files
hooks/register.ts 125 lines
1import 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}
125
hooks/notice.ts 80 lines
1export 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