Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/intellij/implementation.ts

Raw
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { lookup } from "node:dns/promises";
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import {
	DEFAULT_MAX_BYTES,
	DEFAULT_MAX_LINES,
	type ExtensionAPI,
	type ExtensionCommandContext,
	type ExtensionContext,
	getAgentDir,
	keyHint,
	type Theme,
	truncateHead,
} from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";
import { type TSchema, Type } from "typebox";
import { dbg, span } from "./src/debug.ts";
import { findExecutable } from "./src/pi-ext-executable.ts";
import {
	resolveSetting,
	type SettingDeclaration,
} from "./src/pi-ext-settings.ts";

export type IntellijState =
	| "disconnected"
	| "connecting"
	| "connected"
	| "failed";
export type IntellijCommand =
	| "connect"
	| "disconnect"
	| "reconnect"
	| "status"
	| "tools";

type JsonObject = Record<string, unknown>;

type RemoteTool = {
	name: string;
	description?: string;
	inputSchema: JsonObject & { type: "object" };
};

type CatalogTool = RemoteTool & {
	localName: string;
	acceptsProjectPath: boolean;
	agentSchema: JsonObject & { type: "object" };
};

type BridgeContent =
	| { type: "text"; text: string }
	| { type: "image"; data: string; mimeType: string };

type BridgeResult = {
	content: BridgeContent[];
	details: { source: "intellij"; tool: string };
};

type McpResult = {
	content?: unknown[];
	structuredContent?: JsonObject;
	isError?: boolean;
	toolResult?: unknown;
};

interface McpConnection {
	serverName: string;
	listTools(signal?: AbortSignal): Promise<RemoteTool[]>;
	callTool(
		name: string,
		args: JsonObject,
		signal: AbortSignal | undefined,
		onProgress?: (progress: unknown) => void,
	): Promise<McpResult>;
	close(): Promise<void>;
	onClose(handler: () => void): void;
}

export interface IntellijRuntime {
	run(
		command: IntellijCommand,
		argument: string,
		ctx: ExtensionCommandContext,
	): Promise<void>;
	shutdown(): Promise<void>;
}

const CONNECT_TIMEOUT_MS = 3_000;
const DEFAULT_TOOL_TIMEOUT_MS = 60_000;
const TOOL_TIMEOUT_GRACE_MS = 5_000;
const RETRY_WINDOW_MS = 8_000;
const RETRY_DELAY_MS = 400;
const MAX_CATALOG_TOOLS = 512;
const MAX_SCHEMA_BYTES = 256 * 1024;
const VALID_SCHEMA_TYPES = new Set([
	"null",
	"boolean",
	"object",
	"array",
	"number",
	"integer",
	"string",
]);
const SCHEMA_MAP_KEYWORDS = [
	"properties",
	"patternProperties",
	"$defs",
	"definitions",
] as const;
const SCHEMA_CHILD_KEYWORDS = [
	"additionalProperties",
	"unevaluatedProperties",
	"items",
	"contains",
	"propertyNames",
	"not",
	"if",
	"then",
	"else",
] as const;
const SCHEMA_ARRAY_KEYWORDS = [
	"allOf",
	"anyOf",
	"oneOf",
	"prefixItems",
] as const;
const SCHEMA_COMPOSITION_KEYWORDS = ["allOf", "anyOf", "oneOf"] as const;
const PROJECT_PROBE = "get_project_modules";
// nf-dev-intellij, U+E7B5.
const INTELLIJ_ICON = "";
const TOOL_GUIDANCE = [
	"Use intellij_* tools when IDE indexes, inspections, refactoring, builds, run configurations, debugging, or database integrations provide value; prefer built-in tools for plain filesystem and shell work.",
	"After IntelliJ changes source, use available intellij_* inspection tools and an available IntelliJ build tool when build state matters.",
];
const RECOVERY =
	"In IntelliJ, open Settings | Tools | MCP Server | Manual Client Configuration, click Copy HTTP Stream Config, then run /intellij connect <url>.";

class ProjectUnavailableError extends Error {}
class EndpointValidationError extends Error {}

export function normalizeIntellijUrl(value: string): string {
	let url: URL;
	try {
		url = new URL(value);
	} catch {
		throw new Error("IntelliJ MCP URL is malformed.");
	}
	if (url.protocol !== "http:")
		throw new Error("IntelliJ MCP URL must use HTTP.");
	if (url.username || url.password)
		throw new Error("IntelliJ MCP URL must not contain credentials.");
	if (url.hash)
		throw new Error("IntelliJ MCP URL must not contain a fragment.");
	if (!isLoopbackHost(url.hostname))
		throw new Error("IntelliJ MCP URL must use a loopback host.");
	return url.href;
}

function isLoopbackHost(hostname: string): boolean {
	const host = hostname.toLowerCase();
	if (host === "localhost" || host === "[::1]") return true;
	const match = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(host);
	return match !== null && Number(match[1]) === 127;
}

export function acceptsProjectPath(schema: JsonObject): boolean {
	return acceptsProjectPathIn(schema, schema, new Set());
}

function acceptsProjectPathIn(
	schema: unknown,
	root: JsonObject,
	visitedRefs: Set<string>,
): boolean {
	if (!isRecord(schema)) return false;
	const properties = schema.properties;
	if (
		isRecord(properties) &&
		schemaAcceptsString(properties.projectPath, root, visitedRefs)
	)
		return true;
	if (typeof schema.$ref === "string") {
		if (visitedRefs.has(schema.$ref)) return false;
		visitedRefs.add(schema.$ref);
		const target = resolveLocalRef(root, schema.$ref);
		if (acceptsProjectPathIn(target, root, visitedRefs)) return true;
	}
	return SCHEMA_COMPOSITION_KEYWORDS.some((keyword) => {
		const value = schema[keyword];
		return (
			Array.isArray(value) &&
			value.some((entry) => acceptsProjectPathIn(entry, root, visitedRefs))
		);
	});
}

