// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
// SPDX-FileCopyrightText: Petr Baudis <pasky@ucw.cz>
//
// SPDX-License-Identifier: MIT

import { complete, type Message, type Tool } from "@mariozechner/pi-ai";
import type {
	ExtensionAPI,
	ExtensionCommandContext,
	ExtensionContext,
	SessionEntry,
} from "@mariozechner/pi-coding-agent";
import { BorderedLoader, SessionManager, convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent";
import { Key, matchesKey } from "@mariozechner/pi-tui";
import { Type } from "@sinclair/typebox";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";

const STATUS_KEY = "handoff";
const COUNTDOWN_SECONDS = 10;

/**
 * Model used for handoff extraction calls. Set PI_HANDOFF_MODEL env var
 * as "provider/modelId" (e.g. "anthropic/claude-haiku-4-5") to use a
 * different model for extraction than the session's current model.
 */
const HANDOFF_MODEL_OVERRIDE = process.env.PI_HANDOFF_MODEL;

const SYSTEM_PROMPT = `You're helping transfer context between coding sessions. The next session starts fresh with no memory of this conversation, so extract what matters.

Consider these questions:
- What was just done or implemented?
- What decisions were made and why?
- What technical details were discovered (APIs, methods, patterns)?
- What constraints, limitations, or caveats were found?
- What patterns or approaches are being followed?
- What is still unfinished or unresolved?
- What open questions or risks are worth flagging?

Rules:
- Be concrete: prefer file paths, specific decisions, and actual commands over vague summaries.
- Only include files from the provided candidate list.
- Only include skills from the provided loaded skills list.
- If something wasn't explicitly discussed, don't include it.`;

const QUERY_SYSTEM_PROMPT = `You answer questions about a prior pi session.

Rules:
- Use only facts from the provided conversation.
- Prefer concrete outputs: file paths, decisions, TODOs, errors.
- If not present, say explicitly: "Not found in provided session.".
- Keep answer concise.`;

/**
 * Tool definition passed to the extraction LLM call. Models produce
 * structured tool-call arguments far more reliably than free-form JSON
 * in a text response, so we use a single tool instead of responseFormat.
 */
const HANDOFF_EXTRACTION_TOOL: Tool = {
	name: "extract_handoff_context",
	description: "Extract handoff context from the conversation for a new session.",
	parameters: Type.Object({
		relevantFiles: Type.Array(Type.String(), {
			description: "File paths relevant to continuing this work, chosen from the provided candidate list",
		}),
		skillsInUse: Type.Array(Type.String(), {
			description: "Skills relevant to the goal, chosen from the provided list of loaded skills",
		}),
		context: Type.String({
			description:
				"Key context: what was done, decisions made, technical details discovered, constraints, and patterns being followed",
		}),
		openItems: Type.Array(Type.String(), {
			description: "Unfinished work, open questions, known risks, and things to watch out for",
		}),
	}),
};

type PendingAutoSubmit = {
	ctx: ExtensionContext;
	sessionFile: string | undefined;
	interval: ReturnType<typeof setInterval>;
	unsubscribeInput: () => void;
};

type PendingHandoff = {
	prompt: string;
	parentSession: string | undefined;
	newModel: string | undefined;
};

function isEditableInput(data: string): boolean {
	if (!data) return false;
	if (data.length === 1) {
		const code = data.charCodeAt(0);
		if (code >= 32 && code !== 127) return true;
		if (code === 8 || code === 13) return true;
	}

	if (data === "\n" || data === "\r") return true;
	if (data === "\x7f") return true;

	if (data.length > 1 && !data.startsWith("\x1b")) return true;

	return false;
}

function statusLine(ctx: ExtensionContext, seconds: number): string {
	const accent = ctx.ui.theme.fg("accent", `handoff auto-submit in ${seconds}s`);
	const hint = ctx.ui.theme.fg("dim", "(type to edit, Esc to cancel)");
	return `${accent} ${hint}`;
}

function getSessionsRoot(sessionFile: string | undefined): string | undefined {
	if (!sessionFile) return undefined;
	const normalized = sessionFile.replace(/\\/g, "/");
	const marker = "/sessions/";
	const idx = normalized.indexOf(marker);
	if (idx === -1) {
		return path.dirname(path.resolve(sessionFile));
	}
	return normalized.slice(0, idx + marker.length - 1);
}

function getFallbackSessionsRoot(): string | undefined {
	const configuredDir = process.env.PI_CODING_AGENT_DIR;
	const candidate = configuredDir
		? path.resolve(configuredDir, "sessions")
		: path.resolve(os.homedir(), ".pi", "agent", "sessions");
	return fs.existsSync(candidate) ? candidate : undefined;
}

function normalizeSessionPath(sessionPath: string, sessionsRoot: string | undefined): string {
	if (path.isAbsolute(sessionPath)) return path.resolve(sessionPath);
	if (sessionsRoot) return path.resolve(sessionsRoot, sessionPath);
	return path.resolve(sessionPath);
}

function sessionPathAllowed(candidate: string, sessionsRoot: string | undefined): boolean {
	if (!sessionsRoot) return true;
	const root = path.resolve(sessionsRoot);
	const resolved = path.resolve(candidate);
	return resolved === root || resolved.startsWith(`${root}${path.sep}`);
}

/**
 * Build a candidate file set from two sources:
 *   1. Primary: actual tool calls (read, write, edit, create) in the session
 *   2. Secondary: file-like patterns in the conversation text (catches files
 *      that were discussed but never opened)
 */
function extractCandidateFiles(entries: SessionEntry[], conversationText: string): Set<string> {
	const files = new Set<string>();
	const fileToolNames = new Set(["read", "write", "edit", "create"]);

	// Primary: files from actual tool calls
	for (const entry of entries) {
		if (entry.type !== "message") continue;
		const msg = entry.message;
		if (msg.role !== "assistant") continue;

		for (const block of msg.content) {
			if (typeof block !== "object" || block === null || block.type !== "toolCall") continue;
			if (!fileToolNames.has(block.name)) continue;

			const args = block.arguments as Record<string, unknown>;
			const filePath =
				typeof args.path === "string" ? args.path : typeof args.file === "string" ? args.file : undefined;
			if (!filePath) continue;
			if (filePath.endsWith("/SKILL.md")) continue;

			files.add(filePath);
		}
	}

	// Secondary: file-like patterns from conversation text
	const filePattern = /(?:^|\s)([a-zA-Z0-9._\-/]+\.[a-zA-Z0-9]+)(?:\s|$|[,;:\)])/gm;
	let match;
	while ((match = filePattern.exec(conversationText)) !== null) {
		const candidate = match[1];
		if (candidate && !candidate.startsWith(".") && candidate.length > 2) {
			files.add(candidate);
		}
	}

	return files;
}

