fix: list and open sessions across project directories

Sessions outside the server's launch directory were invisible and returned
404 on open, leaving the model picker empty. List without the SDK's
process-cwd filter and normalize working directories at the API boundary and
when reading stored session data, tolerating separator/normalization
differences (including Windows backslash vs forward slash). Requires Pi
coding agent SDK 0.78.0 or newer.
This commit is contained in:
Federico Jaramillo Martinez
2026-06-13 11:40:47 +02:00
parent 38cf334c40
commit c0d12222a2
14 changed files with 2342 additions and 2261 deletions
@@ -45,9 +45,7 @@ describe("OAuthLoginFlowService", () => {
providerId: "test-provider",
providerName: "Test Provider",
authStorage: fakeAuthStorage(async (_providerId, callbacks) => {
const select = callbacks.onSelect;
if (select === undefined) throw new Error("Expected select callback");
selectedValue = await select({
selectedValue = await callbacks.onSelect({
message: "Choose account",
options: [{ id: "work", label: "Work" }, { id: "personal", label: "Personal" }],
});
@@ -72,6 +72,12 @@ export class OAuthLoginFlowService {
if (!this.isCurrentRunning(record)) return;
this.updateState(record, { ...record.state, auth: info });
},
// Device-code flows have no redirect URL; reuse the auth field so the web UI
// shows the verification link and user code without a dedicated API shape.
onDeviceCode: (info) => {
if (!this.isCurrentRunning(record)) return;
this.updateState(record, { ...record.state, auth: { url: info.verificationUri, instructions: `Enter code: ${info.userCode}` } });
},
onPrompt: (prompt) => this.waitForPrompt(record, prompt, "prompt"),
onManualCodeInput: () => this.waitForPrompt(record, { message: "Paste the callback URL or authorization code", allowEmpty: false }, "manual"),
onSelect: (prompt) => this.waitForSelect(record, prompt),
@@ -2,8 +2,10 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createPiSessionManagerGateway, defaultPiSessionDir, defaultPiSessionsRoot, SessionDirResolver } from "./piSessionManagerGateway.js";
import { createPiSessionManagerGateway, defaultPiSessionDir, defaultPiSessionsRoot, filterSessionsForCwd, SessionDirResolver } from "./piSessionManagerGateway.js";
import type { PiSessionListEntry } from "./piSessionService.js";
import type { PiSessionManager } from "./piSessionService.js";
import { sep } from "node:path";
let tempDir: string;
let agentDir: string;
@@ -83,12 +85,54 @@ describe("Pi session manager gateway", () => {
if (!hasSessionDir(created)) throw new Error("Expected SDK session manager");
expect(created.getSessionDir()).toBe(sharedSessionDir);
});
it("lists sessions for cwds that differ from the server process cwd", async () => {
// Regression: SessionManager.list("", dir) filtered against process.cwd(),
// hiding every session outside the daemon's own launch directory.
expect(cwd).not.toBe(process.cwd());
await writeSessionFile(defaultPiSessionDir(cwd, agentDir), "session-elsewhere", cwd);
const gateway = createPiSessionManagerGateway({ agentDir, env: {} });
await expect(gateway.list(cwd)).resolves.toMatchObject([{ id: "session-elsewhere", cwd }]);
});
});
describe("filterSessionsForCwd", () => {
it("matches cwds that differ only by trailing separator or redundant segments", () => {
const sessions = [sessionEntry("a", cwd)];
expect(filterSessionsForCwd(sessions, `${cwd}${sep}`)).toHaveLength(1);
expect(filterSessionsForCwd(sessions, join(cwd, "."))).toHaveLength(1);
});
it("excludes sessions with an empty cwd instead of matching the process cwd", () => {
expect(filterSessionsForCwd([sessionEntry("a", "")], process.cwd())).toHaveLength(0);
});
it("excludes sessions from other cwds", () => {
expect(filterSessionsForCwd([sessionEntry("a", join(tempDir, "other"))], cwd)).toHaveLength(0);
});
});
describe("session listing canonicalization", () => {
it("canonicalizes session header cwds written by external tools", async () => {
// Headers are written by the Pi CLI / SDK consumers and may contain
// unnormalized paths (trailing separators, redundant segments).
await writeSessionFile(defaultPiSessionDir(cwd, agentDir), "session-messy", `${cwd}${sep}.${sep}`);
const gateway = createPiSessionManagerGateway({ agentDir, env: {} });
await expect(gateway.list(cwd)).resolves.toMatchObject([{ id: "session-messy", cwd }]);
});
});
function hasSessionDir(manager: PiSessionManager): manager is PiSessionManager & { getSessionDir(): string } {
return "getSessionDir" in manager && typeof manager.getSessionDir === "function";
}
function sessionEntry(id: string, sessionCwd: string): PiSessionListEntry {
return { path: join(tempDir, `${id}.jsonl`), id, cwd: sessionCwd, created: new Date(), modified: new Date(), messageCount: 0, firstMessage: "", allMessagesText: "" };
}
async function writeSessionFile(dir: string, id: string, sessionCwd: string): Promise<void> {
await mkdir(dir, { recursive: true });
await writeFile(join(dir, `${id}.jsonl`), `${JSON.stringify({ type: "session", version: 3, id, timestamp: "2026-01-01T00:00:00.000Z", cwd: sessionCwd })}\n`, "utf8");
+11 -2
View File
@@ -3,6 +3,7 @@ import { readdir } from "node:fs/promises";
import { homedir } from "node:os";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { getAgentDir, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
import type { PiSessionListEntry, PiSessionManager, PiSessionManagerGateway } from "./piSessionService.js";
export const PI_SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_DIR";
@@ -77,7 +78,13 @@ class SettingsAwarePiSessionManagerGateway implements PiSessionManagerGateway {
}
export async function listSessionsInDir(sessionDir: string): Promise<PiSessionListEntry[]> {
return SessionManager.list("", sessionDir);
// listAll(sessionDir) lists without the SDK's internal cwd filter, which would
// otherwise compare against this process's cwd and drop other projects' sessions.
// Cwd filtering is applied explicitly by filterSessionsForCwd where needed.
// Session file headers are written by external tools (Pi CLI, SDK consumers),
// so their cwd is canonicalized here before it enters pi-web.
const sessions = await SessionManager.listAll(sessionDir);
return sessions.map((session) => ({ ...session, cwd: canonicalizeStoredCwd(session.cwd) }));
}
export async function listSessionsInDefaultPiStore(storeRoot = defaultPiSessionsRoot()): Promise<PiSessionListEntry[]> {
@@ -94,7 +101,9 @@ export async function listSessionsInDefaultPiStore(storeRoot = defaultPiSessions
}
export function filterSessionsForCwd(sessions: readonly PiSessionListEntry[], cwd: string): PiSessionListEntry[] {
return sessions.filter((session) => session.cwd === cwd);
// Sessions with an empty cwd (old session files) are excluded: resolve("") would
// resolve to this process's cwd and produce false matches.
return sessions.filter((session) => session.cwd !== "" && cwdPathsEqual(session.cwd, cwd));
}
export function defaultPiSessionsRoot(agentDir = getAgentDir()): string {
+2 -1
View File
@@ -25,6 +25,7 @@ import type { AuthChange } from "./authService.js";
import { fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
import { computeEditPreview, type EditPreviewResult } from "./editPreview.js";
import { createPiSessionManagerGateway } from "./piSessionManagerGateway.js";
import { cwdPathsEqual } from "../workingDirectory.js";
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
function noop(): void {
@@ -44,7 +45,7 @@ function isPiSessionRef(ref: PiSessionLookup): ref is PiSessionRef {
}
function lookupMatchesActiveSession(ref: PiSessionLookup, active: ActiveSession<PiSessionRuntime>): boolean {
return !isPiSessionRef(ref) || active.runtime.cwd === ref.cwd;
return !isPiSessionRef(ref) || cwdPathsEqual(active.runtime.cwd, ref.cwd);
}
type QueuedPromptKind = "steer" | "followUp";
+4 -2
View File
@@ -3,6 +3,7 @@ import { constants } from "node:fs";
import { access, copyFile, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
import { basename, dirname, join } from "node:path";
import { homedir } from "node:os";
import { canonicalizeStoredCwd } from "../workingDirectory.js";
export interface ArchiveSessionInput {
sessionId: string;
@@ -148,7 +149,7 @@ export class SessionArchiveStore {
function archiveRecordFromInput(session: ArchiveSessionInput, archive: { archivedAt: string; originalPath: string; archivePath: string }): ArchivedSessionRecord {
return {
sessionId: session.sessionId,
cwd: session.cwd,
cwd: canonicalizeStoredCwd(session.cwd),
archivedAt: archive.archivedAt,
originalPath: archive.originalPath,
archivePath: archive.archivePath,
@@ -211,6 +212,7 @@ function parseArchivedSessionRecord(value: unknown): ArchivedSessionRecord {
const cwd = value["cwd"];
const archivedAt = value["archivedAt"];
if (typeof sessionId !== "string" || typeof cwd !== "string" || typeof archivedAt !== "string") throw new Error("Invalid archived session record");
const canonicalCwd = canonicalizeStoredCwd(cwd);
const originalPath = optionalString(value, "originalPath");
const archivePath = optionalString(value, "archivePath");
const created = optionalString(value, "created");
@@ -221,7 +223,7 @@ function parseArchivedSessionRecord(value: unknown): ArchivedSessionRecord {
const parentSessionPath = optionalString(value, "parentSessionPath");
return {
sessionId,
cwd,
cwd: canonicalCwd,
archivedAt,
...(originalPath === undefined ? {} : { originalPath }),
...(archivePath === undefined ? {} : { archivePath }),
+14 -10
View File
@@ -1,4 +1,5 @@
import type { FastifyInstance } from "fastify";
import { normalizeRequestCwd } from "../workingDirectory.js";
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
import type { PiSessionRef, PiSessionService } from "./piSessionService.js";
@@ -22,13 +23,17 @@ interface PromptRequestBody {
export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionService, eventHub: SessionEventHub, prefix = ""): void {
app.get<{ Querystring: SessionQuery }>(`${prefix}/sessions`, async (request, reply) => {
if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" });
return sessions.list(request.query.cwd);
try {
return await sessions.list(normalizeRequestCwd(request.query.cwd));
} catch (error) {
return reply.code(400).send({ error: errorMessage(error) });
}
});
app.post<{ Body: { cwd?: unknown } | undefined }>(`${prefix}/sessions`, async (request, reply) => {
try {
const body = requireRecord(request.body);
return await sessions.start(requireString(body, "cwd"));
return await sessions.start(normalizeRequestCwd(requireString(body, "cwd")));
} catch (error) {
return reply.code(400).send({ error: errorMessage(error) });
}
@@ -214,8 +219,9 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS
});
app.get<{ Params: { sessionId: string }; Querystring: SessionQuery }>(`${prefix}/sessions/:sessionId/events`, { websocket: true }, (socket, request) => {
const lookup = sessionLookupFromQuery(request.params.sessionId, request.query);
eventHub.add(sessionIdFromLookup(lookup), socket);
// Only the id matters for event subscription; cwd is intentionally ignored
// so a malformed value cannot throw inside the websocket handler.
eventHub.add(request.params.sessionId, socket);
});
app.get(`${prefix}/sessions/events`, { websocket: true }, (socket) => {
@@ -235,15 +241,13 @@ function sessionLookupFromBody(id: string, body: Record<string, unknown>): Sessi
const cwd = body["cwd"];
if (cwd === undefined || cwd === "") return id;
if (typeof cwd !== "string") throw new Error("cwd field must be a string");
return { id, cwd };
return { id, cwd: normalizeRequestCwd(cwd) };
}
function sessionLookupFromCwd(id: string, cwd: string | undefined): SessionLookup {
return cwd === undefined || cwd === "" ? id : { id, cwd };
}
function sessionIdFromLookup(lookup: SessionLookup): string {
return typeof lookup === "string" ? lookup : lookup.id;
// Legacy id-only lookups (no cwd) remain supported; a supplied cwd is
// normalized here so everything past the route layer sees canonical paths.
return cwd === undefined || cwd === "" ? id : { id, cwd: normalizeRequestCwd(cwd) };
}
function optionalRecord(value: unknown): Record<string, unknown> {