function schemaAcceptsString(
	schema: unknown,
	root: JsonObject,
	visitedRefs: Set<string>,
): boolean {
	if (!isRecord(schema)) return false;
	if (schema.type === "string") return true;
	if (Array.isArray(schema.type) && schema.type.includes("string")) return true;
	if (typeof schema.$ref === "string") {
		if (visitedRefs.has(schema.$ref)) return false;
		visitedRefs.add(schema.$ref);
		if (
			schemaAcceptsString(resolveLocalRef(root, schema.$ref), root, visitedRefs)
		)
			return true;
	}
	return SCHEMA_COMPOSITION_KEYWORDS.some((keyword) => {
		const value = schema[keyword];
		return (
			Array.isArray(value) &&
			value.some((entry) => schemaAcceptsString(entry, root, visitedRefs))
		);
	});
}

function resolveLocalRef(root: JsonObject, reference: string): unknown {
	if (!reference.startsWith("#/")) return undefined;
	let value: unknown = root;
	for (const encoded of reference.slice(2).split("/")) {
		if (!isRecord(value)) return undefined;
		const key = encoded.replaceAll("~1", "/").replaceAll("~0", "~");
		value = value[key];
	}
	return value;
}

export function schemaWithoutInjectedProjectPath(
	schema: JsonObject & { type: "object" },
): JsonObject & { type: "object" } {
	const nodesToStrip = new Set<JsonObject>();
	const visited = new Set<JsonObject>();
	const findProjectSchemas = (node: unknown): void => {
		if (!isRecord(node) || visited.has(node)) return;
		visited.add(node);
		if (
			(isRecord(node.properties) && "projectPath" in node.properties) ||
			(Array.isArray(node.required) && node.required.includes("projectPath"))
		)
			nodesToStrip.add(node);
		if (typeof node.$ref === "string")
			findProjectSchemas(resolveLocalRef(schema, node.$ref));
		for (const keyword of SCHEMA_COMPOSITION_KEYWORDS) {
			const children = node[keyword];
			if (Array.isArray(children))
				for (const child of children) findProjectSchemas(child);
		}
	};
	findProjectSchemas(schema);
	if (nodesToStrip.size === 0) return schema;

	// MCP schemas are validated immutable JSON. Copy only paths leading to the
	// bridge-owned field; cloning every untouched schema node dominated connect CPU.
	const copyChangedPaths = (value: unknown): unknown => {
		if (Array.isArray(value)) {
			let changed = false;
			const children = value.map((child) => {
				const copy = copyChangedPaths(child);
				if (copy !== child) changed = true;
				return copy;
			});
			return changed ? children : value;
		}
		if (!isRecord(value)) return value;
		let copy: JsonObject | undefined;
		for (const [key, child] of Object.entries(value)) {
			const childCopy = copyChangedPaths(child);
			if (childCopy !== child) {
				copy ??= { ...value };
				copy[key] = childCopy;
			}
		}
		if (nodesToStrip.has(value)) {
			const current = copy ?? value;
			if (isRecord(current.properties) && "projectPath" in current.properties) {
				copy ??= { ...value };
				copy.properties = { ...current.properties };
				delete (copy.properties as JsonObject).projectPath;
			}
			if (
				Array.isArray(current.required) &&
				current.required.includes("projectPath")
			) {
				copy ??= { ...value };
				copy.required = current.required.filter(
					(field) => field !== "projectPath",
				);
			}
		}
		return copy ?? value;
	};
	return copyChangedPaths(schema) as JsonObject & { type: "object" };
}

export function validateRemoteTool(value: unknown): RemoteTool {
	if (!isRecord(value)) throw new Error("MCP advertised a non-object tool.");
	if (
		typeof value.name !== "string" ||
		value.name.length === 0 ||
		value.name.length > 128 ||
		!/^[-A-Za-z0-9_.]+$/.test(value.name)
	)
		throw new Error("MCP advertised an invalid tool name.");
	validateSchema(value.inputSchema);
	if (value.description !== undefined && typeof value.description !== "string")
		throw new Error(`MCP tool ${value.name} has an invalid description.`);
	return {
		name: value.name,
		description: value.description as string | undefined,
		inputSchema: value.inputSchema as RemoteTool["inputSchema"],
	};
}

function validateSchema(value: unknown): void {
	if (!isRecord(value) || value.type !== "object")
		throw new Error("MCP tool input schema must be an object schema.");
	const encoded = JSON.stringify(value);
	if (encoded.length > MAX_SCHEMA_BYTES)
		throw new Error("MCP tool input schema is too large.");
	validateSchemaNode(value, value, 0, { count: 0 });
}

function validateSchemaNode(
	value: unknown,
	root: JsonObject,
	depth: number,
	budget: { count: number },
): void {
	if (!isRecord(value) || depth > 64 || ++budget.count > 10_000)
		throw new Error("MCP tool schema contains an invalid nested schema.");
	if (
		value.type !== undefined &&
		!(
			(typeof value.type === "string" && VALID_SCHEMA_TYPES.has(value.type)) ||
			(Array.isArray(value.type) &&
				value.type.length > 0 &&
				value.type.every(
					(type) => typeof type === "string" && VALID_SCHEMA_TYPES.has(type),
				))
		)
	)
		throw new Error("MCP tool schema has an invalid type.");
	if (value.$ref !== undefined) {
		if (
			typeof value.$ref !== "string" ||
			!value.$ref.startsWith("#/") ||
			resolveLocalRef(root, value.$ref) === undefined
		)
			throw new Error("MCP tool schema has an unsupported reference.");
	}
	if (value.required !== undefined) {
		if (
			!Array.isArray(value.required) ||
			!value.required.every((field) => typeof field === "string") ||
			new Set(value.required).size !== value.required.length
		)
			throw new Error("MCP tool schema required fields are invalid.");
		const properties = value.properties as JsonObject | undefined;
		if (
			isRecord(properties) &&
			value.required.some((field) => !(field in properties))
		)
			throw new Error("MCP tool schema requires an unknown field.");
	}
	for (const keyword of SCHEMA_MAP_KEYWORDS) {
		const entries = value[keyword];
		if (entries === undefined) continue;
		if (!isRecord(entries))
			throw new Error(`MCP tool schema ${keyword} must be an object.`);
		for (const child of Object.values(entries))
			validateSchemaNode(child, root, depth + 1, budget);
	}
	for (const keyword of SCHEMA_CHILD_KEYWORDS) {
		const child = value[keyword];
		if (child === undefined || typeof child === "boolean") continue;
		validateSchemaNode(child, root, depth + 1, budget);
	}
	for (const keyword of SCHEMA_ARRAY_KEYWORDS) {
		const children = value[keyword];
		if (children === undefined) continue;
		if (!Array.isArray(children) || children.length === 0)
			throw new Error(`MCP tool schema ${keyword} must be a non-empty array.`);
		for (const child of children)
			validateSchemaNode(child, root, depth + 1, budget);
	}
}

