Add handoff extraction model picker

Amolith created

Register a handoff command that opens Pi's model picker and stores the
selected extraction model in the user's settings. Rename the extraction
model flag, environment variable, and settings key so they consistently
describe the handoff extraction model.

Bump @amolith/pi-handoff to 0.1.0-beta.2 for publishing with the beta
tag.

Change summary

package-lock.json                        |   2 
packages/handoff/README.md               |  26 +++-
packages/handoff/package.json            |   2 
packages/handoff/src/handoff-command.ts  |   6 
packages/handoff/src/handoff-tool.ts     |   6 
packages/handoff/src/index.ts            |  10 
packages/handoff/src/model-utils.ts      |  32 +++++
packages/handoff/src/session-analysis.ts | 143 +++++++++++++++++++------
8 files changed, 173 insertions(+), 54 deletions(-)

Detailed changes

package-lock.json 🔗

@@ -3347,7 +3347,7 @@
 		},
 		"packages/handoff": {
 			"name": "@amolith/pi-handoff",
-			"version": "0.1.0-beta.1",
+			"version": "0.1.0-beta.2",
 			"peerDependencies": {
 				"@earendil-works/pi-ai": "*",
 				"@earendil-works/pi-coding-agent": "*",

packages/handoff/README.md 🔗

@@ -27,6 +27,11 @@ by pressing most any key.
 pi install npm:@amolith/pi-handoff
 ```
 
+If you try it and find that it doesn't behave as expected, please send me your
+session file, the goal, and the resulting handoff so I can debug. Contact me via
+the methods listed at the bottom of [my website](https://secluded.site), or if
+you know me elsewhere, you're welcome to DM me there.
+
 ## Do it yourself
 
 Run `/handoff` with whatever the next session should do:
@@ -59,12 +64,21 @@ editor so you can tweak before starting the new session.
 
 ## Configure the extraction model
 
-It uses the current session model by default. To use a cheaper/faster model, set
-a `provider/modelId` with one of these:
+It uses the current session model by default. Interactively select a
+cheaper/faster model by running:
+
+```text
+/handoff:set-extraction-model
+```
+
+It opens Pi's model picker and saves the selected model in
+`$PI_CODING_AGENT_DIR/settings.json` as `handoffExtractionModel`.
+
+You can also set a `provider/modelId` with one of these:
 
-1. `--handoff-model provider/modelId`
-2. `$PI_HANDOFF_MODEL`
-3. `handoffModel` in `$PI_CODING_AGENT_DIR/settings.json`
+1. Startup flag: `--handoff-extraction-model provider/modelId`
+2. Env var: `$PI_HANDOFF_EXTRACTION_MODEL`
+3. Settings value: `handoffExtractionModel` in `$PI_CODING_AGENT_DIR/settings.json`
 
 If you set more than one, that list is the precedence order.
 
@@ -72,7 +86,7 @@ Example settings:
 
 ```json
 {
-  "handoffModel": "neuralwatt/qwen3.6-35b-fast"
+  "handoffExtractionModel": "neuralwatt/qwen3.6-35b-fast"
 }
 ```
 

packages/handoff/package.json 🔗

@@ -1,6 +1,6 @@
 {
 	"name": "@amolith/pi-handoff",
-	"version": "0.1.0-beta.1",
+	"version": "0.1.0-beta.2",
 	"peerDependencies": {
 		"@earendil-works/pi-ai": "*",
 		"@earendil-works/pi-coding-agent": "*",

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

@@ -10,9 +10,9 @@ import type {
 	SessionEntry,
 } from "@earendil-works/pi-coding-agent";
 import { BorderedLoader, convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent";
-import { extractCandidateFiles, extractLoadedSkills, resolveExtractionModel } from "./session-analysis.js";
-import { type HandoffExtraction, assembleHandoffDraft, extractHandoffContext } from "./handoff-extraction.js";
+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", {
@@ -63,7 +63,7 @@ export function registerHandoffCommand(pi: ExtensionAPI, startCountdown: (ctx: E
 			const currentSessionFile = ctx.sessionManager.getSessionFile();
 			const candidateFiles = [...extractCandidateFiles(branch, conversationText)];
 			const loadedSkills = extractLoadedSkills(branch);
-			const extractionModel = resolveExtractionModel(pi, ctx) ?? ctx.model;
+			const extractionModel = configuredExtractionModel(pi, ctx) ?? ctx.model;
 
 			const result = await ctx.ui.custom<HandoffExtraction | null>((tui, theme, _kb, done) => {
 				const loader = new BorderedLoader(tui, theme, "Extracting handoff context...");

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

@@ -6,8 +6,8 @@
 import type { ExtensionAPI, SessionEntry } from "@earendil-works/pi-coding-agent";
 import { convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent";
 import { Type } from "typebox";
-import { extractCandidateFiles, extractLoadedSkills, resolveExtractionModel } from "./session-analysis.js";
-import { type HandoffExtraction, assembleHandoffDraft, extractHandoffContext } from "./handoff-extraction.js";
+import { assembleHandoffDraft, extractHandoffContext, type HandoffExtraction } from "./handoff-extraction.js";
+import { configuredExtractionModel, extractCandidateFiles, extractLoadedSkills } from "./session-analysis.js";
 
 export type PendingHandoff = {
 	prompt: string;
@@ -61,7 +61,7 @@ export function registerHandoffTool(pi: ExtensionAPI, setPendingHandoff: (h: Pen
 			const candidateFiles = [...extractCandidateFiles(branch, conversationText)];
 			const loadedSkills = extractLoadedSkills(branch);
 
-			const extractionModel = resolveExtractionModel(pi, ctx) ?? ctx.model;
+			const extractionModel = configuredExtractionModel(pi, ctx) ?? ctx.model;
 			const auth = await ctx.modelRegistry.getApiKeyAndHeaders(extractionModel);
 			if (!auth.ok) {
 				return {

packages/handoff/src/index.ts 🔗

@@ -3,13 +3,12 @@
 //
 // SPDX-License-Identifier: MIT
 
-import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
-import type { SessionManager } from "@earendil-works/pi-coding-agent";
+import type { ExtensionAPI, ExtensionContext, SessionManager } from "@earendil-works/pi-coding-agent";
 import { Key, matchesKey, parseKey } from "@earendil-works/pi-tui";
 import { registerHandoffCommand } from "./handoff-command.js";
-import { registerHandoffTool, type PendingHandoff } from "./handoff-tool.js";
+import { type PendingHandoff, registerHandoffTool } from "./handoff-tool.js";
 import { parseAndSetModelWithNotify } from "./model-utils.js";
-import { registerHandoffModelFlag } from "./session-analysis.js";
+import { registerHandoffExtractionModelCommand, registerHandoffExtractionModelFlag } from "./session-analysis.js";
 import { registerSessionQueryTool } from "./session-query-tool.js";
 
 const STATUS_KEY = "handoff";
@@ -198,7 +197,8 @@ export default function (pi: ExtensionAPI) {
 
 	// --- Register tools and commands ---
 
-	registerHandoffModelFlag(pi);
+	registerHandoffExtractionModelFlag(pi);
+	registerHandoffExtractionModelCommand(pi);
 	registerSessionQueryTool(pi);
 	registerHandoffTool(pi, (h) => {
 		pendingHandoff = h;

packages/handoff/src/model-utils.ts 🔗

@@ -2,7 +2,12 @@
 //
 // SPDX-License-Identifier: MIT
 
-import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
+import {
+	type ExtensionAPI,
+	type ExtensionContext,
+	ModelSelectorComponent,
+	SettingsManager,
+} from "@earendil-works/pi-coding-agent";
 
 export type SetModelResult =
 	| { ok: true }
@@ -50,3 +55,28 @@ export async function parseAndSetModelWithNotify(
 	}
 	return result;
 }
+
+/**
+ * Show Pi's built-in model picker and return the user's selection without
+ * changing Pi's active model or default model setting.
+ */
+export async function pickModel(
+	ctx: ExtensionContext,
+	currentModel: ExtensionContext["model"] = ctx.model,
+	initialSearchInput?: string,
+): Promise<ExtensionContext["model"]> {
+	return ctx.ui.custom<ExtensionContext["model"]>((tui, _theme, _keybindings, done) => {
+		const selector = new ModelSelectorComponent(
+			tui,
+			currentModel,
+			SettingsManager.inMemory(),
+			ctx.modelRegistry,
+			[],
+			(model) => done(model),
+			() => done(undefined),
+			initialSearchInput,
+		);
+
+		return selector;
+	});
+}

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

@@ -3,31 +3,81 @@
 //
 // SPDX-License-Identifier: MIT
 
+import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import * as path from "node:path";
 import {
-	getAgentDir,
-	SettingsManager,
 	type ExtensionAPI,
+	type ExtensionCommandContext,
 	type ExtensionContext,
+	getAgentDir,
 	type SessionEntry,
+	SettingsManager,
 } from "@earendil-works/pi-coding-agent";
-import * as path from "node:path";
+import { pickModel } from "./model-utils.js";
 
 /**
  * Model override sources for handoff extraction calls. Use provider/modelId
  * values (e.g. "anthropic/claude-haiku-4-5"). Precedence is:
- * --handoff-model, PI_HANDOFF_MODEL, user settings handoffModel, session model.
+ * --handoff-extraction-model, PI_HANDOFF_EXTRACTION_MODEL, user settings
+ * handoffExtractionModel, session model.
  */
-const HANDOFF_MODEL_FLAG = "handoff-model";
-const HANDOFF_MODEL_ENV = "PI_HANDOFF_MODEL";
-const HANDOFF_MODEL_SETTING = "handoffModel";
+const HANDOFF_EXTRACTION_MODEL_FLAG = "handoff-extraction-model";
+const HANDOFF_EXTRACTION_MODEL_ENV = "PI_HANDOFF_EXTRACTION_MODEL";
+const HANDOFF_EXTRACTION_MODEL_SETTING = "handoffExtractionModel";
+
+type JsonValue = string | number | boolean | null | JsonObject | JsonValue[];
 
-export function registerHandoffModelFlag(pi: ExtensionAPI): void {
-	pi.registerFlag(HANDOFF_MODEL_FLAG, {
+interface JsonObject {
+	[key: string]: JsonValue;
+}
+
+export function registerHandoffExtractionModelFlag(pi: ExtensionAPI): void {
+	pi.registerFlag(HANDOFF_EXTRACTION_MODEL_FLAG, {
 		description: "Model for handoff extraction as provider/modelId",
 		type: "string",
 	});
 }
 
+export function registerHandoffExtractionModelCommand(pi: ExtensionAPI): void {
+	pi.registerCommand("handoff:set-extraction-model", {
+		description: "Choose the model used to extract handoff context",
+		handler: async (args: string, ctx: ExtensionCommandContext) => {
+			if (!ctx.hasUI || ctx.mode !== "tui") {
+				ctx.ui.notify("/handoff:set-extraction-model requires interactive mode", "error");
+				return;
+			}
+
+			const currentModel = configuredExtractionModel(pi, ctx);
+			const selectedModel = await pickModel(ctx, currentModel, nonEmptyString(args));
+			if (!selectedModel) {
+				ctx.ui.notify("Handoff extraction model unchanged", "info");
+				return;
+			}
+
+			const modelSpec = `${selectedModel.provider}/${selectedModel.id}`;
+			try {
+				setUserSettingsHandoffExtractionModel(modelSpec);
+			} catch (err) {
+				const message = err instanceof Error ? err.message : String(err);
+				ctx.ui.notify(`Failed to save handoff extraction model: ${message}`, "error");
+				return;
+			}
+
+			process.env[HANDOFF_EXTRACTION_MODEL_ENV] = modelSpec;
+
+			if (configuredFlagHandoffExtractionModel(pi)) {
+				ctx.ui.notify(
+					`Saved handoff extraction model: ${modelSpec}. The --${HANDOFF_EXTRACTION_MODEL_FLAG} flag still takes precedence for this run.`,
+					"warning",
+				);
+				return;
+			}
+
+			ctx.ui.notify(`Handoff extraction model: ${modelSpec}`, "info");
+		},
+	});
+}
+
 function nonEmptyString(value: string | undefined): string | undefined {
 	const trimmed = value?.trim();
 	return trimmed ? trimmed : undefined;
@@ -39,15 +89,46 @@ function stringProperty(source: object, key: string): string | undefined {
 	return typeof descriptor.value === "string" ? nonEmptyString(descriptor.value) : undefined;
 }
 
-function userSettingsHandoffModel(cwd: string): string | undefined {
+function userSettingsHandoffExtractionModel(cwd: string): string | undefined {
 	const settingsManager = SettingsManager.create(cwd, getAgentDir());
-	return stringProperty(settingsManager.getGlobalSettings(), HANDOFF_MODEL_SETTING);
+	return stringProperty(settingsManager.getGlobalSettings(), HANDOFF_EXTRACTION_MODEL_SETTING);
 }
 
-function configuredHandoffModel(pi: ExtensionAPI, ctx: { cwd: string }): string | undefined {
-	const flagValue = pi.getFlag(HANDOFF_MODEL_FLAG);
+function configuredFlagHandoffExtractionModel(pi: ExtensionAPI): string | undefined {
+	const flagValue = pi.getFlag(HANDOFF_EXTRACTION_MODEL_FLAG);
 	const flagModel = typeof flagValue === "string" ? nonEmptyString(flagValue) : undefined;
-	return flagModel ?? nonEmptyString(process.env[HANDOFF_MODEL_ENV]) ?? userSettingsHandoffModel(ctx.cwd);
+	return flagModel;
+}
+
+function configuredHandoffExtractionModel(pi: ExtensionAPI, ctx: { cwd: string }): string | undefined {
+	const flagModel = configuredFlagHandoffExtractionModel(pi);
+	return (
+		flagModel ??
+		nonEmptyString(process.env[HANDOFF_EXTRACTION_MODEL_ENV]) ??
+		userSettingsHandoffExtractionModel(ctx.cwd)
+	);
+}
+
+function parseJsonObject(content: string, source: string): JsonObject {
+	const parsed: JsonValue = JSON.parse(content);
+	if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
+		throw new Error(`${source} must contain a JSON object`);
+	}
+	return parsed;
+}
+
+function readSettingsObject(settingsPath: string): JsonObject {
+	if (!existsSync(settingsPath)) return {};
+	return parseJsonObject(readFileSync(settingsPath, "utf-8"), settingsPath);
+}
+
+function setUserSettingsHandoffExtractionModel(modelSpec: string): void {
+	const settingsPath = path.join(getAgentDir(), "settings.json");
+	const settings = readSettingsObject(settingsPath);
+	settings[HANDOFF_EXTRACTION_MODEL_SETTING] = modelSpec;
+
+	mkdirSync(path.dirname(settingsPath), { recursive: true });
+	writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf-8");
 }
 
 function parseModelSpec(spec: string): { provider: string; modelId: string } | undefined {
@@ -59,6 +140,20 @@ function parseModelSpec(spec: string): { provider: string; modelId: string } | u
 	};
 }
 
+export function configuredExtractionModel(
+	pi: ExtensionAPI,
+	ctx: {
+		cwd: string;
+		modelRegistry: ExtensionContext["modelRegistry"];
+	},
+): ExtensionContext["model"] {
+	const modelSpec = configuredHandoffExtractionModel(pi, ctx);
+	if (!modelSpec) return undefined;
+	const parsed = parseModelSpec(modelSpec);
+	if (!parsed) return undefined;
+	return ctx.modelRegistry.find(parsed.provider, parsed.modelId);
+}
+
 /**
  * Build a candidate file set from two sources:
  *   1. Primary: actual tool calls (read, write, edit, create) in the session
@@ -134,23 +229,3 @@ export function extractLoadedSkills(entries: SessionEntry[]): string[] {
 	}
 	return [...skills].sort();
 }
-
-/**
- * Resolve the model to use for handoff extraction calls. Uses --handoff-model,
- * PI_HANDOFF_MODEL, then handoffModel in user settings, falling back to the
- * session model if no valid override is configured.
- */
-export function resolveExtractionModel(
-	pi: ExtensionAPI,
-	ctx: {
-		cwd: string;
-		model: ExtensionContext["model"];
-		modelRegistry: ExtensionContext["modelRegistry"];
-	},
-): ExtensionContext["model"] {
-	const modelSpec = configuredHandoffModel(pi, ctx);
-	if (!modelSpec) return ctx.model;
-	const parsed = parseModelSpec(modelSpec);
-	if (!parsed) return ctx.model;
-	return ctx.modelRegistry.find(parsed.provider, parsed.modelId) ?? ctx.model;
-}