diff --git a/scripts/session-metadata.test.mjs b/scripts/session-metadata.test.mjs index 1f82162..79f5689 100644 --- a/scripts/session-metadata.test.mjs +++ b/scripts/session-metadata.test.mjs @@ -5,6 +5,7 @@ import os from 'node:os'; import path from 'node:path'; import sessionMetadata from '../dist/main/sessionMetadata.js'; +import wslPaths from '../dist/main/wslPaths.js'; const { clearSessionMetadataCache, @@ -12,6 +13,20 @@ const { invalidateSessionMetadataCache, readJsonlCwd, } = sessionMetadata; +const { mapCwdForSource } = wslPaths; + +function wslSource(distro = 'Ubuntu') { + return { + id: `wsl:${distro}`, + label: `WSL ${distro}`, + kind: 'wsl', + distro, + linuxHome: '/home/example', + claudeSessionsDir: `\\\\wsl$\\${distro}\\home\\example\\.claude\\sessions`, + claudeProjectsDir: `\\\\wsl$\\${distro}\\home\\example\\.claude\\projects`, + codexSessionsDir: `\\\\wsl$\\${distro}\\home\\example\\.codex\\sessions`, + }; +} function tempDir() { return fs.mkdtempSync(path.join(os.tmpdir(), 'wmt-session-metadata-')); @@ -104,6 +119,34 @@ test('Claude cwd is read from top-level cwd field', () => { assert.equal(readJsonlCwd(filePath, 'claude'), cwd); }); +test('WSL Linux cwd values are mapped to Windows-readable paths', () => { + const source = wslSource(); + + assert.equal( + mapCwdForSource(source, '/home/example/my-project'), + '\\\\wsl$\\Ubuntu\\home\\example\\my-project', + ); + assert.equal( + mapCwdForSource(source, '/mnt/c/dev/my-project'), + 'C:\\dev\\my-project', + ); +}); + +test('Codex cwd can be read from a WSL log source', () => { + clearSessionMetadataCache(); + const dir = tempDir(); + const filePath = path.join(dir, 'codex-wsl.jsonl'); + + writeJsonl(filePath, [ + JSON.stringify({ type: 'session_meta', payload: { cwd: '/home/example/example-repo' } }), + ]); + + assert.equal( + readJsonlCwd(filePath, 'codex', wslSource()), + '\\\\wsl$\\Ubuntu\\home\\example\\example-repo', + ); +}); + test('Cwd cache reuses unchanged files and refreshes after stat changes', () => { clearSessionMetadataCache(); const dir = tempDir(); diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 7e918d7..d663351 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -22,6 +22,7 @@ export interface AppSettings { hiddenProjects: string[]; excludedProjects: string[]; theme: 'auto' | 'light' | 'dark'; + enableWslTracking: boolean; } export const DEFAULT_SETTINGS: AppSettings = { @@ -37,6 +38,7 @@ export const DEFAULT_SETTINGS: AppSettings = { hiddenProjects: [], excludedProjects: [], theme: 'auto', + enableWslTracking: false, }; export function registerIpcHandlers( diff --git a/src/main/pathSafety.ts b/src/main/pathSafety.ts index 8504bec..a474cb4 100644 --- a/src/main/pathSafety.ts +++ b/src/main/pathSafety.ts @@ -6,8 +6,11 @@ export function isSafeLocalCwd(cwd: string): boolean { const normalized = cwd.replace(/\//g, '\\'); if (process.platform === 'win32') { - if (normalized.startsWith('\\\\')) return false; if (/^\\\\[.?]\\/.test(normalized)) return false; + if (normalized.startsWith('\\\\')) { + return /^\\\\wsl(?:\.localhost|\$)\\[^\\]+\\/i.test(normalized); + } + return /^[a-z]:\\/i.test(normalized); } else if (cwd.startsWith('//')) { return false; } diff --git a/src/main/projectDiscovery.ts b/src/main/projectDiscovery.ts index 58402ec..2ffde96 100644 --- a/src/main/projectDiscovery.ts +++ b/src/main/projectDiscovery.ts @@ -1,17 +1,16 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; import { TrackingProvider } from './sessionDiscovery'; import { isSafeLocalCwd } from './pathSafety'; import { readJsonlCwd } from './sessionMetadata'; +import { getUsageLogSources, UsageLogSource } from './wslPaths'; -const PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects'); -const CODEX_SESSIONS_DIR = path.join(os.homedir(), '.codex', 'sessions'); - -export function discoverAllProjectCwds(provider: TrackingProvider = 'both'): string[] { +export function discoverAllProjectCwds(provider: TrackingProvider = 'both', enableWslTracking = false): string[] { const cwds = new Set(); - if (provider === 'claude' || provider === 'both') addClaudeProjectCwds(cwds); - if (provider === 'codex' || provider === 'both') addCodexProjectCwds(cwds); + for (const source of getUsageLogSources(enableWslTracking)) { + if (provider === 'claude' || provider === 'both') addClaudeProjectCwds(cwds, source); + if (provider === 'codex' || provider === 'both') addCodexProjectCwds(cwds, source); + } return [...cwds].filter(cwd => { if (!isSafeLocalCwd(cwd)) return false; @@ -19,28 +18,28 @@ export function discoverAllProjectCwds(provider: TrackingProvider = 'both'): str }); } -function addClaudeProjectCwds(cwds: Set): void { - if (!fs.existsSync(PROJECTS_DIR)) return; +function addClaudeProjectCwds(cwds: Set, source: UsageLogSource): void { + if (!fs.existsSync(source.claudeProjectsDir)) return; try { - const dirs = fs.readdirSync(PROJECTS_DIR, { withFileTypes: true }) + const dirs = fs.readdirSync(source.claudeProjectsDir, { withFileTypes: true }) .filter(d => d.isDirectory()); for (const dir of dirs) { - const dirPath = path.join(PROJECTS_DIR, dir.name); + const dirPath = `${source.claudeProjectsDir}\\${dir.name}`; try { const jsonlFiles = fs.readdirSync(dirPath) .filter(f => f.endsWith('.jsonl') && !f.startsWith('agent-')); if (jsonlFiles.length === 0) continue; - const cwd = readJsonlCwd(path.join(dirPath, jsonlFiles[0]), 'claude'); + const cwd = readJsonlCwd(`${dirPath}\\${jsonlFiles[0]}`, 'claude', source); if (cwd && isSafeLocalCwd(cwd)) cwds.add(cwd); } catch { /* skip */ } } } catch { /* skip */ } } -function addCodexProjectCwds(cwds: Set): void { - if (!fs.existsSync(CODEX_SESSIONS_DIR)) return; - for (const filePath of listJsonlFiles(CODEX_SESSIONS_DIR)) { - const cwd = readJsonlCwd(filePath, 'codex'); +function addCodexProjectCwds(cwds: Set, source: UsageLogSource): void { + if (!fs.existsSync(source.codexSessionsDir)) return; + for (const filePath of listJsonlFiles(source.codexSessionsDir)) { + const cwd = readJsonlCwd(filePath, 'codex', source); if (cwd && isSafeLocalCwd(cwd)) cwds.add(cwd); } } diff --git a/src/main/sessionDiscovery.ts b/src/main/sessionDiscovery.ts index 1045bac..53fda67 100644 --- a/src/main/sessionDiscovery.ts +++ b/src/main/sessionDiscovery.ts @@ -1,8 +1,8 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; import { isSafeLocalCwd } from './pathSafety'; -import { readCodexSessionHeader } from './sessionMetadata'; +import { readCodexSessionHeader, readJsonlCwd } from './sessionMetadata'; +import { getUsageLogSources, mapCwdForSource, sourceLabel, UsageLogSource } from './wslPaths'; export type SessionState = 'active' | 'waiting' | 'idle' | 'compacting'; export type TrackingProvider = 'claude' | 'codex' | 'both'; @@ -13,10 +13,12 @@ export interface DiscoveredSession { pid: number | null; sessionId: string; cwd: string; + rawCwd: string | null; projectName: string; startedAt: Date; entrypoint: string; source: string; + logSource: string; state: SessionState; jsonlPath: string | null; lastModified: Date | null; @@ -26,9 +28,10 @@ export interface DiscoveredSession { mainRepoName: string | null; } -const SESSIONS_DIR = path.join(os.homedir(), '.claude', 'sessions'); -const PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects'); -const CODEX_SESSIONS_DIR = path.join(os.homedir(), '.codex', 'sessions'); +const WINDOWS_SOURCE = getUsageLogSources(false)[0]; +const SESSIONS_DIR = WINDOWS_SOURCE.claudeSessionsDir; +const PROJECTS_DIR = WINDOWS_SOURCE.claudeProjectsDir; +const CODEX_SESSIONS_DIR = WINDOWS_SOURCE.codexSessionsDir; const worktreeCache = new Map(); function encodeCwd(cwd: string): string { @@ -166,21 +169,43 @@ function isProcessAlive(pid: number): boolean { } } -function findJsonlPath(cwd: string, sessionId: string): string | null { - const encoded = encodeCwd(cwd); - const candidate = path.join(PROJECTS_DIR, encoded, `${sessionId}.jsonl`); - if (fs.existsSync(candidate)) return candidate; +function findClaudeProjectDir(rawCwd: string, source: UsageLogSource): string | null { + const encoded = encodeCwd(rawCwd); + const exact = path.join(source.claudeProjectsDir, encoded); + if (fs.existsSync(exact)) return exact; - // Case mismatch correction: scan directory directly try { - const dirs = fs.readdirSync(PROJECTS_DIR); + const dirs = fs.readdirSync(source.claudeProjectsDir); const match = dirs.find(d => d.toLowerCase() === encoded.toLowerCase()); - if (match) { - const p = path.join(PROJECTS_DIR, match, `${sessionId}.jsonl`); - if (fs.existsSync(p)) return p; - } - } catch { /* ignore */ } - return null; + return match ? path.join(source.claudeProjectsDir, match) : null; + } catch { + return null; + } +} + +function newestJsonlForCwd(dirPath: string, mappedCwd: string, source: UsageLogSource): string | null { + try { + const candidates = fs.readdirSync(dirPath) + .filter(f => f.endsWith('.jsonl') && !f.startsWith('agent-')) + .map(file => path.join(dirPath, file)) + .filter(file => readJsonlCwd(file, 'claude', source) === mappedCwd) + .map(file => ({ file, mtime: getJsonlLastModified(file)?.getTime() ?? 0 })) + .sort((a, b) => b.mtime - a.mtime); + return candidates[0]?.file ?? null; + } catch { + return null; + } +} + +function findJsonlPath(rawCwd: string, mappedCwd: string, sessionId: string, source: UsageLogSource): string | null { + const dirPath = findClaudeProjectDir(rawCwd, source); + if (!dirPath) return null; + + const candidate = path.join(dirPath, `${sessionId}.jsonl`); + if (fs.existsSync(candidate)) return candidate; + + // 최신 Claude Code는 sessions/*.json의 sessionId와 projects/*.jsonl 파일명이 다를 수 있다. + return newestJsonlForCwd(dirPath, mappedCwd, source); } function getJsonlLastModified(jsonlPath: string | null): Date | null { @@ -206,26 +231,27 @@ function shouldIncludeProvider(filter: TrackingProvider, provider: SessionProvid return filter === 'both' || filter === provider; } -function discoverClaudeSessions(): DiscoveredSession[] { - if (!fs.existsSync(SESSIONS_DIR)) return []; +function discoverClaudeSessions(source: UsageLogSource): DiscoveredSession[] { + if (!fs.existsSync(source.claudeSessionsDir)) return []; const results: DiscoveredSession[] = []; let files: string[] = []; try { - files = fs.readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.json')); + files = fs.readdirSync(source.claudeSessionsDir).filter(f => f.endsWith('.json')); } catch { return []; } for (const file of files) { try { - const raw = fs.readFileSync(path.join(SESSIONS_DIR, file), 'utf-8'); + const raw = fs.readFileSync(path.join(source.claudeSessionsDir, file), 'utf-8'); const meta = JSON.parse(raw); - const { pid, sessionId, cwd, startedAt, entrypoint = 'cli', name: _name } = meta; - if (!pid || !sessionId || !cwd) continue; - if (!isSafeLocalCwd(cwd)) continue; + const { pid, sessionId, cwd: rawCwd, startedAt, entrypoint = 'cli', name: _name } = meta; + if (!pid || !sessionId || !rawCwd) continue; + const cwd = mapCwdForSource(source, rawCwd); + if (!cwd || !isSafeLocalCwd(cwd)) continue; - const alive = isProcessAlive(pid); - const jsonlPath = findJsonlPath(cwd, sessionId); + const alive = source.kind === 'wsl' ? null : isProcessAlive(pid); + const jsonlPath = findJsonlPath(rawCwd, cwd, sessionId, source); const lastModified = getJsonlLastModified(jsonlPath); const state = calcState(alive, lastModified); @@ -241,10 +267,12 @@ function discoverClaudeSessions(): DiscoveredSession[] { pid, sessionId, cwd, + rawCwd, projectName: worktreeInfo ? `${worktreeInfo.mainName}` : projectName, startedAt: new Date(startedAt), entrypoint, - source: entrypointToSource(entrypoint, 'claude'), + source: sourceLabel(source, entrypointToSource(entrypoint, 'claude')), + logSource: source.label, state, jsonlPath, lastModified, @@ -276,19 +304,21 @@ function listCodexJsonlFiles(dir: string): string[] { return results; } -function discoverCodexSessions(): DiscoveredSession[] { - if (!fs.existsSync(CODEX_SESSIONS_DIR)) return []; +function discoverCodexSessions(source: UsageLogSource): DiscoveredSession[] { + if (!fs.existsSync(source.codexSessionsDir)) return []; const results: DiscoveredSession[] = []; - const files = listCodexJsonlFiles(CODEX_SESSIONS_DIR); + const files = listCodexJsonlFiles(source.codexSessionsDir); for (const filePath of files) { try { - const header = readCodexSessionHeader(filePath); + const header = readCodexSessionHeader(filePath, source); const payload = header?.payload; if (!payload) continue; - const cwd = typeof payload.cwd === 'string' ? payload.cwd : ''; + const rawCwd = typeof payload.cwd === 'string' ? payload.cwd : ''; + if (!rawCwd) continue; + const cwd = mapCwdForSource(source, rawCwd); if (!cwd) continue; if (!isSafeLocalCwd(cwd)) continue; @@ -310,10 +340,12 @@ function discoverCodexSessions(): DiscoveredSession[] { pid: null, sessionId, cwd, + rawCwd, projectName: worktreeInfo ? `${worktreeInfo.mainName}` : projectName, startedAt, entrypoint, - source: codexSourceLabel(entrypoint, originator), + source: sourceLabel(source, codexSourceLabel(entrypoint, originator)), + logSource: source.label, state: calcState(null, stat.mtime), jsonlPath: filePath, lastModified: stat.mtime, @@ -332,10 +364,12 @@ function discoverCodexSessions(): DiscoveredSession[] { }); } -export function discoverSessions(provider: TrackingProvider = 'both'): DiscoveredSession[] { +export function discoverSessions(provider: TrackingProvider = 'both', enableWslTracking = false): DiscoveredSession[] { const results: DiscoveredSession[] = []; - if (shouldIncludeProvider(provider, 'claude')) results.push(...discoverClaudeSessions()); - if (shouldIncludeProvider(provider, 'codex')) results.push(...discoverCodexSessions()); + for (const source of getUsageLogSources(enableWslTracking)) { + if (shouldIncludeProvider(provider, 'claude')) results.push(...discoverClaudeSessions(source)); + if (shouldIncludeProvider(provider, 'codex')) results.push(...discoverCodexSessions(source)); + } return results.sort((a, b) => { const ta = a.lastModified?.getTime() ?? a.startedAt.getTime(); diff --git a/src/main/sessionMetadata.ts b/src/main/sessionMetadata.ts index 0133144..271c8a0 100644 --- a/src/main/sessionMetadata.ts +++ b/src/main/sessionMetadata.ts @@ -1,6 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { isSafeLocalCwd } from './pathSafety'; +import { mapCwdForSource, UsageLogSource } from './wslPaths'; export type JsonlProvider = 'claude' | 'codex'; @@ -85,21 +86,22 @@ function readFilePrefix(filePath: string, maxBytes: number): string | null { } } -function safeCwd(value: unknown): string | null { +function safeCwd(value: unknown, source?: UsageLogSource): string | null { if (typeof value !== 'string') return null; + if (source) return mapCwdForSource(source, value); return isSafeLocalCwd(value) ? value : null; } -export function readCodexSessionHeader(filePath: string): CodexSessionHeader | null { - return readCodexSessionHeaderResult(filePath).value; +export function readCodexSessionHeader(filePath: string, source?: UsageLogSource): CodexSessionHeader | null { + return readCodexSessionHeaderResult(filePath, source).value; } -function readCodexSessionHeaderResult(filePath: string): MetadataReadResult { +function readCodexSessionHeaderResult(filePath: string, source?: UsageLogSource): MetadataReadResult { let stat: fs.Stats; try { stat = fs.statSync(filePath); } catch { return { ok: false, value: null }; } - const key = cacheKey(filePath, 'codex-header'); + const key = cacheKey(filePath, `codex-header-${source?.id ?? 'default'}`); const cached = getCached(codexHeaderCache, key, stat); if (cached !== undefined) return { ok: true, value: cached }; @@ -116,13 +118,13 @@ function readCodexSessionHeaderResult(filePath: string): MetadataReadResult; - const cwd = safeCwd(data.cwd); + const cwd = safeCwd(data.cwd, source); if (cwd) return setCached(cwdCache, key, stat, cwd); } catch { continue; @@ -166,9 +168,13 @@ export function readJsonlCwd(filePath: string, provider: JsonlProvider): string } export function invalidateSessionMetadataCache(filePath: string): void { - codexHeaderCache.delete(cacheKey(filePath, 'codex-header')); - cwdCache.delete(cacheKey(filePath, 'cwd-codex')); - cwdCache.delete(cacheKey(filePath, 'cwd-claude')); + const normalized = normalizedCacheKey(filePath); + for (const key of [...codexHeaderCache.keys()]) { + if (key.endsWith(`:${normalized}`)) codexHeaderCache.delete(key); + } + for (const key of [...cwdCache.keys()]) { + if (key.endsWith(`:${normalized}`)) cwdCache.delete(key); + } } export function clearSessionMetadataCache(): void { diff --git a/src/main/stateManager.ts b/src/main/stateManager.ts index c4270e1..b45fd3a 100644 --- a/src/main/stateManager.ts +++ b/src/main/stateManager.ts @@ -2,7 +2,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import chokidar from 'chokidar'; -import { discoverSessions, DiscoveredSession, TrackingProvider, CLAUDE_PROJECTS_DIR, CLAUDE_SESSIONS_DIR, CODEX_SESSIONS_DIR, projectKeysForCwd } from './sessionDiscovery'; +import { discoverSessions, DiscoveredSession, TrackingProvider, CLAUDE_PROJECTS_DIR, projectKeysForCwd } from './sessionDiscovery'; import { parseJsonlFile, parseJsonlCached, parseCodexJsonlCached, ParsedEntry, ActivityBreakdown, ActivityBreakdownKind, ParsedFile } from './jsonlParser'; import { JsonlCache } from './jsonlCache'; import { computeUsage, UsageData } from './usageWindows'; @@ -15,6 +15,7 @@ import { getGitStatsAsync, GitStats } from './gitStatsCollector'; import { discoverAllProjectCwds } from './projectDiscovery'; import { isSafeLocalCwd } from './pathSafety'; import { clearSessionMetadataCache, invalidateSessionMetadataCache, readJsonlCwd } from './sessionMetadata'; +import { getUsageLogSources, refreshUsageLogSources, UsageLogSource } from './wslPaths'; export interface SessionInfo extends DiscoveredSession { modelName: string; @@ -63,7 +64,6 @@ export interface AppState { allTimeSessions: number; } -const SESSIONS_DIR = CLAUDE_SESSIONS_DIR; const PROJECTS_DIR = CLAUDE_PROJECTS_DIR; function getJsonlMtime(filePath: string): Date | null { @@ -150,6 +150,14 @@ export class StateManager { return { ...DEFAULT_SETTINGS, ...this.store.store }; } + private getLogSources(): UsageLogSource[] { + return getUsageLogSources(this.getSettings().enableWslTracking); + } + + private async refreshLogSources(force = false): Promise { + await refreshUsageLogSources(this.getSettings().enableWslTracking, force); + } + private emptyState(): AppState { return { sessions: [], @@ -327,18 +335,26 @@ export class StateManager { const provider = this.getSettings().provider ?? 'both'; const watchTargets: string[] = []; - if ((provider === 'claude' || provider === 'both') && fs.existsSync(SESSIONS_DIR)) { - watchTargets.push(SESSIONS_DIR); - } - if ((provider === 'claude' || provider === 'both') && fs.existsSync(PROJECTS_DIR)) { - watchTargets.push(PROJECTS_DIR.replace(/\\/g, '/') + '/**/*.jsonl'); - } - if ((provider === 'codex' || provider === 'both') && fs.existsSync(CODEX_SESSIONS_DIR)) { - watchTargets.push(CODEX_SESSIONS_DIR.replace(/\\/g, '/') + '/**/*.jsonl'); + for (const source of this.getLogSources()) { + if ((provider === 'claude' || provider === 'both') && fs.existsSync(source.claudeSessionsDir)) { + watchTargets.push(source.claudeSessionsDir); + } + if ((provider === 'claude' || provider === 'both') && fs.existsSync(source.claudeProjectsDir)) { + watchTargets.push(source.claudeProjectsDir.replace(/\\/g, '/') + '/**/*.jsonl'); + } + if ((provider === 'codex' || provider === 'both') && fs.existsSync(source.codexSessionsDir)) { + watchTargets.push(source.codexSessionsDir.replace(/\\/g, '/') + '/**/*.jsonl'); + } } if (watchTargets.length === 0) return; - this.watcher = chokidar.watch(watchTargets, { ignoreInitial: true }); + try { + this.watcher = chokidar.watch(watchTargets, { ignoreInitial: true }); + } catch { + this.watcher = null; + return; + } + this.watcher.on('error', () => { /* watcher 실패 시 타이머/수동 새로고침으로 폴백 */ }); this.watcher.on('add', (filePath: string) => { if (filePath.endsWith('.jsonl')) { this.debouncedFastRefresh(filePath); @@ -392,6 +408,8 @@ export class StateManager { } this.heavyInFlight = true; try { + await this.refreshLogSources(force); + this.startWatcher(); await this.refreshApiUsagePct(force); const loaded = this.loadProviderEntries(); this.allEntries = loaded.entries; @@ -570,34 +588,36 @@ export class StateManager { } }; - if ((settings.provider === 'claude' || settings.provider === 'both') && fs.existsSync(PROJECTS_DIR)) { - try { - const projectDirs = fs.readdirSync(PROJECTS_DIR, { withFileTypes: true }) - .filter(d => d.isDirectory()) - .map(d => d.name); - - for (const dir of projectDirs) { - const dirPath = path.join(PROJECTS_DIR, dir); - try { - const files = fs.readdirSync(dirPath).filter(f => f.endsWith('.jsonl') && !f.startsWith('agent-')); - const cwd = files.length > 0 ? readJsonlCwd(path.join(dirPath, files[0]), 'claude') : null; - if (isExcluded([dir, ...(cwd ? projectKeysForCwd(cwd) : [])])) continue; - sessionCount += files.length; - for (const file of files) addEntries(parseJsonlCached(path.join(dirPath, file), this.jsonlCache)); - } catch { /* skip */ } - } - } catch { /* skip */ } - } + for (const source of this.getLogSources()) { + if ((settings.provider === 'claude' || settings.provider === 'both') && fs.existsSync(source.claudeProjectsDir)) { + try { + const projectDirs = fs.readdirSync(source.claudeProjectsDir, { withFileTypes: true }) + .filter(d => d.isDirectory()) + .map(d => d.name); + + for (const dir of projectDirs) { + const dirPath = path.join(source.claudeProjectsDir, dir); + try { + const files = fs.readdirSync(dirPath).filter(f => f.endsWith('.jsonl') && !f.startsWith('agent-')); + const cwd = files.length > 0 ? readJsonlCwd(path.join(dirPath, files[0]), 'claude', source) : null; + if (isExcluded([dir, ...(cwd ? projectKeysForCwd(cwd) : [])])) continue; + sessionCount += files.length; + for (const file of files) addEntries(parseJsonlCached(path.join(dirPath, file), this.jsonlCache)); + } catch { /* skip */ } + } + } catch { /* skip */ } + } - if ((settings.provider === 'codex' || settings.provider === 'both') && fs.existsSync(CODEX_SESSIONS_DIR)) { - for (const filePath of this.listJsonlFiles(CODEX_SESSIONS_DIR)) { - const cwd = readJsonlCwd(filePath, 'codex'); - if (cwd && isExcluded(projectKeysForCwd(cwd))) continue; - const parsed = parseCodexJsonlCached(filePath, this.jsonlCache); - if (parsed.entries.length === 0 && !parsed.codexRateLimits) continue; - sessionCount += 1; - codexRateLimits = this.mergeCodexRateLimits(codexRateLimits, parsed.codexRateLimits); - addEntries(parsed); + if ((settings.provider === 'codex' || settings.provider === 'both') && fs.existsSync(source.codexSessionsDir)) { + for (const filePath of this.listJsonlFiles(source.codexSessionsDir)) { + const cwd = readJsonlCwd(filePath, 'codex', source); + if (cwd && isExcluded(projectKeysForCwd(cwd))) continue; + const parsed = parseCodexJsonlCached(filePath, this.jsonlCache); + if (parsed.entries.length === 0 && !parsed.codexRateLimits) continue; + sessionCount += 1; + codexRateLimits = this.mergeCodexRateLimits(codexRateLimits, parsed.codexRateLimits); + addEntries(parsed); + } } } @@ -640,7 +660,7 @@ export class StateManager { } const isExcluded = makeExcludedMatcher(settings.excludedProjects ?? []); - const allCwds = discoverAllProjectCwds(settings.provider) + const allCwds = discoverAllProjectCwds(settings.provider, settings.enableWslTracking) .filter(cwd => isSafeLocalCwd(cwd) && !isExcluded(projectKeysForCwd(cwd))); const rawStats = await Promise.all(allCwds.map(cwd => this.getCachedGitStatsAsync(cwd))); const repoGitStats: Record = {}; @@ -749,7 +769,7 @@ export class StateManager { private buildSessionInfos(): SessionInfo[] { const settings = this.getSettings(); const isExcluded = makeExcludedMatcher(settings.excludedProjects ?? []); - const discovered = discoverSessions(settings.provider).filter(s => { + const discovered = discoverSessions(settings.provider, settings.enableWslTracking).filter(s => { return !isExcluded(this.sessionProjectKeys(s)); }); return discovered.map(s => this.buildSessionInfo(s)); @@ -764,7 +784,8 @@ export class StateManager { applySettingsChange() { const settings = this.getSettings(); const providerChanged = settings.provider !== this.state.settings.provider; - if (providerChanged) { + const wslChanged = settings.enableWslTracking !== this.state.settings.enableWslTracking; + if (providerChanged || wslChanged) { this.jsonlCache.clear(); clearSessionMetadataCache(); this.codexRateLimits = null; diff --git a/src/main/wslPaths.ts b/src/main/wslPaths.ts new file mode 100644 index 0000000..0bec8f8 --- /dev/null +++ b/src/main/wslPaths.ts @@ -0,0 +1,185 @@ +import { execFile } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { isSafeLocalCwd } from './pathSafety'; + +export type LogSourceKind = 'windows' | 'wsl'; + +export interface UsageLogSource { + id: string; + label: string; + kind: LogSourceKind; + claudeSessionsDir: string; + claudeProjectsDir: string; + codexSessionsDir: string; + distro?: string; + linuxHome?: string; +} + +const CACHE_TTL_MS = 5 * 60 * 1000; +let cachedWslSources: { ts: number; sources: UsageLogSource[] } | null = null; +let pendingWslRefresh: Promise | null = null; + +function windowsSource(): UsageLogSource { + const home = os.homedir(); + return { + id: 'windows', + label: 'Windows', + kind: 'windows', + claudeSessionsDir: path.join(home, '.claude', 'sessions'), + claudeProjectsDir: path.join(home, '.claude', 'projects'), + codexSessionsDir: path.join(home, '.codex', 'sessions'), + }; +} + +function decodeWslOutput(buffer: Buffer): string { + const utf8 = buffer.toString('utf8'); + const nulCount = (utf8.match(/\0/g) ?? []).length; + const decoded = nulCount > Math.max(1, utf8.length / 8) + ? buffer.toString('utf16le') + : utf8; + return decoded.replace(/\0/g, '').replace(/\r/g, '').trim(); +} + +function runWsl(args: string[], timeout = 2500): Promise { + return new Promise((resolve) => { + execFile('wsl.exe', args, { + encoding: 'buffer', + timeout, + windowsHide: true, + }, (error, stdout) => { + if (error) { + resolve(null); + return; + } + const text = decodeWslOutput(Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout ?? '')); + resolve(text || null); + }); + }); +} + +function cleanDistroName(value: string): string | null { + const name = value.trim(); + if (!name || name.includes('\\') || name.includes('/')) return null; + return name; +} + +function isUserDistro(name: string): boolean { + const lower = name.toLowerCase(); + return lower !== 'docker-desktop' && lower !== 'docker-desktop-data'; +} + +async function listWslDistros(): Promise { + const output = await runWsl(['--list', '--quiet']); + if (!output) return []; + const seen = new Set(); + for (const line of output.split('\n')) { + const distro = cleanDistroName(line); + if (distro && isUserDistro(distro)) seen.add(distro); + } + return [...seen]; +} + +function normalizeLinuxPath(value: string): string { + const trimmed = value.trim(); + if (!trimmed.startsWith('/')) return ''; + return trimmed.replace(/\/+/g, '/').replace(/\/$/, '') || '/'; +} + +async function getWslHome(distro: string): Promise { + const home = await runWsl(['-d', distro, 'sh', '-lc', 'printf %s "$HOME"']); + if (!home) return null; + return normalizeLinuxPath(home); +} + +function uncPath(prefix: string, distro: string, linuxPath: string): string { + const parts = normalizeLinuxPath(linuxPath).split('/').filter(Boolean); + return path.win32.join(prefix, distro, ...parts); +} + +function pickWslHomePath(distro: string, linuxHome: string): string { + const localhost = uncPath('\\\\wsl.localhost', distro, linuxHome); + if (fs.existsSync(localhost)) return localhost; + return uncPath('\\\\wsl$', distro, linuxHome); +} + +function buildWslSource(distro: string, linuxHome: string): UsageLogSource { + const home = pickWslHomePath(distro, linuxHome); + return { + id: `wsl:${distro}`, + label: `WSL ${distro}`, + kind: 'wsl', + distro, + linuxHome, + claudeSessionsDir: path.win32.join(home, '.claude', 'sessions'), + claudeProjectsDir: path.win32.join(home, '.claude', 'projects'), + codexSessionsDir: path.win32.join(home, '.codex', 'sessions'), + }; +} + +async function discoverWslSources(force = false): Promise { + const now = Date.now(); + if (!force && cachedWslSources && now - cachedWslSources.ts < CACHE_TTL_MS) { + return cachedWslSources.sources; + } + + const sources: UsageLogSource[] = []; + for (const distro of await listWslDistros()) { + const linuxHome = await getWslHome(distro); + if (!linuxHome) continue; + sources.push(buildWslSource(distro, linuxHome)); + } + + cachedWslSources = { ts: now, sources }; + return sources; +} + +export function getUsageLogSources(enableWslTracking = false): UsageLogSource[] { + const sources = [windowsSource()]; + if (enableWslTracking && cachedWslSources) sources.push(...cachedWslSources.sources); + return sources; +} + +export async function refreshUsageLogSources(enableWslTracking = false, force = false): Promise { + if (!enableWslTracking) return getUsageLogSources(false); + if (!force && pendingWslRefresh) { + const sources = await pendingWslRefresh; + return [windowsSource(), ...sources]; + } + + pendingWslRefresh = discoverWslSources(force).finally(() => { + pendingWslRefresh = null; + }); + const sources = await pendingWslRefresh; + return [windowsSource(), ...sources]; +} + +export function mapCwdForSource(source: UsageLogSource, cwd: string): string | null { + if (!cwd || cwd.includes('\0')) return null; + + if (source.kind === 'windows') { + return isSafeLocalCwd(cwd) ? cwd : null; + } + + const normalized = normalizeLinuxPath(cwd); + if (!normalized || !source.distro) return null; + + const mountedDrive = normalized.match(/^\/mnt\/([a-z])(?:\/(.*))?$/i); + if (mountedDrive) { + const drive = mountedDrive[1].toUpperCase(); + const rest = (mountedDrive[2] ?? '').split('/').filter(Boolean); + const windowsPath = path.win32.join(`${drive}:\\`, ...rest); + return isSafeLocalCwd(windowsPath) ? windowsPath : null; + } + + const homeRoot = path.win32.dirname(source.claudeProjectsDir).replace(/\\\.claude$/, ''); + const root = homeRoot.endsWith('\\') ? homeRoot.slice(0, -1) : homeRoot; + const parts = normalized.split('/').filter(Boolean); + const unc = path.win32.join(root.split('\\').slice(0, 4).join('\\'), ...parts); + return isSafeLocalCwd(unc) ? unc : null; +} + +export function sourceLabel(source: UsageLogSource, base: string): string { + return source.kind === 'wsl' ? `${source.label} - ${base}` : base; +} diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index f0105e2..cf26dfb 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -39,6 +39,7 @@ const DEFAULT_STATE: AppState = { globalHotkey: 'CommandOrControl+Shift+D', enableAlerts: true, trayDisplay: 'h5pct', theme: 'auto', hiddenProjects: [], excludedProjects: [], + enableWslTracking: false, }, autoLimits: null, lastUpdated: 0, @@ -85,10 +86,12 @@ function sameSession(a: AppState['sessions'][number], b: AppState['sessions'][nu && a.pid === b.pid && a.sessionId === b.sessionId && a.cwd === b.cwd + && a.rawCwd === b.rawCwd && a.projectName === b.projectName && String(a.startedAt) === String(b.startedAt) && a.entrypoint === b.entrypoint && a.source === b.source + && a.logSource === b.logSource && a.state === b.state && a.jsonlPath === b.jsonlPath && String(a.lastModified) === String(b.lastModified) diff --git a/src/renderer/types.ts b/src/renderer/types.ts index 4fa9cf6..9baf040 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -28,10 +28,12 @@ export interface SessionInfo { pid: number | null; sessionId: string; cwd: string; + rawCwd?: string | null; projectName: string; startedAt: string; entrypoint: string; source: string; + logSource?: string; state: SessionState; jsonlPath: string | null; lastModified: string | null; @@ -148,6 +150,7 @@ export interface AppSettings { hiddenProjects: string[]; excludedProjects: string[]; theme: 'auto' | 'light' | 'dark'; + enableWslTracking: boolean; } export type NotifType = 'alert'; diff --git a/src/renderer/views/SettingsView.tsx b/src/renderer/views/SettingsView.tsx index bc0646a..ecc00d8 100644 --- a/src/renderer/views/SettingsView.tsx +++ b/src/renderer/views/SettingsView.tsx @@ -108,6 +108,15 @@ export default function SettingsView({ settings, onSave, onBack }: Props) { ))} +
+
+
WSL tracking
+
+ Read Claude/Codex logs from detected WSL distributions +
+
+ setS({ ...s, enableWslTracking: e.target.checked })} /> +