Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/mockup/mockup-tool.ts

Raw
import { randomUUID } from "node:crypto";
import {
	access,
	mkdir,
	readdir,
	readFile,
	rm,
	stat,
	writeFile,
} from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
import {
	defineTool,
	type ExtensionContext,
	keyHint,
} from "@earendil-works/pi-coding-agent";
import { getCapabilities, hyperlink, Text } from "@earendil-works/pi-tui";
import { MOCKUP_DESCRIPTION, mockupParameters } from "./contract.js";
import {
	type MockupRequest,
	type MockupResult,
	type MockupServer,
	startMockupServer,
	validateMockupRequest,
} from "./mockup.js";
import {
	createMockupPreviewManager,
	MockupPreviewComponent,
	type MockupPreviewManager,
	PreviewCancelledError,
} from "./mockup-preview.js";

const CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const CACHE_LIMIT_BYTES = 256 * 1024 * 1024;

export type MockupHistoryEntry = {
	iteration: number;
	question: string;
	options: Array<{ id: string; label: string }>;
	action: MockupResult["action"];
	selected: string[];
	notes: string;
	annotations: MockupResult["annotations"];
	cacheManifest?: string;
};

export type MockupToolDetails = MockupResult & {
	sessionId: string;
	chainId: string;
	iteration: number;
	url: string;
	cacheManifest?: string;
	cacheError?: string;
	history: MockupHistoryEntry[];
	contextCapsule: string;
};

type MockupProgressDetails = {
	kind: "waiting";
	status: "opening" | "waiting";
	url: string;
	iteration: number;
	browserFailed?: boolean;
	timings?: {
		serverMs?: number;
		openerMs?: number;
	};
};

type PreviewProgressDetails = {
	kind: "preview";
	status: "starting" | "running" | "complete";
};

type CancelledDetails = {
	kind: "cancelled";
};

type ActiveChain = {
	server: MockupServer;
	chainId: string;
};

type RuntimeOptions = {
	cacheRoot?: string;
	/** Resolved per browser-lane execution; defaults to opening. */
	openBrowser?: (ctx: ExtensionContext) => boolean;
	browserOpener?: (url: string) => Promise<boolean>;
	previewManager?: MockupPreviewManager;
};

