/ extensions / handover / index.ts
index.ts
  1  import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
  2  import * as fs from "node:fs";
  3  import os from "node:os";
  4  import path from "node:path";
  5  import * as readline from "node:readline";
  6  import { createHash } from "node:crypto";
  7  import { fileURLToPath } from "node:url";
  8  
  9  import type {
 10      ExtensionAPI,
 11      ExtensionCommandContext,
 12      ExtensionContext,
 13      SessionEntry,
 14  } from "@mariozechner/pi-coding-agent";
 15  import { Key, matchesKey } from "@mariozechner/pi-tui";
 16  
 17  import { collectFilesTouched, type FilesTouchedEntry } from "../_shared/files-touched-core.ts";
 18  
 19  const STATUS_KEY = "handover";
 20  const DEFAULT_AUTO_SUBMIT_SECONDS = 10;
 21  const HANDOVER_TITLE = "Handover message";
 22  const PENDING_HANDOVER_DIR = path.join(os.tmpdir(), "pi-handover-pending");
 23  
 24  const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url));
 25  const CONFIG_PATH = path.join(EXTENSION_DIR, "config.json");
 26  
 27  // Optional override (user-editable) to avoid touching the .ts file
 28  const PROMPT_OVERRIDE_PATH = path.join(EXTENSION_DIR, "prompt.md");
 29  
 30  const DEFAULT_STYLE_GUIDE = `
 31  # What to include
 32  
 33  Use these section headings exactly. Omit a section only if it is truly empty. Prefer bullets under each heading.
 34  
 35  ## Brief
 36  Current objective, how it evolved from the original request, current state, immediate next action.
 37  
 38  ## Constraints & preferences
 39  Requirements, preferences, or constraints stated by the user that must be respected.
 40  
 41  ## Key decisions & rejected paths
 42  Decisions made with brief rationale, including approaches tried and ruled out. Equally important: what was abandoned, what failed, and why those paths were closed.
 43  
 44  ## Unexpected findings
 45  What contradicted expectations about the codebase, task, or dependencies. Gotchas and edge cases discovered. What is believed but with low confidence. Distinguish observed facts from inferences.
 46  
 47  ## Status
 48  What is verified-done, what is implemented but unverified, what is in progress, what is blocked. Check the last several user messages for unresolved requests before marking anything done.
 49  
 50  ## Continuation logistics
 51  - Mandatory reading: exact file paths the next agent should open first.
 52  - Environment context, where applicable: ports, env vars, services, active deployments.
 53  - Pending human decisions or approvals.
 54  
 55  Rehydration targets (optional)
 56  If applicable: topics where the needed level of detail depends on unresolved questions. Note what would trigger the need to rehydrate from the parent session.
 57  
 58  ## Next steps
 59  Concrete next actions in execution order. Note dependencies between steps.
 60  
 61  # Style
 62  - The new session starts with near-zero context; make the summary self-contained and high-density
 63  - Preserve exact file paths, symbol names, commands, and error text where useful
 64  - Output only markdown for the summary
 65  `;
 66  
 67  type ExtensionConfig = {
 68      autoSubmitSeconds: number;
 69  };
 70  
 71  type PendingAutoSubmit = {
 72      ctx: ExtensionContext;
 73      sessionFile: string;
 74      interval: ReturnType<typeof setInterval>;
 75      unsubscribeInput: () => void;
 76  };
 77  
 78  type PendingHandoverDraft = {
 79      previousSessionFile: string;
 80      draft: string;
 81      autoSubmitSeconds: number;
 82  };
 83  
 84  type SessionRecord = {
 85      entryIndex: number;
 86      type: string;
 87      timestamp?: string;
 88      summary?: string;
 89      tokensBefore?: number;
 90  };
 91  
 92  function truncateText(text: string, maxChars: number): string {
 93      const normalized = text ?? "";
 94      if (normalized.length <= maxChars) {
 95          return normalized;
 96      }
 97  
 98      return normalized.slice(0, maxChars) + `... (${normalized.length - maxChars} more chars)`;
 99  }
