1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2// SPDX-FileCopyrightText: Petr Baudis <pasky@ucw.cz>
3//
4// SPDX-License-Identifier: MIT
5
6import { complete, type Message, type Tool } from "@mariozechner/pi-ai";
7import type {
8 ExtensionAPI,
9 ExtensionCommandContext,
10 ExtensionContext,
11 SessionEntry,
12} from "@mariozechner/pi-coding-agent";
13import { BorderedLoader, SessionManager, convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent";
14import { Key, matchesKey } from "@mariozechner/pi-tui";
15import { Type } from "@sinclair/typebox";
16import * as fs from "node:fs";
17import * as os from "node:os";
18import * as path from "node:path";
19
20const STATUS_KEY = "handoff";
21const COUNTDOWN_SECONDS = 10;
22
23/**
24 * Model used for handoff extraction calls. Set PI_HANDOFF_MODEL env var
25 * as "provider/modelId" (e.g. "anthropic/claude-haiku-4-5") to use a
26 * different model for extraction than the session's current model.
27 */
28const HANDOFF_MODEL_OVERRIDE = process.env.PI_HANDOFF_MODEL;
29
30const 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.
31
32Consider these questions:
33- What was just done or implemented?
34- What decisions were made and why?
35- What technical details were discovered (APIs, methods, patterns)?
36- What constraints, limitations, or caveats were found?
37- What patterns or approaches are being followed?
38- What is still unfinished or unresolved?
39- What open questions or risks are worth flagging?
40
41Rules:
42- Be concrete: prefer file paths, specific decisions, and actual commands over vague summaries.
43- Only include files from the provided candidate list.
44- Only include skills from the provided loaded skills list.
45- If something wasn't explicitly discussed, don't include it.`;
46
47const QUERY_SYSTEM_PROMPT = `You answer questions about a prior pi session.
48
49Rules:
50- Use only facts from the provided conversation.
51- Prefer concrete outputs: file paths, decisions, TODOs, errors.
52- If not present, say explicitly: "Not found in provided session.".
53- Keep answer concise.`;
54
55/**
56 * Tool definition passed to the extraction LLM call. Models produce
57 * structured tool-call arguments far more reliably than free-form JSON
58 * in a text response, so we use a single tool instead of responseFormat.
59 */
60const HANDOFF_EXTRACTION_TOOL: Tool = {
61 name: "extract_handoff_context",
62 description: "Extract handoff context from the conversation for a new session.",
63 parameters: Type.Object({
64 relevantFiles: Type.Array(Type.String(), {
65 description: "File paths relevant to continuing this work, chosen from the provided candidate list",
66 }),
67 skillsInUse: Type.Array(Type.String(), {
68 description: "Skills relevant to the goal, chosen from the provided list of loaded skills",
69 }),
70 context: Type.String({
71 description:
72 "Key context: what was done, decisions made, technical details discovered, constraints, and patterns being followed",
73 }),
74 openItems: Type.Array(Type.String(), {
75 description: "Unfinished work, open questions, known risks, and things to watch out for",
76 }),
77 }),
78};
79
80type PendingAutoSubmit = {
81 ctx: ExtensionContext;
82 sessionFile: string | undefined;
83 interval: ReturnType<typeof setInterval>;
84 unsubscribeInput: () => void;
85};
86
87type PendingHandoff = {
88 prompt: string;
89 parentSession: string | undefined;
90 newModel: string | undefined;
91};
92
93function isEditableInput(data: string): boolean {
94 if (!data) return false;
95 if (data.length === 1) {
96 const code = data.charCodeAt(0);
97 if (code >= 32 && code !== 127) return true;
98 if (code === 8 || code === 13) return true;
99 }
100
101 if (data === "\n" || data === "\r") return true;
102 if (data === "\x7f") return true;
103
104 if (data.length > 1 && !data.startsWith("\x1b")) return true;
105
106 return false;
107}
108
109function statusLine(ctx: ExtensionContext, seconds: number): string {
110 const accent = ctx.ui.theme.fg("accent", `handoff auto-submit in ${seconds}s`);
111 const hint = ctx.ui.theme.fg("dim", "(type to edit, Esc to cancel)");
112 return `${accent} ${hint}`;
113}
114
115function getSessionsRoot(sessionFile: string | undefined): string | undefined {
116 if (!sessionFile) return undefined;
117 const normalized = sessionFile.replace(/\\/g, "/");
118 const marker = "/sessions/";
119 const idx = normalized.indexOf(marker);
120 if (idx === -1) {
121 return path.dirname(path.resolve(sessionFile));
122 }
123 return normalized.slice(0, idx + marker.length - 1);
124}
125
126function getFallbackSessionsRoot(): string | undefined {
127 const configuredDir = process.env.PI_CODING_AGENT_DIR;
128 const candidate = configuredDir
129 ? path.resolve(configuredDir, "sessions")
130 : path.resolve(os.homedir(), ".pi", "agent", "sessions");
131 return fs.existsSync(candidate) ? candidate : undefined;
132}
133
134function normalizeSessionPath(sessionPath: string, sessionsRoot: string | undefined): string {
135 if (path.isAbsolute(sessionPath)) return path.resolve(sessionPath);
136 if (sessionsRoot) return path.resolve(sessionsRoot, sessionPath);
137 return path.resolve(sessionPath);
138}
139
140function sessionPathAllowed(candidate: string, sessionsRoot: string | undefined): boolean {
141 if (!sessionsRoot) return true;
142 const root = path.resolve(sessionsRoot);
143 const resolved = path.resolve(candidate);
144 return resolved === root || resolved.startsWith(`${root}${path.sep}`);
145}
146
147/**
148 * Build a candidate file set from two sources:
149 * 1. Primary: actual tool calls (read, write, edit, create) in the session
150 * 2. Secondary: file-like patterns in the conversation text (catches files
151 * that were discussed but never opened)
152 */
153function extractCandidateFiles(entries: SessionEntry[], conversationText: string): Set<string> {
154 const files = new Set<string>();
155 const fileToolNames = new Set(["read", "write", "edit", "create"]);
156
157 // Primary: files from actual tool calls
158 for (const entry of entries) {
159 if (entry.type !== "message") continue;
160 const msg = entry.message;
161 if (msg.role !== "assistant") continue;
162
163 for (const block of msg.content) {
164 if (typeof block !== "object" || block === null || block.type !== "toolCall") continue;
165 if (!fileToolNames.has(block.name)) continue;
166
167 const args = block.arguments as Record<string, unknown>;
168 const filePath =
169 typeof args.path === "string" ? args.path : typeof args.file === "string" ? args.file : undefined;
170 if (!filePath) continue;
171 if (filePath.endsWith("/SKILL.md")) continue;
172
173 files.add(filePath);
174 }
175 }
176
177 // Secondary: file-like patterns from conversation text.
178 // Trailing lookahead so the boundary isn't consumed — otherwise adjacent
179 // files separated by a single space (e.g. "file1.txt file2.txt") get skipped.
180 const filePattern = /(?:^|\s)([a-zA-Z0-9._\-/]+\.[a-zA-Z0-9]+)(?=\s|$|[,;:)])/gm;
181 for (const match of conversationText.matchAll(filePattern)) {
182 const candidate = match[1];
183 if (candidate && !candidate.startsWith(".") && candidate.length > 2) {
184 files.add(candidate);
185 }
186 }
187
188 return files;
189}
190
191/**
192 * Extract skill names that were actually loaded during the conversation.
193 * Looks for read() tool calls targeting SKILL.md files and derives the
194 * skill name from the parent directory (the convention for pi skills).
195 */
196function extractLoadedSkills(entries: SessionEntry[]): string[] {
197 const skills = new Set<string>();
198 for (const entry of entries) {
199 if (entry.type !== "message") continue;
200 const msg = entry.message;
201 if (msg.role !== "assistant") continue;
202
203 for (const block of msg.content) {
204 if (typeof block !== "object" || block === null || block.type !== "toolCall") continue;
205
206 // read() calls where the path ends in SKILL.md
207 if (block.name !== "read") continue;
208 const args = block.arguments as Record<string, unknown>;
209 const filePath = typeof args.path === "string" ? args.path : undefined;
210 if (!filePath?.endsWith("/SKILL.md")) continue;
211
212 // Skill name is the parent directory name:
213 // .../skills/backing-up-with-keld/SKILL.md → backing-up-with-keld
214 const parent = path.basename(path.dirname(filePath));
215 if (parent && parent !== "skills") {
216 skills.add(parent);
217 }
218 }
219 }
220 return [...skills].sort();
221}
222
223/**
224 * Resolve the model to use for handoff extraction calls. Uses the
225 * PI_HANDOFF_MODEL env var if set, otherwise falls back to the session model.
226 */
227function resolveExtractionModel(ctx: {
228 model: ExtensionContext["model"];
229 modelRegistry: ExtensionContext["modelRegistry"];
230}): ExtensionContext["model"] {
231 if (!HANDOFF_MODEL_OVERRIDE) return ctx.model;
232 const slashIdx = HANDOFF_MODEL_OVERRIDE.indexOf("/");
233 if (slashIdx <= 0) return ctx.model;
234 const provider = HANDOFF_MODEL_OVERRIDE.slice(0, slashIdx);
235 const modelId = HANDOFF_MODEL_OVERRIDE.slice(slashIdx + 1);
236 return ctx.modelRegistry.find(provider, modelId) ?? ctx.model;
237}
238
239type HandoffExtraction = {
240 files: string[];
241 skills: string[];
242 context: string;
243 openItems: string[];
244};
245
246/**
247 * Run the extraction LLM call and return structured context, or null if
248 * aborted or the model didn't produce a valid tool call.
249 */
250async function extractHandoffContext(
251 model: NonNullable<ExtensionContext["model"]>,
252 apiKey: string | undefined,
253 headers: Record<string, string> | undefined,
254 conversationText: string,
255 goal: string,
256 candidateFiles: string[],
257 loadedSkills: string[],
258 signal?: AbortSignal,
259): Promise<HandoffExtraction | null> {
260 const filesContext =
261 candidateFiles.length > 0
262 ? `\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")}`
263 : "";
264
265 const skillsContext =
266 loadedSkills.length > 0
267 ? `\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.`
268 : "";
269
270 const userMessage: Message = {
271 role: "user",
272 content: [
273 {
274 type: "text",
275 text: `## Conversation\n\n${conversationText}\n\n## Goal for Next Session\n\n${goal}${filesContext}${skillsContext}`,
276 },
277 ],
278 timestamp: Date.now(),
279 };
280
281 const response = await complete(
282 model,
283 {
284 systemPrompt: SYSTEM_PROMPT,
285 messages: [userMessage],
286 tools: [HANDOFF_EXTRACTION_TOOL],
287 },
288 { apiKey, headers, signal },
289 );
290
291 if (response.stopReason === "aborted") return null;
292
293 const toolCall = response.content.find((c) => c.type === "toolCall" && c.name === "extract_handoff_context");
294
295 if (!toolCall || toolCall.type !== "toolCall") {
296 console.error("Model did not call extract_handoff_context:", response.content);
297 return null;
298 }
299
300 const args = toolCall.arguments as Record<string, unknown>;
301
302 if (
303 !Array.isArray(args.relevantFiles) ||
304 !Array.isArray(args.skillsInUse) ||
305 typeof args.context !== "string" ||
306 !Array.isArray(args.openItems)
307 ) {
308 console.error("Unexpected tool call arguments shape:", args);
309 return null;
310 }
311
312 const candidateSet = new Set(candidateFiles);
313 const loadedSkillSet = new Set(loadedSkills);
314
315 return {
316 files: (args.relevantFiles as string[]).filter((f) => typeof f === "string" && candidateSet.has(f)),
317 skills: (args.skillsInUse as string[]).filter((s) => typeof s === "string" && loadedSkillSet.has(s)),
318 context: args.context as string,
319 openItems: (args.openItems as string[]).filter((item) => typeof item === "string"),
320 };
321}
322
323/**
324 * Assemble the handoff draft. The user's goal goes last so it has the
325 * most weight in the new session (recency bias).
326 */
327function assembleHandoffDraft(result: HandoffExtraction, goal: string, parentSessionFile: string | undefined): string {
328 const parentBlock = parentSessionFile
329 ? `**Parent session:** \`${parentSessionFile}\`\n\nUse the \`session_query\` tool with this path if you need details from the prior thread.\n\n`
330 : "";
331
332 const filesSection = result.files.length > 0 ? `## Files\n\n${result.files.map((f) => `- ${f}`).join("\n")}\n\n` : "";
333
334 const skillsSection =
335 result.skills.length > 0 ? `## Skills in Use\n\n${result.skills.map((s) => `- ${s}`).join("\n")}\n\n` : "";
336
337 const contextSection = `## Context\n\n${result.context}\n\n`;
338
339 const openItemsSection =
340 result.openItems.length > 0 ? `## Open Items\n\n${result.openItems.map((item) => `- ${item}`).join("\n")}\n\n` : "";
341
342 return `${parentBlock}${filesSection}${skillsSection}${contextSection}${openItemsSection}${goal}`.trim();
343}
344
345export default function (pi: ExtensionAPI) {
346 let pending: PendingAutoSubmit | null = null;
347 let pendingHandoff: PendingHandoff | null = null;
348 let handoffTimestamp: number | null = null;
349
350 const clearPending = (ctx?: ExtensionContext, notify?: string) => {
351 if (!pending) return;
352
353 clearInterval(pending.interval);
354 pending.unsubscribeInput();
355 pending.ctx.ui.setStatus(STATUS_KEY, undefined);
356
357 const local = pending;
358 pending = null;
359
360 if (notify && ctx) {
361 ctx.ui.notify(notify, "info");
362 } else if (notify) {
363 local.ctx.ui.notify(notify, "info");
364 }
365 };
366
367 const autoSubmitDraft = () => {
368 if (!pending) return;
369
370 const active = pending;
371 const currentSession = active.ctx.sessionManager.getSessionFile();
372 if (active.sessionFile && currentSession !== active.sessionFile) {
373 clearPending(undefined);
374 return;
375 }
376
377 const draft = active.ctx.ui.getEditorText().trim();
378 clearPending(undefined);
379
380 if (!draft) {
381 active.ctx.ui.notify("Handoff draft is empty", "warning");
382 return;
383 }
384
385 active.ctx.ui.setEditorText("");
386
387 try {
388 if (active.ctx.isIdle()) {
389 pi.sendUserMessage(draft);
390 } else {
391 pi.sendUserMessage(draft, { deliverAs: "followUp" });
392 }
393 } catch {
394 pi.sendUserMessage(draft);
395 }
396 };
397
398 const startCountdown = (ctx: ExtensionContext) => {
399 clearPending(ctx);
400
401 let seconds = COUNTDOWN_SECONDS;
402 ctx.ui.setStatus(STATUS_KEY, statusLine(ctx, seconds));
403
404 const unsubscribeInput = ctx.ui.onTerminalInput((data) => {
405 if (matchesKey(data, Key.escape)) {
406 clearPending(ctx, "Handoff auto-submit cancelled");
407 return { consume: true };
408 }
409
410 if (isEditableInput(data)) {
411 clearPending(ctx, "Handoff auto-submit stopped (editing)");
412 }
413
414 return undefined;
415 });
416
417 const interval = setInterval(() => {
418 if (!pending) return;
419
420 seconds -= 1;
421 if (seconds <= 0) {
422 autoSubmitDraft();
423 return;
424 }
425
426 ctx.ui.setStatus(STATUS_KEY, statusLine(ctx, seconds));
427 }, 1000);
428
429 pending = {
430 ctx,
431 sessionFile: ctx.sessionManager.getSessionFile(),
432 interval,
433 unsubscribeInput,
434 };
435 };
436
437 pi.on("session_before_switch", (_event, ctx) => {
438 if (pending) clearPending(ctx);
439 });
440
441 pi.on("session_start", (event, ctx) => {
442 if (pending) clearPending(ctx);
443 // A proper session switch (e.g. /new) or fork fully resets agent state,
444 // so clear the context filter to avoid hiding new messages.
445 if (event.reason === "new" || event.reason === "resume" || event.reason === "fork") {
446 handoffTimestamp = null;
447 }
448 });
449
450 pi.on("session_before_fork", (_event, ctx) => {
451 if (pending) clearPending(ctx);
452 });
453
454 pi.on("session_before_tree", (_event, ctx) => {
455 if (pending) clearPending(ctx);
456 });
457
458 pi.on("session_tree", (_event, ctx) => {
459 if (pending) clearPending(ctx);
460 });
461
462 pi.on("session_shutdown", (_event, ctx) => {
463 if (pending) clearPending(ctx);
464 });
465
466 // --- Tool-path handoff coordination ---
467 //
468 // The /handoff command has ExtensionCommandContext with ctx.newSession()
469 // which does a full agent reset. The handoff tool only gets
470 // ExtensionContext, which lacks newSession(). So the tool stores a
471 // pending handoff and these handlers complete it:
472 //
473 // 1. agent_end: after the agent loop finishes, switch sessions and
474 // send the handoff prompt in the next macrotask
475 // 2. context: filter pre-handoff messages since the low-level session
476 // switch doesn't clear agent.state.messages
477 // 3. session_start (above): clears the filter on proper switches
478
479 pi.on("agent_end", (_event, ctx) => {
480 if (!pendingHandoff) return;
481
482 const { prompt, parentSession, newModel } = pendingHandoff;
483 pendingHandoff = null;
484
485 // Create new session first — this triggers session_start which clears
486 // any stale handoffTimestamp. Then set the timestamp for the current
487 // handoff so the context filter is active for the new session's first turn.
488 (ctx.sessionManager as SessionManager).newSession({ parentSession });
489 handoffTimestamp = Date.now();
490
491 setTimeout(async () => {
492 if (newModel) {
493 const slashIdx = newModel.indexOf("/");
494 if (slashIdx > 0) {
495 const provider = newModel.slice(0, slashIdx);
496 const modelId = newModel.slice(slashIdx + 1);
497 const model = ctx.modelRegistry.find(provider, modelId);
498 if (model) await pi.setModel(model);
499 }
500 }
501 pi.sendUserMessage(prompt);
502 }, 0);
503 });
504
505 pi.on("context", (event, _ctx) => {
506 if (handoffTimestamp === null) return;
507
508 const cutoff = handoffTimestamp;
509 const newMessages = event.messages.filter((m) => m.timestamp >= cutoff);
510 if (newMessages.length > 0) {
511 return { messages: newMessages };
512 }
513 });
514
515 pi.registerTool({
516 name: "session_query",
517 label: "Session Query",
518 description:
519 "Query a prior pi session file. Use when handoff prompt references a parent session and you need details.",
520 parameters: Type.Object({
521 sessionPath: Type.String({
522 description:
523 "Session .jsonl path. Absolute path, or relative to sessions root (e.g. 2026-02-16/foo/session.jsonl)",
524 }),
525 question: Type.String({ description: "Question about that session" }),
526 }),
527 async execute(_toolCallId, params, signal, onUpdate, ctx) {
528 const currentSessionFile = ctx.sessionManager.getSessionFile();
529 const sessionsRoot = getSessionsRoot(currentSessionFile) ?? getFallbackSessionsRoot();
530 const resolvedPath = normalizeSessionPath(params.sessionPath, sessionsRoot);
531
532 const error = (text: string) => ({
533 content: [{ type: "text" as const, text }],
534 details: { error: true } as const,
535 });
536
537 const cancelled = () => ({
538 content: [{ type: "text" as const, text: "Session query cancelled." }],
539 details: { cancelled: true } as const,
540 });
541
542 if (!signal || signal.aborted) {
543 return cancelled();
544 }
545
546 if (!resolvedPath.endsWith(".jsonl")) {
547 return error(`Invalid session path (expected .jsonl): ${params.sessionPath}`);
548 }
549
550 if (!sessionPathAllowed(resolvedPath, sessionsRoot)) {
551 return error(`Session path outside allowed sessions directory: ${params.sessionPath}`);
552 }
553
554 if (!fs.existsSync(resolvedPath)) {
555 return error(`Session file not found: ${resolvedPath}`);
556 }
557
558 let fileStats: fs.Stats;
559 try {
560 fileStats = fs.statSync(resolvedPath);
561 } catch (err) {
562 return error(`Failed to stat session file: ${String(err)}`);
563 }
564
565 if (!fileStats.isFile()) {
566 return error(`Session path is not a file: ${resolvedPath}`);
567 }
568
569 onUpdate?.({
570 content: [{ type: "text", text: `Querying: ${resolvedPath}` }],
571 details: { status: "loading", sessionPath: resolvedPath },
572 });
573
574 let sessionManager: SessionManager;
575 try {
576 sessionManager = SessionManager.open(resolvedPath);
577 } catch (err) {
578 return error(`Failed to open session: ${String(err)}`);
579 }
580
581 const branch = sessionManager.getBranch();
582 const messages = branch
583 .filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message")
584 .map((entry) => entry.message);
585
586 if (messages.length === 0) {
587 return {
588 content: [{ type: "text" as const, text: "Session has no messages." }],
589 details: { empty: true, sessionPath: resolvedPath },
590 };
591 }
592
593 if (!ctx.model) {
594 return error("No model selected for session query.");
595 }
596
597 const conversationText = serializeConversation(convertToLlm(messages));
598 try {
599 const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model);
600 if (!auth.ok) {
601 return error(`Failed to get API key: ${auth.error}`);
602 }
603 const userMessage: Message = {
604 role: "user",
605 content: [
606 {
607 type: "text",
608 text: `## Session\n\n${conversationText}\n\n## Question\n\n${params.question}`,
609 },
610 ],
611 timestamp: Date.now(),
612 };
613
614 const response = await complete(
615 ctx.model,
616 { systemPrompt: QUERY_SYSTEM_PROMPT, messages: [userMessage] },
617 { apiKey: auth.apiKey, headers: auth.headers, signal: signal as AbortSignal },
618 );
619
620 if (response.stopReason === "aborted") {
621 return cancelled();
622 }
623
624 const answer = response.content
625 .filter((c): c is { type: "text"; text: string } => c.type === "text")
626 .map((c) => c.text)
627 .join("\n")
628 .trim();
629
630 return {
631 content: [{ type: "text" as const, text: answer || "No answer generated." }],
632 details: {
633 sessionPath: resolvedPath,
634 question: params.question,
635 messageCount: messages.length,
636 },
637 };
638 } catch (err) {
639 if (signal?.aborted) {
640 return cancelled();
641 }
642 if (err instanceof Error && err.name === "AbortError") {
643 return cancelled();
644 }
645 return error(`Session query failed: ${String(err)}`);
646 }
647 },
648 });
649
650 pi.registerTool({
651 name: "handoff",
652 label: "Handoff",
653 description:
654 "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.",
655 parameters: Type.Object({
656 goal: Type.String({
657 description: "The goal/task for the new session",
658 }),
659 model: Type.Optional(
660 Type.String({
661 description: "Model for the new session as provider/modelId (e.g. 'anthropic/claude-haiku-4-5')",
662 }),
663 ),
664 }),
665 async execute(_toolCallId, params, signal, _onUpdate, ctx) {
666 if (!ctx.model) {
667 return {
668 content: [{ type: "text" as const, text: "No model selected." }],
669 details: undefined,
670 };
671 }
672
673 const branch = ctx.sessionManager.getBranch();
674 const messages = branch
675 .filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message")
676 .map((entry) => entry.message);
677
678 if (messages.length === 0) {
679 return {
680 content: [
681 {
682 type: "text" as const,
683 text: "No conversation to hand off.",
684 },
685 ],
686 details: undefined,
687 };
688 }
689
690 const llmMessages = convertToLlm(messages);
691 const conversationText = serializeConversation(llmMessages);
692 const candidateFiles = [...extractCandidateFiles(branch, conversationText)];
693 const loadedSkills = extractLoadedSkills(branch);
694
695 const extractionModel = resolveExtractionModel(ctx) ?? ctx.model;
696 const auth = await ctx.modelRegistry.getApiKeyAndHeaders(extractionModel);
697 if (!auth.ok) {
698 return {
699 content: [
700 {
701 type: "text" as const,
702 text: `Failed to get API key: ${auth.error}`,
703 },
704 ],
705 details: undefined,
706 };
707 }
708
709 let result: HandoffExtraction | null;
710 try {
711 result = await extractHandoffContext(
712 extractionModel,
713 auth.apiKey,
714 auth.headers,
715 conversationText,
716 params.goal,
717 candidateFiles,
718 loadedSkills,
719 signal,
720 );
721 } catch (err) {
722 if (signal?.aborted) {
723 return {
724 content: [
725 {
726 type: "text" as const,
727 text: "Handoff cancelled.",
728 },
729 ],
730 details: undefined,
731 };
732 }
733 return {
734 content: [
735 {
736 type: "text" as const,
737 text: `Handoff extraction failed: ${String(err)}`,
738 },
739 ],
740 details: undefined,
741 };
742 }
743
744 if (!result) {
745 return {
746 content: [
747 {
748 type: "text" as const,
749 text: "Handoff extraction failed or was cancelled.",
750 },
751 ],
752 details: undefined,
753 };
754 }
755
756 const currentSessionFile = ctx.sessionManager.getSessionFile();
757 const prompt = assembleHandoffDraft(result, params.goal, currentSessionFile);
758
759 pendingHandoff = {
760 prompt,
761 parentSession: currentSessionFile,
762 newModel: params.model,
763 };
764
765 return {
766 content: [
767 {
768 type: "text" as const,
769 text: "Handoff prepared. The session will switch after this turn completes.",
770 },
771 ],
772 details: undefined,
773 };
774 },
775 });
776
777 pi.registerCommand("handoff", {
778 description: "Transfer context to a new session (-model provider/modelId)",
779 handler: async (args: string, ctx: ExtensionCommandContext) => {
780 if (!ctx.hasUI) {
781 ctx.ui.notify("/handoff requires interactive mode", "error");
782 return;
783 }
784
785 if (!ctx.model) {
786 ctx.ui.notify("No model selected", "error");
787 return;
788 }
789
790 // Parse optional -model flag from args
791 let remaining = args;
792 let newSessionModel: string | undefined;
793
794 const modelMatch = remaining.match(/(?:^|\s)-model\s+(\S+)/);
795 if (modelMatch) {
796 newSessionModel = modelMatch[1];
797 remaining = remaining.replace(modelMatch[0], " ");
798 }
799
800 let goal = remaining.trim();
801 if (!goal) {
802 const entered = await ctx.ui.input("handoff goal", "What should the new thread do?");
803 if (!entered?.trim()) {
804 ctx.ui.notify("Handoff cancelled", "info");
805 return;
806 }
807 goal = entered.trim();
808 }
809
810 const branch = ctx.sessionManager.getBranch();
811 const messages = branch
812 .filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message")
813 .map((entry) => entry.message);
814
815 if (messages.length === 0) {
816 ctx.ui.notify("No conversation to hand off", "warning");
817 return;
818 }
819
820 const llmMessages = convertToLlm(messages);
821 const conversationText = serializeConversation(llmMessages);
822 const currentSessionFile = ctx.sessionManager.getSessionFile();
823 const candidateFiles = [...extractCandidateFiles(branch, conversationText)];
824 const loadedSkills = extractLoadedSkills(branch);
825 const extractionModel = resolveExtractionModel(ctx) ?? ctx.model;
826
827 const result = await ctx.ui.custom<HandoffExtraction | null>((tui, theme, _kb, done) => {
828 const loader = new BorderedLoader(tui, theme, "Extracting handoff context...");
829 loader.onAbort = () => done(null);
830
831 const run = async () => {
832 const auth = await ctx.modelRegistry.getApiKeyAndHeaders(extractionModel);
833 if (!auth.ok) {
834 throw new Error(`Failed to get API key: ${auth.error}`);
835 }
836
837 return extractHandoffContext(
838 extractionModel,
839 auth.apiKey,
840 auth.headers,
841 conversationText,
842 goal,
843 candidateFiles,
844 loadedSkills,
845 loader.signal,
846 );
847 };
848
849 run()
850 .then(done)
851 .catch((err) => {
852 console.error("handoff generation failed", err);
853 done(null);
854 });
855
856 return loader;
857 });
858
859 if (!result) {
860 ctx.ui.notify("Handoff cancelled", "info");
861 return;
862 }
863
864 const prefillDraft = assembleHandoffDraft(result, goal, currentSessionFile);
865
866 const editedPrompt = await ctx.ui.editor("Edit handoff draft", prefillDraft);
867 if (editedPrompt === undefined) {
868 ctx.ui.notify("Handoff cancelled", "info");
869 return;
870 }
871
872 const next = await ctx.newSession({
873 parentSession: currentSessionFile ?? undefined,
874 });
875
876 if (next.cancelled) {
877 ctx.ui.notify("New session cancelled", "info");
878 return;
879 }
880
881 // Apply -model if specified
882 if (newSessionModel) {
883 const slashIdx = newSessionModel.indexOf("/");
884 if (slashIdx > 0) {
885 const provider = newSessionModel.slice(0, slashIdx);
886 const modelId = newSessionModel.slice(slashIdx + 1);
887 const model = ctx.modelRegistry.find(provider, modelId);
888 if (model) {
889 await pi.setModel(model);
890 } else {
891 ctx.ui.notify(`Unknown model: ${newSessionModel}`, "warning");
892 }
893 } else {
894 ctx.ui.notify(`Invalid model format "${newSessionModel}", expected provider/modelId`, "warning");
895 }
896 }
897
898 const newSessionFile = ctx.sessionManager.getSessionFile();
899 ctx.ui.notify(`Switched to new session: ${newSessionFile ?? "(unnamed)"}`, "info");
900
901 ctx.ui.setEditorText(editedPrompt);
902 startCountdown(ctx);
903 },
904 });
905}