diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts index faf89d71..cfed5ae9 100644 --- a/src/server/__tests__/sessions.test.ts +++ b/src/server/__tests__/sessions.test.ts @@ -932,6 +932,45 @@ describe('SessionService', () => { await expect(fs.access(worktreeFile)).resolves.toBeNull() }) + it('should move repository metadata to the CLI worktree transcript before deleting placeholders', async () => { + const workDir = await createCleanGitRepo(tmpDir) + const { sessionId } = await service.createSession( + workDir, + { branch: 'main', worktree: true }, + ) + const initialLaunchInfo = await service.getSessionLaunchInfo(sessionId) + const worktreePath = initialLaunchInfo?.repository?.worktreePath + expect(worktreePath).toBeTruthy() + + const worktreeFile = await writeSessionFile(sanitizePath(worktreePath!), sessionId, [ + makeSnapshotEntry(), + { + type: 'system', + subtype: 'init', + cwd: worktreePath, + timestamp: '2026-01-01T00:00:01.000Z', + }, + makeUserEntry('Hello from worktree'), + ]) + + await service.appendSessionMetadata(sessionId, { + workDir: worktreePath!, + }) + const removed = await service.deletePlaceholderSessionFiles(sessionId, worktreePath!) + const launchInfo = await service.getSessionLaunchInfo(sessionId) + + expect(removed).toBe(1) + await expect(fs.access(worktreeFile)).resolves.toBeNull() + expect(launchInfo?.workDir).toBe(worktreePath) + expect(launchInfo?.repository).toMatchObject({ + requestedWorkDir: await fs.realpath(workDir), + branch: 'main', + worktree: true, + worktreePath, + worktreeSlug: initialLaunchInfo?.repository?.worktreeSlug, + }) + }) + it('should recover workDir from transcript cwd when session-meta is missing', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' await writeSessionFile('-tmp-project', sessionId, [ diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index 97c1ab43..a511eec8 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -520,6 +520,12 @@ export class ConversationService { return session?.workDir || '' } + updateSessionWorkDir(sessionId: string, workDir: string): void { + const session = this.sessions.get(sessionId) + if (!session || !workDir.trim()) return + session.workDir = workDir + } + getSessionPermissionMode(sessionId: string): string { const session = this.sessions.get(sessionId) return session?.permissionMode || 'default' diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index 64b55095..19052a1a 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -803,12 +803,11 @@ export class SessionService { * Find the .jsonl file for a given session ID. * Searches across all project directories since sessions may belong to any project. */ - async findSessionFile( + private async findSessionFiles( sessionId: string - ): Promise<{ filePath: string; projectDir: string } | null> { - // Validate sessionId format to prevent path traversal + ): Promise> { if (!this.isValidSessionId(sessionId)) { - return null + return [] } const projectsDir = this.getProjectsDir() @@ -817,20 +816,27 @@ export class SessionService { try { projectDirs = await fs.readdir(projectsDir) } catch { - return null + return [] } + const matches: Array<{ filePath: string; projectDir: string }> = [] for (const dir of projectDirs) { const filePath = path.join(projectsDir, dir, `${sessionId}.jsonl`) try { await fs.access(filePath) - return { filePath, projectDir: dir } + matches.push({ filePath, projectDir: dir }) } catch { continue } } - return null + return matches + } + + async findSessionFile( + sessionId: string + ): Promise<{ filePath: string; projectDir: string } | null> { + return (await this.findSessionFiles(sessionId))[0] ?? null } private isValidSessionId(id: string): boolean { @@ -1460,12 +1466,25 @@ export class SessionService { repository?: PreparedSessionWorkspace['repository'] } ): Promise { - const found = await this.findSessionFile(sessionId) - if (!found) return + const matches = await this.findSessionFiles(sessionId) + if (matches.length === 0) return - const entries = await this.readJsonlFile(found.filePath) - const repository = metadata.repository ?? this.resolveRepositoryFromEntries(entries) - await this.appendJsonlEntry(found.filePath, { + let repository = metadata.repository + if (!repository) { + for (const match of matches) { + const candidate = this.resolveRepositoryFromEntries(await this.readJsonlFile(match.filePath)) + if (candidate) { + repository = candidate + break + } + } + } + + const targetProjectDir = this.sanitizePath(metadata.workDir) + const targetFilePath = path.join(this.getProjectsDir(), targetProjectDir, `${sessionId}.jsonl`) + await fs.mkdir(path.dirname(targetFilePath), { recursive: true }) + + await this.appendJsonlEntry(targetFilePath, { type: 'session-meta', isMeta: true, workDir: metadata.workDir, @@ -1474,7 +1493,7 @@ export class SessionService { }) if (metadata.customTitle) { - await this.appendJsonlEntry(found.filePath, { + await this.appendJsonlEntry(targetFilePath, { type: 'custom-title', customTitle: metadata.customTitle, timestamp: new Date().toISOString(), diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index e30a9135..247f9cb3 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -775,6 +775,7 @@ function markPrewarmed(sessionId: string) { function cacheSessionInitMetadata(sessionId: string, cliMsg: any) { if (cliMsg?.type !== 'system' || cliMsg.subtype !== 'init') return if (typeof cliMsg.cwd === 'string' && cliMsg.cwd.trim()) { + conversationService.updateSessionWorkDir(sessionId, cliMsg.cwd) void (async () => { await sessionService.appendSessionMetadata(sessionId, { workDir: cliMsg.cwd,