Rings a bell and shows a banner whenever Claude is blocked waiting for you, repeating every 5 minutes until you answer

Never leave Claude idle again. Rings a bell, pops a desktop notification, and shows a flashing banner whenever Claude is blocked waiting for you, then rings again every 5 minutes until you answer.
<img src="assets/preview.png" alt="waiting-bell banner above the Claude Code prompt, with a desktop notification" width="100%">
/plugin marketplace add ibrahimkobeissy/claude-mods
/plugin install waiting-bell@claude-mods
| Claude is waiting because… | Banner says |
|---|---|
| It finished its turn | your turn |
| A permission dialog is open | the permission message |
| An MCP server asks for input | the elicitation message |
It asked you a question (AskUserQuestion) | a question for you |
It wants a plan approved (ExitPlanMode) | plan approval |
It rings only when the main session is really waiting on you:
A "your turn" wait ends when you send a prompt or press Silence. A permission prompt, question or plan approval ends as soon as it's answered.
| OS | Sound | Notification |
|---|---|---|
| Linux | pw-play → paplay → canberra-gtk-play → aplay (first that works) | notify-send (critical), plus hyprctl notify under Hyprland |
| macOS | afplay (Glass) | osascript notification |
| Windows / WSL | PowerShell SoundPlayer | PowerShell tray balloon |
It runs only the local commands listed above (plus uname -s to tell Linux from macOS), and reads three environment variables to detect the platform: OS, WSL_DISTRO_NAME, HYPRLAND_INSTANCE_SIGNATURE. The notification shows the project folder's name and the waiting reason. No network access, nothing written to disk.
claude --plugin-dir mods/waiting-bell # try it live; saves hot-reload
scripts/validate.sh mods/waiting-bell # validate + run tests/hooks/register.tsx 242 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Waiting, WaitingKind } from '../types'
5
6const REPEAT_MS = 5 * 60 * 1000
7// After each ring the terminal banner flashes this many times, this fast.
8const FLASH_MS = 500
9const FLASHES = 20
10// How long the on-screen alert stays, where the OS lets us say.
11const OVERLAY_MS = 15_000
12// Tools that stop and wait for an answer while they run.
13const ASKING_TOOLS = new Set(['AskUserQuestion', 'ExitPlanMode'])
14// Notification types the engine raises when a dialog is waiting on the person.
15const WAITING_NOTIFICATIONS = new Set(['permission_prompt', 'elicitation_dialog'])
16// Prompts the person sends; every other origin is the session waking itself.
17const PERSON = new Set(['composer', 'bridge'])
18// Agents still at work will wake the main session again when they finish.
19const BUSY_AGENTS = new Set(['pending', 'running'])
20
21const waiting = atom({ plugin: 'waiting-bell', key: 'waiting' } as const, null)
22
23let repeat: Timer | undefined
24let flashing: Timer | undefined
25
26function flash($: EngineInterface) {
27 flashing?.cancel()
28 let left = FLASHES
29 flashing = $.clock.every(FLASH_MS, () => {
30 left -= 1
31 const isOn = left > 0 && left % 2 === 1
32 void update($, waiting, w => (w ? { ...w, isFlashOn: isOn } : w))
33 if (left <= 0) flashing?.cancel()
34 })
35}
36
37type Os = 'linux' | 'mac' | 'windows'
38type Alert = { title: string; body: string }
39
40let os: Os | undefined
41let isHyprland = false
42
43// No platform noun on $: Windows (and WSL, whose alerts go through Windows)
44// says so in its environment; elsewhere uname tells Linux from macOS.
45async function detectOs($: EngineInterface): Promise<Os> {
46 if (os) return os
47 const [winOs, wsl, hypr] = await Promise.all([
48 $.env.get('OS'),
49 $.env.get('WSL_DISTRO_NAME'),
50 $.env.get('HYPRLAND_INSTANCE_SIGNATURE'),
51 ])
52 isHyprland = Boolean(hypr)
53 if (winOs === 'Windows_NT' || wsl) return (os = 'windows')
54 const uname = await $.process.run(['uname', '-s']).catch(() => undefined)
55 return (os = uname?.stdout.trim() === 'Darwin' ? 'mac' : 'linux')
56}
57
58// Runs the first command that starts and exits 0; a missing tool falls through.
59async function firstThatWorks($: EngineInterface, commands: readonly (readonly string[])[]) {
60 for (const argv of commands) {
61 const ran = await $.process.run(argv, { timeoutMs: OVERLAY_MS + 10_000 }).catch(() => undefined)
62 if (ran?.exitCode === 0) return argv[0]
63 }
64 return undefined
65}
66
67const ps = (text: string) => `'${text.replaceAll("'", "''")}'`
68const applescript = (text: string) => `"${text.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`
69
70function sound($: EngineInterface, on: Os) {
71 if (on === 'mac') {
72 return firstThatWorks($, [['afplay', '/System/Library/Sounds/Glass.aiff']])
73 }
74 if (on === 'windows') {
75 return firstThatWorks($, [
76 ['powershell.exe', '-NoProfile', '-Command', "(New-Object Media.SoundPlayer \"$env:WINDIR\\Media\\Windows Notify System Generic.wav\").PlaySync()"],
77 ['powershell.exe', '-NoProfile', '-Command', '[System.Media.SystemSounds]::Exclamation.Play(); Start-Sleep -Milliseconds 800'],
78 ])
79 }
80 return firstThatWorks($, [
81 ['pw-play', '/usr/share/sounds/freedesktop/stereo/bell.oga'],
82 ['paplay', '/usr/share/sounds/freedesktop/stereo/bell.oga'],
83 ['canberra-gtk-play', '-i', 'bell'],
84 ['aplay', '-q', '/usr/share/sounds/alsa/Front_Center.wav'],
85 ])
86}
87
88function screen($: EngineInterface, on: Os, { title, body }: Alert) {
89 if (on === 'mac') {
90 return firstThatWorks($, [
91 ['osascript', '-e', `display notification ${applescript(body)} with title ${applescript(title)} sound name "Glass"`],
92 ])
93 }
94 if (on === 'windows') {
95 const script = [
96 'Add-Type -AssemblyName System.Windows.Forms',
97 '$n = New-Object System.Windows.Forms.NotifyIcon',
98 '$n.Icon = [System.Drawing.SystemIcons]::Warning',
99 '$n.Visible = $true',
100 `$n.ShowBalloonTip(${OVERLAY_MS}, ${ps(title)}, ${ps(body)}, 'Warning')`,
101 `Start-Sleep -Milliseconds ${OVERLAY_MS}`,
102 '$n.Dispose()',
103 ].join('; ')
104 return firstThatWorks($, [['powershell.exe', '-NoProfile', '-Command', script]])
105 }
106 return Promise.all([
107 isHyprland
108 ? firstThatWorks($, [['hyprctl', 'notify', '0', String(OVERLAY_MS), 'rgb(ffcc00)', `fontsize:30 ${title}: ${body}`]])
109 : undefined,
110 firstThatWorks($, [
111 ['notify-send', '--urgency=critical', '--app-name=Claude Code', `--expire-time=${OVERLAY_MS}`, title, body],
112 ]),
113 ])
114}
115
116async function ring($: EngineInterface, reason: string) {
117 const project = (await $.session.cwd()).split(/[\\/]/).pop() ?? ''
118 $.ui.toast(`🔔 Waiting for you: ${reason}`, { timeoutMs: 8000 })
119 $.ui.status(`🔔 WAITING FOR YOU — ${reason}`)
120 flash($)
121 // Off the hook's own dispatch: a Windows balloon holds its process for its
122 // whole life, and the turn must not wait on it.
123 $.clock.after(0, () => {
124 void detectOs($).then(on =>
125 Promise.allSettled([
126 sound($, on),
127 screen($, on, { title: `🔔 Claude is waiting — ${project}`, body: reason }),
128 ]),
129 )
130 })
131}
132
133async function start($: EngineInterface, kind: WaitingKind, reason: string, agentId?: string) {
134 repeat?.cancel()
135 const since = await $.clock.now()
136 await update($, waiting, (): Waiting => ({ kind, reason, since, rings: 1, isFlashOn: true, isPaused: false, agentId }))
137 await ring($, reason)
138 keepRinging($, reason)
139}
140
141function keepRinging($: EngineInterface, reason: string) {
142 repeat?.cancel()
143 repeat = $.clock.every(REPEAT_MS, () => {
144 void update($, waiting, w => (w ? { ...w, rings: w.rings + 1, isFlashOn: true } : w))
145 void ring($, reason)
146 })
147}
148
149function quiet($: EngineInterface) {
150 repeat?.cancel()
151 repeat = undefined
152 flashing?.cancel()
153 flashing = undefined
154 $.ui.status(undefined)
155}
156
157async function stop($: EngineInterface) {
158 quiet($)
159 await update($, waiting, () => null)
160}
161
162// The session works on a background task's notice: silent until that turn ends.
163async function pause($: EngineInterface) {
164 quiet($)
165 await update($, waiting, w => (w ? { ...w, isPaused: true, isFlashOn: false } : w))
166}
167
168async function hasBusyAgents($: EngineInterface) {
169 const list = await $.agent.list().catch(() => [])
170 return list.some(a => BUSY_AGENTS.has(a.status))
171}
172
173export const register: Register = on => {
174 // The person typed: whatever was waiting has its answer. A prompt from a
175 // background task's notice or a schedule is the session going back to work.
176 on('prompt.submit', async ($, e, next) => {
177 if (PERSON.has(e.origin.kind)) await stop($)
178 else await pause($)
179 return next(e)
180 })
181
182 // The main session finished its turn. It is the person's turn only when
183 // nothing it started is still running; a finished agent wakes it again.
184 on('turn.complete', async ($, e, next) => {
185 const result = await next(e)
186 if (e.agentId !== undefined || e.reason === 'aborted') return result
187 if (await hasBusyAgents($)) return result
188 const w = await read($, waiting)
189 if (w?.kind === 'turn') {
190 // Already waiting on the person since before a notice woke the session.
191 await update($, waiting, x => (x ? { ...x, isPaused: false } : x))
192 keepRinging($, w.reason)
193 return result
194 }
195 await start($, 'turn', e.reason === 'answer' ? 'your turn' : `turn ended (${e.reason})`)
196 return result
197 })
198
199 // A permission dialog or MCP elicitation is up.
200 on('classic.Notification', async ($, e, next) => {
201 if (WAITING_NOTIFICATIONS.has(e.notification_type)) {
202 await start($, 'dialog', e.message || 'permission needed', e.agent_id)
203 }
204 return next(e)
205 })
206
207 // A question or plan approval waits while the tool runs. A dialog is
208 // answered once a tool in the same loop finishes; a turn's wait is not.
209 on('tool.call', async ($, e, next) => {
210 if (ASKING_TOOLS.has(e.tool)) {
211 await start($, 'dialog', e.tool === 'ExitPlanMode' ? 'plan approval' : 'a question for you', e.agentId)
212 }
213 const result = await next(e)
214 const w = await read($, waiting)
215 if (w?.kind === 'dialog' && w.agentId === e.agentId) await stop($)
216 return result
217 })
218
219 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
220 const w = await read($, waiting)
221 if (w === null || w.isPaused || e.props.hasSurvey || e.props.isWorking) return next(e)
222
223 const { Box, Text, Button } = $.ui.resolve(e)
224 const at = new Date(w.since).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
225 const accent = w.isFlashOn ? 'red' : 'yellow'
226 return (
227 <Box flexDirection="column" borderStyle="double" borderColor={accent} paddingX={1}>
228 <Box justifyContent="space-between">
229 <Text color={accent} bold inverse>
230 {' 🔔 WAITING FOR YOU 🔔 '}
231 </Text>
232 <Button key="silence" label="Silence" onPress={() => stop($)} />
233 </Box>
234 <Text color={accent} bold>
235 {w.reason}
236 </Text>
237 <Text color="yellow">{`since ${at} · rang ${w.rings}× · rings again every 5 min until you answer`}</Text>
238 </Box>
239 )
240 })
241}
242types/index.d.ts 22 lines1// 'turn': the main session finished and waits for a prompt. 'dialog': a
2// permission prompt, question or plan approval waits for an answer.
3export type WaitingKind = 'turn' | 'dialog'
4
5export type Waiting = {
6 kind: WaitingKind
7 reason: string
8 since: number
9 rings: number
10 isFlashOn: boolean
11 // The session went back to work on its own (a background task's notice):
12 // quiet until that turn ends, then the same wait resumes.
13 isPaused: boolean
14 agentId?: string
15}
16
17declare module 'claude-code' {
18 interface PluginState {
19 'waiting-bell': { waiting: Waiting | null }
20 }
21}
22