SLOPSHOPPER

waiting-bell

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

newbandguardtoaststatusprompt
★ 3v0.2.0MITupdated 2026-10-10ibrahimkobeissy/claude-mods/mods/waiting-bell
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · waiting-bell
› fix the failing auth test and add an audit log call ╭──────────────────────────────╮ │ waiting-bell │ ⏺ Read(src/auth.ts) │ 🔔 Waiting for you: your │ ⎿ Read 6 lines │ turn │ ⏺ 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 ╔══════════════════════════════════════════════════════════════════════════════════════════════════════════════════╗ ║ 🔔 WAITING FOR YOU 🔔 [ Silence ] ║ ║ your turn ║ ║ since 08:53 AM · rang 1× · rings again every 5 min until you answer ║ ╚══════════════════════════════════════════════════════════════════════════════════════════════════════════════════╝ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ waiting-bell: 🔔 WAITING FOR YOU — your turn

Draws

Band
╔══════════════════════════════════════════════════════════════════════════════════════════════════╗ ║ 🔔 WAITING FOR YOU 🔔 [ Silence ] ║ ║ your turn ║ ║ since 08:53 AM · rang 1× · rings again every 5 min until you answer ║ ╚══════════════════════════════════════════════════════════════════════════════════════════════════╝
README

🔔 waiting-bell

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

Install

/plugin marketplace add ibrahimkobeissy/claude-mods
/plugin install waiting-bell@claude-mods

When it rings

Claude is waiting because…Banner says
It finished its turnyour turn
A permission dialog is openthe permission message
An MCP server asks for inputthe 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:

  • Not while background agents are still running. When they finish, the main session is woken to handle them, so it isn't your turn yet.
  • Not for a finished background shell or agent. The session handles the notice by itself. If you were already being called, that same wait carries on afterwards without ringing again.
  • Not for subagent turns or turns you interrupted.

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.

What each ring does

  • A banner above the prompt that flashes red/yellow for ~10 seconds, with when it started and how many times it has rung
  • A status line entry and a toast in Claude Code
  • A sound and a desktop notification, using whatever your OS already has:
OSSoundNotification
Linuxpw-play → paplay → canberra-gtk-play → aplay (first that works)notify-send (critical), plus hyprctl notify under Hyprland
macOSafplay (Glass)osascript notification
Windows / WSLPowerShell SoundPlayerPowerShell tray balloon

Permissions and privacy

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.

Develop

claude --plugin-dir mods/waiting-bell       # try it live; saves hot-reload
scripts/validate.sh mods/waiting-bell       # validate + run tests/
Source 2 files
hooks/register.tsx 242 lines
1import { 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}
242
types/index.d.ts 22 lines
1// '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