claude-notify-resume
CLAUDE.md1 starsChanged 4 months agoMITView on GitHub
Notes for working on this repo with Claude Code. Start with CONTRIBUTING.md for tests and the release process; this file only records what is easy to get wrong. A hook must never break the user's session. These scripts run on every turn, so every failure path exits 0 silently — missing binary, malformed payload, unreadable transcript. Do not add a path that can exit non-zero, and do not "improve" error handling by surfacing errors to the user. - **bash -x…
- Commits and pushes
In this repositoryOne other file it gives its agents.
- plan-taskSkill
The file
We split this CLAUDE.md into the rules it gives an agent
3 rules. About 0 are common, 2 are distinctive, and 1 is very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈2
Distinctive · estimate
This author's own choices. Worth a look.
1
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 73
The click handler gets a minimal PATH too,: not just the hook. Missing this made open-session.sh report "VS Code CLI not found" on a …
- Distinctive: line 165
When a change corrects a wrong assumption — especially one that took an experiment to settle — add it to the traps above in the same commit.
- Distinctive: line 172
Verify the test, not just the code: After adding an assertion, break the fix on purpose and confirm the test fails.
≈ “Common” is an estimate: at least 1 in 5 of the 26,980 CLAUDE.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
CLAUDE.md
Notes for working on this repo with Claude Code. Start with CONTRIBUTING.md for tests and the release process; this file only records what is easy to get wrong.
The one rule
A hook must never break the user's session. These scripts run on every turn, so every failure path exits 0 silently — missing binary, malformed payload, unreadable transcript. Do not add a path that can exit non-zero, and do not "improve" error handling by surfacing errors to the user.
Traps that already cost hours
bash -xtraces a command and still runs it. Asserting on a trace is how the macOS suite used to deliver real banners on every test run. Use theCLAUDE_NOTIFY_BINstub directory instead —test/run-macos-tests.shshows the pattern.- Never change
BUNDLE_IDinscripts/setup-macos-icon.sh. macOS grants notification permission per bundle id, once, on that id's first appearance. A new id turns every existing install into an unapproved sender whose banners vanish with no error and a success exit code. - A
-titlestarting with[is swallowed byterminal-notifier, which falls back to its own default of "Terminal". Hence theTEST ·marker rather than[TEST]. notify.shdeliberately puts/opt/homebrew/binahead of the caller'sPATH— hooks inherit a minimal environment wherenodeandterminal-notifierare both missing. Prepending a stub directory toPATHtherefore does not shadow them; that is whatCLAUDE_NOTIFY_BINis for.ai-titleis an internal transcript record, not a public API. It is how the banner learns the chat's name. Keep the fallback to the project directory name working.- A manual chat rename is a
custom-titlerecord, not a newai-title. Worse, after a rename the transcript keeps re-emitting the oldai-titleon every turn, after eachcustom-title— so "last title record wins" silently reverts the rename. The parser must prefercustom-titleby type; position in the file proves nothing. - Never spoof the sender with a fake, script-only bundle. The
-senderscheme swallowed the body click by design, and macOS's willingness to launch the fake app on click proved undocumented and flaky: it worked, then refused (unsigned), then launched at delivery time after signing, then went silent even with a freshusernoted. The answer was a real compiled app (claude-notify.swift) that posts natively — the icon and the body click stopped being enemies the moment the notification had a real owner. usernotedcaches the notification→app binding. Rebuilding a bundle leaves banners bound to the old snapshot;killall usernotedresets the daemon (it restarts itself). A useful diagnostic, though not a fix for the spoofed-sender scheme above.- A cold
activateof Terminal opens its startup window, so ado scripton top yields two windows — one empty, one yours. Checkapplication "Terminal" is running(which does not launch it) and reuse the startup window on a cold start. Same for iTerm. - Stub probes need
</dev/null. Anosascript -e …call without a heredoc inherits the caller's stdin; the test stub reads stdin to capture heredoc bodies, so without the redirect the suite hangs forever. - Never build the click action by nesting quotes. It once nested a shell
command inside an AppleScript literal inside a single-quoted
-executearg; a space produced\and an apostrophe closed a layer early, both silently. The click now callsopen-session.shwithprintf %qarguments — keep any new logic in that script rather than growing the string again. TERM_PROGRAMis empty in the hook environment, so the host app cannot be detected at click time (by then the frontmost app is Notification Centre).notify-macos.shwalks the process tree while the hook runs and passesCLAUDE_NOTIFY_HOSTthrough to the click.- A bare
osascriptnotification is owned by Script Editor — clicking it opens Script Editor's file dialog. Send user-facing notifications throughterminal-notifierwhen it is available (notify()inopen-session.sh). - Claude Code overwrites a Terminal tab's
custom titlewith the chat's own name, so a marker written there does not survive and tab reuse silently degrades to a new window per click. Match the tab by tty instead — find it withps ax -o tty=,command=againstclaude --resume <id>. - The click handler gets a minimal PATH too, not just the hook. Missing
this made
open-session.shreport "VS Code CLI not found" on a machine wherecodewas installed — it simply was not on the click's PATH. - The state paths still say
claude-code-notify—~/.claude/claude-code-notifyand~/.local/state/claude-code-notify— and that is deliberate. The plugin was renamed toclaude-notify-resume, but renaming those directories would orphan the click state and icon bundle of every existing install for no user-visible gain. Same reasoning asBUNDLE_ID: internal identifiers stay put. - shellcheck versions disagree, so CI pins one. Ubuntu's apt ships 0.9.0,
which flags
A && B || C(SC2015) where 0.11.0 stays quiet — a lint that was clean locally failed on the first CI run. The workflow fetches 0.11.0 directly and then runsnpm run lint, so CI and a developer run the same check. - Marketplaces are keyed by the
nameinsidemarketplace.json, not by repo URL. This repo andclaude-video-digestboth declared"name": "anaidenko", so adding the second one silently took the slot — the first stayedenabled: truein settings whileclaude plugin listreportedfailed to load, and its hooks died with no error anywhere. It read as "the plugin broke", and cost a full debugging session. Both now live inanaidenko/claude-plugins, and neither repo carries amarketplace.json. Noteclaude plugin marketplace updatedoes not re-read a changedname; that needsremove+add. git push --tagspushes only tags, not the branch. It left v1.1.2–v1.1.5 on GitHub pointing at commits that had never been pushed, whilemainstill showed 1.1.1 — so the marketplace kept serving the old version. Usegit push --follow-tags.- Never bake a plugin-cache path into the icon bundle. Updating installs a
new
<version>/directory and deletes the old one, so a path fixed at build time dies on the next update and the click silently does nothing. Resolve the newestcache/*/claude-notify-resume/*/scripts/at click time. - Old banners carry the command that was current when they were sent. A
banner sitting in Notification Centre still runs the old click action, so
clear them before testing a change to it, or you will debug a fix that is
already in place.
terminal-notifier -remove ALLcovers less than it sounds like:ALLis every group posted through terminal-notifier — including other projects' banners, since they all share thefr.julienxx.oss.terminal-notifierbundle — while banners from the native bundle (the main path since 1.1.9) are not its to remove and-list ALLdoes not even see them. Clear those by hand in Notification Centre. Per-session removal is-remove claude-<session-id>(the group set innotify-macos.sh); there is no wildcard. - One session can appear twice in
ps. The VS Code extension spawns a copy with no controlling terminal, printed as??and listed before the real tab, so{ print $1; exit }returned a tty no tab can own — the lookup failed and every click opened another window next to the session already running. Skip ttyless rows ($1 != "??"). The test that was meant to cover this asserted on its own inline copy of the awk, so it stayed green regardless of what the script did; it now lifts the expression out ofopen-session.sh. - Sort version directories numerically, not as strings. The bundle's
fallback picks the newest installed copy, and
"1.1.10" < "1.1.9"lexicographically — so the first two-digit release quietly routed every fallback click into the previous version, the stale code the fallback exists to escape. Latent from the day the fallback was written; it woke up at 1.1.10. application "X" is runninglies in the click handler's environment. It answers from the caller's Launch Services session, and both the hook and the click run stripped, so it returnsfalsefor a Terminal that is plainly on screen. The cold-start branch then fired on a warm start and produced the extra empty window it exists to prevent — the bug survived a release because the suite stubbed the probe's answer instead of its truthfulness. Usepgrep -x, which asks the kernel and launches nothing. Note the process isiTerm2even though the app isiTerm; matching the app name finds nothing and makes every start look cold.- A hook is finished when its pipes close, not when it exits. Claude Code
reads the hook's stdout/stderr to EOF, and every descendant inherits those
descriptors — so one blocking grandchild stalls the turn even though
notify.shreturned long ago. That was the 2026-08-26 hang: ~60s of spinner on every turn, bisected to this plugin alone. The blocked process wasterminal-notifierinvoked with-sender com.claude-code.notify(the pre-1.2 icon scheme), caught live inpsat 60s+ and still climbing;sampleshowed it parked in-[NSApplication run]— an AppKit event loop it never leaves, because-sendermakes it wait on a Launch Services round-trip to a bundle whose registration had gone stale. Killing that grandchild collapsed the whole chain instantly, which is the proof the wait was one-directional. Note the suspicion at the time was the new nativeclaude-notify postpath; it was innocent — the culprit was the old cache version still on disk. Delivery now runs detached with stdio closed (</dev/null >/dev/null 2>&1 &), which fixes the whole class regardless of which notifier blocks. Test it by asserting the pipes close (… | cat), never by timing the script's exit — the script exited fast in the bug too. - The VS Code extension never emits the
Notificationhook event.Stopfires there, soRepliedbanners arrive and the plugin looks healthy — butPermission needed,Waiting for youand every other Notification-based status cannot fire in a side-panel session, and no hooks.json change will help. Known Claude Code parity bug: anthropics/claude-code #59718, #31285, #26925, #11156. Diagnose it from transcripts, not theories: the machine's~/.claude/projects/*/*.jsonlshowedstop_hook_summaryrecords runningnotify.sh "Replied"while noNotificationhookEvent existed in any transcript at all. The README states the limitation; keep it there until the upstream bug closes for real (the linked issues close as duplicates, not fixes).
Keeping this file honest
When a change corrects a wrong assumption — especially one that took an experiment to settle — add it to the traps above in the same commit. The bar is "cost real time and is not visible in the code", not "might be interesting": this file earns its keep only if it stays short enough to read in full.
Two habits that paid off repeatedly here, both worth continuing:
- Verify the test, not just the code. After adding an assertion, break the
fix on purpose and confirm the test fails. Several "passing" tests here proved
vacuous — one asserted on a
sedexpression that mangled its own input, another checked a branch the machine never took. - Prefer an experiment to a plausible theory.
LSUIElement, the alert style, and the notification style were all convincing explanations for the click problem. All three were wrong; a two-minute test with-executeand no-sendersettled it.
Verifying a change
npm test # macOS, fast
npm run test:linux # Linux, in Docker
npm run lint # shellcheck — keep it at zero warningsBoth suites run the scripts straight out of the repo, so neither needs the plugin installed. Add a test with any new failure path — the Linux icon path was silently broken precisely because nothing asserted on it.
To see a real banner rather than assert on arguments: npm run notify:demo.
Planning a change
For anything larger than a one-file edit, /plan-task writes a structured plan to
.claude/plans/ before any code is touched — see
.claude/skills/plan-task/SKILL.md. Plans are
gitignored personal working documents; commit one only when asked.
Platform reality
macOS is the developed and daily-used path. Linux is covered by the container suite, but no banner has ever been observed on a real Linux desktop — the container has no notification daemon. Windows is deliberately absent rather than shipped untested; do not add a Windows path that cannot be run.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

