You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
project: Windows path case sensitivity silently splits project identity (empty session list, duplicated MCP/plugin init) #52501
On Windows, project identity is keyed by the literal path string, so opening the same project with different path casing silently creates a second, empty project: the session list appears empty, and MCP servers + plugins get initialized a second time for the "new" project. No error or hint is shown.
Environment
opencode version: 2.0.21 (channel: latest)
OS: Windows_NT 10.0.26200 (win32 x64)
Terminal: TERM=xterm-256color (Windows Terminal)
Shell: PowerShell 7 (pwsh.exe)
Install/channel: npm @opencode/cli, latest
Active plugins: opencode-dcp, @mohak34/opencode-notifier
Reproduction
Open a project at D:\Projects\relayhub (standard casing) and create a few sessions.
Start opencode again with the lowercase path d:\projects\relayhub.
The session list is empty — no error shown.
Compare via the API (same running service):
opencode api get "/api/session?directory=D:/Projects/relayhub&parentID=null"# returns sessions
opencode api get "/api/session?directory=d:/projects/relayhub&parentID=null"# returns []
Expected Behavior
NTFS is case-insensitive, so opening the same directory with different casing should resolve to the same project: sessions visible, single set of MCP/plugin instances.
Actual Behavior
The lowercase path resolves to a different project identity: empty session list, no error.
MCP servers and plugins are initialized a second time for the "new" project instance. The server log shows two concurrent instances differing only by casing:
Storage itself keeps normalized paths (no duplicate rows in the DB); the case mismatch happens at project resolution and directory-filtered queries.
Suggested fix: on win32, resolve project/worktree identity case-insensitively (e.g. fs.realpath.native or a case-folded key) — both at project resolution time and in directory-filtered API/TUI queries.
Not a duplicate of #49556: that issue is about exact string matching in the session listing query (read layer), while this issue is about project identity itself being keyed by the literal path string (identity layer). A differently-cased path creates a second project, which causes MCP servers and plugins to be initialized a second time (duplicate child processes). That symptom does not appear in #49556 / #47292 / #14570. Verified on 2.0.21.
Another real-world report, with DB-level evidence (Windows, v2.0.24)
Environment:
opencode v2.0.24 (split also happened on v2.0.6 before upgrading)
Windows NT 10.0.26200 (win32 x64), PowerShell 5.1
Repro (matches the issue):
Open D:\Projects\myapp (canonical casing) and create a few sessions.
Open opencode from a terminal whose CWD string differs only in case,
e.g. d:\projects\myapp (typed lowercase; NTFS resolves it fine).
The session list for the correctly-cased path is different/empty, and
MCP servers + plugins get initialized a second time for the "new"
project. No warning is shown.
Evidence from my own opencode.db before fixing:
session_v2.directory: 99 rows stored as D:/projects/...
project.worktree: 6 rows stored as D:/projects/...
permission.resource: 2 rows stored as D:/projects/...
After rewriting only those path columns to the on-disk casing
(D:/Projects/...), the sessions merged back into a single project view,
so identity really is keyed by the literal string. While a mismatched
client stays connected, the live location also keeps emitting events
under the lower-case path (event.location.directory="D:\projects\...").
Note: the pre-2.0.24 instruction stack overflow (#52874) is gone on
2.0.24, but this identity split remains.
Workarounds I'm using meanwhile:
Always launch from a CWD with on-disk casing. On Windows,
path.resolve/realpath do not fix case; I canonicalize by resolving
segment-by-segment via directory enumeration
([System.IO.Directory]::EnumerateDirectories(parent, segment)), which
returns the real on-disk case.
One-off DB fix: rewrite the columns above; the app provides no migration.
Suggestion: canonicalize directory strings once at the boundary (client
location registration and server project/session keys) using filesystem
casing, or key identity off a sameDirectory-style comparison everywhere,
plus an idempotent migration that merges rows equal under sameDirectory
(99/6/2 rows in my case). I saw #29641, #49530 and #51606 attempted this
and were closed — if that direction is still on the table, I'm happy to
test a patch/repro on Windows.
Proposed approach (incremental, builds on sameDirectory from #53349)
Root cause: project/session/location identity is the literal path string.
Step 1 — comparison-based lookups (no rewrite, no migration)
Use sameDirectory (already in packages/util/src/path.ts, folds case on
win32) everywhere identity is checked, instead of ===:
import{sameDirectory}from"@opencode/util/path"// client location registration: reuse an existing locationconstlocation=known.find((l)=>sameDirectory(l.directory,cwd))??register(cwd)// server project resolution: git root vs client directoryconstproject=projects.find((p)=>sameDirectory(p.worktree,locationDirectory))// session list filter for ?directory=sessions.filter((s)=>sameDirectory(s.directory,query.directory))
Effect: stops new splits, and rows already stored with a different casing
become visible again without touching stored data.
Step 2 — canonical display string (cached)
Resolve the real on-disk casing once and store it for display. path.resolve/realpath don't fix case on win32, and sameDirectory only
folds for comparison:
import{readdir}from"fs/promises"import{join,parse,resolve}from"path"constcache=newMap<string,string>()// win32-only; on other platforms identity is the string itselfexportasyncfunctioncanonicalDirectory(directory: string): Promise<string>{if(process.platform!=="win32")returndirectoryconstabsolute=resolve(directory)constcached=cache.get(absolute)if(cached)returncachedconstroot=parse(absolute).root// "D:\"letcurrent=rootfor(constpartofabsolute.slice(root.length).split(/[\\/]/).filter(Boolean)){constmatch=(awaitreaddir(current)).find((e)=>e.toLowerCase()===part.toLowerCase())if(!match)breakcurrent=join(current,match)}cache.set(absolute,current)returncurrent}
(Preferred fast path: GetFinalPathNameByHandleW via bun:ffi, enumeration
only as fallback. Invalidate the cache on rename/delete watcher events.)
Group by the same equivalence and merge duplicates:
constgroups=newMap<string,{display: string;ids: string[]}>()for(constrowofdb.select().from(sessionV2)){constdisplay=awaitcanonicalDirectory(row.directory)// real casingconstkey=display.toLowerCase()// win32 keyconstgroup=groups.get(key)??{ display,ids: []}group.ids.push(row.id)groups.set(key,group)}for(const{ display, ids }ofgroups.values()){awaitdb.transaction((tx)=>{tx.update(sessionV2).set({directory: display}).where(inArray(sessionV2.id,ids))// reassign messages/parts/permissions to the survivor, delete duplicate project rows})}
Rows affected on my profile: 99 session_v2.directory, 6 project.worktree,
2 permission.resource. Same treatment applies to project_directory.directory and worktree.directory.
Optional hardening: for scale, persist the derived key as a column instead of
pairwise sameDirectory scans; on win32 the lowercased canonical path is
enough, and dev:ino from fs.stat additionally unifies junctions/symlinks
to the same folder (verify on network/VFS).
Tests: win32 case-variant test mirroring #53349 (create under D:\X, query
with d:\x → same project, single MCP/plugin init); migration fixture with
pre-seeded split rows; non-win32 behavior unchanged.
I can test/repro on Windows 10.0.26200 with v2.0.24 if useful.
Thanks for the DB-level evidence — it matches what we saw at the API layer: same running service, /api/session?directory=D:/Projects/relayhub returns sessions while d:/projects/relayhub returns [], and the case-variant client triggers a second, silent MCP/plugin init.
One more data point from our side (v2.0.23, Windows 11 26200): a permission.resource row persisted as d:/projects/relayhub/* from an early lower-case-CWD session, while session_v2.directory and worktree.directory are all canonical — because after filing this issue we've been launching with on-disk casing only. The lower-case permission row is still sitting there with no migration to clean it, which underlines your point: once a case-variant string enters any keyed table, it stays and keeps matching/miss-matching by literal string.
+1 on canonicalizing once at the boundary (client location registration + server project/session keys) with an idempotent sameDirectory-based merge migration. One note for triage: the three earlier canonicalization attempts (#29641, #49530, #51606) were all closed unmerged — #29641 was opened by a maintainer — so this issue is currently the only open tracking point for that direction.
Happy to help test a patch on Windows as well; mixed-case launches against multiple project instances are easy to reproduce here.
Thanks for the detailed write-up — the incremental split (comparison-based lookups first, canonical casing stored at creation, optional idempotent migration) reads like the right sequence to us, and sameDirectory in packages/util/src/path.ts does behave as you describe on the current v2 branch. A Windows repro/test from you would be very welcome; happy to cross-check results on our side (v2.0.23/24, Windows 11 26200) as well.
Additional data from a desktop install (2.0.24, Windows 10.0.26300) confirming the project-identity split:
One repo folder ended up as twoproject rows split only by drive-letter case — 37 sessions under e:/Users/…/citavi6_language_add-on_ZH and 33 under E:/Users/…/citavi6_language_add-on_ZH.
It recurs: after normalizing every existing row to lowercase via PATCH /api/project/{id} (canonical) + POST /api/session/{id}/move (directory), sessions created later through the uppercase sidebar entry were written as E:/… again, re-splitting the project.
Client-side detail: the desktop sidebar persists its own worktree case (e.g. E:\… produced by the folder picker) independently of the project canonical, and the session list filters only on directory, so the project shows an empty conversation list when the two differ. The listing side is tracked in Session list misses sessions when directory case differs on Windows #49556.
Thanks for the detailed report, especially the API comparison and the log lines.
We couldn't reproduce this in our sandbox because it runs Linux. The problem needs a case-insensitive Windows filesystem and the Windows-only path handling in OpenCode, so a maintainer with a Windows machine will need to check it.
Reading the code fits what you describe. When OpenCode stores Windows project paths it turns \ into / but doesn't change letter casing, so D:\Projects\relayhub and d:\projects\relayhub probably end up treated as two projects. We haven't confirmed that on Windows yet.
Until it's looked at, opening the project with the same casing every time (e.g. always D:\Projects\relayhub) should avoid the empty session list and the second set of MCP servers and plugins.
Related: #29532 and #42440 (Windows console windows flashing). The extra MCP/plugin processes from this issue make that flashing worse, but they're separate problems.
Activity
github-actions commentedon Oct 1, 2026
This issue might be a duplicate of existing issues. Please check:
zhzjm commentedon Oct 4, 2026
Not a duplicate of #49556: that issue is about exact string matching in the session listing query (read layer), while this issue is about project identity itself being keyed by the literal path string (identity layer). A differently-cased path creates a second project, which causes MCP servers and plugins to be initialized a second time (duplicate child processes). That symptom does not appear in #49556 / #47292 / #14570. Verified on 2.0.21.
rspadim commentedon Oct 6, 2026
Another real-world report, with DB-level evidence (Windows, v2.0.24)
Environment:
opencode v2.0.24 (split also happened on v2.0.6 before upgrading)
Windows NT 10.0.26200 (win32 x64), PowerShell 5.1
Repro (matches the issue):
Open D:\Projects\myapp (canonical casing) and create a few sessions.
Open opencode from a terminal whose CWD string differs only in case,
e.g. d:\projects\myapp (typed lowercase; NTFS resolves it fine).
The session list for the correctly-cased path is different/empty, and
MCP servers + plugins get initialized a second time for the "new"
project. No warning is shown.
Evidence from my own opencode.db before fixing:
session_v2.directory: 99 rows stored as D:/projects/...
project.worktree: 6 rows stored as D:/projects/...
permission.resource: 2 rows stored as D:/projects/...
After rewriting only those path columns to the on-disk casing
(D:/Projects/...), the sessions merged back into a single project view,
so identity really is keyed by the literal string. While a mismatched
client stays connected, the live location also keeps emitting events
under the lower-case path (event.location.directory="D:\projects\...").
Note: the pre-2.0.24 instruction stack overflow (#52874) is gone on
2.0.24, but this identity split remains.
Workarounds I'm using meanwhile:
Always launch from a CWD with on-disk casing. On Windows,
path.resolve/realpath do not fix case; I canonicalize by resolving
segment-by-segment via directory enumeration
([System.IO.Directory]::EnumerateDirectories(parent, segment)), which
returns the real on-disk case.
One-off DB fix: rewrite the columns above; the app provides no migration.
Suggestion: canonicalize directory strings once at the boundary (client
location registration and server project/session keys) using filesystem
casing, or key identity off a sameDirectory-style comparison everywhere,
plus an idempotent migration that merges rows equal under sameDirectory
(99/6/2 rows in my case). I saw #29641, #49530 and #51606 attempted this
and were closed — if that direction is still on the table, I'm happy to
test a patch/repro on Windows.
Proposed approach (incremental, builds on
sameDirectoryfrom #53349)Root cause: project/session/location identity is the literal path string.
Step 1 — comparison-based lookups (no rewrite, no migration)
Use
sameDirectory(already inpackages/util/src/path.ts, folds case onwin32) everywhere identity is checked, instead of
===:Effect: stops new splits, and rows already stored with a different casing
become visible again without touching stored data.
Step 2 — canonical display string (cached)
Resolve the real on-disk casing once and store it for display.
path.resolve/realpathdon't fix case on win32, andsameDirectoryonlyfolds for comparison:
(Preferred fast path:
GetFinalPathNameByHandleWviabun:ffi, enumerationonly as fallback. Invalidate the cache on rename/delete watcher events.)
Store canonical at creation:
Step 3 — migration (optional, idempotent, transactional)
Group by the same equivalence and merge duplicates:
Rows affected on my profile: 99
session_v2.directory, 6project.worktree,2
permission.resource. Same treatment applies toproject_directory.directoryandworktree.directory.Optional hardening: for scale, persist the derived key as a column instead of
pairwise
sameDirectoryscans; on win32 the lowercased canonical path isenough, and
dev:inofromfs.statadditionally unifies junctions/symlinksto the same folder (verify on network/VFS).
Tests: win32 case-variant test mirroring #53349 (create under
D:\X, querywith
d:\x→ same project, single MCP/plugin init); migration fixture withpre-seeded split rows; non-win32 behavior unchanged.
I can test/repro on Windows 10.0.26200 with v2.0.24 if useful.
zhzjm commentedon Oct 6, 2026
Thanks for the DB-level evidence — it matches what we saw at the API layer: same running service,
/api/session?directory=D:/Projects/relayhubreturns sessions whiled:/projects/relayhubreturns[], and the case-variant client triggers a second, silent MCP/plugin init.One more data point from our side (v2.0.23, Windows 11 26200): a
permission.resourcerow persisted asd:/projects/relayhub/*from an early lower-case-CWD session, whilesession_v2.directoryandworktree.directoryare all canonical — because after filing this issue we've been launching with on-disk casing only. The lower-case permission row is still sitting there with no migration to clean it, which underlines your point: once a case-variant string enters any keyed table, it stays and keeps matching/miss-matching by literal string.+1 on canonicalizing once at the boundary (client location registration + server project/session keys) with an idempotent sameDirectory-based merge migration. One note for triage: the three earlier canonicalization attempts (#29641, #49530, #51606) were all closed unmerged — #29641 was opened by a maintainer — so this issue is currently the only open tracking point for that direction.
Happy to help test a patch on Windows as well; mixed-case launches against multiple project instances are easy to reproduce here.
zhzjm commentedon Oct 6, 2026
Thanks for the detailed write-up — the incremental split (comparison-based lookups first, canonical casing stored at creation, optional idempotent migration) reads like the right sequence to us, and
sameDirectoryinpackages/util/src/path.tsdoes behave as you describe on the current v2 branch. A Windows repro/test from you would be very welcome; happy to cross-check results on our side (v2.0.23/24, Windows 11 26200) as well.Martin-soaring-dev commentedon Oct 7, 2026
Additional data from a desktop install (2.0.24, Windows 10.0.26300) confirming the project-identity split:
projectrows split only by drive-letter case — 37 sessions undere:/Users/…/citavi6_language_add-on_ZHand 33 underE:/Users/…/citavi6_language_add-on_ZH.PATCH /api/project/{id}(canonical) +POST /api/session/{id}/move(directory), sessions created later through the uppercase sidebar entry were written asE:/…again, re-splitting the project.Client-side detail: the desktop sidebar persists its own worktree case (e.g.
E:\…produced by the folder picker) independently of the projectcanonical, and the session list filters only ondirectory, so the project shows an empty conversation list when the two differ. The listing side is tracked in Session list misses sessions when directory case differs on Windows #49556.opencodejr commentedon Oct 28, 2026
Thanks for the detailed report, especially the API comparison and the log lines.
We couldn't reproduce this in our sandbox because it runs Linux. The problem needs a case-insensitive Windows filesystem and the Windows-only path handling in OpenCode, so a maintainer with a Windows machine will need to check it.
Reading the code fits what you describe. When OpenCode stores Windows project paths it turns
\into/but doesn't change letter casing, soD:\Projects\relayhubandd:\projects\relayhubprobably end up treated as two projects. We haven't confirmed that on Windows yet.Until it's looked at, opening the project with the same casing every time (e.g. always
D:\Projects\relayhub) should avoid the empty session list and the second set of MCP servers and plugins.Related: #29532 and #42440 (Windows console windows flashing). The extra MCP/plugin processes from this issue make that flashing worse, but they're separate problems.