100  
101  function extractTextFromContent(content: unknown): string {
102      if (typeof content === "string") {
103          return content.trim();
104      }
105  
106      if (!Array.isArray(content)) {
107          return "";
108      }
109  
110      // Content parts can vary by provider/runtime. Prefer any part that exposes a
111      // string `text` field (common for both `type: "text"` and `type: "output_text"`).
112      return content
113          .map((part) => {
114              if (!part || typeof part !== "object") {
115                  return "";
116              }
117  
118              return typeof (part as any).text === "string" ? (part as any).text : "";
119          })
120          .filter(Boolean)
121          .join("\n")
122          .trim();
123  }
124  
125  function isEditableInput(data: string): boolean {
126      if (!data) {
127          return false;
128      }
129  
130      if (data.length === 1) {
131          const charCode = data.charCodeAt(0);
132          if (charCode >= 32 && charCode !== 127) {
133              return true;
134          }
135  
136          if (charCode === 8 || charCode === 13) {
137              return true;
138          }
139      }
140  
141      if (data === "\n" || data === "\r" || data === "\x7f") {
142          return true;
143      }
144  
145      if (data.length > 1 && !data.startsWith("\x1b")) {
146          return true;
147      }
148  
149      return false;
150  }
151  
152  function getStatusLine(ctx: ExtensionContext, seconds: number): string {
153      const accent = ctx.ui.theme.fg("accent", `handover auto-submit in ${seconds}s`);
154      const hint = ctx.ui.theme.fg("dim", "(type or Esc to cancel)");
155      return `${accent} ${hint}`;
156  }
157  
158  async function loadConfig(): Promise<ExtensionConfig> {
159      const fallback: ExtensionConfig = { autoSubmitSeconds: DEFAULT_AUTO_SUBMIT_SECONDS };
160  
161      try {
162          const raw = await readFile(CONFIG_PATH, "utf8");
163          const parsed = JSON.parse(raw) as Partial<ExtensionConfig>;
164          const rawSeconds = parsed.autoSubmitSeconds;
165  
166          if (typeof rawSeconds !== "number" || Number.isNaN(rawSeconds)) {
167              return fallback;
168          }
169  
170          return {
171              autoSubmitSeconds: Math.max(0, Math.min(300, Math.floor(rawSeconds))),
172          };
173      } catch {
174          return fallback;
175      }
176  }
177  
178  async function loadCompactionRecords(sessionPath: string): Promise<SessionRecord[]> {
179      const records: SessionRecord[] = [];
180  
181      const stream = fs.createReadStream(sessionPath, { encoding: "utf8" });
182      const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
183  
184      const maxCompactionRecords = 20;
185  
186      let entryIndex = 0;
187      for await (const line of rl) {
188          const trimmed = line.trim();
189          if (!trimmed) {
190              continue;
191          }
192  
193          let parsed: any;
194          try {
195              parsed = JSON.parse(trimmed);
196          } catch {
197              continue;
198          }
199  
200          entryIndex += 1;
201  
202          const recordType = typeof parsed?.type === "string" ? parsed.type : "unknown";
203          if (recordType !== "compaction") {
204              continue;
205          }
206  
207          records.push({
208              entryIndex,
209              type: recordType,
210              timestamp: typeof parsed?.timestamp === "string" ? parsed.timestamp : undefined,
211              summary: typeof parsed?.summary === "string" ? parsed.summary : undefined,
212              tokensBefore: typeof parsed?.tokensBefore === "number" ? parsed.tokensBefore : undefined,
213          });
214  
215          if (records.length > maxCompactionRecords) {
216              records.shift();
217          }
218      }
219  
220      return records;
221  }
222  
223  function getPendingHandoverPath(previousSessionFile: string): string {
224      const hash = createHash("sha256").update(previousSessionFile).digest("hex");
225      return path.join(PENDING_HANDOVER_DIR, `${hash}.json`);
226  }
227  
228  async function writePendingHandoverDraft(payload: PendingHandoverDraft): Promise<void> {
229      await mkdir(PENDING_HANDOVER_DIR, { recursive: true });
230      await writeFile(getPendingHandoverPath(payload.previousSessionFile), JSON.stringify(payload), "utf8");
231  }
232  
233  async function consumePendingHandoverDraft(previousSessionFile: string): Promise<PendingHandoverDraft | null> {
234      const pendingPath = getPendingHandoverPath(previousSessionFile);
235  
236      try {
237          const raw = await readFile(pendingPath, "utf8");
238          await unlink(pendingPath).catch(() => {
239              // ignore
240          });
241  
242          const parsed = JSON.parse(raw) as Partial<PendingHandoverDraft>;
243          if (
244              parsed.previousSessionFile !== previousSessionFile
245              || typeof parsed.draft !== "string"
246              || typeof parsed.autoSubmitSeconds !== "number"
247          ) {
248              return null;
249          }
250  
251          return {
252              previousSessionFile,
253              draft: parsed.draft,
254              autoSubmitSeconds: parsed.autoSubmitSeconds,
255          };
256      } catch {
257          return null;
258      }
259  }
260  
261  async function clearPendingHandoverDraft(previousSessionFile: string): Promise<void> {
262      try {
263          await unlink(getPendingHandoverPath(previousSessionFile));
264      } catch {
265          // ignore
266      }
267  }
268  
269  async function buildPriorCompactionsAddendum(ctx: ExtensionCommandContext): Promise<string> {
270      const sessionPath = ctx.sessionManager.getSessionFile();
271      if (!sessionPath || !sessionPath.endsWith(".jsonl") || !fs.existsSync(sessionPath)) {
272          return "";
273      }
274  
275      try {
276          const compactions = await loadCompactionRecords(sessionPath);
277  
278          // Drop the most recent compaction: the current model likely already has it in view
279          const prior = compactions.slice(0, Math.max(0, compactions.length - 1));
280          if (prior.length === 0) {
281              return "";
282          }
283  
284          const maxPerSummaryChars = 4000;
285          const maxTotalChars = 12000;
286  
287          const lines: string[] = [];
288          lines.push("## Prior compaction summaries (verbatim)");
289          lines.push("");
290  
291          let used = 0;
292          for (let i = prior.length - 1; i >= 0; i -= 1) {
293              const record = prior[i];
294              const summary = (record.summary ?? "").trim();
295              if (!summary) {
296                  continue;
297              }
298  
299              const header = `- [#${record.entryIndex}]`;
300              const compactedFrom = typeof record.tokensBefore === "number" ? ` (from ${record.tokensBefore.toLocaleString()} tokens)` : "";
301              const block = `${header}${compactedFrom}\n\n${truncateText(summary, maxPerSummaryChars)}`;
302  
303              if (used + block.length > maxTotalChars) {
304                  lines.push("- (older compaction summaries omitted due to size cap)");
305                  break;
306              }
307  
308              lines.push(block);
309              lines.push("");
310              used += block.length;
311          }
312  
313          return lines.join("\n").trim();
314      } catch {
315          return "";
316      }
317  }
318  
319  async function loadStyleGuide(): Promise<string> {
320      try {
321          const raw = await readFile(PROMPT_OVERRIDE_PATH, "utf8");
322          const trimmed = raw.trim();
323          return trimmed.length > 0 ? trimmed : DEFAULT_STYLE_GUIDE.trim();
324      } catch {
325          return DEFAULT_STYLE_GUIDE.trim();
326      }
327  }
328  
329  type DraftGenerationResult =
330      | { ok: true; draft: string; filesTouchedManifestBlock: string }
331      | { ok: false; error: string };
332  
333  function formatManifestOperations(file: FilesTouchedEntry): string {
334      const operations: string[] = [];
335      if (file.operations.has("read")) operations.push("R");
336      if (file.operations.has("write")) operations.push("W");
337      if (file.operations.has("edit")) operations.push("E");
338      if (file.operations.has("move")) operations.push("M");
339      if (file.operations.has("delete")) operations.push("D");
340      return operations.join("").padEnd(2, " ");
341  }
342  
343  function renderFilesTouchedManifestBlock(files: FilesTouchedEntry[]): string {
344      const lines = [
345          "## Files touched",
346          "R=read, W=write, E=edit, M=move/rename, D=delete",
347          "",
348          "```text",
349      ];
350  
351      if (files.length === 0) {
352          lines.push("(no tracked files)");
353      } else {
354          for (const file of files) {
355              lines.push(`${formatManifestOperations(file)} ${file.displayPath}`);
356          }
357      }
358  
359      lines.push("```");
360      return lines.join("\n");
361  }
362  
363  function stripModelAuthoredFilesTouchedTail(draft: string): string {
364      let cleaned = draft.trimEnd();
365  
366      const trailingPatterns = [
367          /\n{2,}---\n{1,}Files touched in this session[^\n]*:\n{1,}```text[\s\S]*?```\s*$/i,
368          /\n{2,}#{1,6}\s+Files touched[^\n]*\n(?:[^\n]*\n)*?```text[\s\S]*?```\s*$/i,
369          /\n{2,}Files touched in this session[^\n]*:\n{1,}```text[\s\S]*?```\s*$/i,
370      ];
371  
372      let changed = true;
373      while (changed) {
374          changed = false;
375          for (const pattern of trailingPatterns) {
376              const next = cleaned.replace(pattern, "").trimEnd();
377              if (next !== cleaned) {
378                  cleaned = next;
379                  changed = true;
380              }
381          }
382      }
383  
384      return cleaned;
385  }
386  
387  function prependHandoverTitle(draft: string): string {
388      const trimmedDraft = draft.trim();
389      const titleLine = `# ${HANDOVER_TITLE}`;
390      if (!trimmedDraft) {
391          return titleLine;
392      }
393  
394      return trimmedDraft.startsWith(titleLine) ? trimmedDraft : `${titleLine}\n\n${trimmedDraft}`;
395  }
396  
397  function finalizeDraft(draft: string, manifestBlock: string): string {
398      const cleanedDraft = stripModelAuthoredFilesTouchedTail(draft);
399      const titledDraft = prependHandoverTitle(cleanedDraft);
400      return `${titledDraft.trimEnd()}\n\n${manifestBlock}`;
401  }
402  
403  function createNonce(): string {
404      return `handover-${Date.now()}-${Math.random().toString(16).slice(2)}`;
405  }
406  
407  function buildHandoverInstructionPrompt(params: {
408      purpose: string;
409      styleGuide: string;
410      priorCompactionsAddendum: string;
411      filesTouchedManifestBlock: string;
412      nonce: string;
413  }): string {
414      const { purpose, styleGuide, priorCompactionsAddendum, filesTouchedManifestBlock, nonce } = params;
415  
416      const parts: string[] = [];
417  
418      // Marker for reliably correlating the assistant response to this exact prompt.
419      // We match it in the *user* entry; the assistant is instructed not to echo it.
420      parts.push(`<!-- handover-nonce: ${nonce} -->`);
421      parts.push("");
422  
423      parts.push("You are generating a single rich handover / rehydration message for continuing this work in a new session.");
424      parts.push("");
425      parts.push("# Constraints:");
426      parts.push("- do not call tools");
427      parts.push("- do not write any files");
428      parts.push("- do not include the handover-nonce marker in your output");
429      parts.push("- output only the final handover message in markdown");
430      parts.push("- do not add a document title; the final handover will be titled by the system");
431      parts.push("- make it high-signal and self-contained; the agent reading it in the new session will have near-zero context");
432      parts.push("- use the files-touched list below as factual input; it will be appended verbatim to the final handover draft");
433      parts.push("- mention in \"Mandatory reading\" only the subset that matters for continuation; do not restate the full list");
434      parts.push("- do not add a files-touched section, files modified section, files changed section, or any other exhaustive file inventory; the system will append the authoritative list verbatim");
435      parts.push("- do not duplicate the full list in prose");
436      parts.push("");
437      parts.push(`# Purpose\n${purpose.trim()}`);
438      parts.push("");
439  
440      if (priorCompactionsAddendum.trim()) {
441          parts.push(priorCompactionsAddendum.trim());
442          parts.push("");
443      }
444  
445      parts.push(filesTouchedManifestBlock.trim());
446      parts.push("");
447      parts.push(styleGuide.trim());
448  
449      return parts.join("\n").trim();
450  }
451  
452  function findNewUserEntryIndexByNonce(params: {
453      afterEntries: SessionEntry[];
454      beforeEntryIds: Set<string>;
455      nonce: string;
456  }): number {
457      const { afterEntries, beforeEntryIds, nonce } = params;
458  
459      for (let i = 0; i < afterEntries.length; i += 1) {
460          const entry = afterEntries[i];
461          if (beforeEntryIds.has(entry.id)) {
462              continue;
463          }
464  
465          if (entry.type !== "message") {
466              continue;
467          }
468  
469          if (entry.message?.role !== "user") {
470              continue;
471          }
472  
473          const text = extractTextFromContent(entry.message?.content);
474          if (!text) {
475              continue;
476          }
477  
478          if (text.includes(nonce)) {
479              return i;
480          }
481      }
482  
483      return -1;
484  }
485  
486  function extractAssistantDraftForNonce(params: {
487      afterEntries: SessionEntry[];
488      beforeEntryIds: Set<string>;
489      nonce: string;
490  }): string | null {
491      const { afterEntries, beforeEntryIds, nonce } = params;
492  
493      const userIndex = findNewUserEntryIndexByNonce({ afterEntries, beforeEntryIds, nonce });
494      if (userIndex < 0) {
495          return null;
496      }
497  
498      for (let i = userIndex + 1; i < afterEntries.length; i += 1) {
499          const entry = afterEntries[i];
500          if (beforeEntryIds.has(entry.id)) {
501              continue;
502          }
503  
504          if (entry.type !== "message") {
505              continue;
506          }
507  
508          if (entry.message?.role !== "assistant") {
509              continue;
510          }
511  
512          const text = extractTextFromContent(entry.message?.content);
513          if (!text) {
514              continue;
515          }
516  
517          // If the model accidentally echoed the nonce comment, strip it.
518          const cleaned = text.replace(/<!--\s*handover-nonce:[\s\S]*?-->/g, "").trim();
519          return (cleaned || text).trim();
520      }
521  
522      return null;
523  }
524  
525  function sleep(ms: number): Promise<void> {
526      return new Promise((resolve) => {
527          setTimeout(resolve, ms);
528      });
529  }
530  
531  async function waitForQuiescentSession(ctx: ExtensionCommandContext, timeoutMs = 60_000): Promise<boolean> {
532      const startedAt = Date.now();
533  
534      while (Date.now() - startedAt < timeoutMs) {
535          if (ctx.isIdle() && !ctx.hasPendingMessages()) {
536              return true;
537          }
538  
539          // waitForIdle only waits for streaming; pending queue items may still exist.
540          await ctx.waitForIdle();
541          await sleep(80);
542      }
543  
544      return ctx.isIdle() && !ctx.hasPendingMessages();
545  }
546  
547  async function waitForAssistantDraft(params: {
548      ctx: ExtensionCommandContext;
549      beforeEntryIds: Set<string>;
550      nonce: string;
551      timeoutMs?: number;
552  }): Promise<string | null> {
553      const { ctx, beforeEntryIds, nonce, timeoutMs = 5 * 60 * 1000 } = params;
554  
555      const startedAt = Date.now();
556  
557      while (Date.now() - startedAt < timeoutMs) {
558          const afterEntries = ctx.sessionManager.getEntries();
559          const draft = extractAssistantDraftForNonce({ afterEntries, beforeEntryIds, nonce });
560          if (draft) {
561              return draft;
562          }
563  
564          // Wait for the agent loop to run. ctx.waitForIdle() only waits for streaming
565          // to finish; it can return immediately if the queued user message hasn't
566          // started processing yet. So we combine it with small sleeps.
567          if (!ctx.isIdle() || ctx.hasPendingMessages()) {
568              await ctx.waitForIdle();
569          }
570  
571          await sleep(80);
572      }
573  
574      return null;
575  }
576  
577  async function generateHandoverDraftViaAgent(params: {
578      pi: ExtensionAPI;
579      ctx: ExtensionCommandContext;
580      purpose: string;
581      styleGuide: string;
582      priorCompactionsAddendum: string;
583  }): Promise<DraftGenerationResult> {
584      const { pi, ctx, purpose, styleGuide, priorCompactionsAddendum } = params;
585  
586      const ready = await waitForQuiescentSession(ctx);
587      if (!ready) {
588          return {
589              ok: false,
590              error: "Please wait for pending messages to finish (or cancel streaming) and run /handover again",
591          };
592      }
593  
594      const branchEntries = ctx.sessionManager.getBranch();
595  
596      let filesTouchedManifestBlock: string;
597      try {
598          filesTouchedManifestBlock = renderFilesTouchedManifestBlock(
599              collectFilesTouched(branchEntries, ctx.cwd),
600          );
601      } catch (error) {
602          return {
603              ok: false,
604              error: `Failed to build files-touched list: ${error instanceof Error ? error.message : String(error)}`,
605          };
606      }
607  
608      const beforeEntries = ctx.sessionManager.getEntries();
609      const beforeEntryIds = new Set(beforeEntries.map((entry) => entry.id));
610  
611      const nonce = createNonce();
612      const prompt = buildHandoverInstructionPrompt({
613          purpose,
614          styleGuide,
615          priorCompactionsAddendum,
616          filesTouchedManifestBlock,
617          nonce,
618      });
619  
620      ctx.ui.setWorkingMessage("Generating handover draft…");
621      pi.sendUserMessage(prompt);
622  
623      const draft = await waitForAssistantDraft({ ctx, beforeEntryIds, nonce });
624      ctx.ui.setWorkingMessage();
625  
626      if (!draft) {
627          return {
628              ok: false,
629              error: "Could not extract handover draft from assistant output",
630          };
631      }
632  
633      return { ok: true, draft, filesTouchedManifestBlock };
634  }
635  
636  export default function (pi: ExtensionAPI) {
637      let pending: PendingAutoSubmit | null = null;
638  
639      const clearPending = (ctx?: ExtensionContext, notify?: string) => {
640          if (!pending) {
641              return;
642          }
643  
644          clearInterval(pending.interval);
645          pending.unsubscribeInput();
646          pending.ctx.ui.setStatus(STATUS_KEY, undefined);
647  
648          const localPending = pending;
649          pending = null;
650  
651          if (notify && ctx) {
652              ctx.ui.notify(notify, "info");
653              return;
654          }
655  
656          if (notify) {
657              localPending.ctx.ui.notify(notify, "info");
658          }
659      };
660  
661      const autoSubmitDraft = () => {
662          if (!pending) {
663              return;
664          }
665  
666          const active = pending;
667          const currentSession = active.ctx.sessionManager.getSessionFile();
668          if (!currentSession || currentSession !== active.sessionFile) {
669              clearPending(undefined);
670              return;
671          }
672  
673          const draft = active.ctx.ui.getEditorText().trim();
674          clearPending(undefined);
675  
676          if (!draft) {
677              active.ctx.ui.notify("Draft is empty", "warning");
678              return;
679          }
680  
681          active.ctx.ui.setEditorText("");
682  
683          try {
684              if (active.ctx.isIdle()) {
685                  pi.sendUserMessage(draft);
686              } else {
687                  pi.sendUserMessage(draft, { deliverAs: "followUp" });
688              }
689          } catch {
690              pi.sendUserMessage(draft);
691          }
692      };
693  
694      const startCountdown = (ctx: ExtensionContext, secondsTotal: number) => {
695          clearPending(ctx);
696  
697          const sessionFile = ctx.sessionManager.getSessionFile();
698          if (!sessionFile) {
699              ctx.ui.notify("Auto-submit disabled: could not determine session identity", "warning");
700              return;
701          }
702  
703          let secondsRemaining = secondsTotal;
704          ctx.ui.setStatus(STATUS_KEY, getStatusLine(ctx, secondsRemaining));
705  
706          const unsubscribeInput = ctx.ui.onTerminalInput((data) => {
707              if (matchesKey(data, Key.escape)) {
708                  clearPending(ctx, "Auto-submit cancelled");
709                  return { consume: true };
710              }
711  
712              // If the user presses Enter, Pi will submit the editor. We should stop
713              // the countdown to avoid an additional auto-submit, but do it silently
714              // (no confusing "cancelled" toast).
715              if (data === "\r" || data === "\n" || data === "\r\n") {
716                  clearPending(ctx);
717                  return undefined;
718              }
719  
720              if (isEditableInput(data)) {
721                  clearPending(ctx, "Auto-submit cancelled");
722              }
723  
724              return undefined;
725          });
726  
727          const interval = setInterval(() => {
728              if (!pending) {
729                  return;
730              }
731  
732              secondsRemaining -= 1;
733              if (secondsRemaining <= 0) {
734                  autoSubmitDraft();
735                  return;
736              }
737  
738              ctx.ui.setStatus(STATUS_KEY, getStatusLine(ctx, secondsRemaining));
739          }, 1000);
740  
741          pending = {
742              ctx,
743              sessionFile,
744              interval,
745              unsubscribeInput,
746          };
747      };
748  
749      const runHandover = async (args: string, ctx: ExtensionCommandContext) => {
750          if (!ctx.hasUI) {
751              ctx.ui.notify("/handover requires interactive mode", "error");
752              return;
753          }
754  
755          const previousSessionFile = ctx.sessionManager.getSessionFile();
756          if (!previousSessionFile) {
757              ctx.ui.notify("/handover requires a persisted session file", "error");
758              return;
759          }
760  
761          // Purpose is optional: if omitted, default to a simple continuation goal
762          // (do not prompt, so `/handover` is a fast one-shot workflow)
763          const purpose = args.trim() || "Continue from the current milestone/state with a clean child session and a rich rehydration message";
764  
765          const styleGuide = await loadStyleGuide();
766          const priorCompactionsAddendum = await buildPriorCompactionsAddendum(ctx);
767  
768          const draftResult = await generateHandoverDraftViaAgent({
769              pi,
770              ctx,
771              purpose,
772              styleGuide,
773              priorCompactionsAddendum,
774          });
775  
776          if (!draftResult.ok) {
777              ctx.ui.notify(draftResult.error, "error");
778              return;
779          }
780  
781          const finalDraft = finalizeDraft(draftResult.draft, draftResult.filesTouchedManifestBlock);
782          const config = await loadConfig();
783  
784          await writePendingHandoverDraft({
785              previousSessionFile,
786              draft: finalDraft,
787              autoSubmitSeconds: config.autoSubmitSeconds,
788          });
789  
790          try {
791              const newSessionResult = await ctx.newSession({ parentSession: previousSessionFile });
792              if (newSessionResult.cancelled) {
793                  await clearPendingHandoverDraft(previousSessionFile);
794                  ctx.ui.notify("Child session creation cancelled", "warning");
795                  return;
796              }
797  
798              ctx.ui.setEditorText(finalDraft);
799              if (config.autoSubmitSeconds <= 0) {
800                  ctx.ui.notify("Draft ready in editor (auto-submit disabled)", "info");
801              }
802          } catch (error) {
803              await clearPendingHandoverDraft(previousSessionFile);
804              throw error;
805          }
806      };
807  
808      for (const eventName of [
809          "session_before_switch",
810          "session_before_fork",
811          "session_before_tree",
812          "session_tree",
813          "session_shutdown",
814      ] as const) {
815          pi.on(eventName as any, (_event: any, eventCtx: any) => {
816              if (pending) {
817                  clearPending(eventCtx);
818              }
819          });
820      }
821  
822      pi.on("session_start", async (event, ctx) => {
823          if (event.reason !== "new" || !event.previousSessionFile || !ctx.hasUI) {
824              return;
825          }
826  
827          const pendingDraft = await consumePendingHandoverDraft(event.previousSessionFile);
828          if (!pendingDraft) {
829              return;
830          }
831  
832          if (pendingDraft.autoSubmitSeconds <= 0) {
833              return;
834          }
835  
836          startCountdown(ctx, pendingDraft.autoSubmitSeconds);
837      });
838  
839      pi.registerCommand("handover", {
840          description: "Generate rich handover draft, create a linked child session, prefill editor, optional auto-submit",
841          handler: runHandover,
842      });
843  
844  
845  }