[BUG] LSP stale index: files created mid-session never send workspace/didChangeWatchedFiles (Created)
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
Claude Code's LSP client omits the workspace.didChangeWatchedFiles capability from its initialize params and never sends the notification. Language servers therefore have no way to learn about files created, renamed, or deleted after they start.
With the official pyright-lsp plugin this produces phantom diagnostics that never clear: any module created during a session is reported as "x" is unknown import symbol at every import site for the rest of the session, while the pyright CLI on the same files reports zero errors.
This hits an everyday workflow — asking Claude Code to split out or create a module and then import it — and the false errors are actively harmful: Claude sees them in its diagnostics feed and tries to "fix" code that is already correct.
I have root-caused it to two specific lines and verified out-of-band that injecting the missing notification fixes it completely. The minimal fix does not require any capability change — details under Additional Information, including why the obvious fix would make things worse.
What Should Happen?
A module created during a session should resolve at its import sites, exactly as it does for the pyright CLI, without restarting Claude Code.
Error Messages/Logs
# pkg/probe.py exists on disk; pkg/importer.py does "from . import probe"
# Inline diagnostic from the language server, repeated after every edit:
"probe" is unknown import symbol [reportAttributeAccessIssue] (Pyright)
# Same file, same moment, pyright CLI:
$ pyright --outputjson pkg/importer.py
{"filesAnalyzed": 1, "errorCount": 0, "warningCount": 0, "informationCount": 0}
Steps to Reproduce
In any Python project, with the pyright-lsp plugin enabled:
- Start Claude Code and ask it to edit an existing
.pyfile. (The language server spawns lazily on the first edit, not on a read — this fixes the moment of its snapshot.) - Ask it to create a new module, e.g.
pkg/probe.pycontainingPING = "pong". - Ask it to add
from . import probeto an existing file inpkg/.
Result: "probe" is unknown import symbol, re-reported after every subsequent edit, never clearing. pyright --outputjson <file> on the same file returns errorCount: 0.
Things that do NOT fix it:
- Editing or opening the new file. Pyright analyzes it fine and reports genuine errors inside it — I planted
BROKEN: int = "definitely not an int"and it was flagged correctly — yet importing files still cannot resolve it. The file is in the program; it is the import-resolution cache for the directory that is never invalidated. - Killing the language server process. It is never respawned; diagnostics simply stay off for the remainder of the session.
- Only a full Claude Code restart clears it.
Ruled out: workspace root and configuration are correct. In the same session, import pytest, cross-package imports, and a newly added pyrightconfig.json (venvPath/venv) all resolved correctly. Only files created after server start are affected.
Standalone reproduction with no Claude Code involved — drives pyright-langserver directly with the capability set copied from Claude Code's initialize:
initializewithcapabilities.workspacelackingdidChangeWatchedFilesdidOpenpkg/importer.pycontainingfrom . import probe→publishDiagnostics: '"probe" is unknown import symbol'(correct —probe.pyabsent)- create
pkg/probe.pyon disk didChangepkg/importer.py→publishDiagnostics: '"probe" is unknown import symbol'(wrong —probe.pyexists)
Send workspace/didChangeWatchedFiles after step 3 and step 4 comes back clean. I have a ~150-line stdlib-only harness for this and can attach it.
Claude Model
Opus
Is this a regression?
No, this never worked
Last Working Version
_No response_
Claude Code Version
2.1.226 (Claude Code)
Platform
Anthropic API
Operating System
Ubuntu/Debian Linux
Terminal/Shell
Other
Additional Information
Root cause
The initialize params the client sends contain no didChangeWatchedFiles key anywhere under workspace:
capabilities: {
workspace: { configuration: …, workspaceFolders: false },
textDocument: {
synchronization: { dynamicRegistration: false, willSave: false,
willSaveWaitUntil: false, didSave: true },
publishDiagnostics: { … }, hover: { … }, definition: { … },
references: { … }, documentSymbol: { … }, callHierarchy: { … }
},
general: { positionEncodings: ["utf-16"] }
}
Pyright branches on exactly that key (pyright-internal.js, LanguageServerBase.initialize):
this.client.hasWatchFileCapability = !!i.workspace?.didChangeWatchedFiles?.dynamicRegistration;
…
this.client.hasWatchFileCapability &&
this.addDynamicFeature(new FileWatcherDynamicFeature(…));
false → the dynamic feature is never registered. Pyright's language server has no fallback watcher either: WorkspaceFileWatcherProvider.createFileWatcher only parks handlers in an array, invoked solely from onFileChange, which is driven by the LSP notification. chokidar occurs 0 times in the bundle — the CLI watches the real filesystem; the server deliberately delegates to the client.
On the client side, DidChangeWatchedFilesNotification appears only as the bundled vscode-languageserver-protocol type declaration. There is no send site. So both halves are missing: the capability is not advertised, and the notification is never sent.
The fix is smaller than it looks
Pyright registers the handler unconditionally, independent of capability negotiation:
this.connection.onDidChangeWatchedFiles(e => this.onDidChangeWatchedFiles(e))
…
onDidChangeWatchedFiles(e) {
e.changes.forEach(e => { … this.serverOptions.fileWatcherHandler.onFileChange(a, t) })
}
The watchers are created and wired up; nothing ever fires them. Sending the notification is sufficient — no capability renegotiation is required.
Verified with a transparent stdio proxy that forwards both directions byte for byte, learns the root from initialize, polls the tree, and injects workspace/didChangeWatchedFiles. A/B against a temp project, driving the server with Claude Code's exact capability set:
--- A: real pyright-langserver
before create: ['"probe" is unknown import symbol']
after create : ['"probe" is unknown import symbol'] ← the bug
--- B: through the proxy
before create: ['"probe" is unknown import symbol']
after create : clean ← fixed
The injected notification is the only difference between the runs.
Suggested fix, in order
- Emit
workspace/didChangeWatchedFilesfrom the file-mutation paths (Created/Changed/Deleted). No capability change, no registration round trip. Fixes the common case for every language server at once. - Add a real filesystem watcher to also cover changes from outside the session —
git checkout, formatters, the user's editor, code generators. - Only then, if dynamic registration is wanted, fix #32595 first and advertise the capability.
- Independently: consider respawning a language server that has exited (currently, killing it leaves the session with no diagnostics at all).
⚠️ The ordering matters. The obvious-looking first step — advertising didChangeWatchedFiles: { dynamicRegistration: true } — would make pyright send client/registerCapability, and per #32595 the client does not respond to that, leaving servers hung in "starting". Advertising the capability before fixing #32595 would turn this bug into a worse one. Sending the notification alone sidesteps that entirely.
Relationship to existing issues
This is not a duplicate of #17979, and the distinction matters:
| # | State | Relationship |
| --- | --- | --- |
| #76870 | open | The umbrella report — "document state is forwarded for Write/Edit, and not for anything else". This is a root-caused instance of its second half, and sharpens it: even for files Claude Code does create with Write, the creation is never announced — only the document content — so importers stay broken. |
| #33035 | closed not_planned, locked | The same missing notification, deletion direction. Carried has repro. Closed by the stale bot after inactivity, over a user objection. Never fixed. |
| #50271 | closed not_planned, locked, labelled stale | The same pyright symptom (unknown import symbol), different trigger (git rewriting files). Proposed the same fix. Stale-closed. |
| #32595 | closed not_planned, locked | Constrains the fix — see the warning above. |
| #17979 | closed, fixed in 2.1.111 | A different bug: diagnostics were read before the server finished analyzing. That race is fixed. A commenter (Mar 30) listed "file deletions invisible to server" among six client defects; that sub-item was never addressed, and this issue is its surviving half. |
| #57840 | closed | swift-lsp stale diagnostics, "#17979 fix not extended" — shows the 2.1.111 work was per-server plumbing rather than a client-wide fix. |
| #47928 | closed | typescript-lsp reporting diagnostics for files that no longer exist after a branch switch — the same watcher gap through a different server. |
Three separate reports of one missing notification have now been closed without a fix (#33035, #50271, and the deletion half of #17979), because each described a symptom and none pinned the mechanism. That is what this issue is for.
Workaround for others hitting this
If an inline import error looks wrong, check it against the CLI (pyright --outputjson <file>); a clean CLI means the inline diagnostic is stale. Restart Claude Code after creating new modules. Editing the new file and killing the server both do nothing.
3 Comments
Follow-up: I've now run the proxy in a real Claude Code session rather than only against the standalone harness, and it covers deletions as well as creations — which makes the fix in step 1 broader than the report claims.
Creation, verified live. A module created mid-session resolved at its import site with no restart. To be sure this wasn't just diagnostics going quiet, I referenced an attribute that doesn't exist on it:
Byte-identical to what
pyright --outputjsonreports for the same file. The server had genuinely resolved the new module, not merely stopped complaining.Deletion, verified live. Deleting that module while an importer still referenced it flipped the diagnostic straight back to
"lsp_probe3" is unknown import symbol— with no edit to trigger it. Nothing went through Write/Edit; the only event was the file disappearing from disk.That last detail matters for scoping the fix. Emitting
Created/Changed/Deletedfrom one notification path covers:not_plannedand locked),So suggested fix step 1 alone resolves two of the reported issues, and step 2 — an actual watcher — is what picks up the remaining two. The proxy does the watcher version, which is why deletions made outside any tool call are still seen; a tool-call-site-only implementation would fix creation and deletion by Claude Code, but not the git/formatter case.
No capability change was involved in any of this, which remains the point: pyright's
onDidChangeWatchedFilesis registered unconditionally, so the notification works as-is and #32595 never comes into play.Independent confirmation on 2.1.233 / Windows 10, plus evidence that this is one instance of a wider gap: the client sends no file events of any kind, so the Deleted/rename direction rots the same way as Created.
Binary-level confirmation (string-dump of the 2.1.233 CLI executable):
textDocument/didOpen,didChange,didSave,didClose,initialized,exit, plus exactly oneworkspace/didChangeConfigurationat startup.workspace/didChangeWatchedFilesand theworkspace/didRenameFiles/didCreateFiles/didDeleteFilesfamily exist only as protocol-library constants — there is no call site.initializecapabilities object is visible in the binary and contains noworkspace.didChangeWatchedFilesand nofileOperations:``
``capabilities:{workspace:{configuration:...,workspaceFolders:!1},textDocument:{synchronization:{...,didSave:!0},publishDiagnostics:{...},hover:...,definition:...,references:...,documentSymbol:...,callHierarchy:...}}
Consequences beyond Created (all reproduced with the official plugins): after a
git mvdone in a terminal, pyright keeps resolving the old module path andworkspaceSymbolkeeps answering from renamed-away files; the only in-session cure is killing the server processes so the client's lazy crash-recovery respawns them — and that budget ismaxRestarts(default 3) per session. #33035 (Deleted), #50271 (git operations), and #47928 (branch switch) look like the same root cause from different directions.Workaround that works today, for anyone landing here: a transparent stdio proxy registered as the
lspServerscommand, which (a) patchesinitializeto advertiseworkspace.didChangeWatchedFileswith dynamic registration, (b) answers the server'sclient/registerCapabilityfor that method itself and remembers the requested globs, and (c) feeds real OS watcher events (fs.watchrecursive →ReadDirectoryChangesW, the same facility VS Code uses) through those globs asworkspace/didChangeWatchedFiles, debounced and rename-coalesced (Created+Changed→Created, Deleted+Created→Changed). Verified end-to-end with pyright on 2.1.233: a disk-only rename breaks/heals imports within ~1 s, while the direct control run stays blind indefinitely.One caveat worth knowing when fixing this client-side: events alone do not help clangd —
ClangdLSPServer::onFileEventis currently a stub upstream (the FIXME about re-readingcompile_commands.jsonetc.), verified against clangd 22.1.6. pyright/typescript-language-server/rust-analyzer all act on the events; clangd needs the client to keep re-opening/re-parsing, so the OP's minimal push-based fix is the right direction there too.Reproduced on Linux with the released 2.1.233 build. I instrumented the language-server channel and can confirm both halves of your analysis at the wire level:
initializerequest'sworkspacecapabilities contain nodidChangeWatchedFilesentry.didOpen/didChange/didSavefor edited files but never aworkspace/didChangeWatchedFilesnotification."probe" is unknown import symbolfor the importing file for the rest of the session — even after the new file existed on disk and had itself been opened and analyzed — whilepyright --outputjsonon the identical file reportederrorCount: 0.This matches your root cause exactly: language servers are never told about files created, renamed, or deleted after they start, so import-resolution caches go stale with no way to recover short of a full restart. Marking as a confirmed bug — and thank you for an exceptionally well-diagnosed report, including the standalone harness ruling everything else out.
🤖 Generated with Claude Code