From 2e3d2050d29cfc1e1cebe7178a6fa9ebdaee0a8d Mon Sep 17 00:00:00 2001 From: Mohammad Alsayyedan Date: Tue, 3 Nov 2026 13:04:50 -0600 Subject: [PATCH 1/2] feat: name Claude workspaces from the session cwd Claude transcripts record the session's cwd. When a recorded cwd encodes to the project folder name with Claude Code's rule (every character outside [A-Za-z0-9] becomes '-', and a result over 200 characters keeps its first 200 plus '-'), use its basename as the workspace name. Folder decoding stays as the fallback. This recovers the name for deleted folders, truncated long paths, logs from another OS, and projects with only sub-agent files. workspaceId is unchanged. Suggested by @san360 in the #270 review. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RLDXsjE1DMDor8E6cSLBpH --- src/core/parser-claude.test.ts | 34 ++++++++++++++++++++++++++++++++++ src/core/parser-claude.ts | 31 ++++++++++++++++++++++++++++++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/core/parser-claude.test.ts b/src/core/parser-claude.test.ts index a38a20bd..8dd2777c 100644 --- a/src/core/parser-claude.test.ts +++ b/src/core/parser-claude.test.ts @@ -436,6 +436,40 @@ describe('parseClaudeSessions', () => { } }); + // ---- workspace name from the recorded cwd ---- + + const encodeClaudeDir = (p: string) => { + const encoded = p.replaceAll(/[^a-zA-Z0-9]/g, '-'); + return encoded.length > 200 ? `${encoded.slice(0, 200)}-kjgj8i` : encoded; + }; + const longCwd = `/tmp/${Array.from({ length: 22 }, (_, i) => `segment_${i}`).join('/')}/proj.v1`; + + it.each([ + { label: 'a deleted folder', cwd: '/home/my_name/gone_project', expected: 'gone_project' }, + { label: 'a Windows path read on another OS', cwd: String.raw`C:\Users\me\My App`, expected: 'My App' }, + { label: 'a path Claude truncated to 200 characters', cwd: longCwd, expected: 'proj.v1' }, + { label: 'a session with only sub-agent files', cwd: '/home/my_name/gone_project', expected: 'gone_project', subagentOnly: true }, + { label: 'a cwd outside the project folder', cwd: '/elsewhere/other', dir: '-home-me-proj', expected: 'me-proj' }, + ])('names the workspace from the session cwd for $label', ({ cwd, dir, expected, subagentOnly }) => { + const root = fs.mkdtempSync(path.join(longTmpDir(), 'claude-cwd-name-')); + const projDir = path.join(root, 'projects', dir ?? encodeClaudeDir(cwd)); + const sessionDir = subagentOnly ? path.join(projDir, 'sess-1', 'subagents') : projDir; + fs.mkdirSync(sessionDir, { recursive: true }); + fs.writeFileSync( + path.join(sessionDir, subagentOnly ? 'agent-a1.jsonl' : 's.jsonl'), + [makeUser('hello', '2025-06-15T10:00:00Z', { cwd }), makeAssistant('hi')].map(l => JSON.stringify(l)).join('\n'), + ); + + try { + const result = parseClaudeSessions(path.join(root, 'projects')); + expect(result).toHaveLength(1); + expect(result[0].workspaceName).toBe(expected); + expect(result[0].sessions[0].workspaceName).toBe(expected); + } finally { + fs.rmSync(root, { 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 5281138d..f5995ef7 100644 --- a/src/core/parser-claude.ts +++ b/src/core/parser-claude.ts @@ -555,6 +555,28 @@ function projectNameFromEncoded(encoded: string, _projectsDir: string): string { return path.basename(resolved); } +/** Claude Code encodes a session's launch directory into its project folder + * name: every character outside A-Z, a-z and 0-9 becomes `-`, and a result + * over 200 characters keeps its first 200 plus `-`. */ +function cwdMatchesProjectDir(cwd: string, dirName: string): boolean { + const encoded = cwd.replaceAll(/[^a-zA-Z0-9]/g, '-').toLowerCase(); + const dir = dirName.toLowerCase(); + return encoded === dir || (encoded.length > 200 && dir.startsWith(`${encoded.slice(0, 200)}-`)); +} + +/** The workspace name from the first recorded `cwd` that belongs to this + * project folder. Unlike decoding the folder name, this works when the folder + * was deleted, the path was truncated, or the log came from another OS. */ +function workspaceNameFromCwd(cwds: (string | undefined)[], dirName: string): string | undefined { + for (const cwd of cwds) { + if (!cwd || !cwdMatchesProjectDir(cwd, dirName)) continue; + // win32 splits on both separators, so a cwd logged on any OS works. + const name = path.win32.basename(cwd); + if (name) return name; + } + return undefined; +} + function parseClaudeProjectSessions( projectsDir: string, dirName: string, @@ -563,7 +585,7 @@ function parseClaudeProjectSessions( ): { sessions: Session[]; workspaceId: string; workspaceName: string } | null { const projPath = path.join(projectsDir, dirName); const workspaceId = `claude-${dirName}`; - const workspaceName = projectNameFromEncoded(dirName, projectsDir); + let workspaceName = projectNameFromEncoded(dirName, projectsDir); let entries: fs.Dirent[]; try { @@ -578,11 +600,13 @@ function parseClaudeProjectSessions( const sessionsById = new Map(); const sessions: Session[] = []; const sessionFiles = new Map(); + const cwds: (string | undefined)[] = []; for (const entry of entries) { if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue; const filePath = path.join(projPath, entry.name); const session = parseClaudeSessionFile(filePath, workspaceId, workspaceName, { editLocIndex }); if (!session) continue; + cwds.push(session.workspaceRootPath); sessions.push(session); sessionsById.set(session.sessionId, session); sessionFiles.set(session.sessionId, [filePath]); @@ -615,6 +639,7 @@ function parseClaudeProjectSessions( const filePath = path.join(subagentDir, subEntry.name); const subSession = parseClaudeSessionFile(filePath, workspaceId, workspaceName, { editLocIndex, subagentFile: true }); if (!subSession) continue; + cwds.push(subSession.workspaceRootPath); const sourceId = parent?.sessionId ?? entry.name; const files = sessionFiles.get(sourceId) ?? []; files.push(filePath); @@ -667,9 +692,13 @@ function parseClaudeProjectSessions( } } + const cwdName = workspaceNameFromCwd(cwds, dirName); + if (cwdName) workspaceName = cwdName; + // Final pass: re-sort merged parent sessions' requests by timestamp and // refresh the cached requestCount. for (const session of sessions) { + session.workspaceName = workspaceName; if (session.requests.length > 1) { session.requests.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0)); } From 271e8e9f2a72457702f4816ff39fb1147d0675e7 Mon Sep 17 00:00:00 2001 From: Mohammad Alsayyedan Date: Wed, 4 Nov 2026 08:49:54 -0600 Subject: [PATCH 2/2] Apply review: drive-letter case test row, cwd name in loading progress Suggested by @san360 in review. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RLDXsjE1DMDor8E6cSLBpH --- src/core/parser-claude.test.ts | 1 + src/core/parser-claude.ts | 7 +++---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/core/parser-claude.test.ts b/src/core/parser-claude.test.ts index 8dd2777c..01873188 100644 --- a/src/core/parser-claude.test.ts +++ b/src/core/parser-claude.test.ts @@ -449,6 +449,7 @@ describe('parseClaudeSessions', () => { { label: 'a Windows path read on another OS', cwd: String.raw`C:\Users\me\My App`, expected: 'My App' }, { label: 'a path Claude truncated to 200 characters', cwd: longCwd, expected: 'proj.v1' }, { label: 'a session with only sub-agent files', cwd: '/home/my_name/gone_project', expected: 'gone_project', subagentOnly: true }, + { label: 'a drive letter in another case', cwd: String.raw`C:\Users\me\proj`, dir: 'c--Users-me-proj', expected: 'proj' }, { label: 'a cwd outside the project folder', cwd: '/elsewhere/other', dir: '-home-me-proj', expected: 'me-proj' }, ])('names the workspace from the session cwd for $label', ({ cwd, dir, expected, subagentOnly }) => { const root = fs.mkdtempSync(path.join(longTmpDir(), 'claude-cwd-name-')); diff --git a/src/core/parser-claude.ts b/src/core/parser-claude.ts index f5995ef7..7025942e 100644 --- a/src/core/parser-claude.ts +++ b/src/core/parser-claude.ts @@ -761,13 +761,12 @@ export async function parseClaudeSessionsAsync( for (let i = 0; i < projectDirs.length; i++) { const dirName = projectDirs[i]; - const workspaceName = projectNameFromEncoded(dirName, projectsDir); - - if (onProject) onProject(i + 1, projectDirs.length, workspaceName); - const result = parseClaudeProjectSessions(projectsDir, dirName, editLocIndex, sessionSourceIndex); if (result) results.push(result); + // Report after parsing, so the progress detail shows the name taken from the recorded cwd. + if (onProject) onProject(i + 1, projectDirs.length, result?.workspaceName ?? projectNameFromEncoded(dirName, projectsDir)); + // Yield every 5 projects so the event loop stays responsive if (i % 5 === 0) await new Promise(r => setTimeout(r, 0)); }