diff --git a/src/core/parser-claude.test.ts b/src/core/parser-claude.test.ts index a38a20bd..01873188 100644 --- a/src/core/parser-claude.test.ts +++ b/src/core/parser-claude.test.ts @@ -436,6 +436,41 @@ 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 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-')); + 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..7025942e 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)); } @@ -732,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)); }