Skip to content

project: Windows path case sensitivity silently splits project identity (empty session list, duplicated MCP/plugin init) #52501

Open

Description

@zhzjm

Summary

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

  1. Open a project at D:\Projects\relayhub (standard casing) and create a few sessions.
  2. Start opencode again with the lowercase path d:\projects\relayhub.
  3. The session list is empty — no error shown.
  4. 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:
mcp connected server=context7 directory="d:\\projects\\relayhub"
mcp connected server=context7 directory="D:\\Projects\\relayhub"
  • Path separators are handled fine (D:\Projects\relayhub and D:/Projects/relayhub behave the same); only letter casing differs.

Additional Context

Activity

  1. github-actions commented on Oct 1, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

  2. zhzjm commented on Oct 4, 2026

    @zhzjm
    Author

    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.

  3. rspadim commented on Oct 6, 2026

    @rspadim

    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 location
    const location = known.find((l) => sameDirectory(l.directory, cwd)) ?? register(cwd)
    
    // server project resolution: git root vs client directory
    const project = 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"
    
    const cache = new Map<string, string>()
    
    // win32-only; on other platforms identity is the string itself
    export async function canonicalDirectory(directory: string): Promise<string> {
      if (process.platform !== "win32") return directory
      const absolute = resolve(directory)
      const cached = cache.get(absolute)
      if (cached) return cached
    
      const root = parse(absolute).root // "D:\"
      let current = root
      for (const part of absolute.slice(root.length).split(/[\\/]/).filter(Boolean)) {
        const match = (await readdir(current)).find((e) => e.toLowerCase() === part.toLowerCase())
        if (!match) break
        current = join(current, match)
      }
      cache.set(absolute, current)
      return current
    }

    (Preferred fast path: GetFinalPathNameByHandleW via bun:ffi, enumeration
    only as fallback. Invalidate the cache on rename/delete watcher events.)

    Store canonical at creation:

    const directory = await canonicalDirectory(cwd)

    Step 3 — migration (optional, idempotent, transactional)

    Group by the same equivalence and merge duplicates:

    const groups = new Map<string, { display: string; ids: string[] }>()
    for (const row of db.select().from(sessionV2)) {
      const display = await canonicalDirectory(row.directory) // real casing
      const key = display.toLowerCase()                       // win32 key
      const group = groups.get(key) ?? { display, ids: [] }
      group.ids.push(row.id)
      groups.set(key, group)
    }
    for (const { display, ids } of groups.values()) {
      await db.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.

  4. zhzjm commented on Oct 6, 2026

    @zhzjm
    Author

    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.

  5. zhzjm commented on Oct 6, 2026

    @zhzjm
    Author

    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.

  6. Martin-soaring-dev commented on Oct 7, 2026

    @Martin-soaring-dev

    Additional data from a desktop install (2.0.24, Windows 10.0.26300) confirming the project-identity split:

    • One repo folder ended up as two project 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.
  7. opencodejr commented on Oct 28, 2026

    @opencodejr

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions