handoff: Align types with pi SDK 0.65.0

Amolith created

Fix ~15 type errors and 3 noExplicitAny lint warnings caused by SDK API
drift in the handoff extension.

- Replace session_switch/session_fork events with session_start (the
old events no longer exist in the SDK); use event.reason to distinguish
switch, resume, and fork
- Cast sessionManager to SessionManager instead of any for newSession()
- Type the context event handler properly instead of casting to any
- Change label from dynamic function to static string
- Add details property to all AgentToolResult returns
- Guard signal-possibly-undefined with optional chaining
- Accept string | undefined for apiKey in extractHandoffContext
- Handle getSessionFile() returning undefined in command handler
- Fix race condition: call newSession() before setting
handoffTimestamp so session_start clears stale values first

Change summary

packages/handoff/src/index.ts | 53 ++++++++++++++++++++----------------
1 file changed, 30 insertions(+), 23 deletions(-)

Detailed changes

packages/handoff/src/index.ts 🔗

@@ -249,7 +249,7 @@ type HandoffExtraction = {
  */
 async function extractHandoffContext(
 	model: NonNullable<ExtensionContext["model"]>,
-	apiKey: string,
+	apiKey: string | undefined,
 	headers: Record<string, string> | undefined,
 	conversationText: string,
 	goal: string,
@@ -438,21 +438,19 @@ export default function (pi: ExtensionAPI) {
 		if (pending) clearPending(ctx);
 	});
 
-	pi.on("session_switch", (_event, ctx) => {
+	pi.on("session_start", (event, ctx) => {
 		if (pending) clearPending(ctx);
-		// A proper session switch (e.g. /new) fully resets agent state,
+		// A proper session switch (e.g. /new) or fork fully resets agent state,
 		// so clear the context filter to avoid hiding new messages.
-		handoffTimestamp = null;
+		if (event.reason === "new" || event.reason === "resume" || event.reason === "fork") {
+			handoffTimestamp = null;
+		}
 	});
 
 	pi.on("session_before_fork", (_event, ctx) => {
 		if (pending) clearPending(ctx);
 	});
 
-	pi.on("session_fork", (_event, ctx) => {
-		if (pending) clearPending(ctx);
-	});
-
 	pi.on("session_before_tree", (_event, ctx) => {
 		if (pending) clearPending(ctx);
 	});
@@ -476,7 +474,7 @@ export default function (pi: ExtensionAPI) {
 	//    send the handoff prompt in the next macrotask
 	// 2. context: filter pre-handoff messages since the low-level session
 	//    switch doesn't clear agent.state.messages
-	// 3. session_switch (above): clears the filter on proper switches
+	// 3. session_start (above): clears the filter on proper switches
 
 	pi.on("agent_end", (_event, ctx) => {
 		if (!pendingHandoff) return;
@@ -484,8 +482,11 @@ export default function (pi: ExtensionAPI) {
 		const { prompt, parentSession, newModel } = pendingHandoff;
 		pendingHandoff = null;
 
+		// Create new session first — this triggers session_start which clears
+		// any stale handoffTimestamp. Then set the timestamp for the current
+		// handoff so the context filter is active for the new session's first turn.
+		(ctx.sessionManager as SessionManager).newSession({ parentSession });
 		handoffTimestamp = Date.now();
-		(ctx.sessionManager as any).newSession({ parentSession });
 
 		setTimeout(async () => {
 			if (newModel) {
@@ -501,10 +502,11 @@ export default function (pi: ExtensionAPI) {
 		}, 0);
 	});
 
-	pi.on("context", (event) => {
+	pi.on("context", (event, _ctx) => {
 		if (handoffTimestamp === null) return;
 
-		const newMessages = (event as any).messages.filter((m: any) => m.timestamp >= handoffTimestamp);
+		const cutoff = handoffTimestamp;
+		const newMessages = event.messages.filter((m) => m.timestamp >= cutoff);
 		if (newMessages.length > 0) {
 			return { messages: newMessages };
 		}
@@ -512,7 +514,7 @@ export default function (pi: ExtensionAPI) {
 
 	pi.registerTool({
 		name: "session_query",
-		label: (params) => `Session Query: ${params.question}`,
+		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({
@@ -529,15 +531,15 @@ export default function (pi: ExtensionAPI) {
 
 			const error = (text: string) => ({
 				content: [{ type: "text" as const, text }],
-				details: { error: true },
+				details: { error: true } as const,
 			});
 
 			const cancelled = () => ({
 				content: [{ type: "text" as const, text: "Session query cancelled." }],
-				details: { cancelled: true },
+				details: { cancelled: true } as const,
 			});
 
-			if (signal.aborted) {
+			if (!signal || signal.aborted) {
 				return cancelled();
 			}
 
@@ -612,7 +614,7 @@ export default function (pi: ExtensionAPI) {
 				const response = await complete(
 					ctx.model,
 					{ systemPrompt: QUERY_SYSTEM_PROMPT, messages: [userMessage] },
-					{ apiKey: auth.apiKey, headers: auth.headers, signal },
+					{ apiKey: auth.apiKey, headers: auth.headers, signal: signal as AbortSignal },
 				);
 
 				if (response.stopReason === "aborted") {
@@ -634,7 +636,7 @@ export default function (pi: ExtensionAPI) {
 					},
 				};
 			} catch (err) {
-				if (signal.aborted) {
+				if (signal?.aborted) {
 					return cancelled();
 				}
 				if (err instanceof Error && err.name === "AbortError") {
@@ -664,6 +666,7 @@ export default function (pi: ExtensionAPI) {
 			if (!ctx.model) {
 				return {
 					content: [{ type: "text" as const, text: "No model selected." }],
+					details: undefined,
 				};
 			}
 
@@ -680,6 +683,7 @@ export default function (pi: ExtensionAPI) {
 							text: "No conversation to hand off.",
 						},
 					],
+					details: undefined,
 				};
 			}
 
@@ -698,6 +702,7 @@ export default function (pi: ExtensionAPI) {
 							text: `Failed to get API key: ${auth.error}`,
 						},
 					],
+					details: undefined,
 				};
 			}
 
@@ -714,7 +719,7 @@ export default function (pi: ExtensionAPI) {
 					signal,
 				);
 			} catch (err) {
-				if (signal.aborted) {
+				if (signal?.aborted) {
 					return {
 						content: [
 							{
@@ -722,6 +727,7 @@ export default function (pi: ExtensionAPI) {
 								text: "Handoff cancelled.",
 							},
 						],
+						details: undefined,
 					};
 				}
 				return {
@@ -731,6 +737,7 @@ export default function (pi: ExtensionAPI) {
 							text: `Handoff extraction failed: ${String(err)}`,
 						},
 					],
+					details: undefined,
 				};
 			}
 
@@ -742,6 +749,7 @@ export default function (pi: ExtensionAPI) {
 							text: "Handoff extraction failed or was cancelled.",
 						},
 					],
+					details: undefined,
 				};
 			}
 
@@ -761,6 +769,7 @@ export default function (pi: ExtensionAPI) {
 						text: "Handoff prepared. The session will switch after this turn completes.",
 					},
 				],
+				details: undefined,
 			};
 		},
 	});
@@ -861,7 +870,7 @@ export default function (pi: ExtensionAPI) {
 			}
 
 			const next = await ctx.newSession({
-				parentSession: currentSessionFile,
+				parentSession: currentSessionFile ?? undefined,
 			});
 
 			if (next.cancelled) {
@@ -887,9 +896,7 @@ export default function (pi: ExtensionAPI) {
 			}
 
 			const newSessionFile = ctx.sessionManager.getSessionFile();
-			if (newSessionFile) {
-				ctx.ui.notify(`Switched to new session: ${newSessionFile}`, "info");
-			}
+			ctx.ui.notify(`Switched to new session: ${newSessionFile ?? "(unnamed)"}`, "info");
 
 			ctx.ui.setEditorText(editedPrompt);
 			startCountdown(ctx);