From 7cbe5208f7191a9b14ed00948afab69f576b3c47 Mon Sep 17 00:00:00 2001 From: Mohammad Alsayyedan Date: Sun, 20 Sep 2026 20:31:02 -0500 Subject: [PATCH 1/2] Fix workspace name resolution for paths containing underscores Claude Code encodes underscores as hyphens when building the project directory name under ~/.claude/projects, but encodeComponentForMatch only normalized whitespace. Any path component containing an underscore could therefore never match a readdirSync entry, so resolution stopped at the first such level and the fallback used the entire unresolved tail as the workspace name. A user whose home directory is /home/my_name saw every Claude workspace labeled "my-name-AIOps" instead of "AIOps", which also prevents sessions from aggregating with the same project recorded by other harnesses. Normalize underscores alongside whitespace, and add a regression test covering a path component with underscores. --- src/core/parser-claude.test.ts | 33 +++++++++++++++++++++++++++++++++ src/core/parser-claude.ts | 4 ++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/core/parser-claude.test.ts b/src/core/parser-claude.test.ts index 588b33bf..e5c9740c 100644 --- a/src/core/parser-claude.test.ts +++ b/src/core/parser-claude.test.ts @@ -384,6 +384,39 @@ describe('parseClaudeSessions', () => { } }); + // ---- workspace name resolution with underscores ---- + + it('resolves workspace name when path contains a folder with underscores', { timeout: 30_000 }, () => { + // Claude Code encodes underscores as hyphens, so the encoded name cannot be + // matched against readdirSync output unless underscores are normalized too. + const tmpBase = fs.mkdtempSync(path.join(longTmpDir(), 'claude-us-')); + const underscoreParent = path.join(tmpBase, 'my_home_dir'); + const targetDir = path.join(underscoreParent, 'proj'); + fs.mkdirSync(targetDir, { recursive: true }); + + const encodedDirName = targetDir + .replace(/^([a-zA-Z])(?=:)/, d => d.toLowerCase()) + .replace(/[:\\/\s_]/g, '-'); + + const root = fs.mkdtempSync(path.join(longTmpDir(), 'claude-proj-')); + const projectsDir = path.join(root, 'projects'); + const projDir = path.join(projectsDir, encodedDirName); + fs.mkdirSync(projDir, { recursive: true }); + fs.writeFileSync( + path.join(projDir, 's.jsonl'), + [makeUser('hello'), makeAssistant('hi')].map(l => JSON.stringify(l)).join('\n'), + ); + + try { + const result = parseClaudeSessions(projectsDir); + expect(result).toHaveLength(1); + expect(result[0].workspaceName).toBe('proj'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(tmpBase, { recursive: true, force: true }); + } + }); + // ---- workspace name resolution with spaces ---- it('resolves workspace name when path contains a folder with spaces', { timeout: 30_000 }, () => { diff --git a/src/core/parser-claude.ts b/src/core/parser-claude.ts index 73e0ab1f..74bf398c 100644 --- a/src/core/parser-claude.ts +++ b/src/core/parser-claude.ts @@ -384,11 +384,11 @@ export function findClaudeDirs(): string[] { } /** Encode a single filesystem component the way Claude Code does: - * replace whitespace with hyphens. Path separators never appear in a single + * replace whitespace and underscores with hyphens. Path separators never appear in a single * component name, and colons are not handled here because Windows component * names cannot contain them. */ function encodeComponentForMatch(name: string): string { - return name.replace(/\s/g, '-'); + return name.replace(/[\s_]/g, '-'); } /** From a696d56bd644771628097487e7ce6f4721510eb5 Mon Sep 17 00:00:00 2001 From: Mohammad Alsayyedan Date: Tue, 3 Nov 2026 11:41:07 -0600 Subject: [PATCH 2/2] Match Claude Code's full encoding rule for workspace names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace every character outside [A-Za-z0-9] with a hyphen, as Claude Code does, so folders with dots, punctuation or non-ASCII letters also resolve. Make the regression test table-driven over my_home_dir, my.app, .config and café. Suggested in review by @san360. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RLDXsjE1DMDor8E6cSLBpH --- src/core/parser-claude.test.ts | 65 +++++++++++++++++----------------- src/core/parser-claude.ts | 11 +++--- 2 files changed, 39 insertions(+), 37 deletions(-) diff --git a/src/core/parser-claude.test.ts b/src/core/parser-claude.test.ts index bb3646e3..5569c71d 100644 --- a/src/core/parser-claude.test.ts +++ b/src/core/parser-claude.test.ts @@ -431,38 +431,39 @@ describe('parseClaudeSessions', () => { } }); - // ---- workspace name resolution with underscores ---- - - it('resolves workspace name when path contains a folder with underscores', { timeout: 30_000 }, () => { - // Claude Code encodes underscores as hyphens, so the encoded name cannot be - // matched against readdirSync output unless underscores are normalized too. - const tmpBase = fs.mkdtempSync(path.join(longTmpDir(), 'claude-us-')); - const underscoreParent = path.join(tmpBase, 'my_home_dir'); - const targetDir = path.join(underscoreParent, 'proj'); - fs.mkdirSync(targetDir, { recursive: true }); - - const encodedDirName = targetDir - .replace(/^([a-zA-Z])(?=:)/, d => d.toLowerCase()) - .replace(/[:\\/\s_]/g, '-'); - - const root = fs.mkdtempSync(path.join(longTmpDir(), 'claude-proj-')); - const projectsDir = path.join(root, 'projects'); - const projDir = path.join(projectsDir, encodedDirName); - fs.mkdirSync(projDir, { recursive: true }); - fs.writeFileSync( - path.join(projDir, 's.jsonl'), - [makeUser('hello'), makeAssistant('hi')].map(l => JSON.stringify(l)).join('\n'), - ); - - try { - const result = parseClaudeSessions(projectsDir); - expect(result).toHaveLength(1); - expect(result[0].workspaceName).toBe('proj'); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - fs.rmSync(tmpBase, { recursive: true, force: true }); - } - }); + // ---- workspace name resolution with characters outside [A-Za-z0-9] ---- + + it.each(['my_home_dir', 'my.app', '.config', 'café'])( + 'resolves workspace name when a parent folder is %s', { timeout: 30_000 }, (parentName) => { + // Claude Code replaces every character outside [A-Za-z0-9] with a hyphen, + // so the encoded name only matches readdirSync output if we apply the same rule. + const tmpBase = fs.mkdtempSync(path.join(longTmpDir(), 'claude-enc-')); + const targetDir = path.join(tmpBase, parentName, 'proj'); + fs.mkdirSync(targetDir, { recursive: true }); + + const encodedDirName = targetDir + .replace(/^([a-zA-Z])(?=:)/, d => d.toLowerCase()) + .replaceAll(/[^a-zA-Z0-9]/g, '-'); + + const root = fs.mkdtempSync(path.join(longTmpDir(), 'claude-proj-')); + const projectsDir = path.join(root, 'projects'); + const projDir = path.join(projectsDir, encodedDirName); + fs.mkdirSync(projDir, { recursive: true }); + fs.writeFileSync( + path.join(projDir, 's.jsonl'), + [makeUser('hello'), makeAssistant('hi')].map(l => JSON.stringify(l)).join('\n'), + ); + + try { + const result = parseClaudeSessions(projectsDir); + expect(result).toHaveLength(1); + expect(result[0].workspaceName).toBe('proj'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(tmpBase, { recursive: true, force: true }); + } + }, + ); // ---- workspace name resolution with spaces ---- diff --git a/src/core/parser-claude.ts b/src/core/parser-claude.ts index 30d64c41..500ba74e 100644 --- a/src/core/parser-claude.ts +++ b/src/core/parser-claude.ts @@ -452,19 +452,20 @@ export function findClaudeDirs(): string[] { } /** Encode a single filesystem component the way Claude Code does: - * replace whitespace and underscores with hyphens. Path separators never appear in a single + * replace every character outside A-Z, a-z and 0-9 with a hyphen (one per + * UTF-16 code unit). Path separators never appear in a single * component name, and colons are not handled here because Windows component * names cannot contain them. */ function encodeComponentForMatch(name: string): string { - return name.replace(/[\s_]/g, '-'); + return name.replaceAll(/[^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 - * `C:\dev\AI-Engineering-Coach`. Because the encoding is lossy (hyphens, spaces, + * Claude encodes workspace paths by replacing every character outside + * A-Z, a-z and 0-9 (one per UTF-16 code unit) 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. *