Detailed changes
@@ -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": "*",
@@ -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"
}
```
@@ -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": "*",
@@ -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...");
@@ -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 {
@@ -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;
@@ -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;
+ });
+}
@@ -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;
-}