change_directory leaves $CLAUDE_PROJECT_DIR at the launch project, and the resulting ENOENT silently erases every user prompt (exit 2 reads as deny)

Status Open
Reported on v2.1.204
Maintainer reply None cached
Activity 1 comment · opened Sep 4, 2026

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.

  1. change_directory loads 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.
  1. The resulting ENOENT silently deletes user prompts. python3 exits with status 2 when it cannot open the script file it was handed. Exit 2 from a UserPromptSubmit hook 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

  1. Start a desktop session with no folder selected. The app places it in a scratch workspace, and CLAUDE_PROJECT_DIR is set to that scratch path.
  2. Use change_directory to move the session to a real project at /Users/me/my-project, which has a .claude/settings.json containing:

``json
{
"hooks": {
"UserPromptSubmit": [
{ "hooks": [ { "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/inject.py\"", "timeout": 10 } ] }
]
}
}
``

  1. 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.
  2. 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:

  1. Repoint CLAUDE_PROJECT_DIR when change_directory adopts 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.
  2. 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.
  3. Never erase a prompt silently. If a UserPromptSubmit hook 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-session cd. Same variable, different trigger: that one drifts within a project via the Bash tool; this one is pinned to a different, earlier project by change_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 — EnterWorktree not propagating to hook subprocesses. Third variant of the same underlying weakness: CLAUDE_PROJECT_DIR is not authoritatively bound to the session's active project.
  • #78505 — cloud multi-repo sessions never load repo settings, CLAUDE_PROJECT_DIR empty.

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