Hook hot-reload silently breaks when settings.json is replaced (atomic save / git checkout / symlinked dotfiles)
Summary
Removing a hook from ~/.claude/settings.json is not picked up by a running session — every subsequent tool call keeps showing:
PreToolUse:Bash hook error Failed with non-blocking status code: /bin/sh: 1: /root/.claude/hooks/tpu-priority-gate.sh: not found
Restarting the session is the only fix. Recreating the symlink (~/.claude/settings.json → dotfiles target) does not help.
Environment
- Claude Code
2.1.128-dev.20260504.t010211.sha46e7d1c(also reproducible on2.1.134-dev) - Linux x86_64, Node v22.22.2
~/.claude/settings.jsonis a symlink to a dotfiles-managed file (/root/src/dotfiles/users/<user>/.claude/settings.json)- The settings file was edited with a tool that does an atomic save (write tempfile →
rename()over target). This is what vim, VS Code,git checkout, and Claude Code's own Write/Edit tools all do.
Root cause (confirmed via /proc and inotify inspection)
When CC starts, it sets up an inotify watch on the resolved inode of the settings file:
$ cat /proc/<cc_pid>/fdinfo/<inotify_fd> | grep <inode>
inotify wd:75 ino:bc4249 sdev:10300005 mask:4000c82 ...
Mask 0x4000c82 = IN_EXCL_UNLINK | IN_MOVE_SELF | IN_DELETE_SELF | IN_MOVED_TO | IN_MODIFY.
Notably IN_ATTRIB (0x004) is not set and the parent directory is not watched.
When the user edits the file via atomic save, rename() clobbers the target:
- The original inode's hard-link count drops to 0. The kernel fires
IN_ATTRIBon the old inode (link-count change, seefsnotify_link_count()invfs_rename()).IN_ATTRIBis not in CC's mask → CC receives nothing. IN_DELETE_SELFdoes not fire because the inode is kept alive by the inotify watch + a leaked open read-only fd inside CC (/proc/<cc_pid>/fd/185 -> .../settings.json (deleted)). It only fires when the inode is finally evicted, which never happens while the session is alive.IN_MOVED_TO/IN_CREATEwould fire on the parent directory — but the parent dir isn't watched.
Result: CC's watch is permanently attached to a dead inode (bc4249) while the live file is at bc31ae. The watch never fires again.
Recreating the symlink doesn't help because the watch is on the target file's inode, not the symlink, and not the directory. ~/.claude/ directory isn't watched either.
Reproduction without the symlink
This is not symlink-specific. Any atomic-save replacement of a watched settings file breaks it:
T=~/.claude/settings.json
cp $T $T.tmp
# remove a hook from $T.tmp
mv $T.tmp $T # atomic rename — watch is now dead
Additional observation
Even forcing an IN_MODIFY on the dead inode (via cat new.json > /proc/<pid>/fd/<n>, which the kernel does deliver — verified with a standalone inotify test) does not cause CC to refresh its hook registry. The next tengu_run_hook event still shows numCommands:5 (3 user hooks from the old config + 2 plugin hooks) instead of the expected 3. So even when the watcher DOES fire, the hook registry doesn't refresh.
Suggested fix
Any of:
- Watch the parent directory of each settings file in addition to the file itself, and re-establish the file watch when an
IN_MOVED_TO/IN_CREATEmatching the settings filename fires. - Add
IN_ATTRIBto the watch mask and re-stat/re-watch the path when the watched inode's link count changes. - Use polling (
fs.watchFile()/ stat-based) for settings files specifically — they're small and rarely change, so the cost is negligible and polling always re-resolves the path. - Add a manual
/reload(or hook reload to/hooks) so users can force a refresh without losing running monitors / background tasks.
Workaround (for users hitting this)
Restart the session. There is no in-session way to force a settings reload.
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