diff --git a/src/core/cache.ts b/src/core/cache.ts index 3283b1d7..f7cd6eb2 100644 --- a/src/core/cache.ts +++ b/src/core/cache.ts @@ -130,7 +130,7 @@ export interface CacheData { const cacheFilePath = (): string => path.join(getCacheDir(), 'parsed.json'); const cacheMetaPath = (): string => path.join(getCacheDir(), 'meta.json'); -const CACHE_VERSION = 30; +const CACHE_VERSION = 31; /** Refuse to JSON.parse cache files beyond these sizes: a corrupted (or * tampered) cache must degrade to a full re-parse, not OOM the host. */ diff --git a/src/core/parser-claude.test.ts b/src/core/parser-claude.test.ts index a38a20bd..838d6f46 100644 --- a/src/core/parser-claude.test.ts +++ b/src/core/parser-claude.test.ts @@ -503,6 +503,33 @@ describe('parseClaudeSessions', () => { } }); + it('resolves workspace names with dots and underscores, which Claude Code also encodes as hyphens', () => { + // Claude Code turns /work/.agents into -work--agents and /work/my_app into -work-my-app. + const tmpBase = fs.mkdtempSync(path.join(longTmpDir(), 'claude-dot-')); + const root = fs.mkdtempSync(path.join(longTmpDir(), 'claude-proj-')); + const projectsDir = path.join(root, 'projects'); + try { + for (const name of ['.agents', 'my_app.v2']) { + const targetDir = path.join(tmpBase, name); + fs.mkdirSync(targetDir, { recursive: true }); + const encodedDirName = targetDir + .replace(/^([a-zA-Z])(?=:)/, d => d.toLowerCase()) + .replace(/[^A-Za-z0-9]/g, '-'); + const projDir = path.join(projectsDir, encodedDirName); + fs.mkdirSync(projDir, { recursive: true }); + fs.writeFileSync( + path.join(projDir, `${name.replace(/\W/g, '')}.jsonl`), + [makeUser('hello'), makeAssistant('world')].map(l => JSON.stringify(l)).join('\n'), + ); + } + const names = parseClaudeSessions(projectsDir).map(s => s.workspaceName).sort(); + expect(names).toEqual(['.agents', 'my_app.v2']); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(tmpBase, { recursive: true, force: true }); + } + }); + it('resolves workspace name through symlinked directories (OneDrive reparse points)', () => { // On Windows, "OneDrive - Microsoft" is a reparse point that readdirSync // reports as a symlink rather than a directory. The resolution algorithm diff --git a/src/core/parser-claude.ts b/src/core/parser-claude.ts index 5281138d..d68a922a 100644 --- a/src/core/parser-claude.ts +++ b/src/core/parser-claude.ts @@ -471,19 +471,18 @@ export function findClaudeDirs(): string[] { return dirs; } -/** Encode a single filesystem component the way Claude Code does: - * replace whitespace with hyphens. Path separators never appear in a single - * component name, and colons are not handled here because Windows component - * names cannot contain them. */ +/** Encode a single filesystem component the way Claude Code does: every + * character other than an ASCII letter or digit becomes a hyphen, so spaces, + * dots and underscores (`.agents`, `my_app`) are encoded too. */ function encodeComponentForMatch(name: string): string { - return name.replace(/\s/g, '-'); + return name.replace(/[^A-Za-z0-9]/g, '-'); } /** * Resolve an encoded Claude project directory name back to the real folder name. * - * Claude encodes workspace paths by replacing `/`, `\`, `:`, and whitespace - * with `-`. For example `c--dev-AI-Engineering-Coach` represents + * Claude encodes workspace paths by replacing every character other than an + * ASCII letter or digit (`/`, `\`, `:`, whitespace, `.`, `_` and so on) with `-`. For example `c--dev-AI-Engineering-Coach` represents * `C:\dev\AI-Engineering-Coach`. Because the encoding is lossy (hyphens, spaces, * and path separators all become `-`) we resolve the name by listing each * directory level and matching entries against the remaining encoded string.