diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 0588c52b..08678d65 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -2514,6 +2514,25 @@ export function registerSessionRoutes( } } } + // The encoder maps both '/' and '.' to '-', so a literal '.' in the + // original path (e.g. "/home/timkjr/.codeman") collapses into an empty + // split segment here. Retry this window as a dotdir/dotfile: ".". + if (segments[idx] === '' && idx + 1 < segments.length) { + const dotMaxLook = Math.min(idx + 1 + 4, segments.length); + for (let end = dotMaxLook - 1; end >= idx + 1; end--) { + const dotCandidates = + end === idx + 1 + ? [segments[idx + 1]] + : [segments.slice(idx + 1, end + 1).join('-'), segments.slice(idx + 1, end + 1).join('_')]; + for (const child of dotCandidates) { + const candidate = current + '/.' + child; + if (await isDir(candidate)) { + const result = await tryDecode(end + 1, candidate); + if (result) return result; + } + } + } + } return null; } @@ -2547,6 +2566,25 @@ export function registerSessionRoutes( } if (matched) break; } + if (!matched && segments[i] === '' && i + 1 < segments.length) { + const dotMaxLook = Math.min(i + 1 + 4, segments.length); + for (let end = i + 1; end < dotMaxLook; end++) { + const dotCandidates = + end === i + 1 + ? [segments[i + 1]] + : [segments.slice(i + 1, end + 1).join('_'), segments.slice(i + 1, end + 1).join('-')]; + for (const child of dotCandidates) { + const candidate = current + '/.' + child; + if (await isDir(candidate)) { + current = candidate; + i = end + 1; + matched = true; + break; + } + } + if (matched) break; + } + } if (!matched) { current = current + '/' + segments[i]; i++; diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index 259ed71d..14b57c51 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -18,7 +18,7 @@ import Fastify, { type FastifyInstance } from 'fastify'; import fastifyCookie from '@fastify/cookie'; import fastifyMultipart from '@fastify/multipart'; import { join } from 'node:path'; -import { mkdtemp, rm } from 'node:fs/promises'; +import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js'; import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; @@ -1288,6 +1288,39 @@ describe('session-routes', () => { const body = JSON.parse(res.body); expect(body.data.sessions.length).toBeLessThanOrEqual(50); }); + + it('decodes a dotdir working directory (e.g. ~/.codeman) instead of falling back to $HOME', async () => { + // Claude Code's project-key encoding maps both '/' and '.' to '-', so + // "/home/x/.dotcase" and "/home/x/dotcase" collapse to the same-looking + // dash run except for a doubled dash. decodeProjectKey() must still + // recover the real (dotdir) path rather than silently falling back to + // bare $HOME (COD bug: 2026-08-01, ~/.codeman resumed sessions got + // workingDir "/home/timkjr" instead of "/home/timkjr/.codeman"). + const home = process.env.HOME as string; + const realDir = join(home, '.dotcase'); + await mkdir(realDir, { recursive: true }); + + const projectKey = realDir.replace(/\//g, '-').replace(/\./g, '-'); + const projDir = join(home, '.claude', 'projects', projectKey); + await mkdir(projDir, { recursive: true }); + + const sessionId = '12345678-1234-1234-1234-123456789012'; + const transcriptLine = JSON.stringify({ type: 'user', message: { role: 'user', content: 'hello world' } }) + '\n'; + // scanProjectDir skips files under 4000 bytes. + const padding = '#'.repeat(4200 - transcriptLine.length); + await writeFile(join(projDir, `${sessionId}.jsonl`), transcriptLine + padding); + + const res = await harness.app.inject({ + method: 'GET', + url: `/api/history/sessions?projectKey=${projectKey}`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + const row = body.data.sessions.find((s: { sessionId: string }) => s.sessionId === sessionId); + expect(row).toBeDefined(); + expect(row.workingDir).toBe(realDir); + expect(row.workingDir).not.toBe(home); + }); }); // ========== POST /api/sessions (with resumeSessionId) ==========