handoff: Split into focused modules

Amolith created

Break the 905-line single file into six modules along natural
boundaries:

- session-paths.ts: path utilities (getSessionsRoot,
getFallbackSessionsRoot, normalizeSessionPath, sessionPathAllowed)
- session-analysis.ts: extractCandidateFiles, extractLoadedSkills,
resolveExtractionModel
- handoff-extraction.ts: extraction prompts, tool schema,
extractHandoffContext, assembleHandoffDraft
- session-query-tool.ts: session_query tool registration
- handoff-tool.ts: handoff tool registration
- handoff-command.ts: /handoff command registration
- index.ts: wiring, event handlers, shared state, countdown logic

No behavioral changes. Each module has a narrow interface with the rest
of the system — shared state is threaded through callbacks rather than
module-level globals.

Change summary

packages/handoff/src/handoff-command.ts    | 145 ++++
packages/handoff/src/handoff-extraction.ts | 160 +++++
packages/handoff/src/handoff-tool.ts       | 145 ++++
packages/handoff/src/index.ts              | 705 -----------------------
packages/handoff/src/session-analysis.ts   | 106 +++
packages/handoff/src/session-paths.ts      |  40 +
packages/handoff/src/session-query-tool.ts | 156 +++++
7 files changed, 764 insertions(+), 693 deletions(-)

Detailed changes

packages/handoff/src/handoff-command.ts 🔗

@@ -0,0 +1,145 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+// SPDX-FileCopyrightText: Petr Baudis <pasky@ucw.cz>
+//
+// SPDX-License-Identifier: MIT
+
+import type {
+	ExtensionAPI,
+	ExtensionCommandContext,
+	ExtensionContext,
+	SessionEntry,
+} from "@mariozechner/pi-coding-agent";
+import { BorderedLoader, convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent";
+import { extractCandidateFiles, extractLoadedSkills, resolveExtractionModel } from "./session-analysis.js";
+import { type HandoffExtraction, assembleHandoffDraft, extractHandoffContext } from "./handoff-extraction.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 = 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 ?? undefined,
+			});
+
+			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();
+			ctx.ui.notify(`Switched to new session: ${newSessionFile ?? "(unnamed)"}`, "info");
+
+			ctx.ui.setEditorText(editedPrompt);
+			startCountdown(ctx);
+		},
+	});
+}

packages/handoff/src/handoff-extraction.ts 🔗

@@ -0,0 +1,160 @@
+// 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 { ExtensionContext } from "@mariozechner/pi-coding-agent";
+import { Type } from "@sinclair/typebox";
+
+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.`;
+
+/**
+ * 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",
+		}),
+	}),
+};
+
+export 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.
+ */
+export async function extractHandoffContext(
+	model: NonNullable<ExtensionContext["model"]>,
+	apiKey: string | undefined,
+	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).
+ */
+export 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();
+}

packages/handoff/src/handoff-tool.ts 🔗

@@ -0,0 +1,145 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+// SPDX-FileCopyrightText: Petr Baudis <pasky@ucw.cz>
+//
+// SPDX-License-Identifier: MIT
+
+import type { ExtensionAPI, SessionEntry } from "@mariozechner/pi-coding-agent";
+import { convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent";
+import { Type } from "@sinclair/typebox";
+import { extractCandidateFiles, extractLoadedSkills, resolveExtractionModel } from "./session-analysis.js";
+import { type HandoffExtraction, assembleHandoffDraft, extractHandoffContext } from "./handoff-extraction.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 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." }],
+					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 = 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}`,
+						},
+					],
+					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,
+			};
+		},
+	});
+}

packages/handoff/src/index.ts 🔗

@@ -3,80 +3,16 @@
 //
 // 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 type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
+import type { SessionManager } 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";
+import { registerHandoffCommand } from "./handoff-command.js";
+import { registerHandoffTool, type PendingHandoff } from "./handoff-tool.js";
+import { registerSessionQueryTool } from "./session-query-tool.js";
 
 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;
