Archived
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:
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user