`/exit` hangs forever on "Cleaning up worktree…" when `claude --worktree` was launched from inside the worktree
Environment
- Claude Code 2.1.191 (native binary, Homebrew cask, darwin arm64)
- macOS (Darwin 25.5.0)
WorktreeCreate/WorktreeRemovehooks configured in~/.claude/settings.jsonthat relocate worktrees outside the repository (hook-based worktrees)
Summary
If a claude --worktree <name> session is launched from inside the worktree itself (so the session's recorded original cwd is the worktree), then at /exit the automatic cleanup succeeds — the WorktreeRemove hook removes the worktree — but Claude then hangs forever on the spinner:
✶ Cleaning up worktree (no pending changes)…
The process never exits (event loop idle, no child processes, cwd pointing at a deleted inode). The user has to kill it.
Launching from inside the worktree is a natural setup for terminal multiplexers/launchers that create the worktree first and then open a workspace/pane rooted in it running claude --worktree <name> (which re-enters the WorktreeCreate hook and reuses the directory).
Reproduction
Minimal relocating hooks in ~/.claude/settings.json:
{
"hooks": {
"WorktreeCreate": [{"hooks": [{"type": "command", "command": "bash ~/wt-create.sh"}]}],
"WorktreeRemove": [{"hooks": [{"type": "command", "command": "bash ~/wt-remove.sh"}]}]
}
}
~/wt-create.sh (creates/reuses a worktree outside the repo, prints its path):
#!/usr/bin/env bash
set -euo pipefail
input=$(cat)
name=$(printf '%s' "$input" | jq -r '.name')
cwd=$(printf '%s' "$input" | jq -r '.cwd')
root=$(git -C "$cwd" rev-parse --show-toplevel)
dir="$HOME/tmp-worktrees/$name"
if ! git -C "$root" worktree list --porcelain | grep -qxF "worktree $dir"; then
git -C "$root" worktree add -b "worktree-$name" "$dir" HEAD >&2
fi
printf '%s\n' "$dir"
~/wt-remove.sh (removes worktree and branch):
#!/usr/bin/env bash
set -uo pipefail
wt=$(cat | jq -r '.worktree_path')
main=$(dirname "$(git -C "$wt" rev-parse --path-format=absolute --git-common-dir)")
git -C "$main" worktree remove --force "$wt"
git -C "$main" branch -D "$(git -C "$main" branch --list "worktree-*" --format='%(refname:short)' | head -1)" 2>/dev/null
exit 0
Steps:
- In any git repo: create the worktree by invoking the create hook (or run
claude --worktree demoonce from the main checkout and keep it). cd ~/tmp-worktrees/demo— into the worktree.claude --worktree demo(the create hook reuses the existing directory; session cwd = the worktree)./exitwith a clean tree.
Expected: worktree removed, Claude exits.
Actual: worktree removed, Claude hangs forever on "Cleaning up worktree (no pending changes)…".
Control: repeat with step 2 replaced by cd <main checkout> — cleanup completes and Claude exits in under a second. (Verified both ways, repeatedly.)
Diagnosis (from the 2.1.191 bundle)
The exit dialog's auto-clean path runs (minified):
s("removing-clean"), yht().then((Z)=>{ Zyt(f.originalCwd), ..., s("done") }) // no .catch
yht()(the cleanup function) is internally try/caught and completes fine — with a--debug-fileattached, its final log lineLinked worktree cleaned up completelyis the last line ever written.Zytis:
function Zyt(e){process.chdir(e),j_(e),jIm(),OT.cache.clear?.()}
process.chdir(originalCwd) is unguarded. When the session was launched from inside the worktree, originalCwd is the directory the hook just deleted, so chdir throws ENOENT. The .then callback dies before s("done"), there is no .catch/rejection handler on the chain, so the dialog state never advances and the spinner renders forever on an idle event loop.
Supporting observations from a hung process:
lsof -p <pid> -d cwd→ cwd is the deleted worktree directory (deleted inode).sample <pid>→ main thread parked inkevent64(idle uv loop); no child processes.- Debug log ends at
Linked worktree cleaned up completely; theWorktreeRemovehook completed with status 0 and the worktree/branch are genuinely gone.
Notably, yht() itself guards its own chdir (try{process.chdir(n)}catch{...} with a "Could not chdir to original directory while cleaning up worktree" log), and the ExitWorktree tool has full recovery for "original directory no longer exists" (falls back to home/temp and reports it). The /exit dialog's post-cleanup Zyt(f.originalCwd) is the one path missing that protection — the same gap exists in the interactive "remove" branch (let K=await yht(); Zyt(f.originalCwd) ...) and the "keep" branches.
Suggested fix
Either (ideally both):
- Make
Zyt(or its call sites in the exit dialog) tolerate a missing directory — reuse theExitWorktreerecovery logic (fall back to the worktree's parent repo root / home) instead of an unguardedprocess.chdir. - Add a
.catch(or second.thenargument) to the exit-dialog cleanup promise chains so any unexpected error still reachess("done")and the process can exit rather than spinning forever.