fix: respect Pi session directories by cwd

This commit is contained in:
Federico Jaramillo Martinez
2026-06-10 20:26:51 +02:00
parent 3330a5a7fa
commit 06052ea5ec
22 changed files with 583 additions and 252 deletions
+142 -66
View File
@@ -1,9 +1,20 @@
import type { FastifyInstance } from "fastify";
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
import type { PiSessionService } from "./piSessionService.js";
import type { PiSessionRef, PiSessionService } from "./piSessionService.js";
interface SessionQuery {
cwd?: string;
}
interface MessageQuery extends SessionQuery {
before?: string;
limit?: string;
}
class SessionRouteValidationError extends Error {}
export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionService, eventHub: SessionEventHub, prefix = ""): void {
app.get<{ Querystring: { cwd?: string } }>(`${prefix}/sessions`, async (request, reply) => {
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);
});
@@ -12,164 +23,189 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS
try {
return await sessions.start(request.body.cwd);
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
return reply.code(400).send({ error: errorMessage(error) });
}
});
app.get<{ Params: { sessionId: string }; Querystring: { before?: string; limit?: string } }>(`${prefix}/sessions/:sessionId/messages`, async (request, reply) => {
app.get<{ Params: { sessionId: string }; Querystring: MessageQuery }>(`${prefix}/sessions/:sessionId/messages`, async (request, reply) => {
try {
const page = { ...optionalField("before", optionalNumber(request.query.before)), ...optionalField("limit", optionalNumber(request.query.limit)) };
return await sessions.messages(request.params.sessionId, page);
return await sessions.messages(sessionRefFromQuery(request.params.sessionId, request.query), page);
} catch (error) {
return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) });
return reply.code(readErrorStatus(error)).send({ error: errorMessage(error) });
}
});
app.get<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/status`, async (request, reply) => {
app.get<{ Params: { sessionId: string }; Querystring: SessionQuery }>(`${prefix}/sessions/:sessionId/status`, async (request, reply) => {
try {
return await sessions.status(request.params.sessionId);
return await sessions.status(sessionRefFromQuery(request.params.sessionId, request.query));
} catch (error) {
return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) });
return reply.code(readErrorStatus(error)).send({ error: errorMessage(error) });
}
});
app.get<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/models`, async (request, reply) => {
app.get<{ Params: { sessionId: string }; Querystring: SessionQuery }>(`${prefix}/sessions/:sessionId/models`, async (request, reply) => {
try {
return { models: await sessions.availableModels(request.params.sessionId) };
return { models: await sessions.availableModels(sessionRefFromQuery(request.params.sessionId, request.query)) };
} catch (error) {
return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) });
return reply.code(readErrorStatus(error)).send({ error: errorMessage(error) });
}
});
app.post<{ Params: { sessionId: string }; Body: { provider: string; modelId: string } }>(`${prefix}/sessions/:sessionId/model`, async (request, reply) => {
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; provider?: unknown; modelId?: unknown } }>(`${prefix}/sessions/:sessionId/model`, async (request, reply) => {
try {
return await sessions.setModel(request.params.sessionId, request.body.provider, request.body.modelId);
const body = requireRecord(request.body);
return await sessions.setModel(sessionRefFromBody(request.params.sessionId, body), requireString(body, "provider"), requireString(body, "modelId"));
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
return reply.code(400).send({ error: errorMessage(error) });
}
});
app.post<{ Params: { sessionId: string }; Body: { direction?: "forward" | "backward" } }>(`${prefix}/sessions/:sessionId/model/cycle`, async (request, reply) => {
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; direction?: "forward" | "backward" } }>(`${prefix}/sessions/:sessionId/model/cycle`, async (request, reply) => {
try {
return await sessions.cycleModel(request.params.sessionId, request.body.direction ?? "forward");
const body = requireRecord(request.body);
const direction = body["direction"];
if (direction !== undefined && direction !== "forward" && direction !== "backward") throw new Error("direction must be forward or backward");
return await sessions.cycleModel(sessionRefFromBody(request.params.sessionId, body), direction ?? "forward");
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
return reply.code(400).send({ error: errorMessage(error) });
}
});
app.get<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/thinking-levels`, async (request, reply) => {
app.get<{ Params: { sessionId: string }; Querystring: SessionQuery }>(`${prefix}/sessions/:sessionId/thinking-levels`, async (request, reply) => {
try {
return { levels: await sessions.availableThinkingLevels(request.params.sessionId) };
return { levels: await sessions.availableThinkingLevels(sessionRefFromQuery(request.params.sessionId, request.query)) };
} catch (error) {
return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) });
return reply.code(readErrorStatus(error)).send({ error: errorMessage(error) });
}
});
app.post<{ Params: { sessionId: string }; Body: { level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" } }>(`${prefix}/sessions/:sessionId/thinking-level`, async (request, reply) => {
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; level?: unknown } }>(`${prefix}/sessions/:sessionId/thinking-level`, async (request, reply) => {
try {
return await sessions.setThinkingLevel(request.params.sessionId, request.body.level);
const body = requireRecord(request.body);
return await sessions.setThinkingLevel(sessionRefFromBody(request.params.sessionId, body), requireThinkingLevel(body["level"]));
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
return reply.code(400).send({ error: errorMessage(error) });
}
});
app.post<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/thinking-level/cycle`, async (request, reply) => {
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } }>(`${prefix}/sessions/:sessionId/thinking-level/cycle`, async (request, reply) => {
try {
return await sessions.cycleThinkingLevel(request.params.sessionId);
const body = requireRecord(request.body);
return await sessions.cycleThinkingLevel(sessionRefFromBody(request.params.sessionId, body));
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
return reply.code(400).send({ error: errorMessage(error) });
}
});
app.get<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/commands`, async (request, reply) => {
app.get<{ Params: { sessionId: string }; Querystring: SessionQuery }>(`${prefix}/sessions/:sessionId/commands`, async (request, reply) => {
try {
return await sessions.commands(request.params.sessionId);
return await sessions.commands(sessionRefFromQuery(request.params.sessionId, request.query));
} catch (error) {
return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) });
return reply.code(readErrorStatus(error)).send({ error: errorMessage(error) });
}
});
app.post<{ Params: { sessionId: string }; Body: { text: string; streamingBehavior?: "steer" | "followUp" } }>(`${prefix}/sessions/:sessionId/prompt`, async (request, reply) => {
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; text?: unknown; streamingBehavior?: "steer" | "followUp" } }>(`${prefix}/sessions/:sessionId/prompt`, async (request, reply) => {
try {
await sessions.prompt(request.params.sessionId, request.body.text, request.body.streamingBehavior);
const body = requireRecord(request.body);
const streamingBehavior = body["streamingBehavior"];
if (streamingBehavior !== undefined && streamingBehavior !== "steer" && streamingBehavior !== "followUp") throw new Error("streamingBehavior must be steer or followUp");
await sessions.prompt(sessionRefFromBody(request.params.sessionId, body), requireString(body, "text"), streamingBehavior);
return { accepted: true };
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
return reply.code(400).send({ error: errorMessage(error) });
}
});
app.post<{ Params: { sessionId: string }; Body: { text: string } }>(`${prefix}/sessions/:sessionId/shell`, async (request, reply) => {
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; text?: unknown } }>(`${prefix}/sessions/:sessionId/shell`, async (request, reply) => {
try {
await sessions.shell(request.params.sessionId, request.body.text);
const body = requireRecord(request.body);
await sessions.shell(sessionRefFromBody(request.params.sessionId, body), requireString(body, "text"));
return { accepted: true };
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
return reply.code(400).send({ error: errorMessage(error) });
}
});
app.post<{ Params: { sessionId: string }; Body: { text: string } }>(`${prefix}/sessions/:sessionId/commands/run`, async (request, reply) => {
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; text?: unknown } }>(`${prefix}/sessions/:sessionId/commands/run`, async (request, reply) => {
try {
return await sessions.runCommand(request.params.sessionId, request.body.text);
const body = requireRecord(request.body);
return await sessions.runCommand(sessionRefFromBody(request.params.sessionId, body), requireString(body, "text"));
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
return reply.code(400).send({ error: errorMessage(error) });
}
});
app.post<{ Params: { sessionId: string }; Body: { requestId: string; value: string } }>(`${prefix}/sessions/:sessionId/commands/respond`, async (request, reply) => {
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; requestId?: unknown; value?: unknown } }>(`${prefix}/sessions/:sessionId/commands/respond`, async (request, reply) => {
try {
return await sessions.respondToCommand(request.params.sessionId, request.body.requestId, request.body.value);
const body = requireRecord(request.body);
return await sessions.respondToCommand(sessionRefFromBody(request.params.sessionId, body), requireString(body, "requestId"), requireString(body, "value"));
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
return reply.code(400).send({ error: errorMessage(error) });
}
});
app.post<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/abort`, async (request) => {
await sessions.abort(request.params.sessionId);
return { aborted: true };
});
app.post<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/stop`, (request) => {
sessions.stop(request.params.sessionId);
return { stopped: true };
});
app.post<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/archive`, async (request, reply) => {
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } }>(`${prefix}/sessions/:sessionId/abort`, async (request, reply) => {
try {
await sessions.archive(request.params.sessionId);
await sessions.abort(sessionRefFromBody(request.params.sessionId, requireRecord(request.body)));
return { aborted: true };
} catch (error) {
return reply.code(400).send({ error: errorMessage(error) });
}
});
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } }>(`${prefix}/sessions/:sessionId/stop`, (request, reply) => {
try {
sessions.stop(sessionRefFromBody(request.params.sessionId, requireRecord(request.body)));
return { stopped: true };
} catch (error) {
return reply.code(400).send({ error: errorMessage(error) });
}
});
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } }>(`${prefix}/sessions/:sessionId/archive`, async (request, reply) => {
try {
await sessions.archive(sessionRefFromBody(request.params.sessionId, requireRecord(request.body)));
return { archived: true };
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
return reply.code(400).send({ error: errorMessage(error) });
}
});
app.post<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/archive-tree`, async (request, reply) => {
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } }>(`${prefix}/sessions/:sessionId/archive-tree`, async (request, reply) => {
try {
return await sessions.archiveTree(request.params.sessionId);
return await sessions.archiveTree(sessionRefFromBody(request.params.sessionId, requireRecord(request.body)));
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
return reply.code(400).send({ error: errorMessage(error) });
}
});
app.post<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/restore`, async (request, reply) => {
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } }>(`${prefix}/sessions/:sessionId/restore`, async (request, reply) => {
try {
await sessions.restore(request.params.sessionId);
await sessions.restore(sessionRefFromBody(request.params.sessionId, requireRecord(request.body)));
return { restored: true };
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
return reply.code(400).send({ error: errorMessage(error) });
}
});
app.post<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/detach-parent`, async (request, reply) => {
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } }>(`${prefix}/sessions/:sessionId/detach-parent`, async (request, reply) => {
try {
await sessions.detachParent(request.params.sessionId);
await sessions.detachParent(sessionRefFromBody(request.params.sessionId, requireRecord(request.body)));
return { detached: true };
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
return reply.code(400).send({ error: errorMessage(error) });
}
});
app.get<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/events`, { websocket: true }, (socket, request) => {
eventHub.add(request.params.sessionId, socket);
app.get<{ Params: { sessionId: string }; Querystring: SessionQuery }>(`${prefix}/sessions/:sessionId/events`, { websocket: true }, (socket, request) => {
try {
const ref = sessionRefFromQuery(request.params.sessionId, request.query);
eventHub.add(ref.id, socket);
} catch {
socket.close();
}
});
app.get(`${prefix}/sessions/events`, { websocket: true }, (socket) => {
@@ -181,6 +217,34 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS
});
}
function sessionRefFromQuery(id: string, query: SessionQuery): PiSessionRef {
const cwd = query.cwd;
if (cwd === undefined || cwd === "") throw new SessionRouteValidationError("cwd query parameter is required");
return { id, cwd };
}
function sessionRefFromBody(id: string, body: Record<string, unknown>): PiSessionRef {
const cwd = body["cwd"];
if (typeof cwd !== "string" || cwd === "") throw new Error("cwd field is required");
return { id, cwd };
}
function requireRecord(value: unknown): Record<string, unknown> {
if (!isRecord(value)) throw new Error("request body must be an object");
return value;
}
function requireString(record: Record<string, unknown>, field: string): string {
const value = record[field];
if (typeof value !== "string") throw new Error(`${field} field must be a string`);
return value;
}
function requireThinkingLevel(value: unknown): "off" | "minimal" | "low" | "medium" | "high" | "xhigh" {
if (value === "off" || value === "minimal" || value === "low" || value === "medium" || value === "high" || value === "xhigh") return value;
throw new Error("level field is invalid");
}
function optionalField<T>(key: string, value: T | undefined): Record<string, T> | object {
return value === undefined ? {} : { [key]: value };
}
@@ -190,3 +254,15 @@ function optionalNumber(value: string | undefined): number | undefined {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function readErrorStatus(error: unknown): 400 | 404 {
return error instanceof SessionRouteValidationError ? 400 : 404;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}