@@ -84,12 +20,6 @@ type PendingAutoSubmit = {
 	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) {
@@ -112,236 +42,6 @@ function statusLine(ctx: ExtensionContext, seconds: number): string {
 	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 false; // fail closed when root unknown
-	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.
-	// Trailing lookahead so the boundary isn't consumed — otherwise adjacent
-	// files separated by a single space (e.g. "file1.txt file2.txt") get skipped.
-	const filePattern = /(?:^|\s)([a-zA-Z0-9._\-/]+\.[a-zA-Z0-9]+)(?=\s|$|[,;:)])/gm;
-	for (const match of conversationText.matchAll(filePattern)) {
-		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 | undefined,
-	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;
@@ -434,6 +134,8 @@ export default function (pi: ExtensionAPI) {
 		};
 	};
 
+	// --- Event handlers ---
+
 	pi.on("session_before_switch", (_event, ctx) => {
 		if (pending) clearPending(ctx);
 	});
@@ -512,394 +214,11 @@ export default function (pi: ExtensionAPI) {
 		}
 	});
 
-	pi.registerTool({
-		name: "session_query",
-		label: "Session Query",
-		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 } as const,
-			});
-
-			const cancelled = () => ({
-				content: [{ type: "text" as const, text: "Session query cancelled." }],
-				details: { cancelled: true } as const,
-			});
-
-			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: signal as AbortSignal },
-				);
-
-				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." }],
-					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 = 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}`,
-						},
-					],
-					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);
-
-			pendingHandoff = {
-				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,
-			};
-		},
-	});
-
-	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 ?? undefined,
-			});
-
-			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();
-			ctx.ui.notify(`Switched to new session: ${newSessionFile ?? "(unnamed)"}`, "info");
+	// --- Register tools and commands ---
 
-			ctx.ui.setEditorText(editedPrompt);
-			startCountdown(ctx);
-		},
+	registerSessionQueryTool(pi);
+	registerHandoffTool(pi, (h) => {
+		pendingHandoff = h;
 	});
+	registerHandoffCommand(pi, startCountdown);
 }

packages/handoff/src/session-analysis.ts 🔗

@@ -0,0 +1,106 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+// SPDX-FileCopyrightText: Petr Baudis <pasky@ucw.cz>
+//
+// SPDX-License-Identifier: MIT
+
+import type { ExtensionContext, SessionEntry } from "@mariozechner/pi-coding-agent";
+import * as path from "node:path";
+
+/**
+ * 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;
+
+/**
+ * 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)
+ */
+export 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.
+	// Trailing lookahead so the boundary isn't consumed — otherwise adjacent
+	// files separated by a single space (e.g. "file1.txt file2.txt") get skipped.
+	const filePattern = /(?:^|\s)([a-zA-Z0-9._\-/]+\.[a-zA-Z0-9]+)(?=\s|$|[,;:)])/gm;
+	for (const match of conversationText.matchAll(filePattern)) {
+		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).
+ */
+export 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.
+ */
+export 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;
+}

packages/handoff/src/session-paths.ts 🔗

@@ -0,0 +1,40 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+// SPDX-FileCopyrightText: Petr Baudis <pasky@ucw.cz>
+//
+// SPDX-License-Identifier: MIT
+
+import * as fs from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
+
+export 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);
+}
+
+export 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;
+}
+
+export 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);
+}
+
+export function sessionPathAllowed(candidate: string, sessionsRoot: string | undefined): boolean {
+	if (!sessionsRoot) return false; // fail closed when root unknown
+	const root = path.resolve(sessionsRoot);
+	const resolved = path.resolve(candidate);
+	return resolved === root || resolved.startsWith(`${root}${path.sep}`);
+}

packages/handoff/src/session-query-tool.ts 🔗

@@ -0,0 +1,156 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+// SPDX-FileCopyrightText: Petr Baudis <pasky@ucw.cz>
+//
+// SPDX-License-Identifier: MIT
+
+import { complete, type Message } from "@mariozechner/pi-ai";
+import type { ExtensionAPI, SessionEntry } from "@mariozechner/pi-coding-agent";
+import { SessionManager, convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent";
+import { Type } from "@sinclair/typebox";
+import * as fs from "node:fs";
+import { getFallbackSessionsRoot, getSessionsRoot, normalizeSessionPath, sessionPathAllowed } from "./session-paths.js";
+
+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.`;
+
+export function registerSessionQueryTool(pi: ExtensionAPI) {
+	pi.registerTool({
+		name: "session_query",
+		label: "Session Query",
+		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 } as const,
+			});
+
+			const cancelled = () => ({
+				content: [{ type: "text" as const, text: "Session query cancelled." }],
+				details: { cancelled: true } as const,
+			});
+
+			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: signal as AbortSignal },
+				);
+
+				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)}`);
+			}
+		},
+	});
+}