Archived
fix: respect Pi session directories by cwd
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
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 type { PiSessionManager } from "./piSessionService.js";
|
||||
|
||||
let tempDir: string;
|
||||
let agentDir: string;
|
||||
let cwd: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "pi-web-session-gateway-test-"));
|
||||
agentDir = join(tempDir, "agent");
|
||||
cwd = join(tempDir, "workspace");
|
||||
await mkdir(cwd, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("SessionDirResolver", () => {
|
||||
it("uses Pi default session storage when no Pi override is configured", () => {
|
||||
const resolver = new SessionDirResolver({ agentDir, env: {} });
|
||||
|
||||
expect(resolver.resolve(cwd)).toMatchObject({ source: "pi-default", sessionDir: defaultPiSessionDir(cwd, agentDir), usesConfiguredSessionDir: false });
|
||||
expect(defaultPiSessionsRoot(agentDir)).toBe(join(agentDir, "sessions"));
|
||||
});
|
||||
|
||||
it("uses Pi sessionDir settings and resolves relative paths against the session cwd", async () => {
|
||||
await mkdir(agentDir, { recursive: true });
|
||||
await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ sessionDir: ".pi/sessions" }, null, 2)}\n`, "utf8");
|
||||
|
||||
const resolver = new SessionDirResolver({ agentDir, env: {} });
|
||||
|
||||
expect(resolver.resolve(cwd)).toMatchObject({ source: "settings", sessionDir: join(cwd, ".pi", "sessions"), usesConfiguredSessionDir: true });
|
||||
});
|
||||
|
||||
it("lets project-local Pi sessionDir settings override global Pi settings for that cwd", async () => {
|
||||
await mkdir(agentDir, { recursive: true });
|
||||
await mkdir(join(cwd, ".pi"), { recursive: true });
|
||||
await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ sessionDir: join(tempDir, "global-sessions") }, null, 2)}\n`, "utf8");
|
||||
await writeFile(join(cwd, ".pi", "settings.json"), `${JSON.stringify({ sessionDir: ".workspace-sessions" }, null, 2)}\n`, "utf8");
|
||||
|
||||
const resolver = new SessionDirResolver({ agentDir, env: {} });
|
||||
|
||||
expect(resolver.resolve(cwd)).toMatchObject({ source: "settings", sessionDir: join(cwd, ".workspace-sessions"), usesConfiguredSessionDir: true });
|
||||
});
|
||||
|
||||
it("lets the Pi sessionDir environment override Pi settings", async () => {
|
||||
const envDir = join(tempDir, "env-sessions");
|
||||
await mkdir(agentDir, { recursive: true });
|
||||
await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ sessionDir: join(tempDir, "settings-sessions") }, null, 2)}\n`, "utf8");
|
||||
|
||||
const resolver = new SessionDirResolver({ agentDir, env: { PI_CODING_AGENT_SESSION_DIR: envDir } });
|
||||
|
||||
expect(resolver.resolve(cwd)).toMatchObject({ source: "env", sessionDir: envDir, usesConfiguredSessionDir: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Pi session manager gateway", () => {
|
||||
it("lists only sessions for the requested cwd when a custom Pi sessionDir is shared", async () => {
|
||||
const sharedSessionDir = join(tempDir, "shared-sessions");
|
||||
const otherCwd = join(tempDir, "other-workspace");
|
||||
await writeSessionFile(sharedSessionDir, "session-a", cwd);
|
||||
await writeSessionFile(sharedSessionDir, "session-b", otherCwd);
|
||||
const gateway = createPiSessionManagerGateway({ agentDir, env: { PI_CODING_AGENT_SESSION_DIR: sharedSessionDir } });
|
||||
|
||||
await expect(gateway.list(cwd)).resolves.toMatchObject([{ id: "session-a", cwd }]);
|
||||
const created = gateway.create(cwd);
|
||||
expect(hasSessionDir(created)).toBe(true);
|
||||
if (!hasSessionDir(created)) throw new Error("Expected SDK session manager");
|
||||
expect(created.getSessionDir()).toBe(sharedSessionDir);
|
||||
});
|
||||
});
|
||||
|
||||
function hasSessionDir(manager: PiSessionManager): manager is PiSessionManager & { getSessionDir(): string } {
|
||||
return "getSessionDir" in manager && typeof manager.getSessionDir === "function";
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, isAbsolute, join, resolve } from "node:path";
|
||||
import { getAgentDir, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
|
||||
import type { PiSessionListEntry, PiSessionManager, PiSessionManagerGateway } from "./piSessionService.js";
|
||||
|
||||
export const PI_SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_DIR";
|
||||
|
||||
type SessionDirSource = "env" | "settings" | "pi-default";
|
||||
|
||||
export interface SessionDirResolution {
|
||||
source: SessionDirSource;
|
||||
sessionDir: string;
|
||||
usesConfiguredSessionDir: boolean;
|
||||
}
|
||||
|
||||
export interface SessionDirResolverOptions {
|
||||
agentDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
export class SessionDirResolver {
|
||||
private readonly agentDir: string;
|
||||
private readonly env: NodeJS.ProcessEnv;
|
||||
|
||||
constructor(options: SessionDirResolverOptions = {}) {
|
||||
this.agentDir = options.agentDir ?? getAgentDir();
|
||||
this.env = options.env ?? process.env;
|
||||
}
|
||||
|
||||
resolve(cwd: string): SessionDirResolution {
|
||||
const envSessionDir = this.env[PI_SESSION_DIR_ENV];
|
||||
if (envSessionDir !== undefined && envSessionDir !== "") {
|
||||
return { source: "env", sessionDir: resolveConfiguredPath(envSessionDir, cwd), usesConfiguredSessionDir: true };
|
||||
}
|
||||
|
||||
const settingsSessionDir = SettingsManager.create(cwd, this.agentDir).getSessionDir();
|
||||
if (settingsSessionDir !== undefined && settingsSessionDir !== "") {
|
||||
return { source: "settings", sessionDir: resolveConfiguredPath(settingsSessionDir, cwd), usesConfiguredSessionDir: true };
|
||||
}
|
||||
|
||||
return { source: "pi-default", sessionDir: defaultPiSessionDir(cwd, this.agentDir), usesConfiguredSessionDir: false };
|
||||
}
|
||||
}
|
||||
|
||||
export type PiSessionManagerGatewayOptions = SessionDirResolverOptions;
|
||||
|
||||
export function createPiSessionManagerGateway(options: PiSessionManagerGatewayOptions = {}): PiSessionManagerGateway {
|
||||
return new SettingsAwarePiSessionManagerGateway(new SessionDirResolver(options));
|
||||
}
|
||||
|
||||
class SettingsAwarePiSessionManagerGateway implements PiSessionManagerGateway {
|
||||
constructor(private readonly resolver: SessionDirResolver) {}
|
||||
|
||||
async list(cwd: string): Promise<PiSessionListEntry[]> {
|
||||
const resolution = this.resolver.resolve(cwd);
|
||||
return filterSessionsForCwd(await listSessionsInDir(resolution.sessionDir), cwd);
|
||||
}
|
||||
|
||||
create(cwd: string): PiSessionManager {
|
||||
const resolution = this.resolver.resolve(cwd);
|
||||
return SessionManager.create(cwd, resolution.sessionDir);
|
||||
}
|
||||
|
||||
open(path: string): PiSessionManager {
|
||||
return SessionManager.open(path, dirname(path));
|
||||
}
|
||||
}
|
||||
|
||||
export async function listSessionsInDir(sessionDir: string): Promise<PiSessionListEntry[]> {
|
||||
return SessionManager.list("", sessionDir);
|
||||
}
|
||||
|
||||
export function filterSessionsForCwd(sessions: readonly PiSessionListEntry[], cwd: string): PiSessionListEntry[] {
|
||||
return sessions.filter((session) => session.cwd === cwd);
|
||||
}
|
||||
|
||||
export function defaultPiSessionsRoot(agentDir = getAgentDir()): string {
|
||||
return join(agentDir, "sessions");
|
||||
}
|
||||
|
||||
export function defaultPiSessionDir(cwd: string, agentDir = getAgentDir()): string {
|
||||
return sessionDirInDefaultPiStore(defaultPiSessionsRoot(agentDir), cwd);
|
||||
}
|
||||
|
||||
export function sessionDirInDefaultPiStore(storeRoot: string, cwd: string): string {
|
||||
const safePath = `--${cwd.replace(/^[/\\]/u, "").replace(/[/\\:]/gu, "-")}--`;
|
||||
return join(storeRoot, safePath);
|
||||
}
|
||||
|
||||
export function resolveConfiguredPath(path: string, cwd: string): string {
|
||||
const expanded = expandTildePath(path);
|
||||
return isAbsolute(expanded) ? expanded : resolve(cwd, expanded);
|
||||
}
|
||||
|
||||
function expandTildePath(path: string): string {
|
||||
if (path === "~") return homedir();
|
||||
if (path.startsWith("~/")) return join(homedir(), path.slice(2));
|
||||
return path;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,10 @@ function sessionRecord(id: string, cwd = "/workspace") {
|
||||
return { id, path: `/sessions/${id}.jsonl`, cwd, created: new Date("2026-01-01T00:00:00.000Z"), modified: new Date("2026-01-01T00:01:00.000Z"), messageCount: 0, firstMessage: "", allMessagesText: "" };
|
||||
}
|
||||
|
||||
function sessionRef(id: string, cwd = "/workspace") {
|
||||
return { id, cwd };
|
||||
}
|
||||
|
||||
function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession> = {}) {
|
||||
const promptCalls: { text: string; options: unknown }[] = [];
|
||||
const listeners: ((event: unknown) => void)[] = [];
|
||||
@@ -122,7 +126,6 @@ function sessionGateway(records: ReturnType<typeof sessionRecord>[]): SessionGat
|
||||
return {
|
||||
create: () => fakeSessionManager(),
|
||||
list: () => Promise.resolve(records),
|
||||
listAll: () => Promise.resolve(records),
|
||||
open: () => fakeSessionManager(),
|
||||
};
|
||||
}
|
||||
@@ -174,7 +177,7 @@ describe("PiSessionService", () => {
|
||||
heartbeatIntervalMs: 1_000,
|
||||
});
|
||||
|
||||
await service.status("idle-session");
|
||||
await service.status(sessionRef("idle-session"));
|
||||
hub.globalEvents.length = 0;
|
||||
listener?.({ type: "agent_start" });
|
||||
|
||||
@@ -209,7 +212,7 @@ describe("PiSessionService", () => {
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.status("completion-session");
|
||||
await service.status(sessionRef("completion-session"));
|
||||
hub.globalEvents.length = 0;
|
||||
listener?.({ type: "tool_execution_end", toolName: "read", isError: false });
|
||||
|
||||
@@ -235,7 +238,6 @@ describe("PiSessionService", () => {
|
||||
{ ...sessionRecord("active"), messageCount: 1, firstMessage: "hello", allMessagesText: "hello" },
|
||||
{ ...sessionRecord("archived"), messageCount: 2, firstMessage: "bye", allMessagesText: "bye" },
|
||||
]),
|
||||
listAll: () => Promise.resolve([]),
|
||||
open: () => fakeSessionManager(),
|
||||
},
|
||||
heartbeatIntervalMs: 60_000,
|
||||
@@ -262,7 +264,6 @@ describe("PiSessionService", () => {
|
||||
sessionManager: {
|
||||
create: () => fakeSessionManager(),
|
||||
list: () => Promise.resolve([{ ...sessionRecord("active"), messageCount: 1, firstMessage: "hello", allMessagesText: "hello" }]),
|
||||
listAll: () => Promise.resolve([]),
|
||||
open: () => fakeSessionManager(),
|
||||
},
|
||||
heartbeatIntervalMs: 60_000,
|
||||
@@ -301,13 +302,12 @@ describe("PiSessionService", () => {
|
||||
sessionManager: {
|
||||
create: () => fakeSessionManager(),
|
||||
list: (cwd) => Promise.resolve(cwd === "/workspace" ? [root, directChild, archivedChild, grandchild] : [otherWorkspaceChild]),
|
||||
listAll: () => Promise.resolve([root, directChild, archivedChild, grandchild, otherWorkspaceChild]),
|
||||
open: () => fakeSessionManager(),
|
||||
},
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await expect(service.archiveTree("root")).resolves.toEqual({
|
||||
await expect(service.archiveTree(sessionRef("root"))).resolves.toEqual({
|
||||
archived: true,
|
||||
sessionIds: ["root", "direct-child", "grandchild"],
|
||||
archivedCount: 3,
|
||||
@@ -331,7 +331,6 @@ describe("PiSessionService", () => {
|
||||
sessionManager: {
|
||||
create: () => fakeSessionManager(),
|
||||
list: () => Promise.resolve([]),
|
||||
listAll: () => Promise.resolve([]),
|
||||
open: () => fakeSessionManager(),
|
||||
},
|
||||
workspaceActivity: {
|
||||
@@ -360,7 +359,7 @@ describe("PiSessionService", () => {
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.prompt("prompt-session", "Build the thing");
|
||||
await service.prompt(sessionRef("prompt-session"), "Build the thing");
|
||||
|
||||
expect(fake.calls.prompt).toEqual([{ text: "Build the thing", options: undefined }]);
|
||||
await service.dispose();
|
||||
@@ -379,7 +378,7 @@ describe("PiSessionService", () => {
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await expect(service.status("status-session")).resolves.toMatchObject({
|
||||
await expect(service.status(sessionRef("status-session"))).resolves.toMatchObject({
|
||||
pendingMessageCount: 2,
|
||||
queuedMessages: [{ kind: "steer", text: "adjust this turn" }, { kind: "followUp", text: "then do this" }],
|
||||
messageCount: 2,
|
||||
@@ -399,7 +398,7 @@ describe("PiSessionService", () => {
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.prompt("dedupe-session", "already queued", "followUp");
|
||||
await service.prompt(sessionRef("dedupe-session"), "already queued", "followUp");
|
||||
|
||||
expect(fake.calls.prompt).toEqual([]);
|
||||
await service.dispose();
|
||||
@@ -414,7 +413,7 @@ describe("PiSessionService", () => {
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.prompt("queued-session", "Wait for the current turn", "followUp");
|
||||
await service.prompt(sessionRef("queued-session"), "Wait for the current turn", "followUp");
|
||||
|
||||
expect(fake.calls.prompt).toEqual([{ text: "Wait for the current turn", options: { streamingBehavior: "followUp" } }]);
|
||||
expect(hub.sessionEvents.some(({ event }) => event.type === "message.append")).toBe(false);
|
||||
@@ -439,12 +438,12 @@ describe("PiSessionService", () => {
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.prompt("compacting-session", "Start task 1", "followUp");
|
||||
await service.prompt("compacting-session", "Then task 2", "followUp");
|
||||
await service.prompt(sessionRef("compacting-session"), "Start task 1", "followUp");
|
||||
await service.prompt(sessionRef("compacting-session"), "Then task 2", "followUp");
|
||||
|
||||
expect(fake.calls.prompt).toEqual([]);
|
||||
expect(hub.sessionEvents.some(({ event }) => event.type === "message.append")).toBe(false);
|
||||
await expect(service.status("compacting-session")).resolves.toMatchObject({
|
||||
await expect(service.status(sessionRef("compacting-session"))).resolves.toMatchObject({
|
||||
pendingMessageCount: 2,
|
||||
queuedMessages: [{ kind: "followUp", text: "Start task 1" }, { kind: "followUp", text: "Then task 2" }],
|
||||
});
|
||||
@@ -455,7 +454,7 @@ describe("PiSessionService", () => {
|
||||
|
||||
expect(fake.calls.prompt).toEqual([{ text: "Start task 1", options: undefined }]);
|
||||
expect(hub.sessionEvents.some(({ event }) => event.type === "message.append" && JSON.stringify(event.message).includes("Start task 1"))).toBe(true);
|
||||
await expect(service.status("compacting-session")).resolves.toMatchObject({
|
||||
await expect(service.status(sessionRef("compacting-session"))).resolves.toMatchObject({
|
||||
pendingMessageCount: 1,
|
||||
queuedMessages: [{ kind: "followUp", text: "Then task 2" }],
|
||||
});
|
||||
@@ -467,7 +466,7 @@ describe("PiSessionService", () => {
|
||||
{ text: "Start task 1", options: undefined },
|
||||
{ text: "Then task 2", options: { streamingBehavior: "followUp" } },
|
||||
]);
|
||||
await expect(service.status("compacting-session")).resolves.toMatchObject({
|
||||
await expect(service.status(sessionRef("compacting-session"))).resolves.toMatchObject({
|
||||
pendingMessageCount: 0,
|
||||
queuedMessages: [],
|
||||
});
|
||||
@@ -483,8 +482,8 @@ describe("PiSessionService", () => {
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.status("abort-session");
|
||||
await service.abort("abort-session");
|
||||
await service.status(sessionRef("abort-session"));
|
||||
await service.abort(sessionRef("abort-session"));
|
||||
|
||||
expect(fake.calls.clearQueue).toBe(1);
|
||||
expect(fake.calls.abort).toBe(1);
|
||||
@@ -499,13 +498,13 @@ describe("PiSessionService", () => {
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.prompt("abort-compaction-session", "Do not deliver after abort", "followUp");
|
||||
await expect(service.status("abort-compaction-session")).resolves.toMatchObject({ pendingMessageCount: 1 });
|
||||
await service.abort("abort-compaction-session");
|
||||
await service.prompt(sessionRef("abort-compaction-session"), "Do not deliver after abort", "followUp");
|
||||
await expect(service.status(sessionRef("abort-compaction-session"))).resolves.toMatchObject({ pendingMessageCount: 1 });
|
||||
await service.abort(sessionRef("abort-compaction-session"));
|
||||
|
||||
expect(fake.calls.clearQueue).toBe(1);
|
||||
expect(fake.calls.prompt).toEqual([]);
|
||||
await expect(service.status("abort-compaction-session")).resolves.toMatchObject({ pendingMessageCount: 0, queuedMessages: [] });
|
||||
await expect(service.status(sessionRef("abort-compaction-session"))).resolves.toMatchObject({ pendingMessageCount: 0, queuedMessages: [] });
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
@@ -524,7 +523,7 @@ describe("PiSessionService", () => {
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.status("auth-session");
|
||||
await service.status(sessionRef("auth-session"));
|
||||
hub.sessionEvents.length = 0;
|
||||
hub.globalEvents.length = 0;
|
||||
|
||||
@@ -553,8 +552,8 @@ describe("PiSessionService", () => {
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.status("stop-session");
|
||||
service.stop("stop-session");
|
||||
await service.status(sessionRef("stop-session"));
|
||||
service.stop(sessionRef("stop-session"));
|
||||
|
||||
expect(fake.calls.clearQueue).toBe(1);
|
||||
await service.dispose();
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
type CreateAgentSessionRuntimeFactory,
|
||||
type EditToolDetails,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import type { ClientArchiveSessionsResponse, ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionModel, ClientSessionStatus, ClientThinkingLevel, SessionUiEvent } from "../types.js";
|
||||
import type { ClientArchiveSessionsResponse, ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionModel, ClientSessionRef, ClientSessionStatus, ClientThinkingLevel, SessionUiEvent } from "../types.js";
|
||||
import { pageMessagesAtSafeBoundary } from "./messagePaging.js";
|
||||
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
|
||||
import { BUILTIN_COMMANDS } from "./builtinCommands.js";
|
||||
@@ -24,6 +24,7 @@ import type { ActiveSession } from "./sessionRuntimeStore.js";
|
||||
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 type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
|
||||
|
||||
function noop(): void {
|
||||
@@ -34,6 +35,18 @@ function authLossWarningKey(sessionId: string, provider: string, modelId: string
|
||||
return `${sessionId}:${provider}/${modelId}`;
|
||||
}
|
||||
|
||||
function sessionIdFromLookup(ref: PiSessionLookup): string {
|
||||
return typeof ref === "string" ? ref : ref.id;
|
||||
}
|
||||
|
||||
function isPiSessionRef(ref: PiSessionLookup): ref is PiSessionRef {
|
||||
return typeof ref !== "string";
|
||||
}
|
||||
|
||||
function lookupMatchesActiveSession(ref: PiSessionLookup, active: ActiveSession<PiSessionRuntime>): boolean {
|
||||
return !isPiSessionRef(ref) || active.runtime.cwd === ref.cwd;
|
||||
}
|
||||
|
||||
type QueuedPromptKind = "steer" | "followUp";
|
||||
|
||||
interface QueuedPrompt {
|
||||
@@ -42,7 +55,12 @@ interface QueuedPrompt {
|
||||
}
|
||||
|
||||
type SessionArchiveRepository = Pick<SessionArchiveStore, "list" | "get" | "archive" | "restore" | "isArchived">;
|
||||
interface PiSessionListEntry {
|
||||
|
||||
export type PiSessionRef = ClientSessionRef;
|
||||
|
||||
type PiSessionLookup = string | PiSessionRef;
|
||||
|
||||
export interface PiSessionListEntry {
|
||||
id: string;
|
||||
path: string;
|
||||
cwd: string;
|
||||
@@ -74,7 +92,6 @@ export interface PiSessionManager {
|
||||
export interface PiSessionManagerGateway {
|
||||
list(cwd: string): Promise<PiSessionListEntry[]>;
|
||||
create(cwd: string): PiSessionManager;
|
||||
listAll(): Promise<PiSessionListEntry[]>;
|
||||
open(path: string): PiSessionManager;
|
||||
}
|
||||
|
||||
@@ -201,7 +218,7 @@ export class PiSessionService {
|
||||
constructor(private readonly events: SessionEventHub, deps: PiSessionServiceDependencies = {}) {
|
||||
this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
|
||||
this.agentDir = deps.agentDir ?? getAgentDir();
|
||||
this.sessionManager = deps.sessionManager ?? SessionManager;
|
||||
this.sessionManager = deps.sessionManager ?? createPiSessionManagerGateway({ agentDir: this.agentDir });
|
||||
this.modelRegistry = deps.modelRegistry ?? ModelRegistry.create(AuthStorage.create());
|
||||
this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(this.modelRegistry.authStorage, this.modelRegistry);
|
||||
this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime;
|
||||
@@ -277,17 +294,17 @@ export class PiSessionService {
|
||||
};
|
||||
}
|
||||
|
||||
async messages(sessionId: string, page?: { before?: number; limit?: number }): Promise<unknown[] | ClientMessagePage> {
|
||||
const session = await this.getOrOpen(sessionId);
|
||||
async messages(ref: PiSessionLookup, page?: { before?: number; limit?: number }): Promise<unknown[] | ClientMessagePage> {
|
||||
const session = await this.getOrOpen(ref);
|
||||
return pageMessagesAtSafeBoundary(historyMessages(session), page);
|
||||
}
|
||||
|
||||
async status(sessionId: string): Promise<ClientSessionStatus> {
|
||||
return this.statusFromSession(await this.getOrOpen(sessionId));
|
||||
async status(ref: PiSessionLookup): Promise<ClientSessionStatus> {
|
||||
return this.statusFromSession(await this.getOrOpen(ref));
|
||||
}
|
||||
|
||||
async availableModels(sessionId: string): Promise<ClientSessionModel[]> {
|
||||
const session = await this.getOrOpen(sessionId);
|
||||
async availableModels(ref: PiSessionLookup): Promise<ClientSessionModel[]> {
|
||||
const session = await this.getOrOpen(ref);
|
||||
session.modelRegistry.refresh();
|
||||
const models = session.scopedModels.length > 0
|
||||
? session.scopedModels.map((scoped) => scoped.model)
|
||||
@@ -295,9 +312,9 @@ export class PiSessionService {
|
||||
return models.map(modelToClientModel);
|
||||
}
|
||||
|
||||
async setModel(sessionId: string, provider: string, modelId: string): Promise<ClientSessionStatus> {
|
||||
await this.assertWritable(sessionId);
|
||||
const session = await this.getOrOpen(sessionId);
|
||||
async setModel(ref: PiSessionLookup, provider: string, modelId: string): Promise<ClientSessionStatus> {
|
||||
await this.assertWritable(ref);
|
||||
const session = await this.getOrOpen(ref);
|
||||
session.modelRegistry.refresh();
|
||||
const candidates = session.scopedModels.length > 0
|
||||
? session.scopedModels.map((scoped) => scoped.model)
|
||||
@@ -311,9 +328,9 @@ export class PiSessionService {
|
||||
return this.statusFromSession(session);
|
||||
}
|
||||
|
||||
async cycleModel(sessionId: string, direction: "forward" | "backward"): Promise<ClientSessionStatus> {
|
||||
await this.assertWritable(sessionId);
|
||||
const session = await this.getOrOpen(sessionId);
|
||||
async cycleModel(ref: PiSessionLookup, direction: "forward" | "backward"): Promise<ClientSessionStatus> {
|
||||
await this.assertWritable(ref);
|
||||
const session = await this.getOrOpen(ref);
|
||||
const result = await session.cycleModel(direction);
|
||||
if (result === undefined) throw new Error(session.scopedModels.length > 0 ? "Only one model in scope" : "Only one model available");
|
||||
this.publishActivity(session, `model: ${result.model.id}`, "idle", result.model.provider);
|
||||
@@ -321,23 +338,23 @@ export class PiSessionService {
|
||||
return this.statusFromSession(session);
|
||||
}
|
||||
|
||||
async availableThinkingLevels(sessionId: string): Promise<ClientThinkingLevel[]> {
|
||||
const session = await this.getOrOpen(sessionId);
|
||||
async availableThinkingLevels(ref: PiSessionLookup): Promise<ClientThinkingLevel[]> {
|
||||
const session = await this.getOrOpen(ref);
|
||||
return session.getAvailableThinkingLevels();
|
||||
}
|
||||
|
||||
async setThinkingLevel(sessionId: string, level: ClientThinkingLevel): Promise<ClientSessionStatus> {
|
||||
await this.assertWritable(sessionId);
|
||||
const session = await this.getOrOpen(sessionId);
|
||||
async setThinkingLevel(ref: PiSessionLookup, level: ClientThinkingLevel): Promise<ClientSessionStatus> {
|
||||
await this.assertWritable(ref);
|
||||
const session = await this.getOrOpen(ref);
|
||||
session.setThinkingLevel(level);
|
||||
this.publishActivity(session, `thinking: ${session.thinkingLevel}`, "idle");
|
||||
this.publishStatus(session);
|
||||
return this.statusFromSession(session);
|
||||
}
|
||||
|
||||
async cycleThinkingLevel(sessionId: string): Promise<ClientSessionStatus> {
|
||||
await this.assertWritable(sessionId);
|
||||
const session = await this.getOrOpen(sessionId);
|
||||
async cycleThinkingLevel(ref: PiSessionLookup): Promise<ClientSessionStatus> {
|
||||
await this.assertWritable(ref);
|
||||
const session = await this.getOrOpen(ref);
|
||||
const level = session.cycleThinkingLevel();
|
||||
if (level === undefined) throw new Error("Current model does not support thinking");
|
||||
this.publishActivity(session, `thinking: ${level}`, "idle");
|
||||
@@ -345,8 +362,8 @@ export class PiSessionService {
|
||||
return this.statusFromSession(session);
|
||||
}
|
||||
|
||||
async commands(sessionId: string): Promise<ClientCommand[]> {
|
||||
const session = await this.getOrOpen(sessionId);
|
||||
async commands(ref: PiSessionLookup): Promise<ClientCommand[]> {
|
||||
const session = await this.getOrOpen(ref);
|
||||
const commands: ClientCommand[] = [...BUILTIN_COMMANDS];
|
||||
for (const command of session.extensionRunner.getRegisteredCommands()) {
|
||||
commands.push({ name: command.invocationName, ...(command.description === undefined ? {} : { description: command.description }), source: "extension" });
|
||||
@@ -360,9 +377,9 @@ export class PiSessionService {
|
||||
return commands.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
async prompt(sessionId: string, text: string, streamingBehavior?: "steer" | "followUp"): Promise<void> {
|
||||
await this.assertWritable(sessionId);
|
||||
const session = await this.getOrOpen(sessionId);
|
||||
async prompt(ref: PiSessionLookup, text: string, streamingBehavior?: "steer" | "followUp"): Promise<void> {
|
||||
await this.assertWritable(ref);
|
||||
const session = await this.getOrOpen(ref);
|
||||
this.maybeGenerateSessionName(session, text);
|
||||
const isQueued = session.isStreaming || session.isCompacting;
|
||||
const behavior = isQueued ? streamingBehavior ?? "followUp" : undefined;
|
||||
@@ -398,9 +415,9 @@ export class PiSessionService {
|
||||
this.publishStatus(session);
|
||||
}
|
||||
|
||||
async shell(sessionId: string, text: string): Promise<void> {
|
||||
await this.assertWritable(sessionId);
|
||||
const active = await this.getActive(sessionId);
|
||||
async shell(ref: PiSessionLookup, text: string): Promise<void> {
|
||||
await this.assertWritable(ref);
|
||||
const active = await this.getActive(ref);
|
||||
const { session } = active.runtime;
|
||||
const isExcluded = text.startsWith("!!");
|
||||
const command = (isExcluded ? text.slice(2) : text.slice(1)).trim();
|
||||
@@ -433,26 +450,28 @@ export class PiSessionService {
|
||||
});
|
||||
}
|
||||
|
||||
async runCommand(sessionId: string, text: string): Promise<ClientCommandResult> {
|
||||
await this.assertWritable(sessionId);
|
||||
return this.commandService.run(sessionId, text);
|
||||
async runCommand(ref: PiSessionLookup, text: string): Promise<ClientCommandResult> {
|
||||
await this.assertWritable(ref);
|
||||
const active = await this.getActive(ref);
|
||||
return this.commandService.run(active.runtime.session.sessionId, text);
|
||||
}
|
||||
|
||||
async respondToCommand(sessionId: string, requestId: string, value: string): Promise<ClientCommandResult> {
|
||||
await this.assertWritable(sessionId);
|
||||
return this.commandService.respond(sessionId, requestId, value);
|
||||
async respondToCommand(ref: PiSessionLookup, requestId: string, value: string): Promise<ClientCommandResult> {
|
||||
await this.assertWritable(ref);
|
||||
const active = await this.getActive(ref);
|
||||
return this.commandService.respond(active.runtime.session.sessionId, requestId, value);
|
||||
}
|
||||
|
||||
async archive(sessionId: string): Promise<void> {
|
||||
const session = await this.getOrOpen(sessionId);
|
||||
async archive(ref: PiSessionLookup): Promise<void> {
|
||||
const session = await this.getOrOpen(ref);
|
||||
if (this.hasActiveWork(session)) throw new Error("Stop current session activity before archiving");
|
||||
const archiveInput = await this.archiveInputForSession(session);
|
||||
await this.closeActive(session.sessionId);
|
||||
await this.archiveStore.archive(archiveInput);
|
||||
}
|
||||
|
||||
async archiveTree(sessionId: string): Promise<ClientArchiveSessionsResponse> {
|
||||
const session = await this.getOrOpen(sessionId);
|
||||
async archiveTree(ref: PiSessionLookup): Promise<ClientArchiveSessionsResponse> {
|
||||
const session = await this.getOrOpen(ref);
|
||||
const catalog = await this.workspaceArchiveCandidates(session.sessionManager.getCwd());
|
||||
const root = findArchiveCandidateByIdOrPrefix(catalog, session.sessionId) ?? archiveCandidateFromActiveSession(session, false);
|
||||
const plan = planSessionArchiveTree(root, catalog);
|
||||
@@ -471,21 +490,24 @@ export class PiSessionService {
|
||||
};
|
||||
}
|
||||
|
||||
async restore(sessionId: string): Promise<void> {
|
||||
await this.closeActive(sessionId);
|
||||
await this.archiveStore.restore(sessionId);
|
||||
async restore(ref: PiSessionLookup): Promise<void> {
|
||||
const archived = await this.getArchived(ref);
|
||||
if (archived === undefined) throw new Error("Session not found");
|
||||
await this.closeActive(archived.sessionId);
|
||||
await this.archiveStore.restore(archived.sessionId);
|
||||
}
|
||||
|
||||
async detachParent(sessionId: string): Promise<void> {
|
||||
const session = await this.getOrOpen(sessionId);
|
||||
async detachParent(ref: PiSessionLookup): Promise<void> {
|
||||
const session = await this.getOrOpen(ref);
|
||||
const sessionFile = session.sessionFile;
|
||||
if (sessionFile === undefined || sessionFile === "") throw new Error("Session is not persisted");
|
||||
await clearParentSession(sessionFile);
|
||||
}
|
||||
|
||||
async abort(sessionId: string): Promise<void> {
|
||||
const active = this.active.get(sessionId);
|
||||
if (!active) return;
|
||||
async abort(ref: PiSessionLookup): Promise<void> {
|
||||
const active = this.activeForLookup(ref);
|
||||
if (active === undefined) return;
|
||||
const sessionId = active.runtime.session.sessionId;
|
||||
this.clearCompactionPromptQueue(sessionId);
|
||||
clearSessionQueue(active.runtime.session);
|
||||
await active.runtime.session.abort();
|
||||
@@ -493,8 +515,10 @@ export class PiSessionService {
|
||||
this.publishStatus(active.runtime.session);
|
||||
}
|
||||
|
||||
stop(sessionId: string): void {
|
||||
void this.closeActive(sessionId).catch(() => {
|
||||
stop(ref: PiSessionLookup): void {
|
||||
const active = this.activeForLookup(ref);
|
||||
if (active === undefined) return;
|
||||
void this.closeActive(active.runtime.session.sessionId).catch(() => {
|
||||
// Best-effort shutdown; callers that need errors await closeActive directly.
|
||||
});
|
||||
}
|
||||
@@ -591,26 +615,44 @@ export class PiSessionService {
|
||||
}
|
||||
}
|
||||
|
||||
private async assertWritable(sessionId: string): Promise<void> {
|
||||
if (await this.archiveStore.isArchived(sessionId)) throw new Error("Archived sessions are read-only. Restore the session to continue.");
|
||||
private async assertWritable(ref: PiSessionLookup): Promise<void> {
|
||||
if (await this.getArchived(ref) !== undefined) throw new Error("Archived sessions are read-only. Restore the session to continue.");
|
||||
}
|
||||
|
||||
private async getOrOpen(sessionId: string): Promise<PiAgentSession> {
|
||||
return (await this.getActive(sessionId)).runtime.session;
|
||||
private async getOrOpen(ref: PiSessionLookup): Promise<PiAgentSession> {
|
||||
return (await this.getActive(ref)).runtime.session;
|
||||
}
|
||||
|
||||
private async getActive(sessionId: string): Promise<ActiveSession<PiSessionRuntime>> {
|
||||
const active = this.active.get(sessionId);
|
||||
if (active) return active;
|
||||
private async getActive(ref: PiSessionLookup): Promise<ActiveSession<PiSessionRuntime>> {
|
||||
const active = this.activeForLookup(ref);
|
||||
if (active !== undefined) return active;
|
||||
|
||||
const archived = await this.archiveStore.get(sessionId);
|
||||
const archived = await this.getArchived(ref);
|
||||
if (archived?.archivePath !== undefined) return this.create(this.sessionManager.open(archived.archivePath), archived.cwd);
|
||||
|
||||
const match = (await this.sessionManager.listAll()).find((s) => s.id === sessionId || s.id.startsWith(sessionId));
|
||||
if (!isPiSessionRef(ref)) throw new Error("Session not found");
|
||||
const match = (await this.sessionManager.list(ref.cwd)).find((s) => s.id === ref.id || s.id.startsWith(ref.id));
|
||||
if (!match) throw new Error("Session not found");
|
||||
return this.create(this.sessionManager.open(match.path), match.cwd);
|
||||
}
|
||||
|
||||
private async getArchived(ref: PiSessionLookup): Promise<ArchivedSessionRecord | undefined> {
|
||||
const archived = await this.archiveStore.get(sessionIdFromLookup(ref));
|
||||
if (archived === undefined) return undefined;
|
||||
if (isPiSessionRef(ref) && archived.cwd !== ref.cwd) return undefined;
|
||||
return archived;
|
||||
}
|
||||
|
||||
private activeForLookup(ref: PiSessionLookup): ActiveSession<PiSessionRuntime> | undefined {
|
||||
const sessionId = sessionIdFromLookup(ref);
|
||||
const exact = this.active.get(sessionId);
|
||||
if (exact !== undefined && lookupMatchesActiveSession(ref, exact)) return exact;
|
||||
for (const [candidateId, active] of this.active.entries()) {
|
||||
if (candidateId.startsWith(sessionId) && lookupMatchesActiveSession(ref, active)) return active;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async create(sessionManager: PiSessionManager, cwd: string): Promise<ActiveSession<PiSessionRuntime>> {
|
||||
const runtime = await this.createAgentRuntime(this.createRuntime, { cwd, agentDir: this.agentDir, sessionManager });
|
||||
const active: ActiveSession<PiSessionRuntime> = { runtime, unsubscribe: noop };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user