fix(coding-agent): scope custom session dir lookups
This commit is contained in:
@@ -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)).
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user