UNPKG

@earendil-works/pi-coding-agent

Version:

Coding agent CLI with read, bash, edit, write tools and session management

3,743 lines • 175 kB
/**
 * AgentSession - Core abstraction for agent lifecycle and session management.
 *
 * This class is shared between all run modes (interactive, print, rpc).
 * It encapsulates:
 * - Agent state access
 * - Event subscription with automatic session persistence
 * - Model and thinking level management
 * - Compaction (manual and auto)
 * - Bash execution
 * - Session switching and branching
 *
 * Modes use this class and add their own I/O layer on top.
 */
import { readFileSync } from "node:fs";
import { basename, dirname } from "node:path";
import { runToolCall, } from "@earendil-works/pi-agent-core";
import { contentText, getCurrentSystemMessage, getCurrentTools, retryDelayMs } from "@earendil-works/pi-ai";
import { clampThinkingLevel, cleanupSessionResources, getSupportedThinkingLevels, isContextOverflow, isRecoverableLength, isRetryableAssistantError, modelsAreEqual, resetApiProviders, streamSimple, } from "@earendil-works/pi-ai/compat";
import { getThemeByName, theme } from "../modes/interactive/theme/theme.js";
import { stripFrontmatter } from "../utils/frontmatter.js";
import { processImage } from "../utils/image-process.js";
import { sleep } from "../utils/sleep.js";
import { normalizeToolResultImages } from "../utils/tool-result-images.js";
import { formatNoApiKeyFoundMessage, formatNoModelSelectedMessage } from "./auth-guidance.js";
import { executeBashWithOperations } from "./bash-executor.js";
import { generateBugReportSummary } from "./bug-report.js";
import { calculateContextTokens, collectEntriesForBranchSummary, compact, estimateContextTokens, estimateProjectedContextTokens, estimateTokens, generateBranchSummary, prepareCompaction, shouldCompact, } from "./compaction/index.js";
import { DEFAULT_THINKING_LEVEL, THINKING_LEVEL_OPTIONS } from "./defaults.js";
import { exportSessionToHtml } from "./export-html/index.js";
import { createToolHtmlRenderer } from "./export-html/tool-renderer.js";
import { ExtensionRunner, wrapRegisteredTools, } from "./extensions/index.js";
import { emitSessionShutdownEvent } from "./extensions/runner.js";
import { createToolNameMatcher, isMcpToolName } from "./mcp-servers.js";
import { convertToLlm } from "./messages.js";
import { ModelRegistry } from "./model-registry.js";
import { NestedToolCallRunner } from "./nested-tool-calls.js";
import { expandPromptTemplate } from "./prompt-templates.js";
import { exportSessionToJsonl } from "./session-export.js";
import { getLatestCompactionEntry, SessionManager, } from "./session-manager.js";
import { applyToolModifiers, DEFAULT_TOOL_NAMES, } from "./settings-manager.js";
import { BUILTIN_PATH_PREFIX, createSyntheticSourceInfo, isSyntheticPath } from "./source-info.js";
import { buildSystemPrompt, buildSystemPromptSections, diffSystemPromptSections, normalizeBuildSystemPromptOptions, } from "./system-prompt.js";
import { createLocalBashOperations } from "./tools/bash.js";
import { createAllToolDefinitions } from "./tools/index.js";
import { createToolDefinitionFromAgentTool } from "./tools/tool-definition-wrapper.js";
import { addUsageToTotals, combineUsage, createUsageTotals } from "./usage-totals.js";
import { findLatestResponse, getBranchSelection, getVirtualModelState, isVirtualModel, VIRTUAL_MODEL_STATE_ENTRY, } from "./virtual-models.js";
/**
 * Stream functions that resolve auth through a given ModelRuntime themselves, such as the one
 * createAgentSession installs. Summaries sent through them must not pass pre-resolved credentials:
 * the runtime would take them as an explicit API key instead of the stored OAuth login (#11520).
 */
const runtimeStreamFunctions = new WeakMap();
/** Mark `streamFn` as resolving auth through `modelRuntime` on its own. */
export function markRuntimeStreamFunction(streamFn, modelRuntime) {
    runtimeStreamFunctions.set(streamFn, modelRuntime);
}
/**
 * Parse a skill block from message text.
 * Returns null if the text doesn't contain a skill block.
 */
export function parseSkillBlock(text) {
    const match = text.match(/^<skill name="([^"]+)" location="([^"]+)">\n([\s\S]*?)\n<\/skill>(?:\n\n([\s\S]+))?$/);
    if (!match)
        return null;
    return {
        name: match[1],
        location: match[2],
        content: match[3],
        userMessage: match[4]?.trim() || undefined,
    };
}
// ============================================================================
// Types
// ============================================================================
function withoutDeletedHeaders(headers) {
    return headers
        ? Object.fromEntries(Object.entries(headers).filter((entry) => entry[1] !== null))
        : undefined;
}
function estimateMessagesTokens(messages) {
    let tokens = 0;
    for (const message of messages) {
        tokens += estimateTokens(message);
    }
    return tokens;
}
// ============================================================================
// AgentSession Class
// ============================================================================
/**
 * Index of the model to cycle to, or undefined when there is nothing to switch to.
 * When the current model is not in the list (currentIndex -1), forward picks the first
 * entry and backward the last, so a one-entry list still selects its only model.
 */
