Claude Code in WSL: what breaks and why
Claude Code runs fine inside WSL. What breaks is everything around it, because a Windows machine with WSL is two computers that share a screen: two home directories, two config files, two histories, and a network boundary between them. None of it is documented, and all of it is easy to verify. Everything below was measured on Ubuntu 24.04.2 under WSL2 (kernel 6.18.33.2) with Claude Code 2.1.269. If your hooks are the immediate problem, start with the hook checklist.
You have two Claude Code installs, not one
This is the root of almost every WSL surprise. Windows and WSL each have their own home directory, so each has its own ~/.claude, and Claude Code reads whichever one it is launched from.
| Windows | WSL | |
|---|---|---|
| Config | C:\Users\<you>\.claude\settings.json | ~/.claude/settings.json |
| Projects | C:\Users\<you>\.claude\projects\ | ~/.claude/projects/ |
| Session registry | C:\Users\<you>\.claude\sessions\ | ~/.claude/sessions/ |
Measured on a machine that runs both: the Windows settings.json carried two keys, the WSL one carried eleven. They had been configured separately, months apart, and neither knew about the other. If you set up hooks in a terminal and they do nothing in your editor, this is usually why: you configured one install and you are running the other.
The same repo gets two separate histories
Claude Code stores a conversation transcript per project, in a folder named after the working directory with every separator replaced by a dash. That encoding is where the two worlds stop being the same place:
| Working directory | Folder in projects/ |
|---|---|
/mnt/c/Workspace/my-app (WSL) | -mnt-c-Workspace-my-app |
C:\Workspace\my-app (Windows) | C--Workspace-my-app |
Both point at the same files on the same disk. The leading / becomes a leading dash on one side, while C: becomes C-- on the other, so the two never collide. Open the same repo from a Windows terminal and from WSL, and you get two folders, in two different .claude trees, holding two unrelated sets of transcripts. Neither --continue nor --resume will show you the other side's work.
Spaces are encoded the same way as separators, so /mnt/c/Workspace/Animated Characters SVG becomes -mnt-c-Workspace-Animated-Characters-SVG.
Pick one side per repo and stay there. If you must switch, expect to lose continuity.
Hooks: localhost is not localhost
If a hook posts to a local service, WSL's networking mode decides whether it works, and the default mode is the one that breaks.
- NAT (the WSL2 default):
127.0.0.1inside WSL is WSL's own loopback. A Windows process listening on the Windows loopback is not there. The host is reachable on the default route gateway instead. - Mirrored:
127.0.0.1does reach the Windows loopback, and everything just works.
Measured from inside WSL against a Windows app listening on port 49217, with no networkingMode set in .wslconfig:
$ curl -s -m 2 -o /dev/null -w "%{http_code}\n" http://127.0.0.1:49217/event
000 # connection refused, immediately
$ ip route show default
default via 172.28.176.1 dev eth0 proto kernel
$ curl -s -m 2 -o /dev/null -w "%{http_code}\n" http://172.28.176.1:49217/event
404 # the Windows process answered
So write the hook to try both. The refusal is instant rather than a timeout, which is what makes the fallback free in the mirrored case:
D=$(cat); for H in 127.0.0.1 $(ip route show default 2>/dev/null | awk '{print $3; exit}'); do curl -s -m 2 -X POST "http://$H:49217/event" --data-binary "$D" >/dev/null 2>&1 && break; done; true
Two details matter in that line. D=$(cat) buffers the payload first, because stdin can only be read once: chaining two curl --data-binary @- calls sends an empty body on the retry. And the && break stops at the first host that answers.
Note that this is the POSIX form, for the WSL install. The Windows install needs a different one, because Claude Code picks Git Bash or PowerShell depending on what is installed: see hooks on Windows for why an unquoted --data-binary @- is a ParserError under PowerShell.
A WSL pid means nothing to Windows
The session registry writes one JSON file per process, at ~/.claude/sessions/<pid>.json. Inside WSL, the record qualifies its pid with a Linux namespace:
{
"pid": 2530897,
"cwd": "/mnt/c/Workspace/my-app",
"pidDomain": "linux:db5cbacc…:pid:[4026532219]",
"messagingSocketPath": "/run/user/1000/cc-socks/2530897.sock",
"status": "busy"
}
That pidDomain is the warning. The pid belongs to the distro's pid namespace, so the same number on the Windows side refers to some unrelated process, or to nothing. The messaging socket is a UNIX socket inside the distro's tmpfs, invisible from Windows entirely. Any tool that wants to see WSL sessions from Windows has to enter the distro and read /proc there, not enumerate Windows processes.
Nothing prunes these files. The machine measured here had 40 session records for 3 live sessions, the oldest four months old.
Scripting WSL from Windows: three traps
wsl.exe -l -q outputs UTF-16LE, with CRLF. Every other tool gives you UTF-8, so a naive read gives you a string full of NUL bytes:
$ wsl.exe -l -q | od -c | head -2
0000000 U \0 b \0 u \0 n \0 t \0 u \0 \r \0 \n \0
0000020 d \0 o \0 c \0 k \0 e \0 r \0 - \0 d \0
Decode as UTF-16LE, then trim \r, NUL, and the * that marks the default distro.
A registered distro is not a user. Docker Desktop and Rancher register their own back-end distros on machines whose owner has never opened a Linux shell. docker-desktop showing up in that list means nothing about whether anyone runs Claude Code there. A better test is whether ~/.claude/projects in that distro is non-empty.
Pass scripts over stdin, not as arguments. Quoting is mangled crossing the Win32 to WSL boundary, so wsl.exe -d <distro> -- sh -c "…" loses or breaks quotes in anything non-trivial. Feed the script to sh -s on stdin instead, where newlines and quotes are just input. Use bash -c rather than bash -lc: a login shell sources the profile and can print a MOTD into whatever you are trying to parse.
Paths, and the permissions that are not real
wslpath translates both ways, and handles the \\wsl.localhost form for native Linux paths:
$ wslpath -u 'C:\Workspace\my-app' # /mnt/c/Workspace/my-app
$ wslpath -w /mnt/c/Workspace/my-app # C:\Workspace\my-app
$ wslpath -w /home/me/.claude # \\wsl.localhost\Ubuntu\home\me\.claude
$ wslpath -u 'D:\nope' # fails if the drive is not mounted
One more thing that bites hook scripts: everything under /mnt/c is mounted through drvfs and reports mode 0777. There are no real Unix permissions there, so chmod +x on a script living on the Windows drive does not mean what you think it means. Keep hook scripts in the Linux filesystem, or invoke them explicitly with bash script.sh.
When one machine is really two
The practical consequence of all this: a single Windows laptop can run Claude Code in a terminal and inside WSL at the same time, reading two config files, writing two histories, on two sides of a network boundary. Any tool that watches your sessions has to cover both, or it silently covers half your work.
That is exactly the problem Blooby solves on the desktop side: it installs its hooks into both settings.json files, probes each distro as well as Windows itself, and puts one mascot per live session on your taskbar no matter which side it started from. If you run several sessions at once, that is the half of the picture the terminal cannot show you.
See it for yourself
download Get Blooby free