diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 03949776..e3a1c447 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -2551,7 +2551,12 @@ export function registerSessionRoutes( for (let end = maxLook - 1; end >= idx; end--) { const candidates: string[] = []; if (end === idx) { - candidates.push(segments[idx]); + // Skip an EMPTY segment: `isDir(current + '/' + '')` stats `current + '/'`, + // which always succeeds, so the empty candidate would match unconditionally + // and swallow the doubled dash that is the whole signature of a dotdir. It + // then resolves "/home/x/.sib" to "/home/x//sib" whenever a non-dot sibling + // exists, and shadows the dotdir branch below in every other case. + if (segments[idx] !== '') candidates.push(segments[idx]); } else { candidates.push(segments.slice(idx, end + 1).join('-')); candidates.push(segments.slice(idx, end + 1).join('_')); @@ -2600,7 +2605,10 @@ export function registerSessionRoutes( for (let end = i; end < maxLook; end++) { const candidates: string[] = []; if (end === i) { - candidates.push(segments[i]); + // Same empty-segment skip as tryDecode above. This loop is shortest-match + // first, so without it the empty candidate matches on the very first try + // and sets `matched`, leaving the dotdir branch below permanently dead. + if (segments[i] !== '') candidates.push(segments[i]); } else { candidates.push(segments.slice(i, end + 1).join('_')); candidates.push(segments.slice(i, end + 1).join('-')); @@ -2636,8 +2644,21 @@ export function registerSessionRoutes( } } if (!matched) { - current = current + '/' + segments[i]; - i++; + if (segments[i] === '') { + // Nothing on disk matched (the usual reason this fallback runs at all is + // that the directory was deleted). An empty segment still means the + // encoder ate a literal '.', so guess the dotdir form rather than + // appending a bare '/' and emitting a "//" path. + if (i + 1 < segments.length) { + current = current + '/.' + segments[i + 1]; + i += 2; + } else { + i++; + } + } else { + current = current + '/' + segments[i]; + i++; + } } } const finalExists = await fs diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index 14b57c51..6d1a02cc 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -1321,6 +1321,35 @@ describe('session-routes', () => { expect(row.workingDir).toBe(realDir); expect(row.workingDir).not.toBe(home); }); + + it('prefers the dotdir over a same-named non-dot sibling, and never emits a "//" path', async () => { + // A doubled dash also lets the decoder read the empty split segment as a + // directory NAME. `isDir(current + '/' + '')` stats `current + '/'`, which + // always succeeds, so `~/.sib` + `~/sib` both existing used to resolve to + // "/home/x//sib": the wrong directory, spelled with a double slash that + // then fails every string comparison against session.workingDir. The empty + // candidate is never a real path component, so it is skipped outright, + // which is also what lets the dotdir branch below it run at all. + const home = process.env.HOME as string; + const dotDir = join(home, '.sib'); + await mkdir(dotDir, { recursive: true }); + await mkdir(join(home, 'sib'), { recursive: true }); + + const projectKey = dotDir.replace(/\//g, '-').replace(/\./g, '-'); + const projDir = join(home, '.claude', 'projects', projectKey); + await mkdir(projDir, { recursive: true }); + + const sessionId = '22222222-2222-2222-2222-222222222222'; + const line = JSON.stringify({ type: 'user', message: { role: 'user', content: 'hello world' } }) + '\n'; + await writeFile(join(projDir, `${sessionId}.jsonl`), line + '#'.repeat(4200 - line.length)); + + const res = await harness.app.inject({ method: 'GET', url: `/api/history/sessions?projectKey=${projectKey}` }); + expect(res.statusCode).toBe(200); + const row = JSON.parse(res.body).data.sessions.find((s: { sessionId: string }) => s.sessionId === sessionId); + expect(row).toBeDefined(); + expect(row.workingDir).toBe(dotDir); + expect(row.workingDir).not.toContain('//'); + }); }); // ========== POST /api/sessions (with resumeSessionId) ==========