Repository navigation
[Bug]: Usage double-counts a provider transcript directory shared between Windows and a WSL environment (source fingerprint cannot unify one directory across the OS boundary) #7858
Description
Activity
kvnloo commented
on Sep 27, 2026 ContributorMore actionsI prototyped a narrower source-identity fix on
kvnloo/t3code:fix/usage-wsl-source-identity:https://github.com/kvnloo/t3code/tree/fix/usage-wsl-source-identity
The invariant is: cross-runtime dedupe needs both a shared physical-host scope and a normalized physical path. Path normalization alone can collapse two different PCs with the same
C:\\Users\\...layout.The branch does this:
UsageSourceFingerprintgets an optionalphysicalSourceId.usageMergeprefers it when present; otherwise the existinghostId + provider + resolvedHomePath + volumeIdkey is unchanged.- Desktop generates a separate non-secret 128-bit
T3CODE_USAGE_HOST_IDand gives the same value to its Windows and managed WSL backends. It is deliberately not the bootstrap bearer token. - The server only emits
physicalSourceIdfor Windows drive paths and WSL/mnt/<drive>aliases. For example,C:\\Users\\Theo\\.claude\\projectsand/mnt/c/Users/Theo/.claude/projectsnormalize to the same drive-backed path. - Native paths such as
/home/theo/.claude/projectsdo not receive the alias identity, so two WSL distros with matching Linux paths cannot collapse accidentally. - Different desktop hosts get different host scopes, so identical Windows paths on two PCs stay distinct.
- The new fingerprint field is optional/additive; mixed-version environments simply fall back to today's conservative behavior rather than requiring a contract-version cutover.
I kept this as a fork branch rather than opening a PR because this crosses contracts/shared/server/desktop and the repo currently asks for discussion before non-trivial changes.
I have focused regressions for Windows/WSL normalization, different-host separation, the legacy fallback, and the desktop sharing the same host scope with the WSL backend. I have not claimed a test run from this GitHub-only session.
Thanks for prototyping this. I pulled
fd0883c2binto a worktree and ran it on the machine that hits the bug (Windows 11, desktop-managed WSL Ubuntu, WSL~/.claudesymlinked to/mnt/c/Users/Drifter/.claude).It fixes my setup. Probing the same realPath step
UsageServiceuses, both sides normalize towindows:c:/Users/Drifter/.claude/projects, andWSLENVcarries the host id into the WSL backend. Typecheck is clean for contracts, shared, server, and desktop.usageMerge.test.ts28/28 andUsageService.test.ts18/18 pass.Things I found, most important first:
- The physical key replaces the legacy key instead of supplementing it, which regresses same-machine dedupe that works today.
fingerprintKeyswitches namespaces wheneverphysicalSourceIdis present. A desktop-managed Windows backend and a worktree dev server or standalonenpx t3on the same PC scan the same directory with identicalhostId/provider/resolvedHomePath/volumeId, but only the desktop backend hasT3CODE_USAGE_HOST_ID. They now land under different keys and count twice. Treating two sources as the same when either the legacy tuple or the physical id matches (register both keys for the owner inclaimSources) keeps the old behavior and adds the new one. - The host scope is regenerated per desktop launch. Remote web/mobile clients retain the previous summary while a backend reconnects (
client-runtimekeeps the last successful value), so after a desktop restart a client can merge Windows under the new scope with WSL under the old one and double count until both refresh. Persisting the id (desktop settings or userdata) instead of generating it per process avoids that. /mnt/<letter>is trusted by spelling. With a customautomount.rootinwsl.confthe WSL path gets no identity and stays doubled; with automount off and something else mounted at/mnt/cthe two unrelated stores would be merged and one silently dropped. Reading the drive from/proc/mounts(drvfs/9p entries carry the Windows source, e.g.C:\) instead of the path prefix covers both.- Case. The normalized tail keeps its case, and Node's
realpathpreserves the spelling of ordinary components, so a symlink created as/mnt/c/users/drifter/.claudewould not matchC:\Users\Drifter\.claude. Mine matches, so this is an edge case, and blanket lowercasing is not safe given WSL per-directory case sensitivity. Probably worth a note or a test either way. - One existing test breaks:
DesktopBackendConfiguration.test.ts› "resolveWsl preserves existing WSLENV entries when forwarding backend secrets" still expects the oldWSLENVstring; the resolver now insertsT3CODE_USAGE_HOST_IDafter the inherited entries (32/33 in that file).vp fmt --checkalso flagsUsageService.test.tsandusageMerge.test.ts. - Minor: the merge tests use a hand-written lowercase
physicalSourceIdrather than one produced byusagePhysicalSourceId, so nothing covers normalization through to merge, and there is no case where a physical-id source meets a legacy-only source (which is how item 1 slipped through).
Happy to test a revision on this machine.
Claude Opus 5.5 responding on behalf of That1Drifter; second opinion from GPT-6 Astra in Codex
- The physical key replaces the legacy key instead of supplementing it, which regresses same-machine dedupe that works today.


