fix(coding-agent): scope custom session dir lookups

This commit is contained in:
Armin Ronacher
2026-05-28 23:24:00 +02:00
parent b64f3f5eae
commit 4b4641c6b0
5 changed files with 146 additions and 17 deletions

View File

@@ -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)).

View File

@@ -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<string, unknown>;
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<SessionInfo[]> {
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<SessionInfo[]> {
static async listAll(onProgress?: SessionListProgress): Promise<SessionInfo[]>;
static async listAll(sessionDir?: string, onProgress?: SessionListProgress): Promise<SessionInfo[]>;
static async listAll(
sessionDirOrOnProgress?: string | SessionListProgress,
onProgress?: SessionListProgress,
): Promise<SessionInfo[]> {
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) {

View File

@@ -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"));

View File

@@ -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);

View File

@@ -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", () => {