FileChanged hook not triggered by real file deletion (unlink) outside session cwd on Windows, despite correct settings.json config and watchPaths from SessionStart

Status Open
Maintainer reply None cached
Activity 0 comments · opened Aug 28, 2026

Title

FileChanged hook not triggered by real file deletion (unlink) outside session cwd on Windows, despite correct settings.json config and watchPaths from SessionStart

Body

Summary

A FileChanged hook configured per the documented schema (matcher "SKILL.md", plus watchPaths supplied by a SessionStart hook) does not fire when a watched file is actually deleted (unlink) via an external process (Bash rm), even though:

  • the settings.json hook configuration is syntactically and structurally correct (verified by parsing it),
  • the SessionStart companion hook that emits watchPaths returns the correct, valid absolute paths (including the exact file that was deleted),
  • the FileChanged hook script's own logic is provably correct — calling it directly with a synthetic {file_path, event:"unlink"} payload produces the expected log entry and systemMessage output.

The only step that does not happen is: the host never invokes the FileChanged hook command when the actual file deletion occurs.

Environment

  • OS: Windows 11 (10.0.28120)
  • Claude Code: current version as of 2026-08-28
  • Working directory for the session under test: a non-git plain folder (Carreer operations)
  • Watched files: ~/.claude/scheduled-tasks/*/SKILL.md (absolute paths, outside the session's own cwd)

Configuration (settings.json, relevant excerpt)

"hooks": {
  "FileChanged": [
    {
      "matcher": "SKILL.md",
      "hooks": [
        {
          "type": "command",
          "command": "node \"C:\\Users\\<user>\\.claude\\scripts\\hooks\\scheduled-tasks-deletion-monitor.js\"",
          "timeout": 10
        }
      ]
    }
  ],
  "SessionStart": [
    {
      "matcher": "*",
      "hooks": [
        {
          "type": "command",
          "command": "node \"C:\\Users\\<user>\\.claude\\scripts\\hooks\\scheduled-tasks-watch-seed.js\"",
          "timeout": 10
        }
      ]
    }
  ]
}

scheduled-tasks-watch-seed.js reads the ~80 watched SKILL.md files under ~/.claude/scheduled-tasks/*/SKILL.md and returns:

{"watchPaths": ["C:\\Users\\<user>\\.claude\\scheduled-tasks\\aufgaben-board-abend\\SKILL.md", "...79 more..."]}

This output was verified correct by running the script directly (echo '{}' | node scheduled-tasks-watch-seed.js) — it lists the exact absolute path of the file used in the reproduction steps below.

Reproduction steps

  1. Confirm the settings.json above is active (session restarted / /compact run, so a fresh SessionStart fires with trigger: "compact").
  2. From the same session, run (via the Bash tool):

``
rm ~/.claude/scheduled-tasks/aufgaben-board-abend/SKILL.md
``
(a real filesystem deletion, not through the Edit/Write tool)

  1. Wait several seconds.
  2. Check whether the FileChanged hook command ran, e.g. by checking its log file (~/.claude/logs/scheduled-tasks-deletions.jsonl) or its systemMessage output.

Expected behavior

Per the documented FileChanged semantics ("it runs the hook no matter what changed the file: an Edit or Write tool call, a script Claude runs with Bash, or a process outside Claude Code entirely"), the hook command should run with {"file_path": "C:\\Users\\<user>\\.claude\\scheduled-tasks\\aufgaben-board-abend\\SKILL.md", "event": "unlink", ...} on stdin, producing a log entry and a systemMessage.

Actual behavior

No log entry is written, no systemMessage appears. This was tested twice:

  • Once by appending a line to the watched file (a "change" event) — by the hook's own design this correctly produces no alarm (only "unlink" alarms), so this test was inconclusive by itself.
  • Once with an actual rm of the watched file (genuine "unlink") — no hook invocation observed at all, in either case.

What has been ruled out

  • Config syntax/structure: settings.json was parsed with JSON.parse and manually verified — hooks.FileChanged and hooks.SessionStart both present and structured per docs.
  • watchPaths content: the SessionStart hook script was run standalone and returns the correct absolute path of the file used in the test.
  • Hook script logic: scheduled-tasks-deletion-monitor.js was invoked directly (both via CLI/stdin and by importing its exported functions) with a synthetic unlink event for the exact same file path — it correctly detects the watched path, writes the log entry, and returns the expected systemMessage. So the hook command itself, if invoked with the documented payload, behaves correctly.
  • SessionStart timing: the hook config was added mid-session (settings.json edited manually by the user after being blocked by the auto-mode classifier for direct edits); a /compact was run afterward specifically to trigger a fresh SessionStart (trigger=compact) within the same session, on the theory that watchPaths needs to be (re-)registered after a SessionStart that has this config active. This did not change the outcome.

Documentation cross-check

Before filing, I re-checked every assumption above against the current official docs (code.claude.com/docs/en/hooks.md) directly, rather than guessing:

  • FileChanged: "Claude Code detects changes with a filesystem watcher, not by inspecting tool calls, so it runs the hook no matter what changed the file: an Edit or Write tool call, a script Claude runs with Bash, or a process outside Claude Code entirely." — this explicitly covers our case (Bash rm), so the docs say this should have fired.
  • SessionStart output fields: watchPaths is documented as "Array of absolute paths to watch for FileChanged events during this session" — exactly the mechanism used here.
  • FileChanged output: "watchPaths — Array of absolute paths. Replaces the current dynamic watch list. Paths from your matcher configuration are always watched." — confirms matcher and dynamically-supplied watchPaths are meant to be additive, matching our setup (matcher "SKILL.md" + watchPaths from SessionStart).
  • matcher for FileChanged: "the value is split on | and each segment is registered as a literal filename in the working directory" — confirms matcher alone would only cover the session's own cwd, which is exactly why watchPaths was added for the other ~79 files outside cwd.

So per the documented spec, our configuration and expectations are correct, and the Bash rm reproduction case in particular should have triggered the hook even without watchPaths being involved at all (matcher-only coverage would still apply if cwd matched; watchPaths extends beyond that). Since it did not fire in either the matcher-covered or watchPaths-covered scenario, this looks like a genuine behavioral gap between docs and implementation rather than a configuration mistake — but posting here in case something Windows-specific or session-scoping-specific is undocumented.

Open question

Is there a known Windows-specific limitation in the filesystem watcher backing FileChanged/watchPaths (e.g. debounce/latency beyond what was tested, a required minimum file count, or scoping only to changes visible within the same OS-level watch handle)? Happy to provide more reproduction detail (e.g. via --debug output) if useful.

Why this matters

This hook exists specifically to catch accidental deletions of scheduled-task definition files caused by concurrent Claude Code sessions modifying the same shared ~/.claude/scheduled-tasks/ tree (we've had a real incident where one session's file was deleted by a race with another concurrent session). Without a working FileChanged + external-path watchPaths mechanism, there's no way to detect such deletions coming from outside the observing session.

View original on GitHub ↗