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
+1 -1
View File
@@ -24,7 +24,7 @@ export function registerSessionProxyRoutes(app: FastifyInstance, daemon: Session
app.get(`${prefix}/sessiond/health`, (_request, reply) => proxy({ method: "GET", url: `${prefix}/health` }, reply));
app.get<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/events`, { websocket: true }, (socket, request) => {
bridgeSockets(socket, daemon.connectWebSocket(`/sessions/${request.params.sessionId}/events`));
bridgeSockets(socket, daemon.connectWebSocket(stripPrefix(request.url, prefix)));
});
app.get(`${prefix}/sessions/events`, { websocket: true }, (socket) => {
@@ -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;
}
+25 -26
View File
@@ -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();
+103 -61
View File
@@ -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 };
+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);
}
+1
View File
@@ -1,6 +1,7 @@
export type {
Project,
Workspace,
SessionRef as ClientSessionRef,
SessionInfo as ClientSession,
ArchiveSessionsResponse as ClientArchiveSessionsResponse,
MessagePage as ClientMessagePage,