/**
 * Extract skill names that were actually loaded during the conversation.
 * Looks for read() tool calls targeting SKILL.md files and derives the
 * skill name from the parent directory (the convention for pi skills).
 */
function extractLoadedSkills(entries: SessionEntry[]): string[] {
	const skills = new Set<string>();
	for (const entry of entries) {
		if (entry.type !== "message") continue;
		const msg = entry.message;
		if (msg.role !== "assistant") continue;

		for (const block of msg.content) {
			if (typeof block !== "object" || block === null || block.type !== "toolCall") continue;

			// read() calls where the path ends in SKILL.md
			if (block.name !== "read") continue;
			const args = block.arguments as Record<string, unknown>;
			const filePath = typeof args.path === "string" ? args.path : undefined;
			if (!filePath?.endsWith("/SKILL.md")) continue;

			// Skill name is the parent directory name:
			//   .../skills/backing-up-with-keld/SKILL.md → backing-up-with-keld
			const parent = path.basename(path.dirname(filePath));
			if (parent && parent !== "skills") {
				skills.add(parent);
			}
		}
	}
	return [...skills].sort();
}

/**
 * Resolve the model to use for handoff extraction calls. Uses the
 * PI_HANDOFF_MODEL env var if set, otherwise falls back to the session model.
 */
