// SPDX-FileCopyrightText: Amolith // SPDX-FileCopyrightText: Petr Baudis // // SPDX-License-Identifier: MIT import type { ExtensionAPI, SessionEntry } from "@earendil-works/pi-coding-agent"; import { convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import { assembleHandoffDraft, extractHandoffContext, type HandoffExtraction } from "./handoff-extraction.js"; import { configuredExtractionModel, extractCandidateFiles, extractLoadedSkills } from "./session-analysis.js"; export type PendingHandoff = { prompt: string; parentSession: string | undefined; newModel: string | undefined; }; export function registerHandoffTool(pi: ExtensionAPI, setPendingHandoff: (h: PendingHandoff) => void) { pi.registerTool({ name: "handoff", label: "Handoff", description: "Transfer relevant context to a new session. Invoke when the user explicitly asks for a handoff. Provide a clear, precise goal for the new session and context relevant to the goal will automatically transfer to the new session.", 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." }], details: undefined, }; } 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.", }, ], details: undefined, }; } const llmMessages = convertToLlm(messages); const conversationText = serializeConversation(llmMessages); const candidateFiles = [...extractCandidateFiles(branch, conversationText)]; const loadedSkills = extractLoadedSkills(branch); const extractionModel = configuredExtractionModel(pi, 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}`, }, ], details: undefined, }; } 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.", }, ], details: undefined, }; } return { content: [ { type: "text" as const, text: `Handoff extraction failed: ${String(err)}`, }, ], details: undefined, }; } if (!result) { return { content: [ { type: "text" as const, text: "Handoff extraction failed or was cancelled.", }, ], details: undefined, }; } const currentSessionFile = ctx.sessionManager.getSessionFile(); const prompt = assembleHandoffDraft(result, params.goal, currentSessionFile); setPendingHandoff({ prompt, parentSession: currentSessionFile, newModel: params.model, }); return { content: [ { type: "text" as const, text: "Handoff prepared. The session will switch after this turn completes.", }, ], details: undefined, }; }, }); }