From d92b9fea43b2506424fa240f82ed289100f20cce Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:30:30 +0300 Subject: [PATCH] fix(claude): discover Claude Desktop/Cowork sessions in Windows MSIX installs The desktop sessions resolver returned a single per-platform path, so Microsoft Store (MSIX) installs of Claude Desktop were invisible: their data lives under %LOCALAPPDATA%\Packages\\LocalCache\Roaming\Claude\local-agent-mode-sessions and a filesystem junction workaround breaks Cowork's own file access (reported and verified in #611). getDesktopSessionsDir() becomes getDesktopSessionsDirs(): an ordered, deduped candidate list (override, then classic APPDATA, then MSIX packages matching Claude_* or *.Claude_*, existence-checked, lexicographically sorted; .config on Linux). Results are memoized per env-input tuple so the parser's per-file classification never rescans Packages. All call sites scan every candidate; macOS, Linux and classic Windows behavior unchanged. Fixes #611 --- CHANGELOG.md | 5 + docs/providers/claude.md | 17 ++- src/parser.ts | 12 +- src/providers/claude.ts | 161 ++++++++++++++------- src/usage-aggregator.ts | 8 +- tests/providers/claude-config-dirs.test.ts | 142 +++++++++++++++++- 6 files changed, 279 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cfdf1fa..98c38076 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +### Fixed +- Claude Desktop and Cowork sessions are discovered for Windows Microsoft Store (MSIX) installs. (#611) + ## 0.9.19 - 2026-07-20 One version across every surface: CLI, macOS menubar, and the desktop app all ship as 0.9.19. diff --git a/docs/providers/claude.md b/docs/providers/claude.md index b5954c1f..34128977 100644 --- a/docs/providers/claude.md +++ b/docs/providers/claude.md @@ -12,11 +12,26 @@ Anthropic Claude Code CLI and Claude Desktop's local agent mode. |---|---| | Claude Code CLI | `$CLAUDE_CONFIG_DIR` if set, otherwise `~/.claude/projects/` | | Claude Desktop (macOS) | `~/Library/Application Support/Claude/local-agent-mode-sessions/` | -| Claude Desktop (Windows) | `%APPDATA%/Claude/local-agent-mode-sessions/` | +| Claude Desktop (Windows, classic) | `%APPDATA%/Claude/local-agent-mode-sessions/` | +| Claude Desktop (Windows, MSIX) | `%LOCALAPPDATA%/Packages//LocalCache/Roaming/Claude/local-agent-mode-sessions/` | | Claude Desktop (Linux) | `~/.config/Claude/local-agent-mode-sessions/` | For Desktop, `findDesktopProjectDirs` walks up to 8 levels deep looking for `projects/` subdirectories, skipping `node_modules` and `.git`. +Desktop session roots are resolved in this order: + +1. A non-empty `CODEBURN_DESKTOP_SESSIONS_DIR` overrides discovery and is the + only returned root. +2. macOS uses the single path shown above. +3. Windows always includes the classic path first. It then scans + `%LOCALAPPDATA%/Packages` for package directories whose names start with + `Claude_` or contain `.Claude_`, sorted by package name, and includes only + packages whose full MSIX sessions path exists as a directory. +4. Other platforms use the single Linux path shown above. + +All returned roots are absolute, resolved, and deduplicated. Missing or +unreadable Windows package directories are ignored. + ## Storage format JSONL, one event per line, per session file. Sessions live under `/.jsonl`. diff --git a/src/parser.ts b/src/parser.ts index 91856cd5..ee165e3a 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -8,7 +8,7 @@ import { normalizeContentBlocks } from './content-utils.js' import { discoverAllSessions, getProvider } from './providers/index.js' import { flushCodexCache } from './codex-cache.js' import { antigravityCascadeIdFromPath, flushAntigravityCache, shouldReparseAntigravitySource } from './providers/antigravity.js' -import { getDesktopSessionsDir } from './providers/claude.js' +import { getDesktopSessionsDirs } from './providers/claude.js' import { isSqliteBusyError } from './sqlite.js' import { type CachedCall, @@ -81,9 +81,13 @@ function projectNameFromPath(projectPath: string, fallback: string): string { // In both cases the grouping key comes from the Cowork space name resolved in // claude.ts::discoverSessions(). function isCoworkSession(cwd: string, filePath: string): boolean { - const base = resolve(getDesktopSessionsDir()) - const inBase = (p: string) => p.startsWith(base + sep) || p.startsWith(base + '/') - return inBase(resolve(cwd)) || inBase(resolve(filePath)) + const resolvedCwd = resolve(cwd) + const resolvedFilePath = resolve(filePath) + return getDesktopSessionsDirs().some(base => { + const resolvedBase = resolve(base) + const inBase = (p: string) => p.startsWith(resolvedBase + sep) || p.startsWith(resolvedBase + '/') + return inBase(resolvedCwd) || inBase(resolvedFilePath) + }) } async function resolveCanonicalProjectPath(cwd: string): Promise<{ path: string; isWorktree: boolean }> { diff --git a/src/providers/claude.ts b/src/providers/claude.ts index f987b595..dd6b3885 100644 --- a/src/providers/claude.ts +++ b/src/providers/claude.ts @@ -1,3 +1,4 @@ +import { readdirSync, statSync } from 'fs' import { readFile, readdir, stat } from 'fs/promises' import { basename, delimiter as pathDelimiter, join, resolve } from 'path' import { homedir } from 'os' @@ -101,15 +102,72 @@ export async function discoverClaudeConfigSources(): Promise() + +function cacheDesktopSessionsDirs(key: string, candidates: string[]): string[] { + const dirs = dedupeResolved(candidates.map(candidate => resolve(candidate))) + desktopSessionsDirsCache.set(key, dirs) + return [...dirs] +} + +export function getDesktopSessionsDirs(): string[] { const override = process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] - if (override) return override - if (process.platform === 'darwin') return join(homedir(), 'Library', 'Application Support', 'Claude', 'local-agent-mode-sessions') - if (process.platform === 'win32') { - const appData = process.env['APPDATA']?.trim() - return join(appData || join(homedir(), 'AppData', 'Roaming'), 'Claude', 'local-agent-mode-sessions') + const appDataInput = process.env['APPDATA'] + const localAppDataInput = process.env['LOCALAPPDATA'] + const platform = process.platform + const cacheKey = JSON.stringify([platform, override ?? null, appDataInput ?? null, localAppDataInput ?? null]) + const cached = desktopSessionsDirsCache.get(cacheKey) + if (cached) return [...cached] + + if (override) return cacheDesktopSessionsDirs(cacheKey, [override]) + if (platform === 'darwin') { + return cacheDesktopSessionsDirs( + cacheKey, + [join(homedir(), 'Library', 'Application Support', 'Claude', 'local-agent-mode-sessions')], + ) } - return join(homedir(), '.config', 'Claude', 'local-agent-mode-sessions') + if (platform === 'win32') { + const appData = appDataInput?.trim() + const candidates = [ + join(appData || join(homedir(), 'AppData', 'Roaming'), 'Claude', 'local-agent-mode-sessions'), + ] + + const localAppData = localAppDataInput?.trim() + const packagesDir = join(localAppData || join(homedir(), 'AppData', 'Local'), 'Packages') + try { + const entries = readdirSync(packagesDir, { withFileTypes: true }) + .filter(entry => + entry.isDirectory() && + (entry.name.startsWith('Claude_') || entry.name.includes('.Claude_')), + ) + .sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0) + + for (const entry of entries) { + const sessionsDir = join( + packagesDir, + entry.name, + 'LocalCache', + 'Roaming', + 'Claude', + 'local-agent-mode-sessions', + ) + try { + if (statSync(sessionsDir).isDirectory()) candidates.push(sessionsDir) + } catch { + // A package may disappear or be unreadable while Packages is scanned. + } + } + } catch { + // Missing or unreadable Packages is equivalent to no MSIX candidates. + } + + return cacheDesktopSessionsDirs(cacheKey, candidates) + } + return cacheDesktopSessionsDirs( + cacheKey, + [join(homedir(), '.config', 'Claude', 'local-agent-mode-sessions')], + ) } async function findDesktopProjectDirs(base: string): Promise { @@ -221,7 +279,7 @@ export const claude: Provider = { async probeRoots(): Promise { const dirs = await getClaudeConfigDirs() const roots: ProbeRoot[] = dirs.map(dir => ({ path: join(dir, 'projects'), label: 'projects' })) - roots.push({ path: getDesktopSessionsDir(), label: 'desktop' }) + roots.push(...getDesktopSessionsDirs().map(path => ({ path, label: 'desktop' }))) return roots }, @@ -282,51 +340,52 @@ export const claude: Provider = { ) } - const desktopBase = getDesktopSessionsDir() - const desktopDirs = await findDesktopProjectDirs(desktopBase) - const sep = desktopBase.includes('\\') ? '\\' : '/' - // Desktop / Cowork sessions belong to no CLAUDE_CONFIG_DIR. Tag them with a - // distinct source so a per-config view can account for them as their own - // "Claude Desktop" bucket instead of silently dropping them (which made - // sum-of-configs < All). - const desktopSourceId = 'claude-desktop:' + createHash('sha256').update(resolve(desktopBase)).digest('hex').slice(0, 16) - for (const dirPath of desktopDirs) { - const resolved = resolve(dirPath) - if (seenProjectDirs.has(resolved)) continue - seenProjectDirs.add(resolved) - - // For Claude Desktop local-agent-mode (Cowork) sessions, the project dir - // lives inside local_/.claude/projects/. We resolve the space - // name from the sibling .json and spaces.json so it groups correctly. - // Path structure: ///local_/.claude/projects/ - let projectName = basename(dirPath) - const resolvedBase = resolve(desktopBase) - if (resolved.startsWith(resolvedBase + sep) || resolved.startsWith(resolvedBase + '/')) { - const rel = resolved.slice(resolvedBase.length + 1) - const parts = rel.split(/[/\\]/) - // parts = [appId, workspaceId, local_sessionId, .claude, projects, slug] - if ( - parts.length >= 6 && - parts[2]?.startsWith('local_') && - parts[3] === '.claude' && - parts[4] === 'projects' - ) { - const workspaceDir = join(resolvedBase, parts[0]!, parts[1]!) - const sessionId = parts[2]! - const spaceName = await resolveCoworkSpaceName(workspaceDir, sessionId) - if (spaceName) projectName = spaceName + for (const desktopBase of getDesktopSessionsDirs()) { + const desktopDirs = await findDesktopProjectDirs(desktopBase) + const sep = desktopBase.includes('\\') ? '\\' : '/' + // Desktop / Cowork sessions belong to no CLAUDE_CONFIG_DIR. Tag them with a + // distinct source so a per-config view can account for them as their own + // "Claude Desktop" bucket instead of silently dropping them (which made + // sum-of-configs < All). + const desktopSourceId = 'claude-desktop:' + createHash('sha256').update(resolve(desktopBase)).digest('hex').slice(0, 16) + for (const dirPath of desktopDirs) { + const resolved = resolve(dirPath) + if (seenProjectDirs.has(resolved)) continue + seenProjectDirs.add(resolved) + + // For Claude Desktop local-agent-mode (Cowork) sessions, the project dir + // lives inside local_/.claude/projects/. We resolve the space + // name from the sibling .json and spaces.json so it groups correctly. + // Path structure: ///local_/.claude/projects/ + let projectName = basename(dirPath) + const resolvedBase = resolve(desktopBase) + if (resolved.startsWith(resolvedBase + sep) || resolved.startsWith(resolvedBase + '/')) { + const rel = resolved.slice(resolvedBase.length + 1) + const parts = rel.split(/[/\\]/) + // parts = [appId, workspaceId, local_sessionId, .claude, projects, slug] + if ( + parts.length >= 6 && + parts[2]?.startsWith('local_') && + parts[3] === '.claude' && + parts[4] === 'projects' + ) { + const workspaceDir = join(resolvedBase, parts[0]!, parts[1]!) + const sessionId = parts[2]! + const spaceName = await resolveCoworkSpaceName(workspaceDir, sessionId) + if (spaceName) projectName = spaceName + } } - } - sources.push({ - path: dirPath, - project: projectName, - provider: 'claude', - sourceId: desktopSourceId, - sourceLabel: 'Claude Desktop', - sourcePath: desktopBase, - sourceKind: 'claude-desktop', - }) + sources.push({ + path: dirPath, + project: projectName, + provider: 'claude', + sourceId: desktopSourceId, + sourceLabel: 'Claude Desktop', + sourcePath: desktopBase, + sourceKind: 'claude-desktop', + }) + } } return sources diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts index fe7024a5..baf4c319 100644 --- a/src/usage-aggregator.ts +++ b/src/usage-aggregator.ts @@ -4,7 +4,7 @@ import { type PeriodData, type ProviderCost, type BreakdownArrays, type MenubarP import { parseAllSessions, filterProjectsByName, filterProjectsByDays, filterProjectsByClaudeConfigSource, isSessionHydrationComplete } from './parser.js' import { findUnpricedModels, getLocalModelSavingsConfigHash, getPriceOverridesConfigHash, getShortModelName, isExpectedFreeModel } from './models.js' import { getAllProviders, safeDiscoverSessions } from './providers/index.js' -import { claude, getClaudeConfigDirs, getDesktopSessionsDir } from './providers/claude.js' +import { claude, getClaudeConfigDirs, getDesktopSessionsDirs } from './providers/claude.js' import { stat } from 'node:fs/promises' import { aggregateProjectsIntoDays, buildPeriodDataFromDays } from './day-aggregator.js' import { aggregateModelEfficiency } from './model-efficiency.js' @@ -149,7 +149,11 @@ async function claudeConfigSelector(projects: ProjectSummary[], selectedId?: str // idle config or Claude Desktop is still selectable). Only worth it when a // second source is possible: more than one config dir, or a Claude Desktop // sessions dir exists. A plain single-config user skips it entirely. - const desktopExists = await stat(getDesktopSessionsDir()).then(s => s.isDirectory()).catch(() => false) + const desktopExists = ( + await Promise.all( + getDesktopSessionsDirs().map(dir => stat(dir).then(s => s.isDirectory()).catch(() => false)), + ) + ).some(Boolean) if ((await getClaudeConfigDirs()).length > 1 || desktopExists) { for (const source of await claude.discoverSessions()) { if ((source.sourceKind !== 'claude-config' && source.sourceKind !== 'claude-desktop') || !source.sourceId || !source.sourceLabel || !source.sourcePath) continue diff --git a/tests/providers/claude-config-dirs.test.ts b/tests/providers/claude-config-dirs.test.ts index c316c741..571469b1 100644 --- a/tests/providers/claude-config-dirs.test.ts +++ b/tests/providers/claude-config-dirs.test.ts @@ -1,10 +1,10 @@ import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises' -import { delimiter as pathDelimiter, join } from 'path' +import { delimiter as pathDelimiter, join, resolve, sep } from 'path' import { homedir, tmpdir } from 'os' import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { claude, getDesktopSessionsDir } from '../../src/providers/claude.js' +import { claude, getDesktopSessionsDirs } from '../../src/providers/claude.js' import { clearSessionCache, filterProjectsByClaudeConfigSource, parseAllSessions } from '../../src/parser.js' let tmpRoot: string @@ -13,6 +13,7 @@ const savedEnv = { CLAUDE_CONFIG_DIRS: process.env['CLAUDE_CONFIG_DIRS'], CODEBURN_DESKTOP_SESSIONS_DIR: process.env['CODEBURN_DESKTOP_SESSIONS_DIR'], APPDATA: process.env['APPDATA'], + LOCALAPPDATA: process.env['LOCALAPPDATA'], HOME: process.env['HOME'], } @@ -39,6 +40,7 @@ beforeEach(async () => { delete process.env['CLAUDE_CONFIG_DIRS'] delete process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] delete process.env['APPDATA'] + delete process.env['LOCALAPPDATA'] }) afterEach(async () => { @@ -218,30 +220,154 @@ describe('claude provider — CLAUDE_CONFIG_DIRS discovery', () => { }) describe('claude provider — Desktop sessions dir', () => { - it('uses APPDATA as the Windows Claude Desktop sessions root', () => { + async function makeMsixSessionsDir(localAppData: string, packageName: string): Promise { + const sessionsDir = join( + localAppData, + 'Packages', + packageName, + 'LocalCache', + 'Roaming', + 'Claude', + 'local-agent-mode-sessions', + ) + await mkdir(sessionsDir, { recursive: true }) + return resolve(sessionsDir) + } + + it('returns the classic Windows root first and discovers an MSIX sessions root', async () => { + const appData = join(tmpRoot, 'roaming-profile') + const localAppData = join(tmpRoot, 'local-profile') + const classic = join(appData, 'Claude', 'local-agent-mode-sessions') + const msix = await makeMsixSessionsDir(localAppData, 'Claude_fresh9k2m') + await mkdir(classic, { recursive: true }) + process.env['APPDATA'] = appData + process.env['LOCALAPPDATA'] = localAppData + + withPlatform('win32', () => { + expect(getDesktopSessionsDirs()).toEqual([resolve(classic), msix]) + }) + }) + + it('filters MSIX package names and keeps both supported Claude name shapes', async () => { + const appData = join(tmpRoot, 'roaming-profile') + const localAppData = join(tmpRoot, 'local-profile') + const classic = resolve(join(appData, 'Claude', 'local-agent-mode-sessions')) + const direct = await makeMsixSessionsDir(localAppData, 'Claude_new7v4p') + const publisher = await makeMsixSessionsDir(localAppData, 'SomePublisher.Claude_q8w3e') + await makeMsixSessionsDir(localAppData, 'ClaudeXtra_foo') + await makeMsixSessionsDir(localAppData, 'NotClaude_x') + process.env['APPDATA'] = appData + process.env['LOCALAPPDATA'] = localAppData + + withPlatform('win32', () => { + expect(getDesktopSessionsDirs()).toEqual([classic, direct, publisher]) + }) + }) + + it('returns only the classic root when LOCALAPPDATA is unset and Packages is missing', () => { const appData = join(tmpRoot, 'roaming-profile') process.env['APPDATA'] = appData + delete process.env['LOCALAPPDATA'] withPlatform('win32', () => { - expect(getDesktopSessionsDir()).toBe(join(appData, 'Claude', 'local-agent-mode-sessions')) + expect(() => getDesktopSessionsDirs()).not.toThrow() + expect(getDesktopSessionsDirs()).toEqual([ + resolve(join(appData, 'Claude', 'local-agent-mode-sessions')), + ]) }) }) - it('falls back to the legacy Windows roaming profile path when APPDATA is unset', () => { + it('falls back to the legacy Windows roaming path when APPDATA is unset or empty', () => { + const expected = resolve(join(homedir(), 'AppData', 'Roaming', 'Claude', 'local-agent-mode-sessions')) delete process.env['APPDATA'] + delete process.env['LOCALAPPDATA'] + + withPlatform('win32', () => { + expect(getDesktopSessionsDirs()).toEqual([expected]) + process.env['APPDATA'] = '' + expect(getDesktopSessionsDirs()).toEqual([expected]) + }) + }) + + it('excludes an MSIX package whose sessions subpath is missing', async () => { + const appData = join(tmpRoot, 'roaming-profile') + const localAppData = join(tmpRoot, 'local-profile') + await mkdir(join(localAppData, 'Packages', 'Claude_empty6r1t', 'LocalCache'), { recursive: true }) + process.env['APPDATA'] = appData + process.env['LOCALAPPDATA'] = localAppData withPlatform('win32', () => { - expect(getDesktopSessionsDir()).toBe(join(homedir(), 'AppData', 'Roaming', 'Claude', 'local-agent-mode-sessions')) + expect(getDesktopSessionsDirs()).toEqual([ + resolve(join(appData, 'Claude', 'local-agent-mode-sessions')), + ]) }) }) - it('keeps CODEBURN_DESKTOP_SESSIONS_DIR ahead of Windows APPDATA discovery', () => { + it('returns exactly the resolved override without platform discovery', async () => { const override = join(tmpRoot, 'desktop-override') + const localAppData = join(tmpRoot, 'local-profile') + await makeMsixSessionsDir(localAppData, 'Claude_shouldnotappear5n8d') process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = override process.env['APPDATA'] = join(tmpRoot, 'roaming-profile') + process.env['LOCALAPPDATA'] = localAppData + + withPlatform('win32', () => { + expect(getDesktopSessionsDirs()).toEqual([resolve(override)]) + }) + }) + + it('resolves an override containing a parent segment and trailing separator', () => { + const override = join(tmpRoot, 'desktop-parent', 'nested') + `${sep}..${sep}sessions${sep}` + process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = override + + withPlatform('win32', () => { + expect(getDesktopSessionsDirs()).toEqual([resolve(override)]) + }) + }) + + it('returns one platform root on darwin and linux without including MSIX packages', async () => { + const localAppData = join(tmpRoot, 'local-profile') + await makeMsixSessionsDir(localAppData, 'Claude_crossplatform3b7j') + process.env['LOCALAPPDATA'] = localAppData + + withPlatform('darwin', () => { + expect(getDesktopSessionsDirs()).toEqual([ + resolve(join(homedir(), 'Library', 'Application Support', 'Claude', 'local-agent-mode-sessions')), + ]) + }) + withPlatform('linux', () => { + expect(getDesktopSessionsDirs()).toEqual([ + resolve(join(homedir(), '.config', 'Claude', 'local-agent-mode-sessions')), + ]) + }) + }) + + it('sorts two valid MSIX roots by package entry name', async () => { + const appData = join(tmpRoot, 'roaming-profile') + const localAppData = join(tmpRoot, 'local-profile') + const later = await makeMsixSessionsDir(localAppData, 'Claude_zulu4c9x') + const earlier = await makeMsixSessionsDir(localAppData, 'Alpha.Claude_alpha2m6v') + process.env['APPDATA'] = appData + process.env['LOCALAPPDATA'] = localAppData + + withPlatform('win32', () => { + expect(getDesktopSessionsDirs()).toEqual([ + resolve(join(appData, 'Claude', 'local-agent-mode-sessions')), + earlier, + later, + ]) + }) + }) + + it('dedupes classic and MSIX candidates that resolve to the same path', async () => { + const localAppData = join(tmpRoot, 'local-profile') + const packageName = 'Claude_duplicate8h3s' + const sessionsDir = await makeMsixSessionsDir(localAppData, packageName) + process.env['APPDATA'] = join(localAppData, 'Packages', packageName, 'LocalCache', 'Roaming') + process.env['LOCALAPPDATA'] = localAppData withPlatform('win32', () => { - expect(getDesktopSessionsDir()).toBe(override) + expect(getDesktopSessionsDirs()).toEqual([sessionsDir]) }) }) })