Before submitting
Area
apps/server
Steps to reproduce
ln -s /mnt/c/Users/<user>/.claude ~/.claude. (Nothing here is Claude-specific; a shared~/.codex/sessionsgoes through the same code path.)os.homedir()resolution on both; verifiedsettings.jsonon both sides).Expected behavior
The shared transcript directory is counted once. The fingerprint dedupe in
packages/shared/src/usageMerge.ts(claimSources) exists exactly for this: "several environments on one machine ... resolve the same provider home and would otherwise double count every token."Actual behavior
Every token and session in the shared directory is counted twice, and only on the machine hosting the WSL environment. The WSL backend is loopback-only (
apps/desktop/src/wsl/DesktopWslBackend.ts:19-21), so remote clients can never include that environment, and different PCs viewing the same account show different totals.Why the dedupe misses: the source fingerprint is
hostId + provider + resolvedHomePath + volumeId(UsageService.readSummary), compared as an exact string (usageMerge.tsfingerprintKey), with no fallback matching of any kind. For one physical directory seen from both sides of the WSL boundary:resolvedHomePathdiffers:C:\Users\<user>\.claude\projectsvs/home/<user>/.claude/projects(resolveTranscriptDirsnever realpaths, and realpath would still yield/mnt/c/..., a different string), andvolumeId(dev:inofromreadDirectoryVolumeId) is a different namespace on drvfs (e.g.67:3659174697951287) than on NTFS.hostIdhappens to match here (WSL2 inherits the Windows computer name by default, see [Feature]: Mark WSL environments in their label, since WSL inherits the Windows host name #4820), but that is a default, not a guarantee:/etc/wsl.confcan set a different hostname, so a fix must not rely on hostId equality either.Two distinct fingerprints, so
claimSourcesclaims both and everything merges twice.Measured on two PCs, same 30-day window, minutes apart during active use:
The daily Claude chart shows the identical shape at ~2x scale on MainPC. The 72-session delta is attributable to the duplicated WSL source; the small gap vs the ~80 sessions each source should report is consistent with the scans running minutes apart. Definitive per-source evidence would be each environment's
sources[].distinctSessions(both Claude sources reporting ~equal counts) and the WSL source'sskippedFiles; happy to capture those if useful. A read check of all 97 in-window transcripts through 9p from WSL had zero failures, so partial walks look unlikely as an explanation for the gap.Fix directions, offered as options, and the design tension any of them must resolve:
volumeIdexists specifically so a fleet sharing hostname + home path does not collapse into one source (contracts/src/usage.ts,usageMerge.test.ts), and any cross-boundary unification relaxes exactly that guard./mnt/<drive>to Windows path mapping at fingerprint build time. This must also decide whatvolumeIdmeans across the boundary: keeping it as an exact-match requirement leaves cross-boundary fingerprints distinct (fix does nothing), dropping it reintroduces the fleet collision. A workable shape: the server emits an additional normalized identity field, merge prefers it when present on both sides and falls back to today's key otherwise. Requires aUSAGE_CONTRACT_VERSIONbump (v4 today); mixed-version fleets ride the existing stale-environment exclusion.wsl:<distro>). This reduces to the first option plus provenance: identity alone does not unify the path half of the key, and it only covers desktop-provisioned WSL, not servers started manually inside a distro.Any normalization belongs at fingerprint build time in
apps/server;mergeUsageshould stay pure. A session-ID-overlap dedupe was considered and rejected: overlap is not source equivalence (forks, copies, resumes overlap legitimately), and shipping session IDs in summaries has privacy and contract downsides.Impact
Minor bug or occasional failure
(Though it is money-figure reporting off by ~2x for anyone sharing history across WSL; happy to have triage recategorize.)
Version or commit
0.0.34-nightly.20260821.1154; code paths verified against main @ 592c598
Environment
Windows 11 Pro, desktop app hosting local + managed WSL (Ubuntu, WSL2) environments, second Windows 11 PC connected via T3 Connect. Claude Code + Codex providers.
Logs or stack traces
No response
Screenshots, recordings, or supporting files
Will attach: usage page from both PCs (same window), and both Remote environments lists.
Workaround
Disconnect the WSL environment, or make
~/.claude/projectsa real directory inside WSL instead of shared. Note the second option stops the double count but opens a coverage gap in the other direction: sessions run from WSL then land in a directory only the loopback-only WSL environment can report, which remote clients never see.