agent-session.ts
109 KB3328 lines
agent-session.ts
1/**2 * AgentSession - Core abstraction for agent lifecycle and session management.3 *4 * This class is shared between all run modes (interactive, print, rpc).5 * It encapsulates:6 * - Agent state access7 * - Event subscription with automatic session persistence8 * - Model and thinking level management9 * - Compaction (manual and auto)10 * - Bash execution11 * - Session switching and branching12 *13 * Modes use this class and add their own I/O layer on top.14 */1516import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";17import { basename, dirname } from "node:path";18import type {19 Agent,20 AgentEvent,21 AgentMessage,22 AgentState,23 AgentTool,24 PrepareNextTurnContext,25 ThinkingLevel,26} from "@earendil-works/pi-agent-core";27import { contentText } from "@earendil-works/pi-ai";28import type {29 AssistantMessage,30 AuthResult,31 ImageContent,32 Model,33 ProviderHeaders,34 TextContent,35 Usage,36} from "@earendil-works/pi-ai/compat";37import {38 clampThinkingLevel,39 cleanupSessionResources,40 getSupportedThinkingLevels,41 isContextOverflow,42 isRetryableAssistantError,43 modelsAreEqual,44 type RetryCallbacks,45 resetApiProviders,46 streamSimple,47} from "@earendil-works/pi-ai/compat";48import { getThemeByName, theme } from "../modes/interactive/theme/theme.ts";49import { stripFrontmatter } from "../utils/frontmatter.ts";50import { resolvePath } from "../utils/paths.ts";51import { sleep } from "../utils/sleep.ts";52import { formatNoApiKeyFoundMessage, formatNoModelSelectedMessage } from "./auth-guidance.ts";53import { type BashResult, executeBashWithOperations } from "./bash-executor.ts";54import {55 type CompactionResult,56 calculateContextTokens,57 collectEntriesForBranchSummary,58 compact,59 estimateContextTokens,60 estimateTokens,61 generateBranchSummary,62 prepareCompaction,63 shouldCompact,64} from "./compaction/index.ts";65import { DEFAULT_THINKING_LEVEL } from "./defaults.ts";66import { exportSessionToHtml, type ToolHtmlRenderer } from "./export-html/index.ts";67import { createToolHtmlRenderer } from "./export-html/tool-renderer.ts";68import {69 type ContextUsage,70 type ExtensionCommandContextActions,71 type ExtensionErrorListener,72 type ExtensionMode,73 ExtensionRunner,74 type ExtensionUIContext,75 type InputSource,76 type MessageEndEvent,77 type MessageStartEvent,78 type MessageUpdateEvent,79 type ReplacedSessionContext,80 type SessionBeforeCompactResult,81 type SessionBeforeTreeResult,82 type SessionStartEvent,83 type ShutdownHandler,84 type ToolDefinition,85 type ToolExecutionEndEvent,86 type ToolExecutionStartEvent,87 type ToolExecutionUpdateEvent,88 type ToolInfo,89 type TreePreparation,90 type TurnEndEvent,91 type TurnStartEvent,92 wrapRegisteredTools,93} from "./extensions/index.ts";94import { emitSessionShutdownEvent } from "./extensions/runner.ts";95import type { BashExecutionMessage, CustomMessage } from "./messages.ts";96import { ModelRegistry } from "./model-registry.ts";97import type { ModelRuntime } from "./model-runtime.ts";98import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.ts";99import type { ResourceExtensionPaths, ResourceLoader } from "./resource-loader.ts";100import type { BranchSummaryEntry, CompactionEntry, SessionEntry, SessionManager } from "./session-manager.ts";101import { CURRENT_SESSION_VERSION, getLatestCompactionEntry, type SessionHeader } from "./session-manager.ts";102import type { SettingsManager } from "./settings-manager.ts";103import type { SlashCommandInfo } from "./slash-commands.ts";104import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts";105import { type BuildSystemPromptOptions, buildSystemPrompt } from "./system-prompt.ts";106import { type BashOperations, createLocalBashOperations } from "./tools/bash.ts";107import { createAllToolDefinitions } from "./tools/index.ts";108import { createToolDefinitionFromAgentTool } from "./tools/tool-definition-wrapper.ts";109import { addUsageToTotals, createUsageTotals } from "./usage-totals.ts";110111// ============================================================================112// Skill Block Parsing113// ============================================================================114115/** Parsed skill block from a user message */116export interface ParsedSkillBlock {117 name: string;118 location: string;119 content: string;120 userMessage: string | undefined;121}122123/**124 * Parse a skill block from message text.125 * Returns null if the text doesn't contain a skill block.126 */127export function parseSkillBlock(text: string): ParsedSkillBlock | null {128 const match = text.match(/^<skill name="([^"]+)" location="([^"]+)">\n([\s\S]*?)\n<\/skill>(?:\n\n([\s\S]+))?$/);129 if (!match) return null;130 return {131 name: match[1],132 location: match[2],133 content: match[3],134 userMessage: match[4]?.trim() || undefined,135 };136}137138/** Session-specific events that extend the core AgentEvent */139export type AgentSessionEvent =140 | Exclude<AgentEvent, { type: "agent_end" }>141 | {142 type: "agent_end";143 messages: AgentMessage[];144 willRetry: boolean;145 }146 | { type: "agent_settled" }147 | {148 type: "queue_update";149 steering: readonly string[];150 followUp: readonly string[];151 }152 | { type: "compaction_start"; reason: "manual" | "threshold" | "overflow" }153 | { type: "entry_appended"; entry: SessionEntry }154 | { type: "session_info_changed"; name: string | undefined }155 | { type: "thinking_level_changed"; level: ThinkingLevel }156 | {157 type: "compaction_end";158 reason: "manual" | "threshold" | "overflow";159 result: CompactionResult | undefined;160 aborted: boolean;161 willRetry: boolean;162 errorMessage?: string;163 }164 | { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string }165 | { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }166 | {167 type: "summarization_retry_scheduled";168 attempt: number;169 maxAttempts: number;170 delayMs: number;171 errorMessage: string;172 }173 | { type: "summarization_retry_attempt_start"; source: "branchSummary" }174 | {175 type: "summarization_retry_attempt_start";176 source: "compaction";177 reason: "manual" | "threshold" | "overflow";178 }179 | { type: "summarization_retry_finished" }180 | { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }181 | { type: "bash_execution_update"; id?: string; delta: string };182183/** Listener function for agent session events */184export type AgentSessionEventListener = (event: AgentSessionEvent) => void;185186// ============================================================================187// Types188// ============================================================================189190function withoutDeletedHeaders(headers: ProviderHeaders | undefined): Record<string, string> | undefined {191 return headers192 ? Object.fromEntries(Object.entries(headers).filter((entry): entry is [string, string] => entry[1] !== null))193 : undefined;194}195196export interface AgentSessionConfig {197 agent: Agent;198 sessionManager: SessionManager;199 settingsManager: SettingsManager;200 cwd: string;201 /** Models to cycle through with Ctrl+P (from --models flag) */202 scopedModels?: Array<{ model: Model<any>; thinkingLevel?: ThinkingLevel }>;203 /** Resource loader for extensions, skills, prompts, themes, context files, and system prompt */204 resourceLoader: ResourceLoader;205 /** SDK custom tools registered outside extensions */206 customTools?: ToolDefinition[];207 /** Canonical model/auth runtime used by coding-agent internals. */208 modelRuntime: ModelRuntime;209 /** Initial active built-in tool names. Default: [read, bash, edit, write] */210 initialActiveToolNames?: string[];211 /** Optional allowlist of tool names. When provided, only these tool names are exposed. */212 allowedToolNames?: string[];213 /** Optional denylist of tool names. When provided, these tool names are not exposed. */214 excludedToolNames?: string[];215 /**216 * Override base tools (useful for custom runtimes).217 *218 * These are synthesized into minimal ToolDefinitions internally so AgentSession can keep219 * a definition-first registry even when callers provide plain AgentTool instances.220 */221 baseToolsOverride?: Record<string, AgentTool>;222 /** Mutable ref used by Agent to access the current ExtensionRunner */223 extensionRunnerRef?: { current?: ExtensionRunner };224 /** Session start event metadata emitted when extensions bind to this runtime. */225 sessionStartEvent?: SessionStartEvent;226}227228export interface ExtensionBindings {229 uiContext?: ExtensionUIContext;230 mode?: ExtensionMode;231 commandContextActions?: ExtensionCommandContextActions;232 abortHandler?: () => void;233 shutdownHandler?: ShutdownHandler;234 onError?: ExtensionErrorListener;235}236237/** Options for AgentSession.prompt() */238export interface PromptOptions {239 /** Whether to expand file-based prompt templates (default: true) */240 expandPromptTemplates?: boolean;241 /** Image attachments */242 images?: ImageContent[];243 /** When streaming, how to queue the message: "steer" (interrupt) or "followUp" (wait). Required if streaming. */244 streamingBehavior?: "steer" | "followUp";245 /** Source of input for extension input event handlers. Defaults to "interactive". */246 source?: InputSource;247 /** Internal hook used by RPC mode to observe prompt preflight acceptance or rejection. */248 preflightResult?: (success: boolean) => void;249}250251/** Result from cycleModel() */252export interface ModelCycleResult {253 model: Model<any>;254 thinkingLevel: ThinkingLevel;255 /** Whether cycling through scoped models (--models flag) or all available */256 isScoped: boolean;257}258259/** Session statistics for /session command */260export interface SessionStats {261 sessionFile: string | undefined;262 sessionId: string;263 userMessages: number;264 assistantMessages: number;265 toolCalls: number;266 toolResults: number;267 totalMessages: number;268 tokens: {269 input: number;270 output: number;271 cacheRead: number;272 cacheWrite: number;273 total: number;274 };275 cost: number;276 contextUsage?: ContextUsage;277}278279interface ToolDefinitionEntry {280 definition: ToolDefinition;281 sourceInfo: SourceInfo;282}283284function estimateMessagesTokens(messages: AgentMessage[]): number {285 let tokens = 0;286 for (const message of messages) {287 tokens += estimateTokens(message);288 }289 return tokens;290}291292// ============================================================================293// Constants294// ============================================================================295296/** Standard thinking levels */297const THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high"];298299// ============================================================================300// AgentSession Class301// ============================================================================302303export class AgentSession {304 readonly agent: Agent;305 readonly sessionManager: SessionManager;306 readonly settingsManager: SettingsManager;307308 private _scopedModels: Array<{ model: Model<any>; thinkingLevel?: ThinkingLevel }>;309310 // Event subscription state311 private _unsubscribeAgent?: () => void;312 private _eventListeners: AgentSessionEventListener[] = [];313 private _isAgentRunActive = false;314 private _idleWaitPromise: Promise<void> | undefined;315 private _resolveIdleWait: (() => void) | undefined;316317 /** Tracks pending steering messages for UI display. Removed when delivered. */318 private _steeringMessages: string[] = [];319 /** Tracks pending follow-up messages for UI display. Removed when delivered. */320 private _followUpMessages: string[] = [];321 /** Messages queued to be included with the next user prompt as context ("asides"). */322 private _pendingNextTurnMessages: CustomMessage[] = [];323324 // Compaction state325 private _compactionAbortController: AbortController | undefined = undefined;326 private _autoCompactionAbortController: AbortController | undefined = undefined;327 private _overflowRecoveryAttempted = false;328329 // Branch summarization state330 private _branchSummaryAbortController: AbortController | undefined = undefined;331332 // Retry state333 private _retryAbortController: AbortController | undefined = undefined;334 private _retryAttempt = 0;335336 // Bash execution state337 private readonly _bashAbortControllers = new Set<AbortController>();338 private _pendingBashMessages: BashExecutionMessage[] = [];339340 // Extension system341 private _extensionRunner!: ExtensionRunner;342 private _turnIndex = 0;343344 private _resourceLoader: ResourceLoader;345 private _customTools: ToolDefinition[];346 private _baseToolDefinitions: Map<string, ToolDefinition> = new Map();347 private _cwd: string;348 private _extensionRunnerRef?: { current?: ExtensionRunner };349 private _initialActiveToolNames?: string[];350 private _allowedToolNames?: Set<string>;351 private _excludedToolNames?: Set<string>;352 private _baseToolsOverride?: Record<string, AgentTool>;353 private _sessionStartEvent: SessionStartEvent;354 private _extensionUIContext?: ExtensionUIContext;355 private _extensionMode: ExtensionMode = "print";356 private _extensionCommandContextActions?: ExtensionCommandContextActions;357 private _extensionAbortHandler?: () => void;358 private _extensionShutdownHandler?: ShutdownHandler;359 private _extensionErrorListener?: ExtensionErrorListener;360 private _extensionErrorUnsubscriber?: () => void;361362 private _modelRuntime: ModelRuntime;363364 // Tool registry for extension getTools/setTools365 private _toolRegistry: Map<string, AgentTool> = new Map();366 private _toolDefinitions: Map<string, ToolDefinitionEntry> = new Map();367 private _toolPromptSnippets: Map<string, string> = new Map();368 private _toolPromptGuidelines: Map<string, string[]> = new Map();369370 // Base system prompt (without extension appends) - used to apply fresh appends each turn371 private _baseSystemPrompt = "";372 private _baseSystemPromptOptions!: BuildSystemPromptOptions;373 private _systemPromptOverride?: string;374375 constructor(config: AgentSessionConfig) {376 this.agent = config.agent;377 this.sessionManager = config.sessionManager;378 this.settingsManager = config.settingsManager;379 this._scopedModels = config.scopedModels ?? [];380 this._resourceLoader = config.resourceLoader;381 this._customTools = config.customTools ?? [];382 this._cwd = config.cwd;383 this._modelRuntime = config.modelRuntime;384 this._extensionRunnerRef = config.extensionRunnerRef;385 this._initialActiveToolNames = config.initialActiveToolNames;386 this._allowedToolNames = config.allowedToolNames ? new Set(config.allowedToolNames) : undefined;387 this._excludedToolNames = config.excludedToolNames ? new Set(config.excludedToolNames) : undefined;388 this._baseToolsOverride = config.baseToolsOverride;389 this._sessionStartEvent = config.sessionStartEvent ?? { type: "session_start", reason: "startup" };390391 // Always subscribe to agent events for internal handling392 // (session persistence, extensions, auto-compaction, retry logic)393 this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);394 this._installAgentToolHooks();395 this._installAgentNextTurnRefresh();396397 this._buildRuntime({398 activeToolNames: this._initialActiveToolNames,399 includeAllExtensionTools: true,400 });401 }402403 get modelRuntime(): ModelRuntime {404 return this._modelRuntime;405 }406407 private async _getRequiredRequestAuth(model: Model<any>): Promise<{408 apiKey?: string;409 headers?: Record<string, string>;410 env?: Record<string, string>;411 }> {412 let result: AuthResult | undefined;413 try {414 result = await this._modelRuntime.getAuth(model);415 } catch (error) {416 const cause = error instanceof Error ? error.cause : undefined;417 if (cause instanceof Error && cause.message === "authHeader requires a resolved API key") {418 throw new Error(formatNoApiKeyFoundMessage(model.provider));419 }420 throw error;421 }422 if (result && (result.auth.apiKey || result.auth.headers)) {423 return {424 apiKey: result.auth.apiKey,425 headers: withoutDeletedHeaders(result.auth.headers),426 env: result.env,427 };428 }429430 const isOAuth = this._modelRuntime.isUsingOAuth(model.provider);431 if (isOAuth) {432 throw new Error(433 `Authentication failed for "${model.provider}". ` +434 `Credentials may have expired or network is unavailable. ` +435 `Run '/login ${model.provider}' to re-authenticate.`,436 );437 }438 throw new Error(formatNoApiKeyFoundMessage(model.provider));439 }440441 private async _getSummarizationRequestAuth(model: Model<any>): Promise<{442 apiKey?: string;443 headers?: Record<string, string>;444 env?: Record<string, string>;445 }> {446 if (this.agent.streamFunction === streamSimple) {447 return this._getRequiredRequestAuth(model);448 }449450 try {451 const result = await this._modelRuntime.getAuth(model);452 return result453 ? { apiKey: result.auth.apiKey, headers: withoutDeletedHeaders(result.auth.headers), env: result.env }454 : {};455 } catch {456 return {};457 }458 }459460 /**461 * Install tool hooks once on the Agent instance.462 *463 * The callbacks read `this._extensionRunner` at execution time, so extension reload swaps in the464 * new runner without reinstalling hooks. Extension-specific tool wrappers are still used to adapt465 * registered tool execution to the extension context. Tool call and tool result interception now466 * happens here instead of in wrappers.467 */468 private _installAgentToolHooks(): void {469 this.agent.beforeToolCall = async ({ toolCall, args }) => {470 const runner = this._extensionRunner;471 if (!runner.hasHandlers("tool_call")) {472 return undefined;473 }474475 try {476 return await runner.emitToolCall({477 type: "tool_call",478 toolName: toolCall.name,479 toolCallId: toolCall.id,480 input: args as Record<string, unknown>,481 });482 } catch (err) {483 if (err instanceof Error) {484 throw err;485 }486 throw new Error(`Extension failed, blocking execution: ${String(err)}`);487 }488 };489490 this.agent.afterToolCall = async ({ toolCall, args, result, isError }) => {491 const runner = this._extensionRunner;492 if (!runner.hasHandlers("tool_result")) {493 return undefined;494 }495496 const hookResult = await runner.emitToolResult({497 type: "tool_result",498 toolName: toolCall.name,499 toolCallId: toolCall.id,500 input: args as Record<string, unknown>,501 content: result.content,502 details: result.details,503 isError,504 usage: result.usage,505 });506507 if (!hookResult) {508 return undefined;509 }510511 return {512 content: hookResult.content,513 details: hookResult.details,514 isError: hookResult.isError ?? isError,515 usage: hookResult.usage,516 };517 };518 }519520 private _installAgentNextTurnRefresh(): void {521 const previousPrepareNextTurnWithContext =522 this.agent.prepareNextTurnWithContext ??523 (this.agent.prepareNextTurn524 ? async (_turn: PrepareNextTurnContext, signal?: AbortSignal) => await this.agent.prepareNextTurn?.(signal)525 : undefined);526 this.agent.prepareNextTurnWithContext = async (turn, signal) => {527 const previousSnapshot = await previousPrepareNextTurnWithContext?.(turn, signal);528 const previousContext = previousSnapshot?.context ?? turn.context;529530 return {531 ...previousSnapshot,532 context: {533 ...previousContext,534 systemPrompt: this._systemPromptOverride ?? this._baseSystemPrompt,535 tools: this.agent.state.tools.slice(),536 },537 model: this.agent.state.model,538 thinkingLevel: this.agent.state.thinkingLevel,539 };540 };541 }542543 // =========================================================================544 // Event Subscription545 // =========================================================================546547 /** Emit an event to all listeners */548 private _emit(event: AgentSessionEvent): void {549 for (const l of this._eventListeners) {550 l(event);551 }552 }553554 private _emitQueueUpdate(): void {555 this._emit({556 type: "queue_update",557 steering: [...this._steeringMessages],558 followUp: [...this._followUpMessages],559 });560 }561562 private _getIdleWaitPromise(): Promise<void> {563 if (!this._idleWaitPromise) {564 this._idleWaitPromise = new Promise((resolve) => {565 this._resolveIdleWait = resolve;566 });567 }568 return this._idleWaitPromise;569 }570571 private _resolveIdleWaitIfIdle(): void {572 if (this._isAgentRunActive || !this._resolveIdleWait) {573 return;574 }575 const resolve = this._resolveIdleWait;576 this._idleWaitPromise = undefined;577 this._resolveIdleWait = undefined;578 resolve();579 }580581 private async _emitAgentSettled(): Promise<void> {582 this._isAgentRunActive = false;583 try {584 await this._extensionRunner.emit({ type: "agent_settled" });585 this._emit({ type: "agent_settled" });586 } finally {587 this._resolveIdleWaitIfIdle();588 }589 }590591 // Track last assistant message for auto-compaction check592 private _lastAssistantMessage: AssistantMessage | undefined = undefined;593594 /** Internal handler for agent events - shared by subscribe and reconnect */595 private _handleAgentEvent = async (event: AgentEvent): Promise<void> => {596 // When a user message starts, check if it's from either queue and remove it BEFORE emitting597 // This ensures the UI sees the updated queue state598 if (event.type === "message_start" && event.message.role === "user") {599 this._overflowRecoveryAttempted = false;600 const messageText = contentText(event.message.content, "");601 if (messageText) {602 // Check steering queue first603 const steeringIndex = this._steeringMessages.indexOf(messageText);604 if (steeringIndex !== -1) {605 this._steeringMessages.splice(steeringIndex, 1);606 this._emitQueueUpdate();607 } else {608 // Check follow-up queue609 const followUpIndex = this._followUpMessages.indexOf(messageText);610 if (followUpIndex !== -1) {611 this._followUpMessages.splice(followUpIndex, 1);612 this._emitQueueUpdate();613 }614 }615 }616 }617618 // Emit to extensions first619 await this._emitExtensionEvent(event);620621 // Notify all listeners622 this._emit(event.type === "agent_end" ? { ...event, willRetry: this._willRetryAfterAgentEnd(event) } : event);623624 // Handle session persistence625 if (event.type === "message_end") {626 // Check if this is a custom message from extensions627 if (event.message.role === "custom") {628 // Persist as CustomMessageEntry629 this.sessionManager.appendCustomMessageEntry(630 event.message.customType,631 event.message.content,632 event.message.display,633 event.message.details,634 );635 } else if (636 event.message.role === "user" ||637 event.message.role === "assistant" ||638 event.message.role === "toolResult"639 ) {640 // Regular LLM message - persist as SessionMessageEntry641 this.sessionManager.appendMessage(event.message);642 }643 // Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere644645 // Track assistant message for auto-compaction (checked on agent_end)646 if (event.message.role === "assistant") {647 this._lastAssistantMessage = event.message;648649 const assistantMsg = event.message as AssistantMessage;650 if (assistantMsg.stopReason !== "error") {651 this._overflowRecoveryAttempted = false;652 }653654 // Reset retry counter immediately on successful assistant response655 // This prevents accumulation across multiple LLM calls within a turn656 if (assistantMsg.stopReason !== "error" && this._retryAttempt > 0) {657 this._emit({658 type: "auto_retry_end",659 success: true,660 attempt: this._retryAttempt,661 });662 this._retryAttempt = 0;663 }664 }665 }666 };667668 private _willRetryAfterAgentEnd(event: Extract<AgentEvent, { type: "agent_end" }>): boolean {669 const settings = this.settingsManager.getRetrySettings();670 if (!settings.enabled || this._retryAttempt >= settings.maxRetries) {671 return false;672 }673674 for (let i = event.messages.length - 1; i >= 0; i--) {675 const message = event.messages[i];676 if (message.role === "assistant") {677 return this._isRetryableError(message as AssistantMessage);678 }679 }680 return false;681 }682683 /** Find the last assistant message in agent state (including aborted ones) */684 private _findLastAssistantMessage(): AssistantMessage | undefined {685 const messages = this.agent.state.messages;686 for (let i = messages.length - 1; i >= 0; i--) {687 const msg = messages[i];688 if (msg.role === "assistant") {689 return msg as AssistantMessage;690 }691 }692 return undefined;693 }694695 private _replaceMessageInPlace(target: AgentMessage, replacement: AgentMessage): void {696 // Agent-core stores the finalized message object in its state before emitting message_end.697 // SessionManager persistence happens later in _handleAgentEvent() with event.message.698 // Mutating this object in place keeps agent state, later turn/agent events, listeners,699 // and the eventual SessionManager.appendMessage(event.message) persistence in sync.700 if (target === replacement) {701 return;702 }703704 const targetRecord = target as unknown as Record<string, unknown>;705 for (const key of Object.keys(targetRecord)) {706 delete targetRecord[key];707 }708 Object.assign(targetRecord, replacement);709 }710711 /** Emit extension events based on agent events */712 private async _emitExtensionEvent(event: AgentEvent): Promise<void> {713 if (event.type === "agent_start") {714 this._turnIndex = 0;715 await this._extensionRunner.emit({ type: "agent_start" });716 } else if (event.type === "agent_end") {717 await this._extensionRunner.emit({ type: "agent_end", messages: event.messages });718 } else if (event.type === "turn_start") {719 const extensionEvent: TurnStartEvent = {720 type: "turn_start",721 turnIndex: this._turnIndex,722 timestamp: Date.now(),723 };724 await this._extensionRunner.emit(extensionEvent);725 } else if (event.type === "turn_end") {726 const extensionEvent: TurnEndEvent = {727 type: "turn_end",728 turnIndex: this._turnIndex,729 message: event.message,730 toolResults: event.toolResults,731 };732 await this._extensionRunner.emit(extensionEvent);733 this._turnIndex++;734 } else if (event.type === "message_start") {735 const extensionEvent: MessageStartEvent = {736 type: "message_start",737 message: event.message,738 };739 await this._extensionRunner.emit(extensionEvent);740 } else if (event.type === "message_update") {741 const extensionEvent: MessageUpdateEvent = {742 type: "message_update",743 message: event.message,744 assistantMessageEvent: event.assistantMessageEvent,745 };746 await this._extensionRunner.emit(extensionEvent);747 } else if (event.type === "message_end") {748 const extensionEvent: MessageEndEvent = {749 type: "message_end",750 message: event.message,751 };752 const replacement = await this._extensionRunner.emitMessageEnd(extensionEvent);753 if (replacement) {754 // Untyped extension handlers can return messages with null/missing content;755 // normalize so it never enters agent state or session history.756 const normalized =757 (replacement.role === "user" ||758 replacement.role === "assistant" ||759 replacement.role === "toolResult" ||760 replacement.role === "custom") &&761 replacement.content == null762 ? ({ ...replacement, content: [] } as AgentMessage)763 : replacement;764 this._replaceMessageInPlace(event.message, normalized);765 }766 } else if (event.type === "tool_execution_start") {767 const extensionEvent: ToolExecutionStartEvent = {768 type: "tool_execution_start",769 toolCallId: event.toolCallId,770 toolName: event.toolName,771 args: event.args,772 };773 await this._extensionRunner.emit(extensionEvent);774 } else if (event.type === "tool_execution_update") {775 const extensionEvent: ToolExecutionUpdateEvent = {776 type: "tool_execution_update",777 toolCallId: event.toolCallId,778 toolName: event.toolName,779 args: event.args,780 partialResult: event.partialResult,781 };782 await this._extensionRunner.emit(extensionEvent);783 } else if (event.type === "tool_execution_end") {784 const extensionEvent: ToolExecutionEndEvent = {785 type: "tool_execution_end",786 toolCallId: event.toolCallId,787 toolName: event.toolName,788 result: event.result,789 isError: event.isError,790 };791 await this._extensionRunner.emit(extensionEvent);792 }793 }794795 /**796 * Subscribe to agent events.797 * Session persistence is handled internally (saves messages on message_end).798 * Multiple listeners can be added. Returns unsubscribe function for this listener.799 */800 subscribe(listener: AgentSessionEventListener): () => void {801 this._eventListeners.push(listener);802803 // Return unsubscribe function for this specific listener804 return () => {805 const index = this._eventListeners.indexOf(listener);806 if (index !== -1) {807 this._eventListeners.splice(index, 1);808 }809 };810 }811812 /**813 * Temporarily disconnect from agent events.814 * User listeners are preserved and will receive events again after resubscribe().815 * Used internally during operations that need to pause event processing.816 */817 private _disconnectFromAgent(): void {818 if (this._unsubscribeAgent) {819 this._unsubscribeAgent();820 this._unsubscribeAgent = undefined;821 }822 }823824 /**825 * Reconnect to agent events after _disconnectFromAgent().826 * Preserves all existing listeners.827 */828 private _reconnectToAgent(): void {829 if (this._unsubscribeAgent) return; // Already connected830 this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);831 }832833 /**834 * Remove all listeners and disconnect from agent.835 * Call this when completely done with the session.836 */837 dispose(): void {838 try {839 this.abortRetry();840 this.abortCompaction();841 this.abortBranchSummary();842 this.abortBash();843 this.agent.abort();844 } catch {845 // Dispose must succeed even if an abort hook throws.846 }847848 this._extensionRunner.invalidate(849 "This extension ctx is stale after session replacement or reload. Do not use a captured pi or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload(). For newSession, fork, and switchSession, move post-replacement work into withSession and use the ctx passed to withSession. For reload, do not use the old ctx after await ctx.reload().",850 );851 this._disconnectFromAgent();852 this._eventListeners = [];853 cleanupSessionResources(this.sessionId);854 }855856 // =========================================================================857 // Read-only State Access858 // =========================================================================859860 /** Full agent state */861 get state(): AgentState {862 return this.agent.state;863 }864865 /** Current model (may be undefined if not yet selected) */866 get model(): Model<any> | undefined {867 return this.agent.state.model;868 }869870 /** Current thinking level */871 get thinkingLevel(): ThinkingLevel {872 return this.agent.state.thinkingLevel;873 }874875 /** Whether the session is currently processing an agent run or post-run continuation. */876 get isStreaming(): boolean {877 return this._isAgentRunActive;878 }879880 /** Whether the session has no active agent run, retry, auto-compaction, or queued continuation. */881 get isIdle(): boolean {882 return !this._isAgentRunActive;883 }884885 /** Current effective system prompt (includes any per-turn extension modifications) */886 get systemPrompt(): string {887 return this.agent.state.systemPrompt;888 }889890 /** Current retry attempt (0 if not retrying) */891 get retryAttempt(): number {892 return this._retryAttempt;893 }894895 /**896 * Get the names of currently active tools.897 * Returns the names of tools currently set on the agent.898 */899 getActiveToolNames(): string[] {900 return this.agent.state.tools.map((t) => t.name);901 }902903 /**904 * Get all configured tools with name, description, parameter schema, prompt guidelines, and source metadata.905 */906 getAllTools(): ToolInfo[] {907 return Array.from(this._toolDefinitions.values()).map(({ definition, sourceInfo }) => ({908 name: definition.name,909 description: definition.description,910 parameters: definition.parameters,911 promptGuidelines: definition.promptGuidelines,912 sourceInfo,913 }));914 }915916 getToolDefinition(name: string): ToolDefinition | undefined {917 return this._toolDefinitions.get(name)?.definition;918 }919920 /**921 * Set active tools by name.922 * Only tools in the registry can be enabled. Unknown tool names are ignored.923 * Also rebuilds the system prompt to reflect the new tool set.924 * Changes take effect on the next agent turn.925 */926 setActiveToolsByName(toolNames: string[]): void {927 const tools: AgentTool[] = [];928 const validToolNames: string[] = [];929 for (const name of toolNames) {930 const tool = this._toolRegistry.get(name);931 if (tool) {932 tools.push(tool);933 validToolNames.push(name);934 }935 }936 this.agent.state.tools = tools;937938 // Rebuild base system prompt with new tool set939 this._baseSystemPrompt = this._rebuildSystemPrompt(validToolNames);940 this.agent.state.systemPrompt = this._systemPromptOverride ?? this._baseSystemPrompt;941 }942943 /** Whether compaction or branch summarization is currently running */944 get isCompacting(): boolean {945 return (946 this._autoCompactionAbortController !== undefined ||947 this._compactionAbortController !== undefined ||948 this._branchSummaryAbortController !== undefined949 );950 }951952 /** All messages including custom types like BashExecutionMessage */953 get messages(): AgentMessage[] {954 return this.agent.state.messages;955 }956957 /** Current steering mode */958 get steeringMode(): "all" | "one-at-a-time" {959 return this.agent.steeringMode;960 }961962 /** Current follow-up mode */963 get followUpMode(): "all" | "one-at-a-time" {964 return this.agent.followUpMode;965 }966967 /** Current session file path, or undefined if sessions are disabled */968 get sessionFile(): string | undefined {969 return this.sessionManager.getSessionFile();970 }971972 /** Current session ID */973 get sessionId(): string {974 return this.sessionManager.getSessionId();975 }976977 /** Current session display name, if set */978 get sessionName(): string | undefined {979 return this.sessionManager.getSessionName();980 }981982 /** Scoped models for cycling (from --models flag) */983 get scopedModels(): ReadonlyArray<{ model: Model<any>; thinkingLevel?: ThinkingLevel }> {984 return this._scopedModels;985 }986987 /** Update scoped models for cycling */988 setScopedModels(scopedModels: Array<{ model: Model<any>; thinkingLevel?: ThinkingLevel }>): void {989 this._scopedModels = scopedModels;990 }991992 /** File-based prompt templates */993 get promptTemplates(): ReadonlyArray<PromptTemplate> {994 return this._resourceLoader.getPrompts().prompts;995 }996997 private _normalizePromptSnippet(text: string | undefined): string | undefined {998 if (!text) return undefined;999 const oneLine = text1000 .replace(/[\r\n]+/g, " ")1001 .replace(/\s+/g, " ")1002 .trim();1003 return oneLine.length > 0 ? oneLine : undefined;1004 }10051006 private _normalizePromptGuidelines(guidelines: string[] | undefined): string[] {1007 if (!guidelines || guidelines.length === 0) {1008 return [];1009 }10101011 const unique = new Set<string>();1012 for (const guideline of guidelines) {1013 const normalized = guideline.trim();1014 if (normalized.length > 0) {1015 unique.add(normalized);1016 }1017 }1018 return Array.from(unique);1019 }10201021 private _rebuildSystemPrompt(toolNames: string[]): string {1022 const validToolNames = toolNames.filter((name) => this._toolRegistry.has(name));1023 const toolSnippets: Record<string, string> = {};1024 const promptGuidelines: string[] = [];1025 for (const name of validToolNames) {1026 const snippet = this._toolPromptSnippets.get(name);1027 if (snippet) {1028 toolSnippets[name] = snippet;1029 }10301031 const toolGuidelines = this._toolPromptGuidelines.get(name);1032 if (toolGuidelines) {1033 promptGuidelines.push(...toolGuidelines);1034 }1035 }10361037 const loaderSystemPrompt = this._resourceLoader.getSystemPrompt();1038 const loaderAppendSystemPrompt = this._resourceLoader.getAppendSystemPrompt();1039 const appendSystemPrompt =1040 loaderAppendSystemPrompt.length > 0 ? loaderAppendSystemPrompt.join("\n\n") : undefined;1041 const loadedSkills = this._resourceLoader.getSkills().skills;1042 const loadedContextFiles = this._resourceLoader.getAgentsFiles().agentsFiles;10431044 this._baseSystemPromptOptions = {1045 cwd: this._cwd,1046 skills: loadedSkills,1047 contextFiles: loadedContextFiles,1048 customPrompt: loaderSystemPrompt,1049 appendSystemPrompt,1050 selectedTools: validToolNames,1051 toolSnippets,1052 promptGuidelines,1053 };1054 return buildSystemPrompt(this._baseSystemPromptOptions);1055 }10561057 // =========================================================================1058 // Prompting1059 // =========================================================================10601061 private async _runAgentPrompt(messages: AgentMessage | AgentMessage[]): Promise<void> {1062 this._isAgentRunActive = true;1063 try {1064 await this.agent.prompt(messages);1065 while (await this._handlePostAgentRun()) {1066 await this.agent.continue();1067 }1068 } finally {1069 this._systemPromptOverride = undefined;1070 this._flushPendingBashMessages();1071 await this._emitAgentSettled();1072 }1073 }10741075 private async _handlePostAgentRun(): Promise<boolean> {1076 const msg = this._lastAssistantMessage;1077 this._lastAssistantMessage = undefined;1078 if (!msg) {1079 return false;1080 }10811082 if (this._isRetryableError(msg) && (await this._prepareRetry(msg))) {1083 return true;1084 }10851086 if (msg.stopReason === "error" && this._retryAttempt > 0) {1087 this._emit({1088 type: "auto_retry_end",1089 success: false,1090 attempt: this._retryAttempt,1091 finalError: msg.errorMessage,1092 });1093 this._retryAttempt = 0;1094 }10951096 if (await this._checkCompaction(msg)) {1097 return true;1098 }10991100 // The agent loop drains both queues before emitting agent_end. Any messages1101 // here were queued by agent_end extension handlers and need a continuation.1102 return this.agent.hasQueuedMessages();1103 }11041105 /**1106 * Send a prompt to the agent.1107 * - Handles extension commands (registered via pi.registerCommand) immediately, even during streaming1108 * - Expands file-based prompt templates by default1109 * - During streaming, queues via steer() or followUp() based on streamingBehavior option1110 * - Validates model and API key before sending (when not streaming)1111 * @throws Error if streaming and no streamingBehavior specified1112 * @throws Error if no model selected or no API key available (when not streaming)1113 */1114 async prompt(text: string, options?: PromptOptions): Promise<void> {1115 const expandPromptTemplates = options?.expandPromptTemplates ?? true;1116 const preflightResult = options?.preflightResult;1117 let messages: AgentMessage[] | undefined;11181119 try {1120 // Handle extension commands first (execute immediately, even during streaming)1121 // Extension commands manage their own LLM interaction via pi.sendMessage()1122 if (expandPromptTemplates && text.startsWith("/")) {1123 const handled = await this._tryExecuteExtensionCommand(text);1124 if (handled) {1125 // Extension command executed, no prompt to send1126 preflightResult?.(true);1127 return;1128 }1129 }11301131 // Emit input event for extension interception (before skill/template expansion)1132 let currentText = text;1133 let currentImages = options?.images;1134 if (this._extensionRunner.hasHandlers("input")) {1135 const inputResult = await this._extensionRunner.emitInput(1136 currentText,1137 currentImages,1138 options?.source ?? "interactive",1139 this.isStreaming ? options?.streamingBehavior : undefined,1140 );1141 if (inputResult.action === "handled") {1142 preflightResult?.(true);1143 return;1144 }1145 if (inputResult.action === "transform") {1146 currentText = inputResult.text;1147 currentImages = inputResult.images ?? currentImages;1148 }1149 }11501151 // Expand skill commands (/skill:name args) and prompt templates (/template args)1152 let expandedText = currentText;1153 if (expandPromptTemplates) {1154 expandedText = this._expandSkillCommand(expandedText);1155 expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]);1156 }11571158 // If streaming, queue via steer() or followUp() based on option1159 if (this.isStreaming) {1160 if (!options?.streamingBehavior) {1161 throw new Error(1162 "Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message.",1163 );1164 }1165 if (options.streamingBehavior === "followUp") {1166 await this._queueFollowUp(expandedText, currentImages);1167 } else {1168 await this._queueSteer(expandedText, currentImages);1169 }1170 preflightResult?.(true);1171 return;1172 }11731174 // Flush any pending bash messages before the new prompt1175 this._flushPendingBashMessages();11761177 // Validate model1178 if (!this.model) {1179 throw new Error(formatNoModelSelectedMessage());1180 }11811182 const hasConfiguredAuth =1183 this._modelRuntime.hasConfiguredAuth(this.model.provider) ||1184 (await this._modelRuntime.checkAuth(this.model.provider)) !== undefined;1185 if (!hasConfiguredAuth) {1186 const isOAuth = this._modelRuntime.isUsingOAuth(this.model.provider);1187 if (isOAuth) {1188 throw new Error(1189 `Authentication failed for "${this.model.provider}". ` +1190 `Credentials may have expired or network is unavailable. ` +1191 `Run '/login ${this.model.provider}' to re-authenticate.`,1192 );1193 }1194 throw new Error(formatNoApiKeyFoundMessage(this.model.provider));1195 }11961197 // Check if we need to compact before sending (catches aborted responses).1198 // The user's new prompt is sent below, so do not call agent.continue() here.1199 const lastAssistant = this._findLastAssistantMessage();1200 if (lastAssistant) {1201 await this._checkCompaction(lastAssistant, false);1202 }12031204 // Build messages array (custom message if any, then user message)1205 messages = [];12061207 // Add user message1208 const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }];1209 if (currentImages) {1210 userContent.push(...currentImages);1211 }1212 messages.push({1213 role: "user",1214 content: userContent,1215 timestamp: Date.now(),1216 });12171218 // Inject any pending "nextTurn" messages as context alongside the user message1219 for (const msg of this._pendingNextTurnMessages) {1220 messages.push(msg);1221 }1222 this._pendingNextTurnMessages = [];12231224 // Emit before_agent_start extension event1225 const result = await this._extensionRunner.emitBeforeAgentStart(1226 expandedText,1227 currentImages,1228 this._baseSystemPrompt,1229 this._baseSystemPromptOptions,1230 );1231 // Add all custom messages from extensions1232 if (result?.messages) {1233 for (const msg of result.messages) {1234 messages.push({1235 role: "custom",1236 customType: msg.customType,1237 // Untyped extensions can pass null/missing content; normalize at ingestion.1238 content: msg.content ?? [],1239 display: msg.display,1240 details: msg.details,1241 timestamp: Date.now(),1242 });1243 }1244 }1245 // Apply extension-modified system prompt, or reset to base1246 if (result?.systemPrompt !== undefined) {1247 this._systemPromptOverride = result.systemPrompt;1248 this.agent.state.systemPrompt = result.systemPrompt;1249 } else {1250 // Ensure we're using the base prompt (in case previous turn had modifications)1251 this._systemPromptOverride = undefined;1252 this.agent.state.systemPrompt = this._baseSystemPrompt;1253 }1254 } catch (error) {1255 preflightResult?.(false);1256 throw error;1257 }12581259 if (!messages) {1260 return;1261 }12621263 preflightResult?.(true);1264 await this._runAgentPrompt(messages);1265 }12661267 /**1268 * Try to execute an extension command. Returns true if command was found and executed.1269 */1270 private async _tryExecuteExtensionCommand(text: string): Promise<boolean> {1271 // Parse command name and args1272 const spaceIndex = text.indexOf(" ");1273 const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);1274 const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1);12751276 const command = this._extensionRunner.getCommand(commandName);1277 if (!command) return false;12781279 // Get command context from extension runner (includes session control methods)1280 const ctx = this._extensionRunner.createCommandContext();12811282 try {1283 await command.handler(args, ctx);1284 return true;1285 } catch (err) {1286 // Emit error via extension runner1287 this._extensionRunner.emitError({1288 extensionPath: `command:${commandName}`,1289 event: "command",1290 error: err instanceof Error ? err.message : String(err),1291 });1292 return true;1293 }1294 }12951296 /**1297 * Expand skill commands (/skill:name args) to their full content.1298 * Returns the expanded text, or the original text if not a skill command or skill not found.1299 * Emits errors via extension runner if file read fails.1300 */1301 private _expandSkillCommand(text: string): string {1302 if (!text.startsWith("/skill:")) return text;13031304 const spaceIndex = text.indexOf(" ");1305 const skillName = spaceIndex === -1 ? text.slice(7) : text.slice(7, spaceIndex);1306 const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim();13071308 const skill = this.resourceLoader.getSkills().skills.find((s) => s.name === skillName);1309 if (!skill) return text; // Unknown skill, pass through13101311 try {1312 const content = readFileSync(skill.filePath, "utf-8");1313 const body = stripFrontmatter(content).trim();1314 const skillBlock = `<skill name="${skill.name}" location="${skill.filePath}">\nReferences are relative to ${skill.baseDir}.\n\n${body}\n</skill>`;1315 return args ? `${skillBlock}\n\n${args}` : skillBlock;1316 } catch (err) {1317 // Emit error like extension commands do1318 this._extensionRunner.emitError({1319 extensionPath: skill.filePath,1320 event: "skill_expansion",1321 error: err instanceof Error ? err.message : String(err),1322 });1323 return text; // Return original on error1324 }1325 }13261327 /**1328 * Queue a steering message while the agent is running.1329 * Delivered after the current assistant turn finishes executing its tool calls,1330 * before the next LLM call.1331 * Expands skill commands and prompt templates. Errors on extension commands.1332 * @param images Optional image attachments to include with the message1333 * @throws Error if text is an extension command1334 */1335 async steer(text: string, images?: ImageContent[]): Promise<void> {1336 // Check for extension commands (cannot be queued)1337 if (text.startsWith("/")) {1338 this._throwIfExtensionCommand(text);1339 }13401341 // Expand skill commands and prompt templates1342 let expandedText = this._expandSkillCommand(text);1343 expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]);13441345 await this._queueSteer(expandedText, images);1346 }13471348 /**1349 * Queue a follow-up message to be processed after the agent finishes.1350 * Delivered only when agent has no more tool calls or steering messages.1351 * Expands skill commands and prompt templates. Errors on extension commands.1352 * @param images Optional image attachments to include with the message1353 * @throws Error if text is an extension command1354 */1355 async followUp(text: string, images?: ImageContent[]): Promise<void> {1356 // Check for extension commands (cannot be queued)1357 if (text.startsWith("/")) {1358 this._throwIfExtensionCommand(text);1359 }13601361 // Expand skill commands and prompt templates1362 let expandedText = this._expandSkillCommand(text);1363 expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]);13641365 await this._queueFollowUp(expandedText, images);1366 }13671368 /**1369 * Internal: Queue a steering message (already expanded, no extension command check).1370 */1371 private async _queueSteer(text: string, images?: ImageContent[]): Promise<void> {1372 this._steeringMessages.push(text);1373 this._emitQueueUpdate();1374 const content: (TextContent | ImageContent)[] = [{ type: "text", text }];1375 if (images) {1376 content.push(...images);1377 }1378 this.agent.steer({1379 role: "user",1380 content,1381 timestamp: Date.now(),1382 });1383 }13841385 /**1386 * Internal: Queue a follow-up message (already expanded, no extension command check).1387 */1388 private async _queueFollowUp(text: string, images?: ImageContent[]): Promise<void> {1389 this._followUpMessages.push(text);1390 this._emitQueueUpdate();1391 const content: (TextContent | ImageContent)[] = [{ type: "text", text }];1392 if (images) {1393 content.push(...images);1394 }1395 this.agent.followUp({1396 role: "user",1397 content,1398 timestamp: Date.now(),1399 });1400 }14011402 /**1403 * Throw an error if the text is an extension command.1404 */1405 private _throwIfExtensionCommand(text: string): void {1406 const spaceIndex = text.indexOf(" ");1407 const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);1408 const command = this._extensionRunner.getCommand(commandName);14091410 if (command) {1411 throw new Error(1412 `Extension command "/${commandName}" cannot be queued. Use prompt() or execute the command when not streaming.`,1413 );1414 }1415 }14161417 /**1418 * Send a custom message to the session. Creates a CustomMessageEntry.1419 *1420 * Handles three cases:1421 * - Streaming: queues message, processed when loop pulls from queue1422 * - Not streaming + triggerTurn: appends to state/session, starts new turn1423 * - Not streaming + no trigger: appends to state/session, no turn1424 *1425 * @param message Custom message with customType, content, display, details1426 * @param options.triggerTurn If true and not streaming, triggers a new LLM turn1427 * @param options.deliverAs Delivery mode: "steer", "followUp", or "nextTurn"1428 */1429 async sendCustomMessage<T = unknown>(1430 message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details">,1431 options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },1432 ): Promise<void> {1433 const appMessage = {1434 role: "custom" as const,1435 customType: message.customType,1436 // Untyped extensions can pass null/missing content; normalize at ingestion.1437 content: message.content ?? [],1438 display: message.display,1439 details: message.details,1440 timestamp: Date.now(),1441 } satisfies CustomMessage<T>;1442 if (options?.deliverAs === "nextTurn") {1443 this._pendingNextTurnMessages.push(appMessage);1444 } else if (this.isStreaming) {1445 if (options?.deliverAs === "followUp") {1446 this.agent.followUp(appMessage);1447 } else {1448 this.agent.steer(appMessage);1449 }1450 } else if (options?.triggerTurn) {1451 await this._runAgentPrompt(appMessage);1452 } else {1453 this.agent.state.messages.push(appMessage);1454 this.sessionManager.appendCustomMessageEntry(1455 message.customType,1456 message.content,1457 message.display,1458 message.details,1459 );1460 this._emit({ type: "message_start", message: appMessage });1461 this._emit({ type: "message_end", message: appMessage });1462 }1463 }14641465 /**1466 * Send a user message to the agent. Always triggers a turn.1467 * When the agent is streaming, use deliverAs to specify how to queue the message.1468 *1469 * @param content User message content (string or content array)1470 * @param options.deliverAs Delivery mode when streaming: "steer" or "followUp"1471 */1472 async sendUserMessage(1473 content: string | (TextContent | ImageContent)[],1474 options?: { deliverAs?: "steer" | "followUp" },1475 ): Promise<void> {1476 // Normalize content to text string + optional images1477 let text: string;1478 let images: ImageContent[] | undefined;14791480 if (typeof content === "string") {1481 text = content;1482 } else {1483 const textParts: string[] = [];1484 images = [];1485 for (const part of content) {1486 if (part.type === "text") {1487 textParts.push(part.text);1488 } else {1489 images.push(part);1490 }1491 }1492 text = textParts.join("\n");1493 if (images.length === 0) images = undefined;1494 }14951496 // Use prompt() with expandPromptTemplates: false to skip command handling and template expansion1497 await this.prompt(text, {1498 expandPromptTemplates: false,1499 streamingBehavior: options?.deliverAs,1500 images,1501 source: "extension",1502 });1503 }15041505 /**1506 * Clear all queued messages and return them.1507 * Useful for restoring to editor when user aborts.1508 * @returns Object with steering and followUp arrays1509 */1510 clearQueue(): { steering: string[]; followUp: string[] } {1511 const steering = [...this._steeringMessages];1512 const followUp = [...this._followUpMessages];1513 this._steeringMessages = [];1514 this._followUpMessages = [];1515 this.agent.clearAllQueues();1516 this._emitQueueUpdate();1517 return { steering, followUp };1518 }15191520 /** Number of pending messages (includes both steering and follow-up) */1521 get pendingMessageCount(): number {1522 return this._steeringMessages.length + this._followUpMessages.length;1523 }15241525 /** Get pending steering messages (read-only) */1526 getSteeringMessages(): readonly string[] {1527 return this._steeringMessages;1528 }15291530 /** Get pending follow-up messages (read-only) */1531 getFollowUpMessages(): readonly string[] {1532 return this._followUpMessages;1533 }15341535 get resourceLoader(): ResourceLoader {1536 return this._resourceLoader;1537 }15381539 /**1540 * Abort current operation and wait for agent to become idle.1541 */1542 async abort(): Promise<void> {1543 this.abortRetry();1544 this.agent.abort();1545 await this.waitForIdle();1546 }15471548 async waitForIdle(): Promise<void> {1549 if (this.isIdle) {1550 return;1551 }1552 await this._getIdleWaitPromise();1553 }15541555 // =========================================================================1556 // Model Management1557 // =========================================================================15581559 private async _emitModelSelect(1560 nextModel: Model<any>,1561 previousModel: Model<any> | undefined,1562 source: "set" | "cycle" | "restore",1563 ): Promise<void> {1564 if (modelsAreEqual(previousModel, nextModel)) return;1565 await this._extensionRunner.emit({1566 type: "model_select",1567 model: nextModel,1568 previousModel,1569 source,1570 });1571 }15721573 /**1574 * Set model directly.1575 * Validates that auth is configured, saves to session and settings.1576 * @throws Error if no auth is configured for the model1577 */1578 async setModel(model: Model<any>): Promise<void> {1579 if (!(await this._modelRuntime.checkAuth(model.provider))) {1580 throw new Error(`No API key for ${model.provider}/${model.id}`);1581 }15821583 const previousModel = this.model;1584 const thinkingLevel = this._getThinkingLevelForModelSwitch();1585 this.agent.state.model = model;1586 this.sessionManager.appendModelChange(model.provider, model.id);1587 this.settingsManager.setDefaultModelAndProvider(model.provider, model.id);15881589 // Re-clamp thinking level for new model's capabilities1590 this.setThinkingLevel(thinkingLevel);15911592 await this._emitModelSelect(model, previousModel, "set");1593 }15941595 /**1596 * Cycle to next/previous model.1597 * Uses scoped models (from --models flag) if available, otherwise all available models.1598 * @param direction - "forward" (default) or "backward"1599 * @returns The new model info, or undefined if only one model available1600 */1601 async cycleModel(direction: "forward" | "backward" = "forward"): Promise<ModelCycleResult | undefined> {1602 if (this._scopedModels.length > 0) {1603 return this._cycleScopedModel(direction);1604 }1605 return this._cycleAvailableModel(direction);1606 }16071608 private async _cycleScopedModel(direction: "forward" | "backward"): Promise<ModelCycleResult | undefined> {1609 const checks = await Promise.all(1610 this._scopedModels.map(async (scoped) => ({1611 scoped,1612 auth: await this._modelRuntime.checkAuth(scoped.model.provider),1613 })),1614 );1615 const scopedModels = checks.filter(({ auth }) => auth !== undefined).map(({ scoped }) => scoped);1616 if (scopedModels.length <= 1) return undefined;16171618 const currentModel = this.model;1619 let currentIndex = scopedModels.findIndex((sm) => modelsAreEqual(sm.model, currentModel));16201621 if (currentIndex === -1) currentIndex = 0;1622 const len = scopedModels.length;1623 const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len;1624 const next = scopedModels[nextIndex];1625 const thinkingLevel = this._getThinkingLevelForModelSwitch(next.thinkingLevel);16261627 // Apply model1628 this.agent.state.model = next.model;1629 this.sessionManager.appendModelChange(next.model.provider, next.model.id);1630 this.settingsManager.setDefaultModelAndProvider(next.model.provider, next.model.id);16311632 // Apply thinking level.1633 // - Explicit scoped model thinking level overrides current session level1634 // - Undefined scoped model thinking level inherits the current session preference1635 // setThinkingLevel clamps to model capabilities.1636 this.setThinkingLevel(thinkingLevel);16371638 await this._emitModelSelect(next.model, currentModel, "cycle");16391640 return { model: next.model, thinkingLevel: this.thinkingLevel, isScoped: true };1641 }16421643 private async _cycleAvailableModel(direction: "forward" | "backward"): Promise<ModelCycleResult | undefined> {1644 const availableModels = await this._modelRuntime.getAvailable();1645 if (availableModels.length <= 1) return undefined;16461647 const currentModel = this.model;1648 let currentIndex = availableModels.findIndex((m) => modelsAreEqual(m, currentModel));16491650 if (currentIndex === -1) currentIndex = 0;1651 const len = availableModels.length;1652 const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len;1653 const nextModel = availableModels[nextIndex];16541655 const thinkingLevel = this._getThinkingLevelForModelSwitch();1656 this.agent.state.model = nextModel;1657 this.sessionManager.appendModelChange(nextModel.provider, nextModel.id);1658 this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id);16591660 // Re-clamp thinking level for new model's capabilities1661 this.setThinkingLevel(thinkingLevel);16621663 await this._emitModelSelect(nextModel, currentModel, "cycle");16641665 return { model: nextModel, thinkingLevel: this.thinkingLevel, isScoped: false };1666 }16671668 // =========================================================================1669 // Thinking Level Management1670 // =========================================================================16711672 /**1673 * Set thinking level.1674 * Clamps to model capabilities based on available thinking levels.1675 * Saves to session and settings only if the level actually changes.1676 */1677 setThinkingLevel(level: ThinkingLevel): void {1678 const availableLevels = this.getAvailableThinkingLevels();1679 const effectiveLevel = availableLevels.includes(level) ? level : this._clampThinkingLevel(level, availableLevels);16801681 // Only persist if actually changing1682 const previousLevel = this.agent.state.thinkingLevel;1683 const isChanging = effectiveLevel !== previousLevel;16841685 this.agent.state.thinkingLevel = effectiveLevel;16861687 if (isChanging) {1688 this.sessionManager.appendThinkingLevelChange(effectiveLevel);1689 if (this.supportsThinking() || effectiveLevel !== "off") {1690 this.settingsManager.setDefaultThinkingLevel(effectiveLevel);1691 }1692 this._emit({ type: "thinking_level_changed", level: effectiveLevel });1693 void this._extensionRunner.emit({1694 type: "thinking_level_select",1695 level: effectiveLevel,1696 previousLevel,1697 });1698 }1699 }17001701 /**1702 * Cycle to next thinking level.1703 * @returns New level, or undefined if model doesn't support thinking1704 */1705 cycleThinkingLevel(): ThinkingLevel | undefined {1706 if (!this.supportsThinking()) return undefined;17071708 const levels = this.getAvailableThinkingLevels();1709 const currentIndex = levels.indexOf(this.thinkingLevel);1710 const nextIndex = (currentIndex + 1) % levels.length;1711 const nextLevel = levels[nextIndex];17121713 this.setThinkingLevel(nextLevel);1714 return nextLevel;1715 }17161717 /**1718 * Get available thinking levels for current model.1719 * The provider will clamp to what the specific model supports internally.1720 */1721 getAvailableThinkingLevels(): ThinkingLevel[] {1722 if (!this.model) return THINKING_LEVELS;1723 return getSupportedThinkingLevels(this.model) as ThinkingLevel[];1724 }17251726 /**1727 * Check if current model supports thinking/reasoning.1728 */1729 supportsThinking(): boolean {1730 return !!this.model?.reasoning;1731 }17321733 private _getThinkingLevelForModelSwitch(explicitLevel?: ThinkingLevel): ThinkingLevel {1734 if (explicitLevel !== undefined) {1735 return explicitLevel;1736 }1737 if (!this.supportsThinking()) {1738 return this.settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL;1739 }1740 return this.thinkingLevel;1741 }17421743 private _clampThinkingLevel(level: ThinkingLevel, _availableLevels: ThinkingLevel[]): ThinkingLevel {1744 return this.model ? (clampThinkingLevel(this.model, level) as ThinkingLevel) : "off";1745 }17461747 // =========================================================================1748 // Queue Mode Management1749 // =========================================================================17501751 private syncQueueModesFromSettings(): void {1752 this.agent.steeringMode = this.settingsManager.getSteeringMode();1753 this.agent.followUpMode = this.settingsManager.getFollowUpMode();1754 }17551756 /**1757 * Set steering message mode.1758 * Saves to settings.1759 */1760 setSteeringMode(mode: "all" | "one-at-a-time"): void {1761 this.agent.steeringMode = mode;1762 this.settingsManager.setSteeringMode(mode);1763 }17641765 /**1766 * Set follow-up message mode.1767 * Saves to settings.1768 */1769 setFollowUpMode(mode: "all" | "one-at-a-time"): void {1770 this.agent.followUpMode = mode;1771 this.settingsManager.setFollowUpMode(mode);1772 }17731774 // =========================================================================1775 // Compaction1776 // =========================================================================17771778 /**1779 * Manually compact the session context.1780 * Aborts current agent operation first.1781 * @param customInstructions Optional instructions for the compaction summary1782 */1783 async compact(customInstructions?: string): Promise<CompactionResult> {1784 this._disconnectFromAgent();1785 await this.abort();1786 this._compactionAbortController = new AbortController();1787 this._emit({ type: "compaction_start", reason: "manual" });17881789 try {1790 if (!this.model) {1791 throw new Error(formatNoModelSelectedMessage());1792 }17931794 const { apiKey, headers, env } = await this._getSummarizationRequestAuth(this.model);17951796 const pathEntries = this.sessionManager.getBranch();1797 const settings = this.settingsManager.getCompactionSettings();17981799 const preparation = prepareCompaction(pathEntries, settings);1800 if (!preparation) {1801 // Check why we can't compact1802 const lastEntry = pathEntries[pathEntries.length - 1];1803 if (lastEntry?.type === "compaction") {1804 throw new Error("Already compacted");1805 }1806 throw new Error("Nothing to compact (session too small)");1807 }18081809 let extensionCompaction: CompactionResult | undefined;1810 let fromExtension = false;18111812 if (this._extensionRunner.hasHandlers("session_before_compact")) {1813 const result = (await this._extensionRunner.emit({1814 type: "session_before_compact",1815 preparation,1816 branchEntries: pathEntries,1817 customInstructions,1818 reason: "manual",1819 willRetry: false,1820 signal: this._compactionAbortController.signal,1821 })) as SessionBeforeCompactResult | undefined;18221823 if (result?.cancel) {1824 throw new Error("Compaction cancelled");1825 }18261827 if (result?.compaction) {1828 extensionCompaction = result.compaction;1829 fromExtension = true;1830 }1831 }18321833 let summary: string;1834 let firstKeptEntryId: string;1835 let tokensBefore: number;1836 let usage: Usage | undefined;1837 let details: unknown;18381839 if (extensionCompaction) {1840 // Extension provided compaction content1841 summary = extensionCompaction.summary;1842 firstKeptEntryId = extensionCompaction.firstKeptEntryId;1843 tokensBefore = extensionCompaction.tokensBefore;1844 usage = extensionCompaction.usage;1845 details = extensionCompaction.details;1846 } else {1847 // Generate compaction result1848 const result = await compact(1849 preparation,1850 this.model,1851 apiKey,1852 headers,1853 customInstructions,1854 this._compactionAbortController.signal,1855 this.thinkingLevel,1856 this.agent.streamFunction,1857 env,1858 this.settingsManager.getRetrySettings(),1859 this._summarizationRetryCallbacks({ source: "compaction", reason: "manual" }),1860 );1861 summary = result.summary;1862 firstKeptEntryId = result.firstKeptEntryId;1863 tokensBefore = result.tokensBefore;1864 usage = result.usage;1865 details = result.details;1866 }18671868 if (this._compactionAbortController.signal.aborted) {1869 throw new Error("Compaction cancelled");1870 }18711872 this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension, usage);1873 const newEntries = this.sessionManager.getEntries();1874 const sessionContext = this.sessionManager.buildSessionContext();1875 this.agent.state.messages = sessionContext.messages;1876 const estimatedTokensAfter = estimateMessagesTokens(sessionContext.messages);18771878 // Get the saved compaction entry for the extension event1879 const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary) as1880 | CompactionEntry1881 | undefined;18821883 if (this._extensionRunner && savedCompactionEntry) {1884 await this._extensionRunner.emit({1885 type: "session_compact",1886 compactionEntry: savedCompactionEntry,1887 fromExtension,1888 reason: "manual",1889 willRetry: false,1890 });1891 }18921893 const compactionResult: CompactionResult = {1894 summary,1895 firstKeptEntryId,1896 tokensBefore,1897 estimatedTokensAfter,1898 usage,1899 details,1900 };1901 this._emit({1902 type: "compaction_end",1903 reason: "manual",1904 result: compactionResult,1905 aborted: false,1906 willRetry: false,1907 });1908 return compactionResult;1909 } catch (error) {1910 const message = error instanceof Error ? error.message : String(error);1911 const aborted = message === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError");1912 this._emit({1913 type: "compaction_end",1914 reason: "manual",1915 result: undefined,1916 aborted,1917 willRetry: false,1918 errorMessage: aborted ? undefined : `Compaction failed: ${message}`,1919 });1920 throw error;1921 } finally {1922 this._compactionAbortController = undefined;1923 this._reconnectToAgent();1924 }1925 }19261927 /**1928 * Cancel in-progress compaction (manual or auto).1929 */1930 abortCompaction(): void {1931 this._compactionAbortController?.abort();1932 this._autoCompactionAbortController?.abort();1933 }19341935 /**1936 * Cancel in-progress branch summarization.1937 */1938 abortBranchSummary(): void {1939 this._branchSummaryAbortController?.abort();1940 }19411942 /**1943 * Check if compaction is needed and run it.1944 * Called after agent_end and before prompt submission.1945 *1946 * Two cases:1947 * 1. Overflow: LLM returned context overflow error, remove error message from agent state, compact, auto-retry1948 * 2. Threshold: Context over threshold, compact, NO auto-retry (user continues manually)1949 *1950 * @param assistantMessage The assistant message to check1951 * @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true1952 */1953 private async _checkCompaction(assistantMessage: AssistantMessage, skipAbortedCheck = true): Promise<boolean> {1954 const settings = this.settingsManager.getCompactionSettings();1955 if (!settings.enabled) return false;19561957 // Skip if message was aborted (user cancelled) - unless skipAbortedCheck is false1958 if (skipAbortedCheck && assistantMessage.stopReason === "aborted") return false;19591960 const contextWindow = this.model?.contextWindow ?? 0;19611962 // Skip overflow check if the message came from a different model.1963 // This handles the case where user switched from a smaller-context model (e.g. opus)1964 // to a larger-context model (e.g. codex) - the overflow error from the old model1965 // shouldn't trigger compaction for the new model.1966 const sameModel =1967 this.model && assistantMessage.provider === this.model.provider && assistantMessage.model === this.model.id;19681969 // Skip compaction checks if this assistant message is older than the latest1970 // compaction boundary. This prevents a stale pre-compaction usage/error1971 // from retriggering compaction on the first prompt after compaction.1972 const compactionEntry = getLatestCompactionEntry(this.sessionManager.getBranch());1973 const assistantIsFromBeforeCompaction =1974 compactionEntry !== null && assistantMessage.timestamp <= new Date(compactionEntry.timestamp).getTime();1975 if (assistantIsFromBeforeCompaction) {1976 return false;1977 }19781979 // Case 1: Overflow - LLM returned context overflow error, or reported usage exceeded1980 // the configured window. A successful response over the configured window should compact1981 // but must not retry: the assistant answer already completed and agent.continue() cannot1982 // continue from an assistant message.1983 if (sameModel && isContextOverflow(assistantMessage, contextWindow)) {1984 const willRetry = assistantMessage.stopReason !== "stop";19851986 if (!willRetry) {1987 return await this._runAutoCompaction("overflow", false);1988 }19891990 if (this._overflowRecoveryAttempted) {1991 this._emit({1992 type: "compaction_end",1993 reason: "overflow",1994 result: undefined,1995 aborted: false,1996 willRetry: false,1997 errorMessage:1998 "Context overflow recovery failed after one compact-and-retry attempt. Try reducing context or switching to a larger-context model.",1999 });2000 return false;2001 }20022003 this._overflowRecoveryAttempted = true;2004 // Remove the error message from agent state (it IS saved to session for history,2005 // but we don't want it in context for the retry)2006 const messages = this.agent.state.messages;2007 if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {2008 this.agent.state.messages = messages.slice(0, -1);2009 }2010 return await this._runAutoCompaction("overflow", willRetry);2011 }20122013 // Case 2: Threshold - context is getting large2014 // For error messages or all-zero usage messages, estimate from the last valid response.2015 // This ensures sessions that hit persistent API errors (e.g. 529) or malformed zero-usage2016 // responses can still compact and do not reset context accounting.2017 let contextTokens: number;2018 const directContextTokens = assistantMessage.usage ? calculateContextTokens(assistantMessage.usage) : 0;2019 if (assistantMessage.stopReason === "error" || directContextTokens === 0) {2020 const messages = this.agent.state.messages;2021 const estimate = estimateContextTokens(messages);2022 if (estimate.lastUsageIndex === null) return false; // No usage data at all2023 // Verify the usage source is post-compaction. Kept pre-compaction messages2024 // have stale usage reflecting the old (larger) context and would falsely2025 // trigger compaction right after one just finished.2026 const usageMsg = messages[estimate.lastUsageIndex];2027 if (2028 compactionEntry &&2029 usageMsg.role === "assistant" &&2030 (usageMsg as AssistantMessage).timestamp <= new Date(compactionEntry.timestamp).getTime()2031 ) {2032 return false;2033 }2034 contextTokens = estimate.tokens;2035 } else {2036 contextTokens = directContextTokens;2037 }2038 if (shouldCompact(contextTokens, contextWindow, settings)) {2039 return await this._runAutoCompaction("threshold", false);2040 }2041 return false;2042 }20432044 /**2045 * Internal: Run auto-compaction with events.2046 */2047 private async _runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean): Promise<boolean> {2048 const settings = this.settingsManager.getCompactionSettings();2049 let started = false;20502051 try {2052 if (!this.model) {2053 return false;2054 }20552056 let apiKey: string | undefined;2057 let headers: Record<string, string> | undefined;2058 let env: Record<string, string> | undefined;2059 if (this.agent.streamFunction === streamSimple) {2060 ({ apiKey, headers, env } = await this._getRequiredRequestAuth(this.model));2061 } else {2062 ({ apiKey, headers, env } = await this._getSummarizationRequestAuth(this.model));2063 }20642065 const pathEntries = this.sessionManager.getBranch();20662067 const preparation = prepareCompaction(pathEntries, settings);2068 if (!preparation) {2069 return false;2070 }20712072 this._emit({ type: "compaction_start", reason });2073 this._autoCompactionAbortController = new AbortController();2074 started = true;20752076 let extensionCompaction: CompactionResult | undefined;2077 let fromExtension = false;20782079 if (this._extensionRunner.hasHandlers("session_before_compact")) {2080 const extensionResult = (await this._extensionRunner.emit({2081 type: "session_before_compact",2082 preparation,2083 branchEntries: pathEntries,2084 customInstructions: undefined,2085 reason,2086 willRetry,2087 signal: this._autoCompactionAbortController.signal,2088 })) as SessionBeforeCompactResult | undefined;20892090 if (extensionResult?.cancel) {2091 this._emit({2092 type: "compaction_end",2093 reason,2094 result: undefined,2095 aborted: true,2096 willRetry: false,2097 });2098 return false;2099 }21002101 if (extensionResult?.compaction) {2102 extensionCompaction = extensionResult.compaction;2103 fromExtension = true;2104 }2105 }21062107 let summary: string;2108 let firstKeptEntryId: string;2109 let tokensBefore: number;2110 let usage: Usage | undefined;2111 let details: unknown;21122113 if (extensionCompaction) {2114 // Extension provided compaction content2115 summary = extensionCompaction.summary;2116 firstKeptEntryId = extensionCompaction.firstKeptEntryId;2117 tokensBefore = extensionCompaction.tokensBefore;2118 usage = extensionCompaction.usage;2119 details = extensionCompaction.details;2120 } else {2121 // Generate compaction result2122 const compactResult = await compact(2123 preparation,2124 this.model,2125 apiKey,2126 headers,2127 undefined,2128 this._autoCompactionAbortController.signal,2129 this.thinkingLevel,2130 this.agent.streamFunction,2131 env,2132 this.settingsManager.getRetrySettings(),2133 this._summarizationRetryCallbacks({ source: "compaction", reason }),2134 );2135 summary = compactResult.summary;2136 firstKeptEntryId = compactResult.firstKeptEntryId;2137 tokensBefore = compactResult.tokensBefore;2138 usage = compactResult.usage;2139 details = compactResult.details;2140 }21412142 if (this._autoCompactionAbortController.signal.aborted) {2143 this._emit({2144 type: "compaction_end",2145 reason,2146 result: undefined,2147 aborted: true,2148 willRetry: false,2149 });2150 return false;2151 }21522153 this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension, usage);2154 const newEntries = this.sessionManager.getEntries();2155 const sessionContext = this.sessionManager.buildSessionContext();2156 this.agent.state.messages = sessionContext.messages;2157 const estimatedTokensAfter = estimateMessagesTokens(sessionContext.messages);21582159 // Get the saved compaction entry for the extension event2160 const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary) as2161 | CompactionEntry2162 | undefined;21632164 if (this._extensionRunner && savedCompactionEntry) {2165 await this._extensionRunner.emit({2166 type: "session_compact",2167 compactionEntry: savedCompactionEntry,2168 fromExtension,2169 reason,2170 willRetry,2171 });2172 }21732174 const result: CompactionResult = {2175 summary,2176 firstKeptEntryId,2177 tokensBefore,2178 estimatedTokensAfter,2179 usage,2180 details,2181 };2182 this._emit({ type: "compaction_end", reason, result, aborted: false, willRetry });21832184 if (willRetry) {2185 const messages = this.agent.state.messages;2186 const lastMsg = messages[messages.length - 1];2187 if (lastMsg?.role === "assistant" && (lastMsg as AssistantMessage).stopReason === "error") {2188 this.agent.state.messages = messages.slice(0, -1);2189 }2190 return true;2191 }21922193 // Auto-compaction can complete while follow-up/steering/custom messages are waiting.2194 // Continue once so queued messages are delivered.2195 return this.agent.hasQueuedMessages();2196 } catch (error) {2197 const errorMessage = error instanceof Error ? error.message : "compaction failed";2198 if (started) {2199 this._emit({2200 type: "compaction_end",2201 reason,2202 result: undefined,2203 aborted: false,2204 willRetry: false,2205 errorMessage:2206 reason === "overflow"2207 ? `Context overflow recovery failed: ${errorMessage}`2208 : `Auto-compaction failed: ${errorMessage}`,2209 });2210 }2211 return false;2212 } finally {2213 this._autoCompactionAbortController = undefined;2214 }2215 }22162217 /**2218 * Toggle auto-compaction setting.2219 */2220 setAutoCompactionEnabled(enabled: boolean): void {2221 this.settingsManager.setCompactionEnabled(enabled);2222 }22232224 /** Whether auto-compaction is enabled */2225 get autoCompactionEnabled(): boolean {2226 return this.settingsManager.getCompactionEnabled();2227 }22282229 async bindExtensions(bindings: ExtensionBindings): Promise<void> {2230 if (bindings.uiContext !== undefined) {2231 this._extensionUIContext = bindings.uiContext;2232 }2233 if (bindings.mode !== undefined) {2234 this._extensionMode = bindings.mode;2235 }2236 if (bindings.commandContextActions !== undefined) {2237 this._extensionCommandContextActions = bindings.commandContextActions;2238 }2239 if (bindings.abortHandler !== undefined) {2240 this._extensionAbortHandler = bindings.abortHandler;2241 }2242 if (bindings.shutdownHandler !== undefined) {2243 this._extensionShutdownHandler = bindings.shutdownHandler;2244 }2245 if (bindings.onError !== undefined) {2246 this._extensionErrorListener = bindings.onError;2247 }22482249 this._applyExtensionBindings(this._extensionRunner);2250 await this._extensionRunner.emit(this._sessionStartEvent);2251 await this.extendResourcesFromExtensions(this._sessionStartEvent.reason === "reload" ? "reload" : "startup");2252 }22532254 private async extendResourcesFromExtensions(reason: "startup" | "reload"): Promise<void> {2255 if (!this._extensionRunner.hasHandlers("resources_discover")) {2256 return;2257 }22582259 const { skillPaths, promptPaths, themePaths } = await this._extensionRunner.emitResourcesDiscover(2260 this._cwd,2261 reason,2262 );22632264 if (skillPaths.length === 0 && promptPaths.length === 0 && themePaths.length === 0) {2265 return;2266 }22672268 const extensionPaths: ResourceExtensionPaths = {2269 skillPaths: this.buildExtensionResourcePaths(skillPaths),2270 promptPaths: this.buildExtensionResourcePaths(promptPaths),2271 themePaths: this.buildExtensionResourcePaths(themePaths),2272 };22732274 this._resourceLoader.extendResources(extensionPaths);2275 this._baseSystemPrompt = this._rebuildSystemPrompt(this.getActiveToolNames());2276 this.agent.state.systemPrompt = this._baseSystemPrompt;2277 }22782279 private buildExtensionResourcePaths(entries: Array<{ path: string; extensionPath: string }>): Array<{2280 path: string;2281 metadata: { source: string; scope: "temporary"; origin: "top-level"; baseDir?: string };2282 }> {2283 return entries.map((entry) => {2284 const source = this.getExtensionSourceLabel(entry.extensionPath);2285 const baseDir = entry.extensionPath.startsWith("<") ? undefined : dirname(entry.extensionPath);2286 return {2287 path: entry.path,2288 metadata: {2289 source,2290 scope: "temporary",2291 origin: "top-level",2292 baseDir,2293 },2294 };2295 });2296 }22972298 private getExtensionSourceLabel(extensionPath: string): string {2299 if (extensionPath.startsWith("<")) {2300 return `extension:${extensionPath.replace(/[<>]/g, "")}`;2301 }2302 const base = basename(extensionPath);2303 const name = base.replace(/\.(ts|js)$/, "");2304 return `extension:${name}`;2305 }23062307 private _applyExtensionBindings(runner: ExtensionRunner): void {2308 runner.setUIContext(this._extensionUIContext, this._extensionMode);2309 runner.bindCommandContext(this._extensionCommandContextActions);23102311 this._extensionErrorUnsubscriber?.();2312 this._extensionErrorUnsubscriber = this._extensionErrorListener2313 ? runner.onError(this._extensionErrorListener)2314 : undefined;2315 }23162317 private _refreshCurrentModelFromRegistry(): void {2318 const currentModel = this.model;2319 if (!currentModel) {2320 return;2321 }23222323 const refreshedModel = this._modelRuntime.getModel(currentModel.provider, currentModel.id);2324 if (!refreshedModel || refreshedModel === currentModel) {2325 return;2326 }23272328 this.agent.state.model = refreshedModel;2329 }23302331 private _bindExtensionCore(runner: ExtensionRunner): void {2332 const getCommands = (): SlashCommandInfo[] => {2333 const extensionCommands: SlashCommandInfo[] = runner.getRegisteredCommands().map((command) => ({2334 name: command.invocationName,2335 description: command.description,2336 source: "extension",2337 sourceInfo: command.sourceInfo,2338 }));23392340 const templates: SlashCommandInfo[] = this.promptTemplates.map((template) => ({2341 name: template.name,2342 description: template.description,2343 source: "prompt",2344 sourceInfo: template.sourceInfo,2345 }));23462347 const skills: SlashCommandInfo[] = this._resourceLoader.getSkills().skills.map((skill) => ({2348 name: `skill:${skill.name}`,2349 description: skill.description,2350 source: "skill",2351 sourceInfo: skill.sourceInfo,2352 }));23532354 return [...extensionCommands, ...templates, ...skills];2355 };23562357 runner.bindCore(2358 {2359 sendMessage: (message, options) => {2360 this.sendCustomMessage(message, options).catch((err) => {2361 runner.emitError({2362 extensionPath: "<runtime>",2363 event: "send_message",2364 error: err instanceof Error ? err.message : String(err),2365 });2366 });2367 },2368 sendUserMessage: (content, options) => {2369 this.sendUserMessage(content, options).catch((err) => {2370 runner.emitError({2371 extensionPath: "<runtime>",2372 event: "send_user_message",2373 error: err instanceof Error ? err.message : String(err),2374 });2375 });2376 },2377 appendEntry: (customType, data) => {2378 const entryId = this.sessionManager.appendCustomEntry(customType, data);2379 const entry = this.sessionManager.getEntry(entryId);2380 if (entry) {2381 this._emit({ type: "entry_appended", entry });2382 }2383 },2384 setSessionName: (name) => {2385 this.setSessionName(name);2386 },2387 getSessionName: () => {2388 return this.sessionManager.getSessionName();2389 },2390 setLabel: (entryId, label) => {2391 this.sessionManager.appendLabelChange(entryId, label);2392 },2393 getActiveTools: () => this.getActiveToolNames(),2394 getAllTools: () => this.getAllTools(),2395 setActiveTools: (toolNames) => this.setActiveToolsByName(toolNames),2396 refreshTools: () => this._refreshToolRegistry(),2397 getCommands,2398 setModel: async (model) => {2399 if (!this._modelRuntime.hasConfiguredAuth(model.provider)) return false;2400 await this.setModel(model);2401 return true;2402 },2403 getThinkingLevel: () => this.thinkingLevel,2404 setThinkingLevel: (level) => this.setThinkingLevel(level),2405 },2406 {2407 getModel: () => this.model,2408 isIdle: () => this.isIdle,2409 isProjectTrusted: () => this.settingsManager.isProjectTrusted(),2410 getSignal: () => this.agent.signal,2411 abort: () => {2412 if (this._extensionAbortHandler) {2413 this._extensionAbortHandler();2414 return;2415 }2416 void this.abort();2417 },2418 hasPendingMessages: () => this.pendingMessageCount > 0,2419 shutdown: () => {2420 this._extensionShutdownHandler?.();2421 },2422 getContextUsage: () => this.getContextUsage(),2423 compact: (options) => {2424 void (async () => {2425 try {2426 const result = await this.compact(options?.customInstructions);2427 options?.onComplete?.(result);2428 } catch (error) {2429 const err = error instanceof Error ? error : new Error(String(error));2430 options?.onError?.(err);2431 }2432 })();2433 },2434 getSystemPrompt: () => this.systemPrompt,2435 getSystemPromptOptions: () => this._baseSystemPromptOptions,2436 },2437 {2438 registerProvider: (name, config) => {2439 this._modelRuntime.registerProvider(name, config);2440 this._refreshCurrentModelFromRegistry();2441 },2442 registerNativeProvider: (provider) => {2443 this._modelRuntime.registerNativeProvider(provider);2444 this._refreshCurrentModelFromRegistry();2445 },2446 unregisterProvider: (name) => {2447 this._modelRuntime.unregisterProvider(name);2448 this._refreshCurrentModelFromRegistry();2449 },2450 },2451 );2452 }24532454 private _refreshToolRegistry(options?: { activeToolNames?: string[]; includeAllExtensionTools?: boolean }): void {2455 const previousRegistryNames = new Set(this._toolRegistry.keys());2456 const previousActiveToolNames = this.getActiveToolNames();2457 const allowedToolNames = this._allowedToolNames;2458 const excludedToolNames = this._excludedToolNames;2459 const isAllowedTool = (name: string): boolean =>2460 (!allowedToolNames || allowedToolNames.has(name)) && !excludedToolNames?.has(name);24612462 const registeredTools = this._extensionRunner.getAllRegisteredTools();2463 const allCustomTools = [2464 ...registeredTools,2465 ...this._customTools.map((definition) => ({2466 definition,2467 sourceInfo: createSyntheticSourceInfo(`<sdk:${definition.name}>`, { source: "sdk" }),2468 })),2469 ].filter((tool) => isAllowedTool(tool.definition.name));2470 const definitionRegistry = new Map<string, ToolDefinitionEntry>(2471 Array.from(this._baseToolDefinitions.entries())2472 .filter(([name]) => isAllowedTool(name))2473 .map(([name, definition]) => [2474 name,2475 {2476 definition,2477 sourceInfo: createSyntheticSourceInfo(`<builtin:${name}>`, { source: "builtin" }),2478 },2479 ]),2480 );2481 for (const tool of allCustomTools) {2482 definitionRegistry.set(tool.definition.name, {2483 definition: tool.definition,2484 sourceInfo: tool.sourceInfo,2485 });2486 }2487 this._toolDefinitions = definitionRegistry;2488 this._toolPromptSnippets = new Map(2489 Array.from(definitionRegistry.values())2490 .map(({ definition }) => {2491 const snippet = this._normalizePromptSnippet(definition.promptSnippet);2492 return snippet ? ([definition.name, snippet] as const) : undefined;2493 })2494 .filter((entry): entry is readonly [string, string] => entry !== undefined),2495 );2496 this._toolPromptGuidelines = new Map(2497 Array.from(definitionRegistry.values())2498 .map(({ definition }) => {2499 const guidelines = this._normalizePromptGuidelines(definition.promptGuidelines);2500 return guidelines.length > 0 ? ([definition.name, guidelines] as const) : undefined;2501 })2502 .filter((entry): entry is readonly [string, string[]] => entry !== undefined),2503 );2504 const runner = this._extensionRunner;2505 const wrappedExtensionTools = wrapRegisteredTools(allCustomTools, runner);2506 const wrappedBuiltInTools = wrapRegisteredTools(2507 Array.from(this._baseToolDefinitions.values())2508 .filter((definition) => isAllowedTool(definition.name))2509 .map((definition) => ({2510 definition,2511 sourceInfo: createSyntheticSourceInfo(`<builtin:${definition.name}>`, { source: "builtin" }),2512 })),2513 runner,2514 );25152516 const toolRegistry = new Map(wrappedBuiltInTools.map((tool) => [tool.name, tool]));2517 for (const tool of wrappedExtensionTools as AgentTool[]) {2518 toolRegistry.set(tool.name, tool);2519 }2520 this._toolRegistry = toolRegistry;25212522 const nextActiveToolNames = (2523 options?.activeToolNames ? [...options.activeToolNames] : [...previousActiveToolNames]2524 ).filter((name) => isAllowedTool(name));25252526 if (allowedToolNames) {2527 for (const toolName of this._toolRegistry.keys()) {2528 if (allowedToolNames.has(toolName)) {2529 nextActiveToolNames.push(toolName);2530 }2531 }2532 } else if (options?.includeAllExtensionTools) {2533 for (const tool of wrappedExtensionTools) {2534 nextActiveToolNames.push(tool.name);2535 }2536 } else if (!options?.activeToolNames) {2537 for (const toolName of this._toolRegistry.keys()) {2538 if (!previousRegistryNames.has(toolName)) {2539 nextActiveToolNames.push(toolName);2540 }2541 }2542 }25432544 this.setActiveToolsByName([...new Set(nextActiveToolNames)]);2545 }25462547 private _buildRuntime(options: {2548 activeToolNames?: string[];2549 flagValues?: Map<string, boolean | string>;2550 includeAllExtensionTools?: boolean;2551 }): void {2552 const autoResizeImages = this.settingsManager.getImageAutoResize();2553 const shellCommandPrefix = this.settingsManager.getShellCommandPrefix();2554 const shellPath = this.settingsManager.getShellPath();2555 const baseToolDefinitions = this._baseToolsOverride2556 ? Object.fromEntries(2557 Object.entries(this._baseToolsOverride).map(([name, tool]) => [2558 name,2559 createToolDefinitionFromAgentTool(tool),2560 ]),2561 )2562 : createAllToolDefinitions(this._cwd, {2563 read: { autoResizeImages },2564 bash: { commandPrefix: shellCommandPrefix, shellPath },2565 });25662567 this._baseToolDefinitions = new Map(2568 Object.entries(baseToolDefinitions).map(([name, tool]) => [name, tool as ToolDefinition]),2569 );25702571 const extensionsResult = this._resourceLoader.getExtensions();2572 if (options.flagValues) {2573 for (const [name, value] of options.flagValues) {2574 extensionsResult.runtime.flagValues.set(name, value);2575 }2576 }25772578 this._extensionRunner = new ExtensionRunner(2579 extensionsResult.extensions,2580 extensionsResult.runtime,2581 this._cwd,2582 this.sessionManager,2583 new ModelRegistry(this._modelRuntime),2584 );2585 if (this._extensionRunnerRef) {2586 this._extensionRunnerRef.current = this._extensionRunner;2587 }2588 this._bindExtensionCore(this._extensionRunner);2589 this._applyExtensionBindings(this._extensionRunner);25902591 const defaultActiveToolNames = this._baseToolsOverride2592 ? Object.keys(this._baseToolsOverride)2593 : ["read", "bash", "edit", "write"];2594 const baseActiveToolNames = options.activeToolNames ?? defaultActiveToolNames;2595 this._refreshToolRegistry({2596 activeToolNames: baseActiveToolNames,2597 includeAllExtensionTools: options.includeAllExtensionTools,2598 });2599 }26002601 async reload(options?: { beforeSessionStart?: () => void | Promise<void> }): Promise<void> {2602 const previousFlagValues = this._extensionRunner.getFlagValues();2603 await emitSessionShutdownEvent(this._extensionRunner, { type: "session_shutdown", reason: "reload" });2604 await this.settingsManager.reload();2605 this.syncQueueModesFromSettings();2606 resetApiProviders();2607 await this._resourceLoader.reload();2608 this._buildRuntime({2609 activeToolNames: this.getActiveToolNames(),2610 flagValues: previousFlagValues,2611 includeAllExtensionTools: true,2612 });26132614 const hasBindings =2615 this._extensionUIContext ||2616 this._extensionCommandContextActions ||2617 this._extensionShutdownHandler ||2618 this._extensionErrorListener;2619 if (hasBindings) {2620 await options?.beforeSessionStart?.();2621 await this._extensionRunner.emit({ type: "session_start", reason: "reload" });2622 await this.extendResourcesFromExtensions("reload");2623 }2624 }26252626 // =========================================================================2627 // Auto-Retry2628 // =========================================================================26292630 /**2631 * Check if an error is retryable (overloaded, rate limit, server errors).2632 * Context overflow errors are NOT retryable (handled by compaction instead).2633 */2634 private _isRetryableError(message: AssistantMessage): boolean {2635 // Context overflow is handled by compaction, not retry.2636 if (isContextOverflow(message, this.model?.contextWindow ?? 0)) return false;2637 return isRetryableAssistantError(message);2638 }26392640 /**2641 * Retry policy + callbacks shared by compaction and branch-summary summarization calls.2642 * Uses the same `settings.retry` budget/backoff as agent-turn retries so a single transient2643 * stream drop no longer fails the whole operation. `source` carries the context2644 * the TUI needs to render the retry and recreate the underlying indicator.2645 */2646 private _summarizationRetryCallbacks(2647 source: { source: "branchSummary" } | { source: "compaction"; reason: "manual" | "threshold" | "overflow" },2648 ): RetryCallbacks {2649 return {2650 onRetryScheduled: (attempt, maxAttempts, delayMs, errorMessage) => {2651 this._emit({2652 type: "summarization_retry_scheduled",2653 attempt,2654 maxAttempts,2655 delayMs,2656 errorMessage,2657 });2658 },2659 onRetryAttemptStart: () => {2660 this._emit({2661 type: "summarization_retry_attempt_start",2662 ...source,2663 });2664 },2665 onRetryFinished: () => {2666 this._emit({ type: "summarization_retry_finished" });2667 },2668 };2669 }26702671 /**2672 * Prepare a retryable error for continuation with exponential backoff.2673 * @returns true if the caller should continue the agent, false otherwise2674 */2675 private async _prepareRetry(message: AssistantMessage): Promise<boolean> {2676 const settings = this.settingsManager.getRetrySettings();2677 if (!settings.enabled) {2678 return false;2679 }26802681 this._retryAttempt++;26822683 if (this._retryAttempt > settings.maxRetries) {2684 // Preserve the completed attempt count so post-run handling can emit the final failure.2685 this._retryAttempt--;2686 return false;2687 }26882689 const delayMs = settings.baseDelayMs * 2 ** (this._retryAttempt - 1);26902691 this._emit({2692 type: "auto_retry_start",2693 attempt: this._retryAttempt,2694 maxAttempts: settings.maxRetries,2695 delayMs,2696 errorMessage: message.errorMessage || "Unknown error",2697 });26982699 // Remove error message from agent state (keep in session for history)2700 const messages = this.agent.state.messages;2701 if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {2702 this.agent.state.messages = messages.slice(0, -1);2703 }27042705 // Wait with exponential backoff (abortable)2706 this._retryAbortController = new AbortController();2707 try {2708 await sleep(delayMs, this._retryAbortController.signal);2709 } catch {2710 // Aborted during sleep - emit end event so UI can clean up2711 const attempt = this._retryAttempt;2712 this._retryAttempt = 0;2713 this._emit({2714 type: "auto_retry_end",2715 success: false,2716 attempt,2717 finalError: "Retry cancelled",2718 });2719 return false;2720 } finally {2721 this._retryAbortController = undefined;2722 }27232724 return true;2725 }27262727 /**2728 * Cancel in-progress retry.2729 */2730 abortRetry(): void {2731 this._retryAbortController?.abort();2732 }27332734 /** Whether auto-retry is currently in progress */2735 get isRetrying(): boolean {2736 return this._retryAbortController !== undefined;2737 }27382739 /** Whether auto-retry is enabled */2740 get autoRetryEnabled(): boolean {2741 return this.settingsManager.getRetryEnabled();2742 }27432744 /**2745 * Toggle auto-retry setting.2746 */2747 setAutoRetryEnabled(enabled: boolean): void {2748 this.settingsManager.setRetryEnabled(enabled);2749 }27502751 // =========================================================================2752 // Bash Execution2753 // =========================================================================27542755 /**2756 * Execute a bash command.2757 * Adds result to agent context and session.2758 * @param command The bash command to execute2759 * @param onChunk Optional streaming callback for output2760 * @param options.excludeFromContext If true, command output won't be sent to LLM (!! prefix)2761 * @param options.id Optional identifier included in bash execution update events2762 * @param options.operations Custom BashOperations for remote execution2763 */2764 async executeBash(2765 command: string,2766 onChunk?: (chunk: string) => void,2767 options?: { excludeFromContext?: boolean; id?: string; operations?: BashOperations },2768 ): Promise<BashResult> {2769 const abortController = new AbortController();2770 this._bashAbortControllers.add(abortController);27712772 // Apply command prefix if configured (e.g., "shopt -s expand_aliases" for alias support)2773 const prefix = this.settingsManager.getShellCommandPrefix();2774 const shellPath = this.settingsManager.getShellPath();2775 const resolvedCommand = prefix ? `${prefix}\n${command}` : command;27762777 try {2778 const result = await executeBashWithOperations(2779 resolvedCommand,2780 this.sessionManager.getCwd(),2781 options?.operations ?? createLocalBashOperations({ shellPath }),2782 {2783 onChunk: (delta) => {2784 onChunk?.(delta);2785 this._emit({ type: "bash_execution_update", id: options?.id, delta });2786 },2787 signal: abortController.signal,2788 },2789 );27902791 this.recordBashResult(command, result, options);2792 return result;2793 } finally {2794 this._bashAbortControllers.delete(abortController);2795 }2796 }27972798 /**2799 * Record a bash execution result in session history.2800 * Used by executeBash and by extensions that handle bash execution themselves.2801 */2802 recordBashResult(command: string, result: BashResult, options?: { excludeFromContext?: boolean }): void {2803 const bashMessage: BashExecutionMessage = {2804 role: "bashExecution",2805 command,2806 output: result.output,2807 exitCode: result.exitCode,2808 cancelled: result.cancelled,2809 truncated: result.truncated,2810 fullOutputPath: result.fullOutputPath,2811 timestamp: Date.now(),2812 excludeFromContext: options?.excludeFromContext,2813 };28142815 // If agent is streaming, defer adding to avoid breaking tool_use/tool_result ordering2816 if (this.isStreaming) {2817 // Queue for later - will be flushed on agent_end2818 this._pendingBashMessages.push(bashMessage);2819 } else {2820 // Add to agent state immediately2821 this.agent.state.messages.push(bashMessage);28222823 // Save to session2824 this.sessionManager.appendMessage(bashMessage);2825 }2826 }28272828 /**2829 * Cancel running bash command.2830 */2831 abortBash(): void {2832 for (const abortController of [...this._bashAbortControllers]) {2833 abortController.abort();2834 }2835 }28362837 /** Whether a bash command is currently running */2838 get isBashRunning(): boolean {2839 return this._bashAbortControllers.size > 0;2840 }28412842 /** Whether there are pending bash messages waiting to be flushed */2843 get hasPendingBashMessages(): boolean {2844 return this._pendingBashMessages.length > 0;2845 }28462847 /**2848 * Flush pending bash messages to agent state and session.2849 * Called after agent turn completes to maintain proper message ordering.2850 */2851 private _flushPendingBashMessages(): void {2852 if (this._pendingBashMessages.length === 0) return;28532854 for (const bashMessage of this._pendingBashMessages) {2855 // Add to agent state2856 this.agent.state.messages.push(bashMessage);28572858 // Save to session2859 this.sessionManager.appendMessage(bashMessage);2860 }28612862 this._pendingBashMessages = [];2863 }28642865 // =========================================================================2866 // Session Management2867 // =========================================================================28682869 /**2870 * Set a display name for the current session.2871 */2872 setSessionName(name: string): void {2873 this.sessionManager.appendSessionInfo(name);2874 const event = { type: "session_info_changed", name: this.sessionManager.getSessionName() } as const;2875 this._emit(event);2876 void this._extensionRunner.emit(event);2877 }28782879 // =========================================================================2880 // Tree Navigation2881 // =========================================================================28822883 /**2884 * Navigate to a different node in the session tree.2885 * Unlike fork() which creates a new session file, this stays in the same file.2886 *2887 * @param targetId The entry ID to navigate to2888 * @param options.summarize Whether user wants to summarize abandoned branch2889 * @param options.customInstructions Custom instructions for summarizer2890 * @param options.replaceInstructions If true, customInstructions replaces the default prompt2891 * @param options.label Label to attach to the branch summary entry2892 * @returns Result with editorText (if user message) and cancelled status2893 */2894 async navigateTree(2895 targetId: string,2896 options: { summarize?: boolean; customInstructions?: string; replaceInstructions?: boolean; label?: string } = {},2897 ): Promise<{ editorText?: string; cancelled: boolean; aborted?: boolean; summaryEntry?: BranchSummaryEntry }> {2898 const oldLeafId = this.sessionManager.getLeafId();28992900 // No-op if already at target2901 if (targetId === oldLeafId) {2902 return { cancelled: false };2903 }29042905 // Model required for summarization2906 if (options.summarize && !this.model) {2907 throw new Error("No model available for summarization");2908 }29092910 const targetEntry = this.sessionManager.getEntry(targetId);2911 if (!targetEntry) {2912 throw new Error(`Entry ${targetId} not found`);2913 }29142915 // Collect entries to summarize (from old leaf to common ancestor)2916 const { entries: entriesToSummarize, commonAncestorId } = collectEntriesForBranchSummary(2917 this.sessionManager,2918 oldLeafId,2919 targetId,2920 );29212922 // Prepare event data - mutable so extensions can override2923 let customInstructions = options.customInstructions;2924 let replaceInstructions = options.replaceInstructions;2925 let label = options.label;29262927 const preparation: TreePreparation = {2928 targetId,2929 oldLeafId,2930 commonAncestorId,2931 entriesToSummarize,2932 userWantsSummary: options.summarize ?? false,2933 customInstructions,2934 replaceInstructions,2935 label,2936 };29372938 // Set up abort controller for summarization2939 this._branchSummaryAbortController = new AbortController();29402941 try {2942 let extensionSummary: { summary: string; details?: unknown; usage?: Usage } | undefined;2943 let fromExtension = false;29442945 // Emit session_before_tree event2946 if (this._extensionRunner.hasHandlers("session_before_tree")) {2947 const result = (await this._extensionRunner.emit({2948 type: "session_before_tree",2949 preparation,2950 signal: this._branchSummaryAbortController.signal,2951 })) as SessionBeforeTreeResult | undefined;29522953 if (result?.cancel) {2954 return { cancelled: true };2955 }29562957 if (result?.summary && options.summarize) {2958 extensionSummary = result.summary;2959 fromExtension = true;2960 }29612962 // Allow extensions to override instructions and label2963 if (result?.customInstructions !== undefined) {2964 customInstructions = result.customInstructions;2965 }2966 if (result?.replaceInstructions !== undefined) {2967 replaceInstructions = result.replaceInstructions;2968 }2969 if (result?.label !== undefined) {2970 label = result.label;2971 }2972 }29732974 // Run default summarizer if needed2975 let summaryText: string | undefined;2976 let summaryDetails: unknown;2977 let summaryUsage: Usage | undefined;2978 if (options.summarize && entriesToSummarize.length > 0 && !extensionSummary) {2979 const model = this.model!;2980 const { apiKey, headers, env } = await this._getSummarizationRequestAuth(model);2981 const branchSummarySettings = this.settingsManager.getBranchSummarySettings();2982 const result = await generateBranchSummary(entriesToSummarize, {2983 model,2984 apiKey,2985 headers,2986 env,2987 signal: this._branchSummaryAbortController.signal,2988 customInstructions,2989 replaceInstructions,2990 reserveTokens: branchSummarySettings.reserveTokens,2991 streamFn: this.agent.streamFunction,2992 retry: this.settingsManager.getRetrySettings(),2993 callbacks: this._summarizationRetryCallbacks({ source: "branchSummary" }),2994 });2995 if (result.aborted) {2996 return { cancelled: true, aborted: true };2997 }2998 if (result.error) {2999 throw new Error(result.error);3000 }3001 summaryText = result.summary;3002 summaryUsage = result.usage;3003 summaryDetails = {3004 readFiles: result.readFiles || [],3005 modifiedFiles: result.modifiedFiles || [],3006 };3007 } else if (extensionSummary) {3008 summaryText = extensionSummary.summary;3009 summaryDetails = extensionSummary.details;3010 summaryUsage = extensionSummary.usage;3011 }30123013 // Determine the new leaf position based on target type3014 let newLeafId: string | null;3015 let editorText: string | undefined;30163017 if (targetEntry.type === "message" && targetEntry.message.role === "user") {3018 // User message: leaf = parent (null if root), text goes to editor3019 newLeafId = targetEntry.parentId;3020 editorText = contentText(targetEntry.message.content, "");3021 } else if (targetEntry.type === "custom_message") {3022 // Custom message: leaf = parent (null if root), text goes to editor3023 newLeafId = targetEntry.parentId;3024 editorText = contentText(targetEntry.content, "");3025 } else {3026 // Non-user message: leaf = selected node3027 newLeafId = targetId;3028 }30293030 // Switch leaf (with or without summary)3031 // Summary is attached at the navigation target position (newLeafId), not the old branch3032 let summaryEntry: BranchSummaryEntry | undefined;3033 if (summaryText) {3034 // Create summary at target position (can be null for root)3035 const summaryId = this.sessionManager.branchWithSummary(3036 newLeafId,3037 summaryText,3038 summaryDetails,3039 fromExtension,3040 summaryUsage,3041 );3042 summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry;30433044 // Attach label to the summary entry3045 if (label) {3046 this.sessionManager.appendLabelChange(summaryId, label);3047 }3048 } else if (newLeafId === null) {3049 // No summary, navigating to root - reset leaf3050 this.sessionManager.resetLeaf();3051 } else {3052 // No summary, navigating to non-root3053 this.sessionManager.branch(newLeafId);3054 }30553056 // Attach label to target entry when not summarizing (no summary entry to label)3057 if (label && !summaryText) {3058 this.sessionManager.appendLabelChange(targetId, label);3059 }30603061 // Update agent state3062 const sessionContext = this.sessionManager.buildSessionContext();3063 this.agent.state.messages = sessionContext.messages;30643065 // Emit session_tree event3066 await this._extensionRunner.emit({3067 type: "session_tree",3068 newLeafId: this.sessionManager.getLeafId(),3069 oldLeafId,3070 summaryEntry,3071 fromExtension: summaryText ? fromExtension : undefined,3072 });30733074 // Emit to custom tools30753076 return { editorText, cancelled: false, summaryEntry };3077 } finally {3078 this._branchSummaryAbortController = undefined;3079 }3080 }30813082 /**3083 * Get all user messages from session for fork selector.3084 */3085 getUserMessagesForForking(): Array<{ entryId: string; text: string }> {3086 const entries = this.sessionManager.getEntries();3087 const result: Array<{ entryId: string; text: string }> = [];30883089 for (const entry of entries) {3090 if (entry.type !== "message") continue;3091 if (entry.message.role !== "user") continue;30923093 const text = contentText(entry.message.content, "");3094 if (text) {3095 result.push({ entryId: entry.id, text });3096 }3097 }30983099 return result;3100 }31013102 /**3103 * Get session statistics. Aggregates over ALL session entries (including3104 * history that was compacted away), so token/cost totals reflect what was3105 * actually billed across the session.3106 */3107 getSessionStats(): SessionStats {3108 let userMessages = 0;3109 let assistantMessages = 0;3110 let toolResults = 0;3111 let totalMessages = 0;3112 let toolCalls = 0;3113 const usageTotals = createUsageTotals();31143115 for (const entry of this.sessionManager.getEntries()) {3116 if ((entry.type === "branch_summary" || entry.type === "compaction") && entry.usage) {3117 addUsageToTotals(usageTotals, entry.usage);3118 }3119 if (entry.type !== "message") continue;3120 totalMessages++;3121 const message = entry.message;3122 if (message.role === "user") {3123 userMessages++;3124 } else if (message.role === "toolResult") {3125 toolResults++;3126 if (message.usage) {3127 addUsageToTotals(usageTotals, message.usage);3128 }3129 } else if (message.role === "assistant") {3130 assistantMessages++;3131 const assistantMsg = message as AssistantMessage;3132 if (Array.isArray(assistantMsg.content)) {3133 toolCalls += assistantMsg.content.filter((c) => c.type === "toolCall").length;3134 }3135 addUsageToTotals(usageTotals, assistantMsg.usage);3136 }3137 }31383139 return {3140 sessionFile: this.sessionFile,3141 sessionId: this.sessionId,3142 userMessages,3143 assistantMessages,3144 toolCalls,3145 toolResults,3146 totalMessages,3147 tokens: {3148 input: usageTotals.input,3149 output: usageTotals.output,3150 cacheRead: usageTotals.cacheRead,3151 cacheWrite: usageTotals.cacheWrite,3152 total: usageTotals.input + usageTotals.output + usageTotals.cacheRead + usageTotals.cacheWrite,3153 },3154 cost: usageTotals.cost,3155 contextUsage: this.getContextUsage(),3156 };3157 }31583159 getContextUsage(): ContextUsage | undefined {3160 const model = this.model;3161 if (!model) return undefined;31623163 const contextWindow = model.contextWindow ?? 0;3164 if (contextWindow <= 0) return undefined;31653166 // After compaction, the last assistant usage reflects pre-compaction context size.3167 // We can only trust usage from an assistant that responded after the latest compaction.3168 // If no such assistant exists, context token count is unknown until the next LLM response.3169 const branchEntries = this.sessionManager.getBranch();3170 const latestCompaction = getLatestCompactionEntry(branchEntries);31713172 if (latestCompaction) {3173 // Check if there's a valid assistant usage after the compaction boundary3174 const compactionIndex = branchEntries.lastIndexOf(latestCompaction);3175 let hasPostCompactionUsage = false;3176 for (let i = branchEntries.length - 1; i > compactionIndex; i--) {3177 const entry = branchEntries[i];3178 if (entry.type === "message" && entry.message.role === "assistant") {3179 const assistant = entry.message;3180 if (assistant.stopReason !== "aborted" && assistant.stopReason !== "error") {3181 const contextTokens = calculateContextTokens(assistant.usage);3182 if (contextTokens > 0) {3183 hasPostCompactionUsage = true;3184 break;3185 }3186 }3187 }3188 }31893190 if (!hasPostCompactionUsage) {3191 return { tokens: null, contextWindow, percent: null };3192 }3193 }31943195 const estimate = estimateContextTokens(this.messages);3196 const percent = (estimate.tokens / contextWindow) * 100;31973198 return {3199 tokens: estimate.tokens,3200 contextWindow,3201 percent,3202 };3203 }32043205 /**3206 * Export session to HTML.3207 * @param outputPath Optional output path (defaults to session directory)3208 * @returns Path to exported file3209 */3210 async exportToHtml(outputPath?: string): Promise<string> {3211 const configuredThemeName = this.settingsManager.getTheme();3212 const themeName = configuredThemeName && getThemeByName(configuredThemeName) ? configuredThemeName : undefined;32133214 // Create tool renderer if we have an extension runner (for custom tool HTML rendering)3215 const toolRenderer: ToolHtmlRenderer = createToolHtmlRenderer({3216 getToolDefinition: (name) => this.getToolDefinition(name),3217 theme,3218 cwd: this.sessionManager.getCwd(),3219 });32203221 return await exportSessionToHtml(this.sessionManager, this.state, {3222 outputPath,3223 themeName,3224 toolRenderer,3225 });3226 }32273228 /**3229 * Export the current session branch to a JSONL file.3230 * Writes the session header followed by all entries on the current branch path.3231 * @param outputPath Target file path. If omitted, generates a timestamped file in cwd.3232 * @returns The resolved output file path.3233 */3234 exportToJsonl(outputPath?: string): string {3235 const filePath = resolvePath(3236 outputPath ?? `session-${new Date().toISOString().replace(/[:.]/g, "-")}.jsonl`,3237 process.cwd(),3238 );3239 const dir = dirname(filePath);3240 if (!existsSync(dir)) {3241 mkdirSync(dir, { recursive: true });3242 }32433244 const header: SessionHeader = {3245 type: "session",3246 version: CURRENT_SESSION_VERSION,3247 id: this.sessionManager.getSessionId(),3248 timestamp: new Date().toISOString(),3249 cwd: this.sessionManager.getCwd(),3250 };32513252 const branchEntries = this.sessionManager.getBranch();3253 const lines = [JSON.stringify(header)];32543255 // Re-chain parentIds to form a linear sequence3256 let prevId: string | null = null;3257 for (const entry of branchEntries) {3258 const linear = { ...entry, parentId: prevId };3259 lines.push(JSON.stringify(linear));3260 prevId = entry.id;3261 }32623263 writeFileSync(filePath, `${lines.join("\n")}\n`);3264 return filePath;3265 }32663267 // =========================================================================3268 // Utilities3269 // =========================================================================32703271 /**3272 * Get text content of last assistant message.3273 * Useful for /copy command.3274 * @returns Text content, or undefined if no assistant message exists3275 */3276 getLastAssistantText(): string | undefined {3277 const lastAssistant = this.messages3278 .slice()3279 .reverse()3280 .find((m) => {3281 if (m.role !== "assistant") return false;3282 const msg = m as AssistantMessage;3283 // Skip aborted messages with no content3284 if (msg.stopReason === "aborted" && msg.content.length === 0) return false;3285 return true;3286 });32873288 if (!lastAssistant) return undefined;32893290 let text = "";3291 for (const content of (lastAssistant as AssistantMessage).content) {3292 if (content.type === "text") {3293 text += content.text;3294 }3295 }32963297 return text.trim() || undefined;3298 }32993300 // =========================================================================3301 // Extension System3302 // =========================================================================33033304 createReplacedSessionContext(): ReplacedSessionContext {3305 const context = Object.defineProperties(3306 {},3307 Object.getOwnPropertyDescriptors(this._extensionRunner.createCommandContext()),3308 ) as ReplacedSessionContext;3309 context.sendMessage = (message, options) => this.sendCustomMessage(message, options);3310 context.sendUserMessage = (content, options) => this.sendUserMessage(content, options);3311 return context;3312 }33133314 /**3315 * Check if extensions have handlers for a specific event type.3316 */3317 hasExtensionHandlers(eventType: string): boolean {3318 return this._extensionRunner.hasHandlers(eventType);3319 }33203321 /**3322 * Get the extension runner (for setting UI context and error handlers).3323 */3324 get extensionRunner(): ExtensionRunner {3325 return this._extensionRunner;3326 }3327}3328