Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
17 changes: 16 additions & 1 deletion docs/providers/claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<Claude package>/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 `<project>/<sessionId>.jsonl`.
Expand Down
12 changes: 8 additions & 4 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 }> {
Expand Down
161 changes: 110 additions & 51 deletions src/providers/claude.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -101,15 +102,72 @@ export async function discoverClaudeConfigSources(): Promise<ClaudeConfigSource[
})))
}

export function getDesktopSessionsDir(): string {
// Filesystem changes under an unchanged input key intentionally do not invalidate this cache.
const desktopSessionsDirsCache = new Map<string, string[]>()

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<string[]> {
Expand Down Expand Up @@ -221,7 +279,7 @@ export const claude: Provider = {
async probeRoots(): Promise<ProbeRoot[]> {
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
},

Expand Down Expand Up @@ -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_<sessionId>/.claude/projects/. We resolve the space
// name from the sibling .json and spaces.json so it groups correctly.
// Path structure: <desktopBase>/<appId>/<workspaceId>/local_<id>/.claude/projects/<slug>
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_<sessionId>/.claude/projects/. We resolve the space
// name from the sibling .json and spaces.json so it groups correctly.
// Path structure: <desktopBase>/<appId>/<workspaceId>/local_<id>/.claude/projects/<slug>
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
Expand Down
8 changes: 6 additions & 2 deletions src/usage-aggregator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
Loading