function isRecord(value: unknown): value is JsonObject {
	return value !== null && typeof value === "object" && !Array.isArray(value);
}

export type IdeaLauncher = {
	command: string;
	argsBeforePath: string[];
	script?: string;
};

export async function findIdeaLauncher(
	env: NodeJS.ProcessEnv = process.env,
	platform: NodeJS.Platform = process.platform,
): Promise<IdeaLauncher | undefined> {
	const pathValue = getEnv(env, "PATH", platform === "win32");
	if (!pathValue) return undefined;
	const host = platform === "win32" ? getEnv(env, "COMSPEC", true) : undefined;
	const names =
		platform === "win32"
			? host
				? ["idea64.exe", "idea.exe", "idea.cmd", "idea.bat"]
				: ["idea64.exe", "idea.exe"]
			: ["idea"];
	const executable = findExecutable(names, { path: pathValue, platform });
	if (!executable) return undefined;
	if (/\.(?:cmd|bat)$/iu.test(executable)) {
		if (!host) return undefined;
		return {
			command: host,
			argsBeforePath: ["/d", "/v:off", "/s", "/c"],
			script: executable,
		};
	}
	return { command: executable, argsBeforePath: [] };
}

function getEnv(
	env: NodeJS.ProcessEnv,
	key: string,
	caseInsensitive: boolean,
): string | undefined {
	if (!caseInsensitive) return env[key];
	const actual = Object.keys(env).find(
		(candidate) => candidate.toLowerCase() === key.toLowerCase(),
	);
	return actual ? env[actual] : undefined;
}

async function launchIdea(projectPath: string): Promise<void> {
	const launcher = await findIdeaLauncher();
	if (!launcher)
		throw new Error(
			"IntelliJ launcher 'idea' is not on PATH. In IntelliJ, use Tools | Create Command-line Launcher.",
		);
	const args = launcher.script
		? [
				...launcher.argsBeforePath,
				`"${quoteCmdPath(launcher.script)} ${quoteCmdPath(projectPath)}"`,
			]
		: [...launcher.argsBeforePath, projectPath];
	await new Promise<void>((resolveLaunch, reject) => {
		const child = spawn(launcher.command, args, {
			detached: true,
			stdio: "ignore",
			windowsHide: true,
			windowsVerbatimArguments: launcher.script !== undefined,
		});
		child.once("error", reject);
		child.once("spawn", () => {
			child.unref();
			resolveLaunch();
		});
	});
}

