// btw — a `/btw` side-chat popover backed by an isolated in-memory AgentSession seeded with the // main conversation context, with optional summary injection back into the main chat. // // Vendored from `mitsuhiko/agent-stuff` `extensions/btw.ts` (MIT). Adapted for perk: // - `registerBtw(pi, gating)` wrapper (the perk registerX pattern) over the original default export; // - the local `notify` helper routes through the headless-safe `report()` surfaces seam // (`perk: btw — ` grammar, D7); // - the side session's toolset mirrors perk's read-only gate (`sideSessionTools(gating.isActive())`) // and the session cache key carries the gate state so a gate flip recreates the session — perk's // structural read-only guarantee is never bypassed by the isolated side session; // - §5 themed-glyph conformance (`❌`→`✗`, running `⚙`→`▸`) via the extracted core; // - the extended `stripDynamicSystemPromptFooter` regex (also strips `Current date:`). // // Charter note: `/btw`'s UI is a `ctx.ui.custom` overlay — the ONE sanctioned exception to the §6 D6 // decline (docs/design/tui-charter.md). It is human-invoked only (no model tool, not a stage/door), // `ctx.hasUI`-gated, and never machine-reachable (cold/headless/RPC), so it cannot threaten the // machine-executability the decline protects. `ctx.ui.custom` stays declined for all workflow surfaces. import type { ThinkingLevel as AiThinkingLevel, AssistantMessage, Message, } from "@earendil-works/pi-ai"; import { type AgentSession, type AgentSessionEvent, buildSessionContext, createAgentSession, createExtensionRuntime, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, getMarkdownTheme, type KeybindingsManager, type ResourceLoader, SessionManager, } from "@earendil-works/pi-coding-agent"; import { Container, type Focusable, Input, Markdown, truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui"; // Structural slices of pi-tui's `TUI` / `OverlayHandle` — used to dodge the dual-copy class clash // (pi-coding-agent bundles its own pi-tui copy; importing the `TUI` class type directly clashes on // its private fields). The overlay only needs these methods. type TuiLike = { requestRender(): void }; type OverlayHandleLike = { setHidden(hidden: boolean): void; hide(): void; focus(): void; isFocused(): boolean; }; import type { ToolGating } from "../../substrate/toolGating.ts"; import { report } from "../../surfaces/report.ts"; import { btwThreadEntryRenderer, btwThreadResetEntryRenderer, registerTranscriptRenderer, } from "../../surfaces/surfaces.ts"; import { extractEventAssistantText, extractText, formatThread, formatToolArgs, lastAssistantMessage, renderErrorLine, renderToolCallLines, sideSessionTools, stripDynamicSystemPromptFooter, type ToolCallInfo, } from "./core.ts"; const BTW_ENTRY_TYPE = "btw-thread-entry"; const BTW_RESET_TYPE = "btw-thread-reset"; const BTW_SYSTEM_PROMPT = [ "You are BTW, a side-channel assistant embedded in the user's coding agent.", "You have access to the main conversation context — use it to give informed answers.", "Help with focused questions, planning, and quick explorations.", "Be direct and practical.", ].join(" "); const BTW_SUMMARY_PROMPT = "Summarize this side conversation for handoff into the main conversation. Keep key decisions, findings, risks, and next actions. Output only the summary."; type SessionThinkingLevel = "off" | AiThinkingLevel; type BtwDetails = { question: string; answer: string; timestamp: number; provider: string; model: string; thinkingLevel: SessionThinkingLevel; usage?: AssistantMessage["usage"]; }; type BtwResetDetails = { timestamp: number; }; type OverlayRuntime = { handle?: OverlayHandleLike; refresh?: () => void; close?: () => void; finish?: () => void; setDraft?: (value: string) => void; closed?: boolean; }; type SideSessionRuntime = { session: AgentSession; modelKey: string; unsubscribe: () => void; }; function createBtwResourceLoader( ctx: ExtensionContext, appendSystemPrompt: string[] = [BTW_SYSTEM_PROMPT], ): ResourceLoader { const extensionsResult = { extensions: [], errors: [], runtime: createExtensionRuntime() }; const systemPrompt = stripDynamicSystemPromptFooter(ctx.getSystemPrompt()); return { getExtensions: () => extensionsResult, getSkills: () => ({ skills: [], diagnostics: [] }), getPrompts: () => ({ prompts: [], diagnostics: [] }), getThemes: () => ({ themes: [], diagnostics: [] }), getAgentsFiles: () => ({ agentsFiles: [] }), getSystemPrompt: () => systemPrompt, getAppendSystemPrompt: () => appendSystemPrompt, extendResources: () => {}, reload: async () => {}, } as unknown as ResourceLoader; } function buildSeedMessages(ctx: ExtensionContext, thread: BtwDetails[]): Message[] { const seed: Message[] = []; try { const contextMessages = buildSessionContext( ctx.sessionManager.getEntries(), ctx.sessionManager.getLeafId(), ).messages; seed.push(...(contextMessages.filter((message) => "role" in message) as Message[])); } catch { // Ignore context seed failures and continue with an empty side thread. } for (const item of thread) { seed.push( { role: "user", content: [{ type: "text", text: item.question }], timestamp: item.timestamp, }, { role: "assistant", content: [{ type: "text", text: item.answer }], provider: item.provider, model: item.model, api: ctx.model?.api ?? "openai-responses", usage: item.usage ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, stopReason: "stop", timestamp: item.timestamp, } as AssistantMessage, ); } return seed; } class BtwOverlay extends Container implements Focusable { private readonly input: Input; private readonly tui: TuiLike; private readonly theme: ExtensionContext["ui"]["theme"]; private readonly keybindings: KeybindingsManager; private readonly getTranscript: ( width: number, theme: ExtensionContext["ui"]["theme"], ) => string[]; private readonly getStatus: () => string; private readonly onSubmitCallback: (value: string) => void; private readonly onDismissCallback: () => void; private _focused = false; get focused(): boolean { return this._focused; } set focused(value: boolean) { this._focused = value; this.input.focused = value; } constructor( tui: TuiLike, theme: ExtensionContext["ui"]["theme"], keybindings: KeybindingsManager, getTranscript: (width: number, theme: ExtensionContext["ui"]["theme"]) => string[], getStatus: () => string, onSubmit: (value: string) => void, onDismiss: () => void, ) { super(); this.tui = tui; this.theme = theme; this.keybindings = keybindings; this.getTranscript = getTranscript; this.getStatus = getStatus; this.onSubmitCallback = onSubmit; this.onDismissCallback = onDismiss; this.input = new Input(); this.input.onSubmit = (value) => { this.onSubmitCallback(value); }; this.input.onEscape = () => { this.onDismissCallback(); }; } handleInput(data: string): void { if (this.keybindings.matches(data, "tui.select.cancel")) { this.onDismissCallback(); return; } this.input.handleInput(data); } setDraft(value: string): void { this.input.setValue(value); this.tui.requestRender(); } getDraft(): string { return this.input.getValue(); } private frameLine(content: string, innerWidth: number): string { const truncated = truncateToWidth(content, innerWidth, ""); const padding = Math.max(0, innerWidth - visibleWidth(truncated)); return `${this.theme.fg("borderMuted", "│")}${truncated}${" ".repeat(padding)}${this.theme.fg("borderMuted", "│")}`; } private borderLine(innerWidth: number, edge: "top" | "bottom"): string { const left = edge === "top" ? "┌" : "└"; const right = edge === "top" ? "┐" : "┘"; return this.theme.fg("borderMuted", `${left}${"─".repeat(innerWidth)}${right}`); } override render(width: number): string[] { const dialogWidth = Math.max(56, Math.min(width, Math.floor(width * 0.9))); const innerWidth = Math.max(40, dialogWidth - 2); const terminalRows = process.stdout.rows ?? 30; const dialogHeight = Math.max(16, Math.min(30, Math.floor(terminalRows * 0.75))); const chromeHeight = 7; const transcriptHeight = Math.max(6, dialogHeight - chromeHeight); // Markdown renders to innerWidth already — no manual wrapping needed const transcript = this.getTranscript(innerWidth, this.theme); const visibleTranscript = transcript.slice(-transcriptHeight); const transcriptPadding = Math.max(0, transcriptHeight - visibleTranscript.length); const status = this.getStatus(); const previousFocused = this.input.focused; this.input.focused = false; const inputLine = this.input.render(innerWidth)[0] ?? ""; this.input.focused = previousFocused; const lines = [ this.borderLine(innerWidth, "top"), this.frameLine(this.theme.fg("accent", this.theme.bold(" BTW side chat ")), innerWidth), this.frameLine(this.theme.fg("dim", "Separate side conversation. Esc closes."), innerWidth), this.theme.fg("borderMuted", `├${"─".repeat(innerWidth)}┤`), ]; for (const line of visibleTranscript) { lines.push(this.frameLine(line, innerWidth)); } for (let i = 0; i < transcriptPadding; i++) { lines.push(this.frameLine("", innerWidth)); } lines.push(this.theme.fg("borderMuted", `├${"─".repeat(innerWidth)}┤`)); lines.push(this.frameLine(this.theme.fg("warning", status), innerWidth)); lines.push( `${this.theme.fg("borderMuted", "│")}${inputLine}${this.theme.fg("borderMuted", "│")}`, ); lines.push(this.frameLine(this.theme.fg("dim", "Enter submit · Esc close"), innerWidth)); lines.push(this.borderLine(innerWidth, "bottom")); return lines; } } export function registerBtw(pi: ExtensionAPI, gating: ToolGating): void { // Transcript markers for the btw thread entries (audit §2.3): renderer bodies in surfaces.ts, // registration = wiring, feature-detect inside the seam (pre-0.80.4 hosts stay inert). registerTranscriptRenderer(pi, BTW_ENTRY_TYPE, btwThreadEntryRenderer); registerTranscriptRenderer(pi, BTW_RESET_TYPE, btwThreadResetEntryRenderer); let thread: BtwDetails[] = []; let pendingQuestion: string | null = null; let pendingAnswer = ""; let pendingError: string | null = null; let pendingToolCalls: ToolCallInfo[] = []; let sideBusy = false; let overlayStatus = "Ready"; let overlayDraft = ""; let overlayRuntime: OverlayRuntime | null = null; let activeSideSession: SideSessionRuntime | null = null; let overlayRefreshTimer: ReturnType | null = null; const mdTheme = getMarkdownTheme(); // perk gate-mirror: the cache key carries the read-only/read-write gate state, so a gate flip // (read-only ↔ read-write) recreates the side session with the correct `sideSessionTools` set // — never reusing a stale toolset that could bypass perk's read-only guarantee. function getModelKey(ctx: ExtensionContext): string { const model = ctx.model; return model ? `${model.provider}/${model.id}#${gating.isActive() ? "ro" : "rw"}` : "none"; } // perk: route the local notify through the headless-safe report() seam (D7 grammar). function notify( ctx: ExtensionContext | ExtensionCommandContext, message: string, level: "info" | "warning" | "error", ): void { report(ctx, "btw", level, message); } function renderMarkdownLines(text: string, width: number): string[] { if (!text) return []; try { const md = new Markdown(text, 0, 0, mdTheme); return md.render(width); } catch { // Fall back to plain text wrapping if Markdown rendering fails return text.split("\n").flatMap((line) => { if (!line) return [""]; const wrapped: string[] = []; for (let i = 0; i < line.length; i += width) { wrapped.push(line.slice(i, i + width)); } return wrapped.length > 0 ? wrapped : [""]; }); } } function getTranscriptLines(width: number, theme: ExtensionContext["ui"]["theme"]): string[] { try { return getTranscriptLinesInner(width, theme); } catch (error) { return [ theme.fg( "error", `Render error: ${error instanceof Error ? error.message : String(error)}`, ), ]; } } function getTranscriptLinesInner( width: number, theme: ExtensionContext["ui"]["theme"], ): string[] { if (thread.length === 0 && !pendingQuestion && !pendingAnswer && !pendingError) { return [theme.fg("dim", "No BTW messages yet. Type a question below.")]; } const lines: string[] = []; for (const item of thread.slice(-6)) { // User message const userText = item.question.trim().split("\n")[0] ?? ""; lines.push( theme.fg("accent", theme.bold("You: ")) + truncateToWidth(userText, width - 5, "…"), ); lines.push(""); // Assistant message rendered as markdown const mdLines = renderMarkdownLines(item.answer, width); lines.push(...mdLines); lines.push(""); } if (pendingQuestion) { const userText = pendingQuestion.trim().split("\n")[0] ?? ""; lines.push( theme.fg("accent", theme.bold("You: ")) + truncateToWidth(userText, width - 5, "…"), ); // Show tool calls inline (§5-conformed glyphs via the extracted core) if (pendingToolCalls.length > 0) { lines.push(...renderToolCallLines(pendingToolCalls, theme, width)); } if (pendingError) { lines.push(renderErrorLine(theme, pendingError)); } else if (pendingAnswer) { lines.push(""); const mdLines = renderMarkdownLines(pendingAnswer, width); lines.push(...mdLines); } else if (pendingToolCalls.length === 0) { lines.push(theme.fg("dim", "…")); } } // Trim trailing empty line while (lines.length > 0 && lines[lines.length - 1] === "") { lines.pop(); } return lines; } function syncOverlay(): void { overlayRuntime?.refresh?.(); } function scheduleOverlayRefresh(): void { if (overlayRefreshTimer) { return; } overlayRefreshTimer = setTimeout(() => { overlayRefreshTimer = null; syncOverlay(); }, 16); } function setOverlayStatus(status: string, throttled = false): void { overlayStatus = status; if (throttled) { scheduleOverlayRefresh(); } else { syncOverlay(); } } function dismissOverlay(): void { overlayRuntime?.close?.(); overlayRuntime = null; if (overlayRefreshTimer) { clearTimeout(overlayRefreshTimer); overlayRefreshTimer = null; } } function setOverlayDraft(value: string): void { overlayDraft = value; overlayRuntime?.setDraft?.(value); } async function disposeSideSession(): Promise { const current = activeSideSession; activeSideSession = null; if (!current) { return; } try { current.unsubscribe(); } catch { // Ignore unsubscribe errors during cleanup. } try { await current.session.abort(); } catch { // Ignore abort errors during cleanup. } current.session.dispose(); if (overlayRefreshTimer) { clearTimeout(overlayRefreshTimer); overlayRefreshTimer = null; } } async function resetThread( _ctx: ExtensionContext | ExtensionCommandContext, persist = true, ): Promise { thread = []; pendingQuestion = null; pendingAnswer = ""; pendingError = null; pendingToolCalls = []; sideBusy = false; setOverlayDraft(""); setOverlayStatus("Ready"); await disposeSideSession(); if (persist) { const details: BtwResetDetails = { timestamp: Date.now() }; pi.appendEntry(BTW_RESET_TYPE, details); } syncOverlay(); } async function restoreThread(ctx: ExtensionContext): Promise { await disposeSideSession(); thread = []; pendingQuestion = null; pendingAnswer = ""; pendingError = null; pendingToolCalls = []; sideBusy = false; overlayStatus = "Ready"; overlayDraft = ""; const branch = ctx.sessionManager.getBranch(); let lastResetIndex = -1; for (let i = 0; i < branch.length; i++) { const entry = branch[i]; if (entry && entry.type === "custom" && entry.customType === BTW_RESET_TYPE) { lastResetIndex = i; } } for (const entry of branch.slice(lastResetIndex + 1)) { if (entry.type !== "custom" || entry.customType !== BTW_ENTRY_TYPE) { continue; } const details = entry.data as BtwDetails | undefined; if (!details?.question || !details.answer) { continue; } thread.push(details); } syncOverlay(); } async function createSideSession( ctx: ExtensionCommandContext, ): Promise { if (!ctx.model) { return null; } const { session } = await createAgentSession({ sessionManager: SessionManager.inMemory(), model: ctx.model, modelRegistry: ctx.modelRegistry as AgentSession["modelRegistry"], thinkingLevel: pi.getThinkingLevel() as SessionThinkingLevel, // perk gate-mirror: read-only ⇒ ["read"] only (a foreign session's bash can't be sandboxed // by perk's isReadOnlyBashCommand); read-write ⇒ the full set. tools: sideSessionTools(gating.isActive()), resourceLoader: createBtwResourceLoader(ctx), }); const seedMessages = buildSeedMessages(ctx, thread); if (seedMessages.length > 0) { session.agent.state.messages = seedMessages as typeof session.agent.state.messages; } const unsubscribe = session.subscribe((event: AgentSessionEvent) => { if (!sideBusy || !pendingQuestion) { return; } switch (event.type) { case "message_start": case "message_update": case "message_end": { const streamed = extractEventAssistantText(event.message); if (streamed) { pendingAnswer = streamed; pendingError = null; } setOverlayStatus( event.type === "message_end" ? "Finalizing side response..." : "Streaming side response...", true, ); return; } case "tool_execution_start": { const toolName = (event as { toolName?: string }).toolName ?? "unknown"; try { pendingToolCalls.push({ toolCallId: (event as { toolCallId?: string }).toolCallId ?? "", toolName, args: formatToolArgs(toolName, (event as { args?: unknown }).args), status: "running", }); } catch { // Ignore tool tracking failures } setOverlayStatus(`Running tool: ${toolName}...`, true); return; } case "tool_execution_end": { const endToolName = (event as { toolName?: string }).toolName ?? "unknown"; const tc = pendingToolCalls.find( (t) => t.toolName === endToolName && t.status === "running", ); if (tc) { tc.status = (event as { isError?: boolean }).isError ? "error" : "done"; } setOverlayStatus("Streaming side response...", true); return; } case "turn_end": { setOverlayStatus("Finalizing side response...", true); return; } default: return; } }); return { session, modelKey: getModelKey(ctx), unsubscribe, }; } async function ensureSideSession( ctx: ExtensionCommandContext, ): Promise { if (!ctx.model) { return null; } const expectedModelKey = getModelKey(ctx); if (activeSideSession && activeSideSession.modelKey === expectedModelKey) { return activeSideSession; } await disposeSideSession(); activeSideSession = await createSideSession(ctx); return activeSideSession; } async function ensureOverlay(ctx: ExtensionCommandContext | ExtensionContext): Promise { if (!ctx.hasUI) { return; } if (overlayRuntime?.handle) { overlayRuntime.handle.setHidden(false); overlayRuntime.handle.focus(); overlayRuntime.refresh?.(); return; } const runtime: OverlayRuntime = {}; const closeRuntime = () => { if (runtime.closed) { return; } runtime.closed = true; runtime.handle?.hide(); if (overlayRuntime === runtime) { overlayRuntime = null; } runtime.finish?.(); }; runtime.close = closeRuntime; overlayRuntime = runtime; void ctx.ui .custom( async (tui, theme, keybindings, done) => { runtime.finish = () => done(); const overlay = new BtwOverlay( tui, theme, keybindings, (width, t) => getTranscriptLines(width, t), () => overlayStatus, (value) => { void submitFromOverlay(ctx, value); }, () => { void closeOverlayFlow(ctx); }, ); overlay.focused = true; overlay.setDraft(overlayDraft); runtime.setDraft = (value) => overlay.setDraft(value); runtime.refresh = () => { overlay.focused = runtime.handle?.isFocused() ?? false; tui.requestRender(); }; runtime.close = () => { overlayDraft = overlay.getDraft(); closeRuntime(); }; if (runtime.closed) { done(); } return overlay; }, { overlay: true, overlayOptions: { width: "80%", minWidth: 72, maxHeight: "78%", anchor: "top-center", margin: { top: 1, left: 2, right: 2 }, }, onHandle: (handle) => { runtime.handle = handle; handle.focus(); if (runtime.closed) { closeRuntime(); } }, }, ) .catch((error) => { if (overlayRuntime === runtime) { overlayRuntime = null; } notify(ctx, error instanceof Error ? error.message : String(error), "error"); }); } async function summarizeThread(ctx: ExtensionContext, items: BtwDetails[]): Promise { const model = ctx.model; if (!model) { throw new Error("No active model selected."); } const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model); if (auth.ok === false) { throw new Error(auth.error); } const { session } = await createAgentSession({ sessionManager: SessionManager.inMemory(), model, modelRegistry: ctx.modelRegistry as AgentSession["modelRegistry"], thinkingLevel: "off", tools: [], resourceLoader: createBtwResourceLoader(ctx, [BTW_SUMMARY_PROMPT]), }); try { await session.prompt(formatThread(items), { source: "extension" }); const response = lastAssistantMessage(session.state.messages) as AssistantMessage | null; if (!response) { throw new Error("Summary finished without a response."); } if (response.stopReason === "aborted") { throw new Error("Summary request was aborted."); } if (response.stopReason === "error") { throw new Error(response.errorMessage || "Summary request failed."); } return extractText(response.content) || "(No summary generated)"; } finally { try { await session.abort(); } catch { // Ignore abort errors during temporary session teardown. } session.dispose(); } } async function injectSummaryIntoMain( ctx: ExtensionContext | ExtensionCommandContext, ): Promise { if (thread.length === 0) { notify(ctx, "No BTW thread to summarize.", "warning"); return; } setOverlayStatus("Summarizing BTW thread for injection..."); try { const summary = await summarizeThread(ctx, thread); const message = `Summary of my BTW side conversation:\n\n${summary}`; if (ctx.isIdle()) { pi.sendUserMessage(message); } else { pi.sendUserMessage(message, { deliverAs: "followUp" }); } await resetThread(ctx); notify(ctx, "Injected BTW summary into main chat.", "info"); } catch (error) { notify(ctx, error instanceof Error ? error.message : String(error), "error"); } } async function closeOverlayFlow(ctx: ExtensionContext | ExtensionCommandContext): Promise { dismissOverlay(); if (!ctx.hasUI) { return; } if (thread.length === 0) { return; } const choice = await ctx.ui.select("Close BTW:", [ "Keep side thread", "Inject summary into main chat", ]); if (choice === "Inject summary into main chat") { await injectSummaryIntoMain(ctx); } } async function runBtwPrompt(ctx: ExtensionCommandContext, question: string): Promise { const model = ctx.model; if (!model) { setOverlayStatus("No active model selected."); notify(ctx, "No active model selected.", "error"); return; } const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model); if (auth.ok === false) { const message = auth.error; setOverlayStatus(message); notify(ctx, message, "error"); return; } if (sideBusy) { notify(ctx, "BTW is still processing the previous message.", "warning"); return; } const side = await ensureSideSession(ctx); if (!side) { notify(ctx, "Unable to create BTW side session.", "error"); return; } sideBusy = true; pendingQuestion = question; pendingAnswer = ""; pendingError = null; pendingToolCalls = []; setOverlayStatus("Streaming side response..."); syncOverlay(); try { await side.session.prompt(question, { source: "extension" }); const response = lastAssistantMessage(side.session.state.messages) as AssistantMessage | null; if (!response) { throw new Error("BTW request finished without a response."); } if (response.stopReason === "aborted") { throw new Error("BTW request aborted."); } if (response.stopReason === "error") { throw new Error(response.errorMessage || "BTW request failed."); } const answer = extractText(response.content) || "(No text response)"; pendingAnswer = answer; const details: BtwDetails = { question, answer, timestamp: Date.now(), provider: model.provider, model: model.id, thinkingLevel: pi.getThinkingLevel() as SessionThinkingLevel, usage: response.usage, }; thread.push(details); pi.appendEntry(BTW_ENTRY_TYPE, details); pendingQuestion = null; pendingAnswer = ""; pendingToolCalls = []; setOverlayStatus("Ready for the next side question."); } catch (error) { const message = error instanceof Error ? error.message : String(error); pendingError = message; setOverlayStatus("BTW request failed."); notify(ctx, message, "error"); } finally { sideBusy = false; syncOverlay(); } } async function submitFromOverlay( ctx: ExtensionContext | ExtensionCommandContext, rawValue: string, ): Promise { const question = rawValue.trim(); if (!question) { setOverlayStatus("Enter a question first."); return; } setOverlayDraft(""); if (!("waitForIdle" in ctx)) { setOverlayStatus("BTW submit requires command context. Re-open with /btw."); return; } await runBtwPrompt(ctx as ExtensionCommandContext, question); } pi.registerCommand("btw", { description: "Open a simple BTW side-chat popover. `/btw ` asks immediately, `/btw` opens the side thread.", handler: async (args, ctx) => { const question = args.trim(); if (!question) { if (thread.length > 0 && ctx.hasUI) { const choice = await ctx.ui.select("BTW side chat:", [ "Continue previous conversation", "Start fresh", ]); if (choice === "Continue previous conversation") { // Dispose session so it's recreated with fresh main context on next submit await disposeSideSession(); setOverlayStatus("Continuing BTW thread."); await ensureOverlay(ctx); } else if (choice === "Start fresh") { await resetThread(ctx, true); setOverlayStatus("Ready"); await ensureOverlay(ctx); } // null = user cancelled (Esc), do nothing } else { await resetThread(ctx, true); setOverlayStatus("Ready"); await ensureOverlay(ctx); } return; } await ensureOverlay(ctx); await runBtwPrompt(ctx, question); }, }); pi.on("session_start", async (_event, ctx) => { await restoreThread(ctx); }); pi.on("session_tree", async (_event, ctx) => { await restoreThread(ctx); }); pi.on("session_shutdown", async () => { await disposeSideSession(); dismissOverlay(); }); }