[VS Code, Windows] The first Bash result fires CwdChanged for a drive-letter case change (`c:\` to `C:\`), and FileChanged stops firing
Environment: Claude Code VS Code extension 2.1.294 (anthropic.claude-code-2.1.294-win32-x64; the transcript records entrypoint: claude-vscode), VS Code 1.141.0, Windows 11 Pro 10.0.26200, Git for Windows 2.49.0 as the Bash tool's shell, Node 22.19.0 for the hooks.
What happens
In a VS Code window opened on C:\projects\e97-repro:
- A new conversation starts with a lower-case drive letter. The SessionStart hook's
cwdand the transcript'scwdare bothc:\projects\e97-repro. - The first Bash tool result changes the transcript's
cwdtoC:\projects\e97-repro. The command wasecho hi, with nocd. - Claude Code treats this as a directory change. A CwdChanged hook receives
old_cwd: "c:\\projects\\e97-repro"andnew_cwd: "C:\\projects\\e97-repro". - After that, changes to the file that the SessionStart hook named in
watchPathsno longer run the FileChanged hook, unless a CwdChanged hook returns the path insidehookSpecificOutput(run 3 below). This resembles the loss #95440 reports after a realcdon macOS. Here no directory changed at all, so in the extension on Windows a conversation loses its watch at its first Bash call. We watched for about 45 s after the change in each run below. Earlier, in our own project with a different set of hooks, the same loss lasted through 13 minutes of observation.
Expected: on Windows, c:\x and C:\x are the same directory. CwdChanged should not fire, and FileChanged should keep running for the SessionStart path. The hooks reference says CwdChanged "Runs when a shell command in the main conversation changes the working directory", and echo hi does not change it.
Measured
A script appended a line to the watched file every 3 s. Each FileChanged run was logged with its session id. Each run was a new conversation in the same window, with the settings below plus the CwdChanged hook in the table.
| Run | CwdChanged hook | Before the change: appends / FileChanged runs | After the change: appends / FileChanged runs |
|---|---|---|---|
| 1 | none declared | 62 / 62 | 15 / 0 |
| 2 | prints {"watchPaths":[…]} | 13 / 13 | 15 / 0 |
| 3 | prints {"hookSpecificOutput":{"hookEventName":"CwdChanged","watchPaths":[…]}} | 15 / 15 | 15 / 15 |
- How runs are matched to appends. The hook does not see which append caused it, so FileChanged runs are matched to appends by order and timing. The lag was typically about 0.7 s, and at most 1.6 s. One case sits on the boundary. Run 1's last append before the change was at 04:35:27.895Z, the change was recorded at 04:35:28.575Z, and its FileChanged run was logged at 04:35:28.632Z. It is counted before the change.
- The end of each observation. It ended about 45 s after the Bash result. Appends made in the last second, too late for a FileChanged run to be logged, are not counted. Run 3's last append, left out by that rule, also got its FileChanged run.
- CwdChanged's timing. In runs 2 and 3, CwdChanged ran once, 0.20 s and 0.17 s after the Bash result.
Run 2 matches #95440's step F: a top-level watchPaths returned by CwdChanged does not keep the watch. Run 3's hookSpecificOutput form kept FileChanged firing across the change. We've added that to #95440, with the documentation question it raises.
Reproduction
C:\projects\e97-repro\.claude\settings.local.json:
{
"hooks": {
"SessionStart": [{ "hooks": [{ "type": "command", "command": "node C:/projects/e97-repro/start.mjs" }] }],
"FileChanged": [{ "hooks": [{ "type": "command", "command": "node C:/projects/e97-repro/changed.mjs" }] }]
}
}
C:\projects\e97-repro\start.mjs:
import { appendFileSync } from "node:fs";
let raw = "";
process.stdin.on("data", (c) => (raw += c));
process.stdin.on("end", () => {
const input = JSON.parse(raw || "{}");
appendFileSync("C:\\projects\\e97-repro\\log.txt",
`${new Date().toISOString()} SessionStart ${input.session_id} source=${input.source} cwd=${input.cwd}\n`);
process.stdout.write(JSON.stringify({
hookSpecificOutput: { hookEventName: "SessionStart", watchPaths: ["C:\\projects\\e97-repro\\watched.txt"] },
}));
});
C:\projects\e97-repro\changed.mjs:
import { appendFileSync } from "node:fs";
let raw = "";
process.stdin.on("data", (c) => (raw += c));
process.stdin.on("end", () => {
const input = JSON.parse(raw || "{}");
appendFileSync("C:\\projects\\e97-repro\\log.txt",
`${new Date().toISOString()} FileChanged ${input.session_id} ${input.file_path}\n`);
});
Steps:
- Create an empty
C:\projects\e97-repro\watched.txt. In a terminal, start appending to it every 3 s, and leave this running throughout:
node -e "setInterval(()=>require('fs').appendFileSync('C:/projects/e97-repro/watched.txt','x\n'),3000)"
- In VS Code, choose File > New Window, then File > Open Folder... and pick
C:\projects\e97-repro. Trust the folder. - Open a new Claude Code tab and run
/clearin it.log.txtgets aSessionStart <id> source=clear cwd=c:\projects\e97-reproline. Use that id below. The fresh tab's own SessionStart also reportedc:\, so/clearmay not be needed, but it is what we ran. log.txtgets aFileChanged <id>line every 3 s. After at least 15 s, send: `Runecho hiwith the Bash tool.`- Keep watching
log.txtfor at least 45 s after the reply. TheFileChanged <id>lines stop while the appends continue. The transcript, at%USERPROFILE%\.claude\projects\c--projects-e97-repro\<id>.jsonl, records"cwd"asc:\\projects\\e97-reproup to the Bash result and asC:\\projects\\e97-reprofrom that record on.
For runs 2 and 3, save C:\projects\e97-repro\cwd.mjs:
import { appendFileSync } from "node:fs";
const form = process.argv[2]; // "top" or "nested"
let raw = "";
process.stdin.on("data", (c) => (raw += c));
process.stdin.on("end", () => {
const input = JSON.parse(raw || "{}");
appendFileSync("C:\\projects\\e97-repro\\log.txt",
`${new Date().toISOString()} CwdChanged ${input.session_id} form=${form} old=${input.old_cwd} new=${input.new_cwd}\n`);
const watchPaths = ["C:\\projects\\e97-repro\\watched.txt"];
process.stdout.write(JSON.stringify(form === "top"
? { watchPaths }
: { hookSpecificOutput: { hookEventName: "CwdChanged", watchPaths } }));
});
Then add this entry inside "hooks" in the settings file, with top for run 2 and nested for run 3. Save the file before opening that run's new tab, then repeat steps 3 to 5 in the new tab. With top, the FileChanged lines stop as in run 1. With nested, they continue:
"CwdChanged": [{ "hooks": [{ "type": "command", "command": "node C:/projects/e97-repro/cwd.mjs top" }] }]
Earlier observations, outside these three runs
- Version 2.1.291 behaved the same in our own project, a git worktree under
C:\projects. - The starting case varies. In the window used for these runs, every fresh tab and
/clearsuccessor started atc:\. The same day, in other windows, fresh tabs started atC:\and never changed, while/clearsuccessors started atc:\and changed at their first Bash result. We don't know what decides it. - It did not reproduce under
%TEMP%. In folders there, the transcript'scwdnever changed, so CwdChanged never fired. Use a folder like the one above. - Workaround: a CwdChanged hook that returns the same paths in the
hookSpecificOutputform, as in run 3. We use it for now.