export function createMockupRuntime(options: RuntimeOptions = {}) {
	const active = new Map<string, ActiveChain>();
	const contexts = new Map<string, ExtensionContext>();
	const previews = options.previewManager ?? createMockupPreviewManager();
	const cacheRoot = options.cacheRoot ?? mockupCacheRoot();

	const remember = (ctx: ExtensionContext): string => {
		const sessionId = ctx.sessionManager.getSessionId();
		contexts.set(sessionId, ctx);
		return sessionId;
	};
	const abortAgent = (sessionId: string): boolean => {
		const ctx = contexts.get(sessionId);
		if (!ctx || ctx.isIdle()) return false;
		try {
			ctx.abort();
			return true;
		} catch {
			return false;
		}
	};
	const schedulePrune = (protectedSessionId?: string): void => {
		const timer = setTimeout(() => {
			void pruneMockupCache(cacheRoot, protectedSessionId).catch(
				() => undefined,
			);
		}, 0);
		timer.unref?.();
	};

	const tool = defineTool({
		name: "mockup",
		label: "mockup",
		description: MOCKUP_DESCRIPTION,
		promptSnippet:
			"Preview HTML mockups in Pi or review them interactively in a browser",
		parameters: mockupParameters,
		renderShell: "self",
		renderCall(args, theme, context) {
			const text =
				(context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
			const options = Array.isArray(args.options) ? args.options : [];
			const optionCount = options.length;
			const optionWord = `option${optionCount === 1 ? "" : "s"}`;
			const htmlCharacters = options.reduce(
				(total, option) =>
					total + (typeof option.html === "string" ? option.html.length : 0),
				0,
			);
			const phase =
				context.isPartial && !context.argsComplete
					? ` · generating HTML${htmlCharacters > 0 ? ` · ${htmlCharacters} chars` : ""}`
					: context.isPartial && !context.executionStarted
						? " · waiting to run"
						: "";
			let output = `${theme.fg("toolTitle", theme.bold("mockup"))} ${theme.fg("accent", args.title ?? "untitled")} ${theme.fg("toolOutput", `· ${optionCount} ${optionWord}${args.multi ? " · multi-select" : ""}${phase}`)}`;
			if (!context.expanded) {
				output += theme.fg(
					"dim",
					` (${keyHint("app.tools.expand", "to expand")})`,
				);
			} else {
				if (args.question)
					output += `\n${theme.fg("toolOutput", args.question)}`;
				for (const option of args.options ?? []) {
					const description = option.description
						? theme.fg("dim", ` · ${option.description}`)
						: "";
					output += `\n${theme.fg("accent", String(option.id))} ${theme.fg("toolOutput", option.label)}${description}`;
				}
			}
			text.setText(output);
			return text;
		},
		renderResult(result, { expanded, isPartial }, theme, context) {
			const details = result.details as
				| Partial<MockupToolDetails>
				| MockupProgressDetails
				| PreviewProgressDetails
				| CancelledDetails
				| undefined;
			if (details && "kind" in details && details.kind === "preview") {
				const preview = previews.get(context.toolCallId);
				if (preview) {
					if (context.lastComponent instanceof MockupPreviewComponent) {
						context.lastComponent.update(
							(style, value) => theme.fg(style, value),
							context.invalidate,
							context.showImages,
						);
						if (details.status === "complete") context.lastComponent.settle();
						return context.lastComponent;
					}
					return new MockupPreviewComponent(
						preview,
						(style, value) => theme.fg(style, value),
						context.invalidate,
						context.showImages,
					);
				}
				if (details.status === "starting")
					return new Text(theme.fg("accent", "Preparing previews…"), 0, 0);
				return new Text(
					theme.fg(
						"muted",
						"Preview expired. Run the mockup again to render it.",
					),
					0,
					0,
				);
			}
			const text =
				context.lastComponent instanceof Text
					? context.lastComponent
					: new Text("", 0, 0);
			if (details && "kind" in details && details.kind === "cancelled") {
				text.setText(theme.fg("muted", "Mockup cancelled."));
				return text;
			}
			if (
				isPartial &&
				details &&
				"kind" in details &&
				details.kind === "waiting"
			) {
				const status = details.browserFailed
					? theme.fg("warning", "Browser did not open. Open this mockup:")
					: details.status === "opening"
						? theme.fg("accent", "Review ready · opening browser…")
						: theme.fg("accent", "Review ready · awaiting feedback");
				let diagnostics = expanded
					? theme.fg("dim", ` · iteration ${details.iteration}`)
					: "";
				if (expanded && details.timings) {
					if (details.timings.serverMs !== undefined)
						diagnostics += theme.fg(
							"dim",
							` · server ${details.timings.serverMs}ms`,
						);
					if (details.timings.openerMs !== undefined)
						diagnostics += theme.fg(
							"dim",
							` · opener ${details.timings.openerMs}ms`,
						);
				}
				text.setText(`${status}${diagnostics}\n${linkedUrl(details.url)}`);
				return text;
			}
			if (context.isError || !details || "kind" in details) {
				text.setText(
					expandableFallback(
						result.content,
						expanded,
						theme,
						context.isError ? "error" : "muted",
					),
				);
				return text;
			}
			const action = details.action ?? "submitted";
			const status =
				action === "accept"
					? theme.fg("success", "✓ accepted")
					: theme.fg("warning", "↻ iterate");
			const selected = (details.selected ?? []).join(", ") || "none";
			const annotations = details.annotations ?? [];
			let output = `${status}${theme.fg("dim", ` · selected ${selected} · ${annotations.length} annotation${annotations.length === 1 ? "" : "s"}`)}`;
			if (!expanded) {
				output += theme.fg(
					"dim",
					` (${keyHint("app.tools.expand", "to expand")})`,
				);
			} else {
				if (details.notes)
					output += `\n${theme.fg("toolOutput", `notes: ${details.notes}`)}`;
				for (const annotation of annotations) {
					output += `\n${theme.fg("accent", annotation.optionId)} ${theme.fg("dim", `@ ${Math.round(annotation.x * 100)}%,${Math.round(annotation.y * 100)}%`)} ${theme.fg("toolOutput", annotation.note)}`;
				}
				if (details.url) output += `\n${linkedUrl(details.url)}`;
				if (details.history && details.history.length > 1)
					output += `\n${theme.fg("dim", `iterations: ${details.history.length}`)}`;
				if (details.cacheManifest)
					output += `\n${theme.fg("dim", `cache: ${details.cacheManifest}`)}`;
				else if (details.cacheError)
					output += `\n${theme.fg("warning", `cache unavailable: ${details.cacheError}`)}`;
			}
			text.setText(output);
			return text;
		},
		async execute(toolCallId, params, signal, onUpdate, ctx) {
			const request = validateMockupRequest(params);
			const sessionId = remember(ctx);
			const previous = latestMockupDetails(ctx);
			const continuing =
				previous?.sessionId === sessionId && previous.action === "iterate";
			const pendingChain = active.get(sessionId);
			const continuingInBrowser =
				continuing && pendingChain?.chainId === previous.chainId;
			const lane = continuingInBrowser
				? "browser"
				: await selectMockupLane(ctx, signal);
			if (lane === "cancel") return cancelledResult();
			if (pendingChain && !continuingInBrowser) {
				pendingChain.server.close("superseded");
				active.delete(sessionId);
			}
			if (lane === "preview") {
				onUpdate?.({
					content: [{ type: "text", text: "Preparing mockup previews in Pi." }],
					details: { kind: "preview", status: "starting" },
				});
				try {
					const preview = await previews.start(toolCallId, request, signal);
					onUpdate?.({
						content: [{ type: "text", text: "Showing mockup previews in Pi." }],
						details: { kind: "preview", status: "running" },
					});
					await abortable(preview.done, signal, () => preview.close());
					return {
						content: [
							{
								type: "text" as const,
								text: JSON.stringify({
									action: "previewed",
									next: "Wait for the human's conversational feedback. Do not infer acceptance, rejection, or selection from previewing.",
								}),
							},
						],
						details: { kind: "preview" as const, status: "complete" as const },
						terminate: true,
					};
				} catch (error) {
					previews.get(toolCallId)?.close();
					if (signal?.aborted || error instanceof PreviewCancelledError)
						return cancelledResult();
					ctx.ui.notify(
						`Pi preview unavailable: ${error instanceof Error ? error.message : String(error)}`,
						"warning",
					);
					if ((await selectBrowserFallback(ctx, signal)) !== "browser")
						return cancelledResult();
				}
			}
			let chain = continuingInBrowser ? pendingChain : undefined;
			const created = !chain;
			let serverMs: number | undefined;
			if (!chain) {
				const serverStartedAt = performance.now();
				chain = {
					chainId: continuing ? previous.chainId : randomUUID(),
					server: await startMockupServer(request, {
						openBrowser: options.openBrowser?.(ctx) ?? true,
						browserOpener: options.browserOpener,
						abortAgent: () => abortAgent(sessionId),
					}),
				};
				serverMs = Math.round(performance.now() - serverStartedAt);
				active.set(sessionId, chain);
			}
			const iteration = continuing ? previous.iteration + 1 : 1;
			const history = continuing ? previous.history : [];
			try {
				const connected = chain.server.connected();
				const resultPromise = created
					? chain.server.result
					: chain.server.present(request);
				let openerMs: number | undefined;
				const progress = (
					status: MockupProgressDetails["status"],
					browserFailed = false,
				) =>
					onUpdate?.({
						content: [
							{
								type: "text" as const,
								text:
									status === "opening"
										? `Review ready; opening browser: ${chain.server.url}`
										: `Review ready; awaiting feedback: ${chain.server.url}`,
							},
						],
						details: {
							kind: "waiting" as const,
							status,
							url: chain.server.url,
							iteration,
							timings: {
								...(serverMs !== undefined && { serverMs }),
								...(openerMs !== undefined && { openerMs }),
							},
							...(browserFailed && { browserFailed: true }),
						},
					});
				if (!connected) {
					progress("opening");
					const openerStartedAt = performance.now();
					const opened = await chain.server.open();
					openerMs = Math.round(performance.now() - openerStartedAt);
					progress("waiting", opened === false);
				} else {
					progress("waiting");
				}
				const result = await abortable(resultPromise, signal, () =>
					chain?.server.close("aborted"),
				);
				let cacheManifest: string | undefined;
				let cacheError: string | undefined;
				try {
					cacheManifest = await persistMockupIteration(cacheRoot, {
						sessionId,
						sessionFile: ctx.sessionManager.getSessionFile(),
						chainId: chain.chainId,
						iteration,
						toolCallId,
						request,
						result,
						previousManifest: continuing ? previous.cacheManifest : undefined,
					});
				} catch (error) {
					cacheError = error instanceof Error ? error.message : String(error);
				}
				const current = historyEntry(iteration, request, result, cacheManifest);
				const nextHistory = [...history, current];
				const contextCapsule = formatContextCapsule(
					chain.chainId,
					nextHistory,
					cacheError,
				);
				const details: MockupToolDetails = {
					...result,
					sessionId,
					chainId: chain.chainId,
					iteration,
					url: chain.server.url,
					cacheManifest,
					cacheError,
					history: nextHistory,
					contextCapsule,
				};
				if (result.action === "accept") {
					chain.server.close("accepted");
					active.delete(sessionId);
					schedulePrune(sessionId);
				}
				return {
					content: [
						{
							type: "text" as const,
							text: JSON.stringify({
								action: result.action,
								selected: result.selected,
								notes: result.notes,
								annotations: result.annotations,
								cacheManifest,
								contextCapsule,
								next:
									result.action === "iterate"
										? "Apply the feedback and call mockup again unless the human redirects or stops the review."
										: "Continue with the accepted direction.",
							}),
						},
					],
					details,
				};
			} catch (error) {
				chain.server.close("failed");
				active.delete(sessionId);
				schedulePrune(sessionId);
				throw error;
			}
		},
	});

	return {
		tool,
		remember,
		contextCapsule(ctx: ExtensionContext): string | undefined {
			const sessionId = remember(ctx);
			const details = latestMockupDetails(ctx);
			return details?.sessionId === sessionId
				? details.contextCapsule
				: undefined;
		},
		agentWorking(ctx: ExtensionContext): void {
			const sessionId = remember(ctx);
			active.get(sessionId)?.server.progress("Pi is working on the revision.");
		},
		agentSettled(ctx: ExtensionContext): void {
			const sessionId = remember(ctx);
			const chain = active.get(sessionId);
			if (chain?.server.settled()) {
				active.delete(sessionId);
				schedulePrune(sessionId);
			}
		},
		async shutdown(ctx: ExtensionContext): Promise<void> {
			const sessionId = ctx.sessionManager.getSessionId();
			active.get(sessionId)?.server.close("session ended");
			active.delete(sessionId);
			contexts.delete(sessionId);
			await previews.close();
			schedulePrune();
		},
	};
}

type MockupLane = "preview" | "browser" | "cancel";

async function selectMockupLane(
	ctx: ExtensionContext,
	signal?: AbortSignal,
): Promise<MockupLane> {
	if (ctx.mode !== "tui") return "browser";
	const unavailable = previewUnavailableReason();
	if (unavailable) {
		ctx.ui.notify(unavailable, "warning");
		return selectBrowserFallback(ctx, signal);
	}
	const choice = await ctx.ui.select(
		"How should this mockup open?",
		["Show previews in Pi", "Open interactive browser", "Cancel"],
		{ signal },
	);
	if (choice === "Show previews in Pi") return "preview";
	if (choice === "Open interactive browser") return "browser";
	return "cancel";
}

async function selectBrowserFallback(
	ctx: ExtensionContext,
	signal?: AbortSignal,
): Promise<"browser" | "cancel"> {
	if (ctx.mode !== "tui") return "browser";
	const choice = await ctx.ui.select(
		"Use the interactive browser instead?",
		["Open interactive browser", "Cancel"],
		{ signal },
	);
	return choice === "Open interactive browser" ? "browser" : "cancel";
}

function previewUnavailableReason(): string | undefined {
	if (!getCapabilities().images)
		return "This terminal does not support inline image previews.";
	const fullscreen = process.argv.some(
		(argument, index) =>
			argument === "--tui-mode=fullscreen" ||
			(argument === "--tui-mode" && process.argv[index + 1] === "fullscreen"),
	);
	if (fullscreen && getCapabilities().images === "kitty")
		return "Inline previews are unavailable in fullscreen mode until Pi's image redraw regression is fixed.";
	return undefined;
}

function cancelledResult() {
	return {
		content: [
			{
				type: "text" as const,
				text: JSON.stringify({
					action: "cancelled",
					next: "Do not continue the mockup review.",
				}),
			},
		],
		details: { kind: "cancelled" as const },
		terminate: true,
	};
}

function latestMockupDetails(
	ctx: ExtensionContext,
): MockupToolDetails | undefined {
	const branch = ctx.sessionManager.getBranch();
	for (let index = branch.length - 1; index >= 0; index--) {
		const entry = branch[index] as unknown as Record<string, unknown>;
		if (entry.type !== "message") continue;
		const message = entry.message;
		if (!isRecord(message)) continue;
		if (message.role !== "toolResult" || message.toolName !== "mockup")
			continue;
		const details = message.details;
		if (!isRecord(details) || typeof details.contextCapsule !== "string")
			return undefined;
		return details as unknown as MockupToolDetails;
	}
	return undefined;
}

function historyEntry(
	iteration: number,
	request: MockupRequest,
	result: MockupResult,
	cacheManifest?: string,
): MockupHistoryEntry {
	return {
		iteration,
		question: request.question,
		options: request.options.map(({ id, label }) => ({ id, label })),
		action: result.action,
		selected: result.selected,
		notes: result.notes,
		annotations: result.annotations,
		cacheManifest,
	};
}

export function formatContextCapsule(
	chainId: string,
	history: MockupHistoryEntry[],
	cacheError?: string,
): string {
	const latest = history.at(-1);
	if (!latest) return "";
	const lines = [
		"Mockup context capsule",
		`Chain: ${chainId}`,
		`Current: iteration ${latest.iteration}, ${latest.action}`,
		`Question: ${latest.question}`,
		`Selected: ${selectedLabels(latest)}`,
		`Notes: ${latest.notes || "none"}`,
	];
	if (latest.annotations.length === 0) lines.push("Annotations: none");
	else {
		lines.push("Annotations:");
		for (const annotation of latest.annotations)
			lines.push(
				`- ${annotation.optionId} @ ${Math.round(annotation.x * 100)}%,${Math.round(annotation.y * 100)}%: ${annotation.note}`,
			);
	}
	lines.push(
		"Rejected rationale: not recorded separately; never infer rejection from non-selection.",
		`Cache manifest: ${latest.cacheManifest ?? `unavailable${cacheError ? ` (${cacheError})` : ""}`}`,
	);
	if (history.length > 1) {
		lines.push("Earlier iterations, newest first:");
		for (const entry of history.slice(0, -1).reverse()) {
			lines.push(
				`- ${entry.iteration} ${entry.action}; selected ${selectedLabels(entry)}; notes: ${entry.notes || "none"}`,
			);
			for (const annotation of entry.annotations)
				lines.push(`  annotation ${annotation.optionId}: ${annotation.note}`);
			if (entry.cacheManifest) lines.push(`  cache: ${entry.cacheManifest}`);
		}
	}
	return lines.join("\n");
}

function linkedUrl(url: string): string {
	return getCapabilities().hyperlinks ? hyperlink(url, url) : url;
}

function expandableFallback(
	content: Array<{ type: string; text?: string }>,
	expanded: boolean,
	theme: { fg(style: string, text: string): string },
	style: "error" | "muted",
): string {
	const full = content
		.flatMap((item) => (item.type === "text" && item.text ? [item.text] : []))
		.join("\n")
		.trim();
	if (expanded) return theme.fg(style, full);
	const firstLine = full.split(/\r?\n/, 1)[0] ?? "";
	const normalized = firstLine.replace(/\s+/g, " ").trim();
	const summary =
		normalized.length > 160 ? `${normalized.slice(0, 159)}…` : normalized;
	return full !== firstLine || summary !== normalized
		? `${theme.fg(style, summary)}\n${theme.fg("muted", "↳")} ${keyHint("app.tools.expand", "to expand")}`
		: theme.fg(style, summary);
}

function selectedLabels(entry: MockupHistoryEntry): string {
	if (entry.selected.length === 0) return "none";
	const labels = new Map(
		entry.options.map((option) => [option.id, option.label]),
	);
	return entry.selected
		.map((id) => `${id} (${labels.get(id) ?? id})`)
		.join(", ");
}

export function mockupCacheRoot(
	platform: NodeJS.Platform = process.platform,
	env: NodeJS.ProcessEnv = process.env,
	home: string = homedir(),
): string {
	if (platform === "win32")
		return path.join(
			env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"),
			"pi",
			"cache",
			"mockups",
		);
	if (platform === "darwin")
		return path.join(home, "Library", "Caches", "pi", "mockups");
	return path.join(
		env.XDG_CACHE_HOME ?? path.join(home, ".cache"),
		"pi",
		"mockups",
	);
}

type PersistInput = {
	sessionId: string;
	sessionFile: string | undefined;
	chainId: string;
	iteration: number;
	toolCallId: string;
	request: MockupRequest;
	result: MockupResult;
	previousManifest?: string;
};

export async function persistMockupIteration(
	root: string,
	input: PersistInput,
): Promise<string> {
	const sessionDir = path.join(root, safeSegment(input.sessionId));
	const iterationDir = path.join(
		sessionDir,
		safeSegment(input.chainId),
		`${String(input.iteration).padStart(3, "0")}-${safeSegment(input.toolCallId)}`,
	);
	await mkdir(iterationDir, { recursive: true });
	await Promise.all([
		writeFile(
			path.join(sessionDir, "session.json"),
			`${JSON.stringify({ sessionId: input.sessionId, sessionFile: input.sessionFile }, null, 2)}\n`,
		),
		writeFile(
			path.join(iterationDir, "request.json"),
			`${JSON.stringify(input.request, null, 2)}\n`,
		),
		writeFile(
			path.join(iterationDir, "feedback.json"),
			`${JSON.stringify(input.result, null, 2)}\n`,
		),
		...input.request.options.map((option) =>
			writeFile(
				path.join(iterationDir, `${safeSegment(option.id)}.html`),
				option.html,
			),
		),
	]);
	const manifest = path.join(iterationDir, "manifest.json");
	await writeFile(
		manifest,
		`${JSON.stringify(
			{
				version: 1,
				recordedAt: new Date().toISOString(),
				sessionId: input.sessionId,
				chainId: input.chainId,
				iteration: input.iteration,
				toolCallId: input.toolCallId,
				previousManifest: input.previousManifest,
				request: "request.json",
				feedback: "feedback.json",
				options: input.request.options.map((option) => ({
					id: option.id,
					label: option.label,
					html: `${safeSegment(option.id)}.html`,
				})),
			},
			null,
			2,
		)}\n`,
	);
	return manifest;
}

export async function pruneMockupCache(
	root: string,
	protectedSessionId?: string,
	now = Date.now(),
	policy: { ttlMs: number; maxBytes: number } = {
		ttlMs: CACHE_TTL_MS,
		maxBytes: CACHE_LIMIT_BYTES,
	},
): Promise<void> {
	const sessions = await directories(root);
	const candidates: Array<{ path: string; modified: number; bytes: number }> =
		[];
	for (const session of sessions) {
		if (session.name === safeSegment(protectedSessionId ?? "")) continue;
		const metadataPath = path.join(session.path, "session.json");
		const metadata = await readJson(metadataPath);
		if (
			isRecord(metadata) &&
			typeof metadata.sessionFile === "string" &&
			!(await exists(metadata.sessionFile))
		) {
			await rm(session.path, { recursive: true, force: true });
			continue;
		}
		for (const chain of await directories(session.path)) {
			for (const iteration of await directories(chain.path)) {
				const info = await stat(iteration.path);
				if (now - info.mtimeMs >= policy.ttlMs) {
					await rm(iteration.path, { recursive: true, force: true });
					continue;
				}
				candidates.push({
					path: iteration.path,
					modified: info.mtimeMs,
					bytes: await directorySize(iteration.path),
				});
			}
		}
	}
	let total = candidates.reduce((sum, candidate) => sum + candidate.bytes, 0);
	for (const candidate of candidates.sort(
		(left, right) => left.modified - right.modified,
	)) {
		if (total <= policy.maxBytes) break;
		await rm(candidate.path, { recursive: true, force: true });
		total -= candidate.bytes;
	}
}

async function directories(
	root: string,
): Promise<Array<{ name: string; path: string }>> {
	try {
		return (await readdir(root, { withFileTypes: true }))
			.filter((entry) => entry.isDirectory() && !entry.isSymbolicLink())
			.map((entry) => ({
				name: entry.name,
				path: path.join(root, entry.name),
			}));
	} catch (error) {
		if (isNotFound(error)) return [];
		throw error;
	}
}

async function directorySize(root: string): Promise<number> {
	let bytes = 0;
	for (const entry of await readdir(root, { withFileTypes: true })) {
		if (entry.isSymbolicLink()) continue;
		const child = path.join(root, entry.name);
		if (entry.isDirectory()) bytes += await directorySize(child);
		else if (entry.isFile()) bytes += (await stat(child)).size;
	}
	return bytes;
}

async function readJson(file: string): Promise<unknown> {
	try {
		return JSON.parse(await readFile(file, "utf8"));
	} catch (error) {
		if (isNotFound(error)) return undefined;
		throw error;
	}
}

async function exists(file: string): Promise<boolean> {
	try {
		await access(file);
		return true;
	} catch {
		return false;
	}
}

function safeSegment(value: string): string {
	return value.replaceAll(/[^a-zA-Z0-9._-]/g, "_") || "unknown";
}

function isRecord(value: unknown): value is Record<string, unknown> {
	return !!value && typeof value === "object" && !Array.isArray(value);
}

function isNotFound(error: unknown): boolean {
	return isRecord(error) && error.code === "ENOENT";
}

function abortable<T>(
	promise: Promise<T>,
	signal: AbortSignal | undefined,
	abort: () => void,
): Promise<T> {
	if (!signal) return promise;
	if (signal.aborted) {
		abort();
		return Promise.reject(new Error("mockup aborted"));
	}
	return new Promise((resolve, reject) => {
		const onAbort = () => {
			abort();
			reject(new Error("mockup aborted"));
		};
		signal.addEventListener("abort", onAbort, { once: true });
		promise
			.then(resolve, reject)
			.finally(() => signal.removeEventListener("abort", onAbort));
	});
}