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 }