From ace0063a00246723ee0844ebc4ff9b59ce8a7761 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 2 Jan 2026 00:42:11 +0100 Subject: [PATCH] Add handoff example hook Transfers context to a new focused session instead of compacting. User provides a goal, hook generates a prompt with relevant context, creates a new session with parent tracking, and loads the prompt as a draft in the editor for review. Inspired by Amp's handoff feature. --- .../coding-agent/examples/hooks/handoff.ts | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 packages/coding-agent/examples/hooks/handoff.ts diff --git a/packages/coding-agent/examples/hooks/handoff.ts b/packages/coding-agent/examples/hooks/handoff.ts new file mode 100644 index 00000000..1b00ff39 --- /dev/null +++ b/packages/coding-agent/examples/hooks/handoff.ts @@ -0,0 +1,159 @@ +/** + * Handoff hook - transfer context to a new focused session + * + * Instead of compacting (which is lossy), handoff extracts what matters + * for your next task and creates a new session with a generated prompt. + * + * Usage: + * /handoff now implement this for teams as well + * /handoff execute phase one of the plan + * /handoff check other places that need this fix + * + * The generated prompt appears as a draft in the editor for review/editing. + */ + +import { complete, type Message } from "@mariozechner/pi-ai"; +import type { HookAPI } from "@mariozechner/pi-coding-agent"; +import { BorderedLoader } from "@mariozechner/pi-coding-agent"; + +const SYSTEM_PROMPT = `You are a context transfer assistant. Given a conversation history and the user's goal for a new thread, generate a focused prompt that: + +1. Summarizes relevant context from the conversation (decisions made, approaches taken, key findings) +2. Lists any relevant files that were discussed or modified +3. Clearly states the next task based on the user's goal +4. Is self-contained - the new thread should be able to proceed without the old conversation + +Format your response as a prompt the user can send to start the new thread. Be concise but include all necessary context. Do not include any preamble like "Here's the prompt" - just output the prompt itself. + +Example output format: +## Context +We've been working on X. Key decisions: +- Decision 1 +- Decision 2 + +Files involved: +- path/to/file1.ts +- path/to/file2.ts + +## Task +[Clear description of what to do next based on user's goal]`; + +export default function (pi: HookAPI) { + pi.registerCommand("handoff", { + description: "Transfer context to a new focused session", + handler: async (args, ctx) => { + if (!ctx.hasUI) { + ctx.ui.notify("handoff requires interactive mode", "error"); + return; + } + + if (!ctx.model) { + ctx.ui.notify("No model selected", "error"); + return; + } + + const goal = args.trim(); + if (!goal) { + ctx.ui.notify("Usage: /handoff ", "error"); + return; + } + + // Gather conversation context from current branch + const branch = ctx.sessionManager.getBranch(); + const conversationParts: string[] = []; + + for (const entry of branch) { + if (entry.type === "message") { + const msg = entry.message; + if ("role" in msg && (msg.role === "user" || msg.role === "assistant")) { + let text: string; + if (typeof msg.content === "string") { + text = msg.content; + } else { + text = msg.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join("\n"); + } + if (text) { + const role = msg.role === "user" ? "User" : "Assistant"; + conversationParts.push(`${role}: ${text}`); + } + } + } + } + + if (conversationParts.length === 0) { + ctx.ui.notify("No conversation to hand off", "error"); + return; + } + + const conversationText = conversationParts.join("\n\n"); + const currentSessionFile = ctx.sessionManager.getSessionFile(); + + // Generate the handoff prompt with loader UI + const result = await ctx.ui.custom((tui, theme, done) => { + const loader = new BorderedLoader(tui, theme, `Generating handoff prompt...`); + loader.onAbort = () => done(null); + + const doGenerate = async () => { + const apiKey = await ctx.modelRegistry.getApiKey(ctx.model!); + + const userMessage: Message = { + role: "user", + content: [ + { + type: "text", + text: `## Conversation History\n\n${conversationText}\n\n## User's Goal for New Thread\n\n${goal}`, + }, + ], + timestamp: Date.now(), + }; + + const response = await complete( + ctx.model!, + { systemPrompt: SYSTEM_PROMPT, messages: [userMessage] }, + { apiKey, signal: loader.signal }, + ); + + if (response.stopReason === "aborted") { + return null; + } + + return response.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join("\n"); + }; + + doGenerate() + .then(done) + .catch((err) => { + console.error("Handoff generation failed:", err); + done(null); + }); + + return loader; + }); + + if (result === null) { + ctx.ui.notify("Cancelled", "info"); + return; + } + + // Create new session with parent tracking + const newSessionResult = await ctx.newSession({ + parentSession: currentSessionFile, + }); + + if (newSessionResult.cancelled) { + ctx.ui.notify("New session cancelled", "info"); + return; + } + + // Set the generated prompt as a draft in the editor + ctx.ui.setEditorText(result); + ctx.ui.notify("Handoff ready. Review the prompt and submit when ready.", "info"); + }, + }); +}