A tiny ping for Claude Code and opencode. Get a small bell sound the moment your agent needs your attention — a permission prompt, or finishing and going idle waiting for you — so you can look away and come back when it matters. It stays quiet during normal work.
- One script, two targets.
claude(a hook inlined intosettings.json) oropencode(a single auto-loaded plugin file). No files copied around for either beyond what each tool expects. - Works over SSH. The bell travels through your terminal, so a session on a remote server still pings your laptop.
- Any OS. macOS, Linux, Windows (WSL / Git Bash) — the sound is made by your local terminal, not the machine the agent runs on.
Pick a target: claude (default), opencode, or all (both).
sh install.sh # claude (default)
sh install.sh opencode # opencode
sh install.sh all # bothor via the one-liner (defaults to claude; pass a target with sh -s --):
curl -fsSL https://raw.githubusercontent.com/TLGINO/claude-bell/master/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/TLGINO/claude-bell/master/install.sh | sh -s -- opencodeYou can also set TARGET=opencode sh install.sh. Then restart the tool (or start
a new session). That's it.
No installer for opencode? Just copy
opencode-bell.jsinto~/.config/opencode/plugins/(global) or.opencode/plugins/(project) and restart opencode.
Adds a single hook to your
Claude Code settings.json — the Notification event, scoped with the
matcher permission_prompt|idle_prompt:
permission_prompt→ Claude is blocked needing your approval.idle_prompt→ Claude finished and has been waiting for your next prompt (Claude Code debounces this ~60s, so it only fires once you've actually left).
It deliberately does not bind the Stop hook. Stop fires on every
assistant turn — and after every subagent — so a bell on Stop rings constantly
during normal work. Notification fires only when Claude actually wants you.
As a fallback for older Claude Code (before typed Notification matchers), the hook
also reads the stdin payload and rings only when notification_type is
permission_prompt, idle_prompt, or absent — staying silent for auth_success
and MCP elicitation_*.
opencode has no hooks field in its config; instead it auto-loads plugins
(JS/TS files) from:
~/.config/opencode/plugins/— global.opencode/plugins/— project
This installs one plugin, opencode-bell.js, that subscribes to the opencode
event bus and rings on two events:
session.idle→ opencode finished a turn and is waiting for your next prompt (≡ Claude Code'sidle_prompt). Only root sessions ring — subagent/child idles (e.g. a finished Task agent) are tracked viasession.createdand suppressed, so you only hear it when the main session actually needs you back.permission.asked→ opencode is blocked needing your approval (≡ Claude Code'spermission_prompt).
It deliberately does not ring on message.updated or tool.execute.* —
those fire constantly during normal work. session.idle fires only when opencode
actually wants you back, and subagent idles are filtered out so multi-step tasks
with delegation stay quiet until they're truly done.
Both targets run a self-contained one-liner (Claude: detached hook subprocess;
opencode: shell spawned via the plugin's Bun $) that writes the terminal bell
character to your terminal. There's no controlling terminal, so /dev/tty is
usually gone (No such device or address). The bell builds an ordered list of
candidate terminals and rings the first one that accepts a write:
# 1. $SSH_TTY remote / SSH
# 2. ancestor terminals: walk parent PIDs (ps -o ppid=), read each tty (ps -o tty=);
# try both /dev/$d (Linux -> /dev/pts/0) and /dev/tty$d (macOS s000 -> /dev/ttys000)
# 3. /dev/tty Git Bash/MSYS, mintty, real controlling ttys
# 4. stdout last resort
printf '\a' > "$t" # ×2, with a short gapThis is why it works across OSes and over SSH: on a remote box $SSH_TTY
points back at your laptop's terminal; on a local session the ancestor walk
finds the claude/opencode process's terminal (/dev/pts/0 etc.) that the
detached/in-process hook can't reach via /dev/tty alone.
Edit the variables at the top of install.sh before running:
| Variable | Default | Meaning |
|---|---|---|
BEEPS |
2 |
beeps per ping |
GAP |
0.3 |
seconds between beeps |
- claude respects
CLAUDE_CONFIG_DIRif you've moved your Claude config. - opencode respects
OPENCODE_CONFIG_DIR/XDG_CONFIG_HOME, and also readsOPENCODE_BELL_BEEPS/OPENCODE_BELL_GAPenv vars at runtime (opencode inherits your shell environment), so you can tune without reinstalling.
To change which events ring for opencode, edit the filter in the event hook of
opencode-bell.js.
The script sends a test bell at the end of install. If it's silent, your local terminal emulator's audible bell is turned off:
- iTerm2: Profiles → Terminal → uncheck Silence bell
- Windows Terminal: settings →
"bellStyle": "audible" - GNOME Terminal / many Linux: Preferences → Sound → enable Terminal bell
- macOS Terminal.app: Settings → Profiles → Advanced → Audible bell
sh uninstall.sh # claude (default)
sh uninstall.sh opencode # opencode
sh uninstall.sh all # both- claude: removes only the bell hook (tagged
claude-bell) fromNotificationandStop; your other hooks are left untouched. Backs upsettings.jsonbefore editing. - opencode: removes
opencode-bell.js(and any.bakcopies) from the global plugins dir, plus a project-local install if present.
- both:
shplusps/tr/sleep(standard on macOS, Linux, WSL, Git Bash). - claude: additionally
jq,python3/python, ornode(any one) to edit the JSON. If none are present, the installer prints the snippet to paste manually. - opencode: nothing extra — the installer just writes a file.
MIT