change_directory leaves $CLAUDE_PROJECT_DIR at the launch project, and the resulting ENOENT silently erases every user prompt (exit 2 reads as deny)
Environment
- Claude Code: 2.1.204 (CLI reported on the same machine; the failing session ran in the Claude desktop app)
- OS: macOS 26.5.2, arm64
- Node: v24.15.0
- Hook interpreter: python3 3.14.5
- Hook form: shell form,
"command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/<name>.py\" --inject"
Summary
Two defects, one of which is much worse than the other.
change_directoryloads the new project's hooks but does not repoint$CLAUDE_PROJECT_DIR. It stays at the directory the session was launched in. Every hook registered by the newly-adopted project therefore resolves to a path under the old directory, where nothing exists.
- The resulting ENOENT silently deletes user prompts.
python3exits with status 2 when it cannot open the script file it was handed. Exit 2 from aUserPromptSubmithook means block this prompt and erase it. So a file-not-found is indistinguishable from a deliberate policy deny, and the user's input is destroyed with no error surfaced in the session.
The second is the real bug. The first is a path-resolution wrinkle; the second turns any such wrinkle into silent, unrecoverable input loss.
Repro
- Start a desktop session with no folder selected. The app places it in a scratch workspace, and
CLAUDE_PROJECT_DIRis set to that scratch path. - Use
change_directoryto move the session to a real project at/Users/me/my-project, which has a.claude/settings.jsoncontaining:
``json``
{
"hooks": {
"UserPromptSubmit": [
{ "hooks": [ { "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/inject.py\"", "timeout": 10 } ] }
]
}
}
- The session correctly reports that project settings and hooks now come from
/Users/me/my-project, and correctly notes that environment variables from the previous directory persist for the process. - Submit any prompt.
Expected: the hook runs /Users/me/my-project/.claude/hooks/inject.py, as the docs describe $CLAUDE_PROJECT_DIR ("regardless of the working directory when the hook runs").
Actual: it runs
<scratch-workspace>/.claude/hooks/inject.py
which does not exist. python3 prints can't open file '…': [Errno 2] No such file or directory and exits 2. The session records:
UserPromptSubmit operation blocked by hook:
[python3 "$CLAUDE_PROJECT_DIR/.claude/hooks/inject.py"]: … [Errno 2] No such file or directory
Original prompt: <the user's prompt>
The prompt never reaches the model. Every subsequent prompt meets the same fate. From the user's side the session is simply unresponsive — and critically, asking the assistant what is wrong is itself a prompt, so the question about the failure is destroyed by the failure. There is no way to diagnose this from inside the session.
Observed in the wild: four consecutive prompts erased in 33 seconds before the user gave up.
Why exit 2 is the wrong contract here
The UserPromptSubmit exit-code contract collides with CPython's own startup convention. python3 returns 2 for a missing script file, and argparse also exits 2 on a usage error. Both are hook malfunctions, and both are read as an intentional deny.
Any hook whose interpreter reuses exit 2 for its own startup errors will eventually issue a deny it never meant to. The failure is silent, destroys user input, and suppresses the report of itself. That combination seems worth treating as a bug in its own right, independent of the path resolution.
Suggested fixes
Either of the first two would have prevented this outright:
- Repoint
CLAUDE_PROJECT_DIRwhenchange_directoryadopts a new project, since the hooks, permissions, MCP servers and skills are all re-read from that project already. The env var is the one thing left behind. - Distinguish "the hook could not run" from "the hook denied". If the command cannot be executed (ENOENT on the script or the interpreter), treat it as an execution error, not a deny — the way a non-zero exit other than 2 is already treated as non-blocking.
- Never erase a prompt silently. If a
UserPromptSubmithook blocks, the block and its reason should be visible to the user in the transcript, not only in the JSONL. Related: #88830.
Workaround
Shell-form hooks can defend themselves by resolving the directory with a fallback:
H="$CLAUDE_PROJECT_DIR/.claude/hooks"; [ -f "$H/inject.py" ] || H=/Users/me/my-project/.claude/hooks; python3 "$H/inject.py"
Worktrees still win, since they carry their own hooks and satisfy the -f test. This is not available to exec-form hooks (args array, no shell), which have no way to express a fallback — see #81291.
Appending || true also prevents prompt loss, but only for hooks that are pure injectors. It cannot be applied to a hook that legitimately needs to refuse, which is exactly the class of hook that most needs the path to be right.
A third, smaller point
SessionStart hooks do not fire on a mid-session change_directory. So a session that adopts a project this way runs with that project's PreToolUse / UserPromptSubmit hooks active but its SessionStart setup never having run — a partially-initialised state the project's own guardrails may assume cannot happen. Fixing (1) does not fix this.
Related
- #81291 —
${CLAUDE_PROJECT_DIR}resolving against a drifted cwd after a mid-sessioncd. Same variable, different trigger: that one drifts within a project via the Bash tool; this one is pinned to a different, earlier project bychange_directory. Neither covers the exit-2 amplification. - #88830 — hook execution failures invisible in the desktop app with zero diagnostics. Directly compounds this: the block was only recoverable by reading the session JSONL by hand.
- #87890 —
EnterWorktreenot propagating to hook subprocesses. Third variant of the same underlying weakness:CLAUDE_PROJECT_DIRis not authoritatively bound to the session's active project. - #78505 — cloud multi-repo sessions never load repo settings,
CLAUDE_PROJECT_DIRempty.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