Merge remote-tracking branch 'origin/main' into investigate/issue-12-session-dir

# Conflicts:
#	src/client/src/api.ts
#	src/client/src/api/clients.ts
#	src/client/src/api/federatedRouteContract.test.ts
#	src/server/sessions/piSessionService.ts
#	src/server/sessions/sessionRoutes.ts
This commit is contained in:
Federico Jaramillo Martinez
2026-06-10 20:43:10 +02:00
133 changed files with 4848 additions and 968 deletions
+75 -12
View File
@@ -12,6 +12,7 @@ import { MachineService } from "./machines/machineService.js";
import { MachineStore } from "./machines/machineStore.js";
import { WorkspaceService } from "./workspaces/workspaceService.js";
import type { SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js";
import { PI_WEB_CAPABILITIES } from "../shared/capabilities.js";
import { machineScopedPluginId } from "../shared/machinePluginIds.js";
import { MAX_IMAGE_PREVIEW_BYTES } from "../shared/workspaceFiles.js";
import type { Project, Workspace } from "./types.js";
@@ -36,22 +37,20 @@ beforeEach(async () => {
return remoteClient;
},
now: () => new Date("2026-05-25T00:00:00.000Z"),
localStatus: () => Promise.resolve({
localRuntime: () => Promise.resolve({
packageName: "@jmfederico/pi-web",
generatedAt: "2026-05-25T00:00:00.000Z",
components: {
web: { component: "web", label: "PI WEB", stale: false, available: true },
sessiond: { component: "sessiond", label: "PI WEB Session Daemon", stale: false, available: true },
web: { component: "web", label: "PI WEB", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
sessiond: { component: "sessiond", label: "PI WEB Session Daemon", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
},
release: { packageName: "@jmfederico/pi-web", updateAvailable: false },
commands: { update: "", restart: "", restartSystemd: "", restartDev: "" },
messages: [],
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived],
}),
}),
sessionDaemon: fakeSessionDaemon(),
piWebPlugins: {
manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local" }] }),
plugins: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", enabled: true }] }),
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 }] }),
readAsset: (pluginId, assetPath) => Promise.resolve(pluginId === "fake" && assetPath === "plugin.js" ? { content: Buffer.from("export default {};"), contentType: "application/javascript; charset=utf-8" } : undefined),
},
clientDist: false,
@@ -109,6 +108,31 @@ describe("buildApp", () => {
expect(remoteHealth.json()).toMatchObject({ machineId: remote.id, ok: true, status: "online" });
});
it("reports effective machine runtime capabilities for remote machines", 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" },
body: {
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] },
sessiond: { component: "sessiond", label: "Remote Sessiond", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
},
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived],
},
}));
remoteClient = fakeRemoteClient({ requestJson });
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(requestJson).toHaveBeenCalledWith("GET", "/api/pi-web/runtime", undefined, { timeoutMs: 3000 });
});
it("proxies allowlisted remote HTTP routes through the selected machine", 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 }>();
@@ -297,11 +321,11 @@ describe("buildApp", () => {
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);
expect(manifestResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local" }] });
expect(manifestResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] });
const pluginsResponse = await app.inject({ method: "GET", url: "/api/plugins" });
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", enabled: true }] });
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 assetResponse = await app.inject({ method: "GET", url: "/pi-web-plugins/fake/plugin.js?v=1" });
expect(assetResponse.statusCode).toBe(200);
@@ -318,7 +342,7 @@ describe("buildApp", () => {
const requestJson = vi.fn(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: { plugins: [{ id: "remote-tools", module: "/pi-web-plugins/remote-tools/pi-web-plugin.js?v=123", source: "local", scope: "local" }] },
body: { plugins: [{ id: "remote-tools", module: "/pi-web-plugins/remote-tools/pi-web-plugin.js?v=123", source: "local", scope: "local", machineSpecific: true }] },
}));
const request = vi.fn(() => Promise.resolve({
statusCode: 200,
@@ -331,7 +355,7 @@ describe("buildApp", () => {
const scopedPluginId = machineScopedPluginId(remote.id, "remote-tools");
expect(manifestResponse.statusCode).toBe(200);
expect(manifestResponse.json()).toEqual({
plugins: [{ id: "remote-tools", module: `/pi-web-plugins/${scopedPluginId}/pi-web-plugin.js?v=123`, source: "local", scope: "local" }],
plugins: [{ id: "remote-tools", module: `/pi-web-plugins/${scopedPluginId}/pi-web-plugin.js?v=123`, source: "local", scope: "local", machineSpecific: true }],
});
expect(requestJson).toHaveBeenCalledWith("GET", "/pi-web-plugins/manifest.json", undefined, { timeoutMs: 10000 });
@@ -343,6 +367,45 @@ describe("buildApp", () => {
expect(request).toHaveBeenCalledWith("GET", "/pi-web-plugins/remote-tools/pi-web-plugin.js?v=123");
});
it("drops unsafe remote machine plugin manifest modules", 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 }>();
remoteClient = fakeRemoteClient({
requestJson: vi.fn(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: {
plugins: [
{ id: "safe-tools", module: "nested/pi-web-plugin.js?v=1", source: "local", scope: "local" },
{ id: "traversal-tools", module: "..%2F..%2Fapi%2Fconfig", source: "local", scope: "local" },
{ id: "wrong-root", module: "/pi-web-plugins/other/pi-web-plugin.js", source: "local", scope: "local" },
],
},
})),
});
const manifestResponse = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/pi-web-plugins/manifest.json` });
expect(manifestResponse.statusCode).toBe(200);
expect(manifestResponse.json()).toEqual({
plugins: [{ id: "safe-tools", module: `/pi-web-plugins/${machineScopedPluginId(remote.id, "safe-tools")}/nested/pi-web-plugin.js?v=1`, source: "local", scope: "local" }],
});
});
it("rejects remote machine plugin asset traversal 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 request = vi.fn(() => Promise.resolve({ statusCode: 200, headers: {}, body: Readable.from([]) }));
remoteClient = fakeRemoteClient({ request });
const scopedPluginId = machineScopedPluginId(remote.id, "remote-tools");
const response = await app.inject({ method: "GET", url: `/pi-web-plugins/${scopedPluginId}/..%2F..%2Fapi%2Fconfig` });
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "Invalid remote PI WEB plugin asset path" });
expect(request).not.toHaveBeenCalled();
});
it("returns stable errors for invalid project requests", async () => {
const addResponse = await app.inject({
method: "POST",
+11 -4
View File
@@ -17,7 +17,8 @@ import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js";
import { registerWorkspaceDeletionRoutes } from "./workspaces/workspaceDeletionRoutes.js";
import { registerConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
import { PiWebPluginService } from "./piWebPluginService.js";
import { getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
import { createPiWebStatusCache } from "./piWebStatusCache.js";
import { getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
import { MachineService } from "./machines/machineService.js";
import { registerMachineRoutes } from "./machines/machineRoutes.js";
import { registerMachineProxyRoutes } from "./machines/machineProxyRoutes.js";
@@ -91,8 +92,13 @@ 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 machines = deps.machines ?? new MachineService();
const sessionDaemon = deps.sessionDaemon ?? new SessionDaemonClient();
const piWebStatusCache = createPiWebStatusCache(() => getPiWebStatus(sessionDaemon), {
onError: (error) => { app.log.warn({ err: error }, "failed to refresh PI WEB status cache"); },
});
const machines = deps.machines ?? new MachineService(undefined, {
localRuntime: () => getPiWebRuntime(sessionDaemon),
});
app.get("/pi-web-plugins/manifest.json", async () => piWebPlugins.manifest());
@@ -104,8 +110,9 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
return reply.type(asset.contentType).send(asset.content);
});
app.get("/api/pi-web/status", async () => getPiWebStatus());
app.get("/api/pi-web/version", async () => getPiWebVersionStatus());
app.get("/api/pi-web/status", async () => piWebStatusCache.get());
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());
registerConfigRoutes(app, deps.config);
+57 -15
View File
@@ -9,6 +9,7 @@ interface RemotePluginManifestEntry {
module: string;
source?: string;
scope?: string;
machineSpecific?: boolean;
}
interface RemotePluginManifest {
@@ -59,8 +60,14 @@ export async function proxyMachinePluginAsset(machines: MachinePluginProxyMachin
return true;
}
const requestPath = remotePluginAssetRequestPath(remotePlugin, assetPath, requestUrl);
if (requestPath === undefined) {
await reply.code(400).send({ error: "Invalid remote PI WEB plugin asset path" });
return true;
}
try {
const upstream = await client.request("GET", remotePluginAssetRequestPath(remotePlugin, assetPath, requestUrl));
const upstream = await client.request("GET", requestPath);
reply.code(upstream.statusCode);
applySafeHeaders(reply, upstream.headers);
if (upstream.body === undefined) await reply.send();
@@ -87,29 +94,57 @@ function rewriteRemotePluginManifest(machineId: string, manifest: RemotePluginMa
function remotePluginModulePath(pluginId: string, module: string): { path: string; query: string } | undefined {
if (!isPiWebPluginId(pluginId)) return undefined;
const prefix = `/pi-web-plugins/${encodeURIComponent(pluginId)}/`;
const base = new URL(prefix, "http://pi-web.local");
try {
const url = new URL(module, "http://pi-web.local");
const prefix = `/pi-web-plugins/${encodeURIComponent(pluginId)}/`;
if (url.pathname.startsWith(prefix)) {
return { path: url.pathname.slice(prefix.length), query: url.search };
}
if (!module.startsWith("/") && !/^https?:\/\//iu.test(module)) {
const [path, query = ""] = module.split("?", 2);
if (path !== undefined && path !== "") return { path, query: query === "" ? "" : `?${query}` };
}
const url = new URL(module, base);
if (url.origin !== base.origin || !url.pathname.startsWith(prefix)) return undefined;
const path = safeRemotePluginAssetPath(url.pathname.slice(prefix.length));
return path === undefined ? undefined : { path, query: url.search };
} catch {
return undefined;
}
return undefined;
}
function remotePluginAssetRequestPath(remotePlugin: MachineScopedPluginIdParts, assetPath: string, requestUrl: string): string {
function remotePluginAssetRequestPath(remotePlugin: MachineScopedPluginIdParts, assetPath: string, requestUrl: string): string | undefined {
const path = safeRemotePluginAssetPath(assetPath);
if (path === undefined) return undefined;
const query = requestUrl.includes("?") ? requestUrl.slice(requestUrl.indexOf("?")) : "";
return `/pi-web-plugins/${encodeURIComponent(remotePlugin.pluginId)}/${encodePathSegments(assetPath)}${query}`;
return `/pi-web-plugins/${encodeURIComponent(remotePlugin.pluginId)}/${path}${query}`;
}
function encodePathSegments(path: string): string {
return path.split("/").map((segment) => encodeURIComponent(segment)).join("/");
function safeRemotePluginAssetPath(path: string): string | undefined {
const segments: string[] = [];
for (const rawSegment of path.split("/")) {
const segment = safeRemotePluginAssetPathSegment(rawSegment);
if (segment === undefined) return undefined;
if (segment === "") continue;
segments.push(segment);
}
if (segments.length === 0) return undefined;
return segments.map((segment) => encodeURIComponent(segment)).join("/");
}
function safeRemotePluginAssetPathSegment(rawSegment: string): string | undefined {
if (rawSegment === "" || rawSegment === ".") return "";
if (/%(?:2f|5c)/iu.test(rawSegment)) return undefined;
let segment: string;
try {
segment = decodeURIComponent(rawSegment);
} catch {
return undefined;
}
if (segment === "" || segment === ".") return "";
if (segment === ".." || segment.includes("/") || segment.includes("\\") || hasControlCharacter(segment)) return undefined;
return segment;
}
function hasControlCharacter(value: string): boolean {
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
if (code <= 0x1f || code === 0x7f) return true;
}
return false;
}
function parseRemoteManifest(value: unknown): RemotePluginManifest {
@@ -124,11 +159,18 @@ function parseRemoteManifest(value: unknown): RemotePluginManifest {
module: entry["module"],
...(typeof entry["source"] === "string" ? { source: entry["source"] } : {}),
...(typeof entry["scope"] === "string" ? { scope: entry["scope"] } : {}),
...(parseRemoteMachineSpecific(entry["machineSpecific"])),
};
}),
};
}
function parseRemoteMachineSpecific(value: unknown): { machineSpecific?: boolean } {
if (value === undefined) return {};
if (typeof value !== "boolean") throw new Error("Invalid remote PI WEB plugin manifest entry");
return { machineSpecific: value };
}
function applySafeHeaders(reply: FastifyReply, headers: Record<string, string | string[] | undefined>): void {
for (const [name, value] of Object.entries(headers)) {
if (value === undefined) continue;
+6
View File
@@ -18,6 +18,12 @@ export function registerMachineRoutes(app: FastifyInstance, machines = new Machi
return health;
});
app.get<{ Params: { machineId: string } }>("/api/machines/:machineId/runtime", async (request, reply) => {
const runtime = await machines.runtime(request.params.machineId);
if (runtime === undefined) return reply.code(404).send({ error: "Machine not found" });
return runtime;
});
app.get<{ Params: { machineId: string } }>("/api/machines/:machineId", async (request, reply) => {
const machine = await machines.get(request.params.machineId);
if (machine === undefined) return reply.code(404).send({ error: "Machine not found" });
+29 -1
View File
@@ -1,7 +1,7 @@
import { chmod, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MachineService } from "./machineService.js";
import { MachineStore, machineStorePath } from "./machineStore.js";
@@ -69,6 +69,34 @@ describe("MachineService", () => {
await expect(service.add({ name: "Bad", baseUrl: "https://example.test", headers: { Connection: "close" } })).rejects.toThrow("not allowed");
});
it("uses the lightweight runtime check for local machine health", async () => {
const localRuntime = vi.fn(() => Promise.resolve({
packageName: "@jmfederico/pi-web",
generatedAt: "2026-05-25T00:00:00.000Z",
components: {
web: { component: "web" as const, label: "Web/UI", runtimeVersion: "1.0.0", available: true, capabilities: [] },
sessiond: { component: "sessiond" as const, label: "Session daemon", runtimeVersion: "1.0.0", available: true, capabilities: [] },
},
capabilities: [],
}));
const healthService = new MachineService(new MachineStore(storePath), {
localRuntime,
now: () => new Date("2026-05-25T00:00:00.000Z"),
});
const health = await healthService.health("local");
expect(localRuntime).toHaveBeenCalledTimes(1);
expect(health).toEqual({
machineId: "local",
ok: true,
checkedAt: "2026-05-25T00:00:00.000Z",
status: "online",
web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", stale: false, available: true },
sessiond: { component: "sessiond", label: "Session daemon", runtimeVersion: "1.0.0", stale: false, available: true },
});
});
it("does not allow local machine mutation", async () => {
await expect(service.update("local", { name: "Other" })).rejects.toThrow("Local machine cannot be changed");
await expect(service.remove("local")).rejects.toThrow("Local machine cannot be deleted");
+102 -7
View File
@@ -1,5 +1,6 @@
import type { Machine, MachineHealth, PiWebComponentStatus, PiWebStatusResponse } from "../../shared/apiTypes.js";
import { getPiWebStatus } from "../piWebStatus.js";
import type { Machine, MachineHealth, MachineRuntime, PiWebComponentStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebStatusResponse } from "../../shared/apiTypes.js";
import { isPiWebCapability } from "../../shared/capabilities.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";
@@ -13,10 +14,11 @@ export interface CreateMachineInput {
export type UpdateMachineInput = Partial<CreateMachineInput>;
export interface MachineServiceDependencies {
localStatus?: () => Promise<PiWebStatusResponse>;
localRuntime?: () => Promise<PiWebRuntimeResponse>;
remoteClientFactory?: (machine: StoredMachine) => MachineClient;
now?: () => Date;
healthCacheTtlMs?: number;
runtimeCacheTtlMs?: number;
}
const LOCAL_MACHINE_TIMESTAMP = "1970-01-01T00:00:00.000Z";
@@ -24,6 +26,7 @@ const DEFAULT_HEALTH_CACHE_TTL_MS = 5_000;
export class MachineService {
private readonly healthCache = new Map<string, { expiresAt: number; health: MachineHealth }>();
private readonly runtimeCache = new Map<string, { expiresAt: number; runtime: MachineRuntime }>();
constructor(private readonly store = new MachineStore(), private readonly deps: MachineServiceDependencies = {}) {}
@@ -52,14 +55,20 @@ export class MachineService {
if (input.token !== undefined) patch.token = input.token;
if (input.headers !== undefined) patch.headers = validateHeaders(input.headers);
const stored = await this.store.update(id, patch);
if (stored !== undefined) this.healthCache.delete(id);
if (stored !== undefined) {
this.healthCache.delete(id);
this.runtimeCache.delete(id);
}
return stored === undefined ? undefined : publicMachine(stored);
}
async remove(id: string): Promise<boolean> {
if (id === "local") throw new Error("Local machine cannot be deleted");
const removed = await this.store.remove(id);
if (removed) this.healthCache.delete(id);
if (removed) {
this.healthCache.delete(id);
this.runtimeCache.delete(id);
}
return removed;
}
@@ -84,11 +93,29 @@ export class MachineService {
return health;
}
async runtime(id: string): Promise<MachineRuntime | undefined> {
const cached = this.runtimeCache.get(id);
const now = this.now().getTime();
if (cached !== undefined && cached.expiresAt > now) return cached.runtime;
const runtime = id === "local" ? await this.localRuntime() : await this.remoteRuntime(id);
if (runtime === undefined) return undefined;
this.runtimeCache.set(id, { expiresAt: now + (this.deps.runtimeCacheTtlMs ?? DEFAULT_HEALTH_CACHE_TTL_MS), runtime });
return runtime;
}
private async localHealth(): Promise<MachineHealth> {
const checkedAt = this.now().toISOString();
try {
const status = await (this.deps.localStatus ?? getPiWebStatus)();
return { machineId: "local", ok: true, checkedAt, status: "online", web: status.components.web, sessiond: status.components.sessiond };
const runtime = await (this.deps.localRuntime ?? getPiWebRuntime)();
return {
machineId: "local",
ok: true,
checkedAt,
status: "online",
web: componentStatusFromRuntime(runtime.components.web),
sessiond: componentStatusFromRuntime(runtime.components.sessiond),
};
} catch (error) {
return { machineId: "local", ok: false, checkedAt, status: "error", error: errorMessage(error) };
}
@@ -109,6 +136,28 @@ export class MachineService {
}
}
private async localRuntime(): Promise<MachineRuntime> {
const checkedAt = this.now().toISOString();
try {
return machineRuntime("local", checkedAt, await (this.deps.localRuntime ?? getPiWebRuntime)());
} catch (error) {
return { machineId: "local", ok: false, checkedAt, error: errorMessage(error) };
}
}
private async remoteRuntime(id: string): Promise<MachineRuntime | undefined> {
const machine = await this.storedRemote(id);
if (machine === undefined) return undefined;
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);
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) };
}
}
private clientFor(machine: StoredMachine): MachineClient {
return this.deps.remoteClientFactory?.(machine) ?? new RemoteMachineClient(machine);
}
@@ -162,6 +211,29 @@ function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function componentStatusFromRuntime(runtime: PiWebRuntimeComponent): PiWebComponentStatus {
return {
component: runtime.component,
label: runtime.label,
...(runtime.runtimeVersion === undefined ? {} : { runtimeVersion: runtime.runtimeVersion }),
stale: false,
available: runtime.available,
...(runtime.error === undefined ? {} : { error: runtime.error }),
};
}
function machineRuntime(machineId: string, checkedAt: string, runtime: PiWebRuntimeResponse): MachineRuntime {
return {
machineId,
ok: true,
checkedAt,
packageName: runtime.packageName,
generatedAt: runtime.generatedAt,
components: runtime.components,
capabilities: runtime.capabilities,
};
}
function isPiWebStatusResponse(value: unknown): value is PiWebStatusResponse {
if (!isRecord(value)) return false;
const components = value["components"];
@@ -169,6 +241,16 @@ 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"];
@@ -178,6 +260,19 @@ 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);
}
+13 -1
View File
@@ -25,7 +25,7 @@ describe("PiWebPluginService", () => {
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
await expect(service.manifest()).resolves.toEqual({
plugins: [expect.objectContaining({ id: "info", source: "test", scope: "local" })],
plugins: [expect.objectContaining({ id: "info", source: "test", scope: "local", machineSpecific: false })],
});
const manifest = await service.manifest();
expect(manifest.plugins[0]?.module).toMatch(/^\/pi-web-plugins\/info\/pi-web-plugin\.js\?v=\d+$/u);
@@ -35,6 +35,18 @@ describe("PiWebPluginService", () => {
expect(asset?.content.toString("utf8")).toContain("export default");
});
it("includes machine-specific preferences in plugin manifests", async () => {
await writePlugin(join(tempDir, "plugins", "updates"), {
packageJson: { piWeb: { plugins: [{ id: "updates", module: "pi-web-plugin.js", machineSpecific: true }] } },
files: { "pi-web-plugin.js": "export default {};" },
});
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
await expect(service.manifest()).resolves.toMatchObject({ plugins: [{ id: "updates", machineSpecific: true }] });
await expect(service.plugins()).resolves.toMatchObject({ plugins: [{ id: "updates", machineSpecific: true, enabled: true }] });
});
it("discovers Pi package plugins through an injected package provider", async () => {
const packageDir = join(tempDir, "pkg");
await writePlugin(packageDir, {
+24 -4
View File
@@ -18,6 +18,7 @@ export interface PiWebPluginManifestEntry {
module: string;
source: string;
scope: PiWebPluginScope;
machineSpecific: boolean;
}
export interface ConfiguredPiPackage {
@@ -38,6 +39,7 @@ interface PluginRecord {
version: string;
source: string;
scope: PiWebPluginScope;
machineSpecific: boolean;
}
interface PiWebPluginServiceOptions {
@@ -61,6 +63,7 @@ interface PiWebPackageConfig {
interface PiWebPluginEntry {
id: string;
module: string;
machineSpecific: boolean;
}
type ArraylessPluginRecord = Omit<PluginRecord, "source" | "scope">;
@@ -102,7 +105,7 @@ export class PiWebPluginService {
return {
plugins: (await this.plugins()).plugins
.filter((plugin) => plugin.enabled)
.map((plugin) => ({ id: plugin.id, module: plugin.module, source: plugin.source, scope: plugin.scope })),
.map((plugin) => ({ id: plugin.id, module: plugin.module, source: plugin.source, scope: plugin.scope, machineSpecific: plugin.machineSpecific })),
};
}
@@ -135,6 +138,7 @@ export class PiWebPluginService {
module: `/pi-web-plugins/${encodeURIComponent(plugin.id)}/${plugin.entryFile}?v=${encodeURIComponent(plugin.version)}`,
source: plugin.source,
scope: plugin.scope,
machineSpecific: plugin.machineSpecific,
enabled: config.plugins?.[plugin.id]?.enabled !== false,
};
}
@@ -228,7 +232,7 @@ async function discoverPluginEntries(root: string, config: PiWebPackageConfig):
const entryPath = join(root, entry.module);
const entryStat = await stat(entryPath).catch(() => undefined);
if (entryStat?.isFile() !== true) throw new Error(`PI WEB plugin module not found for ${entry.id}: ${entry.module}`);
plugins.push({ id: entry.id, root, entryFile: entry.module, version: String(Math.floor(entryStat.mtimeMs)) });
plugins.push({ id: entry.id, root, entryFile: entry.module, version: String(Math.floor(entryStat.mtimeMs)), machineSpecific: entry.machineSpecific });
}
return plugins;
}
@@ -248,7 +252,7 @@ async function readPiWebPackageConfig(root: string): Promise<PiWebPackageConfig
}
function parsePluginEntries(piWeb: Record<string, unknown>, packagePath: string): PiWebPluginEntry[] {
if (piWeb["plugin"] !== undefined) throw new Error(`Unsupported PI WEB plugin metadata in ${packagePath}: use piWeb.plugins with { id, module } entries`);
if (piWeb["plugin"] !== undefined) throw new Error(`Unsupported PI WEB plugin metadata in ${packagePath}: use piWeb.plugins with { id, module, machineSpecific? } entries`);
const plugins = piWeb["plugins"];
if (plugins === undefined) return [];
if (!Array.isArray(plugins)) throw new Error(`PI WEB plugins must be an array in ${packagePath}`);
@@ -259,10 +263,26 @@ function parsePluginEntries(piWeb: Record<string, unknown>, packagePath: string)
const module = entry["module"];
if (typeof id !== "string" || !isPiWebPluginId(id)) throw new Error(`Invalid PI WEB plugin id in ${packagePath}: ${String(id)}`);
if (typeof module !== "string" || module === "") throw new Error(`Invalid PI WEB plugin module for ${id} in ${packagePath}`);
return { id, module };
return { id, module, machineSpecific: parseMachineSpecific(entry["machineSpecific"], packagePath, id) };
});
}
function parseMachineSpecific(value: unknown, packagePath: string, pluginId: string): boolean {
if (value === undefined) return false;
if (typeof value !== "boolean") throw new Error(`Invalid PI WEB plugin machineSpecific value for ${pluginId} in ${packagePath}: ${formatUnknownValue(value)}`);
return value;
}
function formatUnknownValue(value: unknown): string {
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint" || typeof value === "symbol" || typeof value === "function" || value === null || value === undefined) return String(value);
try {
return JSON.stringify(value);
} catch {
return Object.prototype.toString.call(value);
}
}
function addUnique(records: Map<string, PluginRecord>, plugin: PluginRecord): void {
if (records.has(plugin.id)) {
warnInvalidPlugin(plugin.source, `Duplicate PI WEB plugin id: ${plugin.id}`);
+136 -41
View File
@@ -1,12 +1,14 @@
import { spawnSync } from "node:child_process";
import { execFile } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { readFile, realpath, stat } from "node:fs/promises";
import { promisify } from "node:util";
import { homedir } from "node:os";
import { dirname, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
import type { PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebVersionResponse } from "../shared/apiTypes.js";
import { parsePiWebComponentStatus } from "../shared/piWebStatusParsing.js";
import type { PiWebCapability, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebVersionResponse } from "../shared/apiTypes.js";
import { effectivePiWebCapabilities, WEB_RUNTIME_CAPABILITIES } from "../shared/capabilities.js";
import { parsePiWebComponentStatus, parsePiWebRuntimeComponent } from "../shared/piWebStatusParsing.js";
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web";
@@ -32,6 +34,8 @@ interface NativeServiceCommands {
status?: string;
}
const execFileAsync = promisify(execFile);
const serviceRefs: Record<ServiceId, NativeServiceRef> = {
sessiond: {
id: "sessiond",
@@ -61,10 +65,35 @@ interface PackageInfo {
path: string;
}
interface PiWebStatusDaemon {
request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record<string, string>; body: string }>;
}
let latestReleaseCache: { checkedAtMs: number; latestVersion?: string; error?: string } | undefined;
const runtimePackageInfo = readPackageInfoSync();
export function getPiWebRuntimeComponent(component: PiWebServiceComponent, capabilities: readonly PiWebCapability[] = []): PiWebRuntimeComponent {
return {
component,
label: component === "web" ? "Web/UI" : "Session daemon",
runtimeVersion: runtimePackageInfo?.version ?? DEFAULT_VERSION,
available: true,
capabilities: [...capabilities],
};
}
export async function getPiWebRuntime(daemon: PiWebStatusDaemon = new SessionDaemonClient()): Promise<PiWebRuntimeResponse> {
const web = getPiWebRuntimeComponent("web", WEB_RUNTIME_CAPABILITIES);
const sessiond = await getSessiondRuntimeComponent(daemon);
return {
packageName: PI_WEB_PACKAGE_NAME,
generatedAt: new Date().toISOString(),
components: { web, sessiond },
capabilities: effectivePiWebCapabilities({ web, sessiond }),
};
}
export async function getPiWebComponentStatus(component: PiWebServiceComponent): Promise<PiWebComponentStatus> {
const [installed, installation] = await Promise.all([
readInstalledPackageInfo(),
@@ -83,7 +112,7 @@ export async function getPiWebComponentStatus(component: PiWebServiceComponent):
};
}
export async function getPiWebVersionStatus(daemon = new SessionDaemonClient()): Promise<PiWebVersionResponse> {
export async function getPiWebVersionStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient()): Promise<PiWebVersionResponse> {
const [web, sessiond] = await Promise.all([
getPiWebComponentStatus("web"),
getSessiondComponentStatus(daemon),
@@ -95,12 +124,12 @@ export async function getPiWebVersionStatus(daemon = new SessionDaemonClient()):
};
}
export async function getPiWebStatus(daemon = new SessionDaemonClient()): Promise<PiWebStatusResponse> {
export async function getPiWebStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient()): Promise<PiWebStatusResponse> {
const versionStatus = await getPiWebVersionStatus(daemon);
const { web, sessiond } = versionStatus.components;
const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION);
const components = { web, sessiond };
const commands = commandsFor(components);
const commands = await commandsFor(components);
const messages = buildMessages(components, release, commands);
return {
...versionStatus,
@@ -187,18 +216,21 @@ async function detectPiPackageInstallation(realRoot: string, displayPath: string
}
async function detectNpmGlobalInstallation(realRoot: string, displayPath: string): Promise<PiWebInstallationInfo | undefined> {
const npmRoot = npmGlobalRoot();
const npmRoot = await npmGlobalRoot();
if (npmRoot === undefined) return undefined;
const realNpmRoot = await realPathOrSelf(npmRoot);
if (!isSameOrWithin(realNpmRoot, realRoot)) return undefined;
return { kind: "npm-global", path: displayPath, npmRoot };
}
function npmGlobalRoot(): string | undefined {
const result = spawnSync("npm", ["root", "-g"], { encoding: "utf8" });
if (result.status !== 0) return undefined;
const root = result.stdout.trim();
return root === "" ? undefined : root;
async function npmGlobalRoot(): Promise<string | undefined> {
try {
const { stdout } = await execFileAsync("npm", ["root", "-g"], { encoding: "utf8" });
const root = stdout.trim();
return root === "" ? undefined : root;
} catch {
return undefined;
}
}
function packageRootPath(): string {
@@ -214,21 +246,78 @@ function isSameOrWithin(parent: string, candidate: string): boolean {
return rel === "" || (!rel.startsWith("..") && !rel.startsWith(sep));
}
async function getSessiondComponentStatus(daemon: SessionDaemonClient): Promise<PiWebComponentStatus> {
async function getSessiondRuntimeComponent(daemon: PiWebStatusDaemon): Promise<PiWebRuntimeComponent> {
try {
const upstream = await daemon.request("GET", "/health");
const upstream = await daemon.request("GET", "/runtime");
if (upstream.statusCode < 200 || upstream.statusCode >= 300) {
return unavailableSessiond(`health check returned HTTP ${String(upstream.statusCode)}`);
return await legacySessiondRuntimeComponent(daemon) ?? unavailableSessiondRuntime(`runtime check returned HTTP ${String(upstream.statusCode)}`);
}
const parsed: unknown = upstream.body === "" ? undefined : JSON.parse(upstream.body);
const version = isRecord(parsed) ? parsed["version"] : undefined;
const component = parsePiWebComponentStatus(version);
return component ?? unavailableSessiond("health response did not include version information");
const runtime = parsePiWebRuntimeComponent(parsed);
if (runtime !== undefined) return runtime;
const legacyVersion = isRecord(parsed) ? parsePiWebComponentStatus(parsed["version"]) : undefined;
if (legacyVersion !== undefined) return runtimeComponentFromStatus(legacyVersion);
return await legacySessiondRuntimeComponent(daemon) ?? unavailableSessiondRuntime("runtime response did not include valid runtime information");
} catch (error) {
return unavailableSessiondRuntime(error instanceof Error ? error.message : String(error));
}
}
async function getSessiondComponentStatus(daemon: PiWebStatusDaemon): Promise<PiWebComponentStatus> {
try {
const upstream = await daemon.request("GET", "/runtime");
if (upstream.statusCode < 200 || upstream.statusCode >= 300) {
return await legacySessiondComponentStatus(daemon) ?? unavailableSessiond(`runtime check returned HTTP ${String(upstream.statusCode)}`);
}
const parsed: unknown = upstream.body === "" ? undefined : JSON.parse(upstream.body);
const legacyVersion = isRecord(parsed) ? parsePiWebComponentStatus(parsed["version"]) : undefined;
if (legacyVersion !== undefined) return legacyVersion;
const runtime = parsePiWebRuntimeComponent(parsed);
if (runtime?.available !== true) return await legacySessiondComponentStatus(daemon) ?? unavailableSessiond(runtime?.error ?? "runtime response did not include valid runtime information");
const status = await getPiWebComponentStatus("sessiond");
return { ...status, ...(runtime.runtimeVersion === undefined ? {} : { runtimeVersion: runtime.runtimeVersion }), available: true };
} catch (error) {
return unavailableSessiond(error instanceof Error ? error.message : String(error));
}
}
async function legacySessiondRuntimeComponent(daemon: PiWebStatusDaemon): Promise<PiWebRuntimeComponent | undefined> {
const status = await legacySessiondComponentStatus(daemon);
return status === undefined ? undefined : runtimeComponentFromStatus(status);
}
async function legacySessiondComponentStatus(daemon: PiWebStatusDaemon): Promise<PiWebComponentStatus | undefined> {
try {
const upstream = await daemon.request("GET", "/health");
if (upstream.statusCode < 200 || upstream.statusCode >= 300) return undefined;
const parsed: unknown = upstream.body === "" ? undefined : JSON.parse(upstream.body);
return isRecord(parsed) ? parsePiWebComponentStatus(parsed["version"]) : undefined;
} catch {
return undefined;
}
}
function runtimeComponentFromStatus(status: PiWebComponentStatus): PiWebRuntimeComponent {
return {
component: status.component,
label: status.label,
...(status.runtimeVersion === undefined ? {} : { runtimeVersion: status.runtimeVersion }),
available: status.available,
capabilities: [],
...(status.error === undefined ? {} : { error: status.error }),
};
}
function unavailableSessiondRuntime(error: string): PiWebRuntimeComponent {
return {
component: "sessiond",
label: "Session daemon",
available: false,
capabilities: [],
error,
};
}
function unavailableSessiond(error: string): PiWebComponentStatus {
return {
component: "sessiond",
@@ -282,15 +371,17 @@ async function fetchLatestNpmVersion(currentVersion: string): Promise<string> {
return version;
}
function commandsFor(components: PiWebStatusResponse["components"]): PiWebStatusResponse["commands"] {
async function commandsFor(components: PiWebStatusResponse["components"]): Promise<PiWebStatusResponse["commands"]> {
const installation = preferredInstallation(components);
const serviceCommands = nativeServiceCommands();
const cliCommands = piWebCliCommands(installation);
const [serviceCommands, cliCommands] = await Promise.all([
nativeServiceCommands(),
piWebCliCommands(installation),
]);
const restart = restartCommandFor(installation, serviceCommands, cliCommands);
const restartWeb = serviceCommands.restartWeb ?? cliCommands.restart;
const restartSessiond = serviceCommands.restartSessiond ?? cliCommands.restart;
const status = serviceCommands.status ?? cliCommands.status;
const update = updateCommandFor(installation, restart);
const update = await updateCommandFor(installation, restart);
return {
...(update === undefined ? {} : { update }),
@@ -308,8 +399,8 @@ function preferredInstallation(components: PiWebStatusResponse["components"]): P
return web ?? sessiond;
}
function piWebCliCommands(installation: PiWebInstallationInfo | undefined): NativeServiceCommands {
if (installation?.kind !== "npm-global" || !hasCommand("pi-web")) return {};
async function piWebCliCommands(installation: PiWebInstallationInfo | undefined): Promise<NativeServiceCommands> {
if (installation?.kind !== "npm-global" || !(await hasCommand("pi-web"))) return {};
return { restart: "pi-web restart", status: "pi-web status" };
}
@@ -318,22 +409,22 @@ function restartCommandFor(installation: PiWebInstallationInfo | undefined, serv
return cliCommands.restart ?? serviceCommands.restart;
}
function updateCommandFor(installation: PiWebInstallationInfo | undefined, restartCommand: string | undefined): string | undefined {
async function updateCommandFor(installation: PiWebInstallationInfo | undefined, restartCommand: string | undefined): Promise<string | undefined> {
if (restartCommand === undefined) return undefined;
if (installation?.kind === "pi-package") {
if (!hasCommand("pi")) return undefined;
if (!(await hasCommand("pi"))) return undefined;
return `pi update ${installation.source ?? PI_WEB_NPM_SOURCE} && ${restartCommand}`;
}
if (installation?.kind === "local" && installation.path !== undefined) {
if (!hasCommand("npm") || !isGitCheckoutWithUpstream(installation.path)) return undefined;
if (!(await hasCommand("npm")) || !(await isGitCheckoutWithUpstream(installation.path))) return undefined;
return `cd ${shellQuote(installation.path)} && git pull --ff-only && npm install && npm run build && ${restartCommand}`;
}
if (installation?.kind !== "npm-global" || !hasCommand("npm")) return undefined;
if (installation?.kind !== "npm-global" || !(await hasCommand("npm"))) return undefined;
return `npm install -g ${PI_WEB_PACKAGE_NAME} && ${restartCommand}`;
}
function nativeServiceCommands(): NativeServiceCommands {
const backend = nativeServiceBackend();
async function nativeServiceCommands(): Promise<NativeServiceCommands> {
const backend = await nativeServiceBackend();
if (backend === undefined) return {};
const installed = installedServiceIds(backend);
if (installed.size === 0) return {};
@@ -349,9 +440,9 @@ function nativeServiceCommands(): NativeServiceCommands {
};
}
function nativeServiceBackend(): NativeServiceBackendKind | undefined {
if (process.platform === "linux" && hasCommand("systemctl")) return "systemd";
if (process.platform === "darwin" && hasCommand("launchctl")) return "launchd";
async function nativeServiceBackend(): Promise<NativeServiceBackendKind | undefined> {
if (process.platform === "linux" && await hasCommand("systemctl")) return "systemd";
if (process.platform === "darwin" && await hasCommand("launchctl")) return "launchd";
return undefined;
}
@@ -385,19 +476,23 @@ function statusNativeServicesCommand(backend: NativeServiceBackendKind, refs: Na
return refs.map((ref) => `launchctl print gui/$(id -u)/${ref.launchdLabel}`).join(" && ");
}
function isGitCheckoutWithUpstream(path: string): boolean {
return hasCommand("git")
&& commandSucceeds("git", ["-C", path, "rev-parse", "--is-inside-work-tree"])
&& commandSucceeds("git", ["-C", path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
async function isGitCheckoutWithUpstream(path: string): Promise<boolean> {
return await hasCommand("git")
&& await commandSucceeds("git", ["-C", path, "rev-parse", "--is-inside-work-tree"])
&& await commandSucceeds("git", ["-C", path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
}
function hasCommand(command: string): boolean {
function hasCommand(command: string): Promise<boolean> {
return commandSucceeds("/usr/bin/env", ["sh", "-c", `command -v ${command}`]);
}
function commandSucceeds(command: string, args: string[]): boolean {
const result = spawnSync(command, args, { encoding: "utf8" });
return result.status === 0;
async function commandSucceeds(command: string, args: string[]): Promise<boolean> {
try {
await execFileAsync(command, args, { encoding: "utf8" });
return true;
} catch {
return false;
}
}
function shellQuote(value: string): string {
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it, vi } from "vitest";
import type { PiWebStatusResponse } from "../shared/apiTypes.js";
import { createPiWebStatusCache } from "./piWebStatusCache.js";
describe("createPiWebStatusCache", () => {
it("serves cached status while it is fresh", async () => {
const now = 1_000;
const load = vi.fn(() => Promise.resolve(status("first")));
const cache = createPiWebStatusCache(load, { ttlMs: 100, now: () => now });
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "first" });
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "first" });
expect(load).toHaveBeenCalledTimes(1);
});
it("returns stale status immediately while refreshing in the background", async () => {
let now = 1_000;
const load = vi.fn()
.mockResolvedValueOnce(status("first"))
.mockResolvedValueOnce(status("second"));
const cache = createPiWebStatusCache(load, { ttlMs: 100, now: () => now });
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "first" });
now = 1_101;
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "first" });
await waitForMicrotasks();
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "second" });
expect(load).toHaveBeenCalledTimes(2);
});
it("deduplicates concurrent cold loads", async () => {
const deferred = createDeferred<PiWebStatusResponse>();
const load = vi.fn(() => deferred.promise);
const cache = createPiWebStatusCache(load);
const first = cache.get();
const second = cache.get();
deferred.resolve(status("ready"));
await expect(first).resolves.toMatchObject({ generatedAt: "ready" });
await expect(second).resolves.toMatchObject({ generatedAt: "ready" });
expect(load).toHaveBeenCalledTimes(1);
});
});
function status(generatedAt: string): PiWebStatusResponse {
return {
packageName: "@jmfederico/pi-web",
generatedAt,
components: {
web: { component: "web", label: "Web/UI", stale: false, available: true },
sessiond: { component: "sessiond", label: "Session daemon", stale: false, available: true },
},
release: { packageName: "@jmfederico/pi-web", updateAvailable: false },
commands: {},
messages: [],
};
}
async function waitForMicrotasks(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
function createDeferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve: (value: T) => void = () => undefined;
const promise = new Promise<T>((innerResolve) => {
resolve = innerResolve;
});
return { promise, resolve };
}
+46
View File
@@ -0,0 +1,46 @@
import type { PiWebStatusResponse } from "../shared/apiTypes.js";
const DEFAULT_PI_WEB_STATUS_CACHE_TTL_MS = 60_000;
export interface PiWebStatusCacheOptions {
ttlMs?: number;
now?: () => number;
onError?: (error: unknown) => void;
}
export interface PiWebStatusCache {
get(): Promise<PiWebStatusResponse>;
refresh(): Promise<PiWebStatusResponse>;
}
export function createPiWebStatusCache(load: () => Promise<PiWebStatusResponse>, options: PiWebStatusCacheOptions = {}): PiWebStatusCache {
const ttlMs = options.ttlMs ?? DEFAULT_PI_WEB_STATUS_CACHE_TTL_MS;
const now = options.now ?? Date.now;
let cached: { status: PiWebStatusResponse; expiresAt: number } | undefined;
let pending: Promise<PiWebStatusResponse> | undefined;
const refresh = (): Promise<PiWebStatusResponse> => {
pending ??= Promise.resolve()
.then(load)
.then((status) => {
cached = { status, expiresAt: now() + ttlMs };
return status;
})
.finally(() => {
pending = undefined;
});
return pending;
};
return {
async get(): Promise<PiWebStatusResponse> {
if (cached !== undefined) {
if (cached.expiresAt > now()) return cached.status;
void refresh().catch((error: unknown) => { options.onError?.(error); });
return cached.status;
}
return refresh();
},
refresh,
};
}
+19 -7
View File
@@ -13,7 +13,8 @@ import { registerSessionRoutes } from "./sessions/sessionRoutes.js";
import { sessiondSocketPath } from "../sessiond/config.js";
import { TerminalService } from "./terminals/terminalService.js";
import { registerTerminalRoutes } from "./terminals/terminalRoutes.js";
import { getPiWebComponentStatus } from "./piWebStatus.js";
import { getPiWebRuntimeComponent } from "./piWebStatus.js";
import { SESSIOND_RUNTIME_CAPABILITIES } from "../shared/capabilities.js";
const app = Fastify({ logger: true });
await app.register(fastifyWebsocket);
@@ -29,12 +30,23 @@ registerAuthRoutes(app, auth);
registerSessionRoutes(app, sessions, eventHub);
registerTerminalRoutes(app, terminals);
app.get("/health", async () => ({
ok: true,
activeSessions: sessions.activeCount(),
checkedAt: new Date().toISOString(),
version: await getPiWebComponentStatus("sessiond"),
}));
app.get("/health", () => {
const runtime = getPiWebRuntimeComponent("sessiond", SESSIOND_RUNTIME_CAPABILITIES);
return {
ok: true,
activeSessions: sessions.activeCount(),
checkedAt: new Date().toISOString(),
version: {
component: runtime.component,
label: runtime.label,
...(runtime.runtimeVersion === undefined ? {} : { runtimeVersion: runtime.runtimeVersion }),
stale: false,
available: runtime.available,
},
};
});
app.get("/runtime", () => getPiWebRuntimeComponent("sessiond", SESSIOND_RUNTIME_CAPABILITIES));
let shuttingDown = false;
async function shutdown(signal: NodeJS.Signals): Promise<void> {
@@ -22,6 +22,7 @@ export function registerSessionProxyRoutes(app: FastifyInstance, daemon: Session
};
app.get(`${prefix}/sessiond/health`, (_request, reply) => proxy({ method: "GET", url: `${prefix}/health` }, reply));
app.get(`${prefix}/sessiond/runtime`, (_request, reply) => proxy({ method: "GET", url: `${prefix}/runtime` }, reply));
app.get<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/events`, { websocket: true }, (socket, request) => {
bridgeSockets(socket, daemon.connectWebSocket(stripPrefix(request.url, prefix)));
+101 -1
View File
@@ -49,8 +49,9 @@ function sessionRef(id: string, cwd = "/workspace") {
function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession> = {}) {
const promptCalls: { text: string; options: unknown }[] = [];
const bindExtensionCalls: unknown[] = [];
const listeners: ((event: unknown) => void)[] = [];
const calls = { abort: 0, clearQueue: 0, dispose: 0, prompt: promptCalls };
const calls = { abort: 0, bindExtensions: bindExtensionCalls, clearQueue: 0, dispose: 0, prompt: promptCalls };
const session: TestSession = {
sessionId,
sessionFile: `/tmp/${sessionId}.jsonl`,
@@ -75,6 +76,10 @@ function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession> = {})
if (index !== -1) listeners.splice(index, 1);
};
},
bindExtensions: (bindings: unknown) => {
calls.bindExtensions.push(bindings);
return Promise.resolve();
},
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,
prompt: (text: string, options: unknown) => {
@@ -149,6 +154,7 @@ describe("PiSessionService", () => {
const session = await service.start("/workspace");
expect(createCalls).toBe(1);
expect(fake.calls.bindExtensions).toHaveLength(1);
expect(session).toMatchObject({ id: "session-1", cwd: "/workspace", messageCount: 0 });
expect(service.activeCount()).toBe(1);
expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "session-1")).toBe(true);
@@ -158,6 +164,59 @@ describe("PiSessionService", () => {
expect(fake.calls.dispose).toBe(1);
});
it("binds extensions again when the SDK runtime replaces the active session", async () => {
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("session-1");
const replacement = fakeRuntime("session-2");
let rebindSession: ((session: PiAgentSession) => Promise<void>) | undefined;
fake.runtime.setRebindSession = (callback) => { rebindSession = callback; };
const service = new PiSessionService(hub, {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
});
await service.start("/workspace");
Object.defineProperty(fake.runtime, "session", { configurable: true, value: replacement.session });
await rebindSession?.(replacement.session);
expect(fake.calls.bindExtensions).toHaveLength(1);
expect(replacement.calls.bindExtensions).toHaveLength(1);
expect(service.activeCount()).toBe(1);
expect(await service.status("session-2")).toMatchObject({ sessionId: "session-2" });
await service.dispose();
});
it("publishes extension errors reported while binding session extensions", async () => {
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("extension-session", {
bindExtensions: (bindings) => {
bindings.onError?.({ extensionPath: "pi-mcp-adapter", event: "session_start", error: "MCP failed" });
return Promise.resolve();
},
});
const service = new PiSessionService(hub, {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
});
await service.start("/workspace");
expect(hub.sessionEvents).toContainEqual({
sessionId: "extension-session",
event: { type: "session.error", message: "pi-mcp-adapter: MCP failed" },
});
const extensionErrorActivity = hub.globalEvents.find((event) => event.type === "activity.update" && event.activity.sessionId === "extension-session");
expect(extensionErrorActivity).toMatchObject({
type: "activity.update",
activity: { sessionId: "extension-session", phase: "error", label: "extension error", detail: "pi-mcp-adapter: MCP failed" },
});
await service.dispose();
});
it("clears stale active activity once a previously active session becomes idle", async () => {
vi.useFakeTimers();
let service: PiSessionService | undefined;
@@ -318,6 +377,33 @@ describe("PiSessionService", () => {
await service.dispose();
});
it("permanently deletes archived sessions through the archive store", async () => {
const deletedSessionIds: string[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), {
archiveStore: {
list: () => Promise.resolve([]),
get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId)
? { sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/archived.jsonl" }
: 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: (sessionId) => {
deletedSessionIds.push(sessionId);
return Promise.resolve();
},
},
sessionManager: sessionGateway([sessionRecord("active")]),
heartbeatIntervalMs: 60_000,
});
await expect(service.deleteArchived("arch")).resolves.toBeUndefined();
await expect(service.deleteArchived("active")).rejects.toThrow("Archived session not found");
expect(deletedSessionIds).toEqual(["archived"]);
await service.dispose();
});
it("reconciles workspace activity when listing only archived sessions", async () => {
const reconciliations: { cwd: string; sessionIds: string[] }[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), {
@@ -365,6 +451,20 @@ describe("PiSessionService", () => {
await service.dispose();
});
it("rejects malformed prompt text before opening the runtime", async () => {
const fake = fakeRuntime("prompt-session");
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("prompt-session")]),
heartbeatIntervalMs: 60_000,
});
await expect(service.prompt("prompt-session", undefined)).rejects.toThrow("Prompt text is required");
expect(fake.calls.prompt).toEqual([]);
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" }],
+61 -9
View File
@@ -54,7 +54,18 @@ interface QueuedPrompt {
text: string;
}
type SessionArchiveRepository = Pick<SessionArchiveStore, "list" | "get" | "archive" | "restore" | "isArchived">;
function requirePromptText(value: unknown): string {
if (typeof value !== "string") throw new Error("Prompt text is required");
return value;
}
function parsePromptStreamingBehavior(value: unknown): QueuedPromptKind | undefined {
if (value === undefined) return undefined;
if (value === "steer" || value === "followUp") return value;
throw new Error('Prompt streamingBehavior must be "steer" or "followUp"');
}
type SessionArchiveRepository = Pick<SessionArchiveStore, "list" | "get" | "archive" | "restore" | "isArchived"> & { deleteArchived?: (sessionId: string) => Promise<void> };
export type PiSessionRef = ClientSessionRef;
@@ -95,6 +106,17 @@ export interface PiSessionManagerGateway {
open(path: string): PiSessionManager;
}
interface PiExtensionError {
extensionPath: string;
event: string;
error: string;
stack?: string;
}
interface PiExtensionBindings {
onError?: (error: PiExtensionError) => void;
}
export interface PiAgentSession {
modelRegistry: ModelRegistryInstance;
sessionManager: PiSessionManager;
@@ -113,6 +135,7 @@ export interface PiAgentSession {
promptTemplates: readonly { name: string; description?: string }[];
resourceLoader: { getSkills(): { skills: readonly { name: string; description?: string }[] } };
subscribe(listener: (event: unknown) => void): () => void;
bindExtensions(bindings: PiExtensionBindings): Promise<void>;
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 };
@@ -377,22 +400,24 @@ export class PiSessionService {
return commands.sort((a, b) => a.name.localeCompare(b.name));
}
async prompt(ref: PiSessionLookup, text: string, streamingBehavior?: "steer" | "followUp"): Promise<void> {
async prompt(ref: PiSessionLookup, text: unknown, streamingBehavior?: unknown): Promise<void> {
const promptText = requirePromptText(text);
const requestedBehavior = parsePromptStreamingBehavior(streamingBehavior);
await this.assertWritable(ref);
const session = await this.getOrOpen(ref);
this.maybeGenerateSessionName(session, text);
this.maybeGenerateSessionName(session, promptText);
const isQueued = session.isStreaming || session.isCompacting;
const behavior = isQueued ? streamingBehavior ?? "followUp" : undefined;
if (isQueued && this.hasQueuedMessageText(session, text)) {
const behavior = isQueued ? requestedBehavior ?? "followUp" : undefined;
if (isQueued && this.hasQueuedMessageText(session, promptText)) {
this.publishActivity(session, "duplicate queued message ignored", "active");
this.publishStatus(session);
return;
}
if (session.isCompacting) {
this.enqueuePromptDuringCompaction(session, text, behavior ?? "followUp");
this.enqueuePromptDuringCompaction(session, promptText, behavior ?? "followUp");
return;
}
void this.submitPrompt(session, text, behavior);
void this.submitPrompt(session, promptText, behavior);
}
private submitPrompt(session: PiAgentSession, text: string, behavior: QueuedPromptKind | undefined): Promise<void> {
@@ -497,6 +522,16 @@ export class PiSessionService {
await this.archiveStore.restore(archived.sessionId);
}
async deleteArchived(ref: PiSessionLookup): Promise<void> {
const record = await this.getArchived(ref);
if (record === undefined) throw new Error("Archived session not found");
if (this.archiveStore.deleteArchived === undefined) throw new Error("Archive store does not support deletion");
await this.closeActive(record.sessionId);
if (record.archivePath === undefined) await this.ensureArchivedRecordMoved(record);
await this.archiveStore.deleteArchived(record.sessionId);
}
async detachParent(ref: PiSessionLookup): Promise<void> {
const session = await this.getOrOpen(ref);
const sessionFile = session.sessionFile;
@@ -541,6 +576,12 @@ 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));
}
private async archiveInputForSession(session: PiAgentSession): Promise<ArchiveSessionInput> {
const cwd = session.sessionManager.getCwd();
const sessionFile = session.sessionFile;
@@ -655,17 +696,28 @@ export class PiSessionService {
private async create(sessionManager: PiSessionManager, cwd: string): Promise<ActiveSession<PiSessionRuntime>> {
const runtime = await this.createAgentRuntime(this.createRuntime, { cwd, agentDir: this.agentDir, sessionManager });
await this.bindSessionExtensions(runtime.session);
const active: ActiveSession<PiSessionRuntime> = { runtime, unsubscribe: noop };
this.bindRuntime(active);
runtime.setRebindSession(() => {
runtime.setRebindSession(async (session) => {
await this.bindSessionExtensions(session);
this.bindRuntime(active);
return Promise.resolve();
});
this.active.set(runtime.session.sessionId, active);
this.publishStatus(runtime.session);
return active;
}
private async bindSessionExtensions(session: PiAgentSession): Promise<void> {
await session.bindExtensions({
onError: (error) => {
const message = `${error.extensionPath}: ${error.error}`;
this.publishActivity(session, "extension error", "error", message);
this.events.publish(session.sessionId, { type: "session.error", message });
},
});
}
private bindRuntime(active: ActiveSession<PiSessionRuntime>): void {
active.unsubscribe();
const { session } = active.runtime;
@@ -44,6 +44,33 @@ describe("SessionArchiveStore", () => {
expect(await exists(record.archivePath)).toBe(false);
await expect(store.list()).resolves.toEqual([]);
});
it("permanently deletes archived session files and records", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-web-archive-delete-"));
tempRoots.push(root);
const activeDir = join(root, "active");
await mkdir(activeDir, { recursive: true });
const sourcePath = join(activeDir, "2026-01-01_s1.jsonl");
await writeFile(sourcePath, "session contents\n", "utf8");
const store = new SessionArchiveStore(join(root, "archived-sessions.json"), join(root, "archived-files"));
const record = await store.archive({
sessionId: "s1",
cwd: "/workspace",
path: sourcePath,
created: "2026-01-01T00:00:00.000Z",
modified: "2026-01-01T00:01:00.000Z",
messageCount: 2,
firstMessage: "hello",
});
if (record.archivePath === undefined) throw new Error("Expected archive path");
await store.deleteArchived("s1");
expect(await exists(sourcePath)).toBe(false);
expect(await exists(record.archivePath)).toBe(false);
await expect(store.list()).resolves.toEqual([]);
});
});
async function exists(path: string): Promise<boolean> {
@@ -88,6 +88,18 @@ 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;
if (record.archivePath !== undefined && await pathExists(record.archivePath)) await unlink(record.archivePath);
const sessions = data.sessions.filter((session) => session.sessionId !== sessionId);
await this.write({ sessions });
});
}
async isArchived(sessionId: string): Promise<boolean> {
return (await this.get(sessionId)) !== undefined;
}
@@ -15,4 +15,8 @@ describe("sessionNameGenerator", () => {
expect(fallbackSessionName('<skill name="x" location="/x">\nDo x\n</skill>\n\nCheck the UI now'))
.toBe("Check the UI now");
});
it("skips fallback names when the first request is missing", () => {
expect(fallbackSessionName(undefined)).toBeUndefined();
});
});
+3 -1
View File
@@ -43,7 +43,9 @@ export async function generateShortSessionName<TApi extends Api>(modelRegistry:
return cleanSessionName(finalMessage === undefined ? streamedText : textFromAssistant(finalMessage));
}
export function fallbackSessionName(firstMessage: string): string | undefined {
export function fallbackSessionName(firstMessage: unknown): string | undefined {
if (typeof firstMessage !== "string") return undefined;
return cleanSessionName(firstMessage
.replace(/<skill name="[^"]+" location="[^"]+">[\s\S]*?<\/skill>/g, "")
.replace(/```[\s\S]*?```/g, " ")
+58
View File
@@ -0,0 +1,58 @@
import Fastify, { type FastifyInstance } from "fastify";
import fastifyWebsocket from "@fastify/websocket";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { SessionEventHub } from "../realtime/sessionEventHub.js";
import { PiSessionService, type PiSessionManagerGateway } from "./piSessionService.js";
import { registerSessionRoutes } from "./sessionRoutes.js";
let app: FastifyInstance;
let service: PiSessionService;
let sessionManager: RejectingSessionManager;
beforeEach(async () => {
app = Fastify({ logger: false });
await app.register(fastifyWebsocket);
sessionManager = new RejectingSessionManager();
const eventHub = new SessionEventHub();
service = new PiSessionService(eventHub, { sessionManager, heartbeatIntervalMs: 60_000 });
registerSessionRoutes(app, service, eventHub);
});
afterEach(async () => {
await service.dispose();
await app.close();
});
describe("session routes", () => {
it("rejects prompt payloads that omit text without opening a session", async () => {
const response = await app.inject({ method: "POST", url: "/sessions/session-1/prompt", payload: { cwd: "/repo", body: "Build the thing" } });
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "Prompt text is required" });
expect(sessionManager.calls).toEqual({ create: 0, list: 0, listAll: 0, open: 0 });
});
});
class RejectingSessionManager implements PiSessionManagerGateway {
readonly calls = { create: 0, list: 0, listAll: 0, open: 0 };
list() {
this.calls.list += 1;
return Promise.resolve([]);
}
create(): never {
this.calls.create += 1;
throw new Error("Session manager should not create sessions for invalid prompt payloads");
}
listAll() {
this.calls.listAll += 1;
return Promise.resolve([]);
}
open(): never {
this.calls.open += 1;
throw new Error("Session manager should not open sessions for invalid prompt payloads");
}
}
+17 -4
View File
@@ -13,6 +13,12 @@ interface MessageQuery extends SessionQuery {
class SessionRouteValidationError extends Error {}
interface PromptRequestBody {
cwd?: unknown;
text?: unknown;
streamingBehavior?: unknown;
}
export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionService, eventHub: SessionEventHub, prefix = ""): void {
app.get<{ Querystring: SessionQuery }>(`${prefix}/sessions`, async (request, reply) => {
if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" });
@@ -106,12 +112,10 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS
}
});
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; text?: unknown; streamingBehavior?: "steer" | "followUp" } }>(`${prefix}/sessions/:sessionId/prompt`, async (request, reply) => {
app.post<{ Params: { sessionId: string }; Body: PromptRequestBody | undefined }>(`${prefix}/sessions/:sessionId/prompt`, async (request, reply) => {
try {
const body = requireRecord(request.body);
const streamingBehavior = body["streamingBehavior"];
if (streamingBehavior !== undefined && streamingBehavior !== "steer" && streamingBehavior !== "followUp") throw new Error("streamingBehavior must be steer or followUp");
await sessions.prompt(sessionRefFromBody(request.params.sessionId, body), requireString(body, "text"), streamingBehavior);
await sessions.prompt(sessionRefFromBody(request.params.sessionId, body), body["text"], body["streamingBehavior"]);
return { accepted: true };
} catch (error) {
return reply.code(400).send({ error: errorMessage(error) });
@@ -190,6 +194,15 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS
}
});
app.delete<{ Params: { sessionId: string }; Querystring: SessionQuery }>(`${prefix}/sessions/:sessionId`, async (request, reply) => {
try {
await sessions.deleteArchived(sessionRefFromQuery(request.params.sessionId, request.query));
return { deleted: true };
} catch (error) {
return reply.code(readErrorStatus(error)).send({ error: errorMessage(error) });
}
});
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } }>(`${prefix}/sessions/:sessionId/detach-parent`, async (request, reply) => {
try {
await sessions.detachParent(sessionRefFromBody(request.params.sessionId, requireRecord(request.body)));