function quoteCmdPath(value: string): string {
	if (/[%!"\r\n\0]/.test(value))
		throw new Error("IntelliJ launcher path contains unsupported characters.");
	const escaped = value
		.replaceAll("^", "^^")
		.replace(/[&|<>()]/g, (character) => `^${character}`);
	return `"${escaped}"`;
}

function settingsPath(): string {
	return join(getAgentDir(), "settings.json");
}

// Precedence: trusted project, user, none.
const URL_SETTING: SettingDeclaration<string | undefined> = {
	key: "intellij.url",
	parse: (raw) => (typeof raw === "string" ? raw : undefined),
	default: undefined,
};

export async function persistUrl(url: string): Promise<void> {
	const file = settingsPath();
	let settings: JsonObject = {};
	try {
		const parsed = JSON.parse(await readFile(file, "utf8")) as unknown;
		if (!isRecord(parsed))
			throw new Error("Global settings must be an object.");
		settings = parsed;
	} catch (error) {
		if (!(isNodeError(error) && error.code === "ENOENT")) throw error;
	}
	const intellij = isRecord(settings.intellij) ? settings.intellij : {};
	settings.intellij = { ...intellij, url };
	await mkdir(dirname(file), { recursive: true });
	const temporary = `${file}.tmp-${process.pid}-${randomUUID()}`;
	try {
		await writeFile(temporary, `${JSON.stringify(settings, null, 2)}\n`, {
			encoding: "utf8",
			mode: 0o600,
			flag: "wx",
		});
		await rename(temporary, file);
	} finally {
		await rm(temporary, { force: true }).catch(() => {});
	}
}

function isNodeError(error: unknown): error is NodeJS.ErrnoException {
	return error instanceof Error && "code" in error;
}

async function openMcpConnection(
	url: string,
	signal?: AbortSignal,
): Promise<McpConnection> {
	const endpoint = new URL(url);
	await verifyLoopbackResolution(endpoint.hostname);
	const [{ Client }, { StreamableHTTPClientTransport }] = await Promise.all([
		import("@modelcontextprotocol/sdk/client/index.js"),
		import("@modelcontextprotocol/sdk/client/streamableHttp.js"),
	]);
	const client = new Client(
		{ name: "pi-intellij", version: "0.1.0" },
		{ capabilities: {} },
	);
	const transport = new StreamableHTTPClientTransport(endpoint, {
		fetch: async (input, init) => {
			const target = new URL(input);
			if (target.protocol !== "http:" || !isLoopbackHost(target.hostname))
				throw new Error("IntelliJ MCP request escaped the loopback host.");
			const response = await fetch(input, { ...init, redirect: "manual" });
			if (response.status >= 300 && response.status < 400) {
				await response.body?.cancel();
				throw new Error("IntelliJ MCP endpoint redirected the request.");
			}
			return response;
		},
	});
	try {
		await client.connect(transport, { signal, timeout: CONNECT_TIMEOUT_MS });
	} catch (error) {
		await client.close().catch(() => {});
		throw error;
	}
	return {
		serverName: client.getServerVersion()?.name ?? "",
		async listTools(requestSignal) {
			const tools: RemoteTool[] = [];
			let cursor: string | undefined;
			do {
				const page = await client.listTools(cursor ? { cursor } : undefined, {
					signal: requestSignal,
					timeout: CONNECT_TIMEOUT_MS,
				});
				for (const tool of page.tools) {
					tools.push(validateRemoteTool(tool));
					if (tools.length > MAX_CATALOG_TOOLS)
						throw new Error("IntelliJ exposed too many MCP tools.");
				}
				cursor = page.nextCursor;
			} while (cursor);
			return tools;
		},
		async callTool(name, args, requestSignal, onProgress) {
			return (await client.callTool({ name, arguments: args }, undefined, {
				signal: requestSignal,
				timeout: toolCallTimeout(args),
				resetTimeoutOnProgress: true,
				onprogress: onProgress,
			})) as McpResult;
		},
		close: () => client.close(),
		onClose(handler) {
			client.onclose = handler;
		},
	};
}

async function verifyLoopbackResolution(hostname: string): Promise<void> {
	if (hostname !== "localhost") return;
	const addresses = await lookup(hostname, { all: true });
	if (
		addresses.length === 0 ||
		addresses.some(
			({ address }) =>
				address !== "::1" &&
				!address.startsWith("127.") &&
				!address.toLowerCase().startsWith("::ffff:127."),
		)
	)
		throw new Error(
			"localhost does not resolve exclusively to loopback addresses.",
		);
}

function verifyJetBrains(connection: McpConnection): void {
	if (
		!/(jetbrains|intellij|idea|pycharm|webstorm|rider|clion)/i.test(
			connection.serverName,
		)
	)
		throw new EndpointValidationError(
			`Endpoint is not a JetBrains MCP server: ${connection.serverName || "unnamed server"}.`,
		);
}

async function verifyProject(
	connection: McpConnection,
	tools: RemoteTool[],
	projectPath: string,
	signal?: AbortSignal,
): Promise<void> {
	const probe = tools.find(
		(tool) =>
			tool.name === PROJECT_PROBE && acceptsProjectPath(tool.inputSchema),
	);
	if (!probe)
		throw new EndpointValidationError(
			"IntelliJ must expose get_project_modules so Pi can verify the session project.",
		);
	const result = await connection.callTool(probe.name, { projectPath }, signal);
	if (result.isError)
		throw new ProjectUnavailableError(
			resultText(result) || "Project is not open.",
		);
}

type ConnectionResult = {
	connection: McpConnection;
	tools: RemoteTool[];
};

async function connectOnce(
	url: string,
	projectPath: string,
	signal?: AbortSignal,
): Promise<ConnectionResult> {
	const timeout = AbortSignal.timeout(CONNECT_TIMEOUT_MS);
	const boundedSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
	let connection: McpConnection | undefined;
	try {
		connection = await openMcpConnection(url, boundedSignal);
		verifyJetBrains(connection);
		let tools: RemoteTool[];
		try {
			tools = await connection.listTools(boundedSignal);
		} catch (error) {
			throw new EndpointValidationError(
				error instanceof Error ? error.message : String(error),
			);
		}
		await verifyProject(connection, tools, projectPath, boundedSignal);
		return { connection, tools };
	} catch (error) {
		await connection?.close().catch(() => {});
		throw error;
	}
}

function sleep(ms: number, signal?: AbortSignal): Promise<void> {
	return new Promise((resolveSleep, reject) => {
		if (signal?.aborted) return reject(signal.reason);
		const timer = setTimeout(resolveSleep, ms);
		signal?.addEventListener(
			"abort",
			() => {
				clearTimeout(timer);
				reject(signal.reason);
			},
			{ once: true },
		);
	});
}

async function connectWithLaunch(
	url: string,
	projectPath: string,
	signal: AbortSignal,
	allowLaunch: boolean,
): Promise<ConnectionResult> {
	try {
		return await connectOnce(url, projectPath, signal);
	} catch (error) {
		if (signal.aborted) throw signal.reason;
		if (!allowLaunch || error instanceof EndpointValidationError) throw error;
	}

	await launchIdea(projectPath);
	signal.throwIfAborted();
	const deadline = Date.now() + RETRY_WINDOW_MS;
	for (;;) {
		try {
			return await connectOnce(url, projectPath, signal);
		} catch (error) {
			if (signal.aborted) throw signal.reason;
			if (Date.now() >= deadline) throw error;
			await sleep(RETRY_DELAY_MS, signal);
		}
	}
}

function samePath(left: string, right: string): boolean {
	const normalize = (value: string) => {
		const absolute = resolve(value).replace(/[\\/]+$/, "");
		return process.platform === "win32" ? absolute.toLowerCase() : absolute;
	};
	return normalize(left) === normalize(right);
}

function localBase(remoteName: string): string {
	return `intellij_${remoteName.replace(/[^A-Za-z0-9_-]/g, "_")}`;
}

function resultText(result: McpResult): string {
	const pieces: string[] = [];
	for (const item of result.content ?? []) {
		if (isRecord(item) && item.type === "text" && typeof item.text === "string")
			pieces.push(item.text);
	}
	if (pieces.length === 0 && result.structuredContent !== undefined)
		pieces.push(safeJson(result.structuredContent));
	if (pieces.length === 0 && result.toolResult !== undefined)
		pieces.push(safeJson(result.toolResult));
	return boundedText(pieces.join("\n"));
}

function safeJson(value: unknown): string {
	try {
		return JSON.stringify(value);
	} catch {
		return "[unserializable MCP content]";
	}
}

export function toolCallTimeout(args: JsonObject): number {
	const requested = args.timeout;
	const operationTimeout =
		typeof requested === "number" && Number.isFinite(requested) && requested > 0
			? requested
			: DEFAULT_TOOL_TIMEOUT_MS;
	return Math.min(operationTimeout + TOOL_TIMEOUT_GRACE_MS, 2_147_000_000);
}

function boundedText(value: string): string {
	const result = truncateHead(value, {
		maxBytes: DEFAULT_MAX_BYTES,
		maxLines: DEFAULT_MAX_LINES,
	});
	return result.truncated
		? `${result.content}\n[IntelliJ output truncated: ${result.outputBytes}/${result.totalBytes} bytes, ${result.outputLines}/${result.totalLines} lines]`
		: result.content;
}

function mapResult(result: McpResult, toolName: string): BridgeResult {
	const content: BridgeContent[] = [];
	const seenText = new Set<string>();
	const appendText = (text: string) => {
		const parsed = parseJson(text);
		const identity = parsed === undefined ? text : safeJson(parsed);
		if (seenText.has(identity)) return;
		seenText.add(identity);
		content.push({ type: "text", text: boundedText(text) });
	};
	for (const item of result.content ?? []) {
		if (
			isRecord(item) &&
			item.type === "text" &&
			typeof item.text === "string"
		) {
			appendText(item.text);
		} else if (
			isRecord(item) &&
			item.type === "image" &&
			typeof item.data === "string" &&
			typeof item.mimeType === "string"
		) {
			content.push({ type: "image", data: item.data, mimeType: item.mimeType });
		} else {
			appendText(safeJson(item));
		}
	}
	if (result.structuredContent !== undefined)
		appendText(safeJson(result.structuredContent));
	if (result.toolResult !== undefined) appendText(safeJson(result.toolResult));
	if (content.length === 0)
		content.push({ type: "text", text: "(empty result)" });
	return { content, details: { source: "intellij", tool: toolName } };
}

function parseJson(text: string): unknown {
	try {
		return JSON.parse(text);
	} catch {
		return undefined;
	}
}

function mcpPayload(result: McpResult): unknown {
	if (result.structuredContent !== undefined) return result.structuredContent;
	const texts = (result.content ?? []).filter(
		(item): item is JsonObject & { type: "text"; text: string } =>
			isRecord(item) && item.type === "text" && typeof item.text === "string",
	);
	return texts.length === 1 ? parseJson(texts[0].text) : undefined;
}

function bridgePayload(result: BridgeResult): unknown {
	const texts = result.content.filter(
		(item): item is { type: "text"; text: string } => item.type === "text",
	);
	return texts.length === 1 ? parseJson(texts[0].text) : undefined;
}

function semanticFailure(result: McpResult): boolean {
	const payload = mcpPayload(result);
	return (
		isRecord(payload) &&
		(payload.isSuccess === false || payload.success === false)
	);
}

function normalizeDescription(
	tool: RemoteTool,
	projectPathInjected: boolean,
): string {
	const routing = projectPathInjected
		? "Pi pins this call to the connected session project and injects projectPath automatically."
		: "Pi pins this call to the connected IntelliJ session.";
	const fallback = `Call IntelliJ MCP tool ${tool.name}. ${routing}`;
	if (!tool.description?.trim()) return fallback;
	const lines = tool.description.trim().split(/\r?\n/);
	const indents = lines
		.slice(1)
		.filter((line) => line.trim())
		.map((line) => /^\s*/.exec(line)?.[0].length ?? 0);
	const indent = indents.length > 0 ? Math.min(...indents) : 0;
	const description = lines
		.map((line, index) =>
			index === 0
				? line.trimEnd()
				: line.slice(Math.min(indent, line.length)).trimEnd(),
		)
		.join("\n")
		.replace(/\n{3,}/g, "\n\n");
	return `${description}\n\n${routing}`;
}

function setConnectionStatus(
	ctx: ExtensionContext,
	state: "connecting" | "connected" | "failed",
): void {
	const presentation = {
		connecting: ["warning", `${INTELLIJ_ICON} …`],
		connected: ["success", `${INTELLIJ_ICON} ✓`],
		failed: ["error", `${INTELLIJ_ICON} ✗`],
	} as const;
	const [color, text] = presentation[state];
	ctx.ui.setStatus("intellij", ctx.ui.theme.fg(color, text));
}

function visibleArgs(args: JsonObject): JsonObject {
	const visible = { ...args };
	delete visible.projectPath;
	return visible;
}

function short(value: string, limit = 120): string {
	const oneLine = value.replace(/\s+/g, " ").trim();
	return oneLine.length > limit ? `${oneLine.slice(0, limit - 1)}…` : oneLine;
}

function callSummary(toolName: string, args: JsonObject): string {
	const visible = visibleArgs(args);
	if (toolName === "apply_patch" && typeof visible.input === "string") {
		const files =
			visible.input.match(/^\*\*\* (?:Update|Add|Delete) File:/gm)?.length ?? 0;
		const hunks = visible.input.match(/^@@/gm)?.length ?? 0;
		return `${files} file${files === 1 ? "" : "s"}, ${hunks} hunk${hunks === 1 ? "" : "s"}`;
	}
	const path = [
		"filePath",
		"pathInProject",
		"path",
		"file_path",
		"directoryPath",
	]
		.map((key) => visible[key])
		.find((value) => typeof value === "string");
	if (typeof path === "string") {
		if (
			toolName === "rename_refactoring" &&
			typeof visible.symbolName === "string" &&
			typeof visible.newName === "string"
		)
			return `${path} · ${visible.symbolName} → ${visible.newName}`;
		return path;
	}
	if (Array.isArray(visible.filesToRebuild))
		return `${visible.filesToRebuild.length} file${visible.filesToRebuild.length === 1 ? "" : "s"}`;
	for (const key of [
		"searchText",
		"regexPattern",
		"globPattern",
		"nameKeyword",
		"query",
		"queryText",
		"command",
		"configurationName",
		"symbolName",
		"action",
	]) {
		if (typeof visible[key] === "string") return short(String(visible[key]));
	}
	const first = Object.entries(visible).find(
		([key, value]) => key !== "timeout" && value !== undefined,
	);
	return first ? `${first[0]}=${short(safeJson(first[1]), 80)}` : "project";
}

function formatToolCall(
	tool: CatalogTool,
	args: JsonObject,
	theme: Theme,
	expanded: boolean,
): string {
	const title = theme.fg(
		"toolTitle",
		theme.bold(`${INTELLIJ_ICON} ${tool.name.replaceAll("_", " ")}`),
	);
	const summary = callSummary(tool.name, args);
	let text = `${title} ${theme.fg("muted", summary)}`;
	if (!expanded) return text;
	const visible = visibleArgs(args);
	if (tool.name === "apply_patch" && typeof visible.input === "string") {
		const patch = visible.input;
		delete visible.input;
		if (Object.keys(visible).length > 0)
			text += `\n${theme.fg("dim", JSON.stringify(visible, null, 2))}`;
		return `${text}\n${theme.fg("toolOutput", patch)}`;
	}
	if (Object.keys(visible).length > 0)
		text += `\n${theme.fg("dim", JSON.stringify(visible, null, 2))}`;
	return text;
}

function firstText(result: BridgeResult): string {
	return (
		result.content.find(
			(item): item is { type: "text"; text: string } => item.type === "text",
		)?.text ?? ""
	);
}

function issuePresentation(issues: unknown[]): {
	status: "error" | "warning";
	prefix: "✗" | "⚠";
	text: string;
} {
	let errors = 0;
	let warnings = 0;
	for (const issue of issues) {
		if (!isRecord(issue)) continue;
		const severity = String(issue.kind ?? issue.severity ?? "").toUpperCase();
		if (severity.includes("ERROR")) errors++;
		else if (severity.includes("WARN")) warnings++;
	}
	const parts = [
		errors > 0 ? `${errors} error${errors === 1 ? "" : "s"}` : "",
		warnings > 0 ? `${warnings} warning${warnings === 1 ? "" : "s"}` : "",
	].filter(Boolean);
	return {
		status: errors > 0 ? "error" : "warning",
		prefix: errors > 0 ? "✗" : "⚠",
		text:
			parts.length > 0
				? parts.join(", ")
				: `${issues.length} problem${issues.length === 1 ? "" : "s"}`,
	};
}

function resultSummary(
	toolName: string,
	result: BridgeResult,
	isError: boolean,
): { status: "success" | "error" | "warning" | "muted"; text: string } {
	const text = firstText(result);
	if (isError)
		return {
			status: "error",
			text: `✗ ${short(text.split(/\r?\n/, 1)[0] || "failed")}`,
		};
	const payload = bridgePayload(result);
	if (isRecord(payload)) {
		if (payload.isSuccess === false || payload.success === false) {
			const problems = Array.isArray(payload.problems) ? payload.problems : [];
			return {
				status: "error",
				text: `✗ failed${problems.length > 0 ? ` · ${issuePresentation(problems).text}` : ""}`,
			};
		}
		for (const key of ["errors", "problems"]) {
			if (Array.isArray(payload[key])) {
				const issues = payload[key];
				if (issues.length === 0)
					return { status: "success", text: "✓ no problems" };
				const presentation = issuePresentation(issues);
				return {
					status: presentation.status,
					text: `${presentation.prefix} ${presentation.text}`,
				};
			}
		}
		if (Array.isArray(payload.entries))
			return {
				status: "success",
				text: `✓ ${payload.entries.length} match${payload.entries.length === 1 ? "" : "es"}${payload.probablyHasMoreMatchingEntries ? "+" : ""}`,
			};
		for (const [key, value] of Object.entries(payload)) {
			if (Array.isArray(value))
				return {
					status: "success",
					text: `✓ ${value.length} ${key.replaceAll("_", " ")}`,
				};
		}
		if (payload.isSuccess === true || payload.success === true)
			return { status: "success", text: "✓ succeeded" };
	}
	if (
		/(?:read_file|get_file_text_by_path)$/.test(toolName) &&
		text.includes("\n")
	) {
		const lines = text.split(/\r?\n/).length;
		return { status: "success", text: `✓ ${lines} lines` };
	}
	if (/^(?:ok|\[success\])$/i.test(text.trim()))
		return { status: "success", text: "✓ done" };
	if (/^(?:Applied|Created|Updated|Reformatted|Renamed)/i.test(text.trim()))
		return { status: "success", text: `✓ ${short(text, 100)}` };
	return { status: "success", text: "✓ completed" };
}

function formatIssues(payload: JsonObject): string | undefined {
	const issues = Array.isArray(payload.problems)
		? payload.problems
		: Array.isArray(payload.errors)
			? payload.errors
			: undefined;
	if (!issues || issues.length === 0) return undefined;
	return issues
		.map((issue) => {
			if (!isRecord(issue)) return safeJson(issue);
			const severity = String(issue.kind ?? issue.severity ?? "PROBLEM");
			const file = String(issue.file ?? payload.filePath ?? "");
			const line = typeof issue.line === "number" ? `:${issue.line}` : "";
			const column = typeof issue.column === "number" ? `:${issue.column}` : "";
			const location = file ? ` ${file}${line}${column}` : "";
			const message = String(
				issue.description ?? issue.message ?? safeJson(issue),
			);
			return `${severity}${location}\n  ${message}`;
		})
		.join("\n");
}

function formatPayload(payload: unknown): string {
	if (!isRecord(payload)) return JSON.stringify(payload, null, 2);
	const issues = formatIssues(payload);
	if (issues) return issues;
	if (Array.isArray(payload.entries))
		return payload.entries
			.map((entry) => {
				if (!isRecord(entry)) return safeJson(entry);
				const path = String(entry.filePath ?? entry.path ?? "");
				const line =
					typeof entry.lineNumber === "number" ? `:${entry.lineNumber}` : "";
				const body = String(entry.lineText ?? entry.text ?? "");
				return `${path}${line}${body ? `\n  ${body}` : ""}`;
			})
			.join("\n");
	for (const [key, value] of Object.entries(payload)) {
		if (
			Array.isArray(value) &&
			value.every((entry) => isRecord(entry) && typeof entry.name === "string")
		)
			return `${key}:\n${value
				.map(
					(entry) =>
						`${String((entry as JsonObject).name)}${typeof (entry as JsonObject).type === "string" ? ` · ${String((entry as JsonObject).type)}` : ""}`,
				)
				.join("\n")}`;
	}
	return JSON.stringify(payload, null, 2);
}

function formatExpandedResult(result: BridgeResult, theme: Theme): string {
	return result.content
		.map((item) => {
			if (item.type === "image")
				return theme.fg("dim", `[image ${item.mimeType}]`);
			const payload = parseJson(item.text);
			return theme.fg(
				"toolOutput",
				payload === undefined ? item.text : formatPayload(payload),
			);
		})
		.join("\n");
}

function failureHint(message: string): string {
	if (/timed? out|timeout/i.test(message))
		return "Increase the tool timeout or narrow the operation, then retry.";
	if (/doesn.t exist|can.t be opened|file not found/i.test(message))
		return "Verify the project-relative path and whether the file still exists, then retry.";
	if (/disconnected|connection|fetch failed|socket/i.test(message))
		return "Run /intellij status and reconnect before retrying.";
	return "Correct the listed issue or arguments, then retry the IntelliJ operation.";
}

function toolFailure(
	tool: CatalogTool,
	args: JsonObject,
	projectPath: string,
	message: string,
): Error {
	return new Error(
		`IntelliJ ${tool.name} failed for ${callSummary(tool.name, args)} in ${projectPath}:\n${boundedText(message)}\nNext: ${failureHint(message)}`,
	);
}

class Runtime implements IntellijRuntime {
	private state: IntellijState = "disconnected";
	private endpoint: string | undefined;
	private projectPath: string | undefined;
	private connection: McpConnection | undefined;
	private catalog: CatalogTool[] = [];
	private liveCatalog: CatalogTool[] = [];
	private readonly ownedNames = new Set<string>();
	private readonly localByRemote = new Map<string, string>();
	private generation = 0;
	private pendingAbort: AbortController | undefined;

	private readonly pi: ExtensionAPI;

	constructor(pi: ExtensionAPI) {
		this.pi = pi;
	}

	async run(
		command: IntellijCommand,
		argument: string,
		ctx: ExtensionCommandContext,
	): Promise<void> {
		switch (command) {
			case "connect":
				await this.connect(argument, ctx, true);
				return;
			case "disconnect":
				await this.disconnect();
				ctx.ui.setStatus("intellij", undefined);
				ctx.ui.notify("IntelliJ MCP disconnected.", "info");
				return;
			case "reconnect":
				await this.reconnect(ctx);
				return;
			case "status":
				this.showStatus(ctx);
				return;
			case "tools":
				this.showTools(ctx);
		}
	}

	async shutdown(): Promise<void> {
		await this.disconnect();
	}

	private async connect(
		argument: string,
		ctx: ExtensionCommandContext,
		allowLaunch: boolean,
	): Promise<void> {
		await this.disconnect();
		ctx.ui.setStatus("intellij", undefined);
		let url!: string;
		let rawUrl = argument;
		const fromSavedUrl = !argument;
		let fromProject = false;
		try {
			if (!rawUrl) {
				const saved = resolveSetting(this.pi, ctx, URL_SETTING);
				if (!saved.ok) {
					dbg?.("connection.result", { status: "failed" });
					ctx.ui.notify(`${saved.error}. ${RECOVERY}`, "warning");
					return;
				}
				if (!saved.value) {
					dbg?.("connection.result", { status: "no_url" });
					ctx.ui.notify(`No saved IntelliJ MCP URL. ${RECOVERY}`, "warning");
					return;
				}
				rawUrl = saved.value;
				fromProject = saved.source === "project";
			}
			url = normalizeIntellijUrl(rawUrl);
		} catch (error) {
			dbg?.("connection.result", { status: "failed" });
			this.fail(ctx, error, rawUrl || undefined, fromSavedUrl);
			return;
		}

		const generation = ++this.generation;
		const abort = new AbortController();
		this.pendingAbort = abort;
		const signal = ctx.signal
			? AbortSignal.any([ctx.signal, abort.signal])
			: abort.signal;
		this.state = "connecting";
		this.endpoint = url;
		this.projectPath = ctx.cwd;
		setConnectionStatus(ctx, "connecting");

		let attemptedConnection: McpConnection | undefined;
		try {
			const connected = await connectWithLaunch(
				url,
				ctx.cwd,
				signal,
				allowLaunch,
			);
			attemptedConnection = connected.connection;
			if (generation !== this.generation) {
				dbg?.("connection.result", { status: "stale" });
				await attemptedConnection.close().catch(() => {});
				return;
			}
			await this.activate(
				connected.connection,
				connected.tools,
				ctx,
				generation,
			);
			attemptedConnection = undefined;
			// Project URLs stay project-scoped; only user URLs are machine-wide.
			if (!fromProject) await persistUrl(url);
			if (
				generation !== this.generation ||
				this.connection !== connected.connection
			)
				return;
			this.state = "connected";
			setConnectionStatus(ctx, "connected");
			ctx.ui.notify(
				`IntelliJ MCP connected: ${this.catalog.length} tools for ${ctx.cwd}`,
				"info",
			);
			dbg?.("connection.result", { status: "connected" });
		} catch (error) {
			await attemptedConnection?.close().catch(() => {});
			if (generation !== this.generation) {
				dbg?.("connection.result", { status: "cancelled" });
				return;
			}
			dbg?.("connection.result", {
				status: signal.aborted ? "cancelled" : "failed",
			});
			await this.closeConnection();
			this.deactivateTools();
			this.fail(ctx, error, url, fromSavedUrl);
		} finally {
			if (generation === this.generation) this.pendingAbort = undefined;
		}
	}

	private async reconnect(ctx: ExtensionCommandContext): Promise<void> {
		await this.connect("", ctx, false);
	}

	private async activate(
		connection: McpConnection,
		tools: RemoteTool[],
		ctx: ExtensionCommandContext,
		generation: number,
	): Promise<void> {
		const names = new Set(this.pi.getAllTools().map((tool) => tool.name));
		const seenRemote = new Set<string>();
		const catalog: CatalogTool[] = [];
		for (const tool of [...tools].sort((a, b) =>
			a.name.localeCompare(b.name),
		)) {
			if (seenRemote.has(tool.name))
				throw new Error(`IntelliJ advertised duplicate tool ${tool.name}.`);
			seenRemote.add(tool.name);
			let localName = this.localByRemote.get(tool.name);
			if (!localName) {
				const base = localBase(tool.name);
				localName = base;
				for (let suffix = 2; names.has(localName); suffix++)
					localName = `${base}_${suffix}`;
				this.localByRemote.set(tool.name, localName);
			}
			names.add(localName);
			this.ownedNames.add(localName);
			const catalogTool: CatalogTool = {
				...tool,
				localName,
				acceptsProjectPath: acceptsProjectPath(tool.inputSchema),
				agentSchema: schemaWithoutInjectedProjectPath(tool.inputSchema),
			};
			catalog.push(catalogTool);
			this.registerAdapter(catalogTool);
		}
		this.connection = connection;
		this.catalog = catalog;
		this.liveCatalog = catalog;
		this.projectPath = ctx.cwd;
		connection.onClose(() => {
			if (generation !== this.generation || this.connection !== connection)
				return;
			this.connection = undefined;
			this.liveCatalog = [];
			this.state = "failed";
			this.deactivateTools();
			setConnectionStatus(ctx, "failed");
		});
		const active = this.pi
			.getActiveTools()
			.filter((name) => !this.ownedNames.has(name));
		this.pi.setActiveTools([
			...active,
			...catalog.map((tool) => tool.localName),
		]);
	}

	private registerAdapter(tool: CatalogTool): void {
		const runtime = this;
		this.pi.registerTool({
			name: tool.localName,
			label: `${INTELLIJ_ICON} ${tool.name.replaceAll("_", " ")}`,
			description: normalizeDescription(tool, tool.acceptsProjectPath),
			promptGuidelines: tool.name === PROJECT_PROBE ? TOOL_GUIDANCE : undefined,
			parameters: Type.Unsafe(tool.agentSchema) as TSchema,
			renderCall(args, theme, context) {
				const component =
					(context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
				component.setText(
					formatToolCall(tool, args as JsonObject, theme, context.expanded),
				);
				return component;
			},
			renderResult(result, { expanded, isPartial }, theme, context) {
				const component =
					(context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
				const bridgeResult = result as BridgeResult;
				if (isPartial) {
					const progress = firstText(bridgeResult).replace(
						/^IntelliJ progress:\s*/,
						"",
					);
					let text = theme.fg(
						"warning",
						expanded ? `… ${progress || "working"}` : "… working",
					);
					if (!expanded && progress)
						text += ` ${theme.fg("dim", `(${keyHint("app.tools.expand", "to expand")})`)}`;
					component.setText(text);
					return component;
				}
				const summary = resultSummary(tool.name, bridgeResult, context.isError);
				let text = theme.fg(summary.status, summary.text);
				if (expanded) {
					const detail = formatExpandedResult(bridgeResult, theme);
					if (detail) text += `\n${detail}`;
				} else {
					text += ` ${theme.fg("dim", `(${keyHint("app.tools.expand", "to expand")})`)}`;
				}
				component.setText(text);
				return component;
			},
			async execute(_id, params, signal, onUpdate, ctx) {
				return await runtime.callRemote(
					tool,
					params as JsonObject,
					signal,
					onUpdate,
					ctx,
				);
			},
		});
	}

	private async callRemote(
		tool: CatalogTool,
		params: JsonObject,
		signal: AbortSignal | undefined,
		onUpdate:
			| ((result: {
					content: Array<{ type: "text"; text: string }>;
					details: { source: "intellij"; tool: string };
			  }) => void)
			| undefined,
		ctx: ExtensionContext,
	) {
		const end = span?.("tool.call");
		const live = this.liveCatalog.find(
			(candidate) => candidate.localName === tool.localName,
		);
		if (
			this.state !== "connected" ||
			!this.connection ||
			!live ||
			live.name !== tool.name
		) {
			end?.("error", { type: "unavailable" });
			throw new Error("IntelliJ MCP tool is disconnected or stale.");
		}
		if (!this.projectPath || !samePath(ctx.cwd, this.projectPath)) {
			end?.("error", { type: "routing" });
			throw new Error("IntelliJ MCP tool is routed outside this session cwd.");
		}
		const args = { ...params };
		if (tool.acceptsProjectPath) args.projectPath = ctx.cwd;
		else delete args.projectPath;
		let result: McpResult;
		try {
			result = await this.connection.callTool(
				tool.name,
				args,
				signal,
				(progress) =>
					onUpdate?.({
						content: [
							{
								type: "text",
								text: boundedText(`IntelliJ progress: ${safeJson(progress)}`),
							},
						],
						details: { source: "intellij", tool: tool.name },
					}),
			);
		} catch (error) {
			end?.("error", { type: "remote" });
			throw toolFailure(
				tool,
				args,
				ctx.cwd,
				error instanceof Error ? error.message : String(error),
			);
		}
		if (result.isError || semanticFailure(result)) {
			end?.("error", { type: "remote" });
			throw toolFailure(
				tool,
				args,
				ctx.cwd,
				resultText(result) || "The IntelliJ operation failed without details.",
			);
		}
		end?.("finish", { status: "ok" });
		return mapResult(result, tool.name);
	}

	private async disconnect(): Promise<void> {
		this.generation++;
		this.pendingAbort?.abort(new Error("IntelliJ MCP operation cancelled."));
		this.pendingAbort = undefined;
		await this.closeConnection();
		this.deactivateTools();
		this.liveCatalog = [];
		this.state = "disconnected";
		this.projectPath = undefined;
	}

	private async closeConnection(): Promise<void> {
		const connection = this.connection;
		this.connection = undefined;
		if (connection) await connection.close().catch(() => {});
	}

	private deactivateTools(): void {
		this.pi.setActiveTools(
			this.pi.getActiveTools().filter((name) => !this.ownedNames.has(name)),
		);
	}

	private fail(
		ctx: ExtensionContext,
		error: unknown,
		endpoint?: string,
		staleSavedUrl = false,
	): void {
		this.state = "failed";
		if (endpoint) this.endpoint = endpoint;
		setConnectionStatus(ctx, "failed");
		const message = error instanceof Error ? error.message : String(error);
		ctx.ui.notify(
			`IntelliJ MCP failed${endpoint ? ` at ${endpoint}` : ""}: ${message}${
				staleSavedUrl ? " The saved URL is stale." : ""
			} ${RECOVERY}`,
			"error",
		);
	}

	private showStatus(ctx: ExtensionContext): void {
		let endpoint = this.endpoint;
		let settingError: string | undefined;
		if (!endpoint) {
			const saved = resolveSetting(this.pi, ctx, URL_SETTING);
			if (saved.ok) endpoint = saved.value;
			else settingError = saved.error;
		}
		ctx.ui.notify(
			[
				`IntelliJ MCP: ${this.state}`,
				settingError
					? `endpoint: unavailable (${settingError})`
					: `endpoint: ${endpoint ?? "none"}`,
				`project: ${this.projectPath ?? ctx.cwd}`,
				`active tools: ${
					this.pi.getActiveTools().filter((name) => this.ownedNames.has(name))
						.length
				}`,
			].join("\n"),
			settingError ? "warning" : "info",
		);
	}

	private showTools(ctx: ExtensionContext): void {
		const active = new Set(this.pi.getActiveTools());
		const lines = this.catalog.map(
			(tool) =>
				`${active.has(tool.localName) && this.state === "connected" ? "live" : "stale"} ${tool.localName} ← ${tool.name}`,
		);
		ctx.ui.notify(
			lines.length > 0
				? lines.join("\n")
				: "No IntelliJ MCP catalog fetched in this session.",
			"info",
		);
	}
}

export function createIntellijRuntime(pi: ExtensionAPI): IntellijRuntime {
	return new Runtime(pi);
}

export const __test = {
	acceptsProjectPath,
	localBase,
	mapResult,
	normalizeIntellijUrl,
	validateRemoteTool,
};