index.ts

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2// SPDX-FileCopyrightText: Petr Baudis <pasky@ucw.cz>
  3//
  4// SPDX-License-Identifier: MIT
  5
  6import { complete, type Message, type Tool } from "@mariozechner/pi-ai";
  7import type {
  8	ExtensionAPI,
  9	ExtensionCommandContext,
 10	ExtensionContext,
 11	SessionEntry,
 12} from "@mariozechner/pi-coding-agent";
 13import { BorderedLoader, SessionManager, convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent";
 14import { Key, matchesKey } from "@mariozechner/pi-tui";
 15import { Type } from "@sinclair/typebox";
 16import * as fs from "node:fs";
 17import * as os from "node:os";
 18import * as path from "node:path";
 19
 20const STATUS_KEY = "handoff";
 21const COUNTDOWN_SECONDS = 10;
 22
 23/**
 24 * Model used for handoff extraction calls. Set PI_HANDOFF_MODEL env var
 25 * as "provider/modelId" (e.g. "anthropic/claude-haiku-4-5") to use a
 26 * different model for extraction than the session's current model.
 27 */
 28const HANDOFF_MODEL_OVERRIDE = process.env.PI_HANDOFF_MODEL;
 29
 30const 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.
 31
 32Consider these questions:
 33- What was just done or implemented?
 34- What decisions were made and why?
 35- What technical details were discovered (APIs, methods, patterns)?
 36- What constraints, limitations, or caveats were found?
 37- What patterns or approaches are being followed?
 38- What is still unfinished or unresolved?
 39- What open questions or risks are worth flagging?
 40
 41Rules:
 42- Be concrete: prefer file paths, specific decisions, and actual commands over vague summaries.
 43- Only include files from the provided candidate list.
 44- Only include skills from the provided loaded skills list.
 45- If something wasn't explicitly discussed, don't include it.`;
 46
 47const QUERY_SYSTEM_PROMPT = `You answer questions about a prior pi session.
 48
 49Rules:
 50- Use only facts from the provided conversation.
 51- Prefer concrete outputs: file paths, decisions, TODOs, errors.
 52- If not present, say explicitly: "Not found in provided session.".
 53- Keep answer concise.`;
 54
 55/**
 56 * Tool definition passed to the extraction LLM call. Models produce
 57 * structured tool-call arguments far more reliably than free-form JSON
 58 * in a text response, so we use a single tool instead of responseFormat.
 59 */
 60const HANDOFF_EXTRACTION_TOOL: Tool = {
 61	name: "extract_handoff_context",
 62	description: "Extract handoff context from the conversation for a new session.",
 63	parameters: Type.Object({
 64		relevantFiles: Type.Array(Type.String(), {
 65			description: "File paths relevant to continuing this work, chosen from the provided candidate list",
 66		}),
 67		skillsInUse: Type.Array(Type.String(), {
 68			description: "Skills relevant to the goal, chosen from the provided list of loaded skills",
 69		}),
 70		context: Type.String({
 71			description:
 72				"Key context: what was done, decisions made, technical details discovered, constraints, and patterns being followed",
 73		}),
 74		openItems: Type.Array(Type.String(), {
 75			description: "Unfinished work, open questions, known risks, and things to watch out for",
 76		}),
 77	}),
 78};
 79
 80type PendingAutoSubmit = {
 81	ctx: ExtensionContext;
 82	sessionFile: string | undefined;
 83	interval: ReturnType<typeof setInterval>;
 84	unsubscribeInput: () => void;
 85};
 86
 87type PendingHandoff = {
 88	prompt: string;
 89	parentSession: string | undefined;
 90	newModel: string | undefined;
 91};
 92
 93function isEditableInput(data: string): boolean {
 94	if (!data) return false;
 95	if (data.length === 1) {
 96		const code = data.charCodeAt(0);
 97		if (code >= 32 && code !== 127) return true;
 98		if (code === 8 || code === 13) return true;
 99	}
100
101	if (data === "\n" || data === "\r") return true;
102	if (data === "\x7f") return true;
103
104	if (data.length > 1 && !data.startsWith("\x1b")) return true;
105
106	return false;
107}
108
109function statusLine(ctx: ExtensionContext, seconds: number): string {
110	const accent = ctx.ui.theme.fg("accent", `handoff auto-submit in ${seconds}s`);
111	const hint = ctx.ui.theme.fg("dim", "(type to edit, Esc to cancel)");
112	return `${accent} ${hint}`;
113}
114
115function getSessionsRoot(sessionFile: string | undefined): string | undefined {
116	if (!sessionFile) return undefined;
117	const normalized = sessionFile.replace(/\\/g, "/");
118	const marker = "/sessions/";
119	const idx = normalized.indexOf(marker);
120	if (idx === -1) {
121		return path.dirname(path.resolve(sessionFile));
122	}
123	return normalized.slice(0, idx + marker.length - 1);
124}
125
126function getFallbackSessionsRoot(): string | undefined {
127	const configuredDir = process.env.PI_CODING_AGENT_DIR;
128	const candidate = configuredDir
129		? path.resolve(configuredDir, "sessions")
130		: path.resolve(os.homedir(), ".pi", "agent", "sessions");
131	return fs.existsSync(candidate) ? candidate : undefined;
132}
133
134function normalizeSessionPath(sessionPath: string, sessionsRoot: string | undefined): string {
135	if (path.isAbsolute(sessionPath)) return path.resolve(sessionPath);
136	if (sessionsRoot) return path.resolve(sessionsRoot, sessionPath);
137	return path.resolve(sessionPath);
138}
139
140function sessionPathAllowed(candidate: string, sessionsRoot: string | undefined): boolean {
141	if (!sessionsRoot) return false; // fail closed when root unknown
142	const root = path.resolve(sessionsRoot);
143	const resolved = path.resolve(candidate);
144	return resolved === root || resolved.startsWith(`${root}${path.sep}`);
145}
146
147/**
148 * Build a candidate file set from two sources:
149 *   1. Primary: actual tool calls (read, write, edit, create) in the session
150 *   2. Secondary: file-like patterns in the conversation text (catches files
151 *      that were discussed but never opened)
152 */
153function extractCandidateFiles(entries: SessionEntry[], conversationText: string): Set<string> {
154	const files = new Set<string>();
155	const fileToolNames = new Set(["read", "write", "edit", "create"]);
156
157	// Primary: files from actual tool calls
158	for (const entry of entries) {
159		if (entry.type !== "message") continue;
160		const msg = entry.message;
161		if (msg.role !== "assistant") continue;
162
163		for (const block of msg.content) {
164			if (typeof block !== "object" || block === null || block.type !== "toolCall") continue;
165			if (!fileToolNames.has(block.name)) continue;
166
167			const args = block.arguments as Record<string, unknown>;
168			const filePath =
169				typeof args.path === "string" ? args.path : typeof args.file === "string" ? args.file : undefined;
170			if (!filePath) continue;
171			if (filePath.endsWith("/SKILL.md")) continue;
172
173			files.add(filePath);
174		}
175	}
176
177	// Secondary: file-like patterns from conversation text.
178	// Trailing lookahead so the boundary isn't consumed — otherwise adjacent
179	// files separated by a single space (e.g. "file1.txt file2.txt") get skipped.
180	const filePattern = /(?:^|\s)([a-zA-Z0-9._\-/]+\.[a-zA-Z0-9]+)(?=\s|$|[,;:)])/gm;
181	for (const match of conversationText.matchAll(filePattern)) {
182		const candidate = match[1];
183		if (candidate && !candidate.startsWith(".") && candidate.length > 2) {
184			files.add(candidate);
185		}
186	}
187
188	return files;
189}
190
191/**
192 * Extract skill names that were actually loaded during the conversation.
193 * Looks for read() tool calls targeting SKILL.md files and derives the
194 * skill name from the parent directory (the convention for pi skills).
195 */
196function extractLoadedSkills(entries: SessionEntry[]): string[] {
197	const skills = new Set<string>();
198	for (const entry of entries) {
199		if (entry.type !== "message") continue;
200		const msg = entry.message;
201		if (msg.role !== "assistant") continue;
202
203		for (const block of msg.content) {
204			if (typeof block !== "object" || block === null || block.type !== "toolCall") continue;
205
206			// read() calls where the path ends in SKILL.md
207			if (block.name !== "read") continue;
208			const args = block.arguments as Record<string, unknown>;
209			const filePath = typeof args.path === "string" ? args.path : undefined;
210			if (!filePath?.endsWith("/SKILL.md")) continue;
211
212			// Skill name is the parent directory name:
213			//   .../skills/backing-up-with-keld/SKILL.md → backing-up-with-keld
214			const parent = path.basename(path.dirname(filePath));
215			if (parent && parent !== "skills") {
216				skills.add(parent);
217			}
218		}
219	}
220	return [...skills].sort();
221}
222
223/**
224 * Resolve the model to use for handoff extraction calls. Uses the
225 * PI_HANDOFF_MODEL env var if set, otherwise falls back to the session model.
226 */
227function resolveExtractionModel(ctx: {
228	model: ExtensionContext["model"];
229	modelRegistry: ExtensionContext["modelRegistry"];
230}): ExtensionContext["model"] {
231	if (!HANDOFF_MODEL_OVERRIDE) return ctx.model;
232	const slashIdx = HANDOFF_MODEL_OVERRIDE.indexOf("/");
233	if (slashIdx <= 0) return ctx.model;
234	const provider = HANDOFF_MODEL_OVERRIDE.slice(0, slashIdx);
235	const modelId = HANDOFF_MODEL_OVERRIDE.slice(slashIdx + 1);
236	return ctx.modelRegistry.find(provider, modelId) ?? ctx.model;
237}
238
239type HandoffExtraction = {
240	files: string[];
241	skills: string[];
242	context: string;
243	openItems: string[];
244};
245
246/**
247 * Run the extraction LLM call and return structured context, or null if
248 * aborted or the model didn't produce a valid tool call.
249 */
250async function extractHandoffContext(
251	model: NonNullable<ExtensionContext["model"]>,
252	apiKey: string | undefined,
253	headers: Record<string, string> | undefined,
254	conversationText: string,
255	goal: string,
256	candidateFiles: string[],
257	loadedSkills: string[],
258	signal?: AbortSignal,
259): Promise<HandoffExtraction | null> {
260	const filesContext =
261		candidateFiles.length > 0
262			? `\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")}`
263			: "";
264
265	const skillsContext =
266		loadedSkills.length > 0
267			? `\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.`
268			: "";
269
270	const userMessage: Message = {
271		role: "user",
272		content: [
273			{
274				type: "text",
275				text: `## Conversation\n\n${conversationText}\n\n## Goal for Next Session\n\n${goal}${filesContext}${skillsContext}`,
276			},
277		],
278		timestamp: Date.now(),
279	};
280
281	const response = await complete(
282		model,
283		{
284			systemPrompt: SYSTEM_PROMPT,
285			messages: [userMessage],
286			tools: [HANDOFF_EXTRACTION_TOOL],
287		},
288		{ apiKey, headers, signal },
289	);
290
291	if (response.stopReason === "aborted") return null;
292
293	const toolCall = response.content.find((c) => c.type === "toolCall" && c.name === "extract_handoff_context");
294
295	if (!toolCall || toolCall.type !== "toolCall") {
296		console.error("Model did not call extract_handoff_context:", response.content);
297		return null;
298	}
299
300	const args = toolCall.arguments as Record<string, unknown>;
301
302	if (
303		!Array.isArray(args.relevantFiles) ||
304		!Array.isArray(args.skillsInUse) ||
305		typeof args.context !== "string" ||
306		!Array.isArray(args.openItems)
307	) {
308		console.error("Unexpected tool call arguments shape:", args);
309		return null;
310	}
311
312	const candidateSet = new Set(candidateFiles);
313	const loadedSkillSet = new Set(loadedSkills);
314
315	return {
316		files: (args.relevantFiles as string[]).filter((f) => typeof f === "string" && candidateSet.has(f)),
317		skills: (args.skillsInUse as string[]).filter((s) => typeof s === "string" && loadedSkillSet.has(s)),
318		context: args.context as string,
319		openItems: (args.openItems as string[]).filter((item) => typeof item === "string"),
320	};
321}
322
323/**
324 * Assemble the handoff draft. The user's goal goes last so it has the
325 * most weight in the new session (recency bias).
326 */
327function assembleHandoffDraft(result: HandoffExtraction, goal: string, parentSessionFile: string | undefined): string {
328	const parentBlock = parentSessionFile
329		? `**Parent session:** \`${parentSessionFile}\`\n\nUse the \`session_query\` tool with this path if you need details from the prior thread.\n\n`
330		: "";
331
332	const filesSection = result.files.length > 0 ? `## Files\n\n${result.files.map((f) => `- ${f}`).join("\n")}\n\n` : "";
333
334	const skillsSection =
335		result.skills.length > 0 ? `## Skills in Use\n\n${result.skills.map((s) => `- ${s}`).join("\n")}\n\n` : "";
336
337	const contextSection = `## Context\n\n${result.context}\n\n`;
338
339	const openItemsSection =
340		result.openItems.length > 0 ? `## Open Items\n\n${result.openItems.map((item) => `- ${item}`).join("\n")}\n\n` : "";
341
342	return `${parentBlock}${filesSection}${skillsSection}${contextSection}${openItemsSection}${goal}`.trim();
343}
344
345export default function (pi: ExtensionAPI) {
346	let pending: PendingAutoSubmit | null = null;
347	let pendingHandoff: PendingHandoff | null = null;
348	let handoffTimestamp: number | null = null;
349
350	const clearPending = (ctx?: ExtensionContext, notify?: string) => {
351		if (!pending) return;
352
353		clearInterval(pending.interval);
354		pending.unsubscribeInput();
355		pending.ctx.ui.setStatus(STATUS_KEY, undefined);
356
357		const local = pending;
358		pending = null;
359
360		if (notify && ctx) {
361			ctx.ui.notify(notify, "info");
362		} else if (notify) {
363			local.ctx.ui.notify(notify, "info");
364		}
365	};
366
367	const autoSubmitDraft = () => {
368		if (!pending) return;
369
370		const active = pending;
371		const currentSession = active.ctx.sessionManager.getSessionFile();
372		if (active.sessionFile && currentSession !== active.sessionFile) {
373			clearPending(undefined);
374			return;
375		}
376
377		const draft = active.ctx.ui.getEditorText().trim();
378		clearPending(undefined);
379
380		if (!draft) {
381			active.ctx.ui.notify("Handoff draft is empty", "warning");
382			return;
383		}
384
385		active.ctx.ui.setEditorText("");
386
387		try {
388			if (active.ctx.isIdle()) {
389				pi.sendUserMessage(draft);
390			} else {
391				pi.sendUserMessage(draft, { deliverAs: "followUp" });
392			}
393		} catch {
394			pi.sendUserMessage(draft);
395		}
396	};
397
398	const startCountdown = (ctx: ExtensionContext) => {
399		clearPending(ctx);
400
401		let seconds = COUNTDOWN_SECONDS;
402		ctx.ui.setStatus(STATUS_KEY, statusLine(ctx, seconds));
403
404		const unsubscribeInput = ctx.ui.onTerminalInput((data) => {
405			if (matchesKey(data, Key.escape)) {
406				clearPending(ctx, "Handoff auto-submit cancelled");
407				return { consume: true };
408			}
409
410			if (isEditableInput(data)) {
411				clearPending(ctx, "Handoff auto-submit stopped (editing)");
412			}
413
414			return undefined;
415		});
416
417		const interval = setInterval(() => {
418			if (!pending) return;
419
420			seconds -= 1;
421			if (seconds <= 0) {
422				autoSubmitDraft();
423				return;
424			}
425
426			ctx.ui.setStatus(STATUS_KEY, statusLine(ctx, seconds));
427		}, 1000);
428
429		pending = {
430			ctx,
431			sessionFile: ctx.sessionManager.getSessionFile(),
432			interval,
433			unsubscribeInput,
434		};
435	};
436
437	pi.on("session_before_switch", (_event, ctx) => {
438		if (pending) clearPending(ctx);
439	});
440
441	pi.on("session_start", (event, ctx) => {
442		if (pending) clearPending(ctx);
443		// A proper session switch (e.g. /new) or fork fully resets agent state,
444		// so clear the context filter to avoid hiding new messages.
445		if (event.reason === "new" || event.reason === "resume" || event.reason === "fork") {
446			handoffTimestamp = null;
447		}
448	});
449
450	pi.on("session_before_fork", (_event, ctx) => {
451		if (pending) clearPending(ctx);
452	});
453
454	pi.on("session_before_tree", (_event, ctx) => {
455		if (pending) clearPending(ctx);
456	});
457
458	pi.on("session_tree", (_event, ctx) => {
459		if (pending) clearPending(ctx);
460	});
461
462	pi.on("session_shutdown", (_event, ctx) => {
463		if (pending) clearPending(ctx);
464	});
465
466	// --- Tool-path handoff coordination ---
467	//
468	// The /handoff command has ExtensionCommandContext with ctx.newSession()
469	// which does a full agent reset. The handoff tool only gets
470	// ExtensionContext, which lacks newSession(). So the tool stores a
471	// pending handoff and these handlers complete it:
472	//
473	// 1. agent_end: after the agent loop finishes, switch sessions and
474	//    send the handoff prompt in the next macrotask
475	// 2. context: filter pre-handoff messages since the low-level session
476	//    switch doesn't clear agent.state.messages
477	// 3. session_start (above): clears the filter on proper switches
478
479	pi.on("agent_end", (_event, ctx) => {
480		if (!pendingHandoff) return;
481
482		const { prompt, parentSession, newModel } = pendingHandoff;
483		pendingHandoff = null;
484
485		// Create new session first — this triggers session_start which clears
486		// any stale handoffTimestamp. Then set the timestamp for the current
487		// handoff so the context filter is active for the new session's first turn.
488		(ctx.sessionManager as SessionManager).newSession({ parentSession });
489		handoffTimestamp = Date.now();
490
491		setTimeout(async () => {
492			if (newModel) {
493				const slashIdx = newModel.indexOf("/");
494				if (slashIdx > 0) {
495					const provider = newModel.slice(0, slashIdx);
496					const modelId = newModel.slice(slashIdx + 1);
497					const model = ctx.modelRegistry.find(provider, modelId);
498					if (model) await pi.setModel(model);
499				}
500			}
501			pi.sendUserMessage(prompt);
502		}, 0);
503	});
504
505	pi.on("context", (event, _ctx) => {
506		if (handoffTimestamp === null) return;
507
508		const cutoff = handoffTimestamp;
509		const newMessages = event.messages.filter((m) => m.timestamp >= cutoff);
510		if (newMessages.length > 0) {
511			return { messages: newMessages };
512		}
513	});
514
515	pi.registerTool({
516		name: "session_query",
517		label: "Session Query",
518		description:
519			"Query a prior pi session file. Use when handoff prompt references a parent session and you need details.",
520		parameters: Type.Object({
521			sessionPath: Type.String({
522				description:
523					"Session .jsonl path. Absolute path, or relative to sessions root (e.g. 2026-02-16/foo/session.jsonl)",
524			}),
525			question: Type.String({ description: "Question about that session" }),
526		}),
527		async execute(_toolCallId, params, signal, onUpdate, ctx) {
528			const currentSessionFile = ctx.sessionManager.getSessionFile();
529			const sessionsRoot = getSessionsRoot(currentSessionFile) ?? getFallbackSessionsRoot();
530			const resolvedPath = normalizeSessionPath(params.sessionPath, sessionsRoot);
531
532			const error = (text: string) => ({
533				content: [{ type: "text" as const, text }],
534				details: { error: true } as const,
535			});
536
537			const cancelled = () => ({
538				content: [{ type: "text" as const, text: "Session query cancelled." }],
539				details: { cancelled: true } as const,
540			});
541
542			if (signal?.aborted) {
543				return cancelled();
544			}
545
546			if (!resolvedPath.endsWith(".jsonl")) {
547				return error(`Invalid session path (expected .jsonl): ${params.sessionPath}`);
548			}
549
550			if (!sessionPathAllowed(resolvedPath, sessionsRoot)) {
551				return error(`Session path outside allowed sessions directory: ${params.sessionPath}`);
552			}
553
554			if (!fs.existsSync(resolvedPath)) {
555				return error(`Session file not found: ${resolvedPath}`);
556			}
557
558			let fileStats: fs.Stats;
559			try {
560				fileStats = fs.statSync(resolvedPath);
561			} catch (err) {
562				return error(`Failed to stat session file: ${String(err)}`);
563			}
564
565			if (!fileStats.isFile()) {
566				return error(`Session path is not a file: ${resolvedPath}`);
567			}
568
569			onUpdate?.({
570				content: [{ type: "text", text: `Querying: ${resolvedPath}` }],
571				details: { status: "loading", sessionPath: resolvedPath },
572			});
573
574			let sessionManager: SessionManager;
575			try {
576				sessionManager = SessionManager.open(resolvedPath);
577			} catch (err) {
578				return error(`Failed to open session: ${String(err)}`);
579			}
580
581			const branch = sessionManager.getBranch();
582			const messages = branch
583				.filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message")
584				.map((entry) => entry.message);
585
586			if (messages.length === 0) {
587				return {
588					content: [{ type: "text" as const, text: "Session has no messages." }],
589					details: { empty: true, sessionPath: resolvedPath },
590				};
591			}
592
593			if (!ctx.model) {
594				return error("No model selected for session query.");
595			}
596
597			const conversationText = serializeConversation(convertToLlm(messages));
598			try {
599				const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model);
600				if (!auth.ok) {
601					return error(`Failed to get API key: ${auth.error}`);
602				}
603				const userMessage: Message = {
604					role: "user",
605					content: [
606						{
607							type: "text",
608							text: `## Session\n\n${conversationText}\n\n## Question\n\n${params.question}`,
609						},
610					],
611					timestamp: Date.now(),
612				};
613
614				const response = await complete(
615					ctx.model,
616					{ systemPrompt: QUERY_SYSTEM_PROMPT, messages: [userMessage] },
617					{ apiKey: auth.apiKey, headers: auth.headers, signal: signal as AbortSignal },
618				);
619
620				if (response.stopReason === "aborted") {
621					return cancelled();
622				}
623
624				const answer = response.content
625					.filter((c): c is { type: "text"; text: string } => c.type === "text")
626					.map((c) => c.text)
627					.join("\n")
628					.trim();
629
630				return {
631					content: [{ type: "text" as const, text: answer || "No answer generated." }],
632					details: {
633						sessionPath: resolvedPath,
634						question: params.question,
635						messageCount: messages.length,
636					},
637				};
638			} catch (err) {
639				if (signal?.aborted) {
640					return cancelled();
641				}
642				if (err instanceof Error && err.name === "AbortError") {
643					return cancelled();
644				}
645				return error(`Session query failed: ${String(err)}`);
646			}
647		},
648	});
649
650	pi.registerTool({
651		name: "handoff",
652		label: "Handoff",
653		description:
654			"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.",
655		parameters: Type.Object({
656			goal: Type.String({
657				description: "The goal/task for the new session",
658			}),
659			model: Type.Optional(
660				Type.String({
661					description: "Model for the new session as provider/modelId (e.g. 'anthropic/claude-haiku-4-5')",
662				}),
663			),
664		}),
665		async execute(_toolCallId, params, signal, _onUpdate, ctx) {
666			if (!ctx.model) {
667				return {
668					content: [{ type: "text" as const, text: "No model selected." }],
669					details: undefined,
670				};
671			}
672
673			const branch = ctx.sessionManager.getBranch();
674			const messages = branch
675				.filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message")
676				.map((entry) => entry.message);
677
678			if (messages.length === 0) {
679				return {
680					content: [
681						{
682							type: "text" as const,
683							text: "No conversation to hand off.",
684						},
685					],
686					details: undefined,
687				};
688			}
689
690			const llmMessages = convertToLlm(messages);
691			const conversationText = serializeConversation(llmMessages);
692			const candidateFiles = [...extractCandidateFiles(branch, conversationText)];
693			const loadedSkills = extractLoadedSkills(branch);
694
695			const extractionModel = resolveExtractionModel(ctx) ?? ctx.model;
696			const auth = await ctx.modelRegistry.getApiKeyAndHeaders(extractionModel);
697			if (!auth.ok) {
698				return {
699					content: [
700						{
701							type: "text" as const,
702							text: `Failed to get API key: ${auth.error}`,
703						},
704					],
705					details: undefined,
706				};
707			}
708
709			let result: HandoffExtraction | null;
710			try {
711				result = await extractHandoffContext(
712					extractionModel,
713					auth.apiKey,
714					auth.headers,
715					conversationText,
716					params.goal,
717					candidateFiles,
718					loadedSkills,
719					signal,
720				);
721			} catch (err) {
722				if (signal?.aborted) {
723					return {
724						content: [
725							{
726								type: "text" as const,
727								text: "Handoff cancelled.",
728							},
729						],
730						details: undefined,
731					};
732				}
733				return {
734					content: [
735						{
736							type: "text" as const,
737							text: `Handoff extraction failed: ${String(err)}`,
738						},
739					],
740					details: undefined,
741				};
742			}
743
744			if (!result) {
745				return {
746					content: [
747						{
748							type: "text" as const,
749							text: "Handoff extraction failed or was cancelled.",
750						},
751					],
752					details: undefined,
753				};
754			}
755
756			const currentSessionFile = ctx.sessionManager.getSessionFile();
757			const prompt = assembleHandoffDraft(result, params.goal, currentSessionFile);
758
759			pendingHandoff = {
760				prompt,
761				parentSession: currentSessionFile,
762				newModel: params.model,
763			};
764
765			return {
766				content: [
767					{
768						type: "text" as const,
769						text: "Handoff prepared. The session will switch after this turn completes.",
770					},
771				],
772				details: undefined,
773			};
774		},
775	});
776
777	pi.registerCommand("handoff", {
778		description: "Transfer context to a new session (-model provider/modelId)",
779		handler: async (args: string, ctx: ExtensionCommandContext) => {
780			if (!ctx.hasUI) {
781				ctx.ui.notify("/handoff requires interactive mode", "error");
782				return;
783			}
784
785			if (!ctx.model) {
786				ctx.ui.notify("No model selected", "error");
787				return;
788			}
789
790			// Parse optional -model flag from args
791			let remaining = args;
792			let newSessionModel: string | undefined;
793
794			const modelMatch = remaining.match(/(?:^|\s)-model\s+(\S+)/);
795			if (modelMatch) {
796				newSessionModel = modelMatch[1];
797				remaining = remaining.replace(modelMatch[0], " ");
798			}
799
800			let goal = remaining.trim();
801			if (!goal) {
802				const entered = await ctx.ui.input("handoff goal", "What should the new thread do?");
803				if (!entered?.trim()) {
804					ctx.ui.notify("Handoff cancelled", "info");
805					return;
806				}
807				goal = entered.trim();
808			}
809
810			const branch = ctx.sessionManager.getBranch();
811			const messages = branch
812				.filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message")
813				.map((entry) => entry.message);
814
815			if (messages.length === 0) {
816				ctx.ui.notify("No conversation to hand off", "warning");
817				return;
818			}
819
820			const llmMessages = convertToLlm(messages);
821			const conversationText = serializeConversation(llmMessages);
822			const currentSessionFile = ctx.sessionManager.getSessionFile();
823			const candidateFiles = [...extractCandidateFiles(branch, conversationText)];
824			const loadedSkills = extractLoadedSkills(branch);
825			const extractionModel = resolveExtractionModel(ctx) ?? ctx.model;
826
827			const result = await ctx.ui.custom<HandoffExtraction | null>((tui, theme, _kb, done) => {
828				const loader = new BorderedLoader(tui, theme, "Extracting handoff context...");
829				loader.onAbort = () => done(null);
830
831				const run = async () => {
832					const auth = await ctx.modelRegistry.getApiKeyAndHeaders(extractionModel);
833					if (!auth.ok) {
834						throw new Error(`Failed to get API key: ${auth.error}`);
835					}
836
837					return extractHandoffContext(
838						extractionModel,
839						auth.apiKey,
840						auth.headers,
841						conversationText,
842						goal,
843						candidateFiles,
844						loadedSkills,
845						loader.signal,
846					);
847				};
848
849				run()
850					.then(done)
851					.catch((err) => {
852						console.error("handoff generation failed", err);
853						done(null);
854					});
855
856				return loader;
857			});
858
859			if (!result) {
860				ctx.ui.notify("Handoff cancelled", "info");
861				return;
862			}
863
864			const prefillDraft = assembleHandoffDraft(result, goal, currentSessionFile);
865
866			const editedPrompt = await ctx.ui.editor("Edit handoff draft", prefillDraft);
867			if (editedPrompt === undefined) {
868				ctx.ui.notify("Handoff cancelled", "info");
869				return;
870			}
871
872			const next = await ctx.newSession({
873				parentSession: currentSessionFile ?? undefined,
874			});
875
876			if (next.cancelled) {
877				ctx.ui.notify("New session cancelled", "info");
878				return;
879			}
880
881			// Apply -model if specified
882			if (newSessionModel) {
883				const slashIdx = newSessionModel.indexOf("/");
884				if (slashIdx > 0) {
885					const provider = newSessionModel.slice(0, slashIdx);
886					const modelId = newSessionModel.slice(slashIdx + 1);
887					const model = ctx.modelRegistry.find(provider, modelId);
888					if (model) {
889						await pi.setModel(model);
890					} else {
891						ctx.ui.notify(`Unknown model: ${newSessionModel}`, "warning");
892					}
893				} else {
894					ctx.ui.notify(`Invalid model format "${newSessionModel}", expected provider/modelId`, "warning");
895				}
896			}
897
898			const newSessionFile = ctx.sessionManager.getSessionFile();
899			ctx.ui.notify(`Switched to new session: ${newSessionFile ?? "(unnamed)"}`, "info");
900
901			ctx.ui.setEditorText(editedPrompt);
902			startCountdown(ctx);
903		},
904	});
905}