/** * AgentSession - Core abstraction for agent lifecycle and session management. * * This class is shared between all run modes (interactive, print, rpc). * It encapsulates: * - Agent state access * - Event subscription with automatic session persistence * - Model and thinking level management * - Compaction (manual and auto) * - Bash execution * - Session switching and branching * * Modes use this class and add their own I/O layer on top. */ import type { Agent, AgentEvent, AgentMessage, AgentState, AgentTool, ThinkingLevel, } from "@mariozechner/pi-agent-core"; import type { AssistantMessage, ImageContent, Message, Model, TextContent } from "@mariozechner/pi-ai"; import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@mariozechner/pi-ai"; import { getAuthPath } from "../config.js"; import { type BashResult, executeBash as executeBashCommand } from "./bash-executor.js"; import { type CompactionResult, calculateContextTokens, collectEntriesForBranchSummary, compact, generateBranchSummary, prepareCompaction, shouldCompact, } from "./compaction/index.js"; import { exportSessionToHtml } from "./export-html/index.js"; import type { ExtensionRunner, SessionBeforeBranchResult, SessionBeforeCompactResult, SessionBeforeSwitchResult, SessionBeforeTreeResult, TreePreparation, TurnEndEvent, TurnStartEvent, } from "./extensions/index.js"; import type { BashExecutionMessage, CustomMessage } from "./messages.js"; import type { ModelRegistry } from "./model-registry.js"; import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.js"; import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions, SessionManager } from "./session-manager.js"; import type { SettingsManager, SkillsSettings } from "./settings-manager.js"; /** Session-specific events that extend the core AgentEvent */ export type AgentSessionEvent = | AgentEvent | { type: "auto_compaction_start"; reason: "threshold" | "overflow" } | { type: "auto_compaction_end"; result: CompactionResult | undefined; aborted: boolean; willRetry: boolean } | { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string } | { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }; /** Listener function for agent session events */ export type AgentSessionEventListener = (event: AgentSessionEvent) => void; // ============================================================================ // Types // ============================================================================ export interface AgentSessionConfig { agent: Agent; sessionManager: SessionManager; settingsManager: SettingsManager; /** Models to cycle through with Ctrl+P (from --models flag) */ scopedModels?: Array<{ model: Model; thinkingLevel: ThinkingLevel }>; /** File-based prompt templates for expansion */ promptTemplates?: PromptTemplate[]; /** Extension runner (created in sdk.ts with wrapped tools) */ extensionRunner?: ExtensionRunner; skillsSettings?: Required; /** Model registry for API key resolution and model discovery */ modelRegistry: ModelRegistry; /** Tool registry for extension getTools/setTools - maps name to tool */ toolRegistry?: Map; /** Function to rebuild system prompt when tools change */ rebuildSystemPrompt?: (toolNames: string[]) => string; } /** Options for AgentSession.prompt() */ export interface PromptOptions { /** Whether to expand file-based prompt templates (default: true) */ expandPromptTemplates?: boolean; /** Image attachments */ images?: ImageContent[]; /** When streaming, how to queue the message: "steer" (interrupt) or "followUp" (wait). Required if streaming. */ streamingBehavior?: "steer" | "followUp"; } /** Result from cycleModel() */ export interface ModelCycleResult { model: Model; thinkingLevel: ThinkingLevel; /** Whether cycling through scoped models (--models flag) or all available */ isScoped: boolean; } /** Session statistics for /session command */ export interface SessionStats { sessionFile: string | undefined; sessionId: string; userMessages: number; assistantMessages: number; toolCalls: number; toolResults: number; totalMessages: number; tokens: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number; }; cost: number; } // ============================================================================ // Constants // ============================================================================ /** Standard thinking levels */ const THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high"]; /** Thinking levels including xhigh (for supported models) */ const THINKING_LEVELS_WITH_XHIGH: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"]; // ============================================================================ // AgentSession Class // ============================================================================ export class AgentSession { readonly agent: Agent; readonly sessionManager: SessionManager; readonly settingsManager: SettingsManager; private _scopedModels: Array<{ model: Model; thinkingLevel: ThinkingLevel }>; private _promptTemplates: PromptTemplate[]; // Event subscription state private _unsubscribeAgent?: () => void; private _eventListeners: AgentSessionEventListener[] = []; /** Tracks pending steering messages for UI display. Removed when delivered. */ private _steeringMessages: string[] = []; /** Tracks pending follow-up messages for UI display. Removed when delivered. */ private _followUpMessages: string[] = []; /** Messages queued to be included with the next user prompt as context ("asides"). */ private _pendingNextTurnMessages: CustomMessage[] = []; // Compaction state private _compactionAbortController: AbortController | undefined = undefined; private _autoCompactionAbortController: AbortController | undefined = undefined; // Branch summarization state private _branchSummaryAbortController: AbortController | undefined = undefined; // Retry state private _retryAbortController: AbortController | undefined = undefined; private _retryAttempt = 0; private _retryPromise: Promise | undefined = undefined; private _retryResolve: (() => void) | undefined = undefined; // Bash execution state private _bashAbortController: AbortController | undefined = undefined; private _pendingBashMessages: BashExecutionMessage[] = []; // Extension system private _extensionRunner: ExtensionRunner | undefined = undefined; private _turnIndex = 0; private _skillsSettings: Required | undefined; // Model registry for API key resolution private _modelRegistry: ModelRegistry; // Tool registry for extension getTools/setTools private _toolRegistry: Map; // Function to rebuild system prompt when tools change private _rebuildSystemPrompt?: (toolNames: string[]) => string; // Base system prompt (without extension appends) - used to apply fresh appends each turn private _baseSystemPrompt: string; constructor(config: AgentSessionConfig) { this.agent = config.agent; this.sessionManager = config.sessionManager; this.settingsManager = config.settingsManager; this._scopedModels = config.scopedModels ?? []; this._promptTemplates = config.promptTemplates ?? []; this._extensionRunner = config.extensionRunner; this._skillsSettings = config.skillsSettings; this._modelRegistry = config.modelRegistry; this._toolRegistry = config.toolRegistry ?? new Map(); this._rebuildSystemPrompt = config.rebuildSystemPrompt; this._baseSystemPrompt = config.agent.state.systemPrompt; // Always subscribe to agent events for internal handling // (session persistence, extensions, auto-compaction, retry logic) this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent); } /** Model registry for API key resolution and model discovery */ get modelRegistry(): ModelRegistry { return this._modelRegistry; } // ========================================================================= // Event Subscription // ========================================================================= /** Emit an event to all listeners */ private _emit(event: AgentSessionEvent): void { for (const l of this._eventListeners) { l(event); } } // Track last assistant message for auto-compaction check private _lastAssistantMessage: AssistantMessage | undefined = undefined; /** Internal handler for agent events - shared by subscribe and reconnect */ private _handleAgentEvent = async (event: AgentEvent): Promise => { // When a user message starts, check if it's from either queue and remove it BEFORE emitting // This ensures the UI sees the updated queue state if (event.type === "message_start" && event.message.role === "user") { const messageText = this._getUserMessageText(event.message); if (messageText) { // Check steering queue first const steeringIndex = this._steeringMessages.indexOf(messageText); if (steeringIndex !== -1) { this._steeringMessages.splice(steeringIndex, 1); } else { // Check follow-up queue const followUpIndex = this._followUpMessages.indexOf(messageText); if (followUpIndex !== -1) { this._followUpMessages.splice(followUpIndex, 1); } } } } // Emit to extensions first await this._emitExtensionEvent(event); // Notify all listeners this._emit(event); // Handle session persistence if (event.type === "message_end") { // Check if this is a custom message from extensions if (event.message.role === "custom") { // Persist as CustomMessageEntry this.sessionManager.appendCustomMessageEntry( event.message.customType, event.message.content, event.message.display, event.message.details, ); } else if ( event.message.role === "user" || event.message.role === "assistant" || event.message.role === "toolResult" ) { // Regular LLM message - persist as SessionMessageEntry this.sessionManager.appendMessage(event.message); } // Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere // Track assistant message for auto-compaction (checked on agent_end) if (event.message.role === "assistant") { this._lastAssistantMessage = event.message; } } // Check auto-retry and auto-compaction after agent completes if (event.type === "agent_end" && this._lastAssistantMessage) { const msg = this._lastAssistantMessage; this._lastAssistantMessage = undefined; // Check for retryable errors first (overloaded, rate limit, server errors) if (this._isRetryableError(msg)) { const didRetry = await this._handleRetryableError(msg); if (didRetry) return; // Retry was initiated, don't proceed to compaction } else if (this._retryAttempt > 0) { // Previous retry succeeded - emit success event and reset counter this._emit({ type: "auto_retry_end", success: true, attempt: this._retryAttempt, }); this._retryAttempt = 0; // Resolve the retry promise so waitForRetry() completes this._resolveRetry(); } await this._checkCompaction(msg); } }; /** Resolve the pending retry promise */ private _resolveRetry(): void { if (this._retryResolve) { this._retryResolve(); this._retryResolve = undefined; this._retryPromise = undefined; } } /** Extract text content from a message */ private _getUserMessageText(message: Message): string { if (message.role !== "user") return ""; const content = message.content; if (typeof content === "string") return content; const textBlocks = content.filter((c) => c.type === "text"); return textBlocks.map((c) => (c as TextContent).text).join(""); } /** Find the last assistant message in agent state (including aborted ones) */ private _findLastAssistantMessage(): AssistantMessage | undefined { const messages = this.agent.state.messages; for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; if (msg.role === "assistant") { return msg as AssistantMessage; } } return undefined; } /** Emit extension events based on agent events */ private async _emitExtensionEvent(event: AgentEvent): Promise { if (!this._extensionRunner) return; if (event.type === "agent_start") { this._turnIndex = 0; await this._extensionRunner.emit({ type: "agent_start" }); } else if (event.type === "agent_end") { await this._extensionRunner.emit({ type: "agent_end", messages: event.messages }); } else if (event.type === "turn_start") { const extensionEvent: TurnStartEvent = { type: "turn_start", turnIndex: this._turnIndex, timestamp: Date.now(), }; await this._extensionRunner.emit(extensionEvent); } else if (event.type === "turn_end") { const extensionEvent: TurnEndEvent = { type: "turn_end", turnIndex: this._turnIndex, message: event.message, toolResults: event.toolResults, }; await this._extensionRunner.emit(extensionEvent); this._turnIndex++; } } /** * Subscribe to agent events. * Session persistence is handled internally (saves messages on message_end). * Multiple listeners can be added. Returns unsubscribe function for this listener. */ subscribe(listener: AgentSessionEventListener): () => void { this._eventListeners.push(listener); // Return unsubscribe function for this specific listener return () => { const index = this._eventListeners.indexOf(listener); if (index !== -1) { this._eventListeners.splice(index, 1); } }; } /** * Temporarily disconnect from agent events. * User listeners are preserved and will receive events again after resubscribe(). * Used internally during operations that need to pause event processing. */ private _disconnectFromAgent(): void { if (this._unsubscribeAgent) { this._unsubscribeAgent(); this._unsubscribeAgent = undefined; } } /** * Reconnect to agent events after _disconnectFromAgent(). * Preserves all existing listeners. */ private _reconnectToAgent(): void { if (this._unsubscribeAgent) return; // Already connected this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent); } /** * Remove all listeners and disconnect from agent. * Call this when completely done with the session. */ dispose(): void { this._disconnectFromAgent(); this._eventListeners = []; } // ========================================================================= // Read-only State Access // ========================================================================= /** Full agent state */ get state(): AgentState { return this.agent.state; } /** Current model (may be undefined if not yet selected) */ get model(): Model | undefined { return this.agent.state.model; } /** Current thinking level */ get thinkingLevel(): ThinkingLevel { return this.agent.state.thinkingLevel; } /** Whether agent is currently streaming a response */ get isStreaming(): boolean { return this.agent.state.isStreaming; } /** Current retry attempt (0 if not retrying) */ get retryAttempt(): number { return this._retryAttempt; } /** * Get the names of currently active tools. * Returns the names of tools currently set on the agent. */ getActiveToolNames(): string[] { return this.agent.state.tools.map((t) => t.name); } /** * Get all configured tool names (built-in via --tools or default, plus custom tools). */ getAllToolNames(): string[] { return Array.from(this._toolRegistry.keys()); } /** * Set active tools by name. * Only tools in the registry can be enabled. Unknown tool names are ignored. * Also rebuilds the system prompt to reflect the new tool set. * Changes take effect on the next agent turn. */ setActiveToolsByName(toolNames: string[]): void { const tools: AgentTool[] = []; const validToolNames: string[] = []; for (const name of toolNames) { const tool = this._toolRegistry.get(name); if (tool) { tools.push(tool); validToolNames.push(name); } } this.agent.setTools(tools); // Rebuild base system prompt with new tool set if (this._rebuildSystemPrompt) { this._baseSystemPrompt = this._rebuildSystemPrompt(validToolNames); this.agent.setSystemPrompt(this._baseSystemPrompt); } } /** Whether auto-compaction is currently running */ get isCompacting(): boolean { return this._autoCompactionAbortController !== undefined || this._compactionAbortController !== undefined; } /** All messages including custom types like BashExecutionMessage */ get messages(): AgentMessage[] { return this.agent.state.messages; } /** Current steering mode */ get steeringMode(): "all" | "one-at-a-time" { return this.agent.getSteeringMode(); } /** Current follow-up mode */ get followUpMode(): "all" | "one-at-a-time" { return this.agent.getFollowUpMode(); } /** Current session file path, or undefined if sessions are disabled */ get sessionFile(): string | undefined { return this.sessionManager.getSessionFile(); } /** Current session ID */ get sessionId(): string { return this.sessionManager.getSessionId(); } /** Scoped models for cycling (from --models flag) */ get scopedModels(): ReadonlyArray<{ model: Model; thinkingLevel: ThinkingLevel }> { return this._scopedModels; } /** File-based prompt templates */ get promptTemplates(): ReadonlyArray { return this._promptTemplates; } // ========================================================================= // Prompting // ========================================================================= /** * Send a prompt to the agent. * - Handles extension commands (registered via pi.registerCommand) immediately, even during streaming * - Expands file-based prompt templates by default * - During streaming, queues via steer() or followUp() based on streamingBehavior option * - Validates model and API key before sending (when not streaming) * @throws Error if streaming and no streamingBehavior specified * @throws Error if no model selected or no API key available (when not streaming) */ async prompt(text: string, options?: PromptOptions): Promise { const expandPromptTemplates = options?.expandPromptTemplates ?? true; // Handle extension commands first (execute immediately, even during streaming) // Extension commands manage their own LLM interaction via pi.sendMessage() if (expandPromptTemplates && text.startsWith("/")) { const handled = await this._tryExecuteExtensionCommand(text); if (handled) { // Extension command executed, no prompt to send return; } } // Expand file-based prompt templates if requested const expandedText = expandPromptTemplates ? expandPromptTemplate(text, [...this._promptTemplates]) : text; // If streaming, queue via steer() or followUp() based on option if (this.isStreaming) { if (!options?.streamingBehavior) { throw new Error( "Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message.", ); } if (options.streamingBehavior === "followUp") { await this._queueFollowUp(expandedText); } else { await this._queueSteer(expandedText); } return; } // Flush any pending bash messages before the new prompt this._flushPendingBashMessages(); // Validate model if (!this.model) { throw new Error( "No model selected.\n\n" + `Use /login, set an API key environment variable, or create ${getAuthPath()}\n\n` + "Then use /model to select a model.", ); } // Validate API key const apiKey = await this._modelRegistry.getApiKey(this.model); if (!apiKey) { throw new Error( `No API key found for ${this.model.provider}.\n\n` + `Use /login, set an API key environment variable, or create ${getAuthPath()}`, ); } // Check if we need to compact before sending (catches aborted responses) const lastAssistant = this._findLastAssistantMessage(); if (lastAssistant) { await this._checkCompaction(lastAssistant, false); } // Build messages array (custom message if any, then user message) const messages: AgentMessage[] = []; // Add user message const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }]; if (options?.images) { userContent.push(...options.images); } messages.push({ role: "user", content: userContent, timestamp: Date.now(), }); // Inject any pending "nextTurn" messages as context alongside the user message for (const msg of this._pendingNextTurnMessages) { messages.push(msg); } this._pendingNextTurnMessages = []; // Emit before_agent_start extension event if (this._extensionRunner) { const result = await this._extensionRunner.emitBeforeAgentStart(expandedText, options?.images); // Add all custom messages from extensions if (result?.messages) { for (const msg of result.messages) { messages.push({ role: "custom", customType: msg.customType, content: msg.content, display: msg.display, details: msg.details, timestamp: Date.now(), }); } } // Apply extension systemPromptAppend on top of base prompt if (result?.systemPromptAppend) { this.agent.setSystemPrompt(`${this._baseSystemPrompt}\n\n${result.systemPromptAppend}`); } else { // Ensure we're using the base prompt (in case previous turn had appends) this.agent.setSystemPrompt(this._baseSystemPrompt); } } await this.agent.prompt(messages); await this.waitForRetry(); } /** * Try to execute an extension command. Returns true if command was found and executed. */ private async _tryExecuteExtensionCommand(text: string): Promise { if (!this._extensionRunner) return false; // Parse command name and args const spaceIndex = text.indexOf(" "); const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1); const command = this._extensionRunner.getCommand(commandName); if (!command) return false; // Get command context from extension runner (includes session control methods) const ctx = this._extensionRunner.createCommandContext(); try { await command.handler(args, ctx); return true; } catch (err) { // Emit error via extension runner this._extensionRunner.emitError({ extensionPath: `command:${commandName}`, event: "command", error: err instanceof Error ? err.message : String(err), }); return true; } } /** * Queue a steering message to interrupt the agent mid-run. * Delivered after current tool execution, skips remaining tools. * Expands file-based prompt templates. Errors on extension commands. * @throws Error if text is an extension command */ async steer(text: string): Promise { // Check for extension commands (cannot be queued) if (text.startsWith("/")) { this._throwIfExtensionCommand(text); } // Expand file-based prompt templates const expandedText = expandPromptTemplate(text, [...this._promptTemplates]); await this._queueSteer(expandedText); } /** * Queue a follow-up message to be processed after the agent finishes. * Delivered only when agent has no more tool calls or steering messages. * Expands file-based prompt templates. Errors on extension commands. * @throws Error if text is an extension command */ async followUp(text: string): Promise { // Check for extension commands (cannot be queued) if (text.startsWith("/")) { this._throwIfExtensionCommand(text); } // Expand file-based prompt templates const expandedText = expandPromptTemplate(text, [...this._promptTemplates]); await this._queueFollowUp(expandedText); } /** * Internal: Queue a steering message (already expanded, no extension command check). */ private async _queueSteer(text: string): Promise { this._steeringMessages.push(text); this.agent.steer({ role: "user", content: [{ type: "text", text }], timestamp: Date.now(), }); } /** * Internal: Queue a follow-up message (already expanded, no extension command check). */ private async _queueFollowUp(text: string): Promise { this._followUpMessages.push(text); this.agent.followUp({ role: "user", content: [{ type: "text", text }], timestamp: Date.now(), }); } /** * Throw an error if the text is an extension command. */ private _throwIfExtensionCommand(text: string): void { if (!this._extensionRunner) return; const spaceIndex = text.indexOf(" "); const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); const command = this._extensionRunner.getCommand(commandName); if (command) { throw new Error( `Extension command "/${commandName}" cannot be queued. Use prompt() or execute the command when not streaming.`, ); } } /** * Send a custom message to the session. Creates a CustomMessageEntry. * * Handles three cases: * - Streaming: queues message, processed when loop pulls from queue * - Not streaming + triggerTurn: appends to state/session, starts new turn * - Not streaming + no trigger: appends to state/session, no turn * * @param message Custom message with customType, content, display, details * @param options.triggerTurn If true and not streaming, triggers a new LLM turn * @param options.deliverAs Delivery mode: "steer", "followUp", or "nextTurn" */ async sendCustomMessage( message: Pick, "customType" | "content" | "display" | "details">, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }, ): Promise { const appMessage = { role: "custom" as const, customType: message.customType, content: message.content, display: message.display, details: message.details, timestamp: Date.now(), } satisfies CustomMessage; if (options?.deliverAs === "nextTurn") { this._pendingNextTurnMessages.push(appMessage); } else if (this.isStreaming) { if (options?.deliverAs === "followUp") { this.agent.followUp(appMessage); } else { this.agent.steer(appMessage); } } else if (options?.triggerTurn) { await this.agent.prompt(appMessage); } else { this.agent.appendMessage(appMessage); this.sessionManager.appendCustomMessageEntry( message.customType, message.content, message.display, message.details, ); } } /** * Send a user message to the agent. Always triggers a turn. * When the agent is streaming, use deliverAs to specify how to queue the message. * * @param content User message content (string or content array) * @param options.deliverAs Delivery mode when streaming: "steer" or "followUp" */ async sendUserMessage( content: string | (TextContent | ImageContent)[], options?: { deliverAs?: "steer" | "followUp" }, ): Promise { // Normalize content to text string + optional images let text: string; let images: ImageContent[] | undefined; if (typeof content === "string") { text = content; } else { const textParts: string[] = []; images = []; for (const part of content) { if (part.type === "text") { textParts.push(part.text); } else { images.push(part); } } text = textParts.join("\n"); if (images.length === 0) images = undefined; } // Use prompt() with expandPromptTemplates: false to skip command handling and template expansion await this.prompt(text, { expandPromptTemplates: false, streamingBehavior: options?.deliverAs, images, }); } /** * Clear all queued messages and return them. * Useful for restoring to editor when user aborts. * @returns Object with steering and followUp arrays */ clearQueue(): { steering: string[]; followUp: string[] } { const steering = [...this._steeringMessages]; const followUp = [...this._followUpMessages]; this._steeringMessages = []; this._followUpMessages = []; this.agent.clearAllQueues(); return { steering, followUp }; } /** Number of pending messages (includes both steering and follow-up) */ get pendingMessageCount(): number { return this._steeringMessages.length + this._followUpMessages.length; } /** Get pending steering messages (read-only) */ getSteeringMessages(): readonly string[] { return this._steeringMessages; } /** Get pending follow-up messages (read-only) */ getFollowUpMessages(): readonly string[] { return this._followUpMessages; } get skillsSettings(): Required | undefined { return this._skillsSettings; } /** * Abort current operation and wait for agent to become idle. */ async abort(): Promise { this.abortRetry(); this.agent.abort(); await this.agent.waitForIdle(); } /** * Start a new session, optionally with initial messages and parent tracking. * Clears all messages and starts a new session. * Listeners are preserved and will continue receiving events. * @param options - Optional initial messages and parent session path * @returns true if completed, false if cancelled by extension */ async newSession(options?: NewSessionOptions): Promise { const previousSessionFile = this.sessionFile; // Emit session_before_switch event with reason "new" (can be cancelled) if (this._extensionRunner?.hasHandlers("session_before_switch")) { const result = (await this._extensionRunner.emit({ type: "session_before_switch", reason: "new", })) as SessionBeforeSwitchResult | undefined; if (result?.cancel) { return false; } } this._disconnectFromAgent(); await this.abort(); this.agent.reset(); this.sessionManager.newSession(options); this.agent.sessionId = this.sessionManager.getSessionId(); this._steeringMessages = []; this._followUpMessages = []; this._pendingNextTurnMessages = []; this._reconnectToAgent(); // Emit session_switch event with reason "new" to extensions if (this._extensionRunner) { await this._extensionRunner.emit({ type: "session_switch", reason: "new", previousSessionFile, }); } // Emit session event to custom tools return true; } // ========================================================================= // Model Management // ========================================================================= /** * Set model directly. * Validates API key, saves to session and settings. * @throws Error if no API key available for the model */ async setModel(model: Model): Promise { const apiKey = await this._modelRegistry.getApiKey(model); if (!apiKey) { throw new Error(`No API key for ${model.provider}/${model.id}`); } this.agent.setModel(model); this.sessionManager.appendModelChange(model.provider, model.id); this.settingsManager.setDefaultModelAndProvider(model.provider, model.id); // Re-clamp thinking level for new model's capabilities this.setThinkingLevel(this.thinkingLevel); } /** * Cycle to next/previous model. * Uses scoped models (from --models flag) if available, otherwise all available models. * @param direction - "forward" (default) or "backward" * @returns The new model info, or undefined if only one model available */ async cycleModel(direction: "forward" | "backward" = "forward"): Promise { if (this._scopedModels.length > 0) { return this._cycleScopedModel(direction); } return this._cycleAvailableModel(direction); } private async _cycleScopedModel(direction: "forward" | "backward"): Promise { if (this._scopedModels.length <= 1) return undefined; const currentModel = this.model; let currentIndex = this._scopedModels.findIndex((sm) => modelsAreEqual(sm.model, currentModel)); if (currentIndex === -1) currentIndex = 0; const len = this._scopedModels.length; const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len; const next = this._scopedModels[nextIndex]; // Validate API key const apiKey = await this._modelRegistry.getApiKey(next.model); if (!apiKey) { throw new Error(`No API key for ${next.model.provider}/${next.model.id}`); } // Apply model this.agent.setModel(next.model); this.sessionManager.appendModelChange(next.model.provider, next.model.id); this.settingsManager.setDefaultModelAndProvider(next.model.provider, next.model.id); // Apply thinking level (setThinkingLevel clamps to model capabilities) this.setThinkingLevel(next.thinkingLevel); return { model: next.model, thinkingLevel: this.thinkingLevel, isScoped: true }; } private async _cycleAvailableModel(direction: "forward" | "backward"): Promise { const availableModels = await this._modelRegistry.getAvailable(); if (availableModels.length <= 1) return undefined; const currentModel = this.model; let currentIndex = availableModels.findIndex((m) => modelsAreEqual(m, currentModel)); if (currentIndex === -1) currentIndex = 0; const len = availableModels.length; const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len; const nextModel = availableModels[nextIndex]; const apiKey = await this._modelRegistry.getApiKey(nextModel); if (!apiKey) { throw new Error(`No API key for ${nextModel.provider}/${nextModel.id}`); } this.agent.setModel(nextModel); this.sessionManager.appendModelChange(nextModel.provider, nextModel.id); this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id); // Re-clamp thinking level for new model's capabilities this.setThinkingLevel(this.thinkingLevel); return { model: nextModel, thinkingLevel: this.thinkingLevel, isScoped: false }; } /** * Get all available models with valid API keys. */ async getAvailableModels(): Promise[]> { return this._modelRegistry.getAvailable(); } // ========================================================================= // Thinking Level Management // ========================================================================= /** * Set thinking level. * Clamps to model capabilities based on available thinking levels. * Saves to session and settings. */ setThinkingLevel(level: ThinkingLevel): void { const availableLevels = this.getAvailableThinkingLevels(); const effectiveLevel = availableLevels.includes(level) ? level : this._clampThinkingLevel(level, availableLevels); this.agent.setThinkingLevel(effectiveLevel); this.sessionManager.appendThinkingLevelChange(effectiveLevel); this.settingsManager.setDefaultThinkingLevel(effectiveLevel); } /** * Cycle to next thinking level. * @returns New level, or undefined if model doesn't support thinking */ cycleThinkingLevel(): ThinkingLevel | undefined { if (!this.supportsThinking()) return undefined; const levels = this.getAvailableThinkingLevels(); const currentIndex = levels.indexOf(this.thinkingLevel); const nextIndex = (currentIndex + 1) % levels.length; const nextLevel = levels[nextIndex]; this.setThinkingLevel(nextLevel); return nextLevel; } /** * Get available thinking levels for current model. * The provider will clamp to what the specific model supports internally. */ getAvailableThinkingLevels(): ThinkingLevel[] { if (!this.supportsThinking()) return ["off"]; return this.supportsXhighThinking() ? THINKING_LEVELS_WITH_XHIGH : THINKING_LEVELS; } /** * Check if current model supports xhigh thinking level. */ supportsXhighThinking(): boolean { return this.model ? supportsXhigh(this.model) : false; } /** * Check if current model supports thinking/reasoning. */ supportsThinking(): boolean { return !!this.model?.reasoning; } private _clampThinkingLevel(level: ThinkingLevel, availableLevels: ThinkingLevel[]): ThinkingLevel { const ordered = THINKING_LEVELS_WITH_XHIGH; const available = new Set(availableLevels); const requestedIndex = ordered.indexOf(level); if (requestedIndex === -1) { return availableLevels[0] ?? "off"; } for (let i = requestedIndex; i < ordered.length; i++) { const candidate = ordered[i]; if (available.has(candidate)) return candidate; } for (let i = requestedIndex - 1; i >= 0; i--) { const candidate = ordered[i]; if (available.has(candidate)) return candidate; } return availableLevels[0] ?? "off"; } // ========================================================================= // Queue Mode Management // ========================================================================= /** * Set steering message mode. * Saves to settings. */ setSteeringMode(mode: "all" | "one-at-a-time"): void { this.agent.setSteeringMode(mode); this.settingsManager.setSteeringMode(mode); } /** * Set follow-up message mode. * Saves to settings. */ setFollowUpMode(mode: "all" | "one-at-a-time"): void { this.agent.setFollowUpMode(mode); this.settingsManager.setFollowUpMode(mode); } // ========================================================================= // Compaction // ========================================================================= /** * Manually compact the session context. * Aborts current agent operation first. * @param customInstructions Optional instructions for the compaction summary */ async compact(customInstructions?: string): Promise { this._disconnectFromAgent(); await this.abort(); this._compactionAbortController = new AbortController(); try { if (!this.model) { throw new Error("No model selected"); } const apiKey = await this._modelRegistry.getApiKey(this.model); if (!apiKey) { throw new Error(`No API key for ${this.model.provider}`); } const pathEntries = this.sessionManager.getBranch(); const settings = this.settingsManager.getCompactionSettings(); const preparation = prepareCompaction(pathEntries, settings); if (!preparation) { // Check why we can't compact const lastEntry = pathEntries[pathEntries.length - 1]; if (lastEntry?.type === "compaction") { throw new Error("Already compacted"); } throw new Error("Nothing to compact (session too small)"); } let extensionCompaction: CompactionResult | undefined; let fromExtension = false; if (this._extensionRunner?.hasHandlers("session_before_compact")) { const result = (await this._extensionRunner.emit({ type: "session_before_compact", preparation, branchEntries: pathEntries, customInstructions, signal: this._compactionAbortController.signal, })) as SessionBeforeCompactResult | undefined; if (result?.cancel) { throw new Error("Compaction cancelled"); } if (result?.compaction) { extensionCompaction = result.compaction; fromExtension = true; } } let summary: string; let firstKeptEntryId: string; let tokensBefore: number; let details: unknown; if (extensionCompaction) { // Extension provided compaction content summary = extensionCompaction.summary; firstKeptEntryId = extensionCompaction.firstKeptEntryId; tokensBefore = extensionCompaction.tokensBefore; details = extensionCompaction.details; } else { // Generate compaction result const result = await compact( preparation, this.model, apiKey, customInstructions, this._compactionAbortController.signal, ); summary = result.summary; firstKeptEntryId = result.firstKeptEntryId; tokensBefore = result.tokensBefore; details = result.details; } if (this._compactionAbortController.signal.aborted) { throw new Error("Compaction cancelled"); } this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension); const newEntries = this.sessionManager.getEntries(); const sessionContext = this.sessionManager.buildSessionContext(); this.agent.replaceMessages(sessionContext.messages); // Get the saved compaction entry for the extension event const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary) as | CompactionEntry | undefined; if (this._extensionRunner && savedCompactionEntry) { await this._extensionRunner.emit({ type: "session_compact", compactionEntry: savedCompactionEntry, fromExtension, }); } return { summary, firstKeptEntryId, tokensBefore, details, }; } finally { this._compactionAbortController = undefined; this._reconnectToAgent(); } } /** * Cancel in-progress compaction (manual or auto). */ abortCompaction(): void { this._compactionAbortController?.abort(); this._autoCompactionAbortController?.abort(); } /** * Cancel in-progress branch summarization. */ abortBranchSummary(): void { this._branchSummaryAbortController?.abort(); } /** * Check if compaction is needed and run it. * Called after agent_end and before prompt submission. * * Two cases: * 1. Overflow: LLM returned context overflow error, remove error message from agent state, compact, auto-retry * 2. Threshold: Context over threshold, compact, NO auto-retry (user continues manually) * * @param assistantMessage The assistant message to check * @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true */ private async _checkCompaction(assistantMessage: AssistantMessage, skipAbortedCheck = true): Promise { const settings = this.settingsManager.getCompactionSettings(); if (!settings.enabled) return; // Skip if message was aborted (user cancelled) - unless skipAbortedCheck is false if (skipAbortedCheck && assistantMessage.stopReason === "aborted") return; const contextWindow = this.model?.contextWindow ?? 0; // Skip overflow check if the message came from a different model. // This handles the case where user switched from a smaller-context model (e.g. opus) // to a larger-context model (e.g. codex) - the overflow error from the old model // shouldn't trigger compaction for the new model. const sameModel = this.model && assistantMessage.provider === this.model.provider && assistantMessage.model === this.model.id; // Skip overflow check if the error is from before a compaction in the current path. // This handles the case where an error was kept after compaction (in the "kept" region). // The error shouldn't trigger another compaction since we already compacted. // Example: opus fails → switch to codex → compact → switch back to opus → opus error // is still in context but shouldn't trigger compaction again. const compactionEntry = this.sessionManager.getBranch().find((e) => e.type === "compaction"); const errorIsFromBeforeCompaction = compactionEntry && assistantMessage.timestamp < new Date(compactionEntry.timestamp).getTime(); // Case 1: Overflow - LLM returned context overflow error if (sameModel && !errorIsFromBeforeCompaction && isContextOverflow(assistantMessage, contextWindow)) { // Remove the error message from agent state (it IS saved to session for history, // but we don't want it in context for the retry) const messages = this.agent.state.messages; if (messages.length > 0 && messages[messages.length - 1].role === "assistant") { this.agent.replaceMessages(messages.slice(0, -1)); } await this._runAutoCompaction("overflow", true); return; } // Case 2: Threshold - turn succeeded but context is getting large // Skip if this was an error (non-overflow errors don't have usage data) if (assistantMessage.stopReason === "error") return; const contextTokens = calculateContextTokens(assistantMessage.usage); if (shouldCompact(contextTokens, contextWindow, settings)) { await this._runAutoCompaction("threshold", false); } } /** * Internal: Run auto-compaction with events. */ private async _runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean): Promise { const settings = this.settingsManager.getCompactionSettings(); this._emit({ type: "auto_compaction_start", reason }); this._autoCompactionAbortController = new AbortController(); try { if (!this.model) { this._emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false }); return; } const apiKey = await this._modelRegistry.getApiKey(this.model); if (!apiKey) { this._emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false }); return; } const pathEntries = this.sessionManager.getBranch(); const preparation = prepareCompaction(pathEntries, settings); if (!preparation) { this._emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false }); return; } let extensionCompaction: CompactionResult | undefined; let fromExtension = false; if (this._extensionRunner?.hasHandlers("session_before_compact")) { const extensionResult = (await this._extensionRunner.emit({ type: "session_before_compact", preparation, branchEntries: pathEntries, customInstructions: undefined, signal: this._autoCompactionAbortController.signal, })) as SessionBeforeCompactResult | undefined; if (extensionResult?.cancel) { this._emit({ type: "auto_compaction_end", result: undefined, aborted: true, willRetry: false }); return; } if (extensionResult?.compaction) { extensionCompaction = extensionResult.compaction; fromExtension = true; } } let summary: string; let firstKeptEntryId: string; let tokensBefore: number; let details: unknown; if (extensionCompaction) { // Extension provided compaction content summary = extensionCompaction.summary; firstKeptEntryId = extensionCompaction.firstKeptEntryId; tokensBefore = extensionCompaction.tokensBefore; details = extensionCompaction.details; } else { // Generate compaction result const compactResult = await compact( preparation, this.model, apiKey, undefined, this._autoCompactionAbortController.signal, ); summary = compactResult.summary; firstKeptEntryId = compactResult.firstKeptEntryId; tokensBefore = compactResult.tokensBefore; details = compactResult.details; } if (this._autoCompactionAbortController.signal.aborted) { this._emit({ type: "auto_compaction_end", result: undefined, aborted: true, willRetry: false }); return; } this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension); const newEntries = this.sessionManager.getEntries(); const sessionContext = this.sessionManager.buildSessionContext(); this.agent.replaceMessages(sessionContext.messages); // Get the saved compaction entry for the extension event const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary) as | CompactionEntry | undefined; if (this._extensionRunner && savedCompactionEntry) { await this._extensionRunner.emit({ type: "session_compact", compactionEntry: savedCompactionEntry, fromExtension, }); } const result: CompactionResult = { summary, firstKeptEntryId, tokensBefore, details, }; this._emit({ type: "auto_compaction_end", result, aborted: false, willRetry }); if (willRetry) { const messages = this.agent.state.messages; const lastMsg = messages[messages.length - 1]; if (lastMsg?.role === "assistant" && (lastMsg as AssistantMessage).stopReason === "error") { this.agent.replaceMessages(messages.slice(0, -1)); } setTimeout(() => { this.agent.continue().catch(() => {}); }, 100); } } catch (error) { this._emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false }); if (reason === "overflow") { throw new Error( `Context overflow: ${error instanceof Error ? error.message : "compaction failed"}. Your input may be too large for the context window.`, ); } } finally { this._autoCompactionAbortController = undefined; } } /** * Toggle auto-compaction setting. */ setAutoCompactionEnabled(enabled: boolean): void { this.settingsManager.setCompactionEnabled(enabled); } /** Whether auto-compaction is enabled */ get autoCompactionEnabled(): boolean { return this.settingsManager.getCompactionEnabled(); } // ========================================================================= // Auto-Retry // ========================================================================= /** * Check if an error is retryable (overloaded, rate limit, server errors). * Context overflow errors are NOT retryable (handled by compaction instead). */ private _isRetryableError(message: AssistantMessage): boolean { if (message.stopReason !== "error" || !message.errorMessage) return false; // Context overflow is handled by compaction, not retry const contextWindow = this.model?.contextWindow ?? 0; if (isContextOverflow(message, contextWindow)) return false; const err = message.errorMessage; // Match: overloaded_error, rate limit, 429, 500, 502, 503, 504, service unavailable, connection error return /overloaded|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server error|internal error|connection.?error/i.test( err, ); } /** * Handle retryable errors with exponential backoff. * @returns true if retry was initiated, false if max retries exceeded or disabled */ private async _handleRetryableError(message: AssistantMessage): Promise { const settings = this.settingsManager.getRetrySettings(); if (!settings.enabled) return false; this._retryAttempt++; // Create retry promise on first attempt so waitForRetry() can await it if (this._retryAttempt === 1 && !this._retryPromise) { this._retryPromise = new Promise((resolve) => { this._retryResolve = resolve; }); } if (this._retryAttempt > settings.maxRetries) { // Max retries exceeded, emit final failure and reset this._emit({ type: "auto_retry_end", success: false, attempt: this._retryAttempt - 1, finalError: message.errorMessage, }); this._retryAttempt = 0; this._resolveRetry(); // Resolve so waitForRetry() completes return false; } const delayMs = settings.baseDelayMs * 2 ** (this._retryAttempt - 1); this._emit({ type: "auto_retry_start", attempt: this._retryAttempt, maxAttempts: settings.maxRetries, delayMs, errorMessage: message.errorMessage || "Unknown error", }); // Remove error message from agent state (keep in session for history) const messages = this.agent.state.messages; if (messages.length > 0 && messages[messages.length - 1].role === "assistant") { this.agent.replaceMessages(messages.slice(0, -1)); } // Wait with exponential backoff (abortable) this._retryAbortController = new AbortController(); try { await this._sleep(delayMs, this._retryAbortController.signal); } catch { // Aborted during sleep - emit end event so UI can clean up const attempt = this._retryAttempt; this._retryAttempt = 0; this._retryAbortController = undefined; this._emit({ type: "auto_retry_end", success: false, attempt, finalError: "Retry cancelled", }); this._resolveRetry(); return false; } this._retryAbortController = undefined; // Retry via continue() - use setTimeout to break out of event handler chain setTimeout(() => { this.agent.continue().catch(() => { // Retry failed - will be caught by next agent_end }); }, 0); return true; } /** * Sleep helper that respects abort signal. */ private _sleep(ms: number, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { if (signal?.aborted) { reject(new Error("Aborted")); return; } const timeout = setTimeout(resolve, ms); signal?.addEventListener("abort", () => { clearTimeout(timeout); reject(new Error("Aborted")); }); }); } /** * Cancel in-progress retry. */ abortRetry(): void { this._retryAbortController?.abort(); // Note: _retryAttempt is reset in the catch block of _autoRetry this._resolveRetry(); } /** * Wait for any in-progress retry to complete. * Returns immediately if no retry is in progress. */ private async waitForRetry(): Promise { if (this._retryPromise) { await this._retryPromise; } } /** Whether auto-retry is currently in progress */ get isRetrying(): boolean { return this._retryPromise !== undefined; } /** Whether auto-retry is enabled */ get autoRetryEnabled(): boolean { return this.settingsManager.getRetryEnabled(); } /** * Toggle auto-retry setting. */ setAutoRetryEnabled(enabled: boolean): void { this.settingsManager.setRetryEnabled(enabled); } // ========================================================================= // Bash Execution // ========================================================================= /** * Execute a bash command. * Adds result to agent context and session. * @param command The bash command to execute * @param onChunk Optional streaming callback for output * @param options.excludeFromContext If true, command output won't be sent to LLM (!! prefix) */ async executeBash( command: string, onChunk?: (chunk: string) => void, options?: { excludeFromContext?: boolean }, ): Promise { this._bashAbortController = new AbortController(); try { const result = await executeBashCommand(command, { onChunk, signal: this._bashAbortController.signal, }); // Create and save message const bashMessage: BashExecutionMessage = { role: "bashExecution", command, output: result.output, exitCode: result.exitCode, cancelled: result.cancelled, truncated: result.truncated, fullOutputPath: result.fullOutputPath, timestamp: Date.now(), excludeFromContext: options?.excludeFromContext, }; // If agent is streaming, defer adding to avoid breaking tool_use/tool_result ordering if (this.isStreaming) { // Queue for later - will be flushed on agent_end this._pendingBashMessages.push(bashMessage); } else { // Add to agent state immediately this.agent.appendMessage(bashMessage); // Save to session this.sessionManager.appendMessage(bashMessage); } return result; } finally { this._bashAbortController = undefined; } } /** * Cancel running bash command. */ abortBash(): void { this._bashAbortController?.abort(); } /** Whether a bash command is currently running */ get isBashRunning(): boolean { return this._bashAbortController !== undefined; } /** Whether there are pending bash messages waiting to be flushed */ get hasPendingBashMessages(): boolean { return this._pendingBashMessages.length > 0; } /** * Flush pending bash messages to agent state and session. * Called after agent turn completes to maintain proper message ordering. */ private _flushPendingBashMessages(): void { if (this._pendingBashMessages.length === 0) return; for (const bashMessage of this._pendingBashMessages) { // Add to agent state this.agent.appendMessage(bashMessage); // Save to session this.sessionManager.appendMessage(bashMessage); } this._pendingBashMessages = []; } // ========================================================================= // Session Management // ========================================================================= /** * Switch to a different session file. * Aborts current operation, loads messages, restores model/thinking. * Listeners are preserved and will continue receiving events. * @returns true if switch completed, false if cancelled by extension */ async switchSession(sessionPath: string): Promise { const previousSessionFile = this.sessionManager.getSessionFile(); // Emit session_before_switch event (can be cancelled) if (this._extensionRunner?.hasHandlers("session_before_switch")) { const result = (await this._extensionRunner.emit({ type: "session_before_switch", reason: "resume", targetSessionFile: sessionPath, })) as SessionBeforeSwitchResult | undefined; if (result?.cancel) { return false; } } this._disconnectFromAgent(); await this.abort(); this._steeringMessages = []; this._followUpMessages = []; this._pendingNextTurnMessages = []; // Set new session this.sessionManager.setSessionFile(sessionPath); this.agent.sessionId = this.sessionManager.getSessionId(); // Reload messages const sessionContext = this.sessionManager.buildSessionContext(); // Emit session_switch event to extensions if (this._extensionRunner) { await this._extensionRunner.emit({ type: "session_switch", reason: "resume", previousSessionFile, }); } // Emit session event to custom tools this.agent.replaceMessages(sessionContext.messages); // Restore model if saved if (sessionContext.model) { const availableModels = await this._modelRegistry.getAvailable(); const match = availableModels.find( (m) => m.provider === sessionContext.model!.provider && m.id === sessionContext.model!.modelId, ); if (match) { this.agent.setModel(match); } } // Restore thinking level if saved (setThinkingLevel clamps to model capabilities) if (sessionContext.thinkingLevel) { this.setThinkingLevel(sessionContext.thinkingLevel as ThinkingLevel); } this._reconnectToAgent(); return true; } /** * Create a branch from a specific entry. * Emits before_branch/branch session events to extensions. * * @param entryId ID of the entry to branch from * @returns Object with: * - selectedText: The text of the selected user message (for editor pre-fill) * - cancelled: True if an extension cancelled the branch */ async branch(entryId: string): Promise<{ selectedText: string; cancelled: boolean }> { const previousSessionFile = this.sessionFile; const selectedEntry = this.sessionManager.getEntry(entryId); if (!selectedEntry || selectedEntry.type !== "message" || selectedEntry.message.role !== "user") { throw new Error("Invalid entry ID for branching"); } const selectedText = this._extractUserMessageText(selectedEntry.message.content); let skipConversationRestore = false; // Emit session_before_branch event (can be cancelled) if (this._extensionRunner?.hasHandlers("session_before_branch")) { const result = (await this._extensionRunner.emit({ type: "session_before_branch", entryId, })) as SessionBeforeBranchResult | undefined; if (result?.cancel) { return { selectedText, cancelled: true }; } skipConversationRestore = result?.skipConversationRestore ?? false; } // Clear pending messages (bound to old session state) this._pendingNextTurnMessages = []; if (!selectedEntry.parentId) { this.sessionManager.newSession(); } else { this.sessionManager.createBranchedSession(selectedEntry.parentId); } this.agent.sessionId = this.sessionManager.getSessionId(); // Reload messages from entries (works for both file and in-memory mode) const sessionContext = this.sessionManager.buildSessionContext(); // Emit session_branch event to extensions (after branch completes) if (this._extensionRunner) { await this._extensionRunner.emit({ type: "session_branch", previousSessionFile, }); } // Emit session event to custom tools (with reason "branch") if (!skipConversationRestore) { this.agent.replaceMessages(sessionContext.messages); } return { selectedText, cancelled: false }; } // ========================================================================= // Tree Navigation // ========================================================================= /** * Navigate to a different node in the session tree. * Unlike branch() which creates a new session file, this stays in the same file. * * @param targetId The entry ID to navigate to * @param options.summarize Whether user wants to summarize abandoned branch * @param options.customInstructions Custom instructions for summarizer * @returns Result with editorText (if user message) and cancelled status */ async navigateTree( targetId: string, options: { summarize?: boolean; customInstructions?: string } = {}, ): Promise<{ editorText?: string; cancelled: boolean; aborted?: boolean; summaryEntry?: BranchSummaryEntry }> { const oldLeafId = this.sessionManager.getLeafId(); // No-op if already at target if (targetId === oldLeafId) { return { cancelled: false }; } // Model required for summarization if (options.summarize && !this.model) { throw new Error("No model available for summarization"); } const targetEntry = this.sessionManager.getEntry(targetId); if (!targetEntry) { throw new Error(`Entry ${targetId} not found`); } // Collect entries to summarize (from old leaf to common ancestor) const { entries: entriesToSummarize, commonAncestorId } = collectEntriesForBranchSummary( this.sessionManager, oldLeafId, targetId, ); // Prepare event data const preparation: TreePreparation = { targetId, oldLeafId, commonAncestorId, entriesToSummarize, userWantsSummary: options.summarize ?? false, }; // Set up abort controller for summarization this._branchSummaryAbortController = new AbortController(); let extensionSummary: { summary: string; details?: unknown } | undefined; let fromExtension = false; // Emit session_before_tree event if (this._extensionRunner?.hasHandlers("session_before_tree")) { const result = (await this._extensionRunner.emit({ type: "session_before_tree", preparation, signal: this._branchSummaryAbortController.signal, })) as SessionBeforeTreeResult | undefined; if (result?.cancel) { return { cancelled: true }; } if (result?.summary && options.summarize) { extensionSummary = result.summary; fromExtension = true; } } // Run default summarizer if needed let summaryText: string | undefined; let summaryDetails: unknown; if (options.summarize && entriesToSummarize.length > 0 && !extensionSummary) { const model = this.model!; const apiKey = await this._modelRegistry.getApiKey(model); if (!apiKey) { throw new Error(`No API key for ${model.provider}`); } const branchSummarySettings = this.settingsManager.getBranchSummarySettings(); const result = await generateBranchSummary(entriesToSummarize, { model, apiKey, signal: this._branchSummaryAbortController.signal, customInstructions: options.customInstructions, reserveTokens: branchSummarySettings.reserveTokens, }); this._branchSummaryAbortController = undefined; if (result.aborted) { return { cancelled: true, aborted: true }; } if (result.error) { throw new Error(result.error); } summaryText = result.summary; summaryDetails = { readFiles: result.readFiles || [], modifiedFiles: result.modifiedFiles || [], }; } else if (extensionSummary) { summaryText = extensionSummary.summary; summaryDetails = extensionSummary.details; } // Determine the new leaf position based on target type let newLeafId: string | null; let editorText: string | undefined; if (targetEntry.type === "message" && targetEntry.message.role === "user") { // User message: leaf = parent (null if root), text goes to editor newLeafId = targetEntry.parentId; editorText = this._extractUserMessageText(targetEntry.message.content); } else if (targetEntry.type === "custom_message") { // Custom message: leaf = parent (null if root), text goes to editor newLeafId = targetEntry.parentId; editorText = typeof targetEntry.content === "string" ? targetEntry.content : targetEntry.content .filter((c): c is { type: "text"; text: string } => c.type === "text") .map((c) => c.text) .join(""); } else { // Non-user message: leaf = selected node newLeafId = targetId; } // Switch leaf (with or without summary) // Summary is attached at the navigation target position (newLeafId), not the old branch let summaryEntry: BranchSummaryEntry | undefined; if (summaryText) { // Create summary at target position (can be null for root) const summaryId = this.sessionManager.branchWithSummary(newLeafId, summaryText, summaryDetails, fromExtension); summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry; } else if (newLeafId === null) { // No summary, navigating to root - reset leaf this.sessionManager.resetLeaf(); } else { // No summary, navigating to non-root this.sessionManager.branch(newLeafId); } // Update agent state const sessionContext = this.sessionManager.buildSessionContext(); this.agent.replaceMessages(sessionContext.messages); // Emit session_tree event if (this._extensionRunner) { await this._extensionRunner.emit({ type: "session_tree", newLeafId: this.sessionManager.getLeafId(), oldLeafId, summaryEntry, fromExtension: summaryText ? fromExtension : undefined, }); } // Emit to custom tools this._branchSummaryAbortController = undefined; return { editorText, cancelled: false, summaryEntry }; } /** * Get all user messages from session for branch selector. */ getUserMessagesForBranching(): Array<{ entryId: string; text: string }> { const entries = this.sessionManager.getEntries(); const result: Array<{ entryId: string; text: string }> = []; for (const entry of entries) { if (entry.type !== "message") continue; if (entry.message.role !== "user") continue; const text = this._extractUserMessageText(entry.message.content); if (text) { result.push({ entryId: entry.id, text }); } } return result; } private _extractUserMessageText(content: string | Array<{ type: string; text?: string }>): string { if (typeof content === "string") return content; if (Array.isArray(content)) { return content .filter((c): c is { type: "text"; text: string } => c.type === "text") .map((c) => c.text) .join(""); } return ""; } /** * Get session statistics. */ getSessionStats(): SessionStats { const state = this.state; const userMessages = state.messages.filter((m) => m.role === "user").length; const assistantMessages = state.messages.filter((m) => m.role === "assistant").length; const toolResults = state.messages.filter((m) => m.role === "toolResult").length; let toolCalls = 0; let totalInput = 0; let totalOutput = 0; let totalCacheRead = 0; let totalCacheWrite = 0; let totalCost = 0; for (const message of state.messages) { if (message.role === "assistant") { const assistantMsg = message as AssistantMessage; toolCalls += assistantMsg.content.filter((c) => c.type === "toolCall").length; totalInput += assistantMsg.usage.input; totalOutput += assistantMsg.usage.output; totalCacheRead += assistantMsg.usage.cacheRead; totalCacheWrite += assistantMsg.usage.cacheWrite; totalCost += assistantMsg.usage.cost.total; } } return { sessionFile: this.sessionFile, sessionId: this.sessionId, userMessages, assistantMessages, toolCalls, toolResults, totalMessages: state.messages.length, tokens: { input: totalInput, output: totalOutput, cacheRead: totalCacheRead, cacheWrite: totalCacheWrite, total: totalInput + totalOutput + totalCacheRead + totalCacheWrite, }, cost: totalCost, }; } /** * Export session to HTML. * @param outputPath Optional output path (defaults to session directory) * @returns Path to exported file */ async exportToHtml(outputPath?: string): Promise { const themeName = this.settingsManager.getTheme(); return await exportSessionToHtml(this.sessionManager, this.state, { outputPath, themeName }); } // ========================================================================= // Utilities // ========================================================================= /** * Get text content of last assistant message. * Useful for /copy command. * @returns Text content, or undefined if no assistant message exists */ getLastAssistantText(): string | undefined { const lastAssistant = this.messages .slice() .reverse() .find((m) => { if (m.role !== "assistant") return false; const msg = m as AssistantMessage; // Skip aborted messages with no content if (msg.stopReason === "aborted" && msg.content.length === 0) return false; return true; }); if (!lastAssistant) return undefined; let text = ""; for (const content of (lastAssistant as AssistantMessage).content) { if (content.type === "text") { text += content.text; } } return text.trim() || undefined; } // ========================================================================= // Extension System // ========================================================================= /** * Check if extensions have handlers for a specific event type. */ hasExtensionHandlers(eventType: string): boolean { return this._extensionRunner?.hasHandlers(eventType) ?? false; } /** * Get the extension runner (for setting UI context and error handlers). */ get extensionRunner(): ExtensionRunner | undefined { return this._extensionRunner; } }