function resolveExtractionModel(ctx: {
	model: ExtensionContext["model"];
	modelRegistry: ExtensionContext["modelRegistry"];
}): ExtensionContext["model"] {
	if (!HANDOFF_MODEL_OVERRIDE) return ctx.model;
	const slashIdx = HANDOFF_MODEL_OVERRIDE.indexOf("/");
	if (slashIdx <= 0) return ctx.model;
	const provider = HANDOFF_MODEL_OVERRIDE.slice(0, slashIdx);
	const modelId = HANDOFF_MODEL_OVERRIDE.slice(slashIdx + 1);
	return ctx.modelRegistry.find(provider, modelId) ?? ctx.model;
}

type HandoffExtraction = {
	files: string[];
	skills: string[];
	context: string;
	openItems: string[];
};

/**
 * Run the extraction LLM call and return structured context, or null if
 * aborted or the model didn't produce a valid tool call.
 */
async function extractHandoffContext(
	model: NonNullable<ExtensionContext["model"]>,
	apiKey: string,
	headers: Record<string, string> | undefined,
	conversationText: string,
	goal: string,
	candidateFiles: string[],
	loadedSkills: string[],
	signal?: AbortSignal,
): Promise<HandoffExtraction | null> {
	const filesContext =
		candidateFiles.length > 0
			? `\n\n## Candidate Files\n\nThese files were touched or mentioned during the session. Return only the ones relevant to the goal.\n\n${candidateFiles.map((f) => `- ${f}`).join("\n")}`
			: "";

	const skillsContext =
		loadedSkills.length > 0
			? `\n\n## Skills Loaded During This Session\n\n${loadedSkills.map((s) => `- ${s}`).join("\n")}\n\nReturn only the skills from this list that are relevant to the goal.`
			: "";

	const userMessage: Message = {
		role: "user",
		content: [
			{
				type: "text",
				text: `## Conversation\n\n${conversationText}\n\n## Goal for Next Session\n\n${goal}${filesContext}${skillsContext}`,
			},
		],
		timestamp: Date.now(),
	};

	const response = await complete(
		model,
		{
			systemPrompt: SYSTEM_PROMPT,
			messages: [userMessage],
			tools: [HANDOFF_EXTRACTION_TOOL],
		},
		{ apiKey, headers, signal },
	);

	if (response.stopReason === "aborted") return null;

	const toolCall = response.content.find((c) => c.type === "toolCall" && c.name === "extract_handoff_context");

	if (!toolCall || toolCall.type !== "toolCall") {
		console.error("Model did not call extract_handoff_context:", response.content);
		return null;
	}

	const args = toolCall.arguments as Record<string, unknown>;

	if (
		!Array.isArray(args.relevantFiles) ||
		!Array.isArray(args.skillsInUse) ||
		typeof args.context !== "string" ||
		!Array.isArray(args.openItems)
	) {
		console.error("Unexpected tool call arguments shape:", args);
		return null;
	}

	const candidateSet = new Set(candidateFiles);
	const loadedSkillSet = new Set(loadedSkills);

	return {
		files: (args.relevantFiles as string[]).filter((f) => typeof f === "string" && candidateSet.has(f)),
		skills: (args.skillsInUse as string[]).filter((s) => typeof s === "string" && loadedSkillSet.has(s)),
		context: args.context as string,
		openItems: (args.openItems as string[]).filter((item) => typeof item === "string"),
	};
}

/**
 * Assemble the handoff draft. The user's goal goes last so it has the
 * most weight in the new session (recency bias).
 */
