[VS Code, Windows] The first Bash result fires CwdChanged for a drive-letter case change (`c:\` to `C:\`), and FileChanged stops firing

Status Open
Reported on v2.1.294
Maintainer reply None cached
Activity 0 comments · opened Oct 9, 2026

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:

  1. A new conversation starts with a lower-case drive letter. The SessionStart hook's cwd and the transcript's cwd are both c:\projects\e97-repro.
  2. The first Bash tool result changes the transcript's cwd to C:\projects\e97-repro. The command was echo hi, with no cd.
  3. Claude Code treats this as a directory change. A CwdChanged hook receives old_cwd: "c:\\projects\\e97-repro" and new_cwd: "C:\\projects\\e97-repro".
  4. After that, changes to the file that the SessionStart hook named in watchPaths no longer run the FileChanged hook, unless a CwdChanged hook returns the path inside hookSpecificOutput (run 3 below). This resembles the loss #95440 reports after a real cd on 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:

  1. 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)"

  1. In VS Code, choose File > New Window, then File > Open Folder... and pick C:\projects\e97-repro. Trust the folder.
  2. Open a new Claude Code tab and run /clear in it. log.txt gets a SessionStart <id> source=clear cwd=c:\projects\e97-repro line. Use that id below. The fresh tab's own SessionStart also reported c:\, so /clear may not be needed, but it is what we ran.
  3. log.txt gets a FileChanged <id> line every 3 s. After at least 15 s, send: `Run echo hi with the Bash tool.`
  4. Keep watching log.txt for at least 45 s after the reply. The FileChanged <id> lines stop while the appends continue. The transcript, at %USERPROFILE%\.claude\projects\c--projects-e97-repro\<id>.jsonl, records "cwd" as c:\\projects\\e97-repro up to the Bash result and as C:\\projects\\e97-repro from 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 /clear successor started at c:\. The same day, in other windows, fresh tabs started at C:\ and never changed, while /clear successors started at c:\ 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's cwd never changed, so CwdChanged never fired. Use a folder like the one above.
  • Workaround: a CwdChanged hook that returns the same paths in the hookSpecificOutput form, as in run 3. We use it for now.

View original on GitHub ↗