claude-noti
v0.3.1
Published
Presence-aware notifications for Claude Code. Silent while you're watching the pane, a banner when you're not, a sound when you've walked away. macOS, Windows and Linux. Zero dependencies.
Maintainers
Readme
ClaudeNoti
Claude Code, but you can walk away.
npx claude-notiZero dependencies. macOS, Windows and Linux.
Why
Claude Code's value is that it works without you. But it stops — for permission prompts, for questions, for plan approvals — and you don't find out. So you either babysit the terminal, which defeats the point, or you come back twenty minutes later to discover it was blocked for nineteen of them.
The built-in terminal bell fires the same beep for everything and tells you nothing. With four sessions open across tabs and worktrees, a beep is not an answer to any question you actually have.
What it does
Three things, and the first is the one that matters.
1. It knows where you are
blocked done
───────────────────────────────────────
looking at it — —
another window ♪ banner banner
away ♪ banner ♪ banner
+ phone + phoneIf you're looking at the pane, you already know, and nothing fires. Step into another window and you get a banner. Walk away from the desk and it makes noise, because across the room a silent banner is the same as nothing — and if you're properly gone, it reaches your phone.
The best notification is the one that never fires. Everything else here is in service of that row.
And if you walk away afterwards, it comes back. Presence is read at the moment the event fires, which quietly assumes the moment lasts. It doesn't: a prompt you were looking at goes correctly silent, then you go and make coffee, and nothing ever fires again. So an unanswered prompt is re-checked at two minutes and again at ten, escalating only if you've actually left. Anything you do in that session — answering, typing, the turn ending — cancels the whole chain. That's what makes it trustworthy rather than clever.
2. It says what, not just that
Not "Claude needs your attention":
ClaudeNoti · needs permission Bash · git push --force origin main
StobaeusVoice · done · wrote 4 files The echo report endpoint now validates before finalising.
Both are read from the transcript Claude Code is already writing. No API call, no latency, no cost.
Turn on the optional events and it also tells you before something alarming runs, and when a build breaks:
ClaudeNoti · about to run force-push · git push --force origin main
StobaeusVoice · command failed npm test · exit 1
3. It tells you which terminal
The title is the project, because with five sessions running that's the only
thing that tells you where to go. With terminal-notifier installed, clicking
the banner takes you there — right window, right tmux pane.
brew install terminal-notifier # optional; enables click-to-focusInstall
npx claude-notiOne command does everything:
- detects your OS and installs what it needs —
terminal-notifierfor clickable banners on macOS, and it names the packages on Linux rather than installing quietly into a platform it can't yet support - offers the ⌥Z panel and sets up tmux for it
- runs
doctorautomatically on a first install, so you find out immediately rather than the first time something fails to appear - fires one real notification so you can see it work
- backs up
~/.claude/settings.json, merges rather than overwrites, and leaves other tools' hooks alone
Takes effect in sessions started from then on. Run it again later and it
upgrades in place, keeping your settings. --no-deps and --no-overlay skip
those steps.
To undo it:
claude-noti uninstall # keeps your settings
claude-noti uninstall --purge # deletes those tooCommands
claude-noti status, sessions, and where you are
claude-noti board every session on one screen, live
claude-noti phone pair a phone
claude-noti doctor check the wiring and explain what to fix
claude-noti test [event] blocked | waiting | risky | failed | done
claude-noti last [n] what it decided recently, and why
claude-noti focus [session] jump to the terminal that needs you
claude-noti events turn the optional events on and off
claude-noti segment one line, for a statusline
claude-noti off | on stop and resume
claude-noti mute 30m resume automatically later
claude-noti config print the effective settingsThe panel — ⌥Z
Press ⌥Z and the board appears over your work. Press it again and it's gone, with everything underneath exactly as you left it.
ClaudeNoti 2 ●
1 ● ClaudeNoti blocked 0:42
Bash · rm -rf dist
2 ◐ StobaeusVoice working 3:18
⌥Z to hideIt's a tmux popup, which means it's drawn over the pane rather than beside it. Claude Code isn't resized, redrawn, or detached — it just keeps running, and closing the panel leaves it exactly as it was. The primary thing stays primary. The panel also stays hidden entirely when no session is running.
claude-noti hotkey install # binds ⌥Z in ~/.tmux.conf
claude-noti hotkey status # is it wired, and will it reach this shell?
claude-noti hotkey removeRun Claude Code inside tmux, since tmux is what receives the key:
alias claude='tmux new -A -s claude claude'Why it isn't inside Claude Code. It can't be. Claude Code has no plugin API for panels, and
~/.claude/keybindings.jsonmaps keys only to its own internal actions — there's no "run a command" to bind to. Verified against 2.1.220. So the key has to be caught before Claude Code sees it, and tmux already sits in that position, reading every keystroke on the way through.The alternative was a global hotkey daemon like Hammerspoon. That was built first and then removed: it meant a GUI app resident all day, an Accessibility grant, and a different tool on every OS. tmux needs none of those and behaves identically on macOS, Linux and Windows.
Why ⌥Z and not ⌘⌥Z: macOS never delivers ⌘ combinations to a terminal program — the terminal swallows them. Option comes through as an escape prefix. ⌘ is only reachable via a background helper; ⌥ is reachable without one, which is the entire point.
One key does both directions: tmux opens the popup, and once open the keys go to the board inside, so the same ⌥Z arrives as an escape sequence the board reads as quit.
The full board
claude-noti board is the same board full-screen in its own pane, with keys
that work. It redraws when something changes rather than on a timer, so it costs
nothing to leave open in a split.
ClaudeNoti 2 ●
› 1 ● ClaudeNoti blocked 0:42
Bash · rm -rf dist
2 ◐ StobaeusVoice working 3:18
3 ✓ MarkR done 12:00
1-9 jump m mute ? keys q quit1–9 jumps to that session's terminal, m mutes a noisy one for half an
hour, q quits. It degrades down to 24 columns, dropping the state word before
it ever drops a project name.
For the same thing over your work instead of beside it, use ⌥Z above.
In your statusline
claude-noti segment prints one short line and exits — ● 2 blocked, or
nothing at all when nothing needs you. MarkR has no plugin API for third-party
segments, so this is the seam: call it from any statusline that runs a command.
Run claude-noti test blocked in the terminal you're looking at and it will
correctly refuse to notify you. That's not a bug to work around — it's the
product demonstrating itself. Add --force to hear it anyway.
When something seems missing, claude-noti last shows the decision and the
reason, including the silences:
12:51:39 blocked silent · here
12:47:02 done banner+sound Statusline renders git dirty state.Your phone
claude-noti phoneGenerates a random ntfy topic, copies the URL for you, and tells you the two steps on the phone. No account, no signup, iOS and Android.
Pushes are terse by default, and that is deliberate.
An ntfy topic is a URL. Anyone who learns it can read everything ever posted to it, and there's no undo. Notification bodies carry the command Claude wants to run and the last thing it said — repository names, file paths, and occasionally a secret sitting in an argv.
So over ntfy you get
ClaudeNoti · needs permissionand nothing more: the project, and what it needs. Set"mobile": { "detail": "full" }to opt in knowingly.
The topic name is the only credential in the system, which is why it's generated
from crypto.randomBytes and never something you choose.
If you'd rather nothing left your own account at all:
claude-noti phone imessage +15550100That texts you through Messages — your Mac to your own number, no third party involved, and it reaches an Apple Watch. Full detail is on by default there for exactly that reason.
Push only fires when you're away, and only for events that need an answer —
blocked, waiting, risky, failed. done is deliberately not on that list:
a phone that buzzes on every completed turn is a phone you silence within a day,
and then the ones that mattered don't reach you either. Add "done" to
mobile.events if you want it.
Optional events
Two features are off by default because they ride PreToolUse and
PostToolUse, which fire on every tool call:
claude-noti events risky on # force-push, rm -rf, prod, migrations, secrets
claude-noti events failed on # a build or test command exited non-zeroMeasured cost on an M-series Mac: about 27 ms per tool call, essentially all
of it Node startup. Use one command rather than editing the config by hand — the
flag and the hook have to move together, and claude-noti events … on does both.
doctor reports the drift if they ever come apart.
risky is the one event that speaks even when you're looking at the pane.
The presence rule assumes that if you're watching, you already know — true of a
permission prompt, which fills the screen, and false of the exact flags on a
command scrolling past. Patterns are narrow and named, and any one of them can
be switched off:
{ "risky": { "ignore": ["production"] } }Configuration
~/.claude/noti.json, or .claude/noti.json inside a project to override it
there. Anything you leave out keeps its default — there is no file to generate
and no defaults frozen into your config at install time.
{
"presence": {
"enabled": true, // false makes this an ordinary, always-firing notifier
"awaySeconds": 300 // no input for this long means you left the desk
},
"levels": { "blocked": "loud", "risky": "loud", "failed": "medium", "done": "soft" },
// presence x level -> channels. This table is the whole product.
"routes": {
"here": { "loud": [], "soft": [] },
"nearby": { "loud": ["banner", "sound"], "soft": ["banner"] },
"away": { "loud": ["banner", "sound", "push"], "soft": ["banner", "sound", "push"] }
},
// What happens when nobody answers. `after` is seconds from the event.
"escalation": {
"enabled": true,
"steps": [
{ "after": 120, "presence": ["nearby", "away"], "level": "loud" },
{ "after": 600, "presence": ["away"], "channels": ["push"] }
]
},
"mobile": {
"backend": "off", // ntfy | imessage | both | off
"detail": "terse", // terse | full — see the warning above
"events": ["blocked", "waiting", "risky", "failed"]
},
"events": { "risky": false, "failed": false }, // the every-tool-call hooks
"sounds": { "loud": "Ping", "soft": "Pop" }, // from /System/Library/Sounds
"banner": { "clickToFocus": true }
}Arrays are replaced wholesale, never merged — "loud": ["banner"] means exactly
that. So to add a risky pattern, copy the list and append to it.
A project that should never interrupt you:
// ~/scratch/.claude/noti.json
{ "enabled": false }How presence works
Three probes, none of which needs a permission prompt:
| | macOS | Windows | Linux |
|---|---|---|---|
| idle | ioreg HIDIdleTime | GetLastInputInfo | xprintidle |
| frontmost app | lsappinfo — faster than System Events, and no Accessibility grant | GetForegroundWindow | xdotool |
| active pane | tmux, when the session lives in one | — | tmux |
On Windows both facts come back from a single PowerShell call, because the cost there is the interpreter starting, and paying that twice to ask two questions would waste most of a second on every notification. It runs in the detached worker, never in the hook.
Rather than mapping TERM_PROGRAM onto application names — a table that is wrong
the day someone ships a new terminal, and ambiguous today, since VS Code, Cursor
and Windsurf all report vscode — ClaudeNoti records whichever app was frontmost
at the moment you last typed a prompt. At that instant you were, by definition,
looking at your terminal. It calibrates itself against whatever you use, and
re-calibrates on every prompt.
A probe that fails falls back to nearby, never to here. A broken detector
degrades into an ordinary notifier — mildly annoying, never useless. Silence is
the one unrecoverable failure: you never find out it happened. This is why
Wayland, where neither fact is obtainable, gets a notifier that always fires
rather than one that quietly stops.
How it works
Six hooks by default — SessionStart, UserPromptSubmit, PermissionRequest,
Notification, Stop, SessionEnd — plus PreToolUse and PostToolUse only
if you turn the optional events on. No daemon.
Claude Code waits on hooks, so the hook does almost nothing: parse the payload, write a small session file, hand the job to a detached worker, exit. Presence probes, transcript reads and banners all happen in that worker, which Claude Code has no handle on and never waits for. A hook takes about 27 ms, nearly all of it Node startup.
Escalation needs no daemon either. A blocked event spawns one detached timer
that sleeps, wakes, re-reads the session file, and acts only if the world still
looks the way it did. Cancellation is a data comparison rather than a signal:
every session carries an escalationId, stamped fresh whenever it enters a
blocked state, and a timer whose id no longer matches simply exits. Nothing has
to find it, signal it, or clean it up — including a timer whose machine slept
through its own deadline.
Session state is a directory of small JSON files under ~/.claude/noti/, which
is what makes the multi-session board a directory read rather than a service to
supervise.
Platforms
Notifications, sounds, presence and phone push work on all three. What differs is the two features that need to reach into the window manager.
| | macOS | Windows | Linux |
|---|---|---|---|
| banner | terminal-notifier, else osascript | toast via PowerShell | notify-send |
| sound | afplay | .NET system sounds | libcanberra / paplay |
| presence | ioreg + lsappinfo | GetLastInputInfo + GetForegroundWindow | xprintidle + xdotool, X11 only |
| phone push | ✓ | ✓ | ✓ |
| click to focus | ✓ | — | — |
| ⌥Z panel | ✓ | — (no tmux build) | ✓ |
Two honest caveats. Under Wayland there is no presence detection at all —
the protocol deliberately refuses to tell an unprivileged client what you are
looking at — so ClaudeNoti degrades to an ordinary notifier that fires every
time. And on Windows the ⌥Z panel does not exist, because it is a tmux
popup; claude-noti board in a split pane is the same board.
claude-noti doctor reports which of these your machine actually has, rather
than assuming.
Not yet
- Replying from your phone — approving a permission prompt from the notification. It's the obvious next prize and it's deliberately deferred: it needs something listening on the Mac to receive the answer and inject it into a live session, which is exactly the daemon this design avoids. That earns its own design round, not a corner of this one.
- A QR code in the terminal for pairing. Hand-written QR encoders are fiddly
in a way that fails silently — an attempt here was checked against a reference
encoder and was wrong in about a third of its modules, which would have shipped
as a code that looks convincing and doesn't scan. Fixing it properly meant a
dependency, and zero dependencies is the rule.
claude-noti phonecopies the URL and offers to open it, where ntfy renders its own.
Also deliberately absent, and not planned: Electron, a cloud account, remote control of Claude, and LLM-written summaries by default.
License
MIT
