From 4b4641c6b07aa77c26f97e8f7e4a93c92a0ba707 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 28 May 2026 23:24:00 +0200 Subject: [PATCH] fix(coding-agent): scope custom session dir lookups --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/core/session-manager.ts | 73 +++++++++++++---- packages/coding-agent/src/main.ts | 4 +- .../src/modes/interactive/interactive-mode.ts | 5 +- .../session-manager/file-operations.test.ts | 80 +++++++++++++++++++ 5 files changed, 146 insertions(+), 17 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 392fd0bd..cffe6369 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed custom session directories so current-folder resume/continue lookups stay scoped to the active cwd while all-session listings cover the custom directory. - Fixed SIGTERM/SIGHUP exits to run extension `session_shutdown` cleanup and restore the terminal: signal-triggered shutdown now emits `session_shutdown` before any terminal writes, and SIGHUP no longer hard-exits, so extension resources (e.g. sockets) are released even when the terminal is gone ([#5080](https://github.com/earendil-works/pi/issues/5080)). - Fixed Windows startup crashes under MSYS2 ucrt64 Node.js by updating the native clipboard addon to napi-rs 3.x ([#5028](https://github.com/earendil-works/pi/issues/5028)). - Fixed API key and header config resolution to treat plain strings as literals, support `$ENV_VAR` / `${ENV_VAR}` interpolation and `$!` bang escaping, and require explicit env syntax for config files, avoiding Windows case-insensitive env matches corrupting literal keys ([#5095](https://github.com/earendil-works/pi/issues/5095)). diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 22c1f436..914da08d 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -433,11 +433,15 @@ export function buildSessionContext( * Compute the default session directory for a cwd. * Encodes cwd into a safe directory name under ~/.pi/agent/sessions/. */ -export function getDefaultSessionDir(cwd: string, agentDir: string = getDefaultAgentDir()): string { +function getDefaultSessionDirPath(cwd: string, agentDir: string = getDefaultAgentDir()): string { const resolvedCwd = resolvePath(cwd); const resolvedAgentDir = resolvePath(agentDir); const safePath = `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; - const sessionDir = join(resolvedAgentDir, "sessions", safePath); + return join(resolvedAgentDir, "sessions", safePath); +} + +export function getDefaultSessionDir(cwd: string, agentDir: string = getDefaultAgentDir()): string { + const sessionDir = getDefaultSessionDirPath(cwd, agentDir); if (!existsSync(sessionDir)) { mkdirSync(sessionDir, { recursive: true }); } @@ -473,30 +477,48 @@ export function loadEntriesFromFile(filePath: string): FileEntry[] { return entries; } -function isValidSessionFile(filePath: string): boolean { +function readSessionHeader(filePath: string): SessionHeader | null { try { const fd = openSync(filePath, "r"); const buffer = Buffer.alloc(512); const bytesRead = readSync(fd, buffer, 0, 512, 0); closeSync(fd); const firstLine = buffer.toString("utf8", 0, bytesRead).split("\n")[0]; - if (!firstLine) return false; - const header = JSON.parse(firstLine); - return header.type === "session" && typeof header.id === "string"; + if (!firstLine) return null; + const header = JSON.parse(firstLine) as Record; + if (header.type !== "session" || typeof header.id !== "string") { + return null; + } + return header as unknown as SessionHeader; } catch { - return false; + return null; } } +function getSessionHeaderCwd(header: SessionHeader): string | undefined { + const cwd = (header as { cwd?: unknown }).cwd; + return typeof cwd === "string" ? cwd : undefined; +} + +function sessionCwdMatches(cwd: string | undefined, resolvedCwd: string): boolean { + return cwd !== undefined && cwd !== "" && resolvePath(cwd) === resolvedCwd; +} + /** Exported for testing */ -export function findMostRecentSession(sessionDir: string): string | null { +export function findMostRecentSession(sessionDir: string, cwd?: string): string | null { const resolvedSessionDir = normalizePath(sessionDir); + const resolvedCwd = cwd ? resolvePath(cwd) : undefined; try { const files = readdirSync(resolvedSessionDir) .filter((f) => f.endsWith(".jsonl")) .map((f) => join(resolvedSessionDir, f)) - .filter(isValidSessionFile) - .map((path) => ({ path, mtime: statSync(path).mtime })) + .map((path) => ({ path, header: readSessionHeader(path) })) + .filter( + (file): file is { path: string; header: SessionHeader } => + file.header !== null && + (!resolvedCwd || sessionCwdMatches(getSessionHeaderCwd(file.header), resolvedCwd)), + ) + .map(({ path }) => ({ path, mtime: statSync(path).mtime })) .sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); return files[0]?.path || null; @@ -849,6 +871,10 @@ export class SessionManager { return this.sessionDir; } + usesDefaultSessionDir(): boolean { + return this.sessionDir === getDefaultSessionDirPath(this.cwd); + } + getSessionId(): string { return this.sessionId; } @@ -1363,7 +1389,8 @@ export class SessionManager { */ static continueRecent(cwd: string, sessionDir?: string): SessionManager { const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd); - const mostRecent = findMostRecentSession(dir); + const filterCwd = sessionDir !== undefined && dir !== getDefaultSessionDirPath(cwd); + const mostRecent = findMostRecentSession(dir, filterCwd ? cwd : undefined); if (mostRecent) { return new SessionManager(cwd, dir, mostRecent, true); } @@ -1443,7 +1470,11 @@ export class SessionManager { */ static async list(cwd: string, sessionDir?: string, onProgress?: SessionListProgress): Promise { const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd); - const sessions = await listSessionsFromDir(dir, onProgress); + const filterCwd = sessionDir !== undefined && dir !== getDefaultSessionDirPath(cwd); + const resolvedCwd = resolvePath(cwd); + const sessions = (await listSessionsFromDir(dir, onProgress)).filter( + (session) => !filterCwd || sessionCwdMatches(session.cwd, resolvedCwd), + ); sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime()); return sessions; } @@ -1452,7 +1483,21 @@ export class SessionManager { * List all sessions across all project directories. * @param onProgress Optional callback for progress updates (loaded, total) */ - static async listAll(onProgress?: SessionListProgress): Promise { + static async listAll(onProgress?: SessionListProgress): Promise; + static async listAll(sessionDir?: string, onProgress?: SessionListProgress): Promise; + static async listAll( + sessionDirOrOnProgress?: string | SessionListProgress, + onProgress?: SessionListProgress, + ): Promise { + const customSessionDir = + typeof sessionDirOrOnProgress === "string" ? normalizePath(sessionDirOrOnProgress) : undefined; + const progress = typeof sessionDirOrOnProgress === "function" ? sessionDirOrOnProgress : onProgress; + if (customSessionDir) { + const sessions = await listSessionsFromDir(customSessionDir, progress); + sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime()); + return sessions; + } + const sessionsDir = getSessionsDir(); try { @@ -1482,7 +1527,7 @@ export class SessionManager { const results = await buildSessionInfosWithConcurrency(allFiles, () => { loaded++; - onProgress?.(loaded, totalFiles); + progress?.(loaded, totalFiles); }); for (const info of results) { diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 7e69d933..04865b9a 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -171,7 +171,7 @@ async function resolveSessionPath(sessionArg: string, cwd: string, sessionDir?: } // Try global search across all projects - const allSessions = await SessionManager.listAll(); + const allSessions = await SessionManager.listAll(sessionDir); const globalMatch = allSessions.find((s) => s.id === sessionArg) ?? allSessions.find((s) => s.id.startsWith(sessionArg)); @@ -309,7 +309,7 @@ async function createSessionManager( try { const selectedPath = await selectSession( (onProgress) => SessionManager.list(cwd, sessionDir, onProgress), - SessionManager.listAll, + (onProgress) => SessionManager.listAll(sessionDir, onProgress), ); if (!selectedPath) { console.log(chalk.dim("No session selected")); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index b470070f..012e2f2a 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -4401,7 +4401,10 @@ export class InteractiveMode { const selector = new SessionSelectorComponent( (onProgress) => SessionManager.list(this.sessionManager.getCwd(), this.sessionManager.getSessionDir(), onProgress), - SessionManager.listAll, + (onProgress) => + this.sessionManager.usesDefaultSessionDir() + ? SessionManager.listAll(onProgress) + : SessionManager.listAll(this.sessionManager.getSessionDir(), onProgress), async (sessionPath) => { done(); await this.handleResumeSession(sessionPath); diff --git a/packages/coding-agent/test/session-manager/file-operations.test.ts b/packages/coding-agent/test/session-manager/file-operations.test.ts index c744c3c3..fd771ef9 100644 --- a/packages/coding-agent/test/session-manager/file-operations.test.ts +++ b/packages/coding-agent/test/session-manager/file-operations.test.ts @@ -124,6 +124,86 @@ describe("findMostRecentSession", () => { expect(findMostRecentSession(tempDir)).toBe(valid); }); + + it("filters most recent session by cwd", async () => { + const projectA = join(tempDir, "project-a"); + const projectB = join(tempDir, "project-b"); + const fileA = join(tempDir, "a.jsonl"); + const fileB = join(tempDir, "b.jsonl"); + + writeFileSync( + fileA, + `${JSON.stringify({ type: "session", id: "a", timestamp: "2025-01-01T00:00:00Z", cwd: projectA })}\n`, + ); + await new Promise((r) => setTimeout(r, 10)); + writeFileSync( + fileB, + `${JSON.stringify({ type: "session", id: "b", timestamp: "2025-01-01T00:00:00Z", cwd: projectB })}\n`, + ); + + expect(findMostRecentSession(tempDir, projectA)).toBe(fileA); + expect(findMostRecentSession(tempDir, projectB)).toBe(fileB); + }); +}); + +describe("SessionManager custom flat session directory", () => { + let tempDir: string; + let projectA: string; + let projectB: string; + + beforeEach(() => { + tempDir = join(tmpdir(), `session-test-${Date.now()}`); + projectA = join(tempDir, "project-a"); + projectB = join(tempDir, "project-b"); + mkdirSync(projectA, { recursive: true }); + mkdirSync(projectB, { recursive: true }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + function createPersistedSession(cwd: string, label: string): string { + const session = SessionManager.create(cwd, tempDir); + session.appendMessage({ role: "user", content: label, timestamp: Date.now() }); + session.appendMessage({ + role: "assistant", + content: [{ type: "text", text: `reply to ${label}` }], + api: "anthropic-messages", + provider: "anthropic", + model: "test", + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }); + const sessionFile = session.getSessionFile(); + if (!sessionFile) { + throw new Error("Expected persisted session file"); + } + return sessionFile; + } + + it("scopes current-folder APIs by cwd while listing all flat sessions", async () => { + const sessionA = createPersistedSession(projectA, "from A"); + await new Promise((r) => setTimeout(r, 10)); + const sessionB = createPersistedSession(projectB, "from B"); + + const currentA = await SessionManager.list(projectA, tempDir); + expect(currentA.map((session) => session.path)).toEqual([sessionA]); + + const all = await SessionManager.listAll(tempDir); + expect(new Set(all.map((session) => session.path))).toEqual(new Set([sessionA, sessionB])); + + const continuedA = SessionManager.continueRecent(projectA, tempDir); + expect(continuedA.getSessionFile()).toBe(sessionA); + }); }); describe("SessionManager.setSessionFile with corrupted files", () => {