function getNextCycleIndex(currentIndex, length, direction) {
    if (length === 0)
        return undefined;
    if (currentIndex === -1)
        return direction === "forward" ? 0 : length - 1;
    if (length === 1)
        return undefined;
    return direction === "forward" ? (currentIndex + 1) % length : (currentIndex - 1 + length) % length;
}
export class AgentSession {
    agent;
    sessionManager;
    settingsManager;
    _scopedModels;
    // Event subscription state
    _unsubscribeAgent;
    _eventListeners = [];
    _isAgentRunActive = false;
    _promptPreflightCount = 0;
    _agentRunAbortRequested = false;
    _idleWaitPromise;
    _resolveIdleWait;
    /** Tracks pending steering messages for UI display. Removed when delivered. */
    _steeringMessages = [];
    /** Tracks pending follow-up messages for UI display. Removed when delivered. */
    _followUpMessages = [];
    /** Queued user messages whose images are normalized when they are delivered. */
    _queuedMessagesWithImages = new WeakSet();
    /** Messages queued to be included with the next user prompt as context ("asides"). */
    _pendingNextTurnMessages = [];
    /** Context-only custom messages queued during a run, flushed once the current turn's tool results are in. */
    _pendingCustomMessages = [];
    // Compaction state
    _compactionAbortController = undefined;
    _autoCompactionAbortController = undefined;
    _overflowRecoveryAttempted = false;
    // Branch summarization state
    _branchSummaryAbortController = undefined;
    // Retry state
    _retryAbortController = undefined;
    _retryAttempt = 0;
    /**
     * Failed response that the next request repeats, set by auto-retry and overflow recovery. The
     * retry is routed with it as `failed`, since the context no longer contains it.
     */
    _failedResponse;
    // Bash execution state
    _bashAbortControllers = new Set();
    _pendingBashMessages = [];
    // Extension system
    _extensionRunner;
    _turnIndex = 0;
    _entryIdsByMessage = new WeakMap();
    _boundaryDispatchedMessages = new WeakSet();
    _lastAssistantMessage;
    _lastActivityOutcome = "completed";
    _isBeforeSettle = false;
    _abortDuringBeforeSettle = false;
    _isEmittingAgentSettled = false;
    _deferredSettledActions = [];
    _resourceLoader;
    _customTools;
    _baseToolDefinitions = new Map();
    _cwd;
    _extensionRunnerRef;
    _initialActiveToolNames;
    /**
     * Tools of the restored or reloaded loadout that are not registered yet, such as tools of MCP
     * servers that are still connecting. They are activated when they are registered, and dropped when
     * `setActiveToolsByName()` deactivates a tool or the next agent run starts.
     */
    _pendingToolNames = new Set();
    _usesDefaultTools;
    _defaultToolModifiers;
    /** Matches the `--tools` entries: tool names or patterns. */
    _allowedTools;
    /**
     * Whether the allowlist filters MCP tools: it is empty (`--no-tools`) or names an MCP tool
     * (`mcp__*`). Otherwise it keeps MCP tools registered for codemode and tool_search.
     */
    _allowlistFiltersMcp = false;
    _excludedTools;
    _baseToolsOverride;
    _sessionStartEvent;
    _extensionUIContext;
    _extensionMode = "print";
    _extensionCommandContextActions;
    _extensionAbortHandler;
    _extensionShutdownHandler;
    _extensionErrorListener;
    _extensionErrorUnsubscriber;
    _modelRuntime;
    _cacheWarmer;
    // Tool registry for extension getTools/setTools
    _toolRegistry = new Map();
    /** Created on the first `ctx.executeTool()` call. */
    _nestedToolCalls;
    /** Declared tools whose declarations requests leave out, from `prepareLoadout` hooks. */
    _hiddenDeclarations = new Set();
    _toolDefinitions = new Map();
    _toolPromptSnippets = new Map();
    _toolPromptGuidelines = new Map();
    _baseSystemPromptOptions;
    /** Prompt options after before_agent_start mutations for the active run. */
    _runSystemPromptOptions;
    constructor(config) {
        this.agent = config.agent;
        this.sessionManager = config.sessionManager;
        this.settingsManager = config.settingsManager;
        this._scopedModels = config.scopedModels ?? [];
        this._resourceLoader = config.resourceLoader;
        this._customTools = config.customTools ?? [];
        this._cwd = config.cwd;
        this._modelRuntime = config.modelRuntime;
        this._cacheWarmer = config.cacheWarmer;
        if (this._cacheWarmer) {
            this._cacheWarmer.onWarmed = (entry) => this._emit({ type: "entry_appended", entry });
        }
        this._extensionRunnerRef = config.extensionRunnerRef;
        this._initialActiveToolNames = config.initialActiveToolNames;
        this._usesDefaultTools = config.usesDefaultTools ?? false;
        this._defaultToolModifiers = config.defaultToolModifiers ?? [];
        if (config.allowedToolNames) {
            this._allowedTools = createToolNameMatcher(config.allowedToolNames);
            this._allowlistFiltersMcp =
                config.allowedToolNames.length === 0 || config.allowedToolNames.some((entry) => entry.startsWith("mcp__"));
        }
        this._excludedTools = config.excludedToolNames ? createToolNameMatcher(config.excludedToolNames) : undefined;
        this._baseToolsOverride = config.baseToolsOverride;
        this._sessionStartEvent = config.sessionStartEvent ?? { type: "session_start", reason: "startup" };
        // Always subscribe to agent events for internal handling
        // (session persistence, extensions, auto-compaction, retry logic)
        this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);
        this._installAgentToolHooks();
        this._installAgentNextTurnRefresh();
        this._installAgentRequestProjection();
        this._installAgentBoundaryHooks();
        this._installHiddenDeclarationsProjection();
        this._installAgentForcedPromptProjection();
        this._buildRuntime({
            activeToolNames: this._initialActiveToolNames,
            includeAllExtensionTools: true,
        });
        if (this._usesDefaultTools)
            this._restoreToolsFromTranscript(this._defaultToolModifiers);
    }
    get modelRuntime() {
        return this._modelRuntime;
    }
    async _getRequiredRequestAuth(model, signal) {
        let result;
        try {
            result = await this._modelRuntime.getAuth(model, { signal });
        }
        catch (error) {
            const cause = error instanceof Error ? error.cause : undefined;
            if (cause instanceof Error && cause.message === "authHeader requires a resolved API key") {
                throw new Error(formatNoApiKeyFoundMessage(model.provider));
            }
            throw error;
        }
        if (result && (result.auth.apiKey || result.auth.headers)) {
            const requestModel = result.auth.baseUrl ? { ...model, baseUrl: result.auth.baseUrl } : model;
            return {
                model: requestModel,
                apiKey: result.auth.apiKey,
                headers: withoutDeletedHeaders(result.auth.headers),
                env: result.env,
            };
        }
        const isOAuth = this._modelRuntime.isUsingOAuth(model.provider);
        if (isOAuth) {
            throw new Error(`Authentication failed for "${model.provider}". ` +
                `Credentials may have expired or network is unavailable. ` +
                `Run '/login ${model.provider}' to re-authenticate.`);
        }
        throw new Error(formatNoApiKeyFoundMessage(model.provider));
    }
    async _getSummarizationRequestAuth(selectedModel, signal) {
        // Route a virtual model first: summaries size their input and output from the model they get.
        const { model, thinkingLevel } = isVirtualModel(selectedModel)
            ? await this._modelRuntime.resolveModel(selectedModel, convertToLlm(this.messages), {
                reason: "direct",
                thinkingLevel: this.thinkingLevel,
                signal,
            })
            : { model: selectedModel, thinkingLevel: this.thinkingLevel };
        if (this.agent.streamFunction === streamSimple) {
            return { ...(await this._getRequiredRequestAuth(model, signal)), thinkingLevel };
        }
        if (runtimeStreamFunctions.get(this.agent.streamFunction) === this._modelRuntime) {
            return { model, thinkingLevel };
        }
        try {
            const result = await this._modelRuntime.getAuth(model, { signal });
            if (!result)
                return { model, thinkingLevel };
            const requestModel = result.auth.baseUrl ? { ...model, baseUrl: result.auth.baseUrl } : model;
            return {
                model: requestModel,
                apiKey: result.auth.apiKey,
                headers: withoutDeletedHeaders(result.auth.headers),
                env: result.env,
                thinkingLevel,
            };
        }
        catch (error) {
            if (signal?.aborted)
                throw error;
            return { model, thinkingLevel };
        }
    }
    /**
     * The model whose limits apply to `message`, or undefined when the message came from another
     * model. Under a virtual selection, that is the physical model that produced it.
     */
    _modelForMessage(message) {
        const model = this.model;
        if (model && isVirtualModel(model))
            return this._modelRuntime.getPhysicalModel(message.provider, message.model);
        return model?.provider === message.provider && model.id === message.model ? model : undefined;
    }
    /** Record the live model and thinking level on the current branch, so a resume restores them. */
    _recordSelection() {
        const branch = this.sessionManager.getBranch();
        this._recordModelSelection(branch);
        this._recordThinkingLevel(branch);
    }
    /**
     * Record the model on the current branch when the branch implies another one. Tree navigation
     * can leave the latest `model_change` on another branch; responses cannot record a virtual
     * selection because they name physical models. Responses do record a physical selection unless
     * the branch holds a virtual one; checking a physical selection against responses would record
     * it on every prompt while `prepareRequest` redirects to another model.
     */
    _recordModelSelection(branch) {
        const model = this.model;
        if (!model)
            return;
        const getModel = (provider, modelId) => this._modelRuntime.getModel(provider, modelId);
        const recorded = getBranchSelection(branch, getModel);
        if (!recorded || (recorded.provider === model.provider && recorded.modelId === model.id))
            return;
        const recordedModel = getModel(recorded.provider, recorded.modelId);
        if (!isVirtualModel(model) && !(recordedModel && isVirtualModel(recordedModel)))
            return;
        this.sessionManager.appendModelChange(model.provider, model.id);
    }
    /**
     * Record the thinking level on the current branch when the branch implies another one (#11451).
     * Tree navigation can leave the latest `thinking_level_change` on another branch. The recorded
     * level is compared after clamping to the current model, so a level clamped on resume (e.g.
     * `xhigh` running as `high`) is not recorded over the requested one.
     */
    _recordThinkingLevel(branch) {
        const recorded = branch.findLast((entry) => entry.type === "thinking_level_change");
        if (!recorded)
            return;
        const recordedLevel = recorded.thinkingLevel;
        const availableLevels = this.getAvailableThinkingLevels();
        const effective = availableLevels.includes(recordedLevel)
            ? recordedLevel
            : this._clampThinkingLevel(recordedLevel, availableLevels);
        if (effective === this.thinkingLevel)
            return;
        this.sessionManager.appendThinkingLevelChange(this.thinkingLevel);
    }
    /** The model whose limits apply to the conversation. */
    _limitsModel() {
        return this.routedModel?.model ?? this.model;
    }
    /**
     * Install tool hooks once on the Agent instance.
     *
     * The callbacks read `this._extensionRunner` at execution time, so extension reload swaps in the
     * new runner without reinstalling hooks. Extension-specific tool wrappers are still used to adapt
     * registered tool execution to the extension context. Tool call and tool result interception now
     * happens here instead of in wrappers.
     */
    _installAgentToolHooks() {
        this.agent.beforeToolCall = (context) => this._beforeToolCall(context);
        this.agent.afterToolCall = (context) => this._afterToolCall(context);
    }
    /** `tool_call` handlers. `parentToolCallId` is set for calls another tool made. */
    async _beforeToolCall({ toolCall, args }, parentToolCallId) {
        const runner = this._extensionRunner;
        if (!runner.hasHandlers("tool_call")) {
            return undefined;
        }
        try {
            return await runner.emitToolCall({
                type: "tool_call",
                toolName: toolCall.name,
                toolCallId: toolCall.id,
                ...(parentToolCallId ? { parentToolCallId } : {}),
                input: args,
            });
        }
        catch (err) {
            if (err instanceof Error) {
                throw err;
            }
            throw new Error(`Extension failed, blocking execution: ${String(err)}`);
        }
    }
    /** `tool_result` handlers and image normalization. `parentToolCallId` is set for calls another tool made. */
    async _afterToolCall({ toolCall, args, result, isError }, parentToolCallId) {
        const runner = this._extensionRunner;
        const hookResult = runner.hasHandlers("tool_result")
            ? await runner.emitToolResult({
                type: "tool_result",
                toolName: toolCall.name,
                toolCallId: toolCall.id,
                ...(parentToolCallId ? { parentToolCallId } : {}),
                input: args,
                content: result.content,
                details: result.details,
                ...(result.structuredContent === undefined ? {} : { structuredContent: result.structuredContent }),
                isError,
                usage: result.usage,
            })
            : undefined;
        const content = hookResult?.content ?? result.content ?? [];
        // Runs after the extension hook so images injected or replaced by extensions are normalized too.
        const resizeOptions = this._limitsModel()?.inputLimits?.images?.resize;
        const normalizedContent = await normalizeToolResultImages(content, {
            autoResizeImages: this.settingsManager.getImageAutoResize(),
            ...(resizeOptions ? { resizeOptions } : {}),
        });
        if (!hookResult && normalizedContent === content) {
            return undefined;
        }
        // The hook result already dropped structured content that replaced content no longer matches.
        return {
            content: normalizedContent,
            details: hookResult?.details,
            structuredContent: hookResult ? hookResult.structuredContent : result.structuredContent,
            isError: hookResult?.isError ?? isError,
            usage: hookResult?.usage,
        };
    }
    /**
     * Run a call that the tool call `parentToolCallId` made through `ctx.executeTool()`. It goes
     * through the agent's tool pipeline with the session's hooks, against the callable tools.
     */
    async _executeNestedToolCall(parentToolCallId, name, args, options) {
        this._nestedToolCalls ??= new NestedToolCallRunner({
            getTools: () => this._getCallableTools(),
            isSequential: () => this.agent.toolExecution === "sequential",
            runToolCall: (toolCall, parentId, signal, onUpdate) => {
                const assistantMessage = this._findLastAssistantMessage();
                if (!assistantMessage) {
                    return Promise.resolve({
                        toolCall,
                        result: { content: [{ type: "text", text: "No assistant message issued this call" }], details: {} },
                        isError: true,
                    });
                }
                return runToolCall(toolCall, {
                    tools: this._getCallableTools(),
                    assistantMessage,
                    context: { messages: this.agent.state.messages, tools: this.agent.state.tools },
                    beforeToolCall: (context) => this._beforeToolCall(context, parentId),
                    afterToolCall: (context) => this._afterToolCall(context, parentId),
                    signal,
                    onUpdate,
                });
            },
            emit: async (event) => {
                await this._extensionRunner.emit(event);
                this._emit(event);
            },
        });
        return this._nestedToolCalls.execute(parentToolCallId, name, args, options);
    }
    /** Whether `projection`, the current session projection, exceeds the compaction threshold of `model`. */
    _exceedsCompactionThreshold(model, projection) {
        if (model.contextWindow <= 0)
            return false;
        return shouldCompact(estimateProjectedContextTokens(projection, this.sessionManager.getBranch()).tokens, model.contextWindow, this.settingsManager.getCompactionSettings(this.model));
    }
    async _compactBeforeNextAssistantResponse(context) {
        const projection = this.sessionManager.buildSessionProjection();
        // A virtual selection is checked in prepareRequest, against the model the request is routed to.
        const model = this.model;
        if (!model || isVirtualModel(model) || !this._exceedsCompactionThreshold(model, projection)) {
            return { ...context, messages: projection.messages };
        }
        await this._runAutoCompaction("threshold", false);
        return { ...context, messages: this.sessionManager.buildSessionProjection().messages };
    }
    _installAgentRequestProjection() {
        const previousPrepareRequest = this.agent.prepareRequest;
        this.agent.prepareRequest = async (request, signal) => {
            const failed = this._failedResponse;
            this._failedResponse = undefined;
            const prepare = async () => {
                const projection = this.sessionManager.buildSessionProjection();
                const canonicalContext = {
                    ...request.context,
                    messages: projection.messages,
                    // Messages declare the provider-visible loadout; context.tools keeps executable implementations.
                    tools: this.agent.state.tools.slice(),
                };
                const previous = await previousPrepareRequest?.({
                    ...request,
                    context: canonicalContext,
                    model: this.agent.state.model,
                    thinkingLevel: this.agent.state.thinkingLevel,
                }, signal);
                return { previous, context: previous?.context ?? canonicalContext, projection };
            };
            let { previous, context, projection } = await prepare();
            const model = previous?.model ?? this.agent.state.model;
            const thinkingLevel = previous?.thinkingLevel ?? this.agent.state.thinkingLevel;
            if (!isVirtualModel(model))
                return { ...previous, context, model, thinkingLevel };
            // The selection stays in agent state; only this request uses the routed model. A routing
            // failure rejects, which ends the run with an error response. Only messages the user wrote
            // start a turn; extension messages can follow them, e.g. from before_agent_start.
            const lastResponse = context.messages.findLastIndex((message) => message.role === "assistant");
            const userTurn = context.messages.slice(lastResponse + 1).some((message) => message.role === "user");
            const state = getVirtualModelState(this.sessionManager.getBranch(), model.provider, model.id);
            const route = await this._modelRuntime.resolveModel(model, convertToLlm(context.messages), {
                reason: failed ? "retry" : userTurn ? "user" : "continuation",
                thinkingLevel,
                signal,
                failed,
                state,
            });
            if (route.state !== undefined && route.state !== state) {
                const data = { provider: model.provider, modelId: model.id, state: route.state };
                const entry = this.sessionManager.getEntry(this.sessionManager.appendCustomEntry(VIRTUAL_MODEL_STATE_ENTRY, data));
                if (entry)
                    this._emit({ type: "entry_appended", entry });
            }
            // The route stands: the router already decided this request. The state entry does not change
            // the projection.
            if (this._exceedsCompactionThreshold(route.model, projection)) {
                await this._runAutoCompaction("threshold", false);
                ({ previous, context } = await prepare());
            }
            return { ...previous, context, model: route.model, thinkingLevel: route.thinkingLevel };
        };
    }
    async _dispatchTurnEndBoundary(message, toolResults) {
        this._lastActivityOutcome =
            message.stopReason === "aborted" ? "aborted" : message.stopReason === "error" ? "error" : "completed";
        const messageEntryId = this._findPersistedMessageEntryId(message);
        if (!this._extensionRunner.hasHandlers("turn_end"))
            return false;
        if (!messageEntryId) {
            this._extensionRunner.emitError({
                extensionPath: "<boundary>",
                event: "turn_end",
                error: "turn_end could not resolve the persisted assistant entry ID",
            });
            return false;
        }
        const toolResultEntryIds = toolResults.flatMap((result) => {
            const entryId = this._findPersistedMessageEntryId(result);
            return entryId ? [entryId] : [];
        });
        const boundary = await this._extensionRunner.emitBoundary({
            type: "turn_end",
            turnIndex: this._turnIndex,
            message,
            toolResults,
            messageEntryId,
            toolResultEntryIds,
            outcome: this._lastActivityOutcome,
        }, (entries) => this._buildBoundaryContext(entries, "turn_end"));
        this._commitBoundaryDrafts(boundary.entries);
        if (boundary.continue && !this._buildBoundaryContext([], "turn_end").canContinue) {
            this._reportInvalidBoundaryContinuation("turn_end");
            return false;
        }
        return boundary.continue;
    }
    _installAgentBoundaryHooks() {
        const previousFinishTurn = this.agent.finishTurn;
        this.agent.finishTurn = async (turn, signal) => {
            this._boundaryDispatchedMessages.add(turn.message);
            const extensionContinue = await this._dispatchTurnEndBoundary(turn.message, turn.toolResults);
            const previousDecision = await previousFinishTurn?.(turn, signal);
            if (previousDecision?.action === "end")
                return previousDecision;
            if (extensionContinue || previousDecision?.action === "continue")
                return { action: "continue" };
            return undefined;
        };
    }
    _installAgentNextTurnRefresh() {
        const previousPrepareNextTurnWithContext = this.agent.prepareNextTurnWithContext ??
            (this.agent.prepareNextTurn
                ? async (_turn, signal) => await this.agent.prepareNextTurn?.(signal)
                : undefined);
        this.agent.prepareNextTurnWithContext = async (turn, signal) => {
            const context = await this._compactBeforeNextAssistantResponse(turn.context);
            const previousSnapshot = await previousPrepareNextTurnWithContext?.({ ...turn, context }, signal);
            const nextContext = previousSnapshot?.context ?? context;
            const runOptions = this._runSystemPromptOptions ?? this._baseSystemPromptOptions;
            const options = normalizeBuildSystemPromptOptions({
                ...runOptions,
                selectedTools: this.getActiveToolNames(),
                toolSnippets: { ...this._baseSystemPromptOptions.toolSnippets, ...runOptions.toolSnippets },
                toolGuidelines: { ...this._baseSystemPromptOptions.toolGuidelines, ...runOptions.toolGuidelines },
            });
            const updateMessage = this._preparePromptAndToolLoadout(options, nextContext.messages);
            // Keep session.systemPrompt and ctx.getSystemPrompt() in step with what the provider sees.
            this._runSystemPromptOptions = options;
            return {
                ...previousSnapshot,
                context: {
                    ...nextContext,
                    tools: this.agent.state.tools.slice(),
                },
                messages: updateMessage
                    ? [...(previousSnapshot?.messages ?? []), updateMessage]
                    : previousSnapshot?.messages,
                model: this.agent.state.model,
                thinkingLevel: this.agent.state.thinkingLevel,
            };
        };
    }
    // =========================================================================
    // Event Subscription
    // =========================================================================
    _refreshFinalizedContext() {
        const projection = this.sessionManager.buildSessionProjection();
        for (const entry of projection.entries) {
            for (const message of entry.messages)
                this._entryIdsByMessage.set(message, entry.sourceEntry.id);
        }
        this.agent.state.messages = projection.messages;
    }
    _applyBoundaryDrafts(manager, drafts) {
        const appended = [];
        for (const draft of drafts) {
            let entryId;
            switch (draft.type) {
                case "custom":
                    entryId = manager.appendCustomEntry(draft.customType, draft.data);
                    break;
                case "custom_message":
                    entryId = manager.appendCustomMessageEntry(draft.customType, draft.content, draft.display, draft.details);
                    break;
                case "context_edit":
                    entryId = manager.appendContextEdit(draft.targetId, draft.replacement);
                    break;
                case "compaction": {
                    const tokensBefore = estimateProjectedContextTokens(manager.buildSessionProjection(), manager.getBranch()).tokens;
                    entryId = manager.appendCompaction(draft.summary, draft.firstKeptEntryId, tokensBefore, draft.details, true, draft.usage);
                    break;
                }
            }
            const entry = manager.getEntry(entryId);
            if (entry)
                appended.push(entry);
        }
        return appended;
    }
    _createBoundaryPreviewManager(drafts) {
        const header = this.sessionManager.getHeader();
        if (!header)
            throw new Error("Session header is missing");
        const manager = SessionManager.inMemory(this._cwd, undefined, [header, ...this.sessionManager.getBranch()]);
        this._applyBoundaryDrafts(manager, drafts);
        return manager;
    }
    _getPendingBoundaryMessages() {
        return [...this.agent.peekQueuedMessages(), ...this._pendingCustomMessages];
    }
    _buildBoundaryContext(drafts, boundary) {
        const projection = this._createBoundaryPreviewManager(drafts).buildSessionProjection();
        const pendingMessages = this._getPendingBoundaryMessages();
        const llmMessages = convertToLlm(projection.messages);
        const finalRole = llmMessages[llmMessages.length - 1]?.role;
        const hasNonSystemContext = llmMessages.some((message) => message.role !== "system");
        const contextCanContinue = hasNonSystemContext && finalRole !== "assistant";
        const pendingCustomContext = this._pendingCustomMessages.length > 0;
        return {
            contextEntries: projection.entries,
            contextMessages: projection.messages,
            llmMessages,
            pendingMessages,
            canContinue: contextCanContinue ||
                pendingCustomContext ||
                (boundary === "turn_end"
                    ? this.agent.hasQueuedMessages()
                    : finalRole === "assistant" && this.agent.hasQueuedMessages()),
        };
    }
    _commitBoundaryDrafts(drafts) {
        const appended = this._applyBoundaryDrafts(this.sessionManager, drafts);
        this._refreshFinalizedContext();
        for (const entry of appended)
            this._emit({ type: "entry_appended", entry });
    }
    _reportInvalidBoundaryContinuation(event) {
        this._extensionRunner.emitError({
            extensionPath: "<boundary>",
            event,
            error: `${event} requested continuation without runnable model context`,
        });
    }
    /** Emit an event to all listeners */
    _emit(event) {
        for (const l of this._eventListeners) {
            l(event);
        }
    }
    _emitQueueUpdate() {
        this._emit({
            type: "queue_update",
            steering: [...this._steeringMessages],
            followUp: [...this._followUpMessages],
        });
    }
    async _emitSessionCompactFailed(event) {
        if (this._extensionRunner.hasHandlers("session_compact_failed")) {
            await this._extensionRunner.emit({ type: "session_compact_failed", ...event });
        }
    }
    _getIdleWaitPromise() {
        if (!this._idleWaitPromise) {
            this._idleWaitPromise = new Promise((resolve) => {
                this._resolveIdleWait = resolve;
            });
        }
        return this._idleWaitPromise;
    }
    _resolveIdleWaitIfIdle() {
        if (!this.isIdle || !this._resolveIdleWait) {
            return;
        }
        const resolve = this._resolveIdleWait;
        this._idleWaitPromise = undefined;
        this._resolveIdleWait = undefined;
        resolve();
    }
    async _emitAgentSettled() {
        this._cacheWarmer?.onAgentSettled();
        this._isAgentRunActive = false;
        this._isEmittingAgentSettled = true;
        try {
            const aborted = this._agentRunAbortRequested;
            await this._extensionRunner.emit({ type: "agent_settled", aborted });
            this._emit({ type: "agent_settled", aborted });
        }
        finally {
            this._isEmittingAgentSettled = false;
        }
        const deferred = this._deferredSettledActions.splice(0);
        if (deferred.length > 0) {
            try {
                for (const action of deferred)
                    await action();
            }
            finally {
                this._resolveIdleWaitIfIdle();
            }
            return;
        }
        this._resolveIdleWaitIfIdle();
    }
    /** Internal handler for agent events - shared by subscribe and reconnect */
    _handleAgentEvent = async (event) => {
        // An abort requested while the run was starting, e.g. during before_agent_start, stops it
        // here. agent_start is awaited before the first request.
        if (event.type === "agent_start" && this._agentRunAbortRequested)
            this.agent.abort();
        // Record the calls a tool made through ctx.executeTool() and their usage on its result message.
        if (this._nestedToolCalls) {
            if (event.type === "message_start" && event.message.role === "toolResult") {
                const message = event.message;
                const summary = this._nestedToolCalls.takeRecord(message.toolCallId);
                if (summary?.calls)
                    message.nestedCalls = summary.calls;
                if (summary?.usage) {
                    message.usage = message.usage ? combineUsage(message.usage, summary.usage) : summary.usage;
                }
            }
            else if (event.type === "agent_end") {
                this._nestedToolCalls.clear();
            }
        }
        // When a user message starts, check if it's from either queue and remove it BEFORE emitting
        // This ensures the UI sees the updated queue state
        if (event.type === "message_start" && event.message.role === "user") {
            this._overflowRecoveryAttempted = false;
            const messageText = contentText(event.message.content, "");
            // Check steering queue first
            const steeringIndex = this._steeringMessages.indexOf(messageText);
            if (steeringIndex !== -1) {
                this._steeringMessages.splice(steeringIndex, 1);
                this._emitQueueUpdate();
            }
            else {
                // Check follow-up queue
                const followUpIndex = this._followUpMessages.indexOf(messageText);
                if (followUpIndex !== -1) {
                    this._followUpMessages.splice(followUpIndex, 1);
                    this._emitQueueUpdate();
                }
            }
            // Normalize queued images at delivery, before events and persistence see the message,
            // so they get the same resize as idle prompts with the current model's limits (#11738).
            if (this._queuedMessagesWithImages.has(event.message)) {
                this._queuedMessagesWithImages.delete(event.message);
                await this._normalizeQueuedMessageImages(event.message);
            }
        }
        // Emit to extensions first, then notify public listeners.
        await this._emitExtensionEvent(event);
        this._emit(event.type === "agent_end" ? { ...event, willRetry: this._willRetryAfterAgentEnd(event) } : event);
        // Handle session persistence
        if (event.type === "message_end") {
            let entryId;
            // Check if this is a custom message from extensions
            if (event.message.role === "custom") {
                // Persist as CustomMessageEntry
                entryId = this.sessionManager.appendCustomMessageEntry(event.message.customType, event.message.content, event.message.display, event.message.details);
            }
            else if (event.message.role === "system" ||
                event.message.role === "user" ||
                event.message.role === "assistant" ||
                event.message.role === "toolResult") {
                // Regular LLM message - persist as SessionMessageEntry
                entryId = this.sessionManager.appendMessage(event.message);
            }
            if (entryId)
                this._entryIdsByMessage.set(event.message, entryId);
            // Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere
            if (event.message.role === "assistant") {
                const assistantMsg = event.message;
                this._lastAssistantMessage = assistantMsg;
                if (assistantMsg.stopReason !== "error" && assistantMsg.stopReason !== "length") {
                    this._overflowRecoveryAttempted = false;
                }
                // Reset retry counter immediately on successful assistant response
                // This prevents accumulation across multiple LLM calls within a turn
                if (assistantMsg.stopReason !== "error" && this._retryAttempt > 0) {
                    this._emit({
                        type: "auto_retry_end",
                        success: true,
                        attempt: this._retryAttempt,
                    });
                    this._retryAttempt = 0;
                }
            }
        }
        // A turn ends after its assistant message and every tool result has been appended,
        // so this is the first point in the run where a context-only custom message can be
        // inserted without landing between a tool call and its result. Flushing after the
        // extension and listener dispatch above also picks up messages that turn_end
        // handlers queued.
        if (event.type === "turn_end") {
            this._flushPendingCustomMessages();
        }
    };
    _willRetryAfterAgentEnd(event) {
        if (this._agentRunAbortRequested)
            return false;
        const settings = this.settingsManager.getRetrySettings();
        if (!settings.enabled || this._retryAttempt >= settings.maxRetries) {
            return false;
        }
        for (let i = event.messages.length - 1; i >= 0; i--) {
            const message = event.messages[i];
            if (message.role === "assistant") {
                return this._isRetryableError(message);
            }
        }
        return false;
    }
    _findPersistedMessageEntryId(message) {
        const mapped = this._entryIdsByMessage.get(message);
        if (mapped)
            return mapped;
        for (const entry of [...this.sessionManager.getBranch()].reverse()) {
            if (entry.type === "message" && entry.message === message)
                return entry.id;
        }
        const messageIndex = this.agent.state.messages.indexOf(message);
        if (messageIndex < 0)
            return undefined;
        const projection = this.sessionManager.buildSessionProjection();
        let projectedIndex = 0;
        for (const entry of projection.entries) {
            for (let i = 0; i < entry.messages.length; i++) {
                if (projectedIndex === messageIndex) {
                    this._entryIdsByMessage.set(message, entry.sourceEntry.id);
                    return entry.sourceEntry.id;
                }
                projectedIndex++;
            }
        }
        return undefined;
    }
    /**
     * Persistently omit a failed attempt and its tool results from model context. Results are
     * found in the projected context, so the pair stays together whether the attempt comes from
     * the current run or a resumed session. An unpaired result fails the next request (#11227).
     */
    _omitRecoveryAttempt(message) {
        const toolCallIds = new Set(message.content.flatMap((block) => (block.type === "toolCall" ? [block.id] : [])));
        // A context edit replaces the projected copy, so match it by source entry as well.
        const entryId = this._findPersistedMessageEntryId(message);
        const messages = this.agent.state.messages;
        const index = messages.findIndex((m) => m === message || (entryId !== undefined && this._entryIdsByMessage.get(m) === entryId));
        const toolResults = [];
        for (const m of index < 0 ? [] : messages.slice(index + 1)) {
            if (m.role === "assistant")
                break;
            if (m.role === "toolResult" && toolCallIds.has(m.toolCallId))
                toolResults.push(m);
        }
        const targets = [message, ...toolResults];
        const targetIds = targets.map((target) => this._findPersistedMessageEntryId(target));
        const unresolvedProjectedTarget = targets.some((target, index) => targetIds[index] === undefined && this.agent.state.messages.includes(target));
        if (unresolvedProjectedTarget) {
            throw new Error("Cannot persist recovery omission because a projected message has no source entry");
        }
        for (const targetId of targetIds) {
            if (!targetId)
                continue;
            const editId = this.sessionManager.appendContextEdit(targetId, null);
            const entry = this.sessionManager.getEntry(editId);
            if (entry)
                this._emit({ type: "entry_appended", entry });
        }
        this._refreshFinalizedContext();
    }
    /** Find the last assistant message in agent state (including aborted ones) */
    _findLastAssistantMessage() {
        const messages = this.agent.state.messages;
        for (let i = messages.length - 1; i >= 0; i--) {
            const msg = messages[i];
            if (msg.role === "assistant") {
                return msg;
            }
        }
        return undefined;
    }
    _replaceMessageInPlace(target, replacement) {
        // Agent-core stores the finalized message object in its state before emitting message_end.
        // SessionManager persistence happens later in _handleAgentEvent() with event.message.
        // Mutating this object in place keeps agent state, later turn/agent events, listeners,
        // and the eventual SessionManager.appendMessage(event.message) persistence in sync.
        if (target === replacement) {
            return;
        }
        const targetRecord = target;
        for (const key of Object.keys(targetRecord)) {
            delete targetRecord[key];
        }
        Object.assign(targetRecord, replacement);
    }
    /** Emit extension events based on agent events */
    async _emitExtensionEvent(event) {
        if (event.type === "agent_start") {
            this._turnIndex = 0;
            await this._extensionRunner.emit({ type: "agent_start" });
        }
        else if (event.type === "agent_end") {
            await this._extensionRunner.emit({ type: "agent_end", messages: event.messages });
        }
        else if (event.type === "turn_start") {
            const extensionEvent = {
                type: "turn_start",
                turnIndex: this._turnIndex,
                timestamp: Date.now(),
            };
            await this._extensionRunner.emit(extensionEvent);
        }
        else if (event.type === "turn_end") {
            if (event.message.role === "assistant" && !this._boundaryDispatchedMessages.delete(event.message)) {
                await this._dispatchTurnEndBoundary(event.message, event.toolResults);
            }
            this._turnIndex++;
        }
        else if (event.type === "message_start") {
            const extensionEvent = {
                type: "message_start",
                message: event.message,
            };
            await this._extensionRunner.emit(extensionEvent);
        }
        else if (event.type === "message_update") {
            const extensionEvent = {
                type: "message_update",
                message: event.message,
                assistantMessageEvent: event.assistantMessageEvent,
            };
            await this._extensionRunner.emit(extensionEvent);
        }
        else if (event.type === "message_end") {
            const extensionEvent = {
                type: "message_end",
                message: event.message,
            };
            const replacement = await this._extensionRunner.emitMessageEnd(extensionEvent);
            if (replacement) {
                // Untyped extension handlers can return messages with null/missing content;
                // normalize so it never enters agent state or session history.
                const normalized = (replacement.role === "user" ||
                    replacement.role === "assistant" ||
                    replacement.role === "toolResult" ||
                    replacement.role === "custom") &&
                    replacement.content == null
                    ? { ...replacement, content: [] }
                    : replacement;
                this._replaceMessageInPlace(event.message, normalized);
            }
        }
        else if (event.type === "tool_execution_start") {
            const extensionEvent = {
                type: "tool_execution_start",
                toolCallId: event.toolCallId,
                toolName: event.toolName,
                args: event.args,
            };
            await this._extensionRunner.emit(extensionEvent);
        }
        else if (event.type === "tool_execution_update") {
            const extensionEvent = {
                type: "tool_execution_update",
                toolCallId: event.toolCallId,
                toolName: event.toolName,
                args: event.args,
                partialResult: event.partialResult,
            };
            await this._extensionRunner.emit(extensionEvent);
        }
        else if (event.type === "tool_execution_end") {
            const extensionEvent = {
                type: "tool_execution_end",
                toolCallId: event.toolCallId,
                toolName: event.toolName,
                result: event.result,
                isError: event.isError,
                ...(event.durationMs === undefined ? {} : { durationMs: event.durationMs }),
            };
            await this._extensionRunner.emit(extensionEvent);
        }
    }
    /**
     * Subscribe to agent events.
     * Session persistence is handled internally (saves messages on message_end).
     * Multiple listeners can be added. Returns unsubscribe function for this listener.
     */
    subscribe(listener) {
        this._eventListeners.push(listener);
        // Return unsubscribe function for this specific listener
        return () => {
            const index = this._eventListeners.indexOf(listener);
            if (index !== -1) {
                this._eventListeners.splice(index, 1);
            }
        };
    }
    /** Disconnect from agent events during disposal. */
    _disconnectFromAgent() {
        if (this._unsubscribeAgent) {
            this._unsubscribeAgent();
            this._unsubscribeAgent = undefined;
        }
    }
    /**
     * Remove all listeners and disconnect from agent.
     * Call this when completely done with the session.
     */
    dispose() {
        try {
            this.abortRetry();
            this.abortCompaction();
            this.abortBranchSummary();
            this.abortBash();
            this.agent.abort();
        }
        catch {
            // Dispose must succeed even if an abort hook throws.
        }
        this._extensionRunner.invalidate("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().");
        this._disconnectFromAgent();
        this._eventListeners = [];
        if (this._cacheWarmer) {
            this._cacheWarmer.onWarmed = undefined;
            this._cacheWarmer.cancel();
        }
        cleanupSessionResources(this.sessionId);
    }
    // =========================================================================
    // Read-only State Access
    // =========================================================================
    /** Refresh the public finalized transcript from the canonical session projection. */
    refreshContext() {
        this._refreshFinalizedContext();
    }
    /** Full agent state */
    get state() {
        return this.agent.state;
    }
    /** Current cache-warming state and the policy inputs that produced it. */
    get cacheWarmingStatus() {
        return this._cacheWarmer?.status;
    }
    /** Persist the cache-warming mode and immediately reconcile active warming. */
    setCacheWarmingMode(mode) {
        this.settingsManager.setCacheWarmingMode(mode);
        this._cacheWarmer?.onModeChanged();
    }
    /** Current model (may be undefined if not yet selected) */
    get model() {
        return this.agent.state.model;
    }
    /** Current thinking level */
    get thinkingLevel() {
        return this.agent.state.thinkingLevel;
    }
    /** Under a virtual selection, the physical model and thinking level of the latest successful response. */
    get routedModel() {
        if (!this.model || !isVirtualModel(this.model))
            return undefined;
        const latest = findLatestResponse(this.agent.state.messages);
        const model = latest && this._modelRuntime.getPhysicalModel(latest.provider, latest.model);
        return model && { model, thinkingLevel: latest?.thinkingLevel };
    }
    /** Whether the session is currently processing an agent run or post-run continuation. */
    get isStreaming() {
        return this._isAgentRunActive;
    }
    /** Whether the session has no active agent run, compaction, branch summary, retry, or queued continuation. */
    get isIdle() {
        return !this._isAgentRunActive && !this.isCompacting;
    }
    /** Current effective system prompt, including changes not yet sent to the model. */
    get systemPrompt() {
        return buildSystemPrompt(this._runSystemPromptOptions ?? this._baseSystemPromptOptions);
    }
    /** Current retry attempt (0 if not retrying) */
    get retryAttempt() {
        return this._retryAttempt;
    }
    /**
     * Get the names of currently active tools, which are the tools declared to the model.
     * Tools with `codemode` or `deferred` exposure are callable from other tools without being active.
     */
    getActiveToolNames() {
        return this.agent.state.tools.map((t) => t.name);
    }
    /** Get the names of the tools that tools can call through `ctx.executeTool()`. */
    getCallableToolNames() {
        return this._getCallableTools().map((t) => t.name);
    }
    /**
     * Get all configured tools with name, description, parameter schema, prompt guidelines, and source metadata.
     */
    getAllTools() {
        return Array.from(this._toolDefinitions.values()).map(({ definition, sourceInfo }) => ({
            name: definition.name,
            description: definition.description,
            parameters: definition.parameters,
            promptGuidelines: definition.promptGuidelines,
            exposure: this._getToolExposure(definition.name),
            ...(definition.namespace ? { namespace: definition.namespace } : {}),
            ...(definition.annotations ? { annotations: { ...definition.annotations } } : {}),
            ...(definition.reachesTools ? { reachesTools: definition.reachesTools } : {}),
            sourceInfo,
        }));
    }
    getToolDefinition(name) {
        return this._toolDefinitions.get(name)?.definition;
    }
    /**
     * Set active tools by name.
     * Only tools in the registry can be enabled. Unknown and hidden tool names are ignored.
     * Also rebuilds the system prompt to reflect the new tool set.
     * Changes take effect on the next agent turn.
     */
    setActiveToolsByName(toolNames) {
        const previous = this.getActiveToolNames();
        this._setActiveTools(toolNames);
        // A loadout that deactivates a tool replaces the restored one, whose pending tools are dropped.
        // One that only adds tools, like activating tool_search, keeps them.
        const active = new Set(this.getActiveToolNames());
        if (previous.some((name) => !active.has(name)))
            this._pendingToolNames.clear();
    }
    _setActiveTools(toolNames) {
        const tools = this._applyToolLoadout(toolNames);
        for (const tool of tools)
            this._pendingToolNames.delete(tool.name);
        this._rebuildSystemPrompt(tools.map((tool) => tool.name));
    }
    /**
     * Whether `--tools` and `--exclude-tools` keep the tool registered. MCP tools stay registered
     * unless the allowlist filters them (see `_allowlistFiltersMcp`).
     */
    _isAllowedTool(name) {
        if (this._excludedTools?.(name))
            return false;
        if (!this._allowedTools || this._allowedTools(name))
            return true;
        return !this._allowlistFiltersMcp && isMcpToolName(name);
    }
    /**
     * Whether the tool may be active, which declares it to the model. MCP tools the allowlist keeps
     * without matching them are only for codemode and tool_search: they may be declared only when
     * tool_search can load them (non-`direct` exposure and tool_search registered). This also applies
     * to tools restored from the transcript or set by extensions.
     */
    _isActivatable(name) {
        if (!this._allowedTools || this._allowedTools(name) || !isMcpToolName(name))
            return true;
        return this._getToolExposure(name) !== "direct" && this._toolRegistry.has("tool_search");
    }
    _getToolExposure(name) {
        return this._toolDefinitions.get(name)?.definition.exposure ?? "direct";
    }
    /**
     * Tools callable through `ctx.executeTool()`: the active `direct` tools and every registered
     * `codemode` or `deferred` tool.
     */
    _getCallableTools(active = new Set(this.getActiveToolNames())) {
        return [...this._toolRegistry.values()].filter((tool) => {
            const exposure = this._getToolExposure(tool.name);
            return exposure === "codemode" || exposure === "deferred" || (exposure === "direct" && active.has(tool.name));
        });
    }
    /**
     * Set the agent's tools for the given active tool names and return them. The active tools are
     * the registered, non-hidden ones; they are declared to the model. Active tools with a
     * `prepareLoadout` hook can change the declared descriptions and hide declarations from
     * requests (see {@link _installHiddenDeclarationsProjection}).
     *
     * The active tools are a set: tools that stay active keep their current order and newly active
     * ones are appended, so reordering the same names does not change the prompt (#11495).
     */
    _applyToolLoadout(toolNames) {
        const requested = new Set(toolNames);
        const kept = this.getActiveToolNames().filter((name) => requested.has(name));
        const tools = [...new Set([...kept, ...requested])].flatMap((name) => {
            const tool = this._toolRegistry.get(name);
            return tool && this._getToolExposure(name) !== "hidden" && this._isActivatable(name) ? [tool] : [];
        });
        const hooks = tools.flatMap((tool) => {
            const entry = this._toolDefinitions.get(tool.name);
            return entry?.definition.prepareLoadout ? [entry] : [];
        });
        const hidden = new Set();
        let declared = tools;
        if (hooks.length > 0) {
            const loadout = {
                declared: tools,
                callable: this._getCallableTools(new Set(tools.map((tool) => tool.name))),
                registered: [...this._toolRegistry.values()],
                getExposure: (name) => this._getToolExposure(name),
                getNamespace: (name) => this._toolDefinitions.get(name)?.definition.namespace,
                getPromptGuidelines: (name) => this._toolPromptGuidelines.get(name) ?? [],
            };
            const descriptions = new Map();
            for (const { definition, sourceInfo } of hooks) {
                try {
                    const changes = definition.prepareLoadout?.(loadout);
                    for (const [name, description] of Object.entries(changes?.descriptions ?? {})) {
                        descriptions.set(name, description);
                    }
                    for (const name of changes?.hiddenDeclarations ?? [])
                        hidden.add(name);
                }
                catch (error) {
                    this._extensionRunner.emitError({
                        extensionPath: sourceInfo.path,
                        event: "prepare_loadout",
                        error: error instanceof Error ? error.message : String(error),
                        stack: error instanceof Error ? error.stack : undefined,
                    });
                }
            }
            declared = tools.map((tool) => {
                const description = descriptions.get(tool.name);
                return description === undefined ? tool : { ...tool, description };
            });
        }
        this._hiddenDeclarations = hidden;
        this.agent.state.tools = declared;
        return declared;
    }
    /** Whether compaction or branch summarization is currently running */
    get isCompacting() {
        return (this._autoCompactionAbortController !== undefined ||
            this._compactionAbortController !== undefined ||
            this._branchSummaryAbortController !== undefined);
    }
    /** All messages including custom types like BashExecutionMessage */
    get messages() {
        return this.agent.state.messages;
    }
    /** Current steering mode */
    get steeringMode() {
        return this.agent.steeringMode;
    }
    /** Current follow-up mode */
    get followUpMode() {
        return this.agent.followUpMode;
    }
    /** Current session file path, or undefined if sessions are disabled */
    get sessionFile() {
        return this.sessionManager.getSessionFile();
    }
    /** Current session ID */
    get sessionId() {
        return this.sessionManager.getSessionId();
    }
    /** Current session display name, if set */
    get sessionName() {
        return this.sessionManager.getSessionName();
    }
    /** Scoped models for cycling (from --models flag) */
    get scopedModels() {
        return this._scopedModels;
    }
    /** Update scoped models for cycling */
    setScopedModels(scopedModels) {
        this._scopedModels = scopedModels;
    }
    /** File-based prompt templates */
    get promptTemplates() {
        return this._resourceLoader.getPrompts().prompts;
    }
    _normalizePromptSnippet(text) {
        if (!text)
            return undefined;
        const oneLine = text
            .replace(/[\r\n]+/g, " ")
            .replace(/\s+/g, " ")
            .trim();
        return oneLine.length > 0 ? oneLine : undefined;
    }
    _normalizePromptGuidelines(guidelines) {
        if (!guidelines || guidelines.length === 0) {
            return [];
        }
        const unique = new Set();
        for (const guideline of guidelines) {
            const normalized = guideline.trim();
            if (normalized.length > 0) {
                unique.add(normalized);
            }
        }
        return Array.from(unique);
    }
    _rebuildSystemPrompt(toolNames) {
        const validToolNames = toolNames.filter((name) => this._toolRegistry.has(name));
        const toolSnippets = {};
        for (const name of this._toolRegistry.keys()) {
            const snippet = this._toolPromptSnippets.get(name);
            // Tools without a snippet are not listed.
            if (snippet)
                toolSnippets[name] = snippet;
        }
        const loaderSystemPrompt = this._resourceLoader.getSystemPrompt();
        const loaderAppendSystemPrompt = this._resourceLoader.getAppendSystemPrompt();
        const appendSystemPrompt = loaderAppendSystemPrompt.length > 0 ? loaderAppendSystemPrompt.join("\n\n") : "";
        const loadedSkills = this._resourceLoader.getSkills().skills;
        const loadedContextFiles = this._resourceLoader.getAgentsFiles().agentsFiles;
        this._baseSystemPromptOptions = normalizeBuildSystemPromptOptions({
            cwd: this._cwd,
            skills: loadedSkills,
            contextFiles: loadedContextFiles,
            customPrompt: loaderSystemPrompt,
            appendSystemPrompt,
            selectedTools: validToolNames,
            hiddenTools: [...this._hiddenDeclarations],
            toolSnippets,
            toolGuidelines: Object.fromEntries(this._toolPromptGuidelines),
        });
    }
    /**
     * Apply a prompt and tool loadout for the next request. Sets the executable tools and
     * returns a system message patching the prompt sections the model currently has (replayed
     * from `messages`), or undefined when the prompt is unchanged. Tool changes are declared by
     * the agent loop before the request.
     *
     * A forced prompt does not affect the transcript: the structured sections are still diffed
     * and persisted, and the forced text is projected onto the request by
     * {@link _installAgentForcedPromptProjection}.
     */
    _preparePromptAndToolLoadout(options, messages = this.agent.state.messages) {
        options.selectedTools = this._applyToolLoadout(options.selectedTools).map((tool) => tool.name);
        // The tool list and rules must match the declarations the request carries.
        options.hiddenTools = [...this._hiddenDeclarations];
        const sections = diffSystemPromptSections(getCurrentSystemMessage(messages)?.sections ?? {}, buildSystemPromptSections(options));
        return sections ? { role: "system", content: "", sections, timestamp: Date.now() } : undefined;
    }
    /**
     * Send a forced prompt as the provider's leading system prompt without recording it.
     *
     * A `before_agent_start` handler that returns `systemPrompt` needs that exact text at the
     * head of the request; a mid-conversation system message would leave the original prompt
     * in place. The forced text is a rendering of the current prompt, so the transcript keeps
     * its structured sections and the request is projected instead: the system messages
     * collapse into one head holding the forced text and the current tools. Runs after the
     * `context` extension handlers.
     */
    /**
     * Remove the declarations that `prepareLoadout` hooks hide from every request. The whole
     * transcript is filtered with the current set, so the projected declarations stay consistent
     * across requests and only change when the loadout does.
     */
    _installHiddenDeclarationsProjection() {
        const previousTransformContext = this.agent.transformContext;
        this.agent.transformContext = async (messages, signal) => {
            const transformed = previousTransformContext ? await previousTransformContext(messages, signal) : messages;
            const hidden = this._hiddenDeclarations;
            if (hidden.size === 0)
                return transformed;
            return transformed.map((message) => {
                if (message.role !== "system" || (!message.toolsAdded && !message.toolsRemoved))
                    return message;
                const { toolsAdded, toolsRemoved, ...rest } = message;
                const added = toolsAdded?.filter((tool) => !hidden.has(tool.name)) ?? [];
                const removed = toolsRemoved?.filter((tool) => !hidden.has(tool.name)) ?? [];
                return {
                    ...rest,
                    ...(added.length > 0 ? { toolsAdded: added } : {}),
                    ...(removed.length > 0 ? { toolsRemoved: removed } : {}),
                };
            });
        };
    }
    _installAgentForcedPromptProjection() {
        const previousTransformContext = this.agent.transformContext;
        this.agent.transformContext = async (messages, signal) => {
            const transformed = previousTransformContext ? await previousTransformContext(messages, signal) : messages;
            const forced = this._runSystemPromptOptions?.forceSystemPrompt;
            if (forced === undefined)
                return transformed;
            const current = getCurrentSystemMessage(transformed);
            const head = {
                role: "system",
                content: forced,
                ...(current?.toolsAdded ? { toolsAdded: current.toolsAdded } : {}),
                timestamp: current?.timestamp ?? Date.now(),
            };
            return [head, ...transformed.filter((message) => message.role !== "system")];
        };
    }
    /**
     * Restore the active tool loadout declared by the session transcript, if it declares one.
     * Tools reachable only from other tools are never declared, but they do not depend on the active
     * set, so the transcript's declarations are the whole loadout.
     * @param modifiers `+name`/`-name` entries applied to the restored loadout.
     */
    _restoreToolsFromTranscript(modifiers = []) {
        this._pendingToolNames.clear();
        // All system messages on the branch, including those before a compaction. Compaction snapshots
        // are derived from these, so replaying them gives the current loadout.
        const systemMessages = this.sessionManager
            .getBranch()
            .flatMap((entry) => (entry.type === "message" && entry.message.role === "system" ? [entry.message] : []));
        // No recorded loadout: keep the current tools.
        if (systemMessages.length === 0)
            return;
        // Tools the branch ever added or removed. Removed tools stay inactive.
        const mentioned = new Set(systemMessages
            .flatMap((message) => [...(message.toolsAdded ?? []), ...(message.toolsRemoved ?? [])])
            .map((tool) => tool.name));
        // Extension tools the branch never mentioned were registered after it recorded its loadout, so
        // they are activated like newly registered tools. Built-in tools follow `defaultTools` instead.
        const newTools = [...this._toolRegistry.keys()].filter((name) => !mentioned.has(name) && !this._baseToolDefinitions.has(name) && this._isActivatedOnRegistration(name));
        // The recorded loadout plus the new tools, with `--tools +name`/`-name` applied.
        const names = applyToolModifiers([...getCurrentTools(systemMessages).map((tool) => tool.name), ...newTools], modifiers);
        // Recorded tools that are not registered yet, such as MCP tools of servers still connecting, are
        // activated when they register. Excluded tools never register, so they are not kept pending.
        this._pendingToolNames = new Set(names.filter((name) => this._isAllowedTool(name)));
        // Ignores names that are not registered.
        this._setActiveTools(names);
    }
    // =========================================================================
    // Prompting
    // =========================================================================
    /**
     * Start a run from a user or custom message. The session counts as busy from here on, so
     * messages sent while before_agent_start handlers run are queued into this run instead of
     * starting another, and an abort requested meanwhile aborts the run as soon as it exists.
     * @param onStarted Called once the run's messages are prepared, before the first request.
     */
    async _startRun(trigger, onStarted) {
        this._isAgentRunActive = true;
        this._agentRunAbortRequested = false;
        let messages;
        try {
            messages = await this._prepareRun(trigger);
        }
        catch (error) {
            this._isAgentRunActive = false;
            this._flushPendingBashMessages();
            this._flushPendingCustomMessages();
            this._resolveIdleWaitIfIdle();
            throw error;
        }
        // Messages held while the run was starting go before its starting messages, as they would
        // have while idle. No run exists yet, so they cannot split a tool call from its result.
        this._flushPendingBashMessages();
        this._flushPendingCustomMessages();
        onStarted?.();
        // Compaction before the prompt may have scheduled a retry; the new prompt replaces it.
        this._failedResponse = undefined;
        this._recordSelection();
        // The run records the loadout in the transcript; restored tools that did not register by now
        // are dropped, so a tool that never registers does not stay pending.
        this._pendingToolNames.clear();
        try {
            await this.agent.prompt(messages);
            while (!this._agentRunAbortRequested) {
                if (await this._handlePostAgentRun()) {
                    if (this._agentRunAbortRequested)
                        break;
                    await this.agent.continue();
                    continue;
                }
                if (this._agentRunAbortRequested || !(await this._runBeforeSettleBoundary()))
                    break;
                if (this._agentRunAbortRequested)
                    break;
                await this.agent.continue();
            }
        }
        finally {
            if (this._agentRunAbortRequested)
                this._finishCancelledRetry();
            this._failedResponse = undefined;
            this._runSystemPromptOptions = undefined;
            this._flushPendingBashMessages();
            this._flushPendingCustomMessages();
            await this._emitAgentSettled();
        }
    }
    /**
     * Prepare a run and return its starting messages: compact first when needed, emit
     * before_agent_start, apply the run's prompt options and tool loadout, and build the starting
     * message followed by pending nextTurn messages (consumed here) and handler messages, with a
     * system patch first when the prompt changed.
     */
    async _prepareRun(trigger) {
        // Check if we need to compact before sending (catches aborted responses).
        // The starting message is sent below, so do not call agent.continue() here.
        const lastAssistant = this._findLastAssistantMessage();
        if (lastAssistant) {
            await this._checkCompaction(lastAssistant, false);
        }
        const selectedToolsBefore = this._baseSystemPromptOptions.selectedTools;
        const result = await this._extensionRunner.emitBeforeAgentStart(trigger, this._baseSystemPromptOptions);
        // Handlers may edit event.systemPromptOptions.selectedTools or call setActiveTools(),
        // which updates the live loadout instead. An explicit edit wins; otherwise the live
        // loadout is authoritative, so a setActiveTools() call is not undone here.
        const handlerEditedTools = result.systemPromptOptions.selectedTools.length !== selectedToolsBefore.length ||
            result.systemPromptOptions.selectedTools.some((name, index) => name !== selectedToolsBefore[index]);
        if (!handlerEditedTools)
            result.systemPromptOptions.selectedTools = this.getActiveToolNames();
        const messages = [];
        if (trigger.messageType === "custom") {
            messages.push(trigger.message);
        }
        else {
            // Normalize images after before_agent_start so extension-driven model selection
            // determines the resize profile used for the request and history.
            const normalized = await this._normalizePromptImages(trigger.images);
            const text = normalized.hints.length > 0 ? `${trigger.prompt}\n\n${normalized.hints.join("\n")}` : trigger.prompt;
            messages.push({
                role: "user",
                content: [{ type: "text", text }, ...normalized.images],
                timestamp: Date.now(),
            });
        }
        // Inject any pending "nextTurn" messages as context alongside the starting message
        messages.push(...this._pendingNextTurnMessages);
        this._pendingNextTurnMessages = [];
        for (const msg of result.messages) {
            messages.push({
                role: "custom",
                customType: msg.customType,
                // Untyped extensions can pass null/missing content; normalize at ingestion.
                content: msg.content ?? [],
                display: msg.display,
                details: msg.details,
                timestamp: Date.now(),
            });
        }
        const updateMessage = this._preparePromptAndToolLoadout(result.systemPromptOptions);
        this._runSystemPromptOptions = result.systemPromptOptions;
        if (updateMessage)
            messages.unshift(updateMessage);
        return messages;
    }
    async _handlePostAgentRun() {
        const message = this._lastAssistantMessage;
        this._lastAssistantMessage = undefined;
        if (this._agentRunAbortRequested) {
            this._finishCancelledRetry();
            return false;
        }
        if (!message)
            return this.agent.hasQueuedMessages();
        if (this._isRetryableError(message) && (await this._prepareRetry(message))) {
            if (this._agentRunAbortRequested)
                this._finishCancelledRetry();
            this._failedResponse = message;
            return !this._agentRunAbortRequested;
        }
        if (this._agentRunAbortRequested) {
            this._finishCancelledRetry();
            return false;
        }
        if (message.stopReason === "error" && this._retryAttempt > 0) {
            this._emit({
                type: "auto_retry_end",
                success: false,
                attempt: this._retryAttempt,
                finalError: message.errorMessage,
            });
            this._retryAttempt = 0;
        }
        if (await this._checkCompaction(message)) {
            return !this._agentRunAbortRequested;
        }
        // The low-level loop drains both queues before agent_end. Messages queued by
        // agent_end handlers require a fresh run before pre-settlement handlers fire.
        return !this._agentRunAbortRequested && this.agent.hasQueuedMessages();
    }
    async _runBeforeSettleBoundary() {
        if (!this._extensionRunner.hasHandlers("agent_before_settle"))
            return this.agent.hasQueuedMessages();
        this._isBeforeSettle = true;
        this._abortDuringBeforeSettle = false;
        try {
            const result = await this._extensionRunner.emitBoundary({ type: "agent_before_settle", outcome: this._lastActivityOutcome }, (entries) => this._buildBoundaryContext(entries, "agent_before_settle"));
            this._commitBoundaryDrafts(result.entries);
            this._flushPendingCustomMessages();
            const finalContext = this._buildBoundaryContext([], "agent_before_settle");
            if (this._abortDuringBeforeSettle)
                return false;
            const shouldContinue = result.continue || this.agent.hasQueuedMessages();
            if (shouldContinue && !finalContext.canContinue) {
                if (result.continue)
                    this._reportInvalidBoundaryContinuation("agent_before_settle");
                return false;
            }
            return shouldContinue;
        }
        finally {
            this._isBeforeSettle = false;
        }
    }
    async _runInputHandlers(text, images, source, streamingBehavior) {
        if (!this._extensionRunner.hasHandlers("input")) {
            return { text, images };
        }
        const inputResult = await this._extensionRunner.emitInput(text, images, source, streamingBehavior);
        if (inputResult.action === "handled") {
            return undefined;
        }
        if (inputResult.action === "transform") {
            return { text: inputResult.text, images: inputResult.images ?? images };
        }
        return { text, images };
    }
    async _normalizePromptImages(images) {
        if (!images)
            return { images: [], hints: [] };
        const normalizedImages = [];
        const hints = [];
        for (const image of images) {
            const processed = await processImage(Buffer.from(image.data, "base64"), image.mimeType, {
                autoResizeImages: this.settingsManager.getImageAutoResize(),
                resizeOptions: this._limitsModel()?.inputLimits?.images?.resize,
            });
            if (!processed.ok) {
                hints.push(processed.message);
                continue;
            }
            normalizedImages.push({ type: "image", data: processed.data, mimeType: processed.mimeType });
            hints.push(...processed.hints);
        }
        return { images: normalizedImages, hints };
    }
    /**
     * Send a prompt to the agent.
     * - Handles extension commands (registered via pi.registerCommand) immediately, even during streaming
     * - Expands file-based prompt templates by default
     * - During streaming, queues via steer() or followUp() based on streamingBehavior option
     * - Validates model and API key before sending (when not streaming)
     * @throws Error if streaming and no streamingBehavior specified
     * @throws Error if no model selected or no API key available (when not streaming)
     */
    async prompt(text, options) {
        if (this._isEmittingAgentSettled) {
            this._deferredSettledActions.push(async () => await this.prompt(text, options));
            return;
        }
        const expandPromptTemplates = options?.expandPromptTemplates ?? true;
        const preflightResult = options?.preflightResult;
        // Handle extension commands first (execute immediately, even during streaming)
        // Extension commands manage their own LLM interaction via pi.sendMessage()
        if (expandPromptTemplates && text.startsWith("/")) {
            const handled = await this._tryExecuteExtensionCommand(text);
            if (handled) {
                // Extension command executed, no prompt to send
                preflightResult?.("handled");
                return;
            }
        }
        if (this._compactionAbortController !== undefined || this._branchSummaryAbortController !== undefined) {
            throw new Error("Cannot submit a prompt while compaction is in progress. Wait for compaction to finish and retry.");
        }
        // Reserve the context through async input hooks and auth checks so tree navigation cannot
        // start meanwhile (#9154). _startRun marks the run active synchronously, so there is no gap.
        this._promptPreflightCount++;
        let trigger;
        try {
            trigger = await this._preparePrompt(text, options);
        }
        finally {
            this._promptPreflightCount--;
        }
        if (trigger)
            await this._startRun(trigger, () => preflightResult?.("started"));
    }
    /**
     * Run input hooks, expand templates, queue while streaming, and validate model and auth.
     * Returns the run trigger, or undefined when the prompt was handled or queued.
     */
    async _preparePrompt(text, options) {
        const expandPromptTemplates = options?.expandPromptTemplates ?? true;
        const preflightResult = options?.preflightResult;
        // Emit input event for extension interception (before skill/template expansion)
        const processedInput = await this._runInputHandlers(text, options?.images, options?.source ?? "interactive", this.isStreaming ? options?.streamingBehavior : undefined);
        if (!processedInput) {
            preflightResult?.("handled");
            return undefined;
        }
        const { text: currentText, images: currentImages } = processedInput;
        // Expand skill commands (/skill:name args) and prompt templates (/template args)
        let expandedText = currentText;
        if (expandPromptTemplates) {
            expandedText = this._expandSkillCommand(expandedText);
            expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]);
        }
        // If streaming, queue via steer() or followUp() based on option
        if (this.isStreaming) {
            if (!options?.streamingBehavior) {
                throw new Error("Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message.");
            }
            if (options.streamingBehavior === "followUp") {
                await this._queueFollowUp(expandedText, currentImages);
            }
            else {
                await this._queueSteer(expandedText, currentImages);
            }
            preflightResult?.("queued");
            return undefined;
        }
        // Validate model
        if (!this.model) {
            throw new Error(formatNoModelSelectedMessage());
        }
        const hasConfiguredAuth = this._modelRuntime.hasConfiguredAuth(this.model.provider) ||
            (await this._modelRuntime.checkAuth(this.model.provider)) !== undefined;
        if (!hasConfiguredAuth) {
            const isOAuth = this._modelRuntime.isUsingOAuth(this.model.provider);
            if (isOAuth) {
                throw new Error(`Authentication failed for "${this.model.provider}". ` +
                    `Credentials may have expired or network is unavailable. ` +
                    `Run '/login ${this.model.provider}' to re-authenticate.`);
            }
            throw new Error(formatNoApiKeyFoundMessage(this.model.provider));
        }
        return { messageType: "user", prompt: expandedText, images: currentImages };
    }
    /**
     * Try to execute an extension command. Returns true if command was found and executed.
     */
    async _tryExecuteExtensionCommand(text) {
        // Parse command name and args
        const spaceIndex = text.indexOf(" ");
        const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
        const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1);
        const command = this._extensionRunner.getCommand(commandName);
        if (!command)
            return false;
        // Get command context from extension runner (includes session control methods)
        const ctx = this._extensionRunner.createCommandContext();
        try {
            await command.handler(args, ctx);
            return true;
        }
        catch (err) {
            // Emit error via extension runner
            this._extensionRunner.emitError({
                extensionPath: `command:${commandName}`,
                event: "command",
                error: err instanceof Error ? err.message : String(err),
            });
            return true;
        }
    }
    /**
     * Expand skill commands (/skill:name args) to their full content.
     * Returns the expanded text, or the original text if not a skill command or skill not found.
     * Emits errors via extension runner if file read fails.
     */
    _expandSkillCommand(text) {
        if (!text.startsWith("/skill:"))
            return text;
        const spaceIndex = text.indexOf(" ");
        const skillName = spaceIndex === -1 ? text.slice(7) : text.slice(7, spaceIndex);
        const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim();
        const skill = this.resourceLoader.getSkills().skills.find((s) => s.name === skillName);
        if (!skill)
            return text; // Unknown skill, pass through
        try {
            const content = readFileSync(skill.filePath, "utf-8");
            const body = stripFrontmatter(content).trim();
            const skillBlock = `<skill name="${skill.name}" location="${skill.filePath}">\nReferences are relative to ${skill.baseDir}.\n\n${body}\n</skill>`;
            return args ? `${skillBlock}\n\n${args}` : skillBlock;
        }
        catch (err) {
            // Emit error like extension commands do
            this._extensionRunner.emitError({
                extensionPath: skill.filePath,
                event: "skill_expansion",
                error: err instanceof Error ? err.message : String(err),
            });
            return text; // Return original on error
        }
    }
    async _queueUserInput(text, images, behavior, source) {
        if (text.startsWith("/")) {
            this._throwIfExtensionCommand(text);
        }
        const processedInput = await this._runInputHandlers(text, images, source, this.isStreaming ? behavior : undefined);
        if (!processedInput)
            return "handled";
        let expandedText = this._expandSkillCommand(processedInput.text);
        expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]);
        if (behavior === "steer") {
            await this._queueSteer(expandedText, processedInput.images);
        }
        else {
            await this._queueFollowUp(expandedText, processedInput.images);
        }
        return "queued";
    }
    /**
     * Queue a steering message while the agent is running.
     * Delivered after the current assistant turn finishes executing its tool calls,
     * before the next LLM call.
     * Expands skill commands and prompt templates. Errors on extension commands.
     * @param images Optional image attachments to include with the message
     * @param options Input source; defaults to interactive
     * @throws Error if text is an extension command
     */
    async steer(text, images, options) {
        return this._queueUserInput(text, images, "steer", options?.source ?? "interactive");
    }
    /**
     * Queue a follow-up message to be processed after the agent finishes.
     * Delivered only when agent has no more tool calls or steering messages.
     * Expands skill commands and prompt templates. Errors on extension commands.
     * @param images Optional image attachments to include with the message
     * @param options Input source; defaults to interactive
     * @throws Error if text is an extension command
     */
    async followUp(text, images, options) {
        return this._queueUserInput(text, images, "followUp", options?.source ?? "interactive");
    }
    /**
     * Internal: Queue a steering message (already expanded, no extension command check).
     */
    async _queueSteer(text, images) {
        this._steeringMessages.push(text);
        this._emitQueueUpdate();
        const content = [{ type: "text", text }];
        if (images) {
            content.push(...images);
        }
        const message = { role: "user", content, timestamp: Date.now() };
        if (images?.length)
            this._queuedMessagesWithImages.add(message);
        this.agent.steer(message);
    }
    /**
     * Internal: Queue a follow-up message (already expanded, no extension command check).
     */
    async _queueFollowUp(text, images) {
        this._followUpMessages.push(text);
        this._emitQueueUpdate();
        const content = [{ type: "text", text }];
        if (images) {
            content.push(...images);
        }
        const message = { role: "user", content, timestamp: Date.now() };
        if (images?.length)
            this._queuedMessagesWithImages.add(message);
        this.agent.followUp(message);
    }
    /** Replace a queued user message's images in place with normalized ones, appending hints to its text. */
    async _normalizeQueuedMessageImages(message) {
        if (typeof message.content === "string")
            return;
        const [first, ...rest] = message.content;
        if (first?.type !== "text")
            return;
        const images = rest.filter((block) => block.type === "image");
        const normalized = await this._normalizePromptImages(images);
        const text = normalized.hints.length > 0 ? `${first.text}\n\n${normalized.hints.join("\n")}` : first.text;
        message.content = [
            { type: "text", text },
            ...rest.filter((block) => block.type !== "image"),
            ...normalized.images,
        ];
    }
    /**
     * Throw an error if the text is an extension command.
     */
    _throwIfExtensionCommand(text) {
        const spaceIndex = text.indexOf(" ");
        const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
        const command = this._extensionRunner.getCommand(commandName);
        if (command) {
            throw new Error(`Extension command "/${commandName}" cannot be queued. Use prompt() or execute the command when not streaming.`);
        }
    }
    /**
     * Send a custom message to the session. Creates a CustomMessageEntry.
     *
     * Handles four cases:
     * - Streaming: queues message, processed when loop pulls from queue
     * - Streaming + triggerTurn false: appended to state/session once the current turn ends
     * - Not streaming + triggerTurn: appends to state/session, starts new turn
     * - Not streaming + no trigger: appends to state/session, no turn
     *
     * @param message Custom message with customType, content, display, details
     * @param options.triggerTurn If true and not streaming, triggers a new LLM turn
     * @param options.deliverAs Delivery mode: "steer", "followUp", or "nextTurn"
     */
    async sendCustomMessage(message, options) {
        const appMessage = {
            role: "custom",
            customType: message.customType,
            // Untyped extensions can pass null/missing content; normalize at ingestion.
            content: message.content ?? [],
            display: message.display,
            details: message.details,
            timestamp: Date.now(),
        };
        if (options?.deliverAs === "nextTurn") {
            this._pendingNextTurnMessages.push(appMessage);
        }
        else if (this.isStreaming && options?.triggerTurn !== false) {
            if (options?.deliverAs === "followUp") {
                this.agent.followUp(appMessage);
            }
            else {
                this.agent.steer(appMessage);
            }
        }
        else if (options?.triggerTurn) {
            if (this._isEmittingAgentSettled) {
                this._deferredSettledActions.push(async () => await this.sendCustomMessage(message, options));
                return;
            }
            await this._startRun({ messageType: "custom", message: appMessage });
        }
        else if (this.isStreaming) {
            // Appending now would put the message between an assistant tool call and its
            // result, which providers that validate message order reject on replay. Defer
            // to the end of the turn. Nothing is emitted yet: message events must not
            // describe messages the session tree does not contain.
            this._pendingCustomMessages.push(appMessage);
        }
        else {
            this._appendCustomMessage(appMessage);
        }
    }
    _appendCustomMessage(appMessage) {
        this.sessionManager.appendCustomMessageEntry(appMessage.customType, appMessage.content, appMessage.display, appMessage.details);
        this._refreshFinalizedContext();
        this._emit({ type: "message_start", message: appMessage });
        this._emit({ type: "message_end", message: appMessage });
    }
    /**
     * Append custom messages queued while the agent was running.
     * Called once the current turn's tool results are in agent state and session history.
     */
    _flushPendingCustomMessages() {
        if (this._pendingCustomMessages.length === 0)
            return;
        const pending = this._pendingCustomMessages;
        this._pendingCustomMessages = [];
        for (const appMessage of pending) {
            this._appendCustomMessage(appMessage);
        }
    }
    /**
     * Send a user message to the agent. Always triggers a turn.
     * When the agent is streaming, use deliverAs to specify how to queue the message.
     *
     * @param content User message content (string or content array)
     * @param options.deliverAs Delivery mode when streaming: "steer" or "followUp"
     * @param options.expandPromptTemplates Whether to dispatch extension commands and expand skill commands and prompt templates. Default: false.
     */
    async sendUserMessage(content, options) {
        // Normalize content to text string + optional images
        let text;
        let images;
        if (typeof content === "string") {
            text = content;
        }
        else {
            const textParts = [];
            images = [];
            for (const part of content) {
                if (part.type === "text") {
                    textParts.push(part.text);
                }
                else {
                    images.push(part);
                }
            }
            text = textParts.join("\n");
            if (images.length === 0)
                images = undefined;
        }
        await this.prompt(text, {
            expandPromptTemplates: options?.expandPromptTemplates ?? false,
            streamingBehavior: options?.deliverAs,
            images,
            source: "extension",
        });
    }
    /**
     * Clear all queued messages and return them.
     * Useful for restoring to editor when user aborts.
     * @returns Object with steering and followUp arrays
     */
    clearQueue() {
        const steering = [...this._steeringMessages];
        const followUp = [...this._followUpMessages];
        this._steeringMessages = [];
        this._followUpMessages = [];
        this.agent.clearAllQueues();
        this._emitQueueUpdate();
        return { steering, followUp };
    }
    /** Number of pending messages (includes both steering and follow-up) */
    get pendingMessageCount() {
        return this._steeringMessages.length + this._followUpMessages.length;
    }
    /** Get pending steering messages (read-only) */
    getSteeringMessages() {
        return this._steeringMessages;
    }
    /** Get pending follow-up messages (read-only) */
    getFollowUpMessages() {
        return this._followUpMessages;
    }
    get resourceLoader() {
        return this._resourceLoader;
    }
    /**
     * Abort current operation and wait for agent to become idle.
     */
    async abort() {
        if (this._isAgentRunActive) {
            this._agentRunAbortRequested = true;
        }
        this.abortRetry();
        this.abortCompaction();
        this.abortBranchSummary();
        if (this._isBeforeSettle)
            this._abortDuringBeforeSettle = true;
        this.agent.abort();
        await this.waitForIdle();
    }
    async waitForIdle() {
        if (this.isIdle) {
            return;
        }
        await this._getIdleWaitPromise();
    }
    // =========================================================================
    // Model Management
    // =========================================================================
    async _emitModelSelect(nextModel, previousModel, source) {
        if (modelsAreEqual(previousModel, nextModel))
            return;
        await this._extensionRunner.emit({
            type: "model_select",
            model: nextModel,
            previousModel,
            source,
        });
    }
    /**
     * Set model directly.
     * Validates that auth is configured and saves to the session transcript.
     * Persists to global defaults only when options.persist is true.
     * @throws Error if no auth is configured for the model
     */
    async setModel(model, options = {}) {
        if (!(await this._modelRuntime.checkAuth(model.provider))) {
            throw new Error(`No API key for ${model.provider}/${model.id}`);
        }
        const previousModel = this.model;
        const thinkingLevel = this._getThinkingLevelForModelSwitch(model);
        this.agent.state.model = model;
        this.sessionManager.appendModelChange(model.provider, model.id);
        if (options.persist) {
            this.settingsManager.setDefaultModelAndProvider(model.provider, model.id);
            this._addPersistedDefaultToNonEmptyScope(model);
        }
        // Apply thinking level for the new model.
        // Per-model thinking level overrides take priority over the global default.
        // Model persistence does not implicitly rewrite the global thinking default.
        this.setThinkingLevel(thinkingLevel);
        await this._emitModelSelect(model, previousModel, "set");
    }
    _addPersistedDefaultToNonEmptyScope(model) {
        if (this._scopedModels.length === 0)
            return;
        if (this._scopedModels.some((scoped) => modelsAreEqual(scoped.model, model)))
            return;
        this._scopedModels = [...this._scopedModels, { model }];
        const enabledModels = this.settingsManager.getEnabledModels();
        if (!enabledModels?.length)
            return;
        const modelReference = `${model.provider}/${model.id}`;
        if (enabledModels.some((pattern) => pattern.toLowerCase() === modelReference.toLowerCase()))
            return;
        this.settingsManager.setEnabledModels([...enabledModels, modelReference]);
    }
    /**
     * Cycle to next/previous model.
     * Uses scoped models (from --models flag) if available, otherwise all available models.
     * @param direction - "forward" (default) or "backward"
     * @returns The new model info, or undefined if only one model available
     */
    async cycleModel(direction = "forward", options = {}) {
        if (this._scopedModels.length > 0) {
            return this._cycleScopedModel(direction, options);
        }
        return this._cycleAvailableModel(direction, options);
    }
    async _cycleScopedModel(direction, options) {
        const availableIds = new Set(this._modelRuntime.getAvailableSnapshot().map((model) => `${model.provider}\0${model.id}`));
        const scopedModels = this._scopedModels.filter((scoped) => availableIds.has(`${scoped.model.provider}\0${scoped.model.id}`));
        const currentModel = this.model;
        const currentIndex = scopedModels.findIndex((sm) => modelsAreEqual(sm.model, currentModel));
        const nextIndex = getNextCycleIndex(currentIndex, scopedModels.length, direction);
        if (nextIndex === undefined)
            return undefined;
        const next = scopedModels[nextIndex];
        const thinkingLevel = this._getThinkingLevelForModelSwitch(next.model, next.thinkingLevel);
        // Apply model
        this.agent.state.model = next.model;
        this.sessionManager.appendModelChange(next.model.provider, next.model.id);
        if (options.persist) {
            this.settingsManager.setDefaultModelAndProvider(next.model.provider, next.model.id);
            this._addPersistedDefaultToNonEmptyScope(next.model);
        }
        // Apply thinking level for the new model.
        // - Explicit scoped model thinking level overrides defaults
        // - Per-model thinking level overrides take priority over the global default
        // setThinkingLevel clamps to model capabilities.
        // Model persistence does not implicitly rewrite the global thinking default.
        this.setThinkingLevel(thinkingLevel);
        await this._emitModelSelect(next.model, currentModel, "cycle");
        return { model: next.model, thinkingLevel: this.thinkingLevel, isScoped: true };
    }
    async _cycleAvailableModel(direction, options) {
        const availableModels = this._modelRuntime.getAvailableSnapshot();
        const currentModel = this.model;
        const currentIndex = availableModels.findIndex((m) => modelsAreEqual(m, currentModel));
        const nextIndex = getNextCycleIndex(currentIndex, availableModels.length, direction);
        if (nextIndex === undefined)
            return undefined;
        const nextModel = availableModels[nextIndex];
        const thinkingLevel = this._getThinkingLevelForModelSwitch(nextModel);
        this.agent.state.model = nextModel;
        this.sessionManager.appendModelChange(nextModel.provider, nextModel.id);
        if (options.persist) {
            this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id);
            this._addPersistedDefaultToNonEmptyScope(nextModel);
        }
        // Apply thinking level for the new model.
        // Model persistence does not implicitly rewrite the global thinking default.
        this.setThinkingLevel(thinkingLevel);
        await this._emitModelSelect(nextModel, currentModel, "cycle");
        return { model: nextModel, thinkingLevel: this.thinkingLevel, isScoped: false };
    }
    // =========================================================================
    // Thinking Level Management
    // =========================================================================
    /**
     * Set thinking level.
     * Clamps to model capabilities based on available thinking levels.
     * Saves the clamped level to the session transcript only if the level actually changes.
     * Persists the requested level to global defaults only when options.persist is true.
     */
    setThinkingLevel(level, options = {}) {
        const availableLevels = this.getAvailableThinkingLevels();
        const effectiveLevel = availableLevels.includes(level) ? level : this._clampThinkingLevel(level, availableLevels);
        // Only persist if actually changing
        const previousLevel = this.agent.state.thinkingLevel;
        const isChanging = effectiveLevel !== previousLevel;
        this.agent.state.thinkingLevel = effectiveLevel;
        if (options.persist) {
            this.settingsManager.setDefaultThinkingLevel(level);
        }
        if (isChanging) {
            this.sessionManager.appendThinkingLevelChange(effectiveLevel);
            this._emit({ type: "thinking_level_changed", level: effectiveLevel });
            void this._extensionRunner.emit({
                type: "thinking_level_select",
                level: effectiveLevel,
                previousLevel,
            });
        }
    }
    /**
     * Cycle to next thinking level.
     * @returns New level, or undefined if model doesn't support thinking
     */
    cycleThinkingLevel(options = {}) {
        if (!this.supportsThinking())
            return undefined;
        const levels = this.getAvailableThinkingLevels();
        const currentIndex = levels.indexOf(this.thinkingLevel);
        const nextIndex = (currentIndex + 1) % levels.length;
        const nextLevel = levels[nextIndex];
        this.setThinkingLevel(nextLevel, options);
        return nextLevel;
    }
    /**
     * Get available thinking levels for current model.
     * The provider will clamp to what the specific model supports internally.
     */
    getAvailableThinkingLevels() {
        if (!this.model)
            return [...THINKING_LEVEL_OPTIONS];
        return getSupportedThinkingLevels(this.model);
    }
    /**
     * Check if current model supports thinking/reasoning.
     */
    supportsThinking() {
        return !!this.model?.reasoning;
    }
    _getThinkingLevelForModelSwitch(targetModel, explicitLevel) {
        if (explicitLevel !== undefined) {
            return explicitLevel;
        }
        // Per-model default takes priority when switching to a model that has one
        if (targetModel) {
            const perModel = this.settingsManager.getModelThinkingLevel(targetModel.provider, targetModel.id);
            if (perModel !== undefined) {
                return perModel;
            }
        }
        return this.settingsManager.getDefaultThinkingLevel() ?? this.thinkingLevel ?? DEFAULT_THINKING_LEVEL;
    }
    _clampThinkingLevel(level, _availableLevels) {
        return this.model ? clampThinkingLevel(this.model, level) : "off";
    }
    // =========================================================================
    // Queue Mode Management
    // =========================================================================
    syncQueueModesFromSettings() {
        this.agent.steeringMode = this.settingsManager.getSteeringMode();
        this.agent.followUpMode = this.settingsManager.getFollowUpMode();
    }
    /**
     * Set steering message mode.
     * Saves to settings.
     */
    setSteeringMode(mode) {
        this.agent.steeringMode = mode;
        this.settingsManager.setSteeringMode(mode);
    }
    /**
     * Set follow-up message mode.
     * Saves to settings.
     */
    setFollowUpMode(mode) {
        this.agent.followUpMode = mode;
        this.settingsManager.setFollowUpMode(mode);
    }
    // =========================================================================
    // Compaction
    // =========================================================================
    /** Generate Pi's built-in compaction summary for manual and automatic compaction. */
    async _runDefaultCompaction(preparation, model, customInstructions, signal, reason) {
        // Resolve the request only when Pi summarizes itself: routing may call models or fail.
        const request = await this._getSummarizationRequestAuth(model, signal);
        return compact(preparation, request.model, request.apiKey, request.headers, customInstructions, signal, request.thinkingLevel, this.agent.streamFunction, request.env, this.settingsManager.getRetrySettings(), this._summarizationRetryCallbacks({ source: "compaction", reason }), undefined);
    }
    _clearManualCompactionState() {
        this._compactionAbortController = undefined;
        this._resolveIdleWaitIfIdle();
    }
    /**
     * Manually compact the session context.
     *
     * This is the manual entry point used by `/compact`, RPC, and extensions. It is
     * separate from automatic threshold/overflow compaction, which enters through
     * `_checkCompaction()` and `_runAutoCompaction()`. After preparation and the
     * `session_before_compact` hook, both paths call the lower-level `compact()`
     * function imported from `./compaction/index.ts`, unless the hook cancels or
     * supplies a custom result.
     *
     * Aborts the current agent operation first. Manual compaction never retries or
     * continues the interrupted agent turn.
     *
     * @param customInstructions Optional instructions for the compaction summary
     */
    async compact(customInstructions) {
        await this.abort();
        this._compactionAbortController = new AbortController();
        this._emit({ type: "compaction_start", reason: "manual" });
        let fromExtension = false;
        let cancelledByExtension = false;
        try {
            const model = this.model;
            if (!model) {
                throw new Error(formatNoModelSelectedMessage());
            }
            const settings = this.settingsManager.getCompactionSettings(model);
            const pathEntries = this.sessionManager.getBranch();
            const preparation = prepareCompaction(pathEntries, settings);
            if (!preparation) {
                // Check why we can't compact
                const lastEntry = pathEntries[pathEntries.length - 1];
                if (lastEntry?.type === "compaction") {
                    throw new Error("Already compacted");
                }
                throw new Error("Nothing to compact (session too small)");
            }
            let extensionCompaction;
            if (this._extensionRunner.hasHandlers("session_before_compact")) {
                const result = (await this._extensionRunner.emit({
                    type: "session_before_compact",
                    preparation,
                    branchEntries: pathEntries,
                    customInstructions,
                    reason: "manual",
                    willRetry: false,
                    signal: this._compactionAbortController.signal,
                }));
                if (result?.cancel) {
                    cancelledByExtension = true;
                    throw new Error("Compaction cancelled");
                }
                if (result?.compaction) {
                    extensionCompaction = result.compaction;
                    fromExtension = true;
                }
            }
            let summary;
            let firstKeptEntryId;
            let tokensBefore;
            let usage;
            let details;
            if (extensionCompaction) {
                // Extension provided compaction content
                summary = extensionCompaction.summary;
                firstKeptEntryId = extensionCompaction.firstKeptEntryId;
                tokensBefore = extensionCompaction.tokensBefore;
                usage = extensionCompaction.usage;
                details = extensionCompaction.details;
            }
            else {
                // Shared default summary generator, also used by automatic compaction.
                const result = await this._runDefaultCompaction(preparation, model, customInstructions, this._compactionAbortController.signal, "manual");
                summary = result.summary;
                firstKeptEntryId = result.firstKeptEntryId;
                tokensBefore = result.tokensBefore;
                usage = result.usage;
                details = result.details;
            }
            if (this._compactionAbortController.signal.aborted) {
                throw new Error("Compaction cancelled");
            }
            this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension, usage);
            const newEntries = this.sessionManager.getEntries();
            this._refreshFinalizedContext();
            const estimatedTokensAfter = estimateMessagesTokens(this.sessionManager.buildSessionProjection().messages);
            // Get the saved compaction entry for the extension event
            const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary);
            if (this._extensionRunner && savedCompactionEntry) {
                await this._extensionRunner.emit({
                    type: "session_compact",
                    compactionEntry: savedCompactionEntry,
                    fromExtension,
                    reason: "manual",
                    willRetry: false,
                });
            }
            const compactionResult = {
                summary,
                firstKeptEntryId,
                tokensBefore,
                estimatedTokensAfter,
                usage,
                details,
            };
            // compaction_end listeners may submit queued prompts, so expose idle state before notifying them.
            this._clearManualCompactionState();
            this._emit({
                type: "compaction_end",
                reason: "manual",
                result: compactionResult,
                aborted: false,
                willRetry: false,
            });
            return compactionResult;
        }
        catch (error) {
            const message = error instanceof Error ? error.message : String(error);
            const aborted = this._compactionAbortController.signal.aborted || cancelledByExtension;
            const errorMessage = aborted ? undefined : `Compaction failed: ${message}`;
            this._clearManualCompactionState();
            this._emit({
                type: "compaction_end",
                reason: "manual",
                result: undefined,
                aborted,
                willRetry: false,
                errorMessage,
            });
            await this._emitSessionCompactFailed({
                reason: "manual",
                errorMessage,
                aborted,
                willRetry: false,
                fromExtension,
            });
            throw error;
        }
        finally {
            this._clearManualCompactionState();
        }
    }
    /**
     * Cancel in-progress compaction (manual or auto).
     */
    abortCompaction() {
        this._compactionAbortController?.abort();
        this._autoCompactionAbortController?.abort();
    }
    /**
     * Cancel in-progress branch summarization.
     */
    abortBranchSummary() {
        this._branchSummaryAbortController?.abort();
    }
    /**
     * Dispatch automatic compaction after `agent_end` or before prompt submission.
     * Manual compaction does not call this method; it enters through `compact()`.
     *
     * Automatic cases:
     * 1. Overflow with retry: a context-overflow error or recoverable length stop;
     *    remove the failed assistant message, compact, and retry the turn once.
     * 2. Overflow without retry: a successful response exceeded the configured
     *    context window; compact but preserve the completed response.
     * 3. Threshold without retry: valid or estimated context usage crossed the
     *    configured threshold; compact without retrying the completed response.
     *
     * Each case calls `_runAutoCompaction()`. After preparation and the
     * `session_before_compact` hook, that method calls the lower-level `compact()`
     * function imported from `./compaction/index.ts`, unless the hook cancels or
     * supplies a custom result.
     *
     * @param assistantMessage The assistant message to check
     * @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true
     * @returns Whether the post-run loop should call `agent.continue()` for overflow recovery or queued messages
     */
    async _checkCompaction(assistantMessage, skipAbortedCheck = true) {
        const settings = this.settingsManager.getCompactionSettings(this.model);
        if (!settings.enabled)
            return false;
        // Skip if message was aborted (user cancelled) - unless skipAbortedCheck is false
        if (skipAbortedCheck && assistantMessage.stopReason === "aborted")
            return false;
        // Skip overflow check if the message came from a different model.
        // This handles the case where user switched from a smaller-context model (e.g. opus)
        // to a larger-context model (e.g. codex) - the overflow error from the old model
        // shouldn't trigger compaction for the new model. Under a virtual selection, the
        // physical model that produced the message supplies the limits.
        const messageModel = this._modelForMessage(assistantMessage);
        const sameModel = messageModel !== undefined;
        const contextWindow = (messageModel ?? this.model)?.contextWindow ?? 0;
        // Skip compaction checks if this assistant message is older than the latest
        // compaction boundary. This prevents a stale pre-compaction usage/error
        // from retriggering compaction on the first prompt after compaction.
        const compactionEntry = getLatestCompactionEntry(this.sessionManager.getBranch());
        const assistantIsFromBeforeCompaction = compactionEntry !== null && assistantMessage.timestamp <= new Date(compactionEntry.timestamp).getTime();
        if (assistantIsFromBeforeCompaction) {
            return false;
        }
        // Automatic cases 1 and 2: context overflow.
        // A length stop is recoverable when output ended below the model's original desired limit,
        // independent of the configured context size or any context-clamped provider request limit.
        const currentProjection = this.sessionManager.buildSessionProjection();
        const assistantEntryId = this._findPersistedMessageEntryId(assistantMessage);
        const assistantIsProjected = assistantEntryId === undefined ||
            currentProjection.entries.some((entry) => entry.sourceEntry.id === assistantEntryId &&
                entry.messages.some((message) => message.role === "assistant"));
        const branch = this.sessionManager.getBranch();
        const assistantIndex = assistantEntryId ? branch.findIndex((entry) => entry.id === assistantEntryId) : -1;
        const entriesAfterAssistant = assistantIndex >= 0 ? branch.slice(assistantIndex + 1) : [];
        const hasPostAssistantContextEdit = entriesAfterAssistant.some((entry) => entry.type === "context_edit");
        const latestAssistantEdit = entriesAfterAssistant
            .filter((entry) => entry.type === "context_edit" && entry.targetId === assistantEntryId)
            .at(-1);
        const assistantRetainedForExplicitRecovery = assistantEntryId === undefined ||
            (!entriesAfterAssistant.some((entry) => entry.type === "compaction") &&
                latestAssistantEdit?.replacement !== null);
        const assistantUsageMatchesProjection = assistantIsProjected && !hasPostAssistantContextEdit;
        const explicitOverflow = assistantMessage.stopReason === "error" && isContextOverflow(assistantMessage);
        const contextOverflow = sameModel &&
            ((explicitOverflow && assistantRetainedForExplicitRecovery) ||
                (assistantUsageMatchesProjection && isContextOverflow(assistantMessage, contextWindow)));
        const recoverableLength = sameModel && assistantIsProjected && isRecoverableLength(assistantMessage, messageModel.maxTokens);
        if (contextOverflow || recoverableLength) {
            const willRetry = assistantMessage.stopReason !== "stop";
            // Case 2: the response completed successfully. Compact, but do not retry because
            // agent.continue() cannot continue from a completed assistant response.
            if (!willRetry) {
                return await this._runAutoCompaction("overflow", false);
            }
            if (this._overflowRecoveryAttempted) {
                const errorMessage = contextOverflow
                    ? "Context overflow recovery failed after one compact-and-retry attempt. Try reducing context or switching to a larger-context model."
                    : "Truncated response recovery failed after one compact-and-retry attempt.";
                this._emit({
                    type: "compaction_end",
                    reason: "overflow",
                    result: undefined,
                    aborted: false,
                    willRetry: false,
                    errorMessage,
                });
                await this._emitSessionCompactFailed({
                    reason: "overflow",
                    errorMessage,
                    aborted: false,
                    willRetry: false,
                    fromExtension: false,
                });
                return false;
            }
            // Persistently omit the selected final attempt before post-run recovery compaction.
            this._overflowRecoveryAttempted = true;
            this._omitRecoveryAttempt(assistantMessage);
            const retry = await this._runAutoCompaction("overflow", willRetry);
            if (retry)
                this._failedResponse = assistantMessage;
            return retry;
        }
        // Case 3: threshold compaction without retry.
        // For error messages or all-zero usage messages, estimate from the last valid response.
        // This ensures sessions that hit persistent API errors (e.g. 529) or malformed zero-usage
        // responses can still compact and do not reset context accounting.
        let contextTokens;
        const projection = currentProjection;
        const hasContextEdits = projection.entries.some((entry) => entry.sourceEntry.type === "context_edit");
        const directContextTokens = assistantMessage.usage ? calculateContextTokens(assistantMessage.usage) : 0;
        if (hasContextEdits) {
            contextTokens = estimateProjectedContextTokens(projection, branch).tokens;
        }
        else if (assistantMessage.stopReason === "error" || directContextTokens === 0) {
            const messages = this.agent.state.messages;
            const estimate = estimateContextTokens(messages);
            // Without provider usage, estimate.tokens is the pure message-size estimate.
            // Only usage-backed estimates need the stale pre-compaction check.
            if (estimate.lastUsageIndex !== null) {
                // Verify the usage source is post-compaction. Kept pre-compaction messages
                // have stale usage reflecting the old (larger) context and would falsely
                // trigger compaction right after one just finished.
                const usageMsg = messages[estimate.lastUsageIndex];
                if (compactionEntry &&
                    usageMsg.role === "assistant" &&
                    usageMsg.timestamp <= new Date(compactionEntry.timestamp).getTime()) {
                    return false;
                }
            }
            contextTokens = estimate.tokens;
        }
        else {
            contextTokens = directContextTokens;
        }
        if (shouldCompact(contextTokens, contextWindow, settings)) {
            return await this._runAutoCompaction("threshold", false);
        }
        return false;
    }
    /**
     * Execute threshold or overflow compaction. Manual compaction uses
     * `AgentSession.compact()` instead. Both paths call the lower-level `compact()`
     * function imported from `./compaction/index.ts` after preparation and extension
     * interception.
     *
     * @param reason Automatic trigger selected by `_checkCompaction()`
     * @param willRetry Whether to continue the interrupted turn after overflow compaction
     * @returns Whether the post-run loop should call `agent.continue()`
     */
    async _runAutoCompaction(reason, willRetry) {
        const model = this.model;
        const settings = this.settingsManager.getCompactionSettings(model);
        let abortController;
        let started = false;
        let fromExtension = false;
        let cancelledByExtension = false;
        try {
            if (!model) {
                return false;
            }
            const pathEntries = this.sessionManager.getBranch();
            const preparation = prepareCompaction(pathEntries, settings);
            if (!preparation) {
                return false;
            }
            abortController = new AbortController();
            this._autoCompactionAbortController = abortController;
            started = true;
            this._emit({ type: "compaction_start", reason });
            abortController.signal.throwIfAborted();
            let extensionCompaction;
            if (this._extensionRunner.hasHandlers("session_before_compact")) {
                const extensionResult = (await this._extensionRunner.emit({
                    type: "session_before_compact",
                    preparation,
                    branchEntries: pathEntries,
                    customInstructions: undefined,
                    reason,
                    willRetry,
                    signal: abortController.signal,
                }));
                if (extensionResult?.cancel) {
                    cancelledByExtension = true;
                    throw new Error("Compaction cancelled");
                }
                if (extensionResult?.compaction) {
                    extensionCompaction = extensionResult.compaction;
                    fromExtension = true;
                }
            }
            abortController.signal.throwIfAborted();
            let summary;
            let firstKeptEntryId;
            let tokensBefore;
            let usage;
            let details;
            if (extensionCompaction) {
                // Extension provided compaction content
                summary = extensionCompaction.summary;
                firstKeptEntryId = extensionCompaction.firstKeptEntryId;
                tokensBefore = extensionCompaction.tokensBefore;
                usage = extensionCompaction.usage;
                details = extensionCompaction.details;
            }
            else {
                // Shared default summary generator, also used by manual compaction.
                const compactResult = await this._runDefaultCompaction(preparation, model, undefined, abortController.signal, reason);
                summary = compactResult.summary;
                firstKeptEntryId = compactResult.firstKeptEntryId;
                tokensBefore = compactResult.tokensBefore;
                usage = compactResult.usage;
                details = compactResult.details;
            }
            abortController.signal.throwIfAborted();
            this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension, usage);
            const newEntries = this.sessionManager.getEntries();
            this._refreshFinalizedContext();
            const estimatedTokensAfter = estimateMessagesTokens(this.sessionManager.buildSessionProjection().messages);
            // Get the saved compaction entry for the extension event
            const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary);
            if (this._extensionRunner && savedCompactionEntry) {
                await this._extensionRunner.emit({
                    type: "session_compact",
                    compactionEntry: savedCompactionEntry,
                    fromExtension,
                    reason,
                    willRetry,
                });
            }
            const result = {
                summary,
                firstKeptEntryId,
                tokensBefore,
                estimatedTokensAfter,
                usage,
                details,
            };
            this._emit({ type: "compaction_end", reason, result, aborted: false, willRetry });
            if (willRetry)
                return true;
            // Auto-compaction can complete while follow-up/steering/custom messages are waiting.
            // Continue once so queued messages are delivered.
            return this.agent.hasQueuedMessages();
        }
        catch (error) {
            const message = error instanceof Error ? error.message : "compaction failed";
            const aborted = abortController?.signal.aborted === true || cancelledByExtension;
            if (started) {
                const errorMessage = aborted
                    ? undefined
                    : reason === "overflow"
                        ? `Context overflow recovery failed: ${message}`
                        : `Auto-compaction failed: ${message}`;
                this._emit({
                    type: "compaction_end",
                    reason,
                    result: undefined,
                    aborted,
                    willRetry: false,
                    errorMessage,
                });
                await this._emitSessionCompactFailed({
                    reason,
                    errorMessage,
                    aborted,
                    willRetry: false,
                    fromExtension,
                });
            }
            return false;
        }
        finally {
            if (this._autoCompactionAbortController === abortController) {
                this._autoCompactionAbortController = undefined;
            }
            this._resolveIdleWaitIfIdle();
        }
    }
    /**
     * Toggle auto-compaction setting.
     */
    setAutoCompactionEnabled(enabled) {
        this.settingsManager.setCompactionEnabled(enabled);
    }
    /** Whether auto-compaction is enabled */
    get autoCompactionEnabled() {
        return this.settingsManager.getCompactionEnabled();
    }
    async bindExtensions(bindings) {
        if (bindings.uiContext !== undefined) {
            this._extensionUIContext = bindings.uiContext;
        }
        if (bindings.mode !== undefined) {
            this._extensionMode = bindings.mode;
        }
        if (bindings.commandContextActions !== undefined) {
            this._extensionCommandContextActions = bindings.commandContextActions;
        }
        if (bindings.abortHandler !== undefined) {
            this._extensionAbortHandler = bindings.abortHandler;
        }
        if (bindings.shutdownHandler !== undefined) {
            this._extensionShutdownHandler = bindings.shutdownHandler;
        }
        if (bindings.onError !== undefined) {
            this._extensionErrorListener = bindings.onError;
        }
        this._applyExtensionBindings(this._extensionRunner);
        await this._extensionRunner.emit(this._sessionStartEvent);
        this._extensionRunner.reportUnhandledMcpServers();
        await this.extendResourcesFromExtensions(this._sessionStartEvent.reason === "reload" ? "reload" : "startup");
    }
    async extendResourcesFromExtensions(reason) {
        if (!this._extensionRunner.hasHandlers("resources_discover")) {
            return;
        }
        const { skillPaths, promptPaths, themePaths } = await this._extensionRunner.emitResourcesDiscover(this._cwd, reason);
        if (skillPaths.length === 0 && promptPaths.length === 0 && themePaths.length === 0) {
            return;
        }
        const extensionPaths = {
            skillPaths: this.buildExtensionResourcePaths(skillPaths),
            promptPaths: this.buildExtensionResourcePaths(promptPaths),
            themePaths: this.buildExtensionResourcePaths(themePaths),
        };
        this._resourceLoader.extendResources(extensionPaths);
        this._rebuildSystemPrompt(this.getActiveToolNames());
    }
    buildExtensionResourcePaths(entries) {
        return entries.map((entry) => {
            const source = this.getExtensionSourceLabel(entry.extensionPath);
            const baseDir = isSyntheticPath(entry.extensionPath) ? undefined : dirname(entry.extensionPath);
            return {
                path: entry.path,
                metadata: {
                    source,
                    scope: "temporary",
                    origin: "top-level",
                    baseDir,
                },
            };
        });
    }
    getExtensionSourceLabel(extensionPath) {
        if (isSyntheticPath(extensionPath)) {
            return `extension:${extensionPath.replace(/[<>]/g, "")}`;
        }
        const base = basename(extensionPath);
        const name = base.replace(/\.(ts|js)$/, "");
        return `extension:${name}`;
    }
    _applyExtensionBindings(runner) {
        runner.setUIContext(this._extensionUIContext, this._extensionMode);
        runner.bindCommandContext(this._extensionCommandContextActions);
        this._extensionErrorUnsubscriber?.();
        this._extensionErrorUnsubscriber = this._extensionErrorListener
            ? runner.onError(this._extensionErrorListener)
            : undefined;
    }
    _refreshCurrentModelFromRegistry() {
        const currentModel = this.model;
        if (!currentModel) {
            return;
        }
        const refreshedModel = this._modelRuntime.getModel(currentModel.provider, currentModel.id);
        if (!refreshedModel || refreshedModel === currentModel) {
            return;
        }
        this.agent.state.model = refreshedModel;
    }
    _bindExtensionCore(runner) {
        const getCommands = () => {
            const extensionCommands = runner.getRegisteredCommands().map((command) => ({
                name: command.invocationName,
                description: command.description,
                source: "extension",
                sourceInfo: command.sourceInfo,
            }));
            const templates = this.promptTemplates.map((template) => ({
                name: template.name,
                description: template.description,
                source: "prompt",
                sourceInfo: template.sourceInfo,
            }));
            const skills = this._resourceLoader.getSkills().skills.map((skill) => ({
                name: `skill:${skill.name}`,
                description: skill.description,
                source: "skill",
                sourceInfo: skill.sourceInfo,
            }));
            return [...extensionCommands, ...templates, ...skills];
        };
        runner.bindCore({
            sendMessage: (message, options) => {
                this.sendCustomMessage(message, options).catch((err) => {
                    runner.emitError({
                        extensionPath: "<runtime>",
                        event: "send_message",
                        error: err instanceof Error ? err.message : String(err),
                    });
                });
            },
            sendUserMessage: (content, options) => {
                this.sendUserMessage(content, options).catch((err) => {
                    runner.emitError({
                        extensionPath: "<runtime>",
                        event: "send_user_message",
                        error: err instanceof Error ? err.message : String(err),
                    });
                });
            },
            appendEntry: (customType, data) => {
                const entryId = this.sessionManager.appendCustomEntry(customType, data);
                const entry = this.sessionManager.getEntry(entryId);
                if (entry) {
                    this._emit({ type: "entry_appended", entry });
                }
            },
            setSessionName: (name) => {
                this.setSessionName(name);
            },
            getSessionName: () => {
                return this.sessionManager.getSessionName();
            },
            setLabel: (entryId, label) => {
                this.sessionManager.appendLabelChange(entryId, label);
            },
            getActiveTools: () => this.getActiveToolNames(),
            getAllTools: () => this.getAllTools(),
            getSettings: () => this.settingsManager.getSettings(),
            setActiveTools: (toolNames) => this.setActiveToolsByName(toolNames),
            refreshTools: () => this._refreshToolRegistry(),
            getCommands,
            setModel: async (model) => {
                if (!this._modelRuntime.hasConfiguredAuth(model.provider))
                    return false;
                await this.setModel(model);
                return true;
            },
            getThinkingLevel: () => this.thinkingLevel,
            setThinkingLevel: (level) => this.setThinkingLevel(level),
        }, {
            getModel: () => this.model,
            getScopedModels: () => this._scopedModels,
            isIdle: () => this.isIdle,
            isProjectTrusted: () => this.settingsManager.isProjectTrusted(),
            getSignal: () => this.agent.signal,
            abort: () => {
                if (this._extensionAbortHandler) {
                    this._extensionAbortHandler();
                    return;
                }
                void this.abort();
            },
            hasPendingMessages: () => this.pendingMessageCount > 0,
            shutdown: () => {
                this._extensionShutdownHandler?.();
            },
            getContextUsage: () => this.getContextUsage(),
            compact: (options) => {
                void (async () => {
                    try {
                        const result = await this.compact(options?.customInstructions);
                        options?.onComplete?.(result);
                    }
                    catch (error) {
                        const err = error instanceof Error ? error : new Error(String(error));
                        options?.onError?.(err);
                    }
                })();
            },
            getSystemPrompt: () => this.systemPrompt,
            getSystemPromptOptions: () => this._baseSystemPromptOptions,
            executeTool: (callerId, name, args, options) => this._executeNestedToolCall(callerId, name, args, options),
            getCallableTools: () => this._getCallableTools(),
        }, {
            registerProvider: (name, config) => {
                this._modelRuntime.registerProvider(name, config);
                this._refreshCurrentModelFromRegistry();
            },
            registerNativeProvider: (provider) => {
                this._modelRuntime.registerNativeProvider(provider);
                this._refreshCurrentModelFromRegistry();
            },
            unregisterProvider: (name) => {
                this._modelRuntime.unregisterProvider(name);
                this._refreshCurrentModelFromRegistry();
            },
            registerVirtualModel: (definition) => {
                this._modelRuntime.registerVirtualModel(definition);
                this._refreshCurrentModelFromRegistry();
            },
            unregisterVirtualModel: (provider, id) => {
                this._modelRuntime.unregisterVirtualModel(provider, id);
                this._refreshCurrentModelFromRegistry();
            },
        });
    }
    _refreshToolRegistry(options) {
        // Tools that were already activated on registration. A tool whose exposure changes to
        // `direct` or `model-only` (for example from `hidden`) is activated like a new tool.
        const previousActivatedOnRegistration = new Set([...this._toolRegistry.keys()].filter((name) => this._isActivatedOnRegistration(name)));
        const previousActiveToolNames = this.getActiveToolNames();
        const allowedTools = this._allowedTools;
        const registeredTools = this._extensionRunner.getAllRegisteredTools();
        const allCustomTools = [
            ...registeredTools,
            ...this._customTools.map((definition) => ({
                definition,
                sourceInfo: createSyntheticSourceInfo(`<sdk:${definition.name}>`, { source: "sdk" }),
            })),
        ].filter((tool) => this._isAllowedTool(tool.definition.name));
        const definitionRegistry = new Map(Array.from(this._baseToolDefinitions.entries())
            .filter(([name]) => this._isAllowedTool(name))
            .map(([name, definition]) => [
            name,
            {
                definition,
                sourceInfo: createSyntheticSourceInfo(`${BUILTIN_PATH_PREFIX}${name}`, { source: "builtin" }),
            },
        ]));
        for (const tool of allCustomTools) {
            definitionRegistry.set(tool.definition.name, {
                definition: tool.definition,
                sourceInfo: tool.sourceInfo,
            });
        }
        this._toolDefinitions = definitionRegistry;
        this._toolPromptSnippets = new Map(Array.from(definitionRegistry.values())
            .map(({ definition }) => {
            const snippet = this._normalizePromptSnippet(definition.promptSnippet);
            return snippet ? [definition.name, snippet] : undefined;
        })
            .filter((entry) => entry !== undefined));
        this._toolPromptGuidelines = new Map(Array.from(definitionRegistry.values())
            .map(({ definition }) => {
            const guidelines = this._normalizePromptGuidelines(definition.promptGuidelines);
            return guidelines.length > 0 ? [definition.name, guidelines] : undefined;
        })
            .filter((entry) => entry !== undefined));
        const runner = this._extensionRunner;
        const wrappedExtensionTools = wrapRegisteredTools(allCustomTools, runner);
        const wrappedBuiltInTools = wrapRegisteredTools(Array.from(this._baseToolDefinitions.values())
            .filter((definition) => this._isAllowedTool(definition.name))
            .map((definition) => ({
            definition,
            sourceInfo: createSyntheticSourceInfo(`${BUILTIN_PATH_PREFIX}${definition.name}`, {
                source: "builtin",
            }),
        })), runner);
        const toolRegistry = new Map(wrappedBuiltInTools.map((tool) => [tool.name, tool]));
        for (const tool of wrappedExtensionTools) {
            toolRegistry.set(tool.name, tool);
        }
        this._toolRegistry = toolRegistry;
        const nextActiveToolNames = (options?.activeToolNames ? [...options.activeToolNames] : [...previousActiveToolNames]).filter((name) => this._isAllowedTool(name));
        if (allowedTools) {
            for (const toolName of this._toolRegistry.keys()) {
                // Naming or matching a tool activates it even when it is not active by default. MCP tools
                // kept registered without being named stay inactive.
                if (allowedTools(toolName) && this._isDeclarable(toolName)) {
                    nextActiveToolNames.push(toolName);
                }
            }
        }
        else if (options?.includeAllExtensionTools) {
            for (const tool of wrappedExtensionTools) {
                if (this._isActivatedOnRegistration(tool.name))
                    nextActiveToolNames.push(tool.name);
            }
        }
        else if (!options?.activeToolNames) {
            for (const toolName of this._toolRegistry.keys()) {
                if (!previousActivatedOnRegistration.has(toolName) && this._isActivatedOnRegistration(toolName)) {
                    nextActiveToolNames.push(toolName);
                }
            }
        }
        // Pending tools that are registered now become active.
        nextActiveToolNames.push(...this._pendingToolNames);
        this._setActiveTools([...new Set(nextActiveToolNames)]);
    }
    /** Whether activating the tool declares it to the model. */
    _isDeclarable(name) {
        const exposure = this._getToolExposure(name);
        return exposure === "direct" || exposure === "model-only";
    }
    /** Whether registering the tool activates it, which declares it to the model. */
    _isActivatedOnRegistration(name) {
        return this._isDeclarable(name) && this._toolDefinitions.get(name)?.definition.defaultActive !== false;
    }
    _buildRuntime(options) {
        const autoResizeImages = this.settingsManager.getImageAutoResize();
        const shellCommandPrefix = this.settingsManager.getShellCommandPrefix();
        const shellPath = this.settingsManager.getShellPath();
        const baseToolDefinitions = this._baseToolsOverride
            ? Object.fromEntries(Object.entries(this._baseToolsOverride).map(([name, tool]) => [
                name,
                createToolDefinitionFromAgentTool(tool),
            ]))
            : createAllToolDefinitions(this._cwd, {
                read: { autoResizeImages },
                bash: { commandPrefix: shellCommandPrefix, shellPath },
            });
        this._baseToolDefinitions = new Map(Object.entries(baseToolDefinitions).map(([name, tool]) => [name, tool]));
        const extensionsResult = this._resourceLoader.getExtensions();
        if (options.flagValues) {
            for (const [name, value] of options.flagValues) {
                extensionsResult.runtime.flagValues.set(name, value);
            }
        }
        this._extensionRunner = new ExtensionRunner(extensionsResult.extensions, extensionsResult.runtime, this._cwd, this.sessionManager, new ModelRegistry(this._modelRuntime));
        if (this._extensionRunnerRef) {
            this._extensionRunnerRef.current = this._extensionRunner;
        }
        this._bindExtensionCore(this._extensionRunner);
        this._applyExtensionBindings(this._extensionRunner);
        const defaultActiveToolNames = this._baseToolsOverride
            ? Object.keys(this._baseToolsOverride)
            : ["read", "bash", "edit", "write"];
        const baseActiveToolNames = options.activeToolNames ?? defaultActiveToolNames;
        this._refreshToolRegistry({
            activeToolNames: baseActiveToolNames,
            includeAllExtensionTools: options.includeAllExtensionTools,
        });
    }
    async reload(options) {
        if (this.isStreaming) {
            throw new Error("Wait for the current response to finish before reloading.");
        }
        if (this.isCompacting) {
            throw new Error("Wait for compaction to finish before reloading.");
        }
        const oldRunner = this._extensionRunner;
        const previousFlagValues = oldRunner.getFlagValues();
        await emitSessionShutdownEvent(oldRunner, { type: "session_shutdown", reason: "reload" });
        oldRunner.invalidate();
        const getDefaultTools = () => this._usesDefaultTools
            ? applyToolModifiers(this.settingsManager.getDefaultTools() ?? DEFAULT_TOOL_NAMES, this._defaultToolModifiers)
            : [];
        const previousDefaultTools = new Set(getDefaultTools());
        await this.settingsManager.reload();
        this.syncQueueModesFromSettings();
        resetApiProviders();
        await this._resourceLoader.reload();
        // Activate tools newly added to defaultTools. Removed ones stay active, and tools disabled
        // during the session stay disabled unless the setting newly adds them.
        const addedDefaultTools = getDefaultTools().filter((name) => !previousDefaultTools.has(name));
        // Tools the new extensions register later, such as MCP tools, are pending until then.
        for (const name of this.getActiveToolNames())
            this._pendingToolNames.add(name);
        this._buildRuntime({
            activeToolNames: [...this.getActiveToolNames(), ...addedDefaultTools],
            flagValues: previousFlagValues,
            includeAllExtensionTools: true,
        });
        const hasBindings = this._extensionUIContext ||
            this._extensionCommandContextActions ||
            this._extensionShutdownHandler ||
            this._extensionErrorListener;
        if (hasBindings) {
            await options?.beforeSessionStart?.();
            await this._extensionRunner.emit({ type: "session_start", reason: "reload" });
            this._extensionRunner.reportUnhandledMcpServers();
            await this.extendResourcesFromExtensions("reload");
        }
    }
    // =========================================================================
    // Auto-Retry
    // =========================================================================
    /**
     * Check if an error is retryable (overloaded, rate limit, server errors).
     * Context overflow errors are NOT retryable (handled by compaction instead).
     */
    _isRetryableError(message) {
        // Context overflow is handled by compaction, not retry.
        if (isContextOverflow(message, (this._modelForMessage(message) ?? this.model)?.contextWindow ?? 0))
            return false;
        return isRetryableAssistantError(message);
    }
    /**
     * Retry policy + callbacks shared by compaction and branch-summary summarization calls.
     * Uses the same `settings.retry` budget/backoff as agent-turn retries so a single transient
     * stream drop no longer fails the whole operation. `source` carries the context
     * the TUI needs to render the retry and recreate the underlying indicator.
     */
    _summarizationRetryCallbacks(source) {
        return {
            onRetryScheduled: (attempt, maxAttempts, delayMs, errorMessage) => {
                this._emit({
                    type: "summarization_retry_scheduled",
                    attempt,
                    maxAttempts,
                    delayMs,
                    errorMessage,
                });
            },
            onRetryAttemptStart: () => {
                this._emit({
                    type: "summarization_retry_attempt_start",
                    ...source,
                });
            },
            onRetryFinished: () => {
                this._emit({ type: "summarization_retry_finished" });
            },
        };
    }
    _finishCancelledRetry() {
        if (this._retryAttempt === 0)
            return;
        const attempt = this._retryAttempt;
        this._retryAttempt = 0;
        this._emit({
            type: "auto_retry_end",
            success: false,
            attempt,
            finalError: "Retry cancelled",
        });
    }
    /**
     * Prepare a retryable error for continuation with exponential backoff.
     * @returns true if the caller should continue the agent, false otherwise
     */
    async _prepareRetry(message) {
        const settings = this.settingsManager.getRetrySettings();
        if (!settings.enabled) {
            return false;
        }
        this._retryAttempt++;
        if (this._retryAttempt > settings.maxRetries) {
            // Preserve the completed attempt count so post-run handling can emit the final failure.
            this._retryAttempt--;
            return false;
        }
        const delayMs = retryDelayMs(settings, this._retryAttempt);
        this._emit({
            type: "auto_retry_start",
            attempt: this._retryAttempt,
            maxAttempts: settings.maxRetries,
            delayMs,
            errorMessage: message.errorMessage || "Unknown error",
        });
        // Keep the failed attempt in raw history while durably omitting it from model projection.
        this._omitRecoveryAttempt(message);
        // Wait with exponential backoff (abortable)
        this._retryAbortController = new AbortController();
        try {
            await sleep(delayMs, this._retryAbortController.signal);
        }
        catch {
            // Aborted during sleep - emit end event so UI can clean up
            this._finishCancelledRetry();
            return false;
        }
        finally {
            this._retryAbortController = undefined;
        }
        return true;
    }
    /**
     * Cancel in-progress retry.
     */
    abortRetry() {
        this._retryAbortController?.abort();
    }
    /** Whether auto-retry is currently in progress */
    get isRetrying() {
        return this._retryAbortController !== undefined;
    }
    /** Whether auto-retry is enabled */
    get autoRetryEnabled() {
        return this.settingsManager.getRetryEnabled();
    }
    /**
     * Toggle auto-retry setting.
     */
    setAutoRetryEnabled(enabled) {
        this.settingsManager.setRetryEnabled(enabled);
    }
    // =========================================================================
    // Bash Execution
    // =========================================================================
    /**
     * Execute a bash command.
     * Adds result to agent context and session.
     * @param command The bash command to execute
     * @param onChunk Optional streaming callback for output
     * @param options.excludeFromContext If true, command output won't be sent to LLM (!! prefix)
     * @param options.id Optional identifier included in bash execution update events
     * @param options.operations Custom BashOperations for remote execution
     */
    async executeBash(command, onChunk, options) {
        const abortController = new AbortController();
        this._bashAbortControllers.add(abortController);
        // Apply command prefix if configured (e.g., "shopt -s expand_aliases" for alias support)
        const prefix = this.settingsManager.getShellCommandPrefix();
        const shellPath = this.settingsManager.getShellPath();
        const resolvedCommand = prefix ? `${prefix}\n${command}` : command;
        try {
            const result = await executeBashWithOperations(resolvedCommand, this.sessionManager.getCwd(), options?.operations ?? createLocalBashOperations({ shellPath }), {
                onChunk: (delta) => {
                    onChunk?.(delta);
                    this._emit({ type: "bash_execution_update", id: options?.id, delta });
                },
                signal: abortController.signal,
            });
            this.recordBashResult(command, result, options);
            return result;
        }
        finally {
            this._bashAbortControllers.delete(abortController);
        }
    }
    /**
     * Record a bash execution result in session history.
     * Used by executeBash and by extensions that handle bash execution themselves.
     */
    recordBashResult(command, result, options) {
        const bashMessage = {
            role: "bashExecution",
            command,
            output: result.output,
            exitCode: result.exitCode,
            cancelled: result.cancelled,
            truncated: result.truncated,
            fullOutputPath: result.fullOutputPath,
            timestamp: Date.now(),
            excludeFromContext: options?.excludeFromContext,
        };
        // If agent is streaming, defer adding to avoid breaking tool_use/tool_result ordering
        if (this.isStreaming) {
            // Queue for later - will be flushed on agent_end
            this._pendingBashMessages.push(bashMessage);
        }
        else {
            this.sessionManager.appendMessage(bashMessage);
            this._refreshFinalizedContext();
        }
    }
    /**
     * Cancel running bash command.
     */
    abortBash() {
        for (const abortController of [...this._bashAbortControllers]) {
            abortController.abort();
        }
    }
    /** Whether a bash command is currently running */
    get isBashRunning() {
        return this._bashAbortControllers.size > 0;
    }
    /** Whether there are pending bash messages waiting to be flushed */
    get hasPendingBashMessages() {
        return this._pendingBashMessages.length > 0;
    }
    /**
     * Flush pending bash messages to agent state and session.
     * Called after agent turn completes to maintain proper message ordering.
     */
    _flushPendingBashMessages() {
        if (this._pendingBashMessages.length === 0)
            return;
        for (const bashMessage of this._pendingBashMessages) {
            this.sessionManager.appendMessage(bashMessage);
        }
        this._pendingBashMessages = [];
        this._refreshFinalizedContext();
    }
    // =========================================================================
    // Session Management
    // =========================================================================
    /**
     * Set a display name for the current session.
     */
    setSessionName(name) {
        this.sessionManager.appendSessionInfo(name);
        const event = { type: "session_info_changed", name: this.sessionManager.getSessionName() };
        this._emit(event);
        void this._extensionRunner.emit(event);
    }
    // =========================================================================
    // Tree Navigation
    // =========================================================================
    /**
     * Navigate to a different node in the session tree.
     * Unlike fork() which creates a new session file, this stays in the same file.
     *
     * @param targetId The entry ID to navigate to
     * @param options.summarize Whether user wants to summarize abandoned branch
     * @param options.customInstructions Custom instructions for summarizer
     * @param options.replaceInstructions If true, customInstructions replaces the default prompt
     * @param options.label Label to attach to the branch summary entry
     * @returns Result with editorText (if user message) and cancelled status
     */
    async navigateTree(targetId, options = {}) {
        if (this.isStreaming || this._promptPreflightCount > 0) {
            throw new Error("Wait for the current response to finish before navigating the session tree.");
        }
        if (this.isCompacting) {
            throw new Error("Wait for the current compaction or tree navigation to finish before navigating the session tree.");
        }
        const oldLeafId = this.sessionManager.getLeafId();
        // No-op if already at target
        if (targetId === oldLeafId) {
            return { cancelled: false };
        }
        // Model required for summarization
        if (options.summarize && !this.model) {
            throw new Error("No model available for summarization");
        }
        const targetEntry = this.sessionManager.getEntry(targetId);
        if (!targetEntry) {
            throw new Error(`Entry ${targetId} not found`);
        }
        // Collect entries to summarize (from old leaf to common ancestor)
        const { entries: entriesToSummarize, commonAncestorId } = collectEntriesForBranchSummary(this.sessionManager, oldLeafId, targetId);
        // Prepare event data - mutable so extensions can override
        let customInstructions = options.customInstructions;
        let replaceInstructions = options.replaceInstructions;
        let label = options.label;
        const preparation = {
            targetId,
            oldLeafId,
            commonAncestorId,
            entriesToSummarize,
            userWantsSummary: options.summarize ?? false,
            customInstructions,
            replaceInstructions,
            label,
        };
        // Set up abort controller for summarization
        const abortController = new AbortController();
        this._branchSummaryAbortController = abortController;
        try {
            let extensionSummary;
            let fromExtension = false;
            // Emit session_before_tree event
            if (this._extensionRunner.hasHandlers("session_before_tree")) {
                const result = (await this._extensionRunner.emit({
                    type: "session_before_tree",
                    preparation,
                    signal: abortController.signal,
                }));
                if (result?.cancel) {
                    return { cancelled: true };
                }
                if (result?.summary && options.summarize) {
                    extensionSummary = result.summary;
                    fromExtension = true;
                }
                // Allow extensions to override instructions and label
                if (result?.customInstructions !== undefined) {
                    customInstructions = result.customInstructions;
                }
                if (result?.replaceInstructions !== undefined) {
                    replaceInstructions = result.replaceInstructions;
                }
                if (result?.label !== undefined) {
                    label = result.label;
                }
            }
            // Run default summarizer if needed
            let summaryText;
            let summaryDetails;
            let summaryUsage;
            if (options.summarize && entriesToSummarize.length > 0 && !extensionSummary) {
                const signal = abortController.signal;
                const branchSummarySettings = this.settingsManager.getBranchSummarySettings();
                const result = await generateBranchSummary(entriesToSummarize, {
                    ...(await this._getSummarizationRequestAuth(this.model, signal)),
                    signal,
                    customInstructions,
                    replaceInstructions,
                    reserveTokens: branchSummarySettings.reserveTokens,
                    streamFn: this.agent.streamFunction,
                    retry: this.settingsManager.getRetrySettings(),
                    callbacks: this._summarizationRetryCallbacks({ source: "branchSummary" }),
                });
                if (result.aborted) {
                    return { cancelled: true, aborted: true };
                }
                if (result.error) {
                    throw new Error(result.error);
                }
                summaryText = result.summary;
                summaryUsage = result.usage;
                summaryDetails = {
                    readFiles: result.readFiles || [],
                    modifiedFiles: result.modifiedFiles || [],
                };
            }
            else if (extensionSummary) {
                summaryText = extensionSummary.summary;
                summaryDetails = extensionSummary.details;
                summaryUsage = extensionSummary.usage;
            }
            // Determine the new leaf position based on target type
            let newLeafId;
            let editorText;
            if (targetEntry.type === "message" && targetEntry.message.role === "user") {
                // User message: leaf = parent (null if root), text goes to editor
                newLeafId = targetEntry.parentId;
                editorText = contentText(targetEntry.message.content, "");
            }
            else if (targetEntry.type === "custom_message") {
                // Custom message: leaf = parent (null if root), text goes to editor
                newLeafId = targetEntry.parentId;
                editorText = contentText(targetEntry.content, "");
            }
            else {
                // Non-user message: leaf = selected node
                newLeafId = targetId;
            }
            // Switch leaf (with or without summary)
            // Summary is attached at the navigation target position (newLeafId), not the old branch
            let summaryEntry;
            if (summaryText) {
                // Create summary at target position (can be null for root)
                const summaryId = this.sessionManager.branchWithSummary(newLeafId, summaryText, summaryDetails, fromExtension, summaryUsage);
                summaryEntry = this.sessionManager.getEntry(summaryId);
                // Attach label to the summary entry
                if (label) {
                    this.sessionManager.appendLabelChange(summaryId, label);
                }
            }
            else if (newLeafId === null) {
                // No summary, navigating to root - reset leaf
                this.sessionManager.resetLeaf();
            }
            else {
                // No summary, navigating to non-root
                this.sessionManager.branch(newLeafId);
            }
            // Attach label to target entry when not summarizing (no summary entry to label)
            if (label && !summaryText) {
                this.sessionManager.appendLabelChange(targetId, label);
            }
            // Update finalized context from the canonical session projection.
            this._refreshFinalizedContext();
            this._restoreToolsFromTranscript();
            // The selected context is installed; session_tree handlers may now prompt or navigate again.
            this._branchSummaryAbortController = undefined;
            await this._extensionRunner.emit({
                type: "session_tree",
                newLeafId: this.sessionManager.getLeafId(),
                oldLeafId,
                summaryEntry,
                fromExtension: summaryText ? fromExtension : undefined,
            });
            // Emit to custom tools
            return { editorText, cancelled: false, summaryEntry };
        }
        finally {
            // A navigation started from session_tree owns the controller now; do not clear it.
            if (this._branchSummaryAbortController === abortController) {
                this._branchSummaryAbortController = undefined;
            }
            this._resolveIdleWaitIfIdle();
        }
    }
    /**
     * Get all user messages from session for fork selector.
     */
    getUserMessagesForForking() {
        const entries = this.sessionManager.getEntries();
        const result = [];
        for (const entry of entries) {
            if (entry.type !== "message")
                continue;
            if (entry.message.role !== "user")
                continue;
            const text = contentText(entry.message.content, "");
            if (text) {
                result.push({ entryId: entry.id, text });
            }
        }
        return result;
    }
    /**
     * Get session statistics. Aggregates over ALL session entries (including
     * history that was compacted away), so token/cost totals reflect what was
     * actually billed across the session.
     */
    getSessionStats() {
        let userMessages = 0;
        let assistantMessages = 0;
        let toolResults = 0;
        let totalMessages = 0;
        let toolCalls = 0;
        const usageTotals = createUsageTotals();
        for (const entry of this.sessionManager.getEntries()) {
            if (entry.type === "usage") {
                addUsageToTotals(usageTotals, entry.usage);
            }
            else if ((entry.type === "branch_summary" || entry.type === "compaction") && entry.usage) {
                addUsageToTotals(usageTotals, entry.usage);
            }
            if (entry.type !== "message")
                continue;
            totalMessages++;
            const message = entry.message;
            if (message.role === "user") {
                userMessages++;
            }
            else if (message.role === "toolResult") {
                toolResults++;
                if (message.usage) {
                    addUsageToTotals(usageTotals, message.usage);
                }
            }
            else if (message.role === "assistant") {
                assistantMessages++;
                const assistantMsg = message;
                if (Array.isArray(assistantMsg.content)) {
                    toolCalls += assistantMsg.content.filter((c) => c.type === "toolCall").length;
                }
                addUsageToTotals(usageTotals, assistantMsg.usage);
            }
        }
        return {
            sessionFile: this.sessionFile,
            sessionId: this.sessionId,
            userMessages,
            assistantMessages,
            toolCalls,
            toolResults,
            totalMessages,
            tokens: {
                input: usageTotals.input,
                output: usageTotals.output,
                cacheRead: usageTotals.cacheRead,
                cacheWrite: usageTotals.cacheWrite,
                total: usageTotals.input + usageTotals.output + usageTotals.cacheRead + usageTotals.cacheWrite,
            },
            cost: usageTotals.cost,
            contextUsage: this.getContextUsage(),
        };
    }
    getContextUsage() {
        const model = this._limitsModel();
        if (!model)
            return undefined;
        const contextWindow = model.contextWindow ?? 0;
        if (contextWindow <= 0)
            return undefined;
        // After compaction, the last assistant usage reflects pre-compaction context size.
        // We can only trust usage from an assistant that responded after the latest compaction.
        // If no such assistant exists, context token count is unknown until the next LLM response.
        const projection = this.sessionManager.buildSessionProjection();
        const branch = this.sessionManager.getBranch();
        const latestCompaction = getLatestCompactionEntry(branch);
        if (latestCompaction) {
            const projectedAssistants = new Set(projection.entries.flatMap((entry) => entry.messages.some((message) => message.role === "assistant" &&
                message.stopReason !== "aborted" &&
                message.stopReason !== "error" &&
                calculateContextTokens(message.usage) > 0)
                ? [entry.sourceEntry.id]
                : []));
            const compactionIndex = branch.findIndex((entry) => entry.id === latestCompaction.id);
            const hasPostCompactionUsage = branch
                .slice(compactionIndex + 1)
                .some((entry) => projectedAssistants.has(entry.id));
            if (!hasPostCompactionUsage)
                return { tokens: null, contextWindow, percent: null };
        }
        const estimate = estimateProjectedContextTokens(projection, branch);
        const percent = (estimate.tokens / contextWindow) * 100;
        return {
            tokens: estimate.tokens,
            contextWindow,
            percent,
        };
    }
    /**
     * Export session to HTML.
     * @param outputPath Optional output path (defaults to session directory)
     * @param options Optional export presentation settings
     * @returns Path to exported file
     */
    async exportToHtml(outputPath, options = {}) {
        const themeName = [options.themeName, this.settingsManager.getTheme()].find((candidate) => candidate !== undefined && getThemeByName(candidate) !== undefined);
        // Create tool renderer if we have an extension runner (for custom tool HTML rendering)
        const toolRenderer = createToolHtmlRenderer({
            getToolRenderers: (name) => this._extensionRunner.resolveToolRenderers(name, () => this.getToolDefinition(name)),
            theme,
            cwd: this.sessionManager.getCwd(),
        });
        return await exportSessionToHtml(this.sessionManager, this.state, {
            outputPath,
            themeName,
            toolRenderer,
        });
    }
    /**
     * Export the current session branch to a JSONL file.
     * Writes the session header followed by all entries on the current branch path.
     * @param outputPath Target file path. If omitted, generates a timestamped file in cwd.
     * @returns The resolved output file path.
     */
    exportToJsonl(outputPath) {
        return exportSessionToJsonl(this.sessionManager, outputPath);
    }
    /**
     * Ask the current model to describe what went wrong in this session for a bug report.
     * Used when the user declines to share the transcript itself.
     */
    async summarizeForBugReport(options) {
        const model = this.model;
        if (!model) {
            throw new Error("No model selected");
        }
        return generateBugReportSummary({
            ...(await this._getSummarizationRequestAuth(model, options.signal)),
            messages: this.messages,
            hint: options.hint,
            signal: options.signal,
            streamFn: this.agent.streamFunction,
            retry: this.settingsManager.getRetrySettings(),
            sessionId: this.sessionId,
        });
    }
    // =========================================================================
    // Utilities
    // =========================================================================
    /**
     * Get text content of last assistant message.
     * Useful for /copy command.
     * @returns Text content, or undefined if no assistant message exists
     */
    getLastAssistantText() {
        const lastAssistant = this.messages
            .slice()
            .reverse()
            .find((m) => {
            if (m.role !== "assistant")
                return false;
            const msg = m;
            // Skip aborted messages with no content
            if (msg.stopReason === "aborted" && msg.content.length === 0)
                return false;
            return true;
        });
        if (!lastAssistant)
            return undefined;
        let text = "";
        for (const content of lastAssistant.content) {
            if (content.type === "text") {
                text += content.text;
            }
        }
        return text.trim() || undefined;
    }
    // =========================================================================
    // Extension System
    // =========================================================================
    createReplacedSessionContext() {
        const context = Object.defineProperties({}, Object.getOwnPropertyDescriptors(this._extensionRunner.createCommandContext()));
        context.sendMessage = (message, options) => this.sendCustomMessage(message, options);
        context.sendUserMessage = (content, options) => this.sendUserMessage(content, options);
        return context;
    }
    /**
     * Check if extensions have handlers for a specific event type.
     */
    hasExtensionHandlers(eventType) {
        return this._extensionRunner.hasHandlers(eventType);
    }
    /**
     * Get the extension runner (for setting UI context and error handlers).
     */
    get extensionRunner() {
        return this._extensionRunner;
    }
}
//# sourceMappingURL=agent-session.js.map