function assembleHandoffDraft(result: HandoffExtraction, goal: string, parentSessionFile: string | undefined): string {
	const parentBlock = parentSessionFile
		? `**Parent session:** \`${parentSessionFile}\`\n\nUse the \`session_query\` tool with this path if you need details from the prior thread.\n\n`
		: "";

	const filesSection = result.files.length > 0 ? `## Files\n\n${result.files.map((f) => `- ${f}`).join("\n")}\n\n` : "";

	const skillsSection =
		result.skills.length > 0 ? `## Skills in Use\n\n${result.skills.map((s) => `- ${s}`).join("\n")}\n\n` : "";

	const contextSection = `## Context\n\n${result.context}\n\n`;

	const openItemsSection =
		result.openItems.length > 0 ? `## Open Items\n\n${result.openItems.map((item) => `- ${item}`).join("\n")}\n\n` : "";

	return `${parentBlock}${filesSection}${skillsSection}${contextSection}${openItemsSection}${goal}`.trim();
}

export default function (pi: ExtensionAPI) {
	let pending: PendingAutoSubmit | null = null;
	let pendingHandoff: PendingHandoff | null = null;
	let handoffTimestamp: number | null = null;

	const clearPending = (ctx?: ExtensionContext, notify?: string) => {
		if (!pending) return;

		clearInterval(pending.interval);
		pending.unsubscribeInput();
		pending.ctx.ui.setStatus(STATUS_KEY, undefined);

		const local = pending;
		pending = null;

		if (notify && ctx) {
			ctx.ui.notify(notify, "info");
		} else if (notify) {
			local.ctx.ui.notify(notify, "info");
		}
	};

	const autoSubmitDraft = () => {
		if (!pending) return;

		const active = pending;
		const currentSession = active.ctx.sessionManager.getSessionFile();
		if (active.sessionFile && currentSession !== active.sessionFile) {
			clearPending(undefined);
			return;
		}

		const draft = active.ctx.ui.getEditorText().trim();
		clearPending(undefined);

		if (!draft) {
			active.ctx.ui.notify("Handoff draft is empty", "warning");
			return;
		}

		active.ctx.ui.setEditorText("");

		try {
			if (active.ctx.isIdle()) {
				pi.sendUserMessage(draft);
			} else {
				pi.sendUserMessage(draft, { deliverAs: "followUp" });
			}
		} catch {
			pi.sendUserMessage(draft);
		}
	};

	const startCountdown = (ctx: ExtensionContext) => {
		clearPending(ctx);

		let seconds = COUNTDOWN_SECONDS;
		ctx.ui.setStatus(STATUS_KEY, statusLine(ctx, seconds));

		const unsubscribeInput = ctx.ui.onTerminalInput((data) => {
			if (matchesKey(data, Key.escape)) {
				clearPending(ctx, "Handoff auto-submit cancelled");
				return { consume: true };
			}

			if (isEditableInput(data)) {
				clearPending(ctx, "Handoff auto-submit stopped (editing)");
			}

			return undefined;
		});

		const interval = setInterval(() => {
			if (!pending) return;

			seconds -= 1;
			if (seconds <= 0) {
				autoSubmitDraft();
				return;
			}

			ctx.ui.setStatus(STATUS_KEY, statusLine(ctx, seconds));
		}, 1000);

		pending = {
			ctx,
			sessionFile: ctx.sessionManager.getSessionFile(),
			interval,
			unsubscribeInput,
		};
	};

	pi.on("session_before_switch", (_event, ctx) => {
		if (pending) clearPending(ctx);
	});

	pi.on("session_switch", (_event, ctx) => {
		if (pending) clearPending(ctx);
		// A proper session switch (e.g. /new) fully resets agent state,
		// so clear the context filter to avoid hiding new messages.
		handoffTimestamp = null;
	});

	pi.on("session_before_fork", (_event, ctx) => {
		if (pending) clearPending(ctx);
	});

	pi.on("session_fork", (_event, ctx) => {
		if (pending) clearPending(ctx);
	});

	pi.on("session_before_tree", (_event, ctx) => {
		if (pending) clearPending(ctx);
	});

	pi.on("session_tree", (_event, ctx) => {
		if (pending) clearPending(ctx);
	});

	pi.on("session_shutdown", (_event, ctx) => {
		if (pending) clearPending(ctx);
	});

	// --- Tool-path handoff coordination ---
	//
	// The /handoff command has ExtensionCommandContext with ctx.newSession()
	// which does a full agent reset. The handoff tool only gets
	// ExtensionContext, which lacks newSession(). So the tool stores a
	// pending handoff and these handlers complete it:
	//
	// 1. agent_end: after the agent loop finishes, switch sessions and
	//    send the handoff prompt in the next macrotask
	// 2. context: filter pre-handoff messages since the low-level session
	//    switch doesn't clear agent.state.messages
	// 3. session_switch (above): clears the filter on proper switches

	pi.on("agent_end", (_event, ctx) => {
		if (!pendingHandoff) return;

		const { prompt, parentSession, newModel } = pendingHandoff;
		pendingHandoff = null;

		handoffTimestamp = Date.now();
		(ctx.sessionManager as any).newSession({ parentSession });

		setTimeout(async () => {
			if (newModel) {
				const slashIdx = newModel.indexOf("/");
				if (slashIdx > 0) {
					const provider = newModel.slice(0, slashIdx);
					const modelId = newModel.slice(slashIdx + 1);
					const model = ctx.modelRegistry.find(provider, modelId);
					if (model) await pi.setModel(model);
				}
			}
			pi.sendUserMessage(prompt);
		}, 0);
	});

	pi.on("context", (event) => {
		if (handoffTimestamp === null) return;

		const newMessages = (event as any).messages.filter((m: any) => m.timestamp >= handoffTimestamp);
		if (newMessages.length > 0) {
			return { messages: newMessages };
		}
	});

	pi.registerTool({
		name: "session_query",
		label: (params) => `Session Query: ${params.question}`,
		description:
			"Query a prior pi session file. Use when handoff prompt references a parent session and you need details.",
		parameters: Type.Object({
			sessionPath: Type.String({
				description:
					"Session .jsonl path. Absolute path, or relative to sessions root (e.g. 2026-02-16/foo/session.jsonl)",
			}),
			question: Type.String({ description: "Question about that session" }),
		}),
		async execute(_toolCallId, params, signal, onUpdate, ctx) {
			const currentSessionFile = ctx.sessionManager.getSessionFile();
			const sessionsRoot = getSessionsRoot(currentSessionFile) ?? getFallbackSessionsRoot();
			const resolvedPath = normalizeSessionPath(params.sessionPath, sessionsRoot);

			const error = (text: string) => ({
				content: [{ type: "text" as const, text }],
				details: { error: true },
			});

			const cancelled = () => ({
				content: [{ type: "text" as const, text: "Session query cancelled." }],
				details: { cancelled: true },
			});

			if (signal.aborted) {
				return cancelled();
			}

			if (!resolvedPath.endsWith(".jsonl")) {
				return error(`Invalid session path (expected .jsonl): ${params.sessionPath}`);
			}

			if (!sessionPathAllowed(resolvedPath, sessionsRoot)) {
				return error(`Session path outside allowed sessions directory: ${params.sessionPath}`);
			}

			if (!fs.existsSync(resolvedPath)) {
				return error(`Session file not found: ${resolvedPath}`);
			}

			let fileStats: fs.Stats;
			try {
				fileStats = fs.statSync(resolvedPath);
			} catch (err) {
				return error(`Failed to stat session file: ${String(err)}`);
			}

			if (!fileStats.isFile()) {
				return error(`Session path is not a file: ${resolvedPath}`);
			}

			onUpdate?.({
				content: [{ type: "text", text: `Querying: ${resolvedPath}` }],
				details: { status: "loading", sessionPath: resolvedPath },
			});

			let sessionManager: SessionManager;
			try {
				sessionManager = SessionManager.open(resolvedPath);
			} catch (err) {
				return error(`Failed to open session: ${String(err)}`);
			}

			const branch = sessionManager.getBranch();
			const messages = branch
				.filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message")
				.map((entry) => entry.message);

			if (messages.length === 0) {
				return {
					content: [{ type: "text" as const, text: "Session has no messages." }],
					details: { empty: true, sessionPath: resolvedPath },
				};
			}

			if (!ctx.model) {
				return error("No model selected for session query.");
			}

			const conversationText = serializeConversation(convertToLlm(messages));
			try {
				const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model);
				if (!auth.ok) {
					return error(`Failed to get API key: ${auth.error}`);
				}
				const userMessage: Message = {
					role: "user",
					content: [
						{
							type: "text",
							text: `## Session\n\n${conversationText}\n\n## Question\n\n${params.question}`,
						},
					],
					timestamp: Date.now(),
				};

				const response = await complete(
					ctx.model,
					{ systemPrompt: QUERY_SYSTEM_PROMPT, messages: [userMessage] },
					{ apiKey: auth.apiKey, headers: auth.headers, signal },
				);

				if (response.stopReason === "aborted") {
					return cancelled();
				}

				const answer = response.content
					.filter((c): c is { type: "text"; text: string } => c.type === "text")
					.map((c) => c.text)
					.join("\n")
					.trim();

				return {
					content: [{ type: "text" as const, text: answer || "No answer generated." }],
					details: {
						sessionPath: resolvedPath,
						question: params.question,
						messageCount: messages.length,
					},
				};
			} catch (err) {
				if (signal.aborted) {
					return cancelled();
				}
				if (err instanceof Error && err.name === "AbortError") {
					return cancelled();
				}
				return error(`Session query failed: ${String(err)}`);
			}
		},
	});

	pi.registerTool({
		name: "handoff",
		label: "Handoff",
		description:
			"Transfer context to a new session. Use when the user explicitly asks for a handoff or when the context window is nearly full. Provide a goal describing what the new session should focus on.",
		parameters: Type.Object({
			goal: Type.String({
				description: "The goal/task for the new session",
			}),
			model: Type.Optional(
				Type.String({
					description: "Model for the new session as provider/modelId (e.g. 'anthropic/claude-haiku-4-5')",
				}),
			),
		}),
		async execute(_toolCallId, params, signal, _onUpdate, ctx) {
			if (!ctx.model) {
				return {
					content: [{ type: "text" as const, text: "No model selected." }],
				};
			}

			const branch = ctx.sessionManager.getBranch();
			const messages = branch
				.filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message")
				.map((entry) => entry.message);

			if (messages.length === 0) {
				return {
					content: [
						{
							type: "text" as const,
							text: "No conversation to hand off.",
						},
					],
				};
			}

			const llmMessages = convertToLlm(messages);
			const conversationText = serializeConversation(llmMessages);
			const candidateFiles = [...extractCandidateFiles(branch, conversationText)];
			const loadedSkills = extractLoadedSkills(branch);

			const extractionModel = resolveExtractionModel(ctx) ?? ctx.model;
			const auth = await ctx.modelRegistry.getApiKeyAndHeaders(extractionModel);
			if (!auth.ok) {
				return {
					content: [
						{
							type: "text" as const,
							text: `Failed to get API key: ${auth.error}`,
						},
					],
				};
			}

			let result: HandoffExtraction | null;
			try {
				result = await extractHandoffContext(
					extractionModel,
					auth.apiKey,
					auth.headers,
					conversationText,
					params.goal,
					candidateFiles,
					loadedSkills,
					signal,
				);
			} catch (err) {
				if (signal.aborted) {
					return {
						content: [
							{
								type: "text" as const,
								text: "Handoff cancelled.",
							},
						],
					};
				}
				return {
					content: [
						{
							type: "text" as const,
							text: `Handoff extraction failed: ${String(err)}`,
						},
					],
				};
			}

			if (!result) {
				return {
					content: [
						{
							type: "text" as const,
							text: "Handoff extraction failed or was cancelled.",
						},
					],
				};
			}

			const currentSessionFile = ctx.sessionManager.getSessionFile();
			const prompt = assembleHandoffDraft(result, params.goal, currentSessionFile);

			pendingHandoff = {
				prompt,
				parentSession: currentSessionFile,
				newModel: params.model,
			};

			return {
				content: [
					{
						type: "text" as const,
						text: "Handoff prepared. The session will switch after this turn completes.",
					},
				],
			};
		},
	});

	pi.registerCommand("handoff", {
		description: "Transfer context to a new session (-model provider/modelId)",
		handler: async (args: string, ctx: ExtensionCommandContext) => {
			if (!ctx.hasUI) {
				ctx.ui.notify("/handoff requires interactive mode", "error");
				return;
			}

			if (!ctx.model) {
				ctx.ui.notify("No model selected", "error");
				return;
			}

			// Parse optional -model flag from args
			let remaining = args;
			let newSessionModel: string | undefined;

			const modelMatch = remaining.match(/(?:^|\s)-model\s+(\S+)/);
			if (modelMatch) {
				newSessionModel = modelMatch[1];
				remaining = remaining.replace(modelMatch[0], " ");
			}

			let goal = remaining.trim();
			if (!goal) {
				const entered = await ctx.ui.input("handoff goal", "What should the new thread do?");
				if (!entered?.trim()) {
					ctx.ui.notify("Handoff cancelled", "info");
					return;
				}
				goal = entered.trim();
			}

			const branch = ctx.sessionManager.getBranch();
			const messages = branch
				.filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message")
				.map((entry) => entry.message);

			if (messages.length === 0) {
				ctx.ui.notify("No conversation to hand off", "warning");
				return;
			}

			const llmMessages = convertToLlm(messages);
			const conversationText = serializeConversation(llmMessages);
			const currentSessionFile = ctx.sessionManager.getSessionFile();
			const candidateFiles = [...extractCandidateFiles(branch, conversationText)];
			const loadedSkills = extractLoadedSkills(branch);
			const extractionModel = resolveExtractionModel(ctx) ?? ctx.model;

			const result = await ctx.ui.custom<HandoffExtraction | null>((tui, theme, _kb, done) => {
				const loader = new BorderedLoader(tui, theme, "Extracting handoff context...");
				loader.onAbort = () => done(null);

				const run = async () => {
					const auth = await ctx.modelRegistry.getApiKeyAndHeaders(extractionModel);
					if (!auth.ok) {
						throw new Error(`Failed to get API key: ${auth.error}`);
					}

					return extractHandoffContext(
						extractionModel,
						auth.apiKey,
						auth.headers,
						conversationText,
						goal,
						candidateFiles,
						loadedSkills,
						loader.signal,
					);
				};

				run()
					.then(done)
					.catch((err) => {
						console.error("handoff generation failed", err);
						done(null);
					});

				return loader;
			});

			if (!result) {
				ctx.ui.notify("Handoff cancelled", "info");
				return;
			}

			const prefillDraft = assembleHandoffDraft(result, goal, currentSessionFile);

			const editedPrompt = await ctx.ui.editor("Edit handoff draft", prefillDraft);
			if (editedPrompt === undefined) {
				ctx.ui.notify("Handoff cancelled", "info");
				return;
			}

			const next = await ctx.newSession({
				parentSession: currentSessionFile,
			});

			if (next.cancelled) {
				ctx.ui.notify("New session cancelled", "info");
				return;
			}

			// Apply -model if specified
			if (newSessionModel) {
				const slashIdx = newSessionModel.indexOf("/");
				if (slashIdx > 0) {
					const provider = newSessionModel.slice(0, slashIdx);
					const modelId = newSessionModel.slice(slashIdx + 1);
					const model = ctx.modelRegistry.find(provider, modelId);
					if (model) {
						await pi.setModel(model);
					} else {
						ctx.ui.notify(`Unknown model: ${newSessionModel}`, "warning");
					}
				} else {
					ctx.ui.notify(`Invalid model format "${newSessionModel}", expected provider/modelId`, "warning");
				}
			}

			const newSessionFile = ctx.sessionManager.getSessionFile();
			if (newSessionFile) {
				ctx.ui.notify(`Switched to new session: ${newSessionFile}`, "info");
			}

			ctx.ui.setEditorText(editedPrompt);
			startCountdown(ctx);
		},
	});
}
