Merge remote-tracking branch 'origin/main' into feat/docker-runtime-host-admin

# Conflicts:
#	src/client/src/api.ts
#	src/client/src/api/parsers.test.ts
#	src/shared/piWebStatusParsing.test.ts
This commit is contained in:
Pi Web Agent
2026-07-02 20:52:50 +00:00
155 changed files with 11092 additions and 1306 deletions
+219 -4
View File
@@ -11,11 +11,13 @@ import { RemoteMachineRequestError, type MachineClient } from "./machines/machin
import { MachineService } from "./machines/machineService.js";
import { MachineStore } from "./machines/machineStore.js";
import { WorkspaceService } from "./workspaces/workspaceService.js";
import type { PiPackageService } from "./piPackageService.js";
import type { SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js";
import { PI_WEB_CAPABILITIES } from "../shared/capabilities.js";
import { PI_PACKAGE_MUTATION_PROXY_TIMEOUT_MS } from "../shared/federatedRoutes.js";
import { machineScopedPluginId } from "../shared/machinePluginIds.js";
import { MAX_IMAGE_PREVIEW_BYTES } from "../shared/workspaceFiles.js";
import type { PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
import type { PiPackageInfo, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
import type { Project, Workspace } from "./types.js";
let app: FastifyInstance;
@@ -23,6 +25,7 @@ let tempDir: string;
let projectDir: string;
let remoteClient: MachineClient | undefined;
let sessionDaemonRequests: CapturedSessionDaemonRequest[];
let piPackageRequests: CapturedPiPackageRequest[];
let piWebConfig: PiWebConfigValues;
beforeEach(async () => {
@@ -30,6 +33,7 @@ beforeEach(async () => {
projectDir = join(tempDir, "project");
remoteClient = undefined;
sessionDaemonRequests = [];
piPackageRequests = [];
piWebConfig = {};
app = await buildApp({
projects: new ProjectService(new ProjectStore(join(tempDir, "projects.json"))),
@@ -52,6 +56,7 @@ beforeEach(async () => {
}),
sessionDaemon: fakeSessionDaemon(),
config: fakeConfigService(),
piPackages: fakePiPackageService(),
piWebPlugins: {
manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] }),
plugins: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] }),
@@ -122,10 +127,10 @@ describe("buildApp", () => {
packageName: "@jmfederico/pi-web",
generatedAt: "2026-05-25T00:00:00.000Z",
components: {
web: { component: "web", label: "Remote Web", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
web: { component: "web", label: "Remote Web", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"] },
sessiond: { component: "sessiond", label: "Remote Sessiond", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
},
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived],
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"],
},
}));
remoteClient = fakeRemoteClient({ requestJson });
@@ -133,7 +138,7 @@ describe("buildApp", () => {
const runtime = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/runtime` });
expect(runtime.statusCode).toBe(200);
expect(runtime.json()).toMatchObject({ machineId: remote.id, ok: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] });
expect(runtime.json()).toMatchObject({ machineId: remote.id, ok: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage] });
expect(requestJson).toHaveBeenCalledWith("GET", "/api/pi-web/runtime", undefined, { timeoutMs: 3000 });
});
@@ -155,6 +160,103 @@ describe("buildApp", () => {
expect(request).toHaveBeenCalledWith("GET", "/api/projects?active=true", undefined);
});
it("filters remote selected-machine config reads to machine-safe keys", async () => {
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const requestJson = vi.fn<MachineClient["requestJson"]>(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json", "set-cookie": "secret=1" },
body: piWebConfigResponse(fullPiWebConfig()),
}));
remoteClient = fakeRemoteClient({ requestJson });
const response = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/config` });
expect(response.statusCode).toBe(200);
expect(response.headers["set-cookie"]).toBeUndefined();
expect(response.json<PiWebConfigResponse>()).toEqual({
...piWebConfigResponse(fullPiWebConfig()),
config: selectedMachinePiWebConfig(),
effectiveConfig: selectedMachinePiWebConfig(),
});
expect(requestJson).toHaveBeenCalledWith("GET", "/api/config");
});
it("merges remote selected-machine config updates into the target machine config", async () => {
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const requestJson = vi.fn<MachineClient["requestJson"]>((method, _path, body) => {
if (method === "GET") return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(fullPiWebConfig()) });
return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(configFromMachineConfigWriteBody(body)) });
});
remoteClient = fakeRemoteClient({ requestJson });
const response = await app.inject({
method: "PUT",
url: `/api/machines/${remote.id}/config`,
payload: { config: { plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/srv/remote"] }, uploads: { defaultFolder: "remote\\uploads" }, maxUploadBytes: 4096, spawnSessions: true } },
});
const expectedMerged: PiWebConfigValues = {
...fullPiWebConfig(),
plugins: { info: { enabled: false } },
pathAccess: { allowedPaths: ["/srv/remote"] },
uploads: { defaultFolder: "remote/uploads" },
maxUploadBytes: 4096,
spawnSessions: true,
};
expect(response.statusCode).toBe(200);
expect(requestJson).toHaveBeenNthCalledWith(1, "GET", "/api/config");
expect(requestJson).toHaveBeenNthCalledWith(2, "PUT", "/api/config", { config: expectedMerged });
expect(response.json<PiWebConfigResponse>().config).toEqual({
plugins: { info: { enabled: false } },
pathAccess: { allowedPaths: ["/srv/remote"] },
uploads: { defaultFolder: "remote/uploads" },
maxUploadBytes: 4096,
spawnSessions: true,
subsessions: false,
});
});
it("rejects unsafe remote selected-machine config keys before proxying", async () => {
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const requestJson = vi.fn<MachineClient["requestJson"]>();
remoteClient = fakeRemoteClient({ requestJson });
const response = await app.inject({
method: "PUT",
url: `/api/machines/${remote.id}/config`,
payload: { config: { host: "0.0.0.0", allowedHosts: true, shortcuts: { "core:view.chat": "mod+1" }, spawnSessions: true } },
});
expect(response.statusCode).toBe(400);
expect(response.json<{ error: string }>().error).toContain("PI WEB selected-machine config key is not allowed: host");
expect(requestJson).not.toHaveBeenCalled();
});
it("proxies remote Pi package routes and gives package mutations a longer timeout", async () => {
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const request = vi.fn<MachineClient["request"]>((method, path, body) => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: Readable.from([JSON.stringify({ method, path, body })]),
}));
remoteClient = fakeRemoteClient({ request });
const listResponse = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/pi-packages` });
const installBody = { source: "npm:@acme/new-tools" };
const installResponse = await app.inject({ method: "POST", url: `/api/machines/${remote.id}/pi-packages/install`, payload: installBody });
expect(listResponse.statusCode).toBe(200);
expect(listResponse.json()).toEqual({ method: "GET", path: "/api/pi-packages" });
expect(installResponse.statusCode).toBe(200);
expect(installResponse.json()).toEqual({ method: "POST", path: "/api/pi-packages/install", body: installBody });
expect(request).toHaveBeenNthCalledWith(1, "GET", "/api/pi-packages", undefined);
expect(request).toHaveBeenNthCalledWith(2, "POST", "/api/pi-packages/install", installBody, { timeoutMs: PI_PACKAGE_MUTATION_PROXY_TIMEOUT_MS });
});
it("proxies remote workspace effective upload config through the existing federated workspace route", async () => {
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
@@ -389,6 +491,25 @@ describe("buildApp", () => {
expect(workspacesResponse.json<Workspace[]>()).toEqual([expect.objectContaining({ projectId: project.id, path: projectDir })]);
});
it("serves Pi package management routes through the app wiring", async () => {
const listResponse = await app.inject({ method: "GET", url: "/api/pi-packages" });
expect(listResponse.statusCode).toBe(200);
expect(listResponse.json()).toEqual({ packages: [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/tmp/pi-tools" }] });
const installResponse = await app.inject({ method: "POST", url: "/api/pi-packages/install", payload: { source: "npm:@acme/new-tools" } });
expect(installResponse.statusCode).toBe(200);
expect(installResponse.json()).toMatchObject({ action: "install", source: "npm:@acme/new-tools" });
const localAliasResponse = await app.inject({ method: "POST", url: "/api/machines/local/pi-packages/remove", payload: { source: "npm:@acme/tools", scope: "user" } });
expect(localAliasResponse.statusCode).toBe(200);
expect(localAliasResponse.json()).toMatchObject({ action: "remove", source: "npm:@acme/tools", scope: "user" });
expect(piPackageRequests).toEqual([
{ action: "list" },
{ action: "install", source: "npm:@acme/new-tools" },
{ action: "remove", source: "npm:@acme/tools", scope: "user" },
]);
});
it("serves the PI WEB plugin manifest and plugin assets", async () => {
const manifestResponse = await app.inject({ method: "GET", url: "/pi-web-plugins/manifest.json" });
expect(manifestResponse.statusCode).toBe(200);
@@ -398,6 +519,10 @@ describe("buildApp", () => {
expect(pluginsResponse.statusCode).toBe(200);
expect(pluginsResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] });
const localMachinePluginsResponse = await app.inject({ method: "GET", url: "/api/machines/local/plugins" });
expect(localMachinePluginsResponse.statusCode).toBe(200);
expect(localMachinePluginsResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] });
const assetResponse = await app.inject({ method: "GET", url: "/pi-web-plugins/fake/plugin.js?v=1" });
expect(assetResponse.statusCode).toBe(200);
expect(assetResponse.headers["content-type"]).toContain("application/javascript");
@@ -407,6 +532,24 @@ describe("buildApp", () => {
expect(missingResponse.statusCode).toBe(404);
});
it("proxies remote machine plugin lists for settings", async () => {
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const request = vi.fn(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json", "set-cookie": "secret=1" },
body: Readable.from([JSON.stringify({ plugins: [{ id: "remote-tools", module: "/pi-web-plugins/remote-tools/plugin.js", source: "local", scope: "local", machineSpecific: false, enabled: false }] })]),
}));
remoteClient = fakeRemoteClient({ request });
const response = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/plugins` });
expect(response.statusCode).toBe(200);
expect(response.headers["set-cookie"]).toBeUndefined();
expect(response.json()).toEqual({ plugins: [{ id: "remote-tools", module: "/pi-web-plugins/remote-tools/plugin.js", source: "local", scope: "local", machineSpecific: false, enabled: false }] });
expect(request).toHaveBeenCalledWith("GET", "/api/plugins", undefined);
});
it("rewrites and proxies remote machine plugin manifests and assets", async () => {
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
@@ -885,6 +1028,12 @@ interface CapturedSessionDaemonRequest {
body?: unknown;
}
interface CapturedPiPackageRequest {
action: "list" | "install" | "remove" | "update";
source?: string;
scope?: "user" | "project";
}
function fakeConfigService() {
return {
read: () => piWebConfigResponse(piWebConfig),
@@ -895,6 +1044,32 @@ function fakeConfigService() {
};
}
function fullPiWebConfig(): PiWebConfigValues {
return {
host: "127.0.0.1",
port: 8504,
allowedHosts: ["gateway.example.test"],
shortcuts: { "core:view.chat": "mod+1" },
plugins: { info: { enabled: true, settings: { note: "remote" } } },
pathAccess: { allowedPaths: ["/srv/repos"] },
uploads: { defaultFolder: "uploads" },
maxUploadBytes: 1024,
spawnSessions: false,
subsessions: false,
};
}
function selectedMachinePiWebConfig(): PiWebConfigValues {
return {
plugins: { info: { enabled: true, settings: { note: "remote" } } },
pathAccess: { allowedPaths: ["/srv/repos"] },
uploads: { defaultFolder: "uploads" },
maxUploadBytes: 1024,
spawnSessions: false,
subsessions: false,
};
}
function piWebConfigResponse(config: PiWebConfigValues): PiWebConfigResponse {
return {
path: join(tempDir, "config.json"),
@@ -905,6 +1080,46 @@ function piWebConfigResponse(config: PiWebConfigValues): PiWebConfigResponse {
};
}
interface MachineConfigWriteBody {
config: PiWebConfigValues;
}
function configFromMachineConfigWriteBody(body: unknown): PiWebConfigValues {
if (!isMachineConfigWriteBody(body)) throw new Error("Expected machine config write body");
return body.config;
}
function isMachineConfigWriteBody(value: unknown): value is MachineConfigWriteBody {
if (!isRecord(value)) return false;
return isRecord(value["config"]);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function fakePiPackageService(): PiPackageService {
const packages: PiPackageInfo[] = [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/tmp/pi-tools" }];
return {
list: () => {
piPackageRequests.push({ action: "list" });
return Promise.resolve({ packages });
},
install: (source) => {
piPackageRequests.push({ action: "install", source });
return Promise.resolve({ action: "install", source, packages });
},
remove: (source, scope = "user") => {
piPackageRequests.push({ action: "remove", source, scope });
return Promise.resolve({ action: "remove", source, scope, removed: true, packages });
},
update: (source) => {
piPackageRequests.push({ action: "update", ...(source === undefined ? {} : { source }) });
return Promise.resolve({ action: "update", ...(source === undefined ? {} : { source }), packages });
},
};
}
function fakeSessionDaemon(): SessionProxyDaemon {
return {
request: (method, path, body) => {
+9 -1
View File
@@ -18,8 +18,10 @@ import { registerWorkspaceExplorerRoutes } from "./workspaceExplorerRoutes.js";
import { registerGitRoutes } from "./gitRoutes.js";
import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js";
import { registerWorkspaceDeletionRoutes } from "./workspaces/workspaceDeletionRoutes.js";
import { createFilePiWebConfigService, registerConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
import { createFilePiWebConfigService, registerConfigRoutes, registerLocalMachineConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
import { PiWebPluginService } from "./piWebPluginService.js";
import { createDefaultPiPackageService, type PiPackageService } from "./piPackageService.js";
import { registerPiPackageRoutes } from "./piPackageRoutes.js";
import { createPiWebStatusCache } from "./piWebStatusCache.js";
import { getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
import { MachineService } from "./machines/machineService.js";
@@ -34,6 +36,7 @@ export interface AppDependencies {
machines?: MachineService;
sessionDaemon?: SessionProxyDaemon;
piWebPlugins?: Pick<PiWebPluginService, "manifest" | "plugins" | "readAsset">;
piPackages?: PiPackageService;
config?: PiWebConfigService;
clientDist?: string | false;
logger?: FastifyServerOptions["logger"];
@@ -122,6 +125,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
const projects = deps.projects ?? new ProjectService(new ProjectStore());
const workspaces = deps.workspaces ?? new WorkspaceService();
const piWebPlugins = deps.piWebPlugins ?? new PiWebPluginService();
const piPackages = deps.piPackages ?? createDefaultPiPackageService();
const configService = deps.config ?? createFilePiWebConfigService();
const sessionDaemon = deps.sessionDaemon ?? new SessionDaemonClient();
const piWebStatusCache = createPiWebStatusCache(() => getPiWebStatus(sessionDaemon), {
@@ -145,7 +149,11 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
app.get("/api/pi-web/version", async () => getPiWebVersionStatus(sessionDaemon));
app.get("/api/pi-web/runtime", async () => getPiWebRuntime(sessionDaemon));
app.get("/api/plugins", async () => piWebPlugins.plugins());
app.get("/api/machines/local/plugins", async () => piWebPlugins.plugins());
registerPiPackageRoutes(app, piPackages);
registerPiPackageRoutes(app, piPackages, "/api/machines/local");
registerConfigRoutes(app, configService);
registerLocalMachineConfigRoutes(app, configService);
registerMachineRoutes(app, machines);
registerMachinePluginProxyRoutes(app, machines);
+96 -1
View File
@@ -1,6 +1,6 @@
import Fastify, { type FastifyInstance } from "fastify";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
import { registerConfigRoutes, registerLocalMachineConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
import type { PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
let app: FastifyInstance;
@@ -18,6 +18,7 @@ beforeEach(async () => {
};
app = Fastify({ logger: false });
registerConfigRoutes(app, service);
registerLocalMachineConfigRoutes(app, service);
await app.ready();
});
@@ -92,8 +93,102 @@ describe("config routes", () => {
expect(response.json()).toHaveProperty("error");
expect(service.write).not.toHaveBeenCalled();
});
it("filters local machine config reads to selected-machine-safe keys", async () => {
savedConfig = fullConfig();
const response = await app.inject({ method: "GET", url: "/api/machines/local/config" });
expect(response.statusCode).toBe(200);
expect(response.json<PiWebConfigResponse>()).toEqual({
...responseFor(savedConfig, true),
config: selectedMachineConfig(),
effectiveConfig: selectedMachineConfig(),
});
});
it("merges local selected-machine config updates without dropping gateway-only keys", async () => {
savedConfig = fullConfig();
const response = await app.inject({
method: "PUT",
url: "/api/machines/local/config",
payload: { config: { plugins: { info: { enabled: false } }, uploads: { defaultFolder: "uploads\\manual" }, spawnSessions: true } },
});
const expectedConfig: PiWebConfigValues = {
...fullConfig(),
plugins: { info: { enabled: false } },
uploads: { defaultFolder: "uploads/manual" },
spawnSessions: true,
};
expect(response.statusCode).toBe(200);
expect(savedConfig).toEqual(expectedConfig);
expect(service.write).toHaveBeenCalledWith(expectedConfig);
expect(response.json<PiWebConfigResponse>().config).toEqual({
plugins: { info: { enabled: false } },
pathAccess: { allowedPaths: ["/srv/repos"] },
uploads: { defaultFolder: "uploads/manual" },
maxUploadBytes: 1024,
spawnSessions: true,
subsessions: false,
});
});
it("rejects unsafe local selected-machine config keys before writing", async () => {
savedConfig = fullConfig();
const response = await app.inject({
method: "PUT",
url: "/api/machines/local/config",
payload: { config: { host: "0.0.0.0", spawnSessions: true } },
});
expect(response.statusCode).toBe(400);
expect(response.json<{ error: string }>().error).toContain("PI WEB selected-machine config key is not allowed: host");
expect(savedConfig).toEqual(fullConfig());
expect(service.write).not.toHaveBeenCalled();
});
it("rejects invalid local selected-machine config values before writing", async () => {
const response = await app.inject({
method: "PUT",
url: "/api/machines/local/config",
payload: { config: { spawnSessions: "yes" } },
});
expect(response.statusCode).toBe(400);
expect(response.json<{ error: string }>().error).toContain("PI WEB selected-machine config spawnSessions must be a boolean");
expect(service.write).not.toHaveBeenCalled();
});
});
function fullConfig(): PiWebConfigValues {
return {
host: "127.0.0.1",
port: 8504,
allowedHosts: ["gateway.example.test"],
shortcuts: { "core:view.chat": "mod+1" },
plugins: { info: { enabled: true, settings: { note: "visible" } } },
pathAccess: { allowedPaths: ["/srv/repos"] },
uploads: { defaultFolder: "uploads" },
maxUploadBytes: 1024,
spawnSessions: false,
subsessions: false,
};
}
function selectedMachineConfig(): PiWebConfigValues {
return {
plugins: { info: { enabled: true, settings: { note: "visible" } } },
pathAccess: { allowedPaths: ["/srv/repos"] },
uploads: { defaultFolder: "uploads" },
maxUploadBytes: 1024,
spawnSessions: false,
subsessions: false,
};
}
function responseFor(config: PiWebConfigValues, exists: boolean): PiWebConfigResponse {
return {
path: "/tmp/pi-web/config.json",
+113 -1
View File
@@ -8,6 +8,17 @@ export interface PiWebConfigService {
write: (config: PiWebConfigValues) => PiWebConfigResponse | Promise<PiWebConfigResponse>;
}
export const SELECTED_MACHINE_CONFIG_KEYS = [
"plugins",
"pathAccess",
"uploads",
"maxUploadBytes",
"spawnSessions",
"subsessions",
] as const satisfies readonly (keyof PiWebConfigValues)[];
const SELECTED_MACHINE_CONFIG_KEY_SET = new Set<string>(SELECTED_MACHINE_CONFIG_KEYS);
export function createFilePiWebConfigService(options: LoadOptions = {}): PiWebConfigService {
return {
read: () => currentPiWebConfigResponse(options),
@@ -50,6 +61,62 @@ export function registerConfigRoutes(app: FastifyInstance, service: PiWebConfigS
});
}
export function registerLocalMachineConfigRoutes(app: FastifyInstance, service: PiWebConfigService = createFilePiWebConfigService()): void {
app.get("/api/machines/local/config", async (_request, reply) => {
try {
return selectedMachineConfigResponse(await service.read());
} catch (error) {
return reply.code(500).send({ error: errorMessage(error) });
}
});
app.put<{ Body: { config?: unknown } | undefined }>("/api/machines/local/config", async (request, reply) => {
try {
const current = await service.read();
const patch = parseSelectedMachineConfigRequest(request.body?.config);
return selectedMachineConfigResponse(await service.write(mergeSelectedMachineConfig(current.config, patch)));
} catch (error) {
const status = isConfigValidationError(error) ? 400 : 500;
return reply.code(status).send({ error: errorMessage(error) });
}
});
}
export function parseSelectedMachineConfigRequest(value: unknown): PiWebConfig {
if (!isRecord(value)) throw new Error("PI WEB selected-machine config update must include a config object");
for (const key of Object.keys(value)) {
if (!SELECTED_MACHINE_CONFIG_KEY_SET.has(key)) throw new Error(`PI WEB selected-machine config key is not allowed: ${key}`);
}
try {
return pickSelectedMachineConfig(parseConfigRequest(value));
} catch (error) {
throw new Error(selectedMachineConfigErrorMessage(error), { cause: error });
}
}
export function mergeSelectedMachineConfig(current: PiWebConfigValues, patch: PiWebConfigValues): PiWebConfig {
return { ...current, ...pickSelectedMachineConfig(patch) };
}
export function selectedMachineConfigResponse(response: PiWebConfigResponse): PiWebConfigResponse {
return {
...response,
config: pickSelectedMachineConfig(response.config),
effectiveConfig: pickSelectedMachineConfig(response.effectiveConfig),
};
}
export function parsePiWebConfigResponseBody(value: unknown, source = "PI WEB config response"): PiWebConfigResponse {
const record = requireResponseRecord(value, source);
return {
path: requireResponseString(record, "path", source),
exists: requireResponseBoolean(record, "exists", source),
config: parseConfigRequest(record["config"]),
effectiveConfig: parseConfigRequest(record["effectiveConfig"]),
envOverrides: parsePiWebConfigEnvOverridesResponse(record["envOverrides"], source),
};
}
function parseConfigRequest(value: unknown): PiWebConfig {
if (!isRecord(value)) throw new Error("PI WEB config update must include a config object");
const config: PiWebConfig = {};
@@ -88,6 +155,23 @@ function parseConfigRequest(value: unknown): PiWebConfig {
return config;
}
function pickSelectedMachineConfig(config: PiWebConfigValues): PiWebConfig {
return {
...(config.plugins !== undefined ? { plugins: config.plugins } : {}),
...(config.pathAccess !== undefined ? { pathAccess: config.pathAccess } : {}),
...(config.uploads !== undefined ? { uploads: config.uploads } : {}),
...(config.maxUploadBytes !== undefined ? { maxUploadBytes: config.maxUploadBytes } : {}),
...(config.spawnSessions !== undefined ? { spawnSessions: config.spawnSessions } : {}),
...(config.subsessions !== undefined ? { subsessions: config.subsessions } : {}),
};
}
function selectedMachineConfigErrorMessage(error: unknown): string {
const message = errorMessage(error);
if (message.startsWith("PI WEB config ")) return `PI WEB selected-machine config ${message.slice("PI WEB config ".length)}`;
return `PI WEB selected-machine config ${message}`;
}
function parseAllowedHostsRequest(value: unknown): string[] | true {
if (value === true) return true;
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
@@ -141,6 +225,34 @@ function parsePluginsRequest(value: unknown): NonNullable<PiWebConfig["plugins"]
}));
}
function parsePiWebConfigEnvOverridesResponse(value: unknown, source: string): PiWebConfigEnvOverrides {
const record = requireResponseRecord(value, `${source} envOverrides`);
return {
host: requireResponseBoolean(record, "host", source),
port: requireResponseBoolean(record, "port", source),
allowedHosts: requireResponseBoolean(record, "allowedHosts", source),
spawnSessions: requireResponseBoolean(record, "spawnSessions", source),
subsessions: requireResponseBoolean(record, "subsessions", source),
};
}
function requireResponseRecord(value: unknown, source: string): Record<string, unknown> {
if (!isRecord(value)) throw new Error(`${source} must be an object`);
return value;
}
function requireResponseString(record: Record<string, unknown>, key: string, source: string): string {
const value = record[key];
if (typeof value !== "string") throw new Error(`${source} field must be a string: ${key}`);
return value;
}
function requireResponseBoolean(record: Record<string, unknown>, key: string, source: string): boolean {
const value = record[key];
if (typeof value !== "boolean") throw new Error(`${source} field must be a boolean: ${key}`);
return value;
}
function piWebConfigEnvOverrides(env: NodeJS.ProcessEnv): PiWebConfigEnvOverrides {
return {
host: isEnvSet(env["PI_WEB_HOST"]),
@@ -156,7 +268,7 @@ function isEnvSet(value: string | undefined): boolean {
}
function isConfigValidationError(error: unknown): boolean {
return error instanceof Error && error.message.startsWith("PI WEB config");
return error instanceof Error && (error.message.startsWith("PI WEB config") || error.message.startsWith("PI WEB selected-machine config"));
}
function errorMessage(error: unknown): string {
+72 -12
View File
@@ -1,8 +1,9 @@
import type { FastifyInstance, FastifyReply } from "fastify";
import type { WebSocket } from "ws";
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES } from "../../shared/federatedRoutes.js";
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES, type FederatedHttpRouteSpec } from "../../shared/federatedRoutes.js";
import { mergeSelectedMachineConfig, parsePiWebConfigResponseBody, parseSelectedMachineConfigRequest, selectedMachineConfigResponse } from "../configRoutes.js";
import { bridgeSockets } from "../webSocketBridge.js";
import { RemoteMachineRequestError, type MachineRequestOptions } from "./machineClient.js";
import { RemoteMachineRequestError, type MachineClient, type MachineJsonResponse, type MachineRequestOptions } from "./machineClient.js";
import { MachineService } from "./machineService.js";
export const REMOTE_HTTP_ROUTES = FEDERATED_HTTP_ROUTES;
@@ -23,7 +24,7 @@ export function registerMachineProxyRoutes(app: FastifyInstance, machines = new
app.route<{ Params: { machineId: string }; Body: unknown }>({
method: spec.method,
url: `/api/machines/:machineId${spec.path}`,
handler: (request, reply) => proxyHttpRequest(machines, request.params.machineId, request.method, request.url, request.body, request.headers["content-type"], reply),
handler: (request, reply) => proxyHttpRequest(machines, spec, request.params.machineId, request.method, request.url, request.body, request.headers["content-type"], reply),
});
}
@@ -34,7 +35,7 @@ export function registerMachineProxyRoutes(app: FastifyInstance, machines = new
}
}
async function proxyHttpRequest(machines: MachineService, machineId: string, method: string, requestUrl: string, body: unknown, contentType: string | string[] | undefined, reply: FastifyReply): Promise<FastifyReply> {
async function proxyHttpRequest(machines: MachineService, spec: FederatedHttpRouteSpec, machineId: string, method: string, requestUrl: string, body: unknown, contentType: string | string[] | undefined, reply: FastifyReply): Promise<FastifyReply> {
if (machineId === "local") {
return reply.code(501).send({ error: "Local machine route is not registered for this endpoint" });
}
@@ -45,19 +46,62 @@ async function proxyHttpRequest(machines: MachineService, machineId: string, met
}
try {
const requestOptions = proxyRequestOptions(body, contentType);
const remotePath = remoteApiPath(machineId, requestUrl);
if (spec.path === "/config") return await proxySelectedMachineConfigRequest(client, machineId, method, remotePath, body, reply);
const requestOptions = proxyRequestOptions(spec, body, contentType);
const upstream = requestOptions === undefined
? await client.request(method, remoteApiPath(machineId, requestUrl), body)
: await client.request(method, remoteApiPath(machineId, requestUrl), body, requestOptions);
? await client.request(method, remotePath, body)
: await client.request(method, remotePath, body, requestOptions);
reply.code(upstream.statusCode);
applySafeHeaders(reply, upstream.headers);
if (upstream.body === undefined) return await reply.send();
return await reply.send(upstream.body);
} catch (error) {
if (isSelectedMachineConfigRequestError(error)) return reply.code(400).send({ error: errorMessage(error) });
return sendGatewayError(reply, machineId, error);
}
}
async function proxySelectedMachineConfigRequest(client: MachineClient, machineId: string, method: string, remotePath: string, body: unknown, reply: FastifyReply): Promise<FastifyReply> {
if (method === "GET") {
return sendSelectedMachineConfigResponse(reply, await client.requestJson("GET", remotePath), machineId);
}
if (method === "PUT") {
const patch = parseSelectedMachineConfigRequest(configPayload(body));
const currentResponse = await client.requestJson("GET", remotePath);
if (!isSuccessfulStatus(currentResponse.statusCode)) return sendUpstreamJsonResponse(reply, currentResponse, machineId);
const current = parsePiWebConfigResponseBody(currentResponse.body, "Remote machine config response");
const merged = mergeSelectedMachineConfig(current.config, patch);
return sendSelectedMachineConfigResponse(reply, await client.requestJson("PUT", remotePath, { config: merged }), machineId);
}
return reply.code(405).send({ error: "Method not allowed" });
}
function configPayload(body: unknown): unknown {
return isRecord(body) ? body["config"] : undefined;
}
function sendSelectedMachineConfigResponse(reply: FastifyReply, upstream: MachineJsonResponse, machineId: string): FastifyReply {
if (!isSuccessfulStatus(upstream.statusCode)) return sendUpstreamJsonResponse(reply, upstream, machineId);
reply.code(upstream.statusCode);
applySafeHeaders(reply, upstream.headers);
return reply.send(selectedMachineConfigResponse(parsePiWebConfigResponseBody(upstream.body, "Remote machine config response")));
}
function sendUpstreamJsonResponse(reply: FastifyReply, upstream: MachineJsonResponse, machineId: string): FastifyReply {
reply.code(upstream.statusCode);
applySafeHeaders(reply, upstream.headers);
return reply.send(upstream.body ?? { error: "Remote machine config request failed", machineId, statusCode: upstream.statusCode });
}
function isSuccessfulStatus(statusCode: number): boolean {
return statusCode >= 200 && statusCode < 300;
}
async function proxyWebSocket(machines: MachineService, machineId: string, requestUrl: string, socket: WebSocket): Promise<void> {
if (machineId === "local") {
socket.close(1011, "Local machine route is not registered for this endpoint");
@@ -84,10 +128,14 @@ function remoteApiPath(machineId: string, requestUrl: string): string {
return `/api${compatPath}`;
}
function proxyRequestOptions(body: unknown, contentType: string | string[] | undefined): MachineRequestOptions | undefined {
if (!isRawProxyBody(body)) return undefined;
const value = firstHeaderValue(contentType);
return value === undefined || value === "" ? undefined : { contentType: value };
function proxyRequestOptions(spec: Pick<FederatedHttpRouteSpec, "timeoutMs">, body: unknown, contentType: string | string[] | undefined): MachineRequestOptions | undefined {
const options: MachineRequestOptions = {};
if (spec.timeoutMs !== undefined) options.timeoutMs = spec.timeoutMs;
if (isRawProxyBody(body)) {
const value = firstHeaderValue(contentType);
if (value !== undefined && value !== "") options.contentType = value;
}
return Object.keys(options).length === 0 ? undefined : options;
}
function isRawProxyBody(body: unknown): boolean {
@@ -106,6 +154,18 @@ function applySafeHeaders(reply: FastifyReply, headers: Record<string, string |
}
}
function isSelectedMachineConfigRequestError(error: unknown): boolean {
return error instanceof Error && error.message.startsWith("PI WEB selected-machine config");
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function sendGatewayError(reply: FastifyReply, machineId: string, error: unknown): FastifyReply {
const statusCode = error instanceof RemoteMachineRequestError ? error.statusCode : 502;
const label = statusCode === 504 ? "Remote machine timeout" : "Remote machine unavailable";
@@ -113,6 +173,6 @@ function sendGatewayError(reply: FastifyReply, machineId: string, error: unknown
error: label,
machineId,
statusCode,
detail: error instanceof Error ? error.message : String(error),
detail: errorMessage(error),
});
}
+3 -25
View File
@@ -1,5 +1,5 @@
import type { Machine, MachineHealth, MachineRuntime, PiWebComponentStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebStatusResponse } from "../../shared/apiTypes.js";
import { isPiWebCapability } from "../../shared/capabilities.js";
import { parsePiWebRuntimeResponse } from "../../shared/piWebStatusParsing.js";
import { getPiWebRuntime } from "../piWebStatus.js";
import { DEFAULT_REMOTE_HEALTH_TIMEOUT_MS, RemoteMachineClient, type MachineClient, validateConfiguredMachineHeaders } from "./machineClient.js";
import { MachineStore, type StoredMachine } from "./machineStore.js";
@@ -151,7 +151,8 @@ export class MachineService {
const checkedAt = this.now().toISOString();
try {
const response = await this.clientFor(machine).requestJson("GET", "/api/pi-web/runtime", undefined, { timeoutMs: DEFAULT_REMOTE_HEALTH_TIMEOUT_MS });
if (response.statusCode >= 200 && response.statusCode < 300 && isPiWebRuntimeResponse(response.body)) return machineRuntime(id, checkedAt, response.body);
const runtime = parsePiWebRuntimeResponse(response.body);
if (response.statusCode >= 200 && response.statusCode < 300 && runtime !== undefined) return machineRuntime(id, checkedAt, runtime);
return { machineId: id, ok: false, checkedAt, error: `Remote runtime returned HTTP ${String(response.statusCode)}` };
} catch (error) {
return { machineId: id, ok: false, checkedAt, error: errorMessage(error) };
@@ -241,16 +242,6 @@ function isPiWebStatusResponse(value: unknown): value is PiWebStatusResponse {
return isPiWebComponentStatus(components["web"]) && isPiWebComponentStatus(components["sessiond"]);
}
function isPiWebRuntimeResponse(value: unknown): value is PiWebRuntimeResponse {
if (!isRecord(value)) return false;
const packageName = value["packageName"];
const generatedAt = value["generatedAt"];
const components = value["components"];
const capabilities = value["capabilities"];
if (typeof packageName !== "string" || typeof generatedAt !== "string" || !isRecord(components) || !isPiWebCapabilityArray(capabilities)) return false;
return isPiWebRuntimeComponent(components["web"]) && isPiWebRuntimeComponent(components["sessiond"]);
}
function isPiWebComponentStatus(value: unknown): value is PiWebComponentStatus {
if (!isRecord(value)) return false;
const component = value["component"];
@@ -260,19 +251,6 @@ function isPiWebComponentStatus(value: unknown): value is PiWebComponentStatus {
&& typeof value["available"] === "boolean";
}
function isPiWebRuntimeComponent(value: unknown): boolean {
if (!isRecord(value)) return false;
const component = value["component"];
return (component === "web" || component === "sessiond")
&& typeof value["label"] === "string"
&& typeof value["available"] === "boolean"
&& isPiWebCapabilityArray(value["capabilities"]);
}
function isPiWebCapabilityArray(value: unknown): boolean {
return Array.isArray(value) && value.every(isPiWebCapability);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+114
View File
@@ -0,0 +1,114 @@
import Fastify, { type FastifyInstance } from "fastify";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { PiPackageInfo } from "../shared/apiTypes.js";
import type { PiPackageService } from "./piPackageService.js";
import { registerPiPackageRoutes } from "./piPackageRoutes.js";
let app: FastifyInstance;
let service: PiPackageService;
let serviceMocks: ReturnType<typeof fakePiPackageService>;
beforeEach(async () => {
serviceMocks = fakePiPackageService();
service = serviceMocks.service;
app = Fastify({ logger: false });
registerPiPackageRoutes(app, service);
await app.ready();
});
afterEach(async () => {
await app.close();
});
describe("registerPiPackageRoutes", () => {
it("lists configured Pi packages", async () => {
const response = await app.inject({ method: "GET", url: "/api/pi-packages" });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ packages: [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" }] });
expect(serviceMocks.list).toHaveBeenCalledOnce();
});
it("registers package routes under a custom API prefix", async () => {
const prefixedApp = Fastify({ logger: false });
const prefixedMocks = fakePiPackageService();
registerPiPackageRoutes(prefixedApp, prefixedMocks.service, "/api/machines/local");
await prefixedApp.ready();
try {
const response = await prefixedApp.inject({ method: "GET", url: "/api/machines/local/pi-packages" });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ packages: [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" }] });
expect(prefixedMocks.list).toHaveBeenCalledOnce();
} finally {
await prefixedApp.close();
}
});
it("installs a trimmed Pi package source without accepting a scope", async () => {
const response = await app.inject({ method: "POST", url: "/api/pi-packages/install", payload: { source: " npm:@acme/new-tools " } });
const scopedResponse = await app.inject({ method: "POST", url: "/api/pi-packages/install", payload: { source: "npm:@acme/new-tools", scope: "project" } });
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({ action: "install", source: "npm:@acme/new-tools" });
expect(scopedResponse.statusCode).toBe(400);
expect(scopedResponse.json()).toEqual({ error: "Pi package install scope is not supported; installs use Pi's default package location" });
expect(serviceMocks.install).toHaveBeenCalledOnce();
expect(serviceMocks.install).toHaveBeenCalledWith("npm:@acme/new-tools");
});
it("removes from an explicitly listed package scope", async () => {
const response = await app.inject({ method: "POST", url: "/api/pi-packages/remove", payload: { source: "../project-tools", scope: "project" } });
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({ action: "remove", source: "../project-tools", scope: "project", removed: true });
expect(serviceMocks.remove).toHaveBeenCalledWith("../project-tools", "project");
});
it("updates all packages when source is omitted and one package when source is provided", async () => {
const allResponse = await app.inject({ method: "POST", url: "/api/pi-packages/update" });
const oneResponse = await app.inject({ method: "POST", url: "/api/pi-packages/update", payload: { source: " npm:@acme/tools " } });
expect(allResponse.statusCode).toBe(200);
expect(oneResponse.statusCode).toBe(200);
expect(serviceMocks.update).toHaveBeenNthCalledWith(1);
expect(serviceMocks.update).toHaveBeenNthCalledWith(2, "npm:@acme/tools");
});
it("returns stable 400 errors for invalid requests before calling the service", async () => {
const missingSource = await app.inject({ method: "POST", url: "/api/pi-packages/install", payload: {} });
const blankSource = await app.inject({ method: "POST", url: "/api/pi-packages/remove", payload: { source: " " } });
const invalidScope = await app.inject({ method: "POST", url: "/api/pi-packages/remove", payload: { source: "npm:@acme/tools", scope: "temporary" } });
const invalidUpdate = await app.inject({ method: "POST", url: "/api/pi-packages/update", payload: { source: "" } });
expect(missingSource.statusCode).toBe(400);
expect(missingSource.json()).toEqual({ error: "Pi package source must be a non-empty string" });
expect(blankSource.statusCode).toBe(400);
expect(invalidScope.statusCode).toBe(400);
expect(invalidScope.json()).toEqual({ error: "Pi package scope must be \"user\" or \"project\"" });
expect(invalidUpdate.statusCode).toBe(400);
expect(serviceMocks.install).not.toHaveBeenCalled();
expect(serviceMocks.remove).not.toHaveBeenCalled();
expect(serviceMocks.update).not.toHaveBeenCalled();
});
it("returns stable 500 errors for package-manager failures", async () => {
serviceMocks.install.mockRejectedValueOnce(new Error("install failed"));
const response = await app.inject({ method: "POST", url: "/api/pi-packages/install", payload: { source: "npm:@acme/fails" } });
expect(response.statusCode).toBe(500);
expect(response.json()).toEqual({ error: "install failed" });
});
});
function fakePiPackageService() {
const packages: PiPackageInfo[] = [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" }];
const list = vi.fn<PiPackageService["list"]>(() => Promise.resolve({ packages: [...packages] }));
const install = vi.fn<PiPackageService["install"]>((source) => Promise.resolve({ action: "install", source, packages: [...packages] }));
const remove = vi.fn<PiPackageService["remove"]>((source, scope = "user") => Promise.resolve({ action: "remove", source, scope, removed: true, packages: [...packages] }));
const update = vi.fn<PiPackageService["update"]>((source) => Promise.resolve({ action: "update", ...(source === undefined ? {} : { source }), packages: [...packages] }));
const service: PiPackageService = { list, install, remove, update };
return { service, list, install, remove, update };
}
+92
View File
@@ -0,0 +1,92 @@
import type { FastifyInstance, FastifyReply } from "fastify";
import type { PiPackageScope } from "../shared/apiTypes.js";
import { createDefaultPiPackageService, type PiPackageService } from "./piPackageService.js";
class PiPackageRequestValidationError extends Error {}
export function registerPiPackageRoutes(app: FastifyInstance, service: PiPackageService = createDefaultPiPackageService(), prefix = "/api"): void {
const routePrefix = normalizeRoutePrefix(prefix);
app.get(`${routePrefix}/pi-packages`, async (_request, reply) => {
try {
return await service.list();
} catch (error) {
return sendPiPackageError(reply, error);
}
});
app.post<{ Body: unknown }>(`${routePrefix}/pi-packages/install`, async (request, reply) => {
try {
return await service.install(parseRequiredSourceRequest(request.body));
} catch (error) {
return sendPiPackageError(reply, error);
}
});
app.post<{ Body: unknown }>(`${routePrefix}/pi-packages/remove`, async (request, reply) => {
try {
const body = requireRequestObject(request.body);
return await service.remove(parseRequiredSource(body["source"]), parseOptionalScope(body["scope"]));
} catch (error) {
return sendPiPackageError(reply, error);
}
});
app.post<{ Body: unknown }>(`${routePrefix}/pi-packages/update`, async (request, reply) => {
try {
const source = parseOptionalUpdateSource(request.body);
return source === undefined ? await service.update() : await service.update(source);
} catch (error) {
return sendPiPackageError(reply, error);
}
});
}
function normalizeRoutePrefix(prefix: string): string {
const normalized = prefix.replace(/\/+$/u, "");
return normalized === "" ? "/api" : normalized;
}
function parseRequiredSourceRequest(body: unknown): string {
const request = requireRequestObject(body);
if (request["scope"] !== undefined || request["local"] !== undefined) {
throw new PiPackageRequestValidationError("Pi package install scope is not supported; installs use Pi's default package location");
}
return parseRequiredSource(request["source"]);
}
function parseRequiredSource(value: unknown): string {
if (typeof value !== "string" || value.trim() === "") throw new PiPackageRequestValidationError("Pi package source must be a non-empty string");
return value.trim();
}
function parseOptionalUpdateSource(body: unknown): string | undefined {
if (body === undefined) return undefined;
const source = requireRequestObject(body)["source"];
if (source === undefined) return undefined;
return parseRequiredSource(source);
}
function parseOptionalScope(value: unknown): PiPackageScope | undefined {
if (value === undefined) return undefined;
if (value !== "user" && value !== "project") throw new PiPackageRequestValidationError("Pi package scope must be \"user\" or \"project\"");
return value;
}
function requireRequestObject(value: unknown): Record<string, unknown> {
if (!isRecord(value)) throw new PiPackageRequestValidationError("Pi package request body must be an object");
return value;
}
function sendPiPackageError(reply: FastifyReply, error: unknown): FastifyReply {
const status = error instanceof PiPackageRequestValidationError ? 400 : 500;
return reply.code(status).send({ error: errorMessage(error) });
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+203
View File
@@ -0,0 +1,203 @@
import { describe, expect, it, vi } from "vitest";
import type { PiPackageInfo } from "../shared/apiTypes.js";
import { DefaultPiPackageService, type PiPackageManagerPort } from "./piPackageService.js";
function fakeManager(packages: PiPackageInfo[] = []) {
const listConfiguredPackages = vi.fn<PiPackageManagerPort["listConfiguredPackages"]>(() => packages);
const installAndPersist = vi.fn<PiPackageManagerPort["installAndPersist"]>(() => Promise.resolve());
const removeAndPersist = vi.fn<PiPackageManagerPort["removeAndPersist"]>(() => Promise.resolve(true));
const update = vi.fn<PiPackageManagerPort["update"]>(() => Promise.resolve());
const manager: PiPackageManagerPort = { listConfiguredPackages, installAndPersist, removeAndPersist, update };
return { manager, listConfiguredPackages, installAndPersist, removeAndPersist, update };
}
function deferred<T = void>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
describe("DefaultPiPackageService", () => {
it("lists configured Pi packages with source, scope, filtered status, and installed path", async () => {
const fake = fakeManager([
{ source: "npm:@acme/user-tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/user-tools" },
{ source: "../project-tools", scope: "project", filtered: true },
]);
const service = new DefaultPiPackageService(fake.manager);
await expect(service.list()).resolves.toEqual({
packages: [
{ source: "npm:@acme/user-tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/user-tools" },
{ source: "../project-tools", scope: "project", filtered: true },
],
});
});
it("installs through the default Pi package-manager behavior without a local option", async () => {
const fake = fakeManager([{ source: "npm:@acme/tools", scope: "user", filtered: false }]);
const service = new DefaultPiPackageService(fake.manager);
const response = await service.install("npm:@acme/tools");
expect(fake.installAndPersist).toHaveBeenCalledWith("npm:@acme/tools");
expect(response).toEqual({ action: "install", source: "npm:@acme/tools", packages: [{ source: "npm:@acme/tools", scope: "user", filtered: false }] });
});
it("removes user packages by default and project packages only when the known scope is supplied", async () => {
const fake = fakeManager();
const service = new DefaultPiPackageService(fake.manager);
await service.remove("npm:@acme/user-tools");
await service.remove("../project-tools", "project");
expect(fake.removeAndPersist).toHaveBeenNthCalledWith(1, "npm:@acme/user-tools");
expect(fake.removeAndPersist).toHaveBeenNthCalledWith(2, "../project-tools", { local: true });
});
it("updates all configured packages or a single source", async () => {
const fake = fakeManager();
const service = new DefaultPiPackageService(fake.manager);
await service.update();
await service.update("npm:@acme/tools");
expect(fake.update).toHaveBeenNthCalledWith(1);
expect(fake.update).toHaveBeenNthCalledWith(2, "npm:@acme/tools");
});
it("serializes package mutations in call order and lists after each mutation before starting the next", async () => {
const firstMutation = deferred();
const events: string[] = [];
let packages: PiPackageInfo[] = [{ source: "npm:@acme/old-tools", scope: "user", filtered: false }];
const listConfiguredPackages = vi.fn<PiPackageManagerPort["listConfiguredPackages"]>(() => {
events.push(`list:${packages.map((configuredPackage) => configuredPackage.source).join(",")}`);
return packages;
});
const installAndPersist = vi.fn<PiPackageManagerPort["installAndPersist"]>(async (source) => {
events.push(`install:start:${source}`);
await firstMutation.promise;
packages = [{ source, scope: "user", filtered: false }];
events.push(`install:finish:${source}`);
});
const removeAndPersist = vi.fn<PiPackageManagerPort["removeAndPersist"]>((source) => {
events.push(`remove:start:${source}`);
packages = [];
events.push(`remove:finish:${source}`);
return Promise.resolve(true);
});
const update = vi.fn<PiPackageManagerPort["update"]>(() => Promise.resolve());
const flush = vi.fn<NonNullable<PiPackageManagerPort["flush"]>>(() => {
events.push("flush");
return Promise.resolve();
});
const manager: PiPackageManagerPort = { listConfiguredPackages, installAndPersist, removeAndPersist, update, flush };
const service = new DefaultPiPackageService(manager);
const installPromise = service.install("npm:@acme/new-tools");
const removePromise = service.remove("npm:@acme/new-tools");
await Promise.resolve();
expect(installAndPersist).toHaveBeenCalledOnce();
expect(removeAndPersist).not.toHaveBeenCalled();
expect(events).toEqual(["install:start:npm:@acme/new-tools"]);
firstMutation.resolve();
await expect(Promise.all([installPromise, removePromise])).resolves.toEqual([
{ action: "install", source: "npm:@acme/new-tools", packages: [{ source: "npm:@acme/new-tools", scope: "user", filtered: false }] },
{ action: "remove", source: "npm:@acme/new-tools", scope: "user", removed: true, packages: [] },
]);
expect(events).toEqual([
"install:start:npm:@acme/new-tools",
"install:finish:npm:@acme/new-tools",
"flush",
"list:npm:@acme/new-tools",
"remove:start:npm:@acme/new-tools",
"remove:finish:npm:@acme/new-tools",
"flush",
"list:",
]);
});
it("does not queue list requests behind an in-flight mutation", async () => {
const mutation = deferred();
const events: string[] = [];
let packages: PiPackageInfo[] = [{ source: "npm:@acme/old-tools", scope: "user", filtered: false }];
const listConfiguredPackages = vi.fn<PiPackageManagerPort["listConfiguredPackages"]>(() => {
events.push("list");
return packages;
});
const installAndPersist = vi.fn<PiPackageManagerPort["installAndPersist"]>(async (source) => {
events.push(`install:start:${source}`);
await mutation.promise;
packages = [{ source, scope: "user", filtered: false }];
events.push(`install:finish:${source}`);
});
const removeAndPersist = vi.fn<PiPackageManagerPort["removeAndPersist"]>(() => Promise.resolve(true));
const update = vi.fn<PiPackageManagerPort["update"]>(() => Promise.resolve());
const manager: PiPackageManagerPort = { listConfiguredPackages, installAndPersist, removeAndPersist, update };
const service = new DefaultPiPackageService(manager);
const installPromise = service.install("npm:@acme/new-tools");
await Promise.resolve();
await expect(service.list()).resolves.toEqual({ packages: [{ source: "npm:@acme/old-tools", scope: "user", filtered: false }] });
expect(events).toEqual(["install:start:npm:@acme/new-tools", "list"]);
mutation.resolve();
await expect(installPromise).resolves.toEqual({
action: "install",
source: "npm:@acme/new-tools",
packages: [{ source: "npm:@acme/new-tools", scope: "user", filtered: false }],
});
});
it("releases the mutation queue after a mutation fails", async () => {
const failingMutation = deferred();
const events: string[] = [];
const packages: PiPackageInfo[] = [{ source: "npm:@acme/tools", scope: "user", filtered: false }];
const listConfiguredPackages = vi.fn<PiPackageManagerPort["listConfiguredPackages"]>(() => {
events.push("list");
return packages;
});
const installAndPersist = vi.fn<PiPackageManagerPort["installAndPersist"]>(async (source) => {
events.push(`install:start:${source}`);
await failingMutation.promise;
});
const removeAndPersist = vi.fn<PiPackageManagerPort["removeAndPersist"]>(() => Promise.resolve(true));
const update = vi.fn<PiPackageManagerPort["update"]>((source) => {
events.push(`update:start:${source ?? "all"}`);
return Promise.resolve();
});
const flush = vi.fn<NonNullable<PiPackageManagerPort["flush"]>>(() => {
events.push("flush");
return Promise.resolve();
});
const manager: PiPackageManagerPort = { listConfiguredPackages, installAndPersist, removeAndPersist, update, flush };
const service = new DefaultPiPackageService(manager);
const installPromise = service.install("npm:@acme/fails");
const updatePromise = service.update("npm:@acme/tools");
await Promise.resolve();
expect(update).not.toHaveBeenCalled();
expect(events).toEqual(["install:start:npm:@acme/fails"]);
failingMutation.reject(new Error("install failed"));
await expect(installPromise).rejects.toThrow("install failed");
await expect(updatePromise).resolves.toEqual({
action: "update",
source: "npm:@acme/tools",
packages: [{ source: "npm:@acme/tools", scope: "user", filtered: false }],
});
expect(events).toEqual([
"install:start:npm:@acme/fails",
"update:start:npm:@acme/tools",
"flush",
"list",
]);
});
});
+97
View File
@@ -0,0 +1,97 @@
import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse } from "../shared/apiTypes.js";
export interface PiPackageManagerPort {
listConfiguredPackages(): PiPackageInfo[];
installAndPersist(source: string, options?: { local?: boolean }): Promise<void>;
removeAndPersist(source: string, options?: { local?: boolean }): Promise<boolean>;
update(source?: string): Promise<void>;
flush?(): Promise<void>;
}
export interface PiPackageService {
list(): Promise<PiPackagesResponse>;
install(source: string): Promise<PiPackageMutationResponse>;
remove(source: string, scope?: PiPackageScope): Promise<PiPackageMutationResponse>;
update(source?: string): Promise<PiPackageMutationResponse>;
}
export class DefaultPiPackageService implements PiPackageService {
private mutationQueue: Promise<void> = Promise.resolve();
constructor(private readonly manager: PiPackageManagerPort) {}
list(): Promise<PiPackagesResponse> {
return Promise.resolve({ packages: this.listPackages() });
}
install(source: string): Promise<PiPackageMutationResponse> {
return this.enqueueMutation(async () => {
await this.manager.installAndPersist(source);
await this.flushSettings();
return this.mutationResponse("install", { source });
});
}
remove(source: string, scope: PiPackageScope = "user"): Promise<PiPackageMutationResponse> {
return this.enqueueMutation(async () => {
const removed = scope === "project"
? await this.manager.removeAndPersist(source, { local: true })
: await this.manager.removeAndPersist(source);
await this.flushSettings();
return this.mutationResponse("remove", { source, scope, removed });
});
}
update(source?: string): Promise<PiPackageMutationResponse> {
return this.enqueueMutation(async () => {
if (source === undefined) {
await this.manager.update();
await this.flushSettings();
return this.mutationResponse("update", {});
}
await this.manager.update(source);
await this.flushSettings();
return this.mutationResponse("update", { source });
});
}
private enqueueMutation<T>(operation: () => Promise<T>): Promise<T> {
const queuedMutation = this.mutationQueue.then(operation);
this.mutationQueue = queuedMutation.then(
() => undefined,
() => undefined,
);
return queuedMutation;
}
private mutationResponse(action: PiPackageMutationAction, metadata: Omit<PiPackageMutationResponse, "action" | "packages">): PiPackageMutationResponse {
return { action, ...metadata, packages: this.listPackages() };
}
private async flushSettings(): Promise<void> {
await this.manager.flush?.();
}
private listPackages(): PiPackageInfo[] {
return this.manager.listConfiguredPackages().map((configuredPackage) => ({
source: configuredPackage.source,
scope: configuredPackage.scope,
filtered: configuredPackage.filtered,
...(configuredPackage.installedPath === undefined ? {} : { installedPath: configuredPackage.installedPath }),
}));
}
}
export function createDefaultPiPackageService(cwd = process.cwd(), agentDir = getAgentDir()): PiPackageService {
const settingsManager = SettingsManager.create(cwd, agentDir);
const manager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
return new DefaultPiPackageService({
listConfiguredPackages: () => manager.listConfiguredPackages(),
installAndPersist: (source, options) => manager.installAndPersist(source, options),
removeAndPersist: (source, options) => manager.removeAndPersist(source, options),
update: (source) => manager.update(source),
flush: () => settingsManager.flush(),
});
}
+28
View File
@@ -89,6 +89,29 @@ describe("PiWebPluginService", () => {
expect(manifest.plugins[0]?.module).toMatch(/^\/pi-web-plugins\/review\/dist\/review\.js\?v=\d+$/u);
});
it("refreshes Pi package plugin discovery after Pi package settings change", async () => {
const agentDir = join(tempDir, "agent");
const firstPackageDir = join(tempDir, "first-package");
const secondPackageDir = join(tempDir, "second-package");
await writePlugin(firstPackageDir, {
packageJson: { piWeb: { plugins: [{ id: "first", module: "pi-web-plugin.js" }] } },
files: { "pi-web-plugin.js": "export default {};" },
});
await writePlugin(secondPackageDir, {
packageJson: { piWeb: { plugins: [{ id: "second", module: "pi-web-plugin.js" }] } },
files: { "pi-web-plugin.js": "export default {};" },
});
await writePiPackageSettings(agentDir, [firstPackageDir]);
const service = new PiWebPluginService({ roots: [], cwd: tempDir, agentDir });
await expect(service.manifest()).resolves.toMatchObject({ plugins: [{ id: "first" }] });
await writePiPackageSettings(agentDir, [secondPackageDir]);
const manifest = await service.manifest();
expect(manifest.plugins.map((plugin) => plugin.id)).toEqual(["second"]);
});
it("discovers source checkout plugin packages without symlinks", async () => {
await mkdir(join(tempDir, "src", "server"), { recursive: true });
await writeFile(join(tempDir, "src", "server", "index.ts"), "export {};\n");
@@ -212,6 +235,11 @@ describe("PiWebPluginService", () => {
});
});
async function writePiPackageSettings(agentDir: string, packages: string[]): Promise<void> {
await mkdir(agentDir, { recursive: true });
await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ packages }, null, 2)}\n`);
}
async function writePlugin(root: string, options: { packageJson: unknown; files: Record<string, string> }): Promise<void> {
await mkdir(root, { recursive: true });
await writeFile(join(root, "package.json"), `${JSON.stringify(options.packageJson, null, 2)}\n`);
+11 -11
View File
@@ -69,22 +69,22 @@ interface PiWebPluginEntry {
type ArraylessPluginRecord = Omit<PluginRecord, "source" | "scope">;
export class DefaultPiPackageProvider implements PiPackageProvider {
private readonly packageManager: DefaultPackageManager;
constructor(cwd = process.cwd(), agentDir = getAgentDir()) {
this.packageManager = new DefaultPackageManager({
cwd,
agentDir,
settingsManager: SettingsManager.create(cwd, agentDir),
});
}
constructor(private readonly cwd = process.cwd(), private readonly agentDir = getAgentDir()) {}
listPackages(): ConfiguredPiPackage[] {
return this.packageManager.listConfiguredPackages();
return this.createPackageManager().listConfiguredPackages();
}
getInstalledPath(source: string, scope: "user" | "project"): string | undefined {
return this.packageManager.getInstalledPath(source, scope);
return this.createPackageManager().getInstalledPath(source, scope);
}
private createPackageManager(): DefaultPackageManager {
return new DefaultPackageManager({
cwd: this.cwd,
agentDir: this.agentDir,
settingsManager: SettingsManager.create(this.cwd, this.agentDir),
});
}
}
+20 -1
View File
@@ -2,9 +2,10 @@ import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { comparePackageVersions, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
import { comparePackageVersions, getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
import type { PiWebComponentStatus } from "../shared/apiTypes.js";
import { PI_WEB_CAPABILITIES } from "../shared/capabilities.js";
const originalSkipVersionCheck = process.env["PI_WEB_SKIP_VERSION_CHECK"];
const originalHome = process.env["HOME"];
@@ -50,6 +51,24 @@ describe("PI WEB status", () => {
expect(status).not.toHaveProperty("release");
});
it("reports web-only capabilities from the web runtime", async () => {
const daemon = daemonWithComponent({
component: "sessiond",
label: "Session daemon",
runtimeVersion: "1.202605.7",
installedVersion: "1.202605.8",
stale: true,
available: true,
});
const runtime = await getPiWebRuntime(daemon);
expect(runtime.components.web.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]));
expect(runtime.components.sessiond.capabilities).not.toContain(PI_WEB_CAPABILITIES.piPackagesManage);
expect(runtime.components.sessiond.capabilities).not.toContain(PI_WEB_CAPABILITIES.selectedMachineSettings);
expect(runtime.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]));
});
it("reports stale session daemon versions as messages", async () => {
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
disableDockerRuntimeEnv();
+73 -2
View File
@@ -1,17 +1,22 @@
import { mkdtemp, readFile, readdir, rm } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, readdir, rm, symlink } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { DEFAULT_ATTACHMENT_FOLDER, saveAttachmentsToWorkspace } from "./attachmentService.js";
let workspace: string;
let externalDirectories: string[] = [];
beforeEach(async () => {
workspace = await mkdtemp(join(tmpdir(), "pi-web-attachments-"));
externalDirectories = [];
});
afterEach(async () => {
await rm(workspace, { recursive: true, force: true });
await Promise.all([
rm(workspace, { recursive: true, force: true }),
...externalDirectories.map((directory) => rm(directory, { recursive: true, force: true })),
]);
});
const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
@@ -43,6 +48,59 @@ describe("saveAttachmentsToWorkspace", () => {
expect(written.equals(pngBytes)).toBe(true);
});
it("saves generic files with sanitized original filenames", async () => {
const pdfBytes = Buffer.from("PDF bytes");
const saved = await saveAttachmentsToWorkspace(
workspace,
[
{ kind: "file", mimeType: "application/pdf", data: pdfBytes.toString("base64"), name: "../Quarterly Report (final).pdf" },
{ kind: "file", mimeType: "text/plain", data: "", name: "empty.txt" },
],
{ now: () => new Date("2026-06-13T12:05:01.123Z") },
);
expect(saved[0]?.path.startsWith(`${DEFAULT_ATTACHMENT_FOLDER}/attachment-`)).toBe(true);
expect(saved[0]?.path.endsWith("-1-Quarterly-Report-final.pdf")).toBe(true);
expect(saved[0]).toMatchObject({ mimeType: "application/pdf", size: pdfBytes.byteLength });
expect(saved[1]?.path.endsWith("-2-empty.txt")).toBe(true);
expect(saved[1]).toMatchObject({ mimeType: "text/plain", size: 0 });
expect((await readFile(join(workspace, saved[0]?.path ?? ""))).equals(pdfBytes)).toBe(true);
expect(await readFile(join(workspace, saved[1]?.path ?? ""))).toHaveLength(0);
});
it("does not overwrite an existing attachment name", async () => {
const fixedNow = () => new Date("2026-06-13T12:05:01.123Z");
const first = await saveAttachmentsToWorkspace(
workspace,
[{ kind: "file", mimeType: "text/plain", data: "QUJD", name: "note.txt" }],
{ now: fixedNow },
);
const second = await saveAttachmentsToWorkspace(
workspace,
[{ kind: "file", mimeType: "text/plain", data: "REVG", name: "note.txt" }],
{ now: fixedNow },
);
expect(second[0]?.path).not.toBe(first[0]?.path);
expect(second[0]?.path.endsWith("-1-note-2.txt")).toBe(true);
expect((await readFile(join(workspace, first[0]?.path ?? ""))).toString()).toBe("ABC");
expect((await readFile(join(workspace, second[0]?.path ?? ""))).toString()).toBe("DEF");
});
it("rejects unsafe custom folders", async () => {
await expect(saveAttachmentsToWorkspace(
workspace,
[{ kind: "image", mimeType: "image/png", data: pngBase64 }],
{ folder: "/tmp/uploads" },
)).rejects.toThrow(/Absolute paths/);
await expect(saveAttachmentsToWorkspace(
workspace,
[{ kind: "image", mimeType: "image/png", data: pngBase64 }],
{ folder: "../uploads" },
)).rejects.toThrow(/Path traversal/);
});
it("honors a custom folder", async () => {
const saved = await saveAttachmentsToWorkspace(
workspace,
@@ -52,6 +110,19 @@ describe("saveAttachmentsToWorkspace", () => {
expect(saved[0]?.path.startsWith("uploads/images/")).toBe(true);
});
it("rejects attachment folders that resolve outside the workspace", async () => {
const outside = await mkdtemp(join(tmpdir(), "pi-web-attachments-outside-"));
externalDirectories.push(outside);
await mkdir(join(workspace, ".pi-web"));
await symlink(outside, join(workspace, ".pi-web", "attachments"), "dir");
await expect(saveAttachmentsToWorkspace(
workspace,
[{ kind: "file", mimeType: "text/plain", data: "QUJD", name: "note.txt" }],
)).rejects.toThrow(/Path escapes workspace/);
await expect(readdir(outside)).resolves.toEqual([]);
});
it("returns empty for no attachments", async () => {
expect(await saveAttachmentsToWorkspace(workspace, [])).toEqual([]);
});
+71 -13
View File
@@ -1,10 +1,10 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { mkdir, realpath, writeFile } from "node:fs/promises";
import { basename, extname, join } from "node:path";
import type { ImageContent } from "@earendil-works/pi-ai";
import { formatDimensionNote, resizeImage } from "@earendil-works/pi-coding-agent";
import type { PromptAttachment, SavedPromptAttachment } from "../../shared/apiTypes.js";
import type { PromptAttachment, PromptImageAttachment, SavedPromptAttachment } from "../../shared/apiTypes.js";
import { extensionForImageMimeType } from "../../shared/promptAttachments.js";
import { resolveParentInsideWorkspace } from "../workspaces/pathSafety.js";
import { ensureInside, isNodeErrorWithCode, resolveParentInsideWorkspace } from "../workspaces/pathSafety.js";
/**
* Default workspace-relative folder used when saving pasted/dropped
@@ -26,7 +26,7 @@ export interface InlineImage {
* (2000x2000, ~4.5MB base64). Images that cannot be resized below the limit
* are dropped, matching pi's `[Image omitted]` behaviour.
*/
export async function attachmentsToInlineImages(attachments: PromptAttachment[]): Promise<InlineImage[]> {
export async function attachmentsToInlineImages(attachments: PromptImageAttachment[]): Promise<InlineImage[]> {
const results: InlineImage[] = [];
for (const attachment of attachments) {
const bytes = Buffer.from(attachment.data, "base64");
@@ -57,25 +57,83 @@ export async function saveAttachmentsToWorkspace(
attachments: PromptAttachment[],
options: SaveAttachmentsOptions = {},
): Promise<SavedPromptAttachment[]> {
const folder = normalizeFolder(options.folder ?? DEFAULT_ATTACHMENT_FOLDER);
const folder = options.folder ?? DEFAULT_ATTACHMENT_FOLDER;
const now = options.now ?? (() => new Date());
const { target: folderTarget } = await resolveParentInsideWorkspace(cwd, folder);
await mkdir(folderTarget, { recursive: true });
const { root, target: requestedFolderTarget, relativePath: normalizedFolder } = await resolveParentInsideWorkspace(cwd, folder);
await mkdir(requestedFolderTarget, { recursive: true });
const folderTarget = await realpath(requestedFolderTarget);
ensureInside(root, folderTarget);
const stamp = timestamp(now());
const saved: SavedPromptAttachment[] = [];
for (const [index, attachment] of attachments.entries()) {
const bytes = Buffer.from(attachment.data, "base64");
const filename = `attachment-${stamp}-${String(index + 1)}.${extensionForImageMimeType(attachment.mimeType)}`;
const relativePath = `${folder}/${filename}`;
await writeFile(join(folderTarget, filename), bytes);
const filename = await writeUniqueAttachmentFile(folderTarget, attachmentFilename(attachment, stamp, index), bytes);
const relativePath = normalizedFolder === "" ? filename : `${normalizedFolder}/${filename}`;
saved.push({ path: relativePath, mimeType: attachment.mimeType, size: bytes.byteLength });
}
return saved;
}
function normalizeFolder(folder: string): string {
return folder.split(/[\\/]+/).filter((part) => part !== "" && part !== ".").join("/");
async function writeUniqueAttachmentFile(folderTarget: string, filename: string, bytes: Buffer): Promise<string> {
for (let attempt = 0; attempt < 100; attempt += 1) {
const candidate = attempt === 0 ? filename : addCollisionSuffix(filename, attempt + 1);
try {
await writeFile(join(folderTarget, candidate), bytes, { flag: "wx" });
return candidate;
} catch (error: unknown) {
if (!isNodeErrorWithCode(error, "EEXIST")) throw error;
}
}
throw new Error("Unable to choose a unique attachment filename");
}
function addCollisionSuffix(filename: string, suffix: number): string {
const extension = extname(filename);
const stem = filename.slice(0, filename.length - extension.length);
return `${stem}-${String(suffix)}${extension}`;
}
function attachmentFilename(attachment: PromptAttachment, stamp: string, index: number): string {
const originalName = sanitizeOriginalFilename(attachment.name) ?? fallbackAttachmentFilename(attachment);
return `attachment-${stamp}-${String(index + 1)}-${originalName}`;
}
function fallbackAttachmentFilename(attachment: PromptAttachment): string {
if (attachment.kind === "image") return `image.${extensionForImageMimeType(attachment.mimeType)}`;
return "file.bin";
}
const MAX_ORIGINAL_FILENAME_LENGTH = 96;
function sanitizeOriginalFilename(name: string | undefined): string | undefined {
const trimmed = name?.trim();
if (trimmed === undefined || trimmed === "") return undefined;
const leaf = basename(trimmed.replace(/\\/g, "/"));
const sanitized = stripControlCharacters(leaf)
.normalize("NFKC")
.replace(/[^A-Za-z0-9._-]+/g, "-")
.replace(/-+/g, "-")
.replace(/-+\./g, ".")
.replace(/^\.+/, "")
.replace(/[.-]+$/, "");
if (sanitized === "") return undefined;
return truncateFilename(sanitized, MAX_ORIGINAL_FILENAME_LENGTH);
}
function stripControlCharacters(value: string): string {
return Array.from(value).filter((character) => {
const codePoint = character.codePointAt(0);
return codePoint !== undefined && codePoint > 0x1f && codePoint !== 0x7f;
}).join("");
}
function truncateFilename(filename: string, maxLength: number): string {
if (filename.length <= maxLength) return filename;
const extension = extname(filename);
if (extension.length >= maxLength) return filename.slice(0, maxLength);
const stem = filename.slice(0, filename.length - extension.length);
return `${stem.slice(0, maxLength - extension.length)}${extension}`;
}
function timestamp(date: Date): string {
+1 -1
View File
@@ -20,7 +20,7 @@ export const BUILTIN_COMMANDS: ClientCommand[] = [
{ name: "new", description: "Start a new session", source: "builtin" },
{ name: "compact", description: "Manually compact session context", source: "builtin" },
{ name: "resume", description: "Resume a different session", source: "builtin" },
{ name: "reload", description: "Reload keybindings, extensions, skills, prompts, and themes", source: "builtin" },
{ name: "reload", description: "Reload Pi runtime resources for this session", source: "builtin" },
{ name: "quit", description: "Quit pi", source: "builtin" },
];
@@ -72,6 +72,16 @@ describe("Pi session manager gateway", () => {
await expect(gateway.listAll()).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: "session-a", cwd }), expect.objectContaining({ id: "session-b", cwd: otherCwd })]));
});
it("includes an absolute env-configured session directory in global listing", async () => {
const envSessionDir = join(tempDir, "env-sessions");
await writeSessionFile(defaultPiSessionDir(cwd, agentDir), "default-session", cwd);
await writeSessionFile(envSessionDir, "env-session", cwd);
const gateway = createPiSessionManagerGateway({ agentDir, env: { PI_CODING_AGENT_SESSION_DIR: envSessionDir } });
if (gateway.listAll === undefined) throw new Error("Expected legacy listing support");
await expect(gateway.listAll()).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: "default-session", cwd }), expect.objectContaining({ id: "env-session", cwd })]));
});
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");
+20 -2
View File
@@ -34,6 +34,13 @@ export class SessionDirResolver {
return defaultPiSessionsRoot(this.agentDir);
}
globalEnvSessionDir(): string | undefined {
const envSessionDir = this.env[PI_SESSION_DIR_ENV];
if (envSessionDir === undefined || envSessionDir === "") return undefined;
const expanded = expandTildePath(envSessionDir);
return isAbsolute(expanded) ? expanded : undefined;
}
resolve(cwd: string): SessionDirResolution {
const envSessionDir = this.env[PI_SESSION_DIR_ENV];
if (envSessionDir !== undefined && envSessionDir !== "") {
@@ -68,8 +75,13 @@ class SettingsAwarePiSessionManagerGateway implements PiSessionManagerGateway {
return SessionManager.create(cwd, resolution.sessionDir, options?.parentSession === undefined ? undefined : { parentSession: options.parentSession });
}
listAll(): Promise<PiSessionListEntry[]> {
return listSessionsInDefaultPiStore(this.resolver.defaultSessionsRoot());
async listAll(): Promise<PiSessionListEntry[]> {
const envSessionDir = this.resolver.globalEnvSessionDir();
const [defaultSessions, envSessions] = await Promise.all([
listSessionsInDefaultPiStore(this.resolver.defaultSessionsRoot()),
envSessionDir === undefined ? Promise.resolve([]) : listSessionsInDir(envSessionDir),
]);
return uniqueSessionsByPath([...defaultSessions, ...envSessions]);
}
open(path: string): PiSessionManager {
@@ -106,6 +118,12 @@ export function filterSessionsForCwd(sessions: readonly PiSessionListEntry[], cw
return sessions.filter((session) => session.cwd !== "" && cwdPathsEqual(session.cwd, cwd));
}
function uniqueSessionsByPath(sessions: readonly PiSessionListEntry[]): PiSessionListEntry[] {
const byPath = new Map<string, PiSessionListEntry>();
for (const session of sessions) byPath.set(session.path, session);
return [...byPath.values()].sort((a, b) => b.modified.getTime() - a.modified.getTime());
}
export function defaultPiSessionsRoot(agentDir = getAgentDir()): string {
return join(agentDir, "sessions");
}
+457 -2
View File
@@ -1,6 +1,8 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createAssistantMessageEventStream, type AssistantMessage } from "@earendil-works/pi-ai";
import type { StreamFn } from "@earendil-works/pi-agent-core";
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
import { describe, expect, it, vi } from "vitest";
import type { GlobalSessionEvent, SessionUiEvent } from "../../shared/apiTypes.js";
@@ -52,12 +54,18 @@ function sessionRef(id: string, cwd = "/workspace") {
return { id, cwd };
}
function testModel(): NonNullable<PiAgentSession["model"]> {
const model = ModelRegistry.inMemory(AuthStorage.inMemory()).find("anthropic", "claude-3-5-sonnet-20241022");
if (model === undefined) throw new Error("test model not found");
return model;
}
function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession> = {}) {
const promptCalls: { text: string; options: unknown }[] = [];
const customMessageCalls: { message: { customType: string; content: string; display: boolean; details?: unknown }; options: unknown }[] = [];
const bindExtensionCalls: unknown[] = [];
const listeners: ((event: unknown) => void)[] = [];
const calls = { abort: 0, bindExtensions: bindExtensionCalls, clearQueue: 0, dispose: 0, prompt: promptCalls, sendCustomMessage: customMessageCalls };
const calls = { abort: 0, bindExtensions: bindExtensionCalls, clearQueue: 0, dispose: 0, prompt: promptCalls, reload: 0, sendCustomMessage: customMessageCalls };
const session: TestSession = {
sessionId,
sessionFile: `/tmp/${sessionId}.jsonl`,
@@ -88,6 +96,10 @@ function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession> = {})
},
getSessionStats: () => ({ sessionId, totalMessages: 0, userMessages: 0, assistantMessages: 0, toolCalls: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, cost: 0 }),
getContextUsage: () => undefined,
reload: () => {
calls.reload += 1;
return Promise.resolve();
},
prompt: (text: string, options: unknown) => {
calls.prompt.push({ text, options });
return Promise.resolve();
@@ -115,6 +127,7 @@ function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession> = {})
setSessionName: (name: string) => { session.sessionName = name; },
compact: () => Promise.resolve({ summary: "", tokensBefore: 0 }),
getUserMessagesForForking: () => [],
agent: { streamFn: () => { throw new Error("streamFn should not be called in this test"); } },
...patch,
};
const runtime: PiSessionRuntime = {
@@ -156,6 +169,23 @@ function emptyArchiveStore(): NonNullable<PiSessionServiceDependencies["archiveS
}
describe("PiSessionService", () => {
it("exposes the session's agent.streamFn for one-off model calls", async () => {
const hub = new CapturingSessionEventHub();
const streamFn = vi.fn();
const fake = fakeRuntime("stream-session", { agent: { streamFn } });
const service = new PiSessionService(hub, {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
});
await service.start("/workspace");
expect(fake.session.agent.streamFn).toBe(streamFn);
await service.dispose();
});
it("starts sessions through an injected runtime creator", async () => {
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime();
@@ -185,6 +215,35 @@ describe("PiSessionService", () => {
expect(fake.calls.dispose).toBe(1);
});
it("reports persistence from actual session-file existence for fresh active sessions", async () => {
const dir = await mkdtemp(join(tmpdir(), "pi-web-persisted-"));
const sessionFile = join(dir, "new-session.jsonl");
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("new-session", { sessionFile });
let service: PiSessionService | undefined;
try {
service = new PiSessionService(hub, {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
});
const session = await service.start("/workspace");
const createdEvent = hub.globalEvents.find((event) => event.type === "session.created");
expect(session).toMatchObject({ id: "new-session", path: sessionFile, persisted: false });
expect(createdEvent).toMatchObject({ type: "session.created", session: { id: "new-session", persisted: false } });
await expect(service.status(sessionRef("new-session"))).resolves.toMatchObject({ sessionId: "new-session", persisted: false });
await writeFile(sessionFile, '{"type":"session","id":"new-session"}\n', "utf8");
await expect(service.status(sessionRef("new-session"))).resolves.toMatchObject({ sessionId: "new-session", persisted: true });
} finally {
await service?.dispose();
await rm(dir, { recursive: true, force: true });
}
});
it("opens legacy id-only lookups from the default session store gateway", async () => {
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("legacy-session");
@@ -346,7 +405,7 @@ describe("PiSessionService", () => {
const sessions = await service.list("/workspace");
expect(sessions).toHaveLength(2);
expect(sessions[0]).toMatchObject({ id: "active" });
expect(sessions[0]).toMatchObject({ id: "active", persisted: true });
expect(sessions[0]?.archived).toBeUndefined();
expect(sessions[1]).toMatchObject({ id: "archived", archived: true, archivedAt: "2026-01-01T00:00:00.000Z" });
@@ -446,6 +505,315 @@ describe("PiSessionService", () => {
await service.dispose();
});
it("bulk archives inactive sessions by cwd without opening runtimes", async () => {
const recordsByCwd = new Map([
["/one", [sessionRecord("a", "/one"), sessionRecord("b", "/one")]],
["/two", [sessionRecord("c", "/two")]],
]);
const listCalls: string[] = [];
const open = vi.fn(() => { throw new Error("bulk archive should not open inactive runtimes"); });
const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }))));
const service = new PiSessionService(new CapturingSessionEventHub(), {
archiveStore: {
list: () => Promise.resolve([]),
get: () => Promise.resolve(undefined),
archive: (input) => Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }),
archiveMany,
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(false),
},
sessionManager: {
create: () => fakeSessionManager(),
list: (cwd) => {
listCalls.push(cwd);
return Promise.resolve(recordsByCwd.get(cwd) ?? []);
},
open,
},
heartbeatIntervalMs: 60_000,
});
const result = await service.archiveMany([{ id: "a", cwd: "/one" }, { id: "b", cwd: "/one" }, { id: "c", cwd: "/two" }]);
expect(result).toMatchObject({ archived: true, archivedSessionIds: ["a", "b", "c"], failures: [] });
expect(listCalls).toEqual(["/one", "/two"]);
expect(open).not.toHaveBeenCalled();
expect(archiveMany).toHaveBeenCalledTimes(1);
expect(archiveMany.mock.calls[0]?.[0].map((input) => input.sessionId)).toEqual(["a", "b", "c"]);
await service.dispose();
});
it("bulk archive reports per-session failures without aborting other archives", async () => {
const busy = fakeRuntime("busy", { isStreaming: true });
let createCalls = 0;
const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }))));
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: () => {
createCalls += 1;
return Promise.resolve(busy.runtime);
},
archiveStore: {
list: () => Promise.resolve([]),
get: () => Promise.resolve(undefined),
archive: (input) => Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }),
archiveMany,
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(false),
},
sessionManager: {
create: () => fakeSessionManager(),
list: () => Promise.resolve([sessionRecord("busy"), sessionRecord("ok")]),
open: () => fakeSessionManager(),
},
heartbeatIntervalMs: 60_000,
});
await service.status(sessionRef("busy"));
const result = await service.archiveMany([{ id: "busy", cwd: "/workspace" }, { id: "ok", cwd: "/workspace" }, { id: "missing", cwd: "/workspace" }]);
expect(createCalls).toBe(1);
expect(busy.calls.abort).toBe(0);
expect(archiveMany.mock.calls[0]?.[0].map((input) => input.sessionId)).toEqual(["ok"]);
expect(result.archivedSessionIds).toEqual(["ok"]);
expect(result.failures).toEqual([
{ sessionId: "busy", error: "Stop current session activity before archiving" },
{ sessionId: "missing", error: "Session not found" },
]);
await service.dispose();
});
it("bulk deletes only archived sessions and skips busy active archived runtimes", async () => {
const busyRecord = { sessionId: "busy-archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/busy.jsonl" };
const idleRecord = { sessionId: "idle-archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/idle.jsonl" };
const busy = fakeRuntime("busy-archived", { isStreaming: true });
const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds]));
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(busy.runtime),
archiveStore: {
list: () => Promise.resolve([busyRecord, idleRecord]),
get: (sessionId) => Promise.resolve(sessionId === "busy-archived" ? busyRecord : undefined),
archive: () => { throw new Error("archive should not be called for records that already have archive files"); },
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(false),
deleteArchived: () => Promise.resolve(),
deleteArchivedMany,
},
sessionManager: {
create: () => fakeSessionManager(),
list: () => Promise.resolve([sessionRecord("unarchived")]),
open: () => fakeSessionManager(),
},
heartbeatIntervalMs: 60_000,
});
await service.status(sessionRef("busy-archived"));
const result = await service.deleteArchivedMany([{ id: "busy-archived", cwd: "/workspace" }, { id: "idle-archived", cwd: "/workspace" }, { id: "unarchived", cwd: "/workspace" }]);
expect(busy.calls.abort).toBe(0);
expect(deleteArchivedMany).toHaveBeenCalledWith(["idle-archived"]);
expect(result.deletedSessionIds).toEqual(["idle-archived"]);
expect(result.failures).toEqual([
{ sessionId: "busy-archived", error: "Stop current session activity before deleting archived session" },
{ sessionId: "unarchived", error: "Archived session not found" },
]);
await service.dispose();
});
it("bulk delete moves legacy archived records with one workspace scan before deleting", async () => {
const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z", archivePath: `/archive/${input.sessionId}.jsonl` }))));
const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds]));
const listCalls: string[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), {
archiveStore: {
list: () => Promise.resolve([
{ sessionId: "legacy-a", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" },
{ sessionId: "legacy-b", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" },
{ sessionId: "moved", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/moved.jsonl" },
]),
get: () => Promise.resolve(undefined),
archive: (input) => Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }),
archiveMany,
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(false),
deleteArchived: () => Promise.resolve(),
deleteArchivedMany,
},
sessionManager: {
create: () => fakeSessionManager(),
list: (cwd) => {
listCalls.push(cwd);
return Promise.resolve([sessionRecord("legacy-a"), sessionRecord("legacy-b"), sessionRecord("unarchived")]);
},
open: () => fakeSessionManager(),
},
heartbeatIntervalMs: 60_000,
});
const result = await service.deleteArchivedMany([{ id: "legacy-a", cwd: "/workspace" }, { id: "legacy-b", cwd: "/workspace" }, { id: "moved", cwd: "/workspace" }]);
expect(listCalls).toEqual(["/workspace"]);
expect(archiveMany.mock.calls[0]?.[0].map((input) => input.sessionId)).toEqual(["legacy-a", "legacy-b"]);
expect(deleteArchivedMany).toHaveBeenCalledWith(["legacy-a", "legacy-b", "moved"]);
expect(result.deletedSessionIds).toEqual(["legacy-a", "legacy-b", "moved"]);
expect(result.failures).toEqual([]);
await service.dispose();
});
it("previews session cleanup without mutating and executes a recomputed plan", async () => {
const archivedInputs: string[] = [];
const deletedSessionIds: string[] = [];
let listAllCalls = 0;
const archived = { sessionId: "archived-old", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-old.jsonl" };
const otherArchived = { sessionId: "archived-other", cwd: "/other-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-other.jsonl" };
const service = new PiSessionService(new CapturingSessionEventHub(), {
now: () => new Date("2026-06-25T00:00:00.000Z"),
archiveStore: {
list: () => Promise.resolve([archived, otherArchived]),
get: () => Promise.resolve(undefined),
archive: () => Promise.reject(new Error("cleanup should use archiveMany")),
archiveMany: (inputs) => {
archivedInputs.push(...inputs.map((input) => input.sessionId));
return Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z" })));
},
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(false),
deleteArchived: () => Promise.reject(new Error("cleanup should use deleteArchivedMany")),
deleteArchivedMany: (sessionIds) => {
deletedSessionIds.push(...sessionIds);
return Promise.resolve([...sessionIds]);
},
},
sessionManager: {
create: () => fakeSessionManager(),
list: () => Promise.resolve([]),
listAll: () => {
listAllCalls += 1;
return Promise.resolve([
listAllCalls === 1 ? sessionRecord("preview-only", "/old-project") : sessionRecord("execute-only", "/old-project"),
listAllCalls === 1 ? sessionRecord("preview-other", "/other-project") : sessionRecord("execute-other", "/other-project"),
]);
},
open: () => fakeSessionManager(),
},
heartbeatIntervalMs: 60_000,
});
const preview = await service.cleanupPreview({ thresholds: { archiveIdleDays: 30, deleteArchivedDays: 30 }, projectCwds: ["/old-project"] });
expect(preview.totals).toEqual({ archiveCount: 1, deleteCount: 1 });
expect(preview.projects).toEqual([{ cwd: "/old-project", archiveCount: 1, deleteCount: 1 }]);
expect(archivedInputs).toEqual([]);
expect(deletedSessionIds).toEqual([]);
const result = await service.cleanup({ thresholds: { archiveIdleDays: 30, deleteArchivedDays: 30 }, projectCwds: ["/old-project"] });
expect(result.archivedSessionIds).toEqual(["execute-only"]);
expect(result.deletedSessionIds).toEqual(["archived-old"]);
expect(archivedInputs).toEqual(["execute-only"]);
expect(deletedSessionIds).toEqual(["archived-old"]);
await service.dispose();
});
it("moves legacy cleanup delete records with one workspace scan before batch deleting", async () => {
const listCalls: string[] = [];
const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z", archivePath: `/archive/${input.sessionId}.jsonl` }))));
const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds]));
const service = new PiSessionService(new CapturingSessionEventHub(), {
now: () => new Date("2026-06-25T00:00:00.000Z"),
archiveStore: {
list: () => Promise.resolve([
{ sessionId: "legacy-a", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z" },
{ sessionId: "legacy-b", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z" },
]),
get: () => Promise.resolve(undefined),
archive: () => Promise.reject(new Error("cleanup should use archiveMany")),
archiveMany,
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(false),
deleteArchived: () => Promise.reject(new Error("cleanup should use deleteArchivedMany")),
deleteArchivedMany,
},
sessionManager: {
create: () => fakeSessionManager(),
list: (cwd) => {
listCalls.push(cwd);
return Promise.resolve([sessionRecord("legacy-a", cwd), sessionRecord("legacy-b", cwd)]);
},
listAll: () => Promise.resolve([]),
open: () => fakeSessionManager(),
},
heartbeatIntervalMs: 60_000,
});
const result = await service.cleanup({ thresholds: { deleteArchivedDays: 30 }, projectCwds: ["/old-project"] });
expect(listCalls).toEqual(["/old-project"]);
expect(archiveMany).toHaveBeenCalledTimes(1);
expect(archiveMany.mock.calls[0]?.[0].map((input) => input.sessionId)).toEqual(["legacy-a", "legacy-b"]);
expect(deleteArchivedMany).toHaveBeenCalledWith(["legacy-a", "legacy-b"]);
expect(result.deletedSessionIds).toEqual(["legacy-a", "legacy-b"]);
await service.dispose();
});
it("skips busy active sessions during cleanup execution", async () => {
const fake = fakeRuntime("busy-open", { isStreaming: true, sessionManager: fakeSessionManager("/old-project"), sessionFile: "/sessions/busy-open.jsonl" });
const archivedInputs: string[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), {
now: () => new Date("2026-06-25T00:00:00.000Z"),
createAgentRuntime: runtimeCreator(fake.runtime),
archiveStore: {
list: () => Promise.resolve([]),
get: () => Promise.resolve(undefined),
archive: (input) => {
archivedInputs.push(input.sessionId);
return Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z" });
},
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(false),
},
sessionManager: {
create: () => fakeSessionManager("/old-project"),
list: () => Promise.resolve([sessionRecord("busy-open", "/old-project")]),
listAll: () => Promise.resolve([sessionRecord("busy-open", "/old-project")]),
open: () => fakeSessionManager("/old-project"),
},
heartbeatIntervalMs: 60_000,
});
await service.status("busy-open");
const result = await service.cleanup({ thresholds: { archiveIdleDays: 1 } });
expect(result.archivedSessionIds).toEqual([]);
expect(result.skippedBusySessionIds).toEqual(["busy-open"]);
expect(archivedInputs).toEqual([]);
expect(fake.calls.abort).toBe(0);
await service.dispose();
});
it("runs /reload by refreshing the active runtime resources in place", async () => {
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("runtime-reload-session");
const service = new PiSessionService(hub, {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("runtime-reload-session")]),
heartbeatIntervalMs: 60_000,
});
await expect(service.runCommand(sessionRef("runtime-reload-session"), "/reload")).resolves.toEqual({
type: "done",
message: "Session runtime resources reloaded. Extensions, skills, prompt templates, themes, and context/system prompt files are refreshed for this session. Reload the browser page separately for PI WEB browser plugin changes.",
});
expect(fake.calls.reload).toBe(1);
expect(fake.calls.abort).toBe(0);
expect(fake.calls.dispose).toBe(0);
expect(hub.globalEvents.some((event) => event.type === "activity.update" && event.activity.sessionId === "runtime-reload-session" && event.activity.label === "resources reloaded")).toBe(true);
expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "runtime-reload-session")).toBe(true);
await service.dispose();
});
it("reloads a session by closing the active runtime and re-opening it from disk", async () => {
const first = fakeRuntime("reload-session");
const second = fakeRuntime("reload-session");
@@ -601,6 +969,42 @@ describe("PiSessionService", () => {
await service.dispose();
});
it("generates a session name for the first prompt via the session's agent.streamFn", async () => {
const model = testModel();
const streamCalls: unknown[] = [];
const streamFn: StreamFn = (streamModel, context, options) => {
streamCalls.push({ streamModel, context, options });
const stream = createAssistantMessageEventStream();
const message: AssistantMessage = {
role: "assistant",
content: [{ type: "text", text: "Fix login bug" }],
api: "anthropic-messages",
provider: "anthropic",
model: model.id,
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
stopReason: "stop",
timestamp: Date.now(),
};
stream.push({ type: "done", reason: "stop", message });
stream.end(message);
return stream;
};
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("name-session", { model, agent: { streamFn } });
const service = new PiSessionService(hub, {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("name-session")]),
heartbeatIntervalMs: 60_000,
});
await service.prompt(sessionRef("name-session"), "Please fix the login bug");
await vi.waitFor(() => { expect(fake.session.sessionName).toBe("Fix login bug"); });
expect(streamCalls).toHaveLength(1);
expect(hub.sessionEvents.some(({ event }) => event.type === "session.name" && event.name === "Fix login bug")).toBe(true);
await service.dispose();
});
it("includes queued message details in session status", async () => {
const fake = fakeRuntime("status-session", {
messages: [{ role: "user", content: "hello" }, { role: "assistant", content: "hi" }],
@@ -820,6 +1224,28 @@ describe("PiSessionService", () => {
await service.dispose();
});
it("uses the dispatching session's model as the spawned session's initial model", async () => {
const fake = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl" });
const model = testModel();
let initialModel: PiAgentSession["model"];
const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => {
await Promise.resolve();
initialModel = options.initialModel;
return fake.runtime;
};
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime,
sessionManager: sessionGateway([]),
spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) },
heartbeatIntervalMs: 60_000,
});
await service.spawnSession({ spawningCwd: "/workspace", prompt: "continue", cwd: "/workspace-feature", model });
expect(initialModel).toBe(model);
await service.dispose();
});
it("rejects an out-of-project target without starting a session", async () => {
const { fake, service } = spawnService({ allowed: false, reason: "out-of-project", allowedCwds: ["/workspace"] });
@@ -901,6 +1327,35 @@ describe("PiSessionService", () => {
await service.dispose();
});
it("uses the parent session's model as the tracked child's initial model", async () => {
const parent = fakeRuntime("parent-1", { sessionFile: "/tmp/parent-1.jsonl" });
const child = fakeRuntime("child-1", { sessionFile: "/tmp/child-1.jsonl", sessionManager: fakeSessionManager("/workspace-feature") });
const model = testModel();
const initialModels: PiAgentSession["model"][] = [];
const runtimes = [parent.runtime, child.runtime];
let index = 0;
const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => {
await Promise.resolve();
initialModels.push(options.initialModel);
const runtime = runtimes[index] ?? child.runtime;
index += 1;
return runtime;
};
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime,
sessionManager: sessionGateway([]),
archiveStore: emptyArchiveStore(),
spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) },
heartbeatIntervalMs: 60_000,
});
await service.start("/workspace");
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature", model });
expect(initialModels).toEqual([undefined, model]);
await service.dispose();
});
it("persists tracked child links in the parent and child sessions", async () => {
const parentPersisted: { customType: string; data?: unknown }[] = [];
const childPersisted: { customType: string; data?: unknown }[] = [];
+448 -27
View File
@@ -1,5 +1,7 @@
import { statSync } from "node:fs";
import { open, readFile, writeFile } from "node:fs/promises";
import type { Api, ImageContent, Model } from "@earendil-works/pi-ai";
import type { ImageContent } from "@earendil-works/pi-ai";
import type { StreamFn } from "@earendil-works/pi-agent-core";
import {
AuthStorage,
createAgentSessionFromServices,
@@ -13,7 +15,7 @@ import {
type CreateAgentSessionRuntimeFactory,
type EditToolDetails,
} from "@earendil-works/pi-coding-agent";
import type { ClientArchiveSessionsResponse, ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionModel, ClientSessionRef, ClientSessionStatus, ClientThinkingLevel, SessionUiEvent } from "../types.js";
import type { ClientArchiveSessionsResponse, ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionCleanupExecuteResponse, ClientSessionCleanupPreviewResponse, 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";
@@ -27,13 +29,14 @@ import { computeEditPreview, type EditPreviewResult } from "./editPreview.js";
import { createPiSessionManagerGateway } from "./piSessionManagerGateway.js";
import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
import { parsePromptAttachments } from "../../shared/promptAttachments.js";
import type { SavedPromptAttachment } from "../../shared/apiTypes.js";
import type { SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef } from "../../shared/apiTypes.js";
import { cwdPathsEqual } from "../workingDirectory.js";
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
import { createSpawnSessionToolDefinition, type SpawnSessionInvocation, type SpawnSessionResult } from "./spawnSessionTool.js";
import { createSubsessionToolDefinitions, type SpawnSubsessionInvocation, type SpawnSubsessionResult, type SubsessionCheckResult, type SubsessionReadQuery, type SubsessionReadResult, type SubsessionStatus, type SubsessionSummary, type SubsessionToolDeps } from "./spawnSubsessionTool.js";
import { buildTranscriptView } from "./subsessionTranscript.js";
import { planSessionCleanup, summarizeSessionCleanupExecution, type NormalizedSessionCleanupRequest, type SessionCleanupPlan } from "./sessionCleanup.js";
import type { SpawnTargetDecision, SpawnTargetResolver } from "./spawnTargetResolver.js";
/**
@@ -101,6 +104,11 @@ interface PersistedChildSubsessionLink {
spawnedSessionId: string;
}
interface StartSessionOptions {
parentSession?: string;
initialModel?: AgentModel;
}
function requirePromptText(value: unknown): string {
if (typeof value !== "string") throw new Error("Prompt text is required");
return value;
@@ -112,7 +120,11 @@ function parsePromptStreamingBehavior(value: unknown): QueuedPromptKind | undefi
throw new Error('Prompt streamingBehavior must be "steer" or "followUp"');
}
type SessionArchiveRepository = Pick<SessionArchiveStore, "list" | "get" | "archive" | "restore" | "isArchived"> & { deleteArchived?: (sessionId: string) => Promise<void> };
type SessionArchiveRepository = Pick<SessionArchiveStore, "list" | "get" | "archive" | "restore" | "isArchived"> & {
archiveMany?: (sessions: readonly ArchiveSessionInput[]) => Promise<ArchivedSessionRecord[]>;
deleteArchived?: (sessionId: string) => Promise<void>;
deleteArchivedMany?: (sessionIds: readonly string[]) => Promise<string[]>;
};
export type PiSessionRef = ClientSessionRef;
@@ -137,7 +149,20 @@ interface WorkspaceArchiveCandidate extends SessionArchiveTreeCandidate {
activeSession?: PiAgentSession;
}
type AgentModel = Model<Api>;
interface BulkSessionLookupContext {
sessionsByCwd: Map<string, PiSessionListEntry[]>;
allSessions?: readonly PiSessionListEntry[];
}
interface BulkArchivePlanItem {
input: ArchiveSessionInput;
}
interface BulkDeletePlanItem {
record: ArchivedSessionRecord;
}
type AgentModel = NonNullable<SpawnSessionInvocation["model"]>;
type ModelRegistryInstance = ReturnType<typeof ModelRegistry.create>;
export interface PiSessionManager {
@@ -194,6 +219,7 @@ export interface PiAgentSession {
compact(instructions?: string): Promise<{ summary: string; tokensBefore: number }>;
getUserMessagesForForking(): readonly { entryId: string; text: string }[];
getSessionStats(): { sessionId: string; totalMessages: number; userMessages: number; assistantMessages: number; toolCalls: number; tokens: ClientSessionStatus["tokens"]; cost: number };
reload(): Promise<void>;
getContextUsage(): ClientSessionStatus["contextUsage"] | undefined;
prompt(text: string, options?: { streamingBehavior?: "steer" | "followUp"; images?: ImageContent[] }): Promise<void>;
sendCustomMessage(message: { customType: string; content: string; display: boolean; details?: unknown }, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }): Promise<void>;
@@ -208,6 +234,15 @@ export interface PiAgentSession {
setThinkingLevel(level: ClientThinkingLevel): void;
cycleThinkingLevel(): ClientThinkingLevel | undefined;
setSessionName(name: string): void;
/**
* Narrow re-expression of `AgentSession.agent` (an `@earendil-works/pi-agent-core`
* `Agent`), exposing only `streamFn` — the resolved-auth/headers/retry "call this
* model" function pi's own compaction/branch-summarization code uses internally.
* Lets callers (e.g. session title generation) issue one-off model calls without
* depending on pi-ai's deprecated `/compat` provider registry or leaking the full
* `Agent`/`AgentSession` surface.
*/
agent: { streamFn: StreamFn };
}
export interface PiSessionRuntime {
@@ -222,29 +257,56 @@ interface CreateAgentRuntimeOptions {
cwd: string;
agentDir: string;
sessionManager: PiSessionManager;
initialModel?: AgentModel;
}
type CreateAgentRuntime = (createRuntime: CreateAgentSessionRuntimeFactory, options: CreateAgentRuntimeOptions) => Promise<PiSessionRuntime>;
type PiWebCreateAgentSessionRuntimeFactory = (
options: Parameters<CreateAgentSessionRuntimeFactory>[0] & { initialModel?: AgentModel }
) => ReturnType<CreateAgentSessionRuntimeFactory>;
function defaultCreateAgentRuntime(createRuntime: CreateAgentSessionRuntimeFactory, options: CreateAgentRuntimeOptions): Promise<PiSessionRuntime> {
type CreateAgentRuntime = (createRuntime: PiWebCreateAgentSessionRuntimeFactory, options: CreateAgentRuntimeOptions) => Promise<PiSessionRuntime>;
function defaultCreateAgentRuntime(createRuntime: PiWebCreateAgentSessionRuntimeFactory, options: CreateAgentRuntimeOptions): Promise<PiSessionRuntime> {
if (!(options.sessionManager instanceof SessionManager)) throw new Error("Default runtime creation requires an SDK SessionManager");
return createAgentSessionRuntime(createRuntime, { ...options, sessionManager: options.sessionManager });
const runtimeFactory = createRuntimeWithOneShotInitialModel(createRuntime, options.initialModel);
return createAgentSessionRuntime(runtimeFactory, {
cwd: options.cwd,
agentDir: options.agentDir,
sessionManager: options.sessionManager,
});
}
function createRuntimeWithOneShotInitialModel(createRuntime: PiWebCreateAgentSessionRuntimeFactory, initialModel: AgentModel | undefined): CreateAgentSessionRuntimeFactory {
// The inherited model belongs only to the session being spawned. Do not keep
// reapplying it if that runtime later creates/forks/switches sessions itself.
let pendingInitialModel = initialModel;
return async (options) => {
const model = pendingInitialModel;
pendingInitialModel = undefined;
return createRuntime({
...options,
...(model === undefined ? {} : { initialModel: model }),
});
};
}
type SpawnSessionFn = (input: SpawnSessionInvocation) => Promise<SpawnSessionResult>;
function createDefaultRuntimeFactory(authStorage: AuthStorage, modelRegistry: ModelRegistryInstance, spawn?: SpawnSessionFn, subsessions?: SubsessionToolDeps): CreateAgentSessionRuntimeFactory {
return async ({ cwd, agentDir, sessionManager, sessionStartEvent }) => {
function createDefaultRuntimeFactory(authStorage: AuthStorage, modelRegistry: ModelRegistryInstance, spawn?: SpawnSessionFn, subsessions?: SubsessionToolDeps): PiWebCreateAgentSessionRuntimeFactory {
return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel }) => {
const services = await createAgentSessionServices({ cwd, agentDir, authStorage, modelRegistry });
const customTools = [
createPiWebEditToolDefinition(cwd),
...(spawn === undefined ? [] : [createSpawnSessionToolDefinition(cwd, { spawn })]),
...(subsessions === undefined ? [] : createSubsessionToolDefinitions(cwd, subsessions)),
];
const options = sessionStartEvent === undefined
? { services, sessionManager, customTools }
: { services, sessionManager, sessionStartEvent, customTools };
const result = await createAgentSessionFromServices(options);
const result = await createAgentSessionFromServices({
services,
sessionManager,
customTools,
...(sessionStartEvent === undefined ? {} : { sessionStartEvent }),
...(initialModel === undefined ? {} : { model: initialModel }),
});
return { ...result, services, diagnostics: services.diagnostics };
};
}
@@ -277,7 +339,7 @@ export interface PiSessionServiceDependencies {
archiveStore?: SessionArchiveRepository;
agentDir?: string;
sessionManager?: PiSessionManagerGateway;
createRuntime?: CreateAgentSessionRuntimeFactory;
createRuntime?: PiWebCreateAgentSessionRuntimeFactory;
createAgentRuntime?: CreateAgentRuntime;
modelRegistry?: ModelRegistryInstance;
heartbeatIntervalMs?: number;
@@ -298,6 +360,8 @@ export interface PiSessionServiceDependencies {
subsessionsEnabled?: boolean;
/** Structured logger for notable runtime events (e.g. spawns). */
logger?: PiSessionLogger;
/** Clock seam for cleanup planning tests. */
now?: () => Date;
}
export class PiSessionService {
@@ -325,12 +389,13 @@ export class PiSessionService {
private readonly archiveStore: SessionArchiveRepository;
private readonly agentDir: string;
private readonly sessionManager: PiSessionManagerGateway;
private readonly createRuntime: CreateAgentSessionRuntimeFactory;
private readonly createRuntime: PiWebCreateAgentSessionRuntimeFactory;
private readonly createAgentRuntime: CreateAgentRuntime;
private readonly modelRegistry: ModelRegistryInstance;
private readonly workspaceActivity: Pick<WorkspaceActivityService, "applySessionStatus" | "applySessionActivity" | "removeSession" | "reconcileSessionActivity"> | undefined;
private readonly spawnTargets: SpawnTargetResolver | undefined;
private readonly logger: PiSessionLogger;
private readonly now: () => Date;
constructor(private readonly events: SessionEventHub, deps: PiSessionServiceDependencies = {}) {
this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
@@ -339,6 +404,7 @@ export class PiSessionService {
this.modelRegistry = deps.modelRegistry ?? ModelRegistry.create(AuthStorage.create());
this.spawnTargets = deps.spawnTargets;
this.logger = deps.logger ?? noopLogger;
this.now = deps.now ?? (() => new Date());
// Subsessions are a beta capability gated behind their own flag, and they
// also require the spawn capability (they share its project-scope resolver).
const subsessionsActive = this.spawnTargets !== undefined && deps.subsessionsEnabled === true;
@@ -369,6 +435,7 @@ export class PiSessionService {
this.publishActivity(session, result === "success" ? "compaction complete" : "compaction failed", result === "success" ? "idle" : "error", detail);
this.publishStatus(session);
},
reloadSession: (session) => this.reloadSessionRuntime(session),
},
{ listSessionNames: (cwd) => this.listSessionNames(cwd) },
);
@@ -378,6 +445,52 @@ export class PiSessionService {
return this.active.size;
}
async cleanupPreview(request: NormalizedSessionCleanupRequest): Promise<ClientSessionCleanupPreviewResponse> {
return previewResponseFromPlan(await this.cleanupPlan(request));
}
async cleanup(request: NormalizedSessionCleanupRequest): Promise<ClientSessionCleanupExecuteResponse> {
const plan = await this.cleanupPlan(request);
if (plan.deleteRecords.length > 0 && this.archiveStore.deleteArchived === undefined && this.archiveStore.deleteArchivedMany === undefined) throw new Error("Archive store does not support deletion");
const archiveInputs: ArchiveSessionInput[] = [];
const readyArchiveInputs: ArchiveSessionInput[] = [];
const deleteRecords: ArchivedSessionRecord[] = [];
const readyDeleteRecords: ArchivedSessionRecord[] = [];
const skippedBusySessionIds = new Set(plan.skippedBusySessionIds);
for (const input of plan.archiveInputs) {
if (this.activeSessionHasWork(input.sessionId)) {
skippedBusySessionIds.add(input.sessionId);
continue;
}
await this.closeActive(input.sessionId);
readyArchiveInputs.push(input);
}
await this.archiveStoreArchiveMany(readyArchiveInputs);
archiveInputs.push(...readyArchiveInputs);
for (const record of plan.deleteRecords) {
if (this.activeSessionHasWork(record.sessionId)) {
skippedBusySessionIds.add(record.sessionId);
continue;
}
await this.closeActive(record.sessionId);
readyDeleteRecords.push(record);
}
await this.ensureArchivedRecordsMoved(readyDeleteRecords);
const deletedSessionIds = new Set(await this.archiveStoreDeleteArchivedMany(readyDeleteRecords.map((record) => record.sessionId)));
deleteRecords.push(...readyDeleteRecords.filter((record) => deletedSessionIds.has(record.sessionId)));
return summarizeSessionCleanupExecution({
archiveInputs,
deleteRecords,
thresholds: plan.thresholds,
generatedAt: plan.generatedAt,
skippedBusySessionIds: [...skippedBusySessionIds],
});
}
async dispose(): Promise<void> {
clearInterval(this.heartbeat);
this.clearCompactionDrainTimers();
@@ -417,20 +530,25 @@ export class PiSessionService {
return [...unarchivedSessions, ...archivedSessions];
}
async start(cwd: string, parentSession?: string): Promise<ClientSession> {
const active = await this.create(this.sessionManager.create(cwd, parentSession === undefined ? undefined : { parentSession }), cwd);
async start(cwd: string, options: StartSessionOptions = {}): Promise<ClientSession> {
const active = await this.create(
this.sessionManager.create(cwd, options.parentSession === undefined ? undefined : { parentSession: options.parentSession }),
cwd,
options.initialModel === undefined ? {} : { initialModel: options.initialModel },
);
const { session } = active.runtime;
const created: ClientSession = {
id: session.sessionId,
path: session.sessionFile ?? "",
cwd,
persisted: sessionFileExists(session.sessionFile),
created: new Date().toISOString(),
modified: new Date().toISOString(),
messageCount: session.messages.length,
firstMessage: "",
// Include the parent so listeners can nest the new session in the tree
// immediately, instead of showing it flat until the next reload.
...(parentSession === undefined ? {} : { parentSessionPath: parentSession }),
...(options.parentSession === undefined ? {} : { parentSessionPath: options.parentSession }),
};
// Broadcast so other clients (and the spawning agent's UI) can add the new
// session to their list without a manual reload.
@@ -447,7 +565,7 @@ export class PiSessionService {
if (this.spawnTargets === undefined) throw new Error("Spawning sessions is disabled");
const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
if (!decision.allowed) throw spawnTargetError(decision);
const created = await this.start(decision.cwd);
const created = await this.start(decision.cwd, input.model === undefined ? {} : { initialModel: input.model });
await this.prompt(created.id, input.prompt);
this.logger.info(
{ spawningCwd: input.spawningCwd, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length },
@@ -466,7 +584,10 @@ export class PiSessionService {
if (this.spawnTargets === undefined) throw new Error("Spawning sessions is disabled");
const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
if (!decision.allowed) throw spawnTargetError(decision);
const created = await this.start(decision.cwd, input.parentSessionFile);
const created = await this.start(decision.cwd, {
...(input.parentSessionFile === undefined ? {} : { parentSession: input.parentSessionFile }),
...(input.model === undefined ? {} : { initialModel: input.model }),
});
const parentSessionFile = nonEmptyString(input.parentSessionFile);
const link: TrackedSubsessionLink = {
parentSessionId: input.parentSessionId,
@@ -957,7 +1078,7 @@ export class PiSessionService {
}
async saveAttachments(ref: PiSessionLookup, attachments: unknown, folder?: string): Promise<SavedPromptAttachment[]> {
const parsed = parsePromptAttachments(attachments, { enforceInlineSizeLimit: false });
const parsed = parsePromptAttachments(attachments, { enforceInlineSizeLimit: false, allowFileAttachments: true });
if (parsed.length === 0) return [];
await this.assertWritable(ref);
const active = await this.getActive(ref);
@@ -1011,6 +1132,22 @@ export class PiSessionService {
return this.commandService.respond(active.runtime.session.sessionId, requestId, value);
}
private async reloadSessionRuntime(session: PiAgentSession): Promise<void> {
if (this.hasActiveWork(session)) throw new Error("Stop current session activity before reloading");
this.publishActivity(session, "reloading resources", "active");
try {
await session.reload();
this.publishActivity(session, "resources reloaded", "idle");
this.publishStatus(session);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
this.publishActivity(session, "reload failed", "error", message);
this.events.publish(session.sessionId, { type: "session.error", message });
this.publishStatus(session);
throw error;
}
}
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");
@@ -1019,6 +1156,70 @@ export class PiSessionService {
await this.archiveStore.archive(archiveInput);
}
async archiveMany(refs: readonly SessionBulkMutationRef[]): Promise<SessionBulkArchiveResponse> {
const uniqueRefs = uniqueBulkSessionRefs(refs);
const [archivedRecords, sessionContext] = await Promise.all([
this.archiveStore.list(),
this.bulkSessionLookupContext(uniqueRefs),
]);
const failures: SessionBulkFailure[] = [];
const alreadyArchivedSessionIds: string[] = [];
const planItems: BulkArchivePlanItem[] = [];
for (const ref of uniqueRefs) {
const archived = findArchivedRecordForBulkRef(archivedRecords, ref);
if (archived !== undefined) {
alreadyArchivedSessionIds.push(archived.sessionId);
continue;
}
const active = this.activeForLookup(bulkRefToLookup(ref));
const listed = findListedSessionForBulkRef(sessionContext, ref);
const resolvedSessionId = active?.runtime.session.sessionId ?? listed?.id ?? ref.id;
if (active !== undefined && this.hasActiveWork(active.runtime.session)) {
failures.push({ sessionId: resolvedSessionId, error: "Stop current session activity before archiving" });
continue;
}
try {
if (listed !== undefined) {
planItems.push({ input: archiveInputFromListEntry(listed) });
} else if (active !== undefined) {
planItems.push({ input: archiveInputFromActiveSession(active.runtime.session) });
} else {
failures.push({ sessionId: ref.id, error: "Session not found" });
}
} catch (error: unknown) {
failures.push({ sessionId: resolvedSessionId, error: errorMessage(error) });
}
}
const readyInputs: ArchiveSessionInput[] = [];
for (const item of planItems) {
try {
await this.closeActive(item.input.sessionId);
readyInputs.push(item.input);
} catch (error: unknown) {
failures.push({ sessionId: item.input.sessionId, error: errorMessage(error) });
}
}
const archivedSessionIds = [...alreadyArchivedSessionIds];
try {
const archived = await this.archiveStoreArchiveMany(readyInputs);
archivedSessionIds.push(...archived.map((record) => record.sessionId));
} catch (error: unknown) {
for (const input of readyInputs) failures.push({ sessionId: input.sessionId, error: errorMessage(error) });
}
return {
archived: true,
archivedSessionIds: uniqueStrings(archivedSessionIds),
failures,
generatedAt: new Date().toISOString(),
};
}
async archiveTree(ref: PiSessionLookup): Promise<ClientArchiveSessionsResponse> {
const session = await this.getOrOpen(ref);
const catalog = await this.workspaceArchiveCandidates(session.sessionManager.getCwd());
@@ -1029,7 +1230,7 @@ export class PiSessionService {
const archiveInputs = plan.unarchivedTargets.map((target) => archiveInputFromCandidate(target));
for (const input of archiveInputs) await this.closeActive(input.sessionId);
for (const input of archiveInputs) await this.archiveStore.archive(input);
await this.archiveStoreArchiveMany(archiveInputs);
return {
archived: true,
@@ -1056,6 +1257,61 @@ export class PiSessionService {
await this.archiveStore.deleteArchived(record.sessionId);
}
async deleteArchivedMany(refs: readonly SessionBulkMutationRef[]): Promise<SessionBulkDeleteArchivedResponse> {
if (this.archiveStore.deleteArchived === undefined && this.archiveStore.deleteArchivedMany === undefined) throw new Error("Archive store does not support deletion");
const uniqueRefs = uniqueBulkSessionRefs(refs);
const archivedRecords = await this.archiveStore.list();
const failures: SessionBulkFailure[] = [];
const planItems: BulkDeletePlanItem[] = [];
for (const ref of uniqueRefs) {
const record = findArchivedRecordForBulkRef(archivedRecords, ref);
if (record === undefined) {
failures.push({ sessionId: ref.id, error: "Archived session not found" });
continue;
}
const active = this.activeForLookup({ id: record.sessionId, cwd: record.cwd });
if (active !== undefined && this.hasActiveWork(active.runtime.session)) {
failures.push({ sessionId: record.sessionId, error: "Stop current session activity before deleting archived session" });
continue;
}
planItems.push({ record });
}
const readyRecords: ArchivedSessionRecord[] = [];
for (const item of planItems) {
try {
await this.closeActive(item.record.sessionId);
readyRecords.push(item.record);
} catch (error: unknown) {
failures.push({ sessionId: item.record.sessionId, error: errorMessage(error) });
}
}
const moveFailures = await this.moveLegacyArchivedRecordsForDelete(readyRecords);
failures.push(...moveFailures);
const moveFailureIds = new Set(moveFailures.map((failure) => failure.sessionId));
const deleteIds = readyRecords
.map((record) => record.sessionId)
.filter((sessionId) => !moveFailureIds.has(sessionId));
let deletedSessionIds: string[] = [];
try {
deletedSessionIds = await this.archiveStoreDeleteArchivedMany(deleteIds);
} catch (error: unknown) {
for (const sessionId of deleteIds) failures.push({ sessionId, error: errorMessage(error) });
}
return {
deleted: true,
deletedSessionIds,
failures,
generatedAt: new Date().toISOString(),
};
}
async reload(ref: PiSessionLookup): Promise<void> {
await this.assertWritable(ref);
const session = await this.getOrOpen(ref);
@@ -1093,6 +1349,95 @@ export class PiSessionService {
});
}
private async bulkSessionLookupContext(refs: readonly SessionBulkMutationRef[]): Promise<BulkSessionLookupContext> {
const cwdSet = new Set<string>();
let needsAllSessions = false;
for (const ref of refs) {
if (ref.cwd === undefined) needsAllSessions = true;
else cwdSet.add(ref.cwd);
}
const [sessionsByCwd, allSessions] = await Promise.all([
this.listSessionsByCwd([...cwdSet]),
needsAllSessions ? this.sessionManager.listAll?.() ?? Promise.resolve([]) : Promise.resolve(undefined),
]);
return allSessions === undefined ? { sessionsByCwd } : { sessionsByCwd, allSessions };
}
private async listSessionsByCwd(cwds: readonly string[]): Promise<Map<string, PiSessionListEntry[]>> {
const uniqueCwds = uniqueStrings(cwds);
const entries = await Promise.all(uniqueCwds.map(async (cwd) => [cwd, await this.sessionManager.list(cwd)] as const));
return new Map(entries);
}
private async archiveStoreArchiveMany(inputs: readonly ArchiveSessionInput[]): Promise<ArchivedSessionRecord[]> {
if (inputs.length === 0) return [];
if (this.archiveStore.archiveMany !== undefined) return this.archiveStore.archiveMany(inputs);
const records: ArchivedSessionRecord[] = [];
for (const input of inputs) records.push(await this.archiveStore.archive(input));
return records;
}
private async archiveStoreDeleteArchivedMany(sessionIds: readonly string[]): Promise<string[]> {
if (sessionIds.length === 0) return [];
if (this.archiveStore.deleteArchivedMany !== undefined) return this.archiveStore.deleteArchivedMany(sessionIds);
if (this.archiveStore.deleteArchived === undefined) throw new Error("Archive store does not support deletion");
for (const sessionId of sessionIds) await this.archiveStore.deleteArchived(sessionId);
return [...sessionIds];
}
private async moveLegacyArchivedRecordsForDelete(records: readonly ArchivedSessionRecord[]): Promise<SessionBulkFailure[]> {
const legacyRecords = records.filter((record) => record.archivePath === undefined);
if (legacyRecords.length === 0) return [];
let sessionsByCwd: Map<string, PiSessionListEntry[]>;
try {
sessionsByCwd = await this.listSessionsByCwd(legacyRecords.map((record) => record.cwd));
} catch (error: unknown) {
return legacyRecords.map((record) => ({ sessionId: record.sessionId, error: errorMessage(error) }));
}
const moveInputs = legacyRecords
.map((record) => findSessionByIdOrPrefix(sessionsByCwd.get(record.cwd) ?? [], record.sessionId))
.filter(isDefined)
.map(archiveInputFromListEntry);
if (moveInputs.length === 0) return [];
try {
await this.archiveStoreArchiveMany(moveInputs);
return [];
} catch (error: unknown) {
const failedIds = new Set(moveInputs.map((input) => input.sessionId));
return legacyRecords
.filter((record) => failedIds.has(record.sessionId))
.map((record) => ({ sessionId: record.sessionId, error: errorMessage(error) }));
}
}
private async cleanupPlan(request: NormalizedSessionCleanupRequest) {
const [sessions, archivedRecords] = await Promise.all([this.sessionManager.listAll?.() ?? [], this.archiveStore.list()]);
return planSessionCleanup({
sessions,
archivedRecords,
activeSessions: this.cleanupActiveSessionStatuses(),
thresholds: request.thresholds,
...(request.projectCwds === undefined ? {} : { projectCwds: request.projectCwds }),
now: this.now(),
});
}
private cleanupActiveSessionStatuses(): { sessionId: string; hasActiveWork: boolean }[] {
return [...new Set(this.active.values())].map((active) => ({
sessionId: active.runtime.session.sessionId,
hasActiveWork: this.hasActiveWork(active.runtime.session),
}));
}
private activeSessionHasWork(sessionId: string): boolean {
const active = this.active.get(sessionId);
return active !== undefined && this.hasActiveWork(active.runtime.session);
}
private reconcilableSessionIds(cwd: string, listedSessionIds: string[], archivedById: Map<string, ArchivedSessionRecord>): string[] {
const sessionIds = new Set(listedSessionIds);
for (const active of new Set(this.active.values())) {
@@ -1114,7 +1459,20 @@ export class PiSessionService {
private async ensureArchivedRecordMoved(record: ArchivedSessionRecord): Promise<ArchivedSessionRecord> {
const session = (await this.sessionManager.list(record.cwd)).find((candidate) => candidate.id === record.sessionId);
if (session === undefined) return record;
return this.archiveStore.archive(archiveInputFromListEntry(session));
const [moved] = await this.archiveStoreArchiveMany([archiveInputFromListEntry(session)]);
return moved ?? record;
}
private async ensureArchivedRecordsMoved(records: readonly ArchivedSessionRecord[]): Promise<void> {
const legacyRecords = records.filter((record) => record.archivePath === undefined);
if (legacyRecords.length === 0) return;
const sessionsByCwd = await this.listSessionsByCwd(legacyRecords.map((record) => record.cwd));
const moveInputs = legacyRecords
.map((record) => sessionsByCwd.get(record.cwd)?.find((candidate) => candidate.id === record.sessionId))
.filter(isDefined)
.map(archiveInputFromListEntry);
await this.archiveStoreArchiveMany(moveInputs);
}
private async archiveInputForSession(session: PiAgentSession): Promise<ArchiveSessionInput> {
@@ -1234,8 +1592,13 @@ export class PiSessionService {
return undefined;
}
private async create(sessionManager: PiSessionManager, cwd: string): Promise<ActiveSession<PiSessionRuntime>> {
const runtime = await this.createAgentRuntime(this.createRuntime, { cwd, agentDir: this.agentDir, sessionManager });
private async create(sessionManager: PiSessionManager, cwd: string, options: Pick<StartSessionOptions, "initialModel"> = {}): Promise<ActiveSession<PiSessionRuntime>> {
const runtime = await this.createAgentRuntime(this.createRuntime, {
cwd,
agentDir: this.agentDir,
sessionManager,
...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
});
await this.bindSessionExtensions(runtime.session);
const active: ActiveSession<PiSessionRuntime> = { runtime, unsubscribe: noop };
this.bindRuntime(active);
@@ -1346,7 +1709,7 @@ export class PiSessionService {
const model = session.model;
if (model === undefined) return;
void generateShortSessionName(this.modelRegistry, model, firstMessage).then((name) => {
void generateShortSessionName(session.agent.streamFn, model, firstMessage).then((name) => {
this.applyGeneratedSessionName(session, name ?? fallbackSessionName(firstMessage));
}).catch(() => {
this.applyGeneratedSessionName(session, fallbackSessionName(firstMessage));
@@ -1496,6 +1859,7 @@ export class PiSessionService {
const contextUsage = session.getContextUsage();
return {
sessionId: session.sessionId,
persisted: sessionFileExists(session.sessionFile),
...(model === undefined ? {} : { model }),
thinkingLevel: session.thinkingLevel,
isStreaming: session.isStreaming,
@@ -1523,6 +1887,53 @@ export class PiSessionService {
}
}
function previewResponseFromPlan(plan: SessionCleanupPlan): ClientSessionCleanupPreviewResponse {
return {
generatedAt: plan.generatedAt,
thresholds: plan.thresholds,
projects: plan.projects,
totals: plan.totals,
...(plan.skippedBusySessionIds.length === 0 ? {} : { skippedBusySessionIds: plan.skippedBusySessionIds }),
};
}
function uniqueBulkSessionRefs(refs: readonly SessionBulkMutationRef[]): SessionBulkMutationRef[] {
const seen = new Set<string>();
const unique: SessionBulkMutationRef[] = [];
for (const ref of refs) {
const key = `${ref.cwd ?? ""}\0${ref.id}`;
if (seen.has(key)) continue;
seen.add(key);
unique.push(ref);
}
return unique;
}
function bulkRefToLookup(ref: SessionBulkMutationRef): PiSessionLookup {
return ref.cwd === undefined ? ref.id : { id: ref.id, cwd: ref.cwd };
}
function findArchivedRecordForBulkRef(records: readonly ArchivedSessionRecord[], ref: SessionBulkMutationRef): ArchivedSessionRecord | undefined {
return records.find((record) => (ref.cwd === undefined || record.cwd === ref.cwd) && (record.sessionId === ref.id || record.sessionId.startsWith(ref.id)));
}
function findListedSessionForBulkRef(context: BulkSessionLookupContext, ref: SessionBulkMutationRef): PiSessionListEntry | undefined {
if (ref.cwd !== undefined) return findSessionByIdOrPrefix(context.sessionsByCwd.get(ref.cwd) ?? [], ref.id);
return context.allSessions === undefined ? undefined : findSessionByIdOrPrefix(context.allSessions, ref.id);
}
function findSessionByIdOrPrefix(sessions: readonly PiSessionListEntry[], sessionId: string): PiSessionListEntry | undefined {
return sessions.find((session) => session.id === sessionId) ?? sessions.find((session) => session.id.startsWith(sessionId));
}
function uniqueStrings(values: readonly string[]): string[] {
return [...new Set(values)];
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function modelToClientModel(model: PiAgentSession["model"]): ClientSessionModel {
if (model === undefined) return {};
const name = getString(model, "name");
@@ -1541,6 +1952,7 @@ function clientSessionFromListEntry(session: PiSessionListEntry): ClientSession
id: session.id,
path: session.path,
cwd: session.cwd,
persisted: true,
...(session.name === undefined ? {} : { name: session.name }),
created: session.created.toISOString(),
modified: session.modified.toISOString(),
@@ -1743,6 +2155,15 @@ function sessionPathsEqual(a: string, b: string): boolean {
return cwdPathsEqual(a, b);
}
function sessionFileExists(sessionFile: string | undefined): sessionFile is string {
if (sessionFile === undefined || sessionFile === "") return false;
try {
return statSync(sessionFile).isFile();
} catch {
return false;
}
}
function sessionFileMatches(session: PiAgentSession, expectedSessionFile: string | undefined): boolean {
const sessionFile = nonEmptyString(session.sessionFile);
return sessionFile !== undefined && expectedSessionFile !== undefined && sessionPathsEqual(sessionFile, expectedSessionFile);
@@ -71,6 +71,53 @@ describe("SessionArchiveStore", () => {
expect(await exists(record.archivePath)).toBe(false);
await expect(store.list()).resolves.toEqual([]);
});
it("archives and permanently deletes sessions in batches", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-web-archive-batch-"));
tempRoots.push(root);
const activeDir = join(root, "active");
await mkdir(activeDir, { recursive: true });
const sourceA = join(activeDir, "2026-01-01_a.jsonl");
const sourceB = join(activeDir, "2026-01-01_b.jsonl");
await writeFile(sourceA, "a\n", "utf8");
await writeFile(sourceB, "b\n", "utf8");
const store = new SessionArchiveStore(join(root, "archived-sessions.json"), join(root, "archived-files"));
const records = await store.archiveMany([
{
sessionId: "a",
cwd: "/workspace",
path: sourceA,
created: "2026-01-01T00:00:00.000Z",
modified: "2026-01-01T00:01:00.000Z",
messageCount: 1,
firstMessage: "a",
},
{
sessionId: "b",
cwd: "/workspace",
path: sourceB,
created: "2026-01-01T00:00:00.000Z",
modified: "2026-01-01T00:02:00.000Z",
messageCount: 2,
firstMessage: "b",
},
]);
expect(records.map((record) => record.sessionId)).toEqual(["a", "b"]);
expect(await exists(sourceA)).toBe(false);
expect(await exists(sourceB)).toBe(false);
await expect(store.list()).resolves.toMatchObject([{ sessionId: "a" }, { sessionId: "b" }]);
const archivePaths = records.map((record) => record.archivePath);
if (archivePaths.some((path) => path === undefined)) throw new Error("Expected archive paths");
await expect(store.deleteArchivedMany(["a", "b", "missing"])).resolves.toEqual(["a", "b"]);
for (const archivePath of archivePaths) {
if (archivePath === undefined) throw new Error("Expected archive path");
expect(await exists(archivePath)).toBe(false);
}
await expect(store.list()).resolves.toEqual([]);
});
});
async function exists(path: string): Promise<boolean> {
+49 -19
View File
@@ -53,24 +53,39 @@ export class SessionArchiveStore {
}
async archive(session: ArchiveSessionInput): Promise<ArchivedSessionRecord> {
const [record] = await this.archiveMany([session]);
if (record === undefined) throw new Error("Archive operation did not produce a record");
return record;
}
async archiveMany(sessions: readonly ArchiveSessionInput[]): Promise<ArchivedSessionRecord[]> {
if (sessions.length === 0) return [];
return this.exclusive(async () => {
const data = await this.read();
const existingIndex = data.sessions.findIndex((record) => record.sessionId === session.sessionId);
const existing = existingIndex === -1 ? undefined : data.sessions[existingIndex];
const archivePath = existing?.archivePath ?? this.archivePathFor(session);
const record = archiveRecordFromInput(session, {
archivedAt: existing?.archivedAt ?? new Date().toISOString(),
originalPath: existing?.originalPath ?? session.path,
archivePath,
});
const records: ArchivedSessionRecord[] = [];
const filesToRemove: { source: string; archivePath: string }[] = [];
await copySessionFileToArchive(session.path, archivePath);
for (const session of sessions) {
const existingIndex = data.sessions.findIndex((record) => record.sessionId === session.sessionId);
const existing = existingIndex === -1 ? undefined : data.sessions[existingIndex];
const archivePath = existing?.archivePath ?? this.archivePathFor(session);
const record = archiveRecordFromInput(session, {
archivedAt: existing?.archivedAt ?? new Date().toISOString(),
originalPath: existing?.originalPath ?? session.path,
archivePath,
});
await copySessionFileToArchive(session.path, archivePath);
if (existingIndex === -1) data.sessions.push(record);
else data.sessions[existingIndex] = record;
records.push(record);
filesToRemove.push({ source: session.path, archivePath });
}
if (existingIndex === -1) data.sessions.push(record);
else data.sessions[existingIndex] = record;
await this.write(data);
await removeActiveSessionFile(session.path, archivePath);
return record;
for (const file of filesToRemove) await removeActiveSessionFile(file.source, file.archivePath);
return records;
});
}
@@ -90,14 +105,25 @@ export class SessionArchiveStore {
}
async deleteArchived(sessionId: string): Promise<void> {
await this.exclusive(async () => {
const data = await this.read();
const record = data.sessions.find((session) => session.sessionId === sessionId);
if (record === undefined) return;
await this.deleteArchivedMany([sessionId]);
}
if (record.archivePath !== undefined && await pathExists(record.archivePath)) await unlink(record.archivePath);
const sessions = data.sessions.filter((session) => session.sessionId !== sessionId);
async deleteArchivedMany(sessionIds: readonly string[]): Promise<string[]> {
const targetIds = uniqueStrings(sessionIds);
if (targetIds.length === 0) return [];
return this.exclusive(async () => {
const data = await this.read();
const targetIdSet = new Set(targetIds);
const records = data.sessions.filter((session) => targetIdSet.has(session.sessionId));
if (records.length === 0) return [];
for (const record of records) {
if (record.archivePath !== undefined && await pathExists(record.archivePath)) await unlink(record.archivePath);
}
const sessions = data.sessions.filter((session) => !targetIdSet.has(session.sessionId));
await this.write({ sessions });
const deletedIds = new Set(records.map((record) => record.sessionId));
return targetIds.filter((sessionId) => deletedIds.has(sessionId));
});
}
@@ -254,6 +280,10 @@ function safeFileName(value: string): string {
return value.replace(/[^a-zA-Z0-9._-]/g, "_") || "session";
}
function uniqueStrings(values: readonly string[]): string[] {
return [...new Set(values)];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
+108
View File
@@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest";
import { normalizeSessionCleanupRequest, normalizeSessionCleanupThresholds, planSessionCleanup } from "./sessionCleanup.js";
import type { PiSessionListEntry } from "./piSessionService.js";
import type { ArchivedSessionRecord } from "./sessionArchiveStore.js";
describe("session cleanup planning", () => {
it("plans cleanup by strict cutoffs and groups counts by stored cwd", () => {
const now = new Date("2026-06-25T00:00:00.000Z");
const archivedRecords: ArchivedSessionRecord[] = [
archivedRecord("already-archived", "/unregistered", "2026-06-20T00:00:00.000Z"),
archivedRecord("delete-old", "/other", "2026-06-14T23:59:59.999Z"),
archivedRecord("keep-exact", "/other", "2026-06-15T00:00:00.000Z"),
];
const plan = planSessionCleanup({
now,
thresholds: { archiveIdleDays: 30, deleteArchivedDays: 10 },
archivedRecords,
sessions: [
sessionEntry("archive-old", "/unregistered", "2026-05-25T23:59:59.999Z"),
sessionEntry("keep-exact", "/unregistered", "2026-05-26T00:00:00.000Z"),
sessionEntry("keep-new", "/unregistered", "2026-05-26T00:00:00.001Z"),
sessionEntry("already-archived", "/unregistered", "2026-05-01T00:00:00.000Z"),
],
});
expect(plan.archiveInputs.map((input) => input.sessionId)).toEqual(["archive-old"]);
expect(plan.deleteRecords.map((record) => record.sessionId)).toEqual(["delete-old"]);
expect(plan.projects).toEqual([
{ cwd: "/other", archiveCount: 0, deleteCount: 1 },
{ cwd: "/unregistered", archiveCount: 1, deleteCount: 0 },
]);
expect(plan.totals).toEqual({ archiveCount: 1, deleteCount: 1 });
});
it("filters cleanup candidates to selected project cwd paths", () => {
const plan = planSessionCleanup({
now: new Date("2026-06-25T00:00:00.000Z"),
thresholds: { archiveIdleDays: 30, deleteArchivedDays: 30 },
projectCwds: ["/repo-a"],
sessions: [
sessionEntry("archive-a", "/repo-a", "2026-05-01T00:00:00.000Z"),
sessionEntry("archive-b", "/repo-b", "2026-05-01T00:00:00.000Z"),
],
archivedRecords: [
archivedRecord("delete-a", "/repo-a", "2026-05-01T00:00:00.000Z"),
archivedRecord("delete-b", "/repo-b", "2026-05-01T00:00:00.000Z"),
],
});
expect(plan.archiveInputs.map((input) => input.sessionId)).toEqual(["archive-a"]);
expect(plan.deleteRecords.map((record) => record.sessionId)).toEqual(["delete-a"]);
expect(plan.projects).toEqual([{ cwd: "/repo-a", archiveCount: 1, deleteCount: 1 }]);
expect(plan.totals).toEqual({ archiveCount: 1, deleteCount: 1 });
});
it("skips archive and delete candidates that are busy in memory", () => {
const plan = planSessionCleanup({
now: new Date("2026-06-25T00:00:00.000Z"),
thresholds: { archiveIdleDays: 1, deleteArchivedDays: 1 },
sessions: [sessionEntry("busy-open", "/repo", "2026-06-01T00:00:00.000Z")],
archivedRecords: [archivedRecord("busy-archived", "/repo", "2026-06-01T00:00:00.000Z")],
activeSessions: [
{ sessionId: "busy-open", hasActiveWork: true },
{ sessionId: "busy-archived", hasActiveWork: true },
],
});
expect(plan.archiveInputs).toHaveLength(0);
expect(plan.deleteRecords).toHaveLength(0);
expect(plan.skippedBusySessionIds).toEqual(["busy-archived", "busy-open"]);
expect(plan.totals).toEqual({ archiveCount: 0, deleteCount: 0 });
});
it("validates optional runtime thresholds", () => {
expect(normalizeSessionCleanupThresholds({ archiveIdleDays: 30, deleteArchivedDays: null })).toEqual({ archiveIdleDays: 30 });
expect(normalizeSessionCleanupThresholds({})).toEqual({});
expect(() => normalizeSessionCleanupThresholds({ archiveIdleDays: -1 })).toThrow("archiveIdleDays field must be a non-negative integer");
expect(() => normalizeSessionCleanupThresholds({ deleteArchivedDays: 1.5 })).toThrow("deleteArchivedDays field must be a non-negative integer");
expect(() => normalizeSessionCleanupThresholds({ archiveIdleDays: "30" })).toThrow("archiveIdleDays field must be a non-negative integer");
});
it("validates optional selected project cwd paths", () => {
expect(normalizeSessionCleanupRequest({ archiveIdleDays: 30, projectCwds: ["/repo", "/repo"] })).toEqual({
thresholds: { archiveIdleDays: 30 },
projectCwds: ["/repo"],
});
expect(normalizeSessionCleanupRequest({ projectCwds: null })).toEqual({ thresholds: {} });
expect(() => normalizeSessionCleanupRequest({ projectCwds: ["/repo", 1] })).toThrow("projectCwds field must be an array of strings");
});
});
function sessionEntry(id: string, cwd: string, modified: string): PiSessionListEntry {
return {
id,
cwd,
path: `/sessions/${id}.jsonl`,
created: new Date("2026-01-01T00:00:00.000Z"),
modified: new Date(modified),
messageCount: 1,
firstMessage: "hello",
allMessagesText: "hello",
};
}
function archivedRecord(sessionId: string, cwd: string, archivedAt: string): ArchivedSessionRecord {
return { sessionId, cwd, archivedAt };
}
+203
View File
@@ -0,0 +1,203 @@
import type { SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupThresholds } from "../../shared/apiTypes.js";
import type { PiSessionListEntry } from "./piSessionService.js";
import type { ArchivedSessionRecord, ArchiveSessionInput } from "./sessionArchiveStore.js";
const DAY_MS = 24 * 60 * 60 * 1000;
export interface CleanupActiveSessionStatus {
sessionId: string;
hasActiveWork: boolean;
}
export interface PlanSessionCleanupInput {
sessions: readonly PiSessionListEntry[];
archivedRecords: readonly ArchivedSessionRecord[];
activeSessions?: readonly CleanupActiveSessionStatus[];
thresholds: SessionCleanupThresholds;
projectCwds?: readonly string[];
now: Date;
}
export interface SessionCleanupPlan extends SessionCleanupPreviewResponse {
archiveInputs: ArchiveSessionInput[];
deleteRecords: ArchivedSessionRecord[];
skippedBusySessionIds: string[];
}
export interface NormalizedSessionCleanupRequest {
thresholds: SessionCleanupThresholds;
/** Stored cwd paths to include. Undefined means all discovered projects/workspaces. */
projectCwds?: string[];
}
export function normalizeSessionCleanupRequest(record: Record<string, unknown>): NormalizedSessionCleanupRequest {
const projectCwds = optionalProjectCwds(record);
return {
thresholds: normalizeSessionCleanupThresholds(record),
...(projectCwds === undefined ? {} : { projectCwds }),
};
}
export function normalizeSessionCleanupThresholds(record: Record<string, unknown>): SessionCleanupThresholds {
const thresholds: SessionCleanupThresholds = {};
const archiveIdleDays = optionalDayThreshold(record, "archiveIdleDays");
const deleteArchivedDays = optionalDayThreshold(record, "deleteArchivedDays");
if (archiveIdleDays !== undefined) thresholds.archiveIdleDays = archiveIdleDays;
if (deleteArchivedDays !== undefined) thresholds.deleteArchivedDays = deleteArchivedDays;
return thresholds;
}
export function planSessionCleanup(input: PlanSessionCleanupInput): SessionCleanupPlan {
const thresholds = copyThresholds(input.thresholds);
const archiveCutoff = cutoffTime(input.now, thresholds.archiveIdleDays);
const deleteCutoff = cutoffTime(input.now, thresholds.deleteArchivedDays);
const archivedIds = new Set(input.archivedRecords.map((record) => record.sessionId));
const includedCwds = input.projectCwds === undefined ? undefined : new Set(input.projectCwds);
const busySessionIds = new Set((input.activeSessions ?? []).filter((session) => session.hasActiveWork).map((session) => session.sessionId));
const skippedBusy = new Set<string>();
const archiveInputs: ArchiveSessionInput[] = [];
const deleteRecords: ArchivedSessionRecord[] = [];
if (archiveCutoff !== undefined) {
for (const session of uniqueSessionsById(input.sessions)) {
if (archivedIds.has(session.id)) continue;
if (includedCwds !== undefined && !includedCwds.has(session.cwd)) continue;
if (!isBefore(session.modified, archiveCutoff)) continue;
if (busySessionIds.has(session.id)) {
skippedBusy.add(session.id);
continue;
}
archiveInputs.push(archiveInputFromListEntry(session));
}
}
if (deleteCutoff !== undefined) {
for (const record of input.archivedRecords) {
if (includedCwds !== undefined && !includedCwds.has(record.cwd)) continue;
if (!isTimestampBefore(record.archivedAt, deleteCutoff)) continue;
if (busySessionIds.has(record.sessionId)) {
skippedBusy.add(record.sessionId);
continue;
}
deleteRecords.push(record);
}
}
return {
...summarizeSessionCleanupTargets({ archiveInputs, deleteRecords, thresholds, generatedAt: input.now.toISOString(), skippedBusySessionIds: [...skippedBusy] }),
archiveInputs,
deleteRecords,
skippedBusySessionIds: [...skippedBusy].sort(),
};
}
export function summarizeSessionCleanupTargets(input: {
archiveInputs: readonly ArchiveSessionInput[];
deleteRecords: readonly ArchivedSessionRecord[];
thresholds: SessionCleanupThresholds;
generatedAt: string;
skippedBusySessionIds?: readonly string[];
}): SessionCleanupPreviewResponse {
const projectsByCwd = new Map<string, SessionCleanupProjectSummary>();
let archiveCount = 0;
let deleteCount = 0;
for (const session of input.archiveInputs) {
archiveCount += 1;
projectSummary(projectsByCwd, session.cwd).archiveCount += 1;
}
for (const record of input.deleteRecords) {
deleteCount += 1;
projectSummary(projectsByCwd, record.cwd).deleteCount += 1;
}
const skippedBusySessionIds = [...new Set(input.skippedBusySessionIds ?? [])].sort();
return {
generatedAt: input.generatedAt,
thresholds: copyThresholds(input.thresholds),
projects: [...projectsByCwd.values()].sort((a, b) => a.cwd.localeCompare(b.cwd)),
totals: { archiveCount, deleteCount },
...(skippedBusySessionIds.length === 0 ? {} : { skippedBusySessionIds }),
};
}
export function summarizeSessionCleanupExecution(input: {
archiveInputs: readonly ArchiveSessionInput[];
deleteRecords: readonly ArchivedSessionRecord[];
thresholds: SessionCleanupThresholds;
generatedAt: string;
skippedBusySessionIds?: readonly string[];
}): SessionCleanupExecuteResponse {
return {
...summarizeSessionCleanupTargets(input),
archivedSessionIds: input.archiveInputs.map((session) => session.sessionId),
deletedSessionIds: input.deleteRecords.map((record) => record.sessionId),
};
}
function optionalDayThreshold(record: Record<string, unknown>, field: keyof SessionCleanupThresholds): number | undefined {
const value = record[field];
if (value === undefined || value === null) return undefined;
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) throw new Error(`${field} field must be a non-negative integer`);
return value;
}
function optionalProjectCwds(record: Record<string, unknown>): string[] | undefined {
const value = record["projectCwds"];
if (value === undefined || value === null) return undefined;
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) throw new Error("projectCwds field must be an array of strings");
return [...new Set(value)];
}
function cutoffTime(now: Date, days: number | undefined): number | undefined {
return days === undefined ? undefined : now.getTime() - days * DAY_MS;
}
function isBefore(value: Date, cutoff: number): boolean {
const time = value.getTime();
return Number.isFinite(time) && time < cutoff;
}
function isTimestampBefore(value: string, cutoff: number): boolean {
const time = Date.parse(value);
return Number.isFinite(time) && time < cutoff;
}
function uniqueSessionsById(sessions: readonly PiSessionListEntry[]): PiSessionListEntry[] {
const sessionsById = new Map<string, PiSessionListEntry>();
for (const session of sessions) {
const existing = sessionsById.get(session.id);
if (existing === undefined || session.modified.getTime() > existing.modified.getTime()) sessionsById.set(session.id, session);
}
return [...sessionsById.values()];
}
function archiveInputFromListEntry(session: PiSessionListEntry): ArchiveSessionInput {
return {
sessionId: session.id,
cwd: session.cwd,
path: session.path,
created: session.created.toISOString(),
modified: session.modified.toISOString(),
messageCount: session.messageCount,
firstMessage: session.firstMessage,
...(session.name === undefined ? {} : { name: session.name }),
...(session.parentSessionPath === undefined ? {} : { parentSessionPath: session.parentSessionPath }),
};
}
function projectSummary(projectsByCwd: Map<string, SessionCleanupProjectSummary>, cwd: string): SessionCleanupProjectSummary {
const existing = projectsByCwd.get(cwd);
if (existing !== undefined) return existing;
const created = { cwd, archiveCount: 0, deleteCount: 0 };
projectsByCwd.set(cwd, created);
return created;
}
function copyThresholds(thresholds: SessionCleanupThresholds): SessionCleanupThresholds {
const copy: SessionCleanupThresholds = {};
if (thresholds.archiveIdleDays !== undefined) copy.archiveIdleDays = thresholds.archiveIdleDays;
if (thresholds.deleteArchivedDays !== undefined) copy.deleteArchivedDays = thresholds.deleteArchivedDays;
return copy;
}
@@ -106,6 +106,30 @@ describe("SessionCommandService", () => {
expect(active.runtime.session.compact).toHaveBeenCalledWith("focus on tests");
});
it("reloads runtime resources through the injected lifecycle callback", async () => {
const active = activeSession();
const reloadSession = vi.fn(async () => { await Promise.resolve(); });
const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher(), { reloadSession });
await expect(service.run("s1", "/reload")).resolves.toEqual({
type: "done",
message: "Session runtime resources reloaded. Extensions, skills, prompt templates, themes, and context/system prompt files are refreshed for this session. Reload the browser page separately for PI WEB browser plugin changes.",
});
expect(reloadSession).toHaveBeenCalledWith(active.runtime.session);
});
it("rejects runtime reload while the session has active work", async () => {
const active = activeSession({ isBashRunning: true });
const reloadSession = vi.fn(async () => { await Promise.resolve(); });
const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher(), { reloadSession });
await expect(service.run("s1", "/reload")).resolves.toEqual({
type: "unsupported",
message: "Cannot reload while the session is active. Stop current activity before reloading.",
});
expect(reloadSession).not.toHaveBeenCalled();
});
it("creates fork selection requests from newest message to oldest and responds with selected entry", async () => {
const active = activeSession({
getUserMessagesForForking: vi.fn(() => [
+21 -4
View File
@@ -47,6 +47,12 @@ export interface CommandEventPublisher {
publishGlobal?(event: Extract<SessionUiEvent, { type: "session.name" }>): void;
}
export interface SessionCommandLifecycle<TSession extends CommandSession = CommandSession> {
onCompactionStart?: (session: TSession) => void;
onCompactionEnd?: (session: TSession, result: "success" | "error", detail?: string) => void;
reloadSession?: (session: TSession) => Promise<void>;
}
export interface SessionCommandNaming {
listSessionNames?: (cwd: string) => Promise<readonly string[]>;
}
@@ -65,10 +71,7 @@ export class SessionCommandService<TSession extends CommandSession = CommandSess
private readonly getActive: GetCommandActiveSession<TSession>,
private readonly prompt: (sessionId: string, text: string) => Promise<void>,
private readonly events: CommandEventPublisher,
private readonly lifecycle: {
onCompactionStart?: (session: TSession) => void;
onCompactionEnd?: (session: TSession, result: "success" | "error", detail?: string) => void;
} = {},
private readonly lifecycle: SessionCommandLifecycle<TSession> = {},
private readonly naming: SessionCommandNaming = {},
) {}
@@ -93,6 +96,7 @@ export class SessionCommandService<TSession extends CommandSession = CommandSess
if (name === "session") return { type: "done", message: formatSessionStats(session) };
if (name === "name") return this.nameSession(active, rest);
if (name === "compact") return this.compact(session, rest);
if (name === "reload") return this.reload(session);
if (name === "clone") return this.clone(active);
if (name === "fork") return this.fork(active);
@@ -140,6 +144,19 @@ export class SessionCommandService<TSession extends CommandSession = CommandSess
return { type: "done", message: "Compaction started…" };
}
private async reload(session: TSession): Promise<ClientCommandResult> {
if (sessionHasActiveWork(session)) return { type: "unsupported", message: "Cannot reload while the session is active. Stop current activity before reloading." };
if (this.lifecycle.reloadSession === undefined) return { type: "unsupported", message: "/reload is not available for this session runtime." };
try {
await this.lifecycle.reloadSession(session);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return { type: "unsupported", message: `Reload failed: ${message}` };
}
return { type: "done", message: "Session runtime resources reloaded. Extensions, skills, prompt templates, themes, and context/system prompt files are refreshed for this session. Reload the browser page separately for PI WEB browser plugin changes." };
}
private async clone(active: CommandActiveSession<TSession>): Promise<ClientCommandResult> {
if (sessionHasActiveWork(active.runtime.session)) return forkActiveUnsupported("clone");
const leafId = active.runtime.session.sessionManager.getLeafId();
@@ -1,7 +1,70 @@
import type { Api, AssistantMessage, Model } from "@earendil-works/pi-ai";
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
import type { StreamFn } from "@earendil-works/pi-agent-core";
import { describe, expect, it } from "vitest";
import { cleanSessionName, fallbackSessionName } from "./sessionNameGenerator.js";
import { cleanSessionName, fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
function fakeModel(): Model<Api> {
return { id: "fake-model", name: "Fake Model", api: "anthropic-messages", provider: "anthropic", baseUrl: "https://example.test", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1000, maxTokens: 100 };
}
function fakeAssistantMessage(overrides: Partial<AssistantMessage> = {}): AssistantMessage {
return {
role: "assistant",
content: [],
api: "anthropic-messages",
provider: "anthropic",
model: "fake-model",
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
stopReason: "stop",
timestamp: Date.now(),
...overrides,
};
}
function streamThatCompletes(text: string): StreamFn {
return () => {
const stream = createAssistantMessageEventStream();
const message = fakeAssistantMessage({ content: [{ type: "text", text }] });
stream.push({ type: "done", reason: "stop", message });
stream.end(message);
return stream;
};
}
function streamThatErrors(): StreamFn {
return () => {
const stream = createAssistantMessageEventStream();
const message = fakeAssistantMessage({ stopReason: "error", errorMessage: "boom" });
stream.push({ type: "error", reason: "error", error: message });
stream.end(message);
return stream;
};
}
describe("sessionNameGenerator", () => {
it("generates a session name by calling the injected streamFn", async () => {
const calls: unknown[] = [];
const stream = streamThatCompletes('Title: "Fix the bug"');
const streamFn: StreamFn = (model, context, options) => {
calls.push({ model, context, options });
return stream(model, context, options);
};
const name = await generateShortSessionName(streamFn, fakeModel(), "Please fix the login bug");
expect(name).toBe("Fix the bug");
expect(calls).toHaveLength(1);
});
it("returns undefined when the stream reports an error", async () => {
const streamFn = streamThatErrors();
const name = await generateShortSessionName(streamFn, fakeModel(), "Please fix the login bug");
expect(name).toBeUndefined();
});
it("cleans model-generated titles", () => {
expect(cleanSessionName('Title: "Fix Session Naming."\nextra')).toBe("Fix Session Naming");
});
+4 -62
View File
@@ -1,33 +1,13 @@
import type { Api, AssistantMessage, AssistantMessageEventStream, Context, Model, SimpleStreamOptions } from "@earendil-works/pi-ai";
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
import type { Api, AssistantMessage, Model } from "@earendil-works/pi-ai";
import type { StreamFn } from "@earendil-works/pi-agent-core";
const SESSION_NAME_TIMEOUT_MS = 10_000;
const SESSION_NAME_MAX_INPUT_CHARS = 4_000;
const SESSION_NAME_MAX_LENGTH = 60;
const FALLBACK_SESSION_NAME_MAX_WORDS = 6;
const PI_AI_COMPAT_MODULE = ["@earendil-works/pi-ai", "compat"].join("/");
interface SessionNameApiProvider {
streamSimple(model: Model<Api>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream;
}
interface PiAiProviderRegistryModule {
getApiProvider?: (api: Api) => SessionNameApiProvider | undefined;
}
type ModuleImporter = (specifier: string) => Promise<unknown>;
let piAiProviderRegistryModulePromise: Promise<PiAiProviderRegistryModule> | undefined;
export async function generateShortSessionName<TApi extends Api>(modelRegistry: ModelRegistry, model: Model<TApi>, firstMessage: string): Promise<string | undefined> {
const providerRegistry = await getPiAiProviderRegistryModule();
const provider = providerRegistry.getApiProvider?.(model.api);
if (provider === undefined) return undefined;
const auth = await modelRegistry.getApiKeyAndHeaders(model);
if (!auth.ok) return undefined;
const stream = provider.streamSimple(
export async function generateShortSessionName<TApi extends Api>(streamFn: StreamFn, model: Model<TApi>, firstMessage: string): Promise<string | undefined> {
const stream = await streamFn(
model,
{
systemPrompt: "Generate a concise title for a coding-agent chat session. Return only the title, with no quotes or punctuation wrapper.",
@@ -41,8 +21,6 @@ export async function generateShortSessionName<TApi extends Api>(modelRegistry:
maxTokens: 24,
reasoning: "minimal",
signal: AbortSignal.timeout(SESSION_NAME_TIMEOUT_MS),
...(auth.apiKey === undefined ? {} : { apiKey: auth.apiKey }),
...(auth.headers === undefined ? {} : { headers: auth.headers }),
},
);
@@ -81,42 +59,6 @@ export function cleanSessionName(value: string): string | undefined {
return title === "" ? undefined : title;
}
async function getPiAiProviderRegistryModule(importer: ModuleImporter = (specifier) => import(specifier)): Promise<PiAiProviderRegistryModule> {
piAiProviderRegistryModulePromise ??= loadPiAiProviderRegistryModule(importer);
return piAiProviderRegistryModulePromise;
}
async function loadPiAiProviderRegistryModule(importer: ModuleImporter): Promise<PiAiProviderRegistryModule> {
const compatModule = await importOptionalPiAiModule(PI_AI_COMPAT_MODULE, importer);
if (hasGetApiProvider(compatModule)) return compatModule;
const rootModule = await importer("@earendil-works/pi-ai");
if (hasGetApiProvider(rootModule)) return rootModule;
return {};
}
async function importOptionalPiAiModule(specifier: string, importer: ModuleImporter): Promise<unknown> {
try {
return await importer(specifier);
} catch (error) {
if (isModuleUnavailableError(error)) return undefined;
throw error;
}
}
function hasGetApiProvider(moduleValue: unknown): moduleValue is PiAiProviderRegistryModule {
return typeof moduleValue === "object"
&& moduleValue !== null
&& "getApiProvider" in moduleValue
&& typeof moduleValue.getApiProvider === "function";
}
function isModuleUnavailableError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
const code = "code" in error ? error.code : undefined;
return code === "ERR_MODULE_NOT_FOUND" || code === "ERR_PACKAGE_PATH_NOT_EXPORTED";
}
function textFromAssistant(message: AssistantMessage): string {
return message.content
.filter((part) => part.type === "text")
+109
View File
@@ -2,9 +2,11 @@ import { resolve } from "node:path";
import Fastify, { type FastifyInstance } from "fastify";
import fastifyWebsocket from "@fastify/websocket";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkMutationRef, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse } from "../../shared/apiTypes.js";
import { SessionEventHub } from "../realtime/sessionEventHub.js";
import { PiSessionService, type PiSessionManagerGateway, type PiSessionRef } from "./piSessionService.js";
import { registerSessionRoutes } from "./sessionRoutes.js";
import type { NormalizedSessionCleanupRequest } from "./sessionCleanup.js";
let app: FastifyInstance;
let service: PiSessionService;
@@ -136,17 +138,124 @@ describe("session routes", () => {
await routeApp.close();
}
});
it("normalizes cleanup requests for preview and execute routes", async () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const routeService = new CapturingRouteSessionService(eventHub);
registerSessionRoutes(routeApp, routeService, eventHub);
try {
const previewResponse = await routeApp.inject({ method: "POST", url: "/sessions/cleanup/preview", payload: { archiveIdleDays: 30, deleteArchivedDays: null, projectCwds: ["/repo-a", "/repo-a"] } });
const executeResponse = await routeApp.inject({ method: "POST", url: "/sessions/cleanup", payload: { archiveIdleDays: null, deleteArchivedDays: 7, projectCwds: ["/repo-b"] } });
expect(previewResponse.statusCode).toBe(200);
expect(executeResponse.statusCode).toBe(200);
expect(routeService.cleanupPreviewCalls).toEqual([{ thresholds: { archiveIdleDays: 30 }, projectCwds: ["/repo-a"] }]);
expect(routeService.cleanupCalls).toEqual([{ thresholds: { deleteArchivedDays: 7 }, projectCwds: ["/repo-b"] }]);
} finally {
await routeService.dispose();
await routeApp.close();
}
});
it("rejects invalid cleanup thresholds before calling the service", async () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const routeService = new CapturingRouteSessionService(eventHub);
registerSessionRoutes(routeApp, routeService, eventHub);
try {
const response = await routeApp.inject({ method: "POST", url: "/sessions/cleanup", payload: { archiveIdleDays: -1 } });
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "archiveIdleDays field must be a non-negative integer" });
expect(routeService.cleanupCalls).toEqual([]);
} finally {
await routeService.dispose();
await routeApp.close();
}
});
it("routes bulk archive and delete requests with normalized session refs", async () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const routeService = new CapturingRouteSessionService(eventHub);
registerSessionRoutes(routeApp, routeService, eventHub);
try {
const requestCwd = resolve("/repo");
const archiveResponse = await routeApp.inject({ method: "POST", url: "/sessions/bulk/archive", payload: { sessions: [{ id: "s1", cwd: requestCwd }, { id: "s2" }] } });
const deleteResponse = await routeApp.inject({ method: "POST", url: "/sessions/bulk/delete-archived", payload: { sessions: [{ id: "s1", cwd: requestCwd }] } });
expect(archiveResponse.statusCode).toBe(200);
expect(archiveResponse.json()).toMatchObject({ archived: true, archivedSessionIds: ["s1", "s2"], failures: [] });
expect(deleteResponse.statusCode).toBe(200);
expect(deleteResponse.json()).toMatchObject({ deleted: true, deletedSessionIds: ["s1"], failures: [] });
expect(routeService.bulkArchiveCalls).toEqual([[{ id: "s1", cwd: requestCwd }, { id: "s2" }]]);
expect(routeService.bulkDeleteCalls).toEqual([[{ id: "s1", cwd: requestCwd }]]);
} finally {
await routeService.dispose();
await routeApp.close();
}
});
it("rejects malformed bulk mutation bodies before calling the service", async () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const routeService = new CapturingRouteSessionService(eventHub);
registerSessionRoutes(routeApp, routeService, eventHub);
try {
const response = await routeApp.inject({ method: "POST", url: "/sessions/bulk/archive", payload: { sessions: [{ cwd: "/repo" }] } });
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "id field must be a string" });
expect(routeService.bulkArchiveCalls).toEqual([]);
} finally {
await routeService.dispose();
await routeApp.close();
}
});
});
class CapturingRouteSessionService extends PiSessionService {
readonly calls: unknown[] = [];
readonly reloadCalls: (string | PiSessionRef)[] = [];
readonly cleanupPreviewCalls: NormalizedSessionCleanupRequest[] = [];
readonly cleanupCalls: NormalizedSessionCleanupRequest[] = [];
readonly bulkArchiveCalls: SessionBulkMutationRef[][] = [];
readonly bulkDeleteCalls: SessionBulkMutationRef[][] = [];
reloadError: Error | undefined;
constructor(eventHub: SessionEventHub) {
super(eventHub, { sessionManager: new RejectingSessionManager(), heartbeatIntervalMs: 60_000 });
}
override cleanupPreview(request: NormalizedSessionCleanupRequest): Promise<SessionCleanupPreviewResponse> {
this.cleanupPreviewCalls.push(request);
return Promise.resolve({ generatedAt: "2026-06-25T00:00:00.000Z", thresholds: request.thresholds, projects: [], totals: { archiveCount: 0, deleteCount: 0 } });
}
override cleanup(request: NormalizedSessionCleanupRequest): Promise<SessionCleanupExecuteResponse> {
this.cleanupCalls.push(request);
return Promise.resolve({ generatedAt: "2026-06-25T00:00:00.000Z", thresholds: request.thresholds, projects: [], totals: { archiveCount: 0, deleteCount: 0 }, archivedSessionIds: [], deletedSessionIds: [] });
}
override archiveMany(refs: readonly SessionBulkMutationRef[]): Promise<SessionBulkArchiveResponse> {
this.bulkArchiveCalls.push([...refs]);
return Promise.resolve({ archived: true, archivedSessionIds: refs.map((ref) => ref.id), failures: [], generatedAt: "2026-06-25T00:00:00.000Z" });
}
override deleteArchivedMany(refs: readonly SessionBulkMutationRef[]): Promise<SessionBulkDeleteArchivedResponse> {
this.bulkDeleteCalls.push([...refs]);
return Promise.resolve({ deleted: true, deletedSessionIds: refs.map((ref) => ref.id), failures: [], generatedAt: "2026-06-25T00:00:00.000Z" });
}
override reload(lookup: string | PiSessionRef): Promise<void> {
this.reloadCalls.push(lookup);
if (this.reloadError !== undefined) return Promise.reject(this.reloadError);
+51
View File
@@ -1,7 +1,9 @@
import type { FastifyInstance } from "fastify";
import type { SessionBulkMutationRequest, SessionBulkMutationRef, SessionCleanupRequest } from "../../shared/apiTypes.js";
import { normalizeRequestCwd } from "../workingDirectory.js";
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
import type { PiSessionRef, PiSessionService } from "./piSessionService.js";
import { normalizeSessionCleanupRequest } from "./sessionCleanup.js";
type SessionLookup = string | PiSessionRef;
@@ -46,6 +48,38 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS
}
});
app.post<{ Body: SessionCleanupRequest | undefined }>(`${prefix}/sessions/cleanup/preview`, async (request, reply) => {
try {
return await sessions.cleanupPreview(normalizeSessionCleanupRequest(optionalRecord(request.body)));
} catch (error) {
return reply.code(400).send({ error: errorMessage(error) });
}
});
app.post<{ Body: SessionCleanupRequest | undefined }>(`${prefix}/sessions/cleanup`, async (request, reply) => {
try {
return await sessions.cleanup(normalizeSessionCleanupRequest(optionalRecord(request.body)));
} catch (error) {
return reply.code(400).send({ error: errorMessage(error) });
}
});
app.post<{ Body: SessionBulkMutationRequest | undefined }>(`${prefix}/sessions/bulk/archive`, async (request, reply) => {
try {
return await sessions.archiveMany(bulkMutationRefsFromBody(request.body));
} catch (error) {
return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) });
}
});
app.post<{ Body: SessionBulkMutationRequest | undefined }>(`${prefix}/sessions/bulk/delete-archived`, async (request, reply) => {
try {
return await sessions.deleteArchivedMany(bulkMutationRefsFromBody(request.body));
} catch (error) {
return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) });
}
});
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)) };
@@ -263,6 +297,23 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS
});
}
function bulkMutationRefsFromBody(body: SessionBulkMutationRequest | undefined): SessionBulkMutationRef[] {
const record = requireRecord(body);
const sessions = record["sessions"];
if (!Array.isArray(sessions)) throw new Error("sessions field must be an array");
return sessions.map(parseBulkMutationRef);
}
function parseBulkMutationRef(value: unknown): SessionBulkMutationRef {
const record = requireRecord(value);
const id = requireString(record, "id").trim();
if (id === "") throw new Error("id field must not be empty");
const cwd = record["cwd"];
if (cwd === undefined || cwd === "") return { id };
if (typeof cwd !== "string") throw new Error("cwd field must be a string");
return { id, cwd: normalizeRequestCwd(cwd) };
}
function sessionLookupFromQuery(id: string, query: SessionQuery): SessionLookup {
return sessionLookupFromCwd(id, query.cwd);
}
+16 -5
View File
@@ -2,18 +2,20 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import { describe, expect, it, vi } from "vitest";
import { createSpawnSessionToolDefinition } from "./spawnSessionTool.js";
// The spawn tool's execute() never reads ctx, so an empty stub is sufficient.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub; execute() does not use ctx.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub with the minimal surface the tool reads.
const ctx = {} as ExtensionContext;
const dispatchModel = { provider: "anthropic", id: "claude-sonnet" };
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub with the minimal surface the tool reads.
const ctxWithModel = { model: dispatchModel } as ExtensionContext;
describe("createSpawnSessionToolDefinition", () => {
it("passes the spawning cwd and params to the spawn callback and reports success", async () => {
const spawn = vi.fn(() => Promise.resolve({ sessionId: "new-1", cwd: "/repos/a-feature" }));
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
const result = await tool.execute("call-1", { prompt: "do the thing", cwd: "/repos/a-feature" }, undefined, undefined, ctx);
const result = await tool.execute("call-1", { prompt: "do the thing", cwd: "/repos/a-feature" }, undefined, undefined, ctxWithModel);
expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", prompt: "do the thing", cwd: "/repos/a-feature" });
expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", prompt: "do the thing", cwd: "/repos/a-feature", model: dispatchModel });
expect(result.details).toEqual({ sessionId: "new-1", cwd: "/repos/a-feature" });
expect(result.content[0]).toMatchObject({ type: "text", text: "Started session new-1 in /repos/a-feature." });
});
@@ -27,11 +29,20 @@ describe("createSpawnSessionToolDefinition", () => {
expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", prompt: "continue", cwd: undefined });
});
it("omits the inherited model when the dispatching session has no current model", async () => {
const spawn = vi.fn(() => Promise.resolve({ sessionId: "new-3", cwd: "/repos/a" }));
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
await tool.execute("call-3", { prompt: "continue" }, undefined, undefined, ctx);
expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", prompt: "continue", cwd: undefined });
});
it("propagates the spawn callback error so the agent loop reports it", async () => {
const spawn = vi.fn(() => Promise.reject(new Error("cwd must be a workspace of this project. Allowed: /repos/a")));
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
await expect(tool.execute("call-3", { prompt: "x", cwd: "/elsewhere" }, undefined, undefined, ctx))
await expect(tool.execute("call-4", { prompt: "x", cwd: "/elsewhere" }, undefined, undefined, ctx))
.rejects.toThrow("cwd must be a workspace of this project. Allowed: /repos/a");
});
});
+12 -3
View File
@@ -1,15 +1,19 @@
import { Type } from "typebox";
import { defineTool } from "@earendil-works/pi-coding-agent";
import { defineTool, type ExtensionContext } from "@earendil-works/pi-coding-agent";
export interface SpawnSessionResult {
sessionId: string;
cwd: string;
}
export type SpawnSessionModel = NonNullable<ExtensionContext["model"]>;
export interface SpawnSessionInvocation {
spawningCwd: string;
prompt: string;
cwd: string | undefined;
/** Current model from the dispatching session, used as the spawned session's default. */
model?: SpawnSessionModel;
}
export interface SpawnSessionToolDeps {
@@ -40,11 +44,16 @@ export function createSpawnSessionToolDefinition(spawningCwd: string, deps: Spaw
description: "Start a new, independent pi-web session and send it an initial prompt. Use this to dispatch a fresh agent to continue work or follow a plan. The new session runs on its own and a human can interact with it; you do not receive its output.",
promptSnippet: "spawn_session: start a new independent session with a first prompt",
parameters: SpawnSessionParams,
async execute(_toolCallId, params) {
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
// Failures throw: the agent loop turns the thrown message into an error
// tool result the model sees, so the spawning agent can adapt (e.g. pick a
// valid workspace) rather than crash.
const result = await deps.spawn({ spawningCwd, prompt: params.prompt, cwd: params.cwd });
const result = await deps.spawn({
spawningCwd,
prompt: params.prompt,
cwd: params.cwd,
...(ctx.model === undefined ? {} : { model: ctx.model }),
});
return {
content: [{ type: "text", text: `Started session ${result.sessionId} in ${result.cwd}.` }],
details: result,
@@ -3,11 +3,13 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import { describe, expect, it, vi } from "vitest";
import { createSubsessionToolDefinitions, type SubsessionToolDeps } from "./spawnSubsessionTool.js";
function ctxFor(sessionId: string, sessionFile: string | undefined): ExtensionContext {
const dispatchModel = { provider: "anthropic", id: "claude-sonnet" };
function ctxFor(sessionId: string, sessionFile: string | undefined, model?: unknown): ExtensionContext {
const sessionManager = { getSessionId: () => sessionId, getSessionFile: () => sessionFile };
// The subsession tools only read sessionManager.getSessionId/getSessionFile.
// The subsession tools only read sessionManager.getSessionId/getSessionFile and model.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub with the minimal surface the tools use.
return { sessionManager } as unknown as ExtensionContext;
return { sessionManager, ...(model === undefined ? {} : { model }) } as unknown as ExtensionContext;
}
function tools(deps: Partial<SubsessionToolDeps>) {
@@ -36,7 +38,7 @@ describe("createSubsessionToolDefinitions", () => {
const spawn = vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/repos/a-feature" }));
const { spawn: spawnTool } = tools({ spawn });
const result = await spawnTool.execute("call-1", { prompt: "do it", cwd: "/repos/a-feature" }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl"));
const result = await spawnTool.execute("call-1", { prompt: "do it", cwd: "/repos/a-feature" }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl", dispatchModel));
expect(spawn).toHaveBeenCalledWith({
spawningCwd: "/repos/a",
@@ -44,11 +46,27 @@ describe("createSubsessionToolDefinitions", () => {
parentSessionFile: "/sessions/parent-1.jsonl",
prompt: "do it",
cwd: "/repos/a-feature",
model: dispatchModel,
});
expect(result.details).toEqual({ sessionId: "child-1", cwd: "/repos/a-feature" });
expect(firstText(result.content)).toContain("Started subsession child-1");
});
it("spawn_subsession omits the inherited model when the dispatching session has no current model", async () => {
const spawn = vi.fn(() => Promise.resolve({ sessionId: "child-2", cwd: "/repos/a" }));
const { spawn: spawnTool } = tools({ spawn });
await spawnTool.execute("call-modeless", { prompt: "do it" }, undefined, undefined, ctxFor("parent-1", undefined));
expect(spawn).toHaveBeenCalledWith({
spawningCwd: "/repos/a",
parentSessionId: "parent-1",
parentSessionFile: undefined,
prompt: "do it",
cwd: undefined,
});
});
it("list_subsessions reports the caller's subsessions and their status", async () => {
const list = vi.fn(() => Promise.resolve([
{ sessionId: "child-1", cwd: "/repos/a", status: "working" as const },
+13 -2
View File
@@ -1,5 +1,5 @@
import { Type } from "typebox";
import { defineTool } from "@earendil-works/pi-coding-agent";
import { defineTool, type ExtensionContext } from "@earendil-works/pi-coding-agent";
import type { TranscriptContentKind, TranscriptEntry, TranscriptRole, TranscriptView } from "./subsessionTranscript.js";
/** Lifecycle phase of a tracked subsession as seen by its parent. */
@@ -10,6 +10,8 @@ export interface SpawnSubsessionResult {
cwd: string;
}
export type SpawnSubsessionModel = NonNullable<ExtensionContext["model"]>;
export interface SpawnSubsessionInvocation {
/** cwd of the session that invoked the tool (used for project-scope checks). */
spawningCwd: string;
@@ -19,6 +21,8 @@ export interface SpawnSubsessionInvocation {
parentSessionFile: string | undefined;
prompt: string;
cwd: string | undefined;
/** Current model from the dispatching session, used as the spawned session's default. */
model?: SpawnSubsessionModel;
}
export interface SubsessionSummary {
@@ -180,7 +184,14 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const parentSessionId = ctx.sessionManager.getSessionId();
const parentSessionFile = ctx.sessionManager.getSessionFile() ?? undefined;
const result = await deps.spawn({ spawningCwd, parentSessionId, parentSessionFile, prompt: params.prompt, cwd: params.cwd });
const result = await deps.spawn({
spawningCwd,
parentSessionId,
parentSessionFile,
prompt: params.prompt,
cwd: params.cwd,
...(ctx.model === undefined ? {} : { model: ctx.model }),
});
return {
content: [{ type: "text", text: `Started subsession ${result.sessionId} in ${result.cwd}. You will be notified when it stops working.` }],
details: result,
+4
View File
@@ -4,6 +4,10 @@ export type {
SessionRef as ClientSessionRef,
SessionInfo as ClientSession,
ArchiveSessionsResponse as ClientArchiveSessionsResponse,
SessionCleanupRequest as ClientSessionCleanupRequest,
SessionCleanupThresholds as ClientSessionCleanupThresholds,
SessionCleanupPreviewResponse as ClientSessionCleanupPreviewResponse,
SessionCleanupExecuteResponse as ClientSessionCleanupExecuteResponse,
MessagePage as ClientMessagePage,
SessionStatus as ClientSessionStatus,
SessionModel as ClientSessionModel,
@@ -76,6 +76,37 @@ describe("file suggestions", () => {
]);
});
it("waits for both git probes before falling back in all-file scope", async () => {
let releaseUntracked: (() => void) | undefined;
let resolved = false;
const deps: FileSuggestionDependencies = {
execFile: (file, args) => {
if (file === "git" && args.join(" ") === "ls-files -z") return Promise.reject(new Error("not a git repository"));
if (file === "git" && args.join(" ") === "ls-files --others --exclude-standard -z") {
return new Promise<{ stdout: string }>((resolve) => {
releaseUntracked = () => {
resolve({ stdout: "" });
};
});
}
if (file === "rg") return Promise.resolve({ stdout: "sdk.md\n" });
return Promise.reject(new Error(`unexpected command: ${file} ${args.join(" ")}`));
},
};
const suggestions = listFileSuggestions("/repo", "sdk", { scope: "all" }, deps).then((value) => {
resolved = true;
return value;
});
await Promise.resolve();
expect(resolved).toBe(false);
expect(releaseUntracked).toBeDefined();
releaseUntracked?.();
await expect(suggestions).resolves.toEqual([{ path: "sdk.md", kind: "other" }]);
});
it("keeps git untracked files in all-file scope when the broad scan misses them", async () => {
const deps: FileSuggestionDependencies = {
execFile: (file, args) => {
+7 -4
View File
@@ -253,13 +253,16 @@ async function listTrackedFiles(cwd: string, exec: CommandRunner): Promise<Clien
}
async function listGitFiles(cwd: string, exec: CommandRunner): Promise<ClientFileSuggestion[]> {
const [tracked, untracked] = await Promise.all([
const [trackedResult, untrackedResult] = await Promise.allSettled([
git(cwd, ["ls-files", "-z"], exec),
git(cwd, ["ls-files", "--others", "--exclude-standard", "-z"], exec),
]);
] as const);
if (trackedResult.status === "rejected") throw trackedResult.reason;
if (untrackedResult.status === "rejected") throw untrackedResult.reason;
return [
...withDirectories(nulRecords(tracked), "tracked"),
...withDirectories(nulRecords(untracked), "untracked"),
...withDirectories(nulRecords(trackedResult.value), "tracked"),
...withDirectories(nulRecords(untrackedResult.value), "untracked"),
];
}