Skip to content
Draft
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
43 changes: 43 additions & 0 deletions scripts/session-metadata.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,28 @@ 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,
getSessionMetadataCacheStats,
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-'));
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface AppSettings {
hiddenProjects: string[];
excludedProjects: string[];
theme: 'auto' | 'light' | 'dark';
enableWslTracking: boolean;
}

export const DEFAULT_SETTINGS: AppSettings = {
Expand All @@ -37,6 +38,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
hiddenProjects: [],
excludedProjects: [],
theme: 'auto',
enableWslTracking: false,
};

export function registerIpcHandlers(
Expand Down
5 changes: 4 additions & 1 deletion src/main/pathSafety.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
31 changes: 15 additions & 16 deletions src/main/projectDiscovery.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,45 @@
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<string>();
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;
try { return fs.statSync(cwd).isDirectory(); } catch { return false; }
});
}

function addClaudeProjectCwds(cwds: Set<string>): void {
if (!fs.existsSync(PROJECTS_DIR)) return;
function addClaudeProjectCwds(cwds: Set<string>, 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<string>): 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<string>, 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);
}
}
Expand Down
106 changes: 70 additions & 36 deletions src/main/sessionDiscovery.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand All @@ -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<string, { mainName: string; branch: string } | null>();

function encodeCwd(cwd: string): string {
Expand Down Expand Up @@ -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 {
Expand All @@ -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);

Expand All @@ -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,
Expand Down Expand Up @@ -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;

Expand All @@ -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,
Expand All @@ -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();
Expand Down
Loading