// SPDX-FileCopyrightText: Amolith // SPDX-FileCopyrightText: Petr Baudis // // SPDX-License-Identifier: MIT import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, SessionEntry, } from "@earendil-works/pi-coding-agent"; import { BorderedLoader, convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent"; import { assembleHandoffDraft, extractHandoffContext, type HandoffExtraction } from "./handoff-extraction.js"; import { parseAndSetModelWithNotify } from "./model-utils.js"; import { configuredExtractionModel, extractCandidateFiles, extractLoadedSkills } from "./session-analysis.js"; export function registerHandoffCommand(pi: ExtensionAPI, startCountdown: (ctx: ExtensionContext) => void) { 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 = configuredExtractionModel(pi, ctx) ?? ctx.model; const result = await ctx.ui.custom((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 ?? undefined, }); if (next.cancelled) { ctx.ui.notify("New session cancelled", "info"); return; } // Apply -model if specified if (newSessionModel) { await parseAndSetModelWithNotify(newSessionModel, ctx, pi); } const newSessionFile = ctx.sessionManager.getSessionFile(); ctx.ui.notify(`Switched to new session: ${newSessionFile ?? "(unnamed)"}`, "info"); ctx.ui.setEditorText(editedPrompt); startCountdown(ctx); }, }); }