mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-14 11:04:44 +00:00
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\<Claude package>\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
This commit is contained in:
parent
2f8e5bddcd
commit
d92b9fea43
6 changed files with 278 additions and 65 deletions
|
|
@ -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 }> {
|
||||
|
|
|
|||
|
|
@ -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<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[]> {
|
||||
|
|
@ -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
|
||||
},
|
||||
|
||||
|
|
@ -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 (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
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue