Skip to content

Fix workspace name resolution for paths containing underscores - #270

Open
mostafaseyedan wants to merge 3 commits into
microsoft:mainfrom
mostafaseyedan:fix/underscore-workspace-resolution
Open

mostafaseyedan wants to merge 3 commits into
microsoft:mainfrom
mostafaseyedan:fix/underscore-workspace-resolution

Conversation

@mostafaseyedan

@mostafaseyedan mostafaseyedan commented Sep 21, 2026 •

Copy link
Copy Markdown

Problem

Claude Code replaces every character outside [A-Za-z0-9] with a hyphen when building the project directory name under ~/.claude/projects, but encodeComponentForMatch in src/core/parser-claude.ts only normalized whitespace:

return name.replace(/\s/g, '-');

A path component containing an underscore, dot, other punctuation or a non-ASCII letter can therefore never match a readdirSync entry. projectNameFromEncoded stops resolving at the first such level and falls through to resolved = path.join(resolved, rest), which takes the entire unresolved remainder as one segment.

Impact

For a home directory of /home/my_name, the encoded directory -home-my-name-AIOps fails to match the real folder my_name, so resolution dies at /home and every Claude workspace is labeled my-name-AIOps instead of AIOps.

Beyond the cosmetic mislabeling, this breaks cross-harness aggregation: the Codex and OpenCode parsers derive the plain basename, so the same project is counted as two separate workspaces and never reconciles. On one real log set (1010 sessions), this split AIOps into 330 + 40 and cendien-website into 201 + 50 + 7, and inflated the distinct workspace count from 30 to 38.

This affects any user whose username or project path contains an underscore, a dot (for example .config), other punctuation or non-ASCII letters.

Fix

Apply Claude Code's own encoding rule (see anthropics/claude-code#19972), as suggested in review:

return name.replaceAll(/[^a-zA-Z0-9]/g, '-');

Known limit: sibling directories with equal-length names that encode the same (for example a_b and a-b) are still ambiguous. The first readdirSync entry wins and the walk does not backtrack. This is the same as on main, so it is not a regression.

Testing

Adds a table-driven regression test mirroring the existing spaces test, over parent folders my_home_dir, my.app, .config and café. Verified it fails on main (expected 'my-home-dir-proj' to be 'proj') and passes with the fix.

parser-claude.test.ts passes 22/22. npm run typecheck and npm run lint are clean (0 errors). The 7 failures in github-app-analytics.test.ts are pre-existing on main and unrelated.

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.
@mostafaseyedan

mostafaseyedan commented Sep 21, 2026 via email

Copy link
Copy Markdown
Author

@mostafaseyedan

mostafaseyedan commented Sep 21, 2026 via email

Copy link
Copy Markdown
Author

@mostafaseyedan

mostafaseyedan commented Sep 21, 2026 via email

Copy link
Copy Markdown
Author

mostafaseyedan added a commit to mostafaseyedan/AI-Engineering-Coach that referenced this pull request Sep 28, 2026
extractClaudeImagesFromJsonl is a standalone function, so its tests do
not belong inside the parseClaudeSessions suite. Moving them also keeps
this PR from editing the same lines as microsoft#270.
@mostafaseyedan

Copy link
Copy Markdown
Author

Hi Sanjay Singh (@san360) TamasBoncz Aymen (@aymenfurter), a single note for my five open PRs so I don't ping each thread:

The CLA check passes on all five. CI, CodeQL and Dependency Review are waiting for maintainer approval ("action_required") because this is my first contribution here, so none of them has run yet. Could someone approve the workflow runs?

I ran the CI steps locally on each branch and on all five merged onto current main: npm run check, build, check-size and package all pass. The only exceptions are 7 github-app-analytics tests that need the sqlite3 CLI, which my machine lacks, and they fail the same way on main. Since opening the PRs I pushed two small commits:

The PRs are independent and can be reviewed and merged separately. Happy to adjust anything. Thanks!

@mostafaseyedan

Copy link
Copy Markdown
Author

Hello Sanjay Singh (@san360), TamasBoncz and Aymen (@aymenfurter),

Thank you very much for your time and for the work you put into this project.

I merged the latest main into my open PRs (#270, #272 and #273), because their earlier CI runs failed with a workflow file issue. The CI, CodeQL and Dependency Review runs for #270, #272, #273 and #274 now wait for maintainer approval, because I am a first-time contributor.

If you have a moment, could you please approve those workflow runs? I will fix anything the checks report.

Thank you again for your kind help.

@san360 Sanjay Singh (san360) left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi mostafaseyedan, thank you so much for this fix, and for the clear write-up! The before/after numbers from a real log set (38 → 30 workspaces, AIOps split across harnesses) made the impact immediately obvious, and pairing a two-line change with a regression test that mirrors the existing spaces test is exactly how we like these. Thanks as well for keeping the branch up to date with main.

What we verified

  • Reproduced the bug on current main with a synthetic ~/.claude/projects layout under a temporary home directory: a project at …/my_name/AIOps is labelled my-name-AIOps for Claude while Codex labels the same folder AIOps, so the dashboard shows two workspaces. With your change both are AIOps and the sessions group together; workspaceId is unchanged.
  • Your new test fails on main (expected 'my-home-dir-proj' to be 'proj') and passes with the fix.
  • On main merged with this PR: typecheck, lint (0 errors), knip, lockfile-lint, the full unit suite (3671 passed), build and the bundle-size check all pass.
  • We tried a range of other paths (hyphens, spaces, an underscore next to a hyphen, my_name next to a my-name sibling, drive-letter case, deleted folders, very long paths) and nothing that worked before broke.
  • We also created real Claude Code sessions in throwaway folders. With your change, my_name/AIOps and auto_user/AIOpsAuto now show as AIOps and AIOpsAuto (they were my-name-AIOps and auto-user-AIOpsAuto). Sessions from the Claude Code CLI and from the VS Code extension land in the same workspace whatever the drive-letter case, and the names stay the same after a cached reload. work/auto.app and café_lab/proj still show as auto-app and caf--lab-proj; the broader [^a-zA-Z0-9] regex below resolves both.

One suggestion: cover the rest of Claude Code's encoding

Claude Code doesn't only map _ to -: it replaces every character outside A–Z, a–z, 0–9 (one - per UTF-16 code unit), see anthropics/claude-code#19972. So the same bug still happens for folders with dots, other punctuation or non-ASCII letters:

Project path Expected main This PR With [^a-zA-Z0-9]
…/my_name/AIOps AIOps my-name-AIOps AIOps AIOps
…/work/my.app my.app my-app my-app my.app
…/.config/tool tool -config-tool -config-tool tool
…/v1.2/proj proj v1-2-proj v1-2-proj proj
…/c@d+e/proj proj c-d-e-proj c-d-e-proj proj
…/café/proj proj caf--proj caf--proj proj
…/日本語/proj proj ----proj ----proj proj
…/café_lab/proj proj caf--lab-proj caf--lab-proj proj

We tried name.replaceAll(/[^a-zA-Z0-9]/g, '-') locally: it resolves all of the rows above, and the existing parser-claude tests still pass. Inline suggestions below, for the code and for the test. Would you be up for folding that into this PR? If you'd rather keep this one focused on underscores, that's completely fine too, and we can track the rest as a follow-up.

Small note on the description (no change needed)

The description mentions that _/- sibling ambiguity is mitigated by the longest-first sort. For siblings of equal length such as a_b and a-b, both encode to a-b, so the first readdirSync entry wins and the walk doesn't backtrack: with sib/a_b/x/proj and an empty sib/a-b/, the result is x-proj (same as on main, so not a regression). Just flagging it in case you want to reword that sentence; a backtracking walk could be a nice follow-up.

Optional idea for later

A few cases can't be recovered from the folder name at all: projects whose folder was deleted, paths over 200 characters (Claude truncates them and appends a hash), and logs created on another OS. Claude's transcript lines carry cwd, which the parser already reads for workspaceRootPath; deriving the name from basename(cwd) (falling back to the folder name) could handle those and avoid walking the filesystem. Definitely out of scope here, just sharing in case it's interesting.

Next steps

A maintainer will approve the CI run for this PR (it is waiting for approval, so CI, CodeQL and the spellcheck haven't run yet). Once it's green, this is in good shape to go in, with or without the broader regex.

Thanks again for taking the time to track this down and fix it, it makes the dashboard noticeably more accurate for everyone with an underscore in their path!

Comment thread src/core/parser-claude.ts Outdated
Comment on lines +454 to +459
/** 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, '-');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for updating the comment along with the regex! Since Claude Code replaces every character outside [A-Za-z0-9] with - (anthropics/claude-code#19972), could we match that rule here? With this version, folders like my.app, .config, v1.2, c@d+e, café and 日本語 resolve too (they still fall back to the joined tail with [\s_]), and the existing parser-claude tests still pass. replaceAll also keeps the unicorn/prefer-string-replace-all lint rule happy.

Suggested change
/** 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, '-');
/** Encode a single filesystem component the way Claude Code does:
* 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.replaceAll(/[^a-zA-Z0-9]/g, '-');

If you take this, the JSDoc on projectNameFromEncoded a few lines below ("replacing /, \, :, and whitespace") could get the same wording.

Comment thread src/core/parser-claude.test.ts Outdated
Comment on lines +444 to +446
const encodedDirName = targetDir
.replace(/^([a-zA-Z])(?=:)/, d => d.toLowerCase())
.replace(/[:\\/\s_]/g, '-');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great to have this regression test! One idea: since the test builds the encoded name itself, using Claude Code's actual rule here (rather than the list of characters the extension handles) means the test would catch the remaining cases too:

Suggested change
const encodedDirName = targetDir
.replace(/^([a-zA-Z])(?=:)/, d => d.toLowerCase())
.replace(/[:\\/\s_]/g, '-');
const encodedDirName = targetDir
.replace(/^([a-zA-Z])(?=:)/, d => d.toLowerCase())
.replaceAll(/[^a-zA-Z0-9]/g, '-');

And if you go with the broader regex, it could become table-driven over a few parent folder names, for example:

it.each(['my_home_dir', 'my.app', '.config', 'café'])(
  'resolves workspace name when a parent folder is %s', { timeout: 30_000 }, (parentName) => {
    const tmpBase = fs.mkdtempSync(path.join(longTmpDir(), 'claude-enc-'));
    const targetDir = path.join(tmpBase, parentName, 'proj');
    // … same body as above …
    expect(result[0].workspaceName).toBe('proj');
  },
);

We tried both snippets locally: with the current [\s_] regex the my.app, .config and café rows fail, and with the suggested regex all of them pass.

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLDXsjE1DMDor8E6cSLBpH
@mostafaseyedan

Copy link
Copy Markdown
Author

Hi Sanjay Singh (@san360),

Thank you very much for the thorough review, and for testing with real Claude Code sessions. That table made the remaining gap very clear.

I folded in both suggestions:

  • encodeComponentForMatch now uses name.replaceAll(/[^a-zA-Z0-9]/g, '-'), with your JSDoc wording.
  • The projectNameFromEncoded JSDoc now describes the same rule.
  • The regression test is now table-driven over my_home_dir, my.app, .config and café, and it builds the encoded name with Claude Code's real rule.

I also corrected the description: the longest-first sort does not resolve equal-length siblings such as a_b and a-b. Thank you for catching that. The backtracking walk and the basename(cwd) idea both sound like good follow-ups.

Typecheck and lint are clean on my side. Thanks again for your help!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants