Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions src/core/parser-claude.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },

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.

Could we add a row for the case-insensitive match? Claude Code keeps the drive-letter case the session started with (c:\… → c--…), and on Windows a later C:\… session writes into the same folder. Today, removing the .toLowerCase() calls in cwdMatchesProjectDir keeps all five rows green. This row passes with the PR and fails on main:

Suggested change
{ label: 'a cwd outside the project folder', cwd: '/elsewhere/other', dir: '-home-me-proj', expected: 'me-proj' },
{ 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 }, () => {
Expand Down
38 changes: 33 additions & 5 deletions src/core/parser-claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `-<hash>`. */
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,
Expand All @@ -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 {
Expand All @@ -578,11 +600,13 @@ function parseClaudeProjectSessions(
const sessionsById = new Map<string, Session>();
const sessions: Session[] = [];
const sessionFiles = new Map<string, string[]>();
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]);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -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<void>(r => setTimeout(r, 0));
}
Expand Down
Loading