/** * 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 * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { scheduler } from "node:timers/promises"; import { isPromise } from "node:util/types"; import type { InMemorySnapshotStore } from "@oh-my-pi/hashline"; import { type AfterToolCallContext, type AfterToolCallResult, type Agent, AgentBusyError, type AgentEvent, type AgentMessage, type AgentState, type AgentTool, type AgentToolCall, type AgentToolContext, type AgentToolResult, type AgentTurnEndContext, AppendOnlyContextManager, type AsideMessage, resolveTelemetry, type StreamFn, TERMINAL_TOOL_RESULT_ABORT_REASON, type ThinkingLevel, type ToolChoiceDirective, } from "@oh-my-pi/pi-agent-core"; import { type CompactionPreparation, type CompactionResult, calculatePromptTokens, collectEntriesForBranchSummary, estimateTokens, generateBranchSummary, type ShakeConfig, } from "@oh-my-pi/pi-agent-core/compaction"; import type { AssistantMessage, CodexCompactionContext, ImageContent, Message, Model, ModelUsageHealth, ProviderSessionState, ResetCreditAccountStatus, ResetCreditRedeemOutcome, ResetCreditTarget, ServiceTier, ServiceTierByFamily, ServiceTierFamily, SimpleStreamOptions, TextContent, ToolCall, ToolChoice, ToolResultMessage, UsageReport, } from "@oh-my-pi/pi-ai"; import { deriveClaudeDeviceId, type Effort, streamSimple } from "@oh-my-pi/pi-ai"; import * as AIError from "@oh-my-pi/pi-ai/error"; import { resetOpenAICodexHistoryAfterCompaction } from "@oh-my-pi/pi-ai/providers/openai-codex-responses"; import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema"; import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models"; import { MacOSPowerAssertion } from "@oh-my-pi/pi-natives"; import { escapeXmlText, formatDuration, getAgentDbPath, getInstallId, isBunTestRuntime, isEnoent, isInteractiveHost, isRecord, logger, postmortem, prompt, Snowflake, stringProperty, withTimeout, } from "@oh-my-pi/pi-utils"; import type { AdvisorConfig, AdvisorRuntimeStatus } from "../advisor"; import { type AsyncJob, AsyncJobManager } from "../async"; import { shouldEnableAppendOnlyContext } from "../config/append-only-context-mode"; import type { ModelRegistry } from "../config/model-registry"; import { type ResolvedModelRoleValue, resolveModelOverride } from "../config/model-resolver"; import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-templates"; import { buildServiceTierByFamily } from "../config/service-tier"; import type { Settings, SkillsSettings } from "../config/settings"; import { onAppendOnlyModeChanged, onModelRolesChanged } from "../config/settings"; import { RawSseDebugBuffer } from "../debug/raw-sse-buffer"; import { getFileSnapshotStore } from "../edit/file-snapshot-store"; import type { PythonResult } from "../eval/py/executor"; import type { BashResult } from "../exec/bash-executor"; import type { TtsrManager } from "../export/ttsr"; import type { LoadedCustomCommand } from "../extensibility/custom-commands"; import type { CustomTool } from "../extensibility/custom-tools/types"; import type { ExtensionCommandContext, ExtensionRunner, ExtensionUIContext, MessageEndEvent, MessageStartEvent, MessageUpdateEvent, SessionBeforeBranchResult, SessionBeforeSwitchResult, SessionBeforeTreeResult, SessionStopEventResult, ToolExecutionEndEvent, ToolExecutionStartEvent, ToolExecutionUpdateEvent, TreePreparation, TurnEndEvent, TurnStartEvent, } from "../extensibility/extensions"; import { emitSessionShutdownEvent } from "../extensibility/extensions"; import { ManagedTimers } from "../extensibility/extensions/managed-timers"; import { createExtensionModelQuery } from "../extensibility/extensions/model-api"; import type { CompactOptions, ContextUsage } from "../extensibility/extensions/types"; import type { HookCommandContext } from "../extensibility/hooks/types"; import type { Skill, SkillWarning } from "../extensibility/skills"; import { expandSlashCommand, type FileSlashCommand } from "../extensibility/slash-commands"; import { GoalRuntime } from "../goals/runtime"; import type { GoalModeState } from "../goals/state"; import type { HindsightSessionState } from "../hindsight/state"; import { type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls"; import type { IrcMessage } from "../irc/bus"; import { shutdownMnemopiEmbedClient } from "../mnemopi/embed-client"; import { getMnemopiSessionState, type MnemopiSessionState, setMnemopiSessionState } from "../mnemopi/state"; import { containsOrchestrate, ORCHESTRATE_NOTICE } from "../modes/orchestrate"; import { theme } from "../modes/theme/theme"; import { parseTurnBudget } from "../modes/turn-budget"; import { containsUltrathink, ULTRATHINK_NOTICE } from "../modes/ultrathink"; import { computeNonMessageTokens } from "../modes/utils/context-usage"; import { containsWorkflow, renderWorkflowNotice } from "../modes/workflow"; import { type PlanApprovalDetails, resolveApprovedPlan } from "../plan-mode/approved-plan"; import { listPlanFiles, readPlanFile } from "../plan-mode/plan-files"; import type { PlanModeState } from "../plan-mode/state"; import goalModeContextPrompt from "../prompts/goals/goal-mode-context.md" with { type: "text" }; import goalTodoContextPrompt from "../prompts/goals/goal-todo-context.md" with { type: "text" }; import autoContinuePrompt from "../prompts/system/auto-continue.md" with { type: "text" }; import interruptedThinkingTemplate from "../prompts/system/interrupted-thinking.md" with { type: "text" }; import planModeActivePrompt from "../prompts/system/plan-mode-active.md" with { type: "text" }; import planModeReferencePrompt from "../prompts/system/plan-mode-reference.md" with { type: "text" }; import planModeToolDecisionReminderPrompt from "../prompts/system/plan-mode-tool-decision-reminder.md" with { type: "text", }; import rewindReportTemplate from "../prompts/system/rewind-report.md" with { type: "text" }; import sideChannelNoToolsReminder from "../prompts/system/side-channel-no-tools.md" with { type: "text" }; import vibeModeActivePrompt from "../prompts/system/vibe-mode-active.md" with { type: "text" }; import { deobfuscateAssistantContent, deobfuscateSessionContext, deobfuscateToolArguments, obfuscateProviderContext, type SecretObfuscator, } from "../secrets/obfuscator"; import { AUTO_THINKING, type ConfiguredThinkingLevel, parseConfiguredThinkingLevel, shouldDisableReasoning, toReasoningEffort, } from "../thinking"; import { shutdownTinyTitleClient } from "../tiny/title-client"; import { type AskToolDetails, type AskToolInput, recoverAskQuestions } from "../tools/ask"; import { releaseTabsForOwner } from "../tools/browser/tab-supervisor"; import type { CheckpointState, CompletedRewindState } from "../tools/checkpoint"; import { releaseComputerSessionsForOwner } from "../tools/computer/supervisor"; import { normalizeLocalScheme, resolveToCwd } from "../tools/path-utils"; import { buildResolveReminderMessage, isPreviewResolutionToolCall, isProposeToolCall, type PlanProposalHandler, PROPOSE_DEVICE_NAME, writeDeviceDispatch, } from "../tools/resolve"; import type { TodoPhase } from "../tools/todo"; import { ToolError } from "../tools/tool-errors"; import { parseCommandArgs } from "../utils/command-args"; import type { EditMode } from "../utils/edit-mode"; import { resolveFileDisplayMode } from "../utils/file-display-mode"; import { extractFileMentions, generateFileMentionMessages } from "../utils/file-mentions"; import { normalizeModelContextImages } from "../utils/image-loading"; import { generateSessionTitle } from "../utils/title-generator"; import { buildNamedToolChoice, isToolChoiceActive } from "../utils/tool-choice"; import type { VibeModeState } from "../vibe/state"; import type { AgentSessionEvent, AgentSessionEventListener } from "./agent-session-events"; import type { AgentSessionConfig, AgentSessionDisposeOptions, AsyncJobSnapshot, CommandMetadataChangedListener, ContextUsageBreakdown, FollowUpOptions, FreshSessionResult, HandoffResult, ModelCycleResult, Prewalk, PromptOptions, ResolvedRoleModel, RestoredQueuedMessage, RoleModelCycle, RoleModelCycleResult, SessionHandoffOptions, SessionOAuthAccountList, SessionStats, UsageFallbackConfirmation, } from "./agent-session-types"; import { ASYNC_INLINE_RESULT_MAX_CHARS, ASYNC_PREVIEW_MAX_CHARS, ASYNC_RESULT_MESSAGE_TYPE, type AsyncResultEntry, buildAsyncResultBatchMessage, } from "./async-job-delivery"; import type { AuthStorage } from "./auth-storage"; import { BashRunner, type BashRunnerHost } from "./bash-runner"; import { checkpointStartedAtFromEntry, completedRewindFromEntry, isSuccessfulCheckpointEntry, semanticToolResult, } from "./checkpoint-entries"; import type { ClientBridge } from "./client-bridge"; import { type CodexAutoRedeemRedeemDecision, defaultCodexAutoRedeemCoordinator, evaluateCodexAutoRedeem, shouldEvaluateCodexAutoRedeem, shouldPromptCodexAutoRedeem, } from "./codex-auto-reset"; import { EvalRunner, type EvalRunnerHost } from "./eval-runner"; import { collectPendingToolCalls, createInterruptedTurnAbortMessage, SESSION_EXIT_CUSTOM_TYPE, type SessionExitData, summarizeToolArguments, TOOL_EXECUTION_START_CUSTOM_TYPE, type ToolExecutionStartData, } from "./exit-diagnostics"; import { IrcBridge, type IrcBridgeHost } from "./irc-bridge"; import { type BashExecutionMessage, buildReplanTitleContext, type CustomMessage, type CustomMessagePayload, convertToLlm, dedupeEphemeralReply, demoteInterruptedThinking, didSessionMessagesChange, type FileMentionMessage, type HookMessage, INTERRUPTED_THINKING_MESSAGE_TYPE, type InterruptedThinkingDetails, isEmptyErrorTurn, isUserInterruptAbort, logProviderTurnError, normalizeCustomMessagePayload, type PythonExecutionMessage, SILENT_ABORT_MARKER, SKILL_PROMPT_MESSAGE_TYPE, sanitizeAssistantForReparentedHistory, USER_INTERRUPT_LABEL, } from "./messages"; import { ModelControls, type ModelControlsHost } from "./model-controls"; import { PrewalkCoordinator, type PrewalkCoordinatorHost } from "./prewalk"; import { isAdvisorCard, isDisplayableQueuedMessage, isHiddenUserCompanion, isUserQueuedMessage, queueChipText, toRestoredQueuedMessage, } from "./queued-messages"; import { formatRetryFallbackSelector, type RetryFallbackSelector } from "./retry-fallback-chains"; import { type AdvisorStats, SessionAdvisors, type SessionAdvisorsHost } from "./session-advisors"; import type { BuildSessionContextOptions, SessionContext } from "./session-context"; import { getRestorableSessionModels } from "./session-context"; import { formatSessionDumpText } from "./session-dump-format"; import type { BranchSummaryEntry, NewSessionOptions } from "./session-entries"; import { SessionHandoff, type SessionHandoffHost } from "./session-handoff"; import { COMPACTION_CHECK_NONE, createCodexCompactionContext as createMaintenanceCodexCompactionContext, SessionMaintenance, type SessionMaintenanceHost, } from "./session-maintenance"; import { cleanupEmptyMoveSession, type SessionManager } from "./session-manager"; import { SessionMemory, type SessionMemoryHost } from "./session-memory"; import { SessionProviderBoundary, type SessionProviderBoundaryHost } from "./session-provider-boundary"; import { SessionStatsTracker, type SessionStatsTrackerHost } from "./session-stats"; import { SessionTools, type SessionToolsHost } from "./session-tools"; import type { ShakeMode, ShakeResult } from "./shake-types"; import { ToolChoiceQueue } from "./tool-choice-queue"; import { planTurnPersistence, sameMessageContent, sessionMessagePersistenceKey } from "./turn-persistence"; import { TurnRecovery, type TurnRecoveryHost } from "./turn-recovery"; import { YieldQueue } from "./yield-queue"; export * from "./agent-session-events"; export * from "./agent-session-types"; export type { AdvisorStats, PerAdvisorStat } from "./session-advisors"; const SESSION_STOP_CONTINUATION_CAP = 8; import { LoopGuards, type StreamGuardsHost, StreamingEditGuard } from "./stream-guards"; import { TodoTracker, type TodoTrackerHost } from "./todo-tracker"; import { TtsrCoordinator, type TtsrCoordinatorHost } from "./ttsr-coordinator"; const PLAN_MODE_REMINDER_MAX = 3; /** Internal marker for hook messages queued through the agent loop */ // ============================================================================ // Constants // ============================================================================ /** Standard thinking levels */ /** * Build the per-request `metadata` payload for the Anthropic provider, shaped * like real Claude Code's `getAPIMetadata` output (`{ session_id, account_uuid, * device_id }`) so the backend buckets requests under one session and attributes * them to the authenticated OAuth account when available. Resolved at request * time so token refreshes and login/logout transitions don't strand a stale * account UUID in memory. `account_uuid` and `device_id` are omitted for * non-Anthropic providers to avoid leaking the user's Claude identity to * third-party APIs (including Anthropic-format-compatible proxies such as * cloudflare-ai-gateway or gitlab-duo). * * `provider` is the target provider string (e.g. `"anthropic"`) and gates the * `account_uuid` and `device_id` lookups — only `"anthropic"` requests carry them. * * `sessionId` is forwarded to the auth-storage session-sticky lookup so that * multi-credential setups attribute to the same OAuth account used for the * actual API request rather than always picking the first credential. * * `authStorage` is treated as optional so test fixtures that stub `modelRegistry` * without a real storage layer still work; the resolver simply skips the lookup * and emits `{ session_id }` alone, matching the no-OAuth-credential path. */ function buildSessionMetadata( sessionId: string, provider: string, authStorage: AuthStorage | undefined, ): Record { const userId: Record = { session_id: sessionId }; // Only look up account_uuid when the request is going to Anthropic. Injecting // a Claude OAuth account_uuid into requests bound for other providers (including // Anthropic-format-compatible proxies like cloudflare-ai-gateway or gitlab-duo) // would leak the user's Anthropic identity to unrelated third-party APIs. if (provider === "anthropic") { const accountUuid = authStorage?.getOAuthAccountId("anthropic", sessionId); if (typeof accountUuid === "string" && accountUuid.length > 0) { userId.account_uuid = accountUuid; // Claude Code's `device_id` is a stable 64-hex account-scoped install // identifier. Include both omp's persistent install id and the Claude // account UUID so two accounts on the same install do not share a device. userId.device_id = deriveClaudeDeviceId(getInstallId(), accountUuid); } } return { user_id: JSON.stringify(userId) }; } const noOpUIContext: ExtensionUIContext = { select: async (_title, _options, _dialogOptions) => undefined, confirm: async (_title, _message, _dialogOptions) => false, input: async (_title, _placeholder, _dialogOptions) => undefined, notify: () => {}, onTerminalInput: () => () => {}, setStatus: () => {}, setWorkingMessage: () => {}, setWidget: () => {}, setTitle: () => {}, custom: async () => undefined as never, setEditorText: () => {}, pasteToEditor: () => {}, getEditorText: () => "", editor: async () => undefined, addAutocompleteProvider: () => {}, get theme() { return theme; }, getAllThemes: () => Promise.resolve([]), getTheme: () => Promise.resolve(undefined), setTheme: _theme => Promise.resolve({ success: false, error: "UI not available" }), setFooter: () => {}, setHeader: () => {}, setEditorComponent: () => {}, getToolsExpanded: () => false, setToolsExpanded: () => {}, }; // ============================================================================ // AgentSession Class // ============================================================================ type MessageEndPersistenceSlot = { readonly promise: Promise; persist: (persistMessage: () => void) => Promise; release: () => void; }; type PostPromptSkipReason = "aborted" | "stale-generation"; type AgentContinueSkipReason = | PostPromptSkipReason | "session-unavailable" | "should-continue-false" | "post-restore-unavailable"; type ScheduledAgentContinueOptions = { delayMs?: number; generation?: number; shouldContinue?: () => boolean; onSkip?: (reason: AgentContinueSkipReason) => void; onError?: () => void; }; type SessionTitleSource = "auto" | "user"; type SessionNameTrigger = "replan"; type SetSessionNameWithTrigger = ( name: string, source?: SessionTitleSource, trigger?: SessionNameTrigger, ) => Promise; export class AgentSession { readonly agent: Agent; readonly sessionManager: SessionManager; readonly settings: Settings; /** Entries of tools mounted under `xd://`; empty when virtual devices are unmounted. */ getXdevToolEntries: () => Array<{ name: string; summary: string }>; readonly yieldQueue: YieldQueue; fileSnapshotStore?: InMemorySnapshotStore; #powerAssertion: MacOSPowerAssertion | undefined; readonly configWarnings: string[] = []; readonly #models: ModelControls; readonly #tools: SessionTools; readonly #prewalk: PrewalkCoordinator; readonly #providerBoundary: SessionProviderBoundary; #promptTemplates: PromptTemplate[]; #slashCommands: FileSlashCommand[]; // Event subscription state #unsubscribeAgent?: () => void; #cancelExitRecorder?: () => void; #exitRecorded = false; #unsubscribeAppendOnly?: () => void; #unsubscribeModelRoles?: () => void; /** Last (enable, providerId) tuple resolved by `#syncAppendOnlyContext` — used to skip no-op invalidations. */ #lastAppendOnlyResolution?: { enable: boolean; providerId: string | undefined }; #eventListeners: AgentSessionEventListener[] = []; #commandMetadataChangedListeners: CommandMetadataChangedListener[] = []; /** Messages queued to be included with the next user prompt as context ("asides"). */ #pendingNextTurnMessages: CustomMessage[] = []; #scheduledHiddenNextTurnGeneration: number | undefined = undefined; #queuedMessageDrainScheduled = false; #planModeState: PlanModeState | undefined; #vibeModeState: VibeModeState | undefined; #goalModeState: GoalModeState | undefined; #goalRuntime: GoalRuntime; readonly #advisors: SessionAdvisors; #goalTurnCounter = 0; #planReferenceSent = false; #planReferencePath = "local://PLAN.md"; #clientBridge: ClientBridge | undefined; #allowAcpAgentInitiatedTurns = false; /** Session file created by this session's `/move`; removed on dispose if it stayed empty. */ #movedFromEmptySessionFile?: string; readonly #maintenance: SessionMaintenance; // Branch summarization state #branchSummaryAbortController: AbortController | undefined = undefined; readonly #handoff: SessionHandoff; // Retry state readonly #recovery: TurnRecovery; #planModeReminderCount = 0; #planModeReminderAwaitingProgress = false; readonly #todo: TodoTracker; #replanTitleRefreshInFlight: Promise | undefined = undefined; /** Resolved TITLE_SYSTEM.md override applied to every automatic session-title * generation path. Refresh via {@link AgentSession.setTitleSystemPrompt} when * the session cwd changes. */ #titleSystemPrompt: string | undefined; #titleGenerationAbortController = new AbortController(); #toolChoiceQueue = new ToolChoiceQueue(); readonly #bash: BashRunner; readonly #eval: EvalRunner; /** * AsyncJobManager owned by this session (top-level only). Subagents leave * this undefined and **MUST NOT** dispose the global instance on teardown. */ readonly #ownedAsyncJobManager: AsyncJobManager | undefined; /** * AsyncJobManager scoped to this session for introspection/cancellation. * * This differs from `#ownedAsyncJobManager`: subagents can inherit a parent * manager for their own owner id, while secondary top-level sessions are left * undefined to avoid reading the primary's jobs. */ readonly #asyncJobManager: AsyncJobManager | undefined; /** Clears this session's owner delivery sink registration; set when a manager + agent id exist. */ #unregisterAsyncDeliverySink: (() => void) | undefined; readonly #irc: IrcBridge; // Agent identity (registry id) used for IRC routing and job ownership. #agentId: string | undefined; #agentKind: "main" | "sub" = "main"; #providerSessionId: string | undefined; #freshProviderSessionId: string | undefined; #inheritedProviderPromptCacheKey: string | undefined; #autolearnCaptureAbortController: AbortController | undefined; #autolearnCaptureTask: Promise | undefined; #isDisposed = false; // Extension system #extensionRunner: ExtensionRunner | undefined = undefined; /** * Backs `ctx.setInterval`/`setTimeout`/`clearTimer` for the runner-less * command-context fallback (SDK embeddings with no extension runner). Lazily * created; cleared on dispose alongside the runner's own timers (#5664). */ #fallbackExtensionTimers: ManagedTimers | undefined = undefined; #turnIndex = 0; #messageEndPersistenceTail: Promise = Promise.resolve(); #pendingMessageEndPersistence = new Map>(); #persistedMessageKeys: { anchor: string; keys: Set } | undefined; // Custom commands (TypeScript slash commands) #customCommands: LoadedCustomCommand[] = []; /** MCP prompt commands (updated dynamically when prompts are loaded) */ #mcpPromptCommands: LoadedCustomCommand[] = []; // Model registry for API key resolution #modelRegistry: ModelRegistry; #usageFallbackConfirmer: ((confirmation: UsageFallbackConfirmation) => Promise) | undefined; #usageReserveApprovedSelector: string | undefined; #usagePreflightAbortControllers = new Set(); #transformContext: (messages: AgentMessage[], signal?: AbortSignal) => AgentMessage[] | Promise; #onPayload: SimpleStreamOptions["onPayload"] | undefined; #onResponse: SimpleStreamOptions["onResponse"] | undefined; #onSseEvent: SimpleStreamOptions["onSseEvent"] | undefined; #sideStreamFn: StreamFn; #preferWebsockets: boolean | undefined; #convertToLlm: (messages: AgentMessage[]) => Message[] | Promise; #disconnectOwnedMcpManager: (() => Promise) | undefined; readonly #ttsr: TtsrCoordinator; readonly #stats: SessionStatsTracker; /** One-shot flag for expected internal plan-mode aborts. Approval actions may * abort the post-approval continuation before compaction, execution, or * manual refinement. Consumed inside `#handleAgentEvent` for the matching * `message_end` + `stopReason: "aborted"`; callers clear it in `finally` so * it cannot leak into later unrelated aborts. */ #planInternalAbortPending = false; #pendingAbortErrorId?: number; #postPromptTasks = new Set>(); #postPromptTasksPromise: Promise | undefined = undefined; #postPromptTasksResolve: (() => void) | undefined = undefined; #postPromptTasksAbortController = new AbortController(); readonly #streamingEditGuard: StreamingEditGuard; readonly #loopGuards: LoopGuards; #promptInFlightCount = 0; #abortInProgress = false; // Wire-level agent_end emission deferred until #promptInFlightCount drops to 0. // Internal extension hooks and post-emit work (auto-retry, auto-compaction, todo // checks in #handleAgentEvent) still fire on the original schedule — only the // `#emit(event)` that reaches external subscribers (rpc-mode stdout, ACP bridge, // Cursor exec, TUI listeners) is held back. Without this, a client that resumes // on `agent_end` can fire its next `prompt` before #promptWithMessage's finally #promptGeneration = 0; #pendingAgentEndEmit: AgentSessionEvent | undefined; #sessionStopContinuationCount = 0; #sessionStopHookActive = false; #obfuscator: SecretObfuscator | undefined; /** Session-start value of `inlineToolDescriptors`; drives handoff tool pruning. */ #pruneToolDescriptions = false; #checkpointState: CheckpointState | undefined = undefined; #pendingRewindReport: string | undefined = undefined; #lastCompletedRewind: CompletedRewindState | undefined = undefined; #rewoundToolResultIds = new Set(); #lastSuccessfulYieldToolCallId: string | undefined = undefined; /** * Sticky across an in-flight prompt run: a successful `yield` makes the run * terminal for execution purposes, so any trailing empty/aborted assistant * stop must NOT trigger empty-stop/unexpected-stop/compaction continuations. * Cleared before every new prompt turn so the next turn evaluates cleanly. */ #yieldTerminationPending = false; #synchronouslyTerminatedYieldToolCallIds = new Set(); #providerSessionState = new Map(); #hindsightSessionState: HindsightSessionState | undefined = undefined; readonly #memory: SessionMemory; readonly rawSseDebugBuffer: RawSseDebugBuffer; #resetPromptMaintenanceState(): void { this.#recovery.resetForNewPrompt(); this.#yieldTerminationPending = false; } #acquirePowerAssertion(): void { if (process.platform !== "darwin") return; if (isBunTestRuntime()) return; if (this.#powerAssertion) return; const mode = this.settings.get("power.sleepPrevention"); if (mode === "off") return; try { this.#powerAssertion = MacOSPowerAssertion.start({ reason: "Oh My Pi agent session", idle: true, display: mode === "display" || mode === "system", system: mode === "system", user: mode === "system", }); } catch (error) { logger.warn("Failed to acquire macOS power assertion", { error: String(error) }); } } #releasePowerAssertion(): void { const assertion = this.#powerAssertion; this.#powerAssertion = undefined; if (!assertion) return; try { assertion.stop(); } catch (error) { logger.warn("Failed to release macOS power assertion", { error: String(error) }); } } #beginInFlight(): void { this.#promptInFlightCount++; if (this.#promptInFlightCount === 1) { this.#acquirePowerAssertion(); } } #endInFlight(): void { this.#promptInFlightCount = Math.max(0, this.#promptInFlightCount - 1); if (this.#promptInFlightCount === 0) { this.#releasePowerAssertion(); this.#flushPendingAgentEnd(); this.#drainStrandedQueuedMessages(); } } /** A steer/follow-up can land after the agent loop's final queue poll, or * after an abort stops an auto-continued queued turn. In both cases the * agent-core queue still owns the message, but no loop is left to poll it. * Runs whenever the session settles; the guard makes it a no-op when the * queue was consumed normally or a new turn already started. */ #drainStrandedQueuedMessages(): void { if (this.#abortInProgress) return; // Session transitions (newSession/`/new`, compact, model-switch, session-switch, // dispose) call #disconnectFromAgent() BEFORE `await abort()`, so abort's own // finally lands here with no listener attached. Auto-resuming now would snapshot // the still-old context (the transition hasn't reached agent.reset() yet), start a // stale provider turn that races the reset, and — once reconnected — append its // output to the fresh session (issue #5800). A disconnected session never owns the // queue: the transition does. newSession/switchSession drop the queue (reset / // clearAllQueues), so nothing survives; compaction preserves it and re-drains itself // after #reconnectToAgent (see compact()'s finally); an explicit prompt flushes it // in every case. if (this.#unsubscribeAgent === undefined) return; // A concern steered into a resumed streaming run after a user interrupt can // strand at the turn tail (steered past the loop's final boundary poll). While // that interrupt's suppression is still in effect, reclaim such advisor steers // as visible advice once idle — mirroring abort's #extractQueuedAdvisorCards — // so they neither auto-resume the run the user stopped (a non-empty steer queue // otherwise bypasses the latch in #canAutoContinueForFollowUp) nor linger to // flush at the next prompt. Real user steers/follow-ups are left untouched. if (this.#advisors.autoResumeSuppressed && !this.isStreaming) { for (const card of this.#extractQueuedAdvisorCards()) { this.#preserveAdvisorCard(card); } } this.#scheduleQueuedMessageDrain(); this.#resumeStrandedIrcAsides(); } /** IRC records that arrive after the loop's final aside poll — or while an abort skipped that * poll — land in pending IRC queues with no loop left to drain them; the queued-message drain's * gate (agent.hasQueuedMessages()) does not count peer IRC interrupts. Once idle, wake a turn so * the agent responds to the peer. Skip only when a queued steer/follow-up will itself drive a * resume turn whose aside poll already consumes these (no double-wake). */ #resumeStrandedIrcAsides(): void { if (this.#isDisposed || this.isStreaming || !this.#irc.hasPending()) return; if (this.#canAutoContinueForFollowUp() && this.agent.hasQueuedMessages()) return; const records = this.#irc.drainPending(); if (this.#planModeState?.enabled) { // Plan mode: fold stranded IRC asides into context without waking an // autonomous turn. Convergence to ask/resolve stays user-driven. for (const record of records) { this.agent.appendMessage(record); this.sessionManager.appendCustomMessageEntry( record.customType, record.content, record.display, record.details, record.attribution ?? "agent", ); } return; } this.#wakeForIrc(records); } /** Fire-and-forget wake turn for incoming IRC — idle delivery and stranded-aside resume both * route here. Wrapped in #beginInFlight/#endInFlight so the turn is tracked and its settle * re-drains anything that stranded during it. A user interrupt may have intentionally left a * follow-up queued behind an invalid tail (seam #5); the wake turn's loop would otherwise drain * it, so park the follow-up queue across the wake and restore it after. It stays queued post-wake * because #canAutoContinueForFollowUp suppresses follow-up auto-resume while a user interrupt is * in effect, even though the wake left a provider-valid tail. */ #wakeForIrc(records: CustomMessage[]): void { // Park only a *blocked* follow-up (one a user interrupt is intentionally holding); an // already-resumable follow-up can ride the wake turn normally without reordering. const parkedFollowUps = this.agent.peekSteeringQueue().length === 0 && this.agent.peekFollowUpQueue().length > 0 && !this.#canAutoContinueForFollowUp() ? [...this.agent.peekFollowUpQueue()] : []; if (parkedFollowUps.length > 0) { this.agent.replaceQueues([...this.agent.peekSteeringQueue()], []); } this.#resetPromptMaintenanceState(); this.#beginInFlight(); void this.agent .prompt(records) .catch(error => { logger.warn("IRC wake turn failed", { error: String(error) }); }) .finally(() => { if (parkedFollowUps.length > 0) { this.agent.replaceQueues( [...this.agent.peekSteeringQueue()], [...parkedFollowUps, ...this.agent.peekFollowUpQueue()], ); } this.#endInFlight(); }); } /** Remove advisor concern/blocker cards from the agent-core steer/follow-up * queues and return them. Used on a deliberate user interrupt so the post-abort * stranded-message drain cannot auto-resume the run on an advisor card that was * steered in just before the user stopped; real user follow-ups stay queued. * Synchronous and await-free so it runs before the abort path polls the queue. */ #extractQueuedAdvisorCards(): CustomMessage[] { const steering = this.agent.peekSteeringQueue(); const followUp = this.agent.peekFollowUpQueue(); const cards = [...steering, ...followUp].filter(isAdvisorCard); if (cards.length === 0) return []; this.agent.replaceQueues( steering.filter(m => !isAdvisorCard(m)), followUp.filter(m => !isAdvisorCard(m)), ); return cards; } /** Record a suppressed advisor concern as visible, persisted advice without * triggering a turn. When the agent is idle (the normal post-interrupt case, * including the post-prompt unwind window where the core loop has ended), emit * message_start/message_end like #flushPendingIrcAsides so #handleAgentEvent * renders it live (TUI/ACP) and persists it as a CustomMessageEntry. Only while * an abort is still tearing a live turn down do we park it hidden, so abort's * settle step replays it once idle — never appended into a live streamMessage. */ #preserveAdvisorCard(card: CustomMessage): void { if (this.#abortInProgress && this.isStreaming) { this.#pendingNextTurnMessages.push(card); return; } this.agent.emitExternalEvent({ type: "message_start", message: card }); this.agent.emitExternalEvent({ type: "message_end", message: card }); } #resetInFlight(): void { this.#promptInFlightCount = 0; this.#releasePowerAssertion(); this.#flushPendingAgentEnd(); this.#drainStrandedQueuedMessages(); } #flushPendingAgentEnd(): void { const pending = this.#pendingAgentEndEmit; if (!pending) return; this.#pendingAgentEndEmit = undefined; this.#emit(pending); } /** * Arm prewalk outside the normal startup path so an explicit slash command starts immediately. */ armPrewalk(target: Model, thinkingLevel?: ConfiguredThinkingLevel): void { this.#prewalk.arm(target, thinkingLevel); } /** Validate the active plan artifact and shape an `xd://propose` result for review-mode hosts. */ async preparePlanForReview(title: string): Promise> { const state = this.getPlanModeState(); if (!state?.enabled) { throw new ToolError("Plan mode is not active."); } const { planFilePath, title: resolvedTitle } = await resolveApprovedPlan({ suppliedTitle: title, statePlanFilePath: state.planFilePath, readPlan: url => this.#readPlanFile(url), listPlanFiles: () => this.#listPlanFiles(), }); return { content: [{ type: "text", text: "Plan ready for review." }], details: { planFilePath, title: resolvedTitle, planExists: true }, }; } async #readPlanFile(planFilePath: string): Promise { return readPlanFile(planFilePath, { localProtocolOptions: this.#localProtocolOptions(), cwd: this.sessionManager.getCwd(), }); } /** `local://` URLs of plan files in the session-local root, newest first — * a fallback for `resolveApprovedPlan` when the agent dropped `extra.title`. */ async #listPlanFiles(): Promise { return listPlanFiles({ localProtocolOptions: this.#localProtocolOptions() }); } constructor(config: AgentSessionConfig) { this.agent = config.agent; this.sessionManager = config.sessionManager; this.settings = config.settings; this.#modelRegistry = config.modelRegistry; const bashHost: BashRunnerHost = { agent: this.agent, sessionManager: this.sessionManager, settings: this.settings, extensionRunner: () => this.#extensionRunner, isStreaming: () => this.isStreaming, }; this.#bash = new BashRunner(bashHost); // Power assertions are taken per turn (see #beginInFlight); nothing acquired here. const evalHost: EvalRunnerHost = { agent: this.agent, sessionManager: this.sessionManager, settings: this.settings, extensionRunner: () => this.#extensionRunner, isStreaming: () => this.isStreaming, appendSessionMessage: message => { this.agent.appendMessage(message); this.sessionManager.appendMessage(message); }, }; this.#eval = new EvalRunner(evalHost, { kernelOwnerId: config.evalKernelOwnerId ?? `agent-session:${Snowflake.next()}`, parentSessionId: config.parentEvalSessionId, }); const ircHost: IrcBridgeHost = { agent: this.agent, sessionManager: this.sessionManager, settings: this.settings, isDisposed: () => this.#isDisposed, isStreaming: () => this.isStreaming, planModeEnabled: () => this.#planModeState?.enabled === true, emitSessionEvent: event => this.#emitSessionEvent(event), wakeForIrc: records => this.#wakeForIrc(records), runEphemeralTurn: args => this.runEphemeralTurn(args), }; this.#irc = new IrcBridge(ircHost); const prewalkHost: PrewalkCoordinatorHost = { agent: this.agent, sessionManager: this.sessionManager, model: () => this.model, emitNotice: (level, message, source) => this.emitNotice(level, message, source), setModelTemporary: (model, thinkingLevel, options) => this.setModelTemporary(model, thinkingLevel, options), setActiveToolsByName: names => this.setActiveToolsByName(names), getActiveToolNames: () => this.getActiveToolNames(), getEnabledToolNames: () => this.getEnabledToolNames(), hasBuiltInTool: name => this.hasBuiltInTool(name), getPlanModeState: () => this.getPlanModeState(), setPlanModeState: state => this.setPlanModeState(state), getPlanReferencePath: () => this.getPlanReferencePath(), setPlanProposalHandler: handler => this.setPlanProposalHandler(handler), waitForSessionMessagePersistence: message => this.#waitForSessionMessagePersistence(message), localProtocolOptions: () => this.#localProtocolOptions(), }; this.#prewalk = new PrewalkCoordinator(prewalkHost, { prewalk: config.prewalk, planYolo: config.planYolo, }); const todoHost: TodoTrackerHost = { agent: this.agent, sessionManager: this.sessionManager, settings: this.settings, model: () => this.model, agentKind: () => this.#agentKind, emitSessionEvent: event => this.#emitSessionEvent(event), scheduleAgentContinue: options => this.#scheduleAgentContinue(options), promptGeneration: () => this.#promptGeneration, hasPendingAsyncWake: () => this.#hasPendingAsyncWake(), getActiveToolNames: () => this.getActiveToolNames(), toolRegistry: () => this.#tools.registry, planModeEnabled: () => this.#planModeState?.enabled === true, consumeLastServedToolChoiceLabel: () => this.#toolChoiceQueue.consumeLastServedLabel(), }; this.#todo = new TodoTracker(todoHost); this.#ownedAsyncJobManager = config.ownedAsyncJobManager; this.#asyncJobManager = config.asyncJobManager ?? config.ownedAsyncJobManager; const modelControlsHost: ModelControlsHost = { agent: this.agent, settings: this.settings, modelRegistry: this.#modelRegistry, sessionManager: this.sessionManager, providerSessionState: this.#providerSessionState, model: () => this.model, sessionId: () => this.sessionId, promptGeneration: () => this.#promptGeneration, resolveActiveEditMode: () => this.#tools.resolveActiveEditMode(), syncAfterModelChange: previousEditMode => this.#tools.syncAfterModelChange(previousEditMode), setModelWithProviderSessionReset: model => this.#setModelWithProviderSessionReset(model), clearActiveRetryFallback: () => this.#recovery.clearActiveRetryFallback(), clearInheritedProviderPromptCacheKey: () => this.#clearInheritedProviderPromptCacheKey(), magicKeywordEnabled: keyword => this.#magicKeywordEnabled(keyword), emit: event => this.#emit(event), emitSessionEvent: event => this.#emitSessionEvent(event), emitNotice: (level, message, source) => this.emitNotice(level, message, source), }; this.#models = new ModelControls(modelControlsHost, { scopedModels: config.scopedModels, thinkingLevel: config.thinkingLevel, serviceTierByFamily: config.serviceTierByFamily, }); this.#promptTemplates = config.promptTemplates ?? []; this.#slashCommands = config.slashCommands ?? []; this.#extensionRunner = config.extensionRunner; this.#customCommands = config.customCommands ?? []; const recoveryHost: TurnRecoveryHost = { agent: this.agent, sessionManager: this.sessionManager, settings: this.settings, modelRegistry: this.#modelRegistry, configWarnings: this.configWarnings, model: () => this.model, thinkingLevel: () => this.thinkingLevel, configuredThinkingLevel: () => this.configuredThinkingLevel(), setThinkingLevel: level => this.setThinkingLevel(level), isDisposed: () => this.#isDisposed, isStreaming: () => this.isStreaming, isCompacting: () => this.isCompacting, abortInProgress: () => this.#abortInProgress, streamingEditAbortTriggered: () => this.#streamingEditGuard.abortTriggered, promptGeneration: () => this.#promptGeneration, sessionId: () => this.sessionId, emitSessionEvent: event => this.#emitSessionEvent(event), scheduleAgentContinue: options => this.#scheduleAgentContinue(options), waitForSessionMessagePersistence: message => this.#waitForSessionMessagePersistence(message), appendSessionMessage: message => this.#appendSessionMessage(message), sessionMessageAlreadyPersisted: message => this.#sessionMessageAlreadyPersisted(message), setModelWithProviderSessionReset: model => this.#setModelWithProviderSessionReset(model), resetCurrentResponsesProviderSession: reason => this.#resetCurrentResponsesProviderSession(reason), maybeAutoRedeemCodexReset: () => this.#maybeAutoRedeemCodexReset(), runAutoCompaction: (reason, willRetry, deferred, allowDefer, options) => this.#maintenance.runAutoCompaction(reason, willRetry, deferred, allowDefer, options), withBashBranchTransition: operation => this.#bash.withBranchTransition(operation), }; this.#recovery = new TurnRecovery(recoveryHost, { initialRetryFallback: config.initialRetryFallback }); const statsHost: SessionStatsTrackerHost = { session: this, agent: this.agent, sessionManager: this.sessionManager, modelRegistry: this.#modelRegistry, model: () => this.model, sessionId: () => this.sessionId, }; this.#stats = new SessionStatsTracker(statsHost); const memoryHost: SessionMemoryHost = { agent: this.agent, settings: this.settings, modelRegistry: this.#modelRegistry, isDisposed: () => this.#isDisposed, memoryBackendSession: () => this, getHindsightSessionState: () => this.getHindsightSessionState(), setHindsightSessionState: state => this.setHindsightSessionState(state), getMnemopiSessionState: () => this.getMnemopiSessionState(), takeMnemopiSessionState: () => setMnemopiSessionState(this, undefined), setBaseSystemPrompt: prompt => { this.#tools.setBaseSystemPrompt(prompt); this.agent.setSystemPrompt(prompt); }, refreshBaseSystemPrompt: () => this.#tools.refreshBaseSystemPrompt(), replaceMemoryTools: tools => this.#tools.replaceMemoryTools(tools), }; this.#memory = new SessionMemory(memoryHost, { memoryAgentDir: config.memoryAgentDir, memoryTaskDepth: config.memoryTaskDepth, createMemoryTools: config.createMemoryTools, }); // Resolve the wire service-tier per request so the Fireworks Priority // toggle scopes priority to Fireworks alone, without mutating the shared // session `serviceTier` that drives `/fast` and OpenAI/Anthropic priority. this.agent.serviceTierResolver = model => this.#models.effectiveServiceTier(model); this.#titleSystemPrompt = config.titleSystemPrompt; this.#pruneToolDescriptions = config.pruneToolDescriptions === true; this.#transformContext = config.transformContext ?? (messages => messages); this.#sideStreamFn = config.sideStreamFn ?? streamSimple; this.#preferWebsockets = config.preferWebsockets; this.#onPayload = config.onPayload; this.rawSseDebugBuffer = config.rawSseDebugBuffer ?? new RawSseDebugBuffer(); // Avoid wrapping in an `async` closure when no user callback is configured: the // outer await on `#onResponse` (provider-response.ts) tolerates a sync void return, // and skipping the wrapper drops a per-event `newPromiseCapability` allocation that // shows up as ~3.5% self time in streaming profiles. const configuredOnResponse = config.onResponse; this.#onResponse = configuredOnResponse ? async (response, model) => { this.rawSseDebugBuffer.recordResponse(response, model); this.#stats.ingestProviderUsageHeaders(response, model); await configuredOnResponse(response, model); } : (response, model) => { this.rawSseDebugBuffer.recordResponse(response, model); this.#stats.ingestProviderUsageHeaders(response, model); }; const configuredOnSseEvent = config.onSseEvent; this.#onSseEvent = configuredOnSseEvent ? (event, model) => { this.rawSseDebugBuffer.recordEvent(event, model); configuredOnSseEvent(event, model); } : (event, model) => { this.rawSseDebugBuffer.recordEvent(event, model); }; this.agent.setProviderResponseInterceptor(this.#onResponse); this.agent.setRawSseEventInterceptor(this.#onSseEvent); this.agent.setOnTurnEnd(async (messages, signal, context) => { if (signal?.aborted) return; const rewindReport = this.#extractRewindReport(messages); if (rewindReport) { this.#pendingRewindReport = undefined; await this.#applyRewind(rewindReport, messages); } this.#loopGuards.recordTurn(messages, context); await this.#prewalk.advanceAtTurnEnd(messages, context); await this.#advisors.onPrimaryTurnEnd(messages, context?.willContinue, signal); await this.#maintenance.maintainContextMidRun(messages, signal, context); }); this.yieldQueue = new YieldQueue({ isStreaming: () => this.isStreaming, injectIdle: async messages => { const first = messages[0]; if (!first) return; await this.agent.prompt(messages.length === 1 ? first : messages); }, scheduleIdleFlush: run => { this.#schedulePostPromptTask( async () => { await run(); }, { delayMs: 1 }, ); }, }); // Background-job completions / late diagnostics are pulled into the run at // each step boundary as non-interrupting asides. Peer IRCs share the aside // injection boundary, but also expose a non-consuming interrupt peek so // `hub` waits can return early before the boundary drains them. this.agent.hasIrcInterrupts = () => this.#irc.hasInterrupts(); this.agent.setAsideMessageProvider(() => { const thunks: AsideMessage[] = this.#irc.drainPending().map(record => () => record); thunks.push(...this.yieldQueue.drainLazy()); // Mid-run todo reconciliation — evaluated at injection time so a turn // that flips a todo just before this poll suppresses the nudge. thunks.push(() => this.#todo.takeMidRunNudge()); return thunks; }); this.#convertToLlm = config.convertToLlm ?? convertToLlm; this.getXdevToolEntries = config.getXdevToolEntries ?? (() => []); const sessionToolsHost: SessionToolsHost = { agent: this.agent, sessionManager: this.sessionManager, settings: this.settings, modelRegistry: this.#modelRegistry, extensionRunner: () => this.#extensionRunner, clientBridge: () => this.#clientBridge, agentKind: () => this.#agentKind, isDisposed: () => this.#isDisposed, isStreaming: () => this.isStreaming, queuedMessageCount: () => this.queuedMessageCount, planModeEnabled: () => this.#planModeState?.enabled === true, model: () => this.model, memoryBackendSession: () => this, clearInheritedProviderPromptCacheKey: () => this.#clearInheritedProviderPromptCacheKey(), clearMemoryPromotionSnapshot: () => this.#memory.clearPromotionSnapshot(), captureMemoryPromotionSnapshot: prompt => this.#memory.capturePromotionSnapshot(prompt), emitNotice: (level, message, source) => this.emitNotice(level, message, source), notifyCommandMetadataChanged: () => this.#notifyCommandMetadataChanged(), localProtocolOptions: () => this.#localProtocolOptions(), }; this.#tools = new SessionTools(sessionToolsHost, { autoApprove: config.autoApprove, toolRegistry: config.toolRegistry, createVibeTools: config.createVibeTools, createComputerTool: config.createComputerTool, builtInToolNames: config.builtInToolNames, presentationPinnedToolNames: config.presentationPinnedToolNames, ensureWriteRegistered: config.ensureWriteRegistered, rebuildSystemPrompt: config.rebuildSystemPrompt, getLocalCalendarDate: config.getLocalCalendarDate, getMcpServerInstructions: config.getMcpServerInstructions, xdevRegistry: config.xdevRegistry, initialMountedXdevToolNames: config.initialMountedXdevToolNames, setActiveToolNames: config.setActiveToolNames, baseSystemPrompt: this.agent.state.systemPrompt, skills: config.skills, skillWarnings: config.skillWarnings, skillsSettings: config.skillsSettings, skillsReloadable: config.skillsReloadable, }); this.#disconnectOwnedMcpManager = config.disconnectOwnedMcpManager; const ttsrHost: TtsrCoordinatorHost = { agent: this.agent, sessionManager: this.sessionManager, settings: this.settings, emitSessionEvent: event => this.#emitSessionEvent(event), schedulePostPromptTask: (task, options) => this.#schedulePostPromptTask(task, options), scheduleAgentContinue: options => this.#scheduleAgentContinue(options), promptGeneration: () => this.#promptGeneration, }; this.#ttsr = new TtsrCoordinator(ttsrHost, config.ttsrManager); this.#obfuscator = config.obfuscator; const providerBoundaryHost: SessionProviderBoundaryHost = { agent: this.agent, sessionManager: this.sessionManager, settings: this.settings, modelRegistry: this.#modelRegistry, model: () => this.model, sessionId: () => this.sessionId, localProtocolOptions: () => this.#localProtocolOptions(), transformContext: (messages, signal) => this.#transformContext(messages, signal), convertToLlm: messages => this.#convertToLlm(messages), onPayload: this.#onPayload, onResponse: this.#onResponse, onSseEvent: this.#onSseEvent, obfuscator: this.#obfuscator, }; this.#providerBoundary = new SessionProviderBoundary(providerBoundaryHost); const streamGuardsHost: StreamGuardsHost = { agent: this.agent, settings: this.settings, sessionManager: this.sessionManager, obfuscator: this.#obfuscator, model: () => this.model, isDisposed: () => this.#isDisposed, promptGeneration: () => this.#promptGeneration, localProtocolOptions: () => this.#localProtocolOptions(), emitNotice: (level, message, source) => this.emitNotice(level, message, source), schedulePostPromptTask: task => this.#schedulePostPromptTask(task), discardAssistantTurn: message => this.#recovery.discardAssistantTurn(message), }; this.#streamingEditGuard = new StreamingEditGuard(streamGuardsHost); this.#loopGuards = new LoopGuards(streamGuardsHost); this.#agentId = config.agentId; this.#agentKind = config.agentKind ?? "main"; this.#providerSessionId = config.providerSessionId; this.#inheritedProviderPromptCacheKey = config.providerPromptCacheKeySource === "fork" ? this.agent.promptCacheKey : undefined; // Owner-routed async delivery: completions for jobs this agent owns are // injected into THIS session's run as async-result follow-ups. Without a // registered sink the manager dead-letters owned deliveries, so this // registration is what makes background jobs usable — for the main // session and for subagents inheriting the process manager alike. if (this.#asyncJobManager && this.#agentId) { const manager = this.#asyncJobManager; this.#unregisterAsyncDeliverySink = manager.registerDeliverySink(this.#agentId, (jobId, text, job) => this.#deliverAsyncJobResult(manager, jobId, text, job), ); this.yieldQueue.register("async-result", { isStale: entry => manager.isDeliverySuppressed(entry.jobId), build: buildAsyncResultBatchMessage, }); } this.agent.setAssistantMessageEventInterceptor((message, assistantMessageEvent) => { const event: AgentEvent = { type: "message_update", message, assistantMessageEvent, }; this.#streamingEditGuard.preCache(event); this.#streamingEditGuard.maybeAbort(event); this.#loopGuards.onAssistantEvent(message, assistantMessageEvent); }); // Tool-result hook owns synchronous post-tool actions that must affect the current loop. this.agent.afterToolCall = ctx => this.#afterToolCall(ctx); this.agent.providerSessionState = this.#providerSessionState; this.#syncAgentSessionId(); this.#todo.syncFromBranch(); this.#goalRuntime = new GoalRuntime({ getState: () => this.#goalModeState, setState: state => { this.#goalModeState = state; }, getCurrentUsage: () => { const usage = this.getSessionStats().tokens; return { input: usage.input, output: usage.output, cacheRead: usage.cacheRead, cacheWrite: usage.cacheWrite, }; }, emit: event => { if (event.type === "goal_updated") { return this.#emitSessionEvent({ type: "goal_updated", goal: event.goal, state: event.state }); } }, persist: (mode, state) => { if (mode === "none") { this.sessionManager.appendModeChange("none"); } else if (state) { this.sessionManager.appendModeChange(mode, { goal: state.goal }); } }, sendHiddenMessage: async message => { await this.sendCustomMessage( { customType: message.customType, content: message.content, display: false, attribution: "agent", }, { deliverAs: message.deliverAs }, ); }, }); this.#cancelExitRecorder = postmortem.register(`agent-session:${this.sessionManager.getSessionId()}`, reason => { this.#recordSessionExit(reason); }); const advisorsHost: SessionAdvisorsHost = { agent: this.agent, sessionManager: this.sessionManager, settings: this.settings, modelRegistry: this.#modelRegistry, yieldQueue: this.yieldQueue, obfuscator: this.#obfuscator, providerSessionState: this.#providerSessionState, preferWebsockets: this.#preferWebsockets, onPayload: this.#onPayload, onResponse: this.#onResponse, onSseEvent: this.#onSseEvent, agentKind: () => this.#agentKind, isDisposed: () => this.#isDisposed, abortInProgress: () => this.#abortInProgress, allowAgentInitiatedTurns: () => this.#allowAcpAgentInitiatedTurns, planModeState: () => this.#planModeState, clientBridge: () => this.#clientBridge, emitSessionEvent: event => this.#emitSessionEvent(event), emitNotice: (level, message, source) => this.emitNotice(level, message, source), sendCustomMessage: (message, options) => this.sendCustomMessage(message, options), extractQueuedAdvisorCards: () => this.#extractQueuedAdvisorCards(), dropPendingAdvisorCards: () => { this.#pendingNextTurnMessages = this.#pendingNextTurnMessages.filter(message => !isAdvisorCard(message)); }, preserveAdvisorCard: card => this.#preserveAdvisorCard(card), hasPendingNextTurnMessages: () => this.#pendingNextTurnMessages.length > 0, convertToLlmForSideRequest: messages => this.#convertToLlmForSideRequest(messages), effectiveServiceTier: model => this.#models.effectiveServiceTier(model), resolveContextPromotionTarget: (model, contextWindow) => this.#maintenance.resolveContextPromotionTarget(model, contextWindow), resolveCompactionModelCandidates: (model, availableModels) => this.#maintenance.resolveCompactionModelCandidates(model, availableModels), resolveRetryFallbackRole: (selector, model) => this.#recovery.resolveRetryFallbackRole(selector, model), findRetryFallbackCandidates: (role, selector, model) => this.#recovery.findRetryFallbackCandidates(role, selector, model), isRetryFallbackSelectorSuppressed: selector => this.#recovery.isRetryFallbackSelectorSuppressed(selector), noteRetryFallbackCooldown: (selector, retryAfterMs, errorMessage) => this.#recovery.noteRetryFallbackCooldown(selector, retryAfterMs, errorMessage), createCodexCompactionContext: createMaintenanceCodexCompactionContext, sessionId: () => this.sessionId, }; this.#advisors = new SessionAdvisors(advisorsHost, { enabled: this.settings.get("advisor.enabled"), tools: config.advisorTools, watchdogPrompt: config.advisorWatchdogPrompt, sharedInstructions: config.advisorSharedInstructions, contextPrompt: config.advisorContextPrompt, configs: config.advisorConfigs, streamFn: config.advisorStreamFn, transformProviderContext: config.transformProviderContext, }); const maintenanceHost: SessionMaintenanceHost = { agent: this.agent, sessionManager: this.sessionManager, settings: this.settings, modelRegistry: this.#modelRegistry, extensionRunner: this.#extensionRunner, sideStreamFn: this.#sideStreamFn, providerSessionState: this.#providerSessionState, model: () => this.model, thinkingLevel: () => this.thinkingLevel, isDisposed: () => this.#isDisposed, isStreaming: () => this.isStreaming, isGeneratingHandoff: () => this.isGeneratingHandoff, promptGeneration: () => this.#promptGeneration, sessionId: () => this.sessionId, messages: () => this.messages, baseSystemPrompt: () => this.#tools.baseSystemPrompt, goalModeState: () => this.#goalModeState, planReferencePath: () => this.#planReferencePath, nonMessageTokenSource: () => this, memoryBackendSession: () => this, emitSessionEvent: event => this.#emitSessionEvent(event), emitNotice: (level, message, source) => this.emitNotice(level, message, source), schedulePostPromptTask: (task, options) => this.#schedulePostPromptTask(task, options), scheduleAgentContinue: options => this.#scheduleAgentContinue(options), scheduleCompactionContinuation: options => this.#scheduleCompactionContinuation(options), persistTurnMessagesForMidRunCompaction: context => this.#persistTurnMessagesForMidRunCompaction(context), findLastAssistantMessage: () => this.#findLastAssistantMessage(), disconnectFromAgent: () => this.#disconnectFromAgent(), reconnectToAgent: () => this.#reconnectToAgent(), drainStrandedQueuedMessages: () => this.#drainStrandedQueuedMessages(), buildDisplaySessionContext: () => this.buildDisplaySessionContext(), convertToLlmForSideRequest: messages => this.#convertToLlmForSideRequest(messages), obfuscateTextForProvider: text => this.#obfuscateTextForProvider(text), obfuscatePreparationForProvider: preparation => this.#obfuscatePreparationForProvider(preparation), closeCodexProviderSessionsForHistoryRewrite: () => this.#closeCodexProviderSessionsForHistoryRewrite(), resetCodexProviderAfterCompaction: compaction => this.#resetCodexProviderAfterCompaction(compaction), resetPlanReference: () => { this.#planReferenceSent = false; }, syncTodoPhasesFromBranch: () => this.#todo.syncFromBranch(), resetAdvisorRuntimes: () => this.#advisors.resetAllRuntimes(), rebaseAfterCompaction: () => this.#stats.rebaseAfterCompaction(), getContextBreakdown: options => this.getContextBreakdown(options), getContextUsage: options => this.getContextUsage(options), shake: (mode, options) => this.shake(mode, options), dropImages: () => this.dropImages(), runHandoff: (customInstructions, options) => this.handoff(customInstructions, options), removeAssistantMessageFromActiveContext: message => this.#recovery.removeAssistantMessageFromActiveContext(message), dropPersistedAssistantTurn: message => this.#recovery.dropPersistedAssistantTurn(message), runRecoveryCompactionWithRollback: (reason, message, allowDefer, options) => this.#recovery.runRecoveryCompactionWithRollback(reason, message, allowDefer, options), parseRetryAfterMsFromError: errorMessage => this.#recovery.parseRetryAfterMsFromError(errorMessage), setModelTemporary: (model, thinkingLevel, options) => this.setModelTemporary(model, thinkingLevel, options), abort: options => this.abort(options), abortHandoff: () => this.abortHandoff(), }; this.#maintenance = new SessionMaintenance(maintenanceHost); const handoffHost: SessionHandoffHost = { agent: this.agent, sessionManager: this.sessionManager, settings: this.settings, modelRegistry: this.#modelRegistry, extensionRunner: this.#extensionRunner, sideStreamFn: this.#sideStreamFn, obfuscator: this.#obfuscator, model: () => this.model, thinkingLevel: () => this.thinkingLevel, sessionId: () => this.sessionId, sessionFile: () => this.sessionFile, baseSystemPrompt: () => this.#tools.baseSystemPrompt, assertVibeSessionTransitionAllowed: action => this.#assertVibeSessionTransitionAllowed(action), setSkipPostTurnMaintenance: timestamp => { this.#maintenance.skipPostTurnMaintenanceAssistantTimestamp = timestamp; }, obfuscateTextForProvider: text => this.#obfuscateTextForProvider(text), deobfuscateFromProvider: text => this.#deobfuscateFromProvider(text), convertMessagesToLlm: (messages, signal) => this.convertMessagesToLlm(messages, signal), prepareSimpleStreamOptions: (options, provider) => this.prepareSimpleStreamOptions(options, provider), effectiveServiceTier: model => this.#models.effectiveServiceTier(model), flushPendingBash: () => this.#bash.flushPending(), beginBashSessionTransition: () => this.#bash.beginSessionTransition(), markBashSessionTransition: transition => this.#bash.markSessionTransition(transition), finishBashSessionTransition: (transition, success) => this.#bash.finishSessionTransition(transition, success), cancelOwnAsyncJobs: () => this.#cancelOwnAsyncJobs(), clearCheckpointRuntimeState: () => this.#clearCheckpointRuntimeState(), clearSessionScopedToolState: () => this.#clearSessionScopedToolState(), clearFreshProviderSessionId: () => { this.#freshProviderSessionId = undefined; }, syncAgentSessionId: () => this.#syncAgentSessionId(), rekeyMemoryForCurrentSessionId: () => { this.#memory.rekeyForCurrentSessionId(); }, resetMemoryContextForNewTranscript: () => this.#memory.resetContextForNewTranscript(), clearPendingNextTurnMessages: () => { this.#pendingNextTurnMessages = []; this.#scheduledHiddenNextTurnGeneration = undefined; }, resetTodoCycle: () => this.#todo.resetCycle(), buildDisplaySessionContext: () => this.buildDisplaySessionContext(), resetAdvisorRuntimes: () => this.#advisors.resetAllRuntimes(), syncTodoPhasesFromBranch: () => this.#todo.syncFromBranch(), }; this.#handoff = new SessionHandoff(handoffHost); this.#rehydrateCheckpointRewindState(); // Always subscribe to agent events for internal handling // (session persistence, hooks, auto-compaction, retry logic) this.#unsubscribeAgent = this.agent.subscribe(this.#handleAgentEvent); // Re-evaluate append-only context mode when the setting changes at runtime. this.#unsubscribeAppendOnly = onAppendOnlyModeChanged(_value => this.#syncAppendOnlyContext(this.model)); this.#unsubscribeModelRoles = onModelRolesChanged(() => this.#advisors.onModelRolesChanged()); } /** Model registry for API key resolution and model discovery */ get modelRegistry(): ModelRegistry { return this.#modelRegistry; } get asyncJobManager(): AsyncJobManager | undefined { return this.#asyncJobManager; } getAgentId(): string | undefined { return this.#agentId; } /** Dequeue the next HARD forced tool choice for the upcoming LLM call, dropping * (and rejecting) one whose named tool is no longer active. */ #nextHardToolChoice(): ToolChoice | undefined { const choice = this.#toolChoiceQueue.nextToolChoice(); if (isToolChoiceActive(choice, this.agent.state.tools)) { return choice; } this.#toolChoiceQueue.reject("unavailable"); return undefined; } /** * The per-turn tool-choice directive for the agent loop's `getToolChoice`. Priority: * 1. a HARD forced choice from the queue (genuine forces: user-force, eager-todo, …) — * consuming (advances the queue generator); * 2. else, when a non-forcing preview is pending, a {@link SoftToolRequirement} — a * PEEK (advances/pops nothing), so the agent-loop injects the reminder once per head * and escalates to a forced `write` only if the model declines to * resolve via `xd://resolve` or `xd://reject`. A compliant turn * pays ZERO tool_choice change (no prompt-cache messages-cache invalidation); * 3. else undefined. */ nextToolChoiceDirective(): ToolChoiceDirective | undefined { const hard = this.#nextHardToolChoice(); if (hard !== undefined) return hard; const head = this.#toolChoiceQueue.peekPendingHead(); if (head !== undefined) { return { soft: true, id: head.id, // Preview resolution is a `write` to xd://resolve or xd://reject; // only those exact shapes satisfy the requirement — a plain write // elsewhere is a detour. toolName: "write", satisfies: isPreviewResolutionToolCall, reminder: [buildResolveReminderMessage(head.sourceToolName)], }; } return undefined; } /** Peek the head non-forcing pending preview invoker, for the preview-resolution dispatch. */ peekPendingInvoker(): ((input: unknown) => Promise | unknown) | undefined { return this.#toolChoiceQueue.peekPendingInvoker(); } /** Clear stale non-forcing pending preview invokers after a resolve dispatch proves none can run. */ clearPendingInvokers(): void { this.#toolChoiceQueue.clearPendingInvokers(); } /** * Force the next model call to target a specific active tool, then terminate * the agent loop. Pushes a two-step sequence [forced, "none"] so the model * calls exactly the forced tool once and then cannot call another. */ setForcedToolChoice(toolName: string): void { if (!this.getActiveToolNames().includes(toolName)) { throw new Error(`Tool "${toolName}" is not currently active.`); } const forced = buildNamedToolChoice(toolName, this.model); if (!forced || typeof forced === "string") { throw new Error("Current model does not support forcing a specific tool."); } this.#toolChoiceQueue.pushSequence([forced, "none"], { label: "user-force", onRejected: () => "requeue", }); } /** The tool-choice queue: forces forthcoming tool invocations and carries handlers. */ get toolChoiceQueue(): ToolChoiceQueue { return this.#toolChoiceQueue; } /** Peek the in-flight directive's invocation handler for the preview-resolution dispatch. */ peekQueueInvoker(): ((input: unknown) => Promise | unknown) | undefined { return this.#toolChoiceQueue.peekInFlightInvoker(); } /** Plan-proposal handler consulted by `xd://propose` while plan mode is active. */ #planProposalHandler: PlanProposalHandler | undefined; peekPlanProposalHandler(): PlanProposalHandler | undefined { return this.#planProposalHandler; } setPlanProposalHandler(handler: PlanProposalHandler | null): void { this.#planProposalHandler = handler ?? undefined; } #sessionBeforeSwitchReconciler: (() => Promise) | undefined; setSessionBeforeSwitchReconciler(reconciler: (() => Promise) | null): void { this.#sessionBeforeSwitchReconciler = reconciler ?? undefined; } #sessionSwitchReconciler: (() => Promise) | undefined; setSessionSwitchReconciler(reconciler: (() => Promise) | null): void { this.#sessionSwitchReconciler = reconciler ?? undefined; } /** Provider-scoped mutable state store for transport/session caches. */ get providerSessionState(): Map { return this.#providerSessionState; } /** Hint forwarded to provider calls that support websocket transport. */ get preferWebsockets(): boolean | undefined { return this.#preferWebsockets; } getHindsightSessionState(): HindsightSessionState | undefined { return this.#hindsightSessionState; } setHindsightSessionState(state: HindsightSessionState | undefined): HindsightSessionState | undefined { const previous = this.#hindsightSessionState; this.#hindsightSessionState = state; return previous; } getMnemopiSessionState(): MnemopiSessionState | undefined { return getMnemopiSessionState(this); } /** TTSR manager for time-traveling stream rules */ get ttsrManager(): TtsrManager | undefined { return this.#ttsr.manager; } /** Secret obfuscator, when secrets are configured; /share redaction reuses it. */ get obfuscator(): SecretObfuscator | undefined { return this.#obfuscator; } /** Whether a TTSR abort is pending (stream was aborted to inject rules) */ get isTtsrAbortPending(): boolean { return this.#ttsr.abortPending; } /** Whether an expected internal plan-mode abort is pending. Consumed by * `#handleAgentEvent` to stamp `SILENT_ABORT_MARKER` on the next aborted * assistant message_end; callers clear it in `finally`. */ get isPlanInternalAbortPending(): boolean { return this.#planInternalAbortPending; } /** Arm the silent-abort marker for the next aborted assistant message_end. * Caller MUST clear via `clearPlanInternalAbortPending()` in a `finally` * to guarantee no leak. */ markPlanInternalAbortPending(): void { this.#planInternalAbortPending = true; } /** Unconditionally clear the silent-abort flag. Idempotent: safe when the * flag was never set OR was already consumed by `#handleAgentEvent`. */ clearPlanInternalAbortPending(): void { this.#planInternalAbortPending = false; } getAsyncJobSnapshot(options?: { recentLimit?: number }): AsyncJobSnapshot | null { const manager = this.#asyncJobManager; if (!manager) return null; const ownerFilter = this.#agentId ? { ownerId: this.#agentId } : undefined; const running = manager.getRunningJobs(ownerFilter).map(job => ({ id: job.id, type: job.type, status: job.status, label: job.label, startTime: job.startTime, })); const recent = manager.getRecentJobs(options?.recentLimit ?? 5, ownerFilter).map(job => ({ id: job.id, type: job.type, status: job.status, label: job.label, startTime: job.startTime, })); const delivery = manager.getDeliveryState(ownerFilter); return { running, recent, delivery }; } /** * Cancel async jobs registered by *this* agent only. Used by lifecycle * transitions (newSession, switchSession, handoff, dispose) so a subagent * cleans up its own background work without touching its parent's jobs. * * Cancellation runs against this session's scoped manager. Subagents have * unique agent ids and inherit the parent's manager to clean up their own * jobs. A secondary in-process top-level session gets no scoped manager, * because it defaults to `MAIN_AGENT_ID`; reaching through the global * singleton would tear down the owning primary session's bash/task jobs at * dispose time (issue #1923). * * No-op when no manager is reachable or this session has no agent id. */ #cancelOwnAsyncJobs(): void { if (!this.#agentId) return; const manager = this.#asyncJobManager; manager?.cancelAll({ ownerId: this.#agentId }); } /** * True when a background async job owned by this agent is still running with * an unsuppressed delivery, a finished job's delivery is still queued or in * flight, or a delivered result is still sitting on the yield queue awaiting * injection. In every case the async-result follow-up will re-wake the loop, * so a settle observed now is a scheduling pause rather than a terminal stop: * stop-time passes (todo reminder, session_stop hooks) defer to the settle * reached once the session is fully idle. Suppressed deliveries * (acknowledged, or watched by an in-flight `hub` wait) never wake the loop, * so they don't count. */ #hasPendingAsyncWake(): boolean { const manager = this.#asyncJobManager; if (!manager) return false; const ownerFilter = this.#agentId ? { ownerId: this.#agentId } : undefined; return ( manager.getRunningJobs(ownerFilter).some(job => !manager.isDeliverySuppressed(job.id)) || manager.hasPendingDeliveries(ownerFilter) || // Delivered but not yet injected: the sink has enqueued the // async-result follow-up on the yield queue, and the manager no // longer reports it. Without this leg a terminal yield in the // (idle-flush delay / step-boundary) handoff window would read as // quiescent and the run driver would drop the queued result. this.yieldQueue.has(ASYNC_RESULT_MESSAGE_TYPE) ); } /** * Public view of the pending-async-wake state for run drivers: true while * owner-scoped async work can still re-wake this session's run (a running * background job with an unsuppressed delivery, or a queued / in-flight * delivery). The task executor's quiescence barrier polls this to * distinguish a scheduling pause from terminal completion. */ hasPendingAsyncWork(): boolean { return this.#hasPendingAsyncWake(); } /** * Settle one generation of owner-scoped async work: wait for running owner * jobs to finish, deliver their queued results (which enqueue async-result * follow-ups on this session's yield queue), and wait for the injected * follow-up turn(s) to go idle. Callers loop while * {@link hasPendingAsyncWork} still holds — a follow-up turn may start new * jobs. */ async settleAsyncWork(): Promise { const manager = this.#asyncJobManager; if (!manager || !this.#agentId) return; await manager.waitForOwnerJobs(this.#agentId, { excludeSuppressed: true }); await manager.drainDeliveries({ filter: { ownerId: this.#agentId } }); await this.waitForIdle(); } /** * Delivery sink for async jobs owned by this agent: format the result * (spilling oversized output to an artifact) and enqueue it as an * async-result follow-up on the yield queue. The queue's idle flush starts * the follow-up turn when the session is between turns. */ async #deliverAsyncJobResult(manager: AsyncJobManager, jobId: string, text: string, job?: AsyncJob): Promise { if (this.#isDisposed) return; if (manager.isDeliverySuppressed(jobId)) return; const formatted = await this.#formatAsyncResultForFollowUp(text); if (manager.isDeliverySuppressed(jobId)) return; const durationMs = job ? Math.max(0, Date.now() - job.startTime) : undefined; this.yieldQueue.enqueue("async-result", { jobId, result: formatted, job, durationMs }); } async #formatAsyncResultForFollowUp(result: string): Promise { if (result.length <= ASYNC_INLINE_RESULT_MAX_CHARS) { return result; } const preview = `${result.slice(0, ASYNC_PREVIEW_MAX_CHARS)}\n\n[Output truncated. Showing first ${ASYNC_PREVIEW_MAX_CHARS.toLocaleString()} characters.]`; try { const { path: artifactPath, id: artifactId } = await this.sessionManager.allocateArtifactPath("async"); if (artifactPath && artifactId) { await Bun.write(artifactPath, result); return `${preview}\nFull output: artifact://${artifactId}`; } } catch (error) { logger.warn("Failed to persist async follow-up artifact", { error: error instanceof Error ? error.message : String(error), }); } return preview; } // ========================================================================= // Event Subscription // ========================================================================= /** Emit an event to all listeners */ #emit(event: AgentSessionEvent): void { // Copy array before iteration to avoid mutation during iteration. const listeners = [...this.#eventListeners]; for (const l of listeners) { try { const result = l(event) as unknown; // Listener may be an async function whose returned Promise we don't await; // attach a catch so a rejection does not become an unhandled rejection. if (isPromise(result)) { result.catch(err => { logger.warn("AgentSession listener rejected", { error: err instanceof Error ? err.message : String(err), }); }); } } catch (err) { logger.warn("AgentSession listener threw", { error: err instanceof Error ? err.message : String(err), }); } } } /** * Emit a UI-only notice to the session. Surfaces in interactive mode as a * `showWarning` / `showError` / `showStatus` line; non-interactive modes * receive the event through the normal subscribe stream. * * Notices are NOT added to agent state and never reach the LLM — use this * for out-of-band conditions the user should see but the model shouldn't * react to (e.g. background queue flush failures). */ emitNotice(level: "info" | "warning" | "error", message: string, source?: string): void { this.#emit({ type: "notice", level, message, source }); } #recordToolExecutionStart(event: Extract): void { const data: ToolExecutionStartData = { toolCallId: event.toolCallId, toolName: event.toolName, startedAt: new Date().toISOString(), }; // The assistant message already persists the full arguments; store only // the command/path projection the resume warning renders. const args = summarizeToolArguments(event.args); if (args) data.args = args; if (event.intent) data.intent = event.intent; this.sessionManager.appendCustomEntry(TOOL_EXECUTION_START_CUSTOM_TYPE, data); } #recordSessionExit(reason: postmortem.Reason | "dispose"): void { if (this.#exitRecorded) return; this.#exitRecorded = true; const pendingToolCalls = collectPendingToolCalls(this.sessionManager.getBranch()); if ( pendingToolCalls.length === 0 && !this.sessionManager.getEntries().some(entry => entry.type === "message" && entry.message.role === "assistant") ) { return; } const kind: SessionExitData["kind"] = reason === "dispose" || reason === postmortem.Reason.MANUAL ? "normal" : reason === postmortem.Reason.UNCAUGHT_EXCEPTION || reason === postmortem.Reason.UNHANDLED_REJECTION ? "fatal" : reason === postmortem.Reason.EXIT ? "process_exit" : "signal"; const data: SessionExitData = { reason, kind, recordedAt: new Date().toISOString(), }; if (pendingToolCalls.length > 0) data.pendingToolCalls = pendingToolCalls; try { this.sessionManager.appendCustomEntry(SESSION_EXIT_CUSTOM_TYPE, data); this.sessionManager.flushSync(); // Only pending tool calls or an abnormal teardown are noteworthy; a // clean dispose logs at debug so routine exits don't read as problems. const exitLog = pendingToolCalls.length > 0 || kind !== "normal" ? logger.warn : logger.debug; exitLog("Session exit recorded", { sessionId: this.sessionManager.getSessionId(), sessionFile: this.sessionManager.getSessionFile(), reason, kind, pendingToolCalls: pendingToolCalls.length, }); } catch (error) { logger.error("Failed to record session exit", { sessionId: this.sessionManager.getSessionId(), sessionFile: this.sessionManager.getSessionFile(), reason, error: error instanceof Error ? error.message : String(error), }); } } #queuedExtensionEvents: Promise = Promise.resolve(); #queueExtensionEvent(event: AgentSessionEvent): Promise { const emit = async () => { await this.#emitExtensionEvent(event); }; const queued = this.#queuedExtensionEvents.then(emit, emit); this.#queuedExtensionEvents = queued.catch(() => {}); return queued; } /** * Orders subscriber fan-out across concurrent `#emitSessionEvent` calls. * Extension emits only await when the event type has handlers, so an event * with no handlers could otherwise overtake an earlier event still inside * its extension emit — an instant refusal delivered its assistant * `message_end` to the TUI before its own `message_start`, skipping the * turn-ending error render entirely. */ #subscriberEmitGate: Promise = Promise.resolve(); async #emitSessionEvent(event: AgentSessionEvent): Promise { if (event.type === "message_update") { this.#emit(event); void this.#queueExtensionEvent(event); return; } // Take a FIFO ticket before the extension emit: extension deliveries for // consecutive events still run concurrently, but subscriber fan-out waits // for every earlier event's fan-out (or deferral) to happen first. const previousGate = this.#subscriberEmitGate; const { promise: gate, resolve: releaseGate } = Promise.withResolvers(); this.#subscriberEmitGate = gate; try { await this.#emitExtensionEvent(event); await previousGate; // Hold the wire-level agent_end until in-flight prompts unwind. Subscribers // (rpc-mode, ACP, Cursor) treat agent_end as the "session is idle" signal; // emitting while #promptInFlightCount > 0 lets a client fire its next // `prompt` into a session that still reports isStreaming === true. Flush // happens in #endInFlight / #resetInFlight. A later agent_end (e.g. from // an auto-compaction turn that starts before the original prompt unwinds) // supersedes the pending one, which is what subscribers want — they only // care about the final settle. if (event.type === "agent_end" && this.#promptInFlightCount > 0) { this.#pendingAgentEndEmit = event; return; } this.#emit(event); } finally { releaseGate(); } } // Track last assistant message for auto-compaction check #lastAssistantMessage: AssistantMessage | undefined = undefined; /** * Classifier-refusal turn pruned from active context at settle (#3591). * Retained until the next run starts so post-settle readers * ({@link getLastAssistantMessage}: print mode, task executor) still see * the terminal error instead of a silently successful-looking state. */ #prunedTerminalRefusal: AssistantMessage | undefined = undefined; /** Internal handler for agent events - shared by subscribe and reconnect. * * `agent_end` handling schedules deferred post-prompt recovery work * (compaction/handoff, context-promotion continuations). It is invoked * fire-and-forget by the agent's synchronous `#emit`, and only reaches * `#checkCompaction` after several internal awaits. `prompt()` runs * `#waitForPostPromptRecovery()` the instant `agent.prompt()` resolves — which * can land BEFORE the handler registers its tasks, so the wait would observe an * empty task set and return early, letting a deferred handoff/promotion race * prompt completion. Tracking the `agent_end` handler as a post-prompt task * that is registered SYNCHRONOUSLY (before the first await) closes that window: * `#postPromptTasksPromise` is set the moment `#emit` invokes this handler, so * the recovery wait always sees the in-flight handler and blocks until it — and * everything it schedules — settles. */ #handleAgentEvent = async (event: AgentEvent): Promise => { if (event.type === "tool_execution_end" && this.#isTerminalYieldToolResult(event)) { const alreadyTerminated = this.#synchronouslyTerminatedYieldToolCallIds.delete(event.toolCallId); if (!alreadyTerminated) { this.#markTerminalYieldToolCall(event.toolCallId); this.agent.abort(TERMINAL_TOOL_RESULT_ABORT_REASON); } } if (event.type !== "agent_end") { const processing = this.#processAgentEvent(event); if ((event.type === "message_start" || event.type === "message_end") && isAdvisorCard(event.message)) { this.#advisors.trackCardEvent(processing); } return processing; } const { promise, resolve } = Promise.withResolvers(); this.#trackPostPromptTask(promise); try { await this.#processAgentEvent(event); } finally { resolve(); } }; #createMessageEndPersistenceSlot(message: AgentMessage): MessageEndPersistenceSlot | undefined { const key = sessionMessagePersistenceKey(message); if (!key) return undefined; const previous = this.#messageEndPersistenceTail; const { promise, resolve } = Promise.withResolvers(); const clear = () => { if (this.#pendingMessageEndPersistence.get(key) === promise) { this.#pendingMessageEndPersistence.delete(key); } }; this.#pendingMessageEndPersistence.set(key, promise); this.#messageEndPersistenceTail = promise.catch(() => {}); return { promise, persist: async persistMessage => { await previous; try { persistMessage(); } finally { resolve(); clear(); } }, release: () => { resolve(); clear(); }, }; } async #waitForSessionMessagePersistence(message: AgentMessage): Promise { const key = sessionMessagePersistenceKey(message); if (!key) return; await this.#pendingMessageEndPersistence.get(key); } /** * Index every message entry on the current branch by persistence key, so * the mid-run-compaction planner can ask "is this turn message already on * the branch?" in O(1). The set is memoized through the current leaf path * and validated at use time against a (session file, leaf id) anchor. * * The mid-run ordering check uses key identity alone: same-key content * variants are one logical message at this boundary, because otherwise a * display-side rewrite can make the assistant look missing after its tool * results have already persisted. * * Coherency is anchor-based, not invalidation-based: every branch mutation * (rewind, branch switch, new session, custom-entry append) changes the * session manager's leaf id or session file, so `#ensurePersistedMessageKeys` * detects staleness itself and rebuilds. No mutation call site has to * remember to invalidate anything. * * Pre-#3629 the equivalent was `sessionManager.getBranch()` called twice * per turn message, each call rebuilding the path via O(n²) `unshift` and * structurally JSON-comparing every entry — seconds of synchronous work * per `onTurnEnd` on a long session and the load-bearing source of the * `ui.loop-blocked` warnings in the bug report. */ #indexPersistedMessageKeys(): Set { return this.#ensurePersistedMessageKeys(); } #persistedMessageKeysAnchor(): string { return `${this.sessionManager.getSessionFile() ?? ""}\u0000${this.sessionManager.getLeafId() ?? ""}`; } #ensurePersistedMessageKeys(): Set { const anchor = this.#persistedMessageKeysAnchor(); let cache = this.#persistedMessageKeys; if (cache === undefined || cache.anchor !== anchor) { cache = { anchor, keys: this.#buildPersistedMessageKeySet() }; this.#persistedMessageKeys = cache; } return cache.keys; } #buildPersistedMessageKeySet(): Set { const keys = new Set(); for (const entry of this.sessionManager.getBranch()) { if (entry.type !== "message") continue; const key = sessionMessagePersistenceKey(entry.message); if (key !== undefined) keys.add(key); } return keys; } /** * True when {@link message} is structurally identical to a message already * appended to the current branch. Uses the current branch's memoized * persistence-key cache for the common missing-key case, and only walks the * branch to verify content when a key hit could be a rare collision. */ #sessionMessageAlreadyPersisted(message: AgentMessage): boolean { const key = sessionMessagePersistenceKey(message); if (key === undefined) return false; const keys = this.#ensurePersistedMessageKeys(); if (!keys.has(key)) return false; const branch = this.sessionManager.getBranch(); for (let index = branch.length - 1; index >= 0; index--) { const entry = branch[index]; if (entry.type !== "message") continue; if (sessionMessagePersistenceKey(entry.message) !== key) continue; if (sameMessageContent(entry.message, message)) return true; } return false; } #appendSessionMessage( message: | Message | CustomMessage | HookMessage | BashExecutionMessage | PythonExecutionMessage | FileMentionMessage, ): string { const cache = this.#persistedMessageKeys; const wasFresh = cache !== undefined && cache.anchor === this.#persistedMessageKeysAnchor(); const entryId = this.sessionManager.appendMessage(message); const key = sessionMessagePersistenceKey(message); if (wasFresh && cache && key) { cache.keys.add(key); cache.anchor = this.#persistedMessageKeysAnchor(); } return entryId; } #persistSessionMessageIfMissing(message: AgentMessage): void { if ( message.role !== "user" && message.role !== "developer" && message.role !== "assistant" && message.role !== "toolResult" && message.role !== "fileMention" ) { return; } if (this.#sessionMessageAlreadyPersisted(message)) return; if (message.role === "assistant") { const assistantMsg = message as AssistantMessage; if (this.#recovery.isClassifierRefusal(assistantMsg)) return; if (isEmptyErrorTurn(assistantMsg)) return; if (assistantMsg.stopReason !== "aborted" && assistantMsg.stopReason !== "error" && assistantMsg.usage) { assistantMsg.contextSnapshot = { promptTokens: calculatePromptTokens(assistantMsg.usage), nonMessageTokens: this.#stats.pendingNonMessageTokens ?? computeNonMessageTokens(this), }; } } const skipPersistedRewindResult = message.role === "toolResult" && semanticToolResult(message.toolName, message)?.toolName === "rewind" && this.#rewoundToolResultIds.delete(message.toolCallId); if (!skipPersistedRewindResult) { this.#appendSessionMessage(message); } } /** * On a user-interrupted (`Esc`) abort, copy the trailing thinking run into a * hidden `display: false` continuity message for the next turn WITHOUT * mutating the assistant message. The original thinking stays on the message * so live render, reload, and Ctrl+L rebuilds keep showing it; `convertToLlm` * strips the run from the provider request (incomplete/unsigned thinking is * rejected on resend) when this continuity message follows the assistant turn. */ #demoteInterruptedThinkingOnUserInterrupt( message: AssistantMessage, ): CustomMessage | undefined { if (message.stopReason !== "aborted" || !isUserInterruptAbort(message)) return undefined; const demoted = demoteInterruptedThinking(message); if (!demoted) return undefined; const interruptedAt = Date.now(); return { role: "custom", customType: INTERRUPTED_THINKING_MESSAGE_TYPE, content: prompt.render(interruptedThinkingTemplate, { reasoning: demoted.reasoning }), display: false, details: { interruptedAt, provider: message.provider, model: message.model, blockCount: demoted.blockCount, }, attribution: "agent", timestamp: interruptedAt, }; } async #persistTurnMessagesForMidRunCompaction(context: AgentTurnEndContext | undefined): Promise { if (!context) return true; const turnMessages = [context.message, ...context.toolResults]; for (const message of turnMessages) { await this.#waitForSessionMessagePersistence(message); } // One branch snapshot + one persistence-key index drives the entire // planning pass. Pre-#3629 this re-walked the branch and structurally // JSON-compared every entry per turn message, which on long sessions // turned each `onTurnEnd` into a seconds-long sync block (the // `ui.loop-blocked` warnings tagged `subagent:*` in the bug report). const branchKeys = this.#indexPersistedMessageKeys(); const turnKeys = turnMessages.map(sessionMessagePersistenceKey); const persistedKeys = new Set(); for (let index = 0; index < turnMessages.length; index++) { const key = turnKeys[index]; if (key === undefined) continue; // Mid-run ordering is keyed by logical identity. A persisted display // variant (for example, redacted/deobfuscated content) must still count; // otherwise the assistant can look missing while later tool results are // present, producing a false out-of-order skip. if (branchKeys.has(key)) { persistedKeys.add(key); } } const plan = planTurnPersistence(turnKeys, persistedKeys); if (plan.kind === "out-of-order") { const message = turnMessages[plan.messageIndex]; logger.debug("Skipping mid-run compaction because turn persistence is out of order", { role: message.role, timestamp: message.timestamp, }); return false; } for (const index of plan.toPersist) { this.#persistSessionMessageIfMissing(turnMessages[index]); } return true; } #processAgentEvent = async (event: AgentEvent): Promise => { // A fresh run supersedes the previously settled (and pruned) refusal // turn: state-based lookups take over again. if (event.type === "agent_start") { this.#prunedTerminalRefusal = undefined; } // Step the mid-run todo counter synchronously, BEFORE any await in this // handler. The agent loop's next-turn `getAsideMessages` poll can run // before queued microtasks drain, so `#takeMidRunTodoNudge` MUST see the // freshest counter — otherwise a turn that just invoked `todo` could // trip a spurious nudge against stale state, and a turn that just hit // the threshold could fail to nudge until a later turn (issue #3651). // Pure in-memory math — no ordering requirement vs persistence or // session-event fan-out. Keyed on toolResult (not the assistant toolCall // turn) so planned-but-aborted or permission-denied calls never count, // and only successful mutating tools tick — read-only exploration is // not progress an agent could mark done. if (event.type === "message_end" && event.message.role === "toolResult") { this.#todo.onToolResult(event.message.toolName, event.message.isError); } // Track the settled assistant turn synchronously as well: agent_end // maintenance reads `#lastAssistantMessage`, and when a turn's events all // land in one tick its handler can run before this handler's post-emit // bookkeeping — leaving maintenance looking at the previous (e.g. // toolUse) assistant message and skipping settle-only work. if (event.type === "message_end" && event.message.role === "assistant") { this.#lastAssistantMessage = event.message; } // Plan-mode internal transition: stamp `SILENT_ABORT_MARKER` on the // persisted message BEFORE the obfuscator's display-side copy below. // Invariant (must hold across refactors): this branch precedes the // `let displayEvent = event; ... displayEvent = { ...event, message: { ...message, content: deobfuscated } }` // block. After stamping, both `displayEvent.message` (via the spread) // and `event.message` (in-place mutation, used by SessionManager // persistence) carry the marker, guaranteeing streaming render and // history replay branch identically. The one-shot flag is consumed // here, scoped strictly to this aborted message_end; callers still clear it // in `finally` so a leaked flag cannot silence a later unrelated abort. if ( event.type === "message_end" && event.message.role === "assistant" && event.message.stopReason === "aborted" ) { const message = event.message as AssistantMessage; if (this.#planInternalAbortPending) { message.errorMessage = SILENT_ABORT_MARKER; message.errorId = AIError.create(AIError.Flag.SilentAbort); this.#planInternalAbortPending = false; } else if (this.#pendingAbortErrorId) { message.errorId = this.#pendingAbortErrorId; this.#pendingAbortErrorId = undefined; } } const interruptedThinkingMessage = event.type === "message_end" && event.message.role === "assistant" ? this.#demoteInterruptedThinkingOnUserInterrupt(event.message as AssistantMessage) : undefined; // `message_end` handling is fire-and-forget from agent-core. Make the // hidden continuity turn visible to the next prompt before any awaited // extension delivery or persistence can stall this handler. if (interruptedThinkingMessage) { this.agent.appendMessage(interruptedThinkingMessage); } const messageEndPersistence = event.type === "message_end" ? this.#createMessageEndPersistenceSlot(event.message) : undefined; // Deobfuscate assistant message content for display emission — the LLM echoes back // obfuscated placeholders, but listeners (TUI, extensions, exporters) must see real // values. The original event.message stays obfuscated so the persistence path below // writes `#HASH#` tokens to the session file; convertToLlm re-obfuscates outbound // traffic on the next turn. Walks text, thinking, and toolCall arguments/intent. let displayEvent: AgentEvent = event; const obfuscator = this.#obfuscator; if (obfuscator && event.type === "message_end" && event.message.role === "assistant") { const message = event.message; const deobfuscatedContent = deobfuscateAssistantContent(obfuscator, message.content); if (deobfuscatedContent !== message.content) { displayEvent = { ...event, message: { ...message, content: deobfuscatedContent } }; } } if (event.type === "turn_start") { const usage = this.getSessionStats().tokens; this.#goalRuntime.onTurnStart(`turn-${++this.#goalTurnCounter}`, { input: usage.input, output: usage.output, cacheRead: usage.cacheRead, cacheWrite: usage.cacheWrite, }); } if (event.type === "tool_execution_start") { this.#recordToolExecutionStart(event); } if (event.type !== "agent_end") { try { await this.#emitSessionEvent(displayEvent); } catch (error) { messageEndPersistence?.release(); throw error; } } if (event.type === "turn_start") { this.#streamingEditGuard.reset(); this.#ttsr.onTurnStart(); } if (event.type === "turn_end") this.#ttsr.onTurnEnd(); // Finalize the tool-choice queue's in-flight yield after tools have executed. // This must happen at turn_end (not message_end) because onInvoked handlers // run during tool execution, which happens between message_end and turn_end. if (event.type === "turn_end" && this.#toolChoiceQueue.hasInFlight) { const msg = event.message as AssistantMessage; if (msg.stopReason === "aborted" || msg.stopReason === "error") { this.#toolChoiceQueue.reject(msg.stopReason === "error" ? "error" : "aborted"); } else { this.#toolChoiceQueue.resolve(); } } if (event.type === "tool_execution_end") { if (event.toolName === "goal") { await this.#goalRuntime.onGoalToolCompleted(); } else { await this.#goalRuntime.onToolCompleted(event.toolName); } this.#planModeReminderAwaitingProgress = false; if ( event.toolName === "ask" || writeDeviceDispatch(event.toolName, event.result)?.tool === PROPOSE_DEVICE_NAME ) { this.#planModeReminderCount = 0; this.#planModeReminderAwaitingProgress = false; } } if (await this.#ttsr.checkMessageUpdate(event)) return; if ( event.type === "message_update" && (event.assistantMessageEvent.type === "toolcall_start" || event.assistantMessageEvent.type === "toolcall_delta" || event.assistantMessageEvent.type === "toolcall_end") ) { this.#streamingEditGuard.preCache(event); } if ( event.type === "message_update" && (event.assistantMessageEvent.type === "toolcall_end" || event.assistantMessageEvent.type === "toolcall_delta") ) { this.#streamingEditGuard.maybeAbort(event); } // Handle session persistence if (event.type === "message_end") { const persistMessageEnd = () => { // Check if this is a hook/custom message if (event.message.role === "hookMessage" || event.message.role === "custom") { // Persist as CustomMessageEntry this.sessionManager.appendCustomMessageEntry( event.message.customType, event.message.content, event.message.display, event.message.details, event.message.attribution ?? "agent", ); if (event.message.role === "custom" && event.message.customType === "ttsr-injection") { this.#ttsr.markInjectedFromDetails(event.message.details); } } else { this.#persistSessionMessageIfMissing(event.message); } }; if (messageEndPersistence) { await messageEndPersistence.persist(persistMessageEnd); } else { persistMessageEnd(); } if (interruptedThinkingMessage) { this.sessionManager.appendCustomMessageEntry( interruptedThinkingMessage.customType, interruptedThinkingMessage.content, interruptedThinkingMessage.display, interruptedThinkingMessage.details, interruptedThinkingMessage.attribution, ); } // Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere if (event.message.role === "assistant") { const assistantMsg = event.message as AssistantMessage; // Fold this turn's timing into per-model perf aggregates (drives the // /models TPS/TTFT display). Errored turns measure nothing; aborted // turns with reported usage are still valid throughput samples. if (assistantMsg.stopReason !== "error" && assistantMsg.duration !== undefined) { this.settings.getStorage()?.recordModelPerf(`${assistantMsg.provider}/${assistantMsg.model}`, { outputTokens: assistantMsg.usage.output, durationMs: assistantMsg.duration, ttftMs: assistantMsg.ttft, }); } if ( assistantMsg.disabledFeatures?.includes("priority") && this.serviceTierByFamily.anthropic === "priority" ) { this.setServiceTierFamily("anthropic", undefined); this.emitNotice( "warning", "Priority/fast mode rejected for this model; retried without it. Fast mode is now off.", "priority", ); } this.#ttsr.onAssistantMessageEnd(assistantMsg); if (this.#handoff.isGeneratingHandoff) { this.#maintenance.skipPostTurnMaintenanceAssistantTimestamp = assistantMsg.timestamp; } await this.#recovery.onAssistantSettledSuccessfully(assistantMsg); if (assistantMsg.provider === "opencode-go") { this.#modelRegistry.authStorage.recordUsageCost(assistantMsg.provider, assistantMsg.usage.cost.total, { sessionId: this.#activeProviderSessionId(), recordedAt: assistantMsg.timestamp, baseUrl: this.#modelRegistry.getProviderBaseUrl?.(assistantMsg.provider), }); } // Broker deployments: report this request's burn so the broker can // attribute token usage per install. No-op with a local auth store. this.#modelRegistry.authStorage.recordObservedUsage({ provider: assistantMsg.provider, model: assistantMsg.model, at: assistantMsg.timestamp, usage: { input: assistantMsg.usage.input, output: assistantMsg.usage.output, cacheRead: assistantMsg.usage.cacheRead, cacheWrite: assistantMsg.usage.cacheWrite, }, costUsd: assistantMsg.usage.cost.total, }); } if (event.message.role === "toolResult") { const { toolName, toolCallId, isError, content } = event.message; const details = isRecord(event.message.details) ? event.message.details : undefined; const semanticResult = semanticToolResult(toolName, event.message); const semanticDetails = isRecord(semanticResult?.details) ? semanticResult.details : undefined; // Invalidate streaming edit cache when edit tool completes to prevent stale data const editedPath = details ? stringProperty(details, "path") : undefined; if (toolName === "edit" && editedPath) { this.#streamingEditGuard.invalidate(editedPath); } if (toolName === "todo" && !isError && details && this.#todo.onTodoResultDetails(details, toolCallId)) { this.#scheduleReplanTitleRefresh(); } if (toolName === "todo" && isError) { const errorText = content.find(part => part.type === "text")?.text; const reminderText = [ "", "todo failed, so todo progress is not visible to the user.", errorText ? `Failure: ${errorText}` : "Failure: todo returned an error.", "Fix the todo payload and call todo again before continuing.", "", ].join("\n"); await this.sendCustomMessage( { customType: "todo-error-reminder", content: reminderText, display: false, details: { toolName, errorText }, }, { deliverAs: "nextTurn" }, ); } if (semanticResult?.toolName === "checkpoint" && !isError) { const checkpointEntryId = this.sessionManager.getEntries().at(-1)?.id ?? null; this.#checkpointState = { checkpointMessageCount: this.agent.state.messages.length, checkpointEntryId, startedAt: (semanticDetails && stringProperty(semanticDetails, "startedAt")) ?? new Date().toISOString(), }; this.#pendingRewindReport = undefined; this.#lastCompletedRewind = undefined; } if (semanticResult?.toolName === "rewind" && !isError && this.#checkpointState) { const detailReport = semanticDetails ? (stringProperty(semanticDetails, "report")?.trim() ?? "") : ""; const textReport = content?.find(part => part.type === "text")?.text?.trim() ?? ""; const report = detailReport || textReport; if (report.length > 0) { this.#pendingRewindReport = report; } } } } // Check auto-retry and auto-compaction after agent completes if (event.type === "agent_end") { const settledMessages = event.messages; const activeMessages = this.agent.state.messages; // TTSR retry work runs concurrently and clears the live flag before // maintenance can emit agent_end, so preserve the state at settle entry. const ttsrAbortPendingAtAgentEnd = this.#ttsr.abortPending; const emitAgentEndNotification = async (options?: { willContinue?: boolean }) => { // Public agent_end is held out of the eager display pass and emitted // here after maintenance routing, tagged isTerminal so subscribers can // tell final settles from scheduled continuations. await this.#emitSessionEvent({ ...event, isTerminal: !options?.willContinue }); void this.#emitAgentEndNotification(activeMessages, options).catch(err => { logger.error("Agent end extension notification failed", { err }); }); }; const usage = this.getSessionStats().tokens; await this.#goalRuntime.onAgentEnd({ currentUsage: { input: usage.input, output: usage.output, cacheRead: usage.cacheRead, cacheWrite: usage.cacheWrite, }, }); const fallbackAssistant = [...settledMessages] .reverse() .find((message): message is AssistantMessage => message.role === "assistant"); const msg = this.#lastAssistantMessage ?? fallbackAssistant; this.#lastAssistantMessage = undefined; if (!msg) { this.#lastSuccessfulYieldToolCallId = undefined; logger.debug("agent_end maintenance routing", { reason: "no-assistant-message", goalModeEnabled: this.#goalModeState?.enabled === true, goalStatus: this.#goalModeState?.goal.status, }); await emitAgentEndNotification(); return; } const yieldOnThisMessage = this.#assistantEndedWithSuccessfulYield(msg); const successfulYieldMessage = yieldOnThisMessage ? msg : this.#findSuccessfulYieldAssistantMessage(settledMessages); const maintenanceRoute = (route: string, extra?: Record) => { logger.debug("agent_end maintenance routing", { route, stopReason: msg.stopReason, provider: msg.provider, model: msg.model, contentBlocks: msg.content.length, hasToolCalls: msg.content.some(content => content.type === "toolCall"), hasText: msg.content.some(content => content.type === "text"), goalModeEnabled: this.#goalModeState?.enabled === true, goalStatus: this.#goalModeState?.goal.status, successfulYield: successfulYieldMessage !== undefined, ...extra, }); }; maintenanceRoute("entered"); // Surface provider stream failures in the main log. The routing trace // above is debug-only and drops the error fields, so a session dying // repeatedly on provider errors otherwise leaves no actionable trace // outside the session transcript (issue #6177). logProviderTurnError(msg); // Invalidate GitHub Copilot credentials on auth failure so stale tokens // aren't reused on the next request if ( msg.stopReason === "error" && msg.provider === "github-copilot" && AIError.is(AIError.classifyMessage(msg), AIError.Flag.AuthFailed) ) { await this.#modelRegistry.authStorage.remove("github-copilot"); } if (this.#maintenance.skipPostTurnMaintenanceAssistantTimestamp === msg.timestamp) { this.#maintenance.skipPostTurnMaintenanceAssistantTimestamp = undefined; this.#lastSuccessfulYieldToolCallId = undefined; maintenanceRoute("skip-post-turn-maintenance"); await emitAgentEndNotification(); return; } const activeGoal = this.#goalModeState?.enabled === true && this.#goalModeState.goal.status === "active"; // A successful `yield` in this run is terminal for execution purposes. // Suppress empty-stop retry, unexpected-stop retry, queued-message drain, // and compaction-driven continuations for the rest of this prompt cycle: // the executor consumed the yield as the terminal result, so a trailing // empty/aborted assistant stop must NOT revive the agent loop. The // `#yieldTerminationPending` sticky flag clears on the next `prompt()`. if (successfulYieldMessage || this.#yieldTerminationPending) { this.#lastSuccessfulYieldToolCallId = undefined; if (successfulYieldMessage && activeGoal) { maintenanceRoute( yieldOnThisMessage ? "successful-yield-active-goal-checkCompaction" : "post-yield-trailing-stop-active-goal-checkCompaction", ); const compactionTask = this.#maintenance.checkCompaction(successfulYieldMessage); this.#trackPostPromptTask(compactionTask); await compactionTask; } else if (successfulYieldMessage) { maintenanceRoute("successful-yield-no-active-goal"); } else { maintenanceRoute("post-yield-trailing-stop-suppressed"); } await emitAgentEndNotification(); return; } this.#lastSuccessfulYieldToolCallId = undefined; // Empty-stop cleanup MUST run before any compaction continuation: an // empty toolUse stop must be stripped from active context + session // history before we schedule another turn, otherwise the next // Anthropic turn carries a tool_use block with no matching // tool_result and corrupts message history. The handler also // schedules its own retry, so a real empty stop never needs the // active-goal threshold pre-empt below. if (await this.#recovery.handleEmptyAssistantStop(msg)) { maintenanceRoute("empty-stop-handled"); await emitAgentEndNotification({ willContinue: true }); return; } let compactionResult = COMPACTION_CHECK_NONE; let checkedCompaction = false; if (activeGoal) { maintenanceRoute("active-goal-pre-empt-checkCompaction"); const compactionTask = this.#maintenance.checkCompaction(msg); this.#trackPostPromptTask(compactionTask); compactionResult = await compactionTask; checkedCompaction = true; const compactionContinues = compactionResult.deferredHandoff || compactionResult.continuationScheduled; if (compactionContinues || compactionResult.automaticContinuationBlocked) { maintenanceRoute("active-goal-pre-empt-compaction-handled", { deferredHandoff: compactionResult.deferredHandoff, continuationScheduled: compactionResult.continuationScheduled, automaticContinuationBlocked: compactionResult.automaticContinuationBlocked === true, }); this.#recovery.resolveRetry(); await emitAgentEndNotification( compactionResult.continuationScheduled ? { willContinue: true } : undefined, ); return; } } if (await this.#recovery.handleUnexpectedAssistantStop(msg)) { maintenanceRoute("unexpected-stop-handled"); await emitAgentEndNotification({ willContinue: true }); return; } if (this.#recovery.isRetryableReasonlessAbort(msg)) { const didRetry = await this.#recovery.handleRetryableError(msg, { allowModelFallback: false }); if (didRetry) { await emitAgentEndNotification({ willContinue: true }); return; } } // A deliberate abort should settle the current turn, not trigger queued // continuations — except TTSR self-repair, which already scheduled a // hidden retry while #ttsrAbortPending is still true. if (msg.stopReason === "aborted") { this.#recovery.resolveRetry(); this.#resetSessionStopContinuationState(); await emitAgentEndNotification(ttsrAbortPendingAtAgentEnd ? { willContinue: true } : undefined); return; } // Fireworks Fast variants degrade to their base model on a failed turn — // including hard router errors the generic retry classifier rejects — so // run this gate before the standard retryability check. if (this.#recovery.isFireworksFastFallbackEligible(msg)) { const didRetry = await this.#recovery.handleRetryableError(msg, { fireworksFastFallback: true }); if (didRetry) { await emitAgentEndNotification({ willContinue: true }); return; } } const resumeResolvedStreamStall = this.#recovery.canResumeResolvedStreamStall(msg); if (resumeResolvedStreamStall || this.#recovery.isRetryableError(msg)) { const didRetry = await this.#recovery.handleRetryableError( msg, resumeResolvedStreamStall ? { preserveFailedTurn: true } : undefined, ); if (didRetry) { await emitAgentEndNotification({ willContinue: true }); return; } } else if (this.#recovery.isHardErrorFallbackEligible(msg)) { // A non-retryable hard error on a model covered by a configured // fallback chain: retrying the SAME model is pointless, but a // DIFFERENT model is a fresh chance — consult the chain before // surfacing the failure. #handleRetryableError bails out (no // backoff-retry of the failing model) when no switch happens. const didRetry = await this.#recovery.handleRetryableError(msg, { hardErrorFallback: true }); if (didRetry) { await emitAgentEndNotification({ willContinue: true }); return; } } // Classifier refusals are persisted-skipped above; also prune the trailing // stub from active context so the next turn's prompt does not replay it. // Keep a reference for post-settle readers (print mode, task executor via // getLastAssistantMessage) — pruning made the terminal error invisible to // anything inspecting agent state after prompt() resolved. // Fall through to the standard error tail so `session_stop` hooks (block, // continue, telemetry) still fire — matching the pre-fix flow for // `stopReason === "error"`. if (this.#recovery.isClassifierRefusal(msg)) { this.#prunedTerminalRefusal = msg; this.#recovery.removeAssistantMessageFromActiveContext(msg); } else if (!AIError.isContextOverflow(msg, this.model?.contextWindow ?? 0)) { // No retry, fallback, or compaction continuation fired: this errored // turn ends the run. #persistSessionMessageIfMissing dropped it as an // empty error turn, so record it here — otherwise the JSONL stops at // the last tool result and the provider's errorMessage is lost (#6249). // Idempotent and a no-op for non-empty turns. Content-less overflow // rejections stay live-UI only per the auto-compaction progress guard: // persisting one would replay an empty assistant turn on reload. await this.#recovery.persistTerminalEmptyErrorTurn(msg); } this.#recovery.resolveRetry(); if (!checkedCompaction) { maintenanceRoute("bottom-checkCompaction"); const compactionTask = this.#maintenance.checkCompaction(msg); this.#trackPostPromptTask(compactionTask); compactionResult = await compactionTask; } await this.#recovery.onErrorSettledWithoutRetry(msg, compactionResult); // Stop-time todo reconciliation only fires at a text-only final stop. A run // that ends still mid-tool-use (deadline hit, context full, etc.) skips the // reminder so we don't pile a follow-up onto an already in-flight turn. // Mid-run sync is handled separately via #takeMidRunTodoNudge so a long // tool-use loop still gets prodded to keep the live HUD honest (issue #3651). const hasToolCalls = msg.content.some(content => content.type === "toolCall"); if (hasToolCalls) { await emitAgentEndNotification(); return; } // When compaction queued recovery or hit a deliberate dead-end, skip the // rewind/todo/session_stop passes: any reminder or hook continuation we append // here would race the handoff, retry, auto-continue prompt, queued-message // drain, or the explicit pause that is preventing a compaction loop. if ( compactionResult.deferredHandoff || compactionResult.continuationScheduled || compactionResult.automaticContinuationBlocked ) { await emitAgentEndNotification(compactionResult.continuationScheduled ? { willContinue: true } : undefined); return; } if (msg.stopReason !== "error") { if (this.#enforceRewindBeforeYield()) { await emitAgentEndNotification({ willContinue: true }); return; } const planModeContinuationScheduled = await this.#enforcePlanModeDecisionAtSettle(); if (planModeContinuationScheduled) { await emitAgentEndNotification({ willContinue: true }); return; } const todoContinuationScheduled = await this.#todo.checkCompletion(msg); if (todoContinuationScheduled) { await emitAgentEndNotification({ willContinue: true }); return; } } // A pending async wake means this settle is a scheduling pause, not // the terminal stop: the async-result delivery continues the loop and // the real stop settles later. Defer the session_stop hook pass until // the session is fully idle (the todo reminder above defers the same // way inside #checkTodoCompletion). if (this.#hasPendingAsyncWake()) { await emitAgentEndNotification({ willContinue: true }); return; } const sessionStopWillContinue = await this.#emitSessionStopEvent(activeMessages, msg); await emitAgentEndNotification(sessionStopWillContinue ? { willContinue: true } : undefined); } }; #ensurePostPromptTasksPromise(): void { if (this.#postPromptTasksPromise) return; const { promise, resolve } = Promise.withResolvers(); this.#postPromptTasksPromise = promise; this.#postPromptTasksResolve = resolve; } #resolvePostPromptTasks(): void { if (!this.#postPromptTasksResolve) return; this.#postPromptTasksResolve(); this.#postPromptTasksResolve = undefined; this.#postPromptTasksPromise = undefined; } #trackPostPromptTask(task: Promise): void { this.#postPromptTasks.add(task); this.#ensurePostPromptTasksPromise(); void task .catch(() => {}) .finally(() => { this.#postPromptTasks.delete(task); if (this.#postPromptTasks.size === 0) { this.#resolvePostPromptTasks(); } }); } #schedulePostPromptTask( task: (signal: AbortSignal) => Promise, options?: { delayMs?: number; generation?: number; onSkip?: (reason: PostPromptSkipReason) => void }, ): void { const delayMs = options?.delayMs ?? 0; const signal = this.#postPromptTasksAbortController.signal; const scheduled = (async () => { if (delayMs > 0) { try { await scheduler.wait(delayMs, { signal }); } catch { return; } } if (signal.aborted) { options?.onSkip?.("aborted"); return; } if (options?.generation !== undefined && this.#promptGeneration !== options.generation) { options.onSkip?.("stale-generation"); return; } await task(signal); })(); this.#trackPostPromptTask(scheduled); } #skipAgentContinue(reason: AgentContinueSkipReason, options: ScheduledAgentContinueOptions | undefined): void { logger.debug("agent.continue skipped after scheduling", { reason }); options?.onSkip?.(reason); } #scheduleAgentContinue(options?: ScheduledAgentContinueOptions): void { this.#schedulePostPromptTask( async signal => { // Defense in depth: if compaction/handoff slipped onto the post-prompt queue // alongside us (e.g. via a scheduler we don't own), refuse to start a fresh // streaming turn — agent.continue() here would race the handoff's session // reset. The first-class fix is in #checkCompaction/the agent_end handler, // but this guard catches anything that bypasses that path. if (signal.aborted || this.#isDisposed || this.isCompacting || this.isGeneratingHandoff) { this.#skipAgentContinue("session-unavailable", options); return; } if (options?.shouldContinue && !options.shouldContinue()) { this.#skipAgentContinue("should-continue-false", options); return; } this.#beginInFlight(); try { await this.#recovery.maybeRestoreRetryFallbackPrimary(); if ( this.settings.get("retry.modelFallback") && this.settings.get("retry.usageAwareFallback") && !(await this.#runUsageAwarePreflight()) ) { this.#skipAgentContinue("session-unavailable", options); return; } if (signal.aborted || this.#isDisposed) { this.#skipAgentContinue("post-restore-unavailable", options); return; } await this.agent.continue(); } catch (error) { logger.warn("agent.continue failed after scheduling", { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }); options?.onError?.(); } finally { this.#endInFlight(); } }, { delayMs: options?.delayMs, generation: options?.generation, onSkip: reason => this.#skipAgentContinue(reason, options), }, ); } #scheduleCompactionContinuation(options: { generation: number; autoContinue: boolean; terminalTextAnswer: boolean; suppressContinuation: boolean; }): boolean { if (options.suppressContinuation) return false; if (this.agent.hasQueuedMessages()) { this.#scheduleAgentContinue({ delayMs: 100, generation: options.generation, shouldContinue: () => this.agent.hasQueuedMessages(), }); return true; } if (!options.autoContinue) return false; const activeGoal = this.#goalModeState?.enabled === true && this.#goalModeState.goal.status === "active"; if (options.terminalTextAnswer && !activeGoal) return false; return this.#scheduleAutoContinuePrompt(options.generation); } #scheduleAutoContinuePrompt(generation: number): boolean { const continuePrompt = async () => { // Compaction summarizes away the first-message eager preludes, so re-assert the // delegate-via-tasks / phased-todo reminders on this auto-resumed turn. This runs // at invocation (past the abort check below), so an aborted continuation queues // nothing; scoped to this request via prependMessages, never the shared queue. const eagerNudges = this.#todo.buildPostCompactionEagerNudges(); await this.#promptWithMessage( { role: "developer", content: [{ type: "text", text: autoContinuePrompt }], attribution: "agent", timestamp: Date.now(), }, autoContinuePrompt, { skipPostPromptRecoveryWait: true, prependMessages: eagerNudges.length > 0 ? eagerNudges : undefined, }, ); }; this.#schedulePostPromptTask( async signal => { await Promise.resolve(); if (signal.aborted) return; if (this.agent.hasQueuedMessages()) { this.#scheduleAgentContinue({ generation, shouldContinue: () => this.agent.hasQueuedMessages(), }); return; } await continuePrompt(); }, { generation }, ); return true; } async #cancelPostPromptTasks(): Promise { this.#postPromptTasksAbortController.abort(); this.#postPromptTasksAbortController = new AbortController(); this.#ttsr.resolveResume(); const pendingTasks = Array.from(this.#postPromptTasks); if (pendingTasks.length === 0) { this.#resolvePostPromptTasks(); return; } await Promise.allSettled(pendingTasks); if (this.#postPromptTasks.size === 0) { this.#resolvePostPromptTasks(); } } /** * Wait for retry, TTSR resume, and any background continuation to settle. * Loops because a TTSR continuation can trigger a retry (or vice-versa), * and fire-and-forget `agent.continue()` may still be streaming after * the TTSR resume gate resolves. */ async #waitForPostPromptRecovery(generation?: number): Promise { while (true) { // An abort bumps #promptGeneration. When this wait runs on behalf of a // specific prompt turn, stop as soon as that turn has been superseded: // its promise must resolve on the abort, not block on a queued // steer/follow-up that the post-abort drain starts as a fresh turn. if (generation !== undefined && this.#promptGeneration !== generation) return; const retryPromise = this.#recovery.retryPromise; if (retryPromise) { await retryPromise; continue; } const ttsrResumeGate = this.#ttsr.resumeGate; if (ttsrResumeGate) { await ttsrResumeGate; continue; } if (this.#postPromptTasksPromise) { await this.#postPromptTasksPromise; continue; } // Tracked post-prompt tasks cover deferred continuations scheduled from // event handlers. Keep the streaming fallback for direct agent activity // outside the scheduler. if (this.agent.state.isStreaming) { await this.agent.waitForIdle(); continue; } break; } } #afterToolCall(ctx: AfterToolCallContext): AfterToolCallResult | undefined { if ( this.#isTerminalYieldToolResult({ toolName: ctx.toolCall.name, isError: ctx.isError, result: ctx.result, }) ) { this.#markTerminalYieldToolCall(ctx.toolCall.id); this.#synchronouslyTerminatedYieldToolCallIds.add(ctx.toolCall.id); this.agent.abort(TERMINAL_TOOL_RESULT_ABORT_REASON); } return this.#ttsr.afterToolCall(ctx); } /** Find the last assistant message in agent state (including aborted ones) */ #findLastAssistantMessage(): AssistantMessage | undefined { const messages = this.agent.state.messages; for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; if (msg.role === "assistant") { return msg as AssistantMessage; } } return undefined; } #localProtocolOptions(): LocalProtocolOptions { return { getArtifactsDir: () => this.sessionManager.getArtifactsDir(), getSessionId: () => this.sessionManager.getSessionId(), }; } #resetSessionStopContinuationState(): void { this.#sessionStopContinuationCount = 0; this.#sessionStopHookActive = false; } #clearPendingSessionStopContinuations(): void { if (!this.#pendingNextTurnMessages.some(message => message.customType === "session-stop-continuation")) { return; } this.#pendingNextTurnMessages = this.#pendingNextTurnMessages.filter( message => message.customType !== "session-stop-continuation", ); } #sessionStopContinuationContext(result: SessionStopEventResult | undefined): string | undefined { if (!result) return undefined; const additionalContext = typeof result.additionalContext === "string" && result.additionalContext.length > 0 ? result.additionalContext : undefined; const reason = typeof result.reason === "string" && result.reason.length > 0 ? result.reason : undefined; if (result.continue === true) { return additionalContext ?? reason; } if (result.decision === "block") { return reason ?? additionalContext; } return undefined; } async #emitAgentEndNotification(messages: AgentMessage[], options?: { willContinue?: boolean }): Promise { await this.#extensionRunner?.emit({ type: "agent_end", messages, willContinue: options?.willContinue, }); } /** @returns true when a hidden session_stop continuation turn was scheduled. */ async #emitSessionStopEvent( messages: AgentMessage[], lastAssistantMessage = this.getLastAssistantMessage(), ): Promise { if (this.#abortInProgress || this.#isDisposed) { this.#resetSessionStopContinuationState(); return false; } if (this.#agentKind === "sub" || !this.#extensionRunner?.hasHandlers("session_stop")) { return false; } const generation = this.#promptGeneration; const result = await this.#extensionRunner.emitSessionStop({ messages, turn_id: Math.max(0, this.#turnIndex - 1), last_assistant_message: lastAssistantMessage, session_id: this.sessionId, session_file: this.sessionFile, stop_hook_active: this.#sessionStopHookActive, }); if (this.#promptGeneration !== generation || this.#abortInProgress || this.#isDisposed) { this.#resetSessionStopContinuationState(); return false; } const additionalContext = this.#sessionStopContinuationContext(result); if (!additionalContext) { this.#resetSessionStopContinuationState(); return false; } if (this.#sessionStopContinuationCount >= SESSION_STOP_CONTINUATION_CAP) { logger.warn("session_stop continuation cap reached", { sessionId: this.sessionId, cap: SESSION_STOP_CONTINUATION_CAP, }); this.#resetSessionStopContinuationState(); return false; } this.#sessionStopContinuationCount++; this.#sessionStopHookActive = true; this.#queueHiddenNextTurnMessage( { role: "custom", customType: "session-stop-continuation", content: additionalContext, display: false, attribution: "agent", timestamp: Date.now(), }, true, ); return true; } /** Emit extension events based on session events */ async #emitExtensionEvent(event: AgentSessionEvent): Promise { if (!this.#extensionRunner) return; if (event.type === "agent_start") { this.#turnIndex = 0; await this.#extensionRunner.emit({ type: "agent_start" }); return; } if (!this.#extensionRunner.hasHandlers(event.type)) return; if (event.type === "agent_end") { // `agent_end` extension notification is emitted from the settled // agent_end maintenance path so `session_stop` control hooks are not // blocked by unrelated notification-only work. } else if (event.type === "turn_start") { const hookEvent: TurnStartEvent = { type: "turn_start", turnIndex: this.#turnIndex, timestamp: Date.now(), }; await this.#extensionRunner.emit(hookEvent); } else if (event.type === "turn_end") { const hookEvent: TurnEndEvent = { type: "turn_end", turnIndex: this.#turnIndex, message: event.message, toolResults: event.toolResults, }; await this.#extensionRunner.emit(hookEvent); this.#turnIndex++; } else if (event.type === "message_start") { const extensionEvent: MessageStartEvent = { type: "message_start", message: event.message, }; await this.#extensionRunner.emit(extensionEvent); } else if (event.type === "message_update") { const extensionEvent: MessageUpdateEvent = { type: "message_update", message: event.message, assistantMessageEvent: event.assistantMessageEvent, }; await this.#extensionRunner.emit(extensionEvent); } else if (event.type === "message_end") { const extensionEvent: MessageEndEvent = { type: "message_end", message: event.message, }; await this.#extensionRunner.emit(extensionEvent); } else if (event.type === "tool_execution_start") { const extensionEvent: ToolExecutionStartEvent = { type: "tool_execution_start", toolCallId: event.toolCallId, toolName: event.toolName, args: event.args, intent: event.intent, }; await this.#extensionRunner.emit(extensionEvent); } else if (event.type === "tool_execution_update") { const extensionEvent: ToolExecutionUpdateEvent = { 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: ToolExecutionEndEvent = { type: "tool_execution_end", toolCallId: event.toolCallId, toolName: event.toolName, result: event.result, isError: event.isError ?? false, }; await this.#extensionRunner.emit(extensionEvent); } else if (event.type === "auto_compaction_start") { await this.#extensionRunner.emit({ type: "auto_compaction_start", reason: event.reason, action: event.action, }); } else if (event.type === "auto_compaction_end") { await this.#extensionRunner.emit({ type: "auto_compaction_end", action: event.action, result: event.result, aborted: event.aborted, willRetry: event.willRetry, errorMessage: event.errorMessage, skipped: event.skipped, }); } else if (event.type === "auto_retry_start") { await this.#extensionRunner.emit({ type: "auto_retry_start", attempt: event.attempt, maxAttempts: event.maxAttempts, delayMs: event.delayMs, errorMessage: event.errorMessage, errorId: event.errorId, }); } else if (event.type === "auto_retry_end") { await this.#extensionRunner.emit({ type: "auto_retry_end", success: event.success, attempt: event.attempt, finalError: event.finalError, recoveredErrors: event.recoveredErrors, }); } else if (event.type === "ttsr_triggered") { await this.#extensionRunner.emit({ type: "ttsr_triggered", rules: event.rules }); } else if (event.type === "todo_reminder") { await this.#extensionRunner.emit({ type: "todo_reminder", todos: event.todos, attempt: event.attempt, maxAttempts: event.maxAttempts, }); } else if (event.type === "goal_updated") { await this.#extensionRunner.emit({ type: "goal_updated", goal: event.goal, state: event.state, }); } } /** * Subscribe to agent events. * Session persistence is handled internally (saves messages on message_end). * Multiple listeners can be added. Returns unsubscribe function for this listener. */ subscribe(listener: AgentSessionEventListener): () => void { this.#eventListeners.push(listener); // Return unsubscribe function for this specific listener return () => { const index = this.#eventListeners.indexOf(listener); if (index !== -1) { this.#eventListeners.splice(index, 1); } }; } subscribeCommandMetadataChanged(listener: CommandMetadataChangedListener): () => void { this.#commandMetadataChangedListeners.push(listener); return () => { const index = this.#commandMetadataChangedListeners.indexOf(listener); if (index !== -1) { this.#commandMetadataChangedListeners.splice(index, 1); } }; } #notifyCommandMetadataChanged(): void { const listeners = [...this.#commandMetadataChangedListeners]; for (const listener of listeners) { try { void listener(); } catch (err) { logger.error("Command metadata listener threw", { err }); } } } /** * Temporarily disconnect from agent events. * User listeners are preserved and will receive events again after resubscribe(). * Used internally during operations that need to pause event processing. */ #disconnectFromAgent(): void { if (this.#unsubscribeAgent) { this.#unsubscribeAgent(); this.#unsubscribeAgent = undefined; } } /** * Reconnect to agent events after _disconnectFromAgent(). * Preserves all existing listeners. */ #reconnectToAgent(): void { if (this.#unsubscribeAgent) return; // Already connected this.#unsubscribeAgent = this.agent.subscribe(this.#handleAgentEvent); } #activeProviderSessionId(sessionId?: string): string { return this.#freshProviderSessionId ?? this.#providerSessionId ?? sessionId ?? this.sessionManager.getSessionId(); } #adoptInheritedProviderPromptCacheKey(): void { const key = this.sessionManager.getHeader()?.providerPromptCacheKey; if (!key) return; if (this.#inheritedProviderPromptCacheKey !== undefined || this.agent.promptCacheKey === undefined) { this.agent.promptCacheKey = key; this.#inheritedProviderPromptCacheKey = key; } } #clearInheritedProviderPromptCacheKey(): void { const key = this.#inheritedProviderPromptCacheKey; this.#inheritedProviderPromptCacheKey = undefined; if (key !== undefined && this.agent.promptCacheKey === key) { this.agent.promptCacheKey = undefined; } } /** * Set agent.sessionId from the session manager and install a dynamic * metadata resolver so every Anthropic API request carries * `metadata.user_id` shaped like real Claude Code's `getAPIMetadata` output: * `{ session_id, account_uuid, device_id }`. `account_uuid` is included only * when an Anthropic OAuth credential with a known account UUID is loaded; * `device_id` is derived from both the persistent omp install id and that * account UUID. Resolving live keeps the value in sync with auth-state changes * (login/logout, token refresh that surfaces a new account UUID) without * needing to re-call `#syncAgentSessionId()` on every such event. */ #syncAgentSessionId(sessionId?: string): void { const sid = this.#activeProviderSessionId(sessionId); this.agent.sessionId = sid; this.agent.setMetadataResolver((provider: string) => buildSessionMetadata(sid, provider, this.#modelRegistry.authStorage), ); } /** Run one abortable auto-learn capture outside the primary agent loop. */ async runAutolearnCapture(capture: (signal: AbortSignal) => Promise): Promise { if (this.#autolearnCaptureTask || this.#isDisposed) return; const controller = new AbortController(); this.#autolearnCaptureAbortController = controller; const task = (async () => { try { await capture(controller.signal); } catch (error) { if (!controller.signal.aborted) throw error; } finally { if (this.#autolearnCaptureAbortController === controller) { this.#autolearnCaptureAbortController = undefined; } } })(); this.#autolearnCaptureTask = task; try { await task; } finally { if (this.#autolearnCaptureTask === task) this.#autolearnCaptureTask = undefined; } } #abortAutolearnCapture(): void { this.#autolearnCaptureAbortController?.abort(); } async #drainAutolearnCapture(): Promise { const task = this.#autolearnCaptureTask; if (!task) return; try { await withTimeout(task, 3_000, "Timed out draining auto-learn capture during dispose"); } catch (error) { logger.warn("Auto-learn capture did not settle during dispose", { error: String(error) }); } } /** True once dispose() has begun; deferred background work (e.g. the deferred * MCP discovery task in sdk.ts) must not touch the session past this point. */ get isDisposed(): boolean { return this.#isDisposed; } markMovedFromEmptySessionFile(sessionFile: string): void { this.#movedFromEmptySessionFile = path.resolve(sessionFile); } /** * Synchronously mark the session as disposing so new work is rejected * immediately: eval starts throw, queued asides are dropped, and the * aside provider is detached. Idempotent; `dispose()` runs it first. * * Wrappers that await other teardown before delegating to `dispose()` MUST * call this before their first await — otherwise work started in that async * gap slips past the disposal guards. */ beginDispose(): void { this.#isDisposed = true; this.#memory.cancelLocalMemoryStartup(); this.#titleGenerationAbortController.abort(); this.#abortAutolearnCapture(); this.#irc.flushPending(); this.yieldQueue.clear(); this.agent.setAsideMessageProvider(undefined); this.agent.hasIrcInterrupts = undefined; this.#advisors.stopRuntime(); this.#eval.beginDispose(); } /** * Remove all listeners, flush pending writes, and disconnect from agent. * Call this when completely done with the session. * * Idempotent: concurrent or repeated calls share one settled promise. The * keypress `InteractiveMode.shutdown()` path and the postmortem * `SIGTERM`/`SIGHUP`/`uncaughtException` callback can both target this * method, so a second invocation must never re-emit `session_shutdown` or * double-drain the owned `AsyncJobManager` (issue #4080). */ #disposeCall?: Promise; dispose(options: AgentSessionDisposeOptions = {}): Promise { if (!this.#disposeCall) this.#disposeCall = this.#doDispose(options); return this.#disposeCall; } async #disposeOwnedAsyncJobs(): Promise { // Unregister before cancelling: a job completing during teardown must // dead-letter rather than enqueue a follow-up into a disposing session. this.#unregisterAsyncDeliverySink?.(); this.#unregisterAsyncDeliverySink = undefined; this.#cancelOwnAsyncJobs(); const manager = this.#ownedAsyncJobManager; if (!manager) return; try { const drained = await manager.dispose({ timeoutMs: 3_000 }); const deliveryState = manager.getDeliveryState(); if (drained === false && deliveryState) { logger.warn("Async job completion deliveries still pending during dispose", { ...deliveryState }); } } finally { if (AsyncJobManager.instance() === manager) { AsyncJobManager.setInstance(undefined); } } } async #releaseOwnedBrowserTabs(ownerId: string | undefined): Promise { if (!ownerId) return; try { const released = await withTimeout( releaseTabsForOwner(ownerId, { kill: true }), 3_000, "Timed out releasing owned browser tabs during dispose", ); if (released > 0) { logger.debug("Released owned browser tabs during dispose", { ownerId, released }); } } catch (error) { logger.warn("Failed to release owned browser tabs during dispose", { error: String(error) }); } } async #releaseOwnedComputerSessions(ownerId: string | undefined): Promise { if (!ownerId) return; try { await withTimeout( releaseComputerSessionsForOwner(ownerId), 3_000, "Timed out releasing native computer session during dispose", ); } catch (error) { logger.warn("Failed to release native computer session during dispose", { error: String(error) }); } } async #disconnectOwnedMcp(): Promise { if (!this.#disconnectOwnedMcpManager) return; try { await withTimeout( this.#disconnectOwnedMcpManager(), 3_000, "Timed out disconnecting owned MCP manager during dispose", ); } catch (error) { logger.warn("Failed to disconnect owned MCP manager during dispose", { error: String(error) }); } } async #disposeMnemopi( state: MnemopiSessionState | undefined, consolidateTimeoutMs: number | undefined, ): Promise { try { await state?.dispose({ timeoutMs: consolidateTimeoutMs }); } finally { // Consolidation may embed final memories, so terminate its worker only afterward. await shutdownMnemopiEmbedClient(); } } async #doDispose(options: AgentSessionDisposeOptions = {}): Promise { this.beginDispose(); this.#recordSessionExit(options.reason ?? "dispose"); this.#cancelExitRecorder?.(); this.#cancelExitRecorder = undefined; try { await emitSessionShutdownEvent(this.#extensionRunner); } catch (error) { logger.warn("Failed to emit session_shutdown event", { error: String(error) }); } // Stop fallback extension timers before aborting deferred work they could enqueue. this.#fallbackExtensionTimers?.clearAll(); this.abortRetry(); this.abortCompaction(); const postPromptDrain = this.#cancelPostPromptTasks(); this.agent.abort(); try { await withTimeout(postPromptDrain, 5_000, "Timed out draining post-prompt tasks during dispose"); } catch (error) { logger.warn("Post-prompt tasks still draining at dispose deadline", { error: String(error) }); } await this.#drainAutolearnCapture(); await this.#memory.transition; const hindsightState = this.getHindsightSessionState(); const mnemopiState = setMnemopiSessionState(this, undefined); const advisorRecorderClosed = this.#advisors.recorderClosed(); const results = await Promise.allSettled([ this.#disposeOwnedAsyncJobs(), this.#eval.disposeKernels(), this.#releaseOwnedBrowserTabs(this.sessionManager.getSessionId()), this.#releaseOwnedComputerSessions(this.#eval.getKernelOwnerId()), shutdownTinyTitleClient(), this.#disconnectOwnedMcp(), advisorRecorderClosed, hindsightState?.flushRetainQueue() ?? Promise.resolve(), this.#disposeMnemopi(mnemopiState, options.mnemopiConsolidateTimeoutMs), ]); for (const result of results) { if (result.status === "rejected") { logger.warn("Session dispose subsystem failed during parallel teardown", { error: String(result.reason), }); } } this.#releasePowerAssertion(); await cleanupEmptyMoveSession(this.sessionManager, this.#movedFromEmptySessionFile); this.#movedFromEmptySessionFile = undefined; // All teardown branches that can append session entries have settled. await this.sessionManager.close(); this.#closeAllProviderSessions("dispose"); this.setHindsightSessionState(undefined); hindsightState?.dispose(); this.#disconnectFromAgent(); if (this.#unsubscribeAppendOnly) { this.#unsubscribeAppendOnly(); this.#unsubscribeAppendOnly = undefined; } if (this.#unsubscribeModelRoles) { this.#unsubscribeModelRoles(); this.#unsubscribeModelRoles = undefined; } this.#eventListeners = []; } #closeAllProviderSessions(reason: string): void { for (const [providerKey, state] of this.#providerSessionState) { try { state.close(); } catch (error) { logger.warn("Failed to close provider session state", { providerKey, reason, error: String(error), }); } } this.#providerSessionState.clear(); } freshSession(): FreshSessionResult | undefined { if (this.isStreaming) return undefined; const previousSessionId = this.sessionId; const closedProviderSessions = this.#providerSessionState.size; this.#closeAllProviderSessions("fresh session"); this.#freshProviderSessionId = Bun.randomUUIDv7(); this.#syncAgentSessionId(); this.#memory.rekeyForCurrentSessionId(); this.agent.appendOnlyContext?.invalidateForModelChange(); return { previousSessionId, sessionId: this.sessionId, closedProviderSessions, }; } // ========================================================================= // Read-only State Access // ========================================================================= /** Full agent state */ get state(): AgentState { return this.agent.state; } /** Current model (may be undefined if not yet selected) */ get model(): Model | undefined { return this.agent.state.model; } /** Resolved selector while retry routing is using a fallback model. */ get retryFallbackModel(): string | undefined { return this.#recovery.retryFallbackModel; } /** Install the interactive decision surface for reserve-triggered model changes. */ setUsageFallbackConfirmer( confirmer: ((confirmation: UsageFallbackConfirmation) => Promise) | undefined, ): void { this.#usageFallbackConfirmer = confirmer; } async #runUsageAwarePreflight(): Promise { const generation = this.#promptGeneration; const controller = new AbortController(); this.#usagePreflightAbortControllers.add(controller); try { await this.#maybeApplyUsageAwareFallback(controller.signal); return !controller.signal.aborted && this.#promptGeneration === generation; } catch (error) { if (controller.signal.aborted || this.#promptGeneration !== generation) return false; throw error; } finally { this.#usagePreflightAbortControllers.delete(controller); } } async #confirmUsageFallback(confirmation: UsageFallbackConfirmation, signal: AbortSignal): Promise { const confirmer = this.#usageFallbackConfirmer; if (!confirmer || signal.aborted) return false; const aborted = Promise.withResolvers(); const onAbort = () => aborted.resolve(false); signal.addEventListener("abort", onAbort, { once: true }); try { return await Promise.race([confirmer(confirmation), aborted.promise]); } finally { signal.removeEventListener("abort", onAbort); } } async #maybeApplyUsageAwareFallback(signal: AbortSignal): Promise { if (!this.settings.get("retry.modelFallback") || !this.settings.get("retry.usageAwareFallback")) return; const currentModel = this.model; if (!currentModel) return; const currentSelector = formatRetryFallbackSelector(currentModel, this.thinkingLevel); let health: ModelUsageHealth; try { health = await this.#modelRegistry.authStorage.getModelUsageHealth(currentModel.provider, { modelId: currentModel.id, sessionId: this.sessionId, baseUrl: currentModel.baseUrl, reserveFraction: this.settings.get("retry.usageReservePct") / 100, signal, }); } catch (error) { logger.debug("Usage-aware runtime preflight failed open", { provider: currentModel.provider, model: currentModel.id, error: String(error), }); return; } if (signal.aborted) return; if (health.state === "healthy") { this.#usageReserveApprovedSelector = undefined; const selected = health.accounts.find(account => account.selected); if (selected && selected.state !== "healthy" && health.accounts.some(account => account.state === "healthy")) { this.#modelRegistry.authStorage.releaseSessionCredentialForReselection( currentModel.provider, this.sessionId, ); } return; } if (health.state === "unknown") { this.#usageReserveApprovedSelector = undefined; return; } const reservePolicy = this.settings.get("retry.usageReservePolicy"); if (reservePolicy === "fail-closed") { const condition = health.state === "reserve" ? "reserve reached" : "usage depleted"; throw new Error(`${condition} for ${currentSelector}; reserve policy is fail-closed.`); } const role = this.#recovery.resolveRetryFallbackRole(currentSelector, currentModel); if (!role) return; let fallback: { selector: RetryFallbackSelector; apiKey: string } | undefined; for (const candidate of this.#recovery.findRetryFallbackCandidates(role, currentSelector, currentModel)) { if (this.#recovery.isRetryFallbackSelectorSuppressed(candidate)) continue; const resolved = resolveModelOverride([candidate.raw], this.#modelRegistry, this.settings); const candidateModel = resolved.model ?? this.#modelRegistry.find(candidate.provider, candidate.id); if (!candidateModel) continue; if (!this.#modelRegistry.hasConfiguredAuth(candidateModel)) continue; try { const candidateHealth = await this.#modelRegistry.authStorage.getModelUsageHealth(candidateModel.provider, { modelId: candidateModel.id, sessionId: this.sessionId, baseUrl: candidateModel.baseUrl, reserveFraction: this.settings.get("retry.usageReservePct") / 100, signal, }); if (signal.aborted) return; if (candidateHealth.state === "depleted" || candidateHealth.state === "reserve") continue; if (candidateHealth.state === "healthy") { const selected = candidateHealth.accounts.find(account => account.selected); if ( selected && selected.state !== "healthy" && candidateHealth.accounts.some(account => account.state === "healthy") ) { this.#modelRegistry.authStorage.releaseSessionCredentialForReselection( candidateModel.provider, this.sessionId, ); } } } catch { if (signal.aborted) return; // Unknown usage fails open for an otherwise valid fallback. } if (signal.aborted) return; let apiKey: string | undefined; try { apiKey = await this.#modelRegistry.getApiKey(candidateModel, this.sessionId, { signal }); } catch { if (signal.aborted) return; continue; } if (signal.aborted) return; if (!apiKey) continue; fallback = { selector: candidate, apiKey }; break; } if (!fallback) return; if (health.state === "reserve") { if (reservePolicy === "confirm" && this.#usageFallbackConfirmer) { if (this.#usageReserveApprovedSelector === currentSelector) return; const selected = health.accounts.find(account => account.selected); const remainingFraction = selected?.remainingFraction ?? health.accounts.reduce( (minimum, account) => account.remainingFraction === undefined ? minimum : minimum === undefined ? account.remainingFraction : Math.min(minimum, account.remainingFraction), undefined, ); const shouldFallback = await this.#confirmUsageFallback( { from: currentSelector, to: fallback.selector.raw, remainingPercent: remainingFraction === undefined ? undefined : Math.max(0, remainingFraction * 100), }, signal, ); if (signal.aborted) return; if (!shouldFallback) { this.#usageReserveApprovedSelector = currentSelector; return; } } } if (signal.aborted) return; this.#usageReserveApprovedSelector = undefined; await this.#recovery.applyRetryFallbackCandidate(role, fallback.selector, currentSelector, { pinFallback: true, apiKey: fallback.apiKey, signal, }); } /** Effective thinking level applied to the agent (the resolved level when `auto`). */ get thinkingLevel(): ThinkingLevel | undefined { return this.#models.thinkingLevel; } /** The selector the user configured: `auto` when auto mode is active, else the effective level. */ configuredThinkingLevel(): ConfiguredThinkingLevel | undefined { return this.#models.configuredThinkingLevel(); } /** True when `auto` thinking mode is active. */ get isAutoThinking(): boolean { return this.#models.isAutoThinking; } /** The level `auto` resolved to for the current turn (undefined until classified). */ autoResolvedThinkingLevel(): Effort | undefined { return this.#models.autoResolvedThinkingLevel; } /** Live per-family service tiers (OpenAI / Anthropic / Google). */ get serviceTierByFamily(): ServiceTierByFamily { return this.#models.serviceTierByFamily; } /** Whether agent is currently streaming a response */ get isStreaming(): boolean { return this.agent.state.isStreaming || this.#promptInFlightCount > 0; } get isAborting(): boolean { return this.agent.isAborting; } /** Wait until streaming, event persistence, and deferred recovery work are fully settled. */ async waitForIdle(): Promise { await this.agent.waitForIdle(); await this.#advisors.waitForPendingCardEvents(); await this.#waitForPostPromptRecovery(); } /** * Prevent advisor notes from starting hidden primary turns while a headless * caller prints and drains the final primary response. */ prepareForHeadlessAdvisorDrain(): void { this.#advisors.prepareForHeadlessAdvisorDrain(); } /** * Wait for active advisor reviews and their emitted card events before a * headless caller disposes the session. Returns `false` and logs work disposal * will abandon when the shared deadline expires or an advisor fails. */ waitForAdvisorCatchup(timeoutMs: number): Promise { return this.#advisors.waitForAdvisorCatchup(timeoutMs); } async drainAsyncJobDeliveriesForAcp(options?: { timeoutMs?: number }): Promise { const manager = this.#asyncJobManager; if (!manager) return false; const ownerFilter = this.#agentId ? { ownerId: this.#agentId } : undefined; const before = manager.getDeliveryState(ownerFilter); if (before.queued === 0 && !before.delivering) return false; const previousAllowAcpAgentInitiatedTurns = this.#allowAcpAgentInitiatedTurns; this.#allowAcpAgentInitiatedTurns = true; try { const drained = await manager.drainDeliveries({ timeoutMs: options?.timeoutMs, filter: ownerFilter }); const after = manager.getDeliveryState(ownerFilter); return drained && (before.queued !== after.queued || before.delivering !== after.delivering); } finally { this.#allowAcpAgentInitiatedTurns = previousAllowAcpAgentInitiatedTurns; } } /** * Most recent settled assistant message. A classifier-refusal turn pruned * from active context at settle is still reported until the next run * starts, so terminal-outcome consumers (print mode, task executor) see * the refusal error rather than the previous turn — or nothing. */ getLastAssistantMessage(): AssistantMessage | undefined { return this.#prunedTerminalRefusal ?? this.#findLastAssistantMessage(); } /** Current effective system prompt blocks (includes any per-turn extension modifications) */ get systemPrompt(): string[] { return this.agent.state.systemPrompt; } /** Current retry attempt (0 if not retrying) */ get retryAttempt(): number { return this.#recovery.attempt; } /** Names of tools currently exposed at the top level. */ getActiveToolNames(): string[] { return this.#tools.getActiveToolNames(); } /** Enabled top-level and discoverable tool names. */ getEnabledToolNames(): string[] { return this.#tools.getEnabledToolNames(); } /** Names of dynamic tools mounted under `xd://`. */ getMountedXdevToolNames(): string[] { return this.#tools.getMountedXdevToolNames(); } /** Whether the edit tool is registered in this session. */ get hasEditTool(): boolean { return this.#tools.hasEditTool; } /** Looks up a registered tool by name. */ getToolByName(name: string): AgentTool | undefined { return this.#tools.getToolByName(name); } /** Whether a registry entry came from a built-in factory. */ hasBuiltInTool(name: string): boolean { return this.#tools.hasBuiltInTool(name); } /** Names of every registered tool. */ getAllToolNames(): string[] { return this.#tools.getAllToolNames(); } /** Installs and activates the ephemeral vibe tool set. */ activateVibeTools(baseToolNames: string[]): Promise { return this.#tools.activateVibeTools(baseToolNames); } /** Uninstalls vibe tools and activates the replacement set. */ deactivateVibeTools(nextToolNames: string[]): Promise { return this.#tools.deactivateVibeTools(nextToolNames); } /** Removes vibe tools without restoring a source-session snapshot. */ removeVibeToolsPreservingActive(): Promise { return this.#tools.removeVibeToolsPreservingActive(); } #resolveActiveEditMode(): EditMode { return this.#tools.resolveActiveEditMode(); } #syncAfterModelChange(previousEditMode: EditMode): Promise { return this.#tools.syncAfterModelChange(previousEditMode); } /** Enabled MCP tools in their current presentation partition. */ getSelectedMCPToolNames(): string[] { return this.#tools.getSelectedMCPToolNames(); } #applyActiveToolsByName(toolNames: string[]): Promise { return this.#tools.applyActiveToolsByName(toolNames); } #takePendingXdevMountNotice(): CustomMessage | undefined { return this.#tools.takePendingXdevMountNotice(); } /** Rediscovers reloadable skills and refreshes prompt metadata. */ refreshSkills(): Promise { return this.#tools.refreshSkills(); } /** Selects enabled tools, ignoring names absent from the registry. */ setActiveToolsByName(toolNames: string[]): Promise { return this.#tools.setActiveToolsByName(toolNames); } /** Restores an exact top-level versus `xd://` tool partition. */ setActiveToolPresentation(toolNames: string[], mountedToolNames: string[]): Promise { return this.#tools.setActiveToolPresentation(toolNames, mountedToolNames); } /** * Session-scoped enable/disable for the settings-gated `computer` tool. * * Enabling builds the tool through {@link AgentSessionConfig.createComputerTool} * on first use and activates it; disabling drops it from the active set while * keeping the registry entry so repeated toggles reuse one desktop controller. * * @returns false when enabling was requested but this session cannot build the * tool (e.g. restricted child sessions have no factory). */ setComputerToolEnabled(enabled: boolean): Promise { return this.#tools.setComputerToolEnabled(enabled); } /** Cancels the local rollout-memory startup owned by this session. */ cancelLocalMemoryStartup(): void { this.#memory.cancelLocalMemoryStartup(); } /** Starts a new local rollout-memory generation and cancels its predecessor. */ beginLocalMemoryStartup(): AbortSignal { return this.#memory.beginLocalMemoryStartup(); } /** Releases the local startup slot if `signal` still owns it. */ endLocalMemoryStartup(signal: AbortSignal): void { this.#memory.endLocalMemoryStartup(signal); } /** Applies the selected memory backend to runtime state, tools, and prompt. */ applyMemoryBackend(): Promise { return this.#memory.applyMemoryBackend(); } /** Rebuilds the stable base prompt for the current tools and model. */ refreshBaseSystemPrompt(): Promise { return this.#tools.refreshBaseSystemPrompt(); } #buildSystemPromptForAgentStart(promptText: string): Promise { return this.#tools.buildSystemPromptForAgentStart(promptText); } /** Replaces connected MCP tools and enables them immediately. */ refreshMCPTools(mcpTools: CustomTool[]): Promise { return this.#tools.refreshMCPTools(mcpTools); } /** Replaces host-owned RPC tools before the next model call. */ refreshRpcHostTools(rpcTools: AgentTool[]): Promise { return this.#tools.refreshRpcHostTools(rpcTools); } /** Whether auto-compaction is currently running */ get isCompacting(): boolean { return this.#maintenance.isCompacting; } /** Strip image content from the current branch and persist the rewrite. */ dropImages(): Promise<{ removed: number }> { return this.#maintenance.dropImages(); } /** Reduce stored context with the selected shake strategy. */ shake(mode: ShakeMode, opts: { config?: ShakeConfig; signal?: AbortSignal } = {}): Promise { return this.#maintenance.shake(mode, opts); } /** Compact the active session history. */ compact(customInstructions?: string, options?: CompactOptions): Promise { return this.#maintenance.compact(customInstructions, options); } /** Cancel active manual, automatic, and handoff maintenance. */ abortCompaction(): void { this.#maintenance.abortCompaction(); } /** Trigger idle compaction through the automatic maintenance flow. */ async runIdleCompaction(): Promise { await this.#maintenance.runIdleCompaction(); } /** Toggle automatic compaction. */ setAutoCompactionEnabled(enabled: boolean): void { this.#maintenance.setAutoCompactionEnabled(enabled); } /** Whether automatic compaction is enabled. */ get autoCompactionEnabled(): boolean { return this.#maintenance.autoCompactionEnabled; } /** * Whether idle-flush tasks, auto-continuations, or other short-lived * post-prompt work are pending. True in the brief window after * `session.prompt()` returns but before a scheduled background delivery * (e.g. an async-job result) has finished its own streaming turn. * Loop-mode and similar auto-submit paths should treat this as a block * to avoid racing against the delivery turn. */ get hasPostPromptWork(): boolean { return this.#postPromptTasks.size > 0; } /** Register post-prompt work in tests without driving a full agent turn. */ trackPostPromptTaskForTests(task: Promise): void { if (!isBunTestRuntime()) throw new Error("trackPostPromptTaskForTests is test-only"); this.#trackPostPromptTask(task); } /** All messages including custom types like BashExecutionMessage */ get messages(): AgentMessage[] { return this.agent.state.messages; } /** Latest image attachments addressable by tools as `Image #N` or `attachment://N`. */ getImageAttachments(): { label: string; uri: string; image: ImageContent }[] { return this.#providerBoundary.getImageAttachments(); } buildDisplaySessionContext(): SessionContext { return this.#providerBoundary.buildDisplaySessionContext(); } /** * Transcript for TUI display. Full history is kept for export/resume-style * callers; live chat can collapse compacted history to keep the hot render * surface bounded. Display-only — NEVER feed the result to * `agent.replaceMessages` or a provider. Because it is never re-obfuscated, * it opts into legacy index-derived alias restoration so pre-keyed sessions * still render their secrets; the agent-feeding paths * (`buildDisplaySessionContext`) keep the keyed-only default. */ buildTranscriptSessionContext( options?: Pick, ): SessionContext { return this.#providerBoundary.buildTranscriptSessionContext(options); } #obfuscateTextForProvider(text: string | undefined): string | undefined { return this.#providerBoundary.obfuscateText(text); } #obfuscatePreparationForProvider(preparation: CompactionPreparation): CompactionPreparation { return this.#providerBoundary.obfuscateCompactionPreparation(preparation); } #deobfuscateFromProvider(text: string): string { return this.#providerBoundary.deobfuscateText(text); } #deobfuscatedProviderTextReadyForDelta(text: string): string { return this.#providerBoundary.deobfuscateDelta(text); } #convertToLlmForSideRequest(messages: AgentMessage[]): Message[] { return this.#providerBoundary.convertToLlmForSideRequest(messages); } /** Convert session messages using the same pre-LLM pipeline as the active session. */ async convertMessagesToLlm(messages: AgentMessage[], signal?: AbortSignal): Promise { return await this.#providerBoundary.convertMessagesToLlm(messages, signal); } /** Apply session-level stream hooks to a direct side request. */ prepareSimpleStreamOptions(options: SimpleStreamOptions, provider = "anthropic"): SimpleStreamOptions { return this.#providerBoundary.prepareSimpleStreamOptions(options, provider); } /** Current steering mode */ get steeringMode(): "all" | "one-at-a-time" { return this.agent.getSteeringMode(); } /** Current follow-up mode */ get followUpMode(): "all" | "one-at-a-time" { return this.agent.getFollowUpMode(); } /** Current interrupt mode */ get interruptMode(): "immediate" | "wait" { return this.agent.getInterruptMode(); } /** Current session file path, or undefined if sessions are disabled */ get sessionFile(): string | undefined { return this.sessionManager.getSessionFile(); } /** Current session ID */ get sessionId(): string { return this.#activeProviderSessionId(); } getEvalSessionId(): string | null { return this.#eval.getSessionId(); } getEvalKernelOwnerId(): string { return this.#eval.getKernelOwnerId(); } /** Current session display name, if set */ get sessionName(): string | undefined { return this.sessionManager.getSessionName(); } /** Scoped models for cycling (from --models flag) */ get scopedModels(): ReadonlyArray<{ model: Model; thinkingLevel?: ThinkingLevel }> { return this.#models.scopedModels; } /** Prompt templates */ getPlanModeState(): PlanModeState | undefined { return this.#planModeState; } /** Prewalk state, if armed and active */ getPrewalkState(): Prewalk | undefined { return this.#prewalk.state; } setPlanModeState(state: PlanModeState | undefined): void { this.#planModeState = state; if (state?.enabled) { this.#planReferenceSent = false; this.#planReferencePath = state.planFilePath; } else { this.#planModeReminderCount = 0; this.#planModeReminderAwaitingProgress = false; // Drop any unconsumed forced decision so a post-plan execution turn // does not inherit a stale `required` tool choice. this.#toolChoiceQueue.removeByLabel("plan-mode-decision"); } } getGoalModeState(): GoalModeState | undefined { return this.#goalModeState; } setGoalModeState(state: GoalModeState | undefined): void { this.#goalModeState = state; } getVibeModeState(): VibeModeState | undefined { return this.#vibeModeState; } setVibeModeState(state: VibeModeState | undefined): void { this.#vibeModeState = state; } #assertVibeSessionTransitionAllowed(action: string): void { if (this.#vibeModeState?.enabled) { throw new Error(`Cannot ${action} while vibe mode is active. Exit vibe mode first.`); } } get goalRuntime(): GoalRuntime { return this.#goalRuntime; } markPlanReferenceSent(): void { this.#planReferenceSent = true; } setPlanReferencePath(path: string): void { this.#planReferencePath = path; } getPlanReferencePath(): string { return this.#planReferencePath; } get clientBridge(): ClientBridge | undefined { return this.#clientBridge; } setClientBridge(bridge: ClientBridge | undefined): void { this.#clientBridge = bridge; this.#tools.refreshAcpPermissionGates(); } #clearCheckpointRuntimeState(): void { this.#checkpointState = undefined; this.#pendingRewindReport = undefined; this.#lastCompletedRewind = undefined; this.#rewoundToolResultIds.clear(); } /** Drop mutable tool decisions and directives owned by the previous logical session. */ #clearSessionScopedToolState(): void { this.#toolChoiceQueue.clear(); this.#tools.clearAcpPermissionDecisions(); } /** * Rebuild checkpoint/rewind runtime state from the current branch. Handles two * cases surfaced by session resume, `switchSession()` reloading the same file, * and tree navigation: * - The branch's most recent checkpoint has already been rewound → restore * `#lastCompletedRewind` so a repeat `rewind` call receives the * "checkpoint already completed" recovery guidance. * - The branch's most recent checkpoint has NOT been rewound (e.g. the run * was aborted between `checkpoint` and `rewind`) → restore * `#checkpointState` so the next `rewind` call can complete the * checkpoint instead of failing with "No active checkpoint". */ #rehydrateCheckpointRewindState(): void { this.#clearCheckpointRuntimeState(); let completed: CompletedRewindState | undefined; let pending: { entryId: string; startedAt: string; messageCount: number } | undefined; let messageCount = 0; for (const entry of this.sessionManager.getBranch()) { if (entry.type === "message") messageCount++; if (isSuccessfulCheckpointEntry(entry)) { completed = undefined; pending = { entryId: entry.id, startedAt: checkpointStartedAtFromEntry(entry) ?? entry.timestamp, messageCount, }; continue; } const completedFromEntry = completedRewindFromEntry(entry); if (completedFromEntry) { completed = completedFromEntry; pending = undefined; } } if (pending) { this.#checkpointState = { checkpointEntryId: pending.entryId, startedAt: pending.startedAt, checkpointMessageCount: pending.messageCount, }; return; } this.#lastCompletedRewind = completed; } getCheckpointState(): CheckpointState | undefined { return this.#checkpointState; } getLastCompletedRewind(): CompletedRewindState | undefined { return this.#lastCompletedRewind; } setCheckpointState(state: CheckpointState | undefined): void { this.#checkpointState = state; if (state) { this.#lastCompletedRewind = undefined; } else { this.#pendingRewindReport = undefined; } } /** * Inject the plan mode context message into the conversation history. */ async sendPlanModeContext(options?: { deliverAs?: "steer" | "followUp" | "nextTurn" }): Promise { const message = await this.#buildPlanModeMessage(); if (!message) return; await this.sendCustomMessage( { customType: message.customType, content: message.content, display: message.display, details: message.details, }, options ? { deliverAs: options.deliverAs } : undefined, ); } async sendGoalModeContext(options?: { deliverAs?: "steer" | "followUp" | "nextTurn" }): Promise { const message = this.#buildGoalModeMessage(); if (!message) return; await this.sendCustomMessage( { customType: message.customType, content: message.content, display: message.display, details: message.details, attribution: message.attribution, }, options ? { deliverAs: options.deliverAs } : undefined, ); } async sendVibeModeContext(options?: { deliverAs?: "steer" | "followUp" | "nextTurn" }): Promise { const message = this.#buildVibeModeMessage(); if (!message) return; await this.sendCustomMessage( { customType: message.customType, content: message.content, display: message.display, details: message.details, attribution: message.attribution, }, options ? { deliverAs: options.deliverAs } : undefined, ); } resolveRoleModel(role: string): Model | undefined { return this.#models.resolveRoleModel(role); } /** * Resolve a role to its model AND thinking level. * Unlike resolveRoleModel(), this preserves the thinking level suffix * from role configuration (e.g., "anthropic/claude-sonnet-4-5:xhigh"). */ resolveRoleModelWithThinking(role: string): ResolvedModelRoleValue { return this.#models.resolveRoleModelWithThinking(role); } /** * Resolve the explicit thinking suffix that should apply when a temporary * picker selects a model already assigned to a configured role. */ resolveTemporaryModelThinkingLevel(model: Model): ConfiguredThinkingLevel | undefined { return this.#models.resolveTemporaryModelThinkingLevel(model); } get promptTemplates(): ReadonlyArray { return this.#promptTemplates; } /** Replace file-based slash commands used for prompt expansion. */ setSlashCommands(slashCommands: FileSlashCommand[]): void { this.#slashCommands = [...slashCommands]; } /** Custom commands (TypeScript slash commands and MCP prompts) */ get customCommands(): ReadonlyArray { if (this.#mcpPromptCommands.length === 0) return this.#customCommands; return [...this.#customCommands, ...this.#mcpPromptCommands]; } /** MCP prompt commands only, for command-list metadata. */ get mcpPromptCommands(): ReadonlyArray { return this.#mcpPromptCommands; } /** Update the MCP prompt commands list. Called when server prompts are (re)loaded. */ setMCPPromptCommands(commands: LoadedCustomCommand[]): void { this.#mcpPromptCommands = commands; this.#notifyCommandMetadataChanged(); } // ========================================================================= // Prompting // ========================================================================= /** * Build a plan mode message. * Returns null if plan mode is not enabled. * @returns The plan mode message, or null if plan mode is not enabled. */ async #buildPlanReferenceMessage(): Promise { if (this.#planModeState?.enabled) return null; if (this.#planReferenceSent) return null; const planFilePath = this.#planReferencePath; const resolvedPlanPath = resolveLocalUrlToPath(planFilePath, this.#localProtocolOptions()); try { await fs.promises.access(resolvedPlanPath, fs.constants.R_OK); } catch (error) { if (isEnoent(error)) { return null; } throw error; } const content = prompt.render(planModeReferencePrompt, { planFilePath, }); return { role: "custom", customType: "plan-mode-reference", content, display: false, attribution: "agent", timestamp: Date.now(), }; } async #buildPlanModeMessage(): Promise { const state = this.#planModeState; if (!state?.enabled) return null; const sessionPlanUrl = "local://PLAN.md"; const resolvedPlanPath = state.planFilePath.startsWith("local:") ? resolveLocalUrlToPath(normalizeLocalScheme(state.planFilePath), this.#localProtocolOptions()) : resolveToCwd(state.planFilePath, this.sessionManager.getCwd()); const resolvedSessionPlan = resolveLocalUrlToPath(sessionPlanUrl, this.#localProtocolOptions()); const displayPlanPath = state.planFilePath.startsWith("local:") || resolvedPlanPath !== resolvedSessionPlan ? state.planFilePath : sessionPlanUrl; const planExists = fs.existsSync(resolvedPlanPath); const content = prompt.render(planModeActivePrompt, { planFilePath: displayPlanPath, planExists, askToolName: "ask", writeToolName: "write", editToolName: "edit", isHashlineEditMode: this.#resolveActiveEditMode() === "hashline", reentry: state.reentry ?? false, iterative: state.workflow === "iterative", }); return { role: "custom", customType: "plan-mode-context", content, display: false, attribution: "agent", timestamp: Date.now(), }; } #buildGoalModeMessage(): CustomMessage | null { const content = this.#goalRuntime.buildActivePrompt(); if (!content) return null; const todoContext = this.#buildGoalTodoContext(); return { role: "custom", customType: "goal-mode-context", content: prompt.render(goalModeContextPrompt, { goalContext: content, todoContext }), display: false, attribution: "agent", timestamp: Date.now(), }; } #buildVibeModeMessage(): CustomMessage | null { if (!this.#vibeModeState?.enabled) return null; return { role: "custom", customType: "vibe-mode-context", content: prompt.render(vibeModeActivePrompt), display: false, attribution: "agent", timestamp: Date.now(), }; } #sanitizeGoalTodoText(text: string): string { return escapeXmlText(text) .replace(/\r\n/g, "\\n") .replace(/\r/g, "\\r") .replace(/\n/g, "\\n") .replace(/\t/g, "\\t") .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u2028\u2029]/g, " "); } #buildGoalTodoContext(): string | undefined { if (!this.settings.get("todo.enabled")) return undefined; const canCallTodoTool = this.getActiveToolNames().includes("todo"); if (!canCallTodoTool) return undefined; const phases = this.getTodoPhases().filter(phase => phase.tasks.length > 0); if (phases.length === 0) return undefined; let total = 0; let closed = 0; let open = 0; const promptPhases = phases.map(phase => ({ name: this.#sanitizeGoalTodoText(phase.name), tasks: phase.tasks.map(task => { total++; if (task.status === "completed" || task.status === "abandoned") { closed++; } else { open++; } return { content: this.#sanitizeGoalTodoText(task.content), status: task.status }; }), })); return prompt.render(goalTodoContextPrompt, { canCallTodoTool, closed: String(closed), open: String(open), phases: promptPhases, total: String(total), }); } #normalizeImagesForModel(images: ImageContent[] | undefined): Promise { return normalizeModelContextImages(images, { model: this.model }); } #buildImageDescriptionNotice( normalizedImages: ImageContent[], signal?: AbortSignal, ): Promise { return this.#providerBoundary.buildImageDescriptionNotice(normalizedImages, signal); } #normalizeAgentMessageImages(message: T): Promise { return this.#providerBoundary.normalizeAgentMessageImages(message); } #magicKeywordEnabled(keyword: "orchestrate" | "ultrathink" | "workflow"): boolean { return this.settings.get("magicKeywords.enabled") && this.settings.get(`magicKeywords.${keyword}`); } #createMagicKeywordNotices(text: string): CustomMessage[] { const timestamp = Date.now(); const turnBudget = parseTurnBudget(text); this.sessionManager.beginTurnBudget(turnBudget?.total ?? null, turnBudget?.hard ?? false); const keywordNotices: CustomMessage[] = []; if (this.#magicKeywordEnabled("ultrathink") && containsUltrathink(text)) { keywordNotices.push({ role: "custom", customType: "ultrathink-notice", content: ULTRATHINK_NOTICE, display: false, attribution: "user", timestamp, }); } if (this.#magicKeywordEnabled("orchestrate") && containsOrchestrate(text)) { keywordNotices.push({ role: "custom", customType: "orchestrate-notice", content: ORCHESTRATE_NOTICE, display: false, attribution: "user", timestamp, }); } if (this.#magicKeywordEnabled("workflow") && containsWorkflow(text)) { const activeToolNames = this.getActiveToolNames(); if (activeToolNames.includes("task") && activeToolNames.includes("eval")) { keywordNotices.push({ role: "custom", customType: "workflow-notice", content: renderWorkflowNotice({ taskBatch: this.settings.get("task.batch") }), display: false, attribution: "user", timestamp, }); } } return keywordNotices; } /** * 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) */ /** * Returns `false` when the command was fully handled locally (extension or * custom-TS command consumed without calling the LLM). Returns `true` when * the prompt was forwarded to the agent — either directly or queued as a * steer/follow-up. Callers that render a UI or manage turn lifecycle (e.g. * the ACP agent) use this to know whether to expect an `agent_end` event. */ async prompt(text: string, options?: PromptOptions): Promise { const expandPromptTemplates = options?.expandPromptTemplates ?? true; // Handle extension commands first (execute immediately, even during streaming) if (expandPromptTemplates && text.startsWith("/")) { const handled = await this.#tryExecuteExtensionCommand(text); if (handled) { return false; } // Try custom commands (TypeScript slash commands) const customResult = await this.#tryExecuteCustomCommand(text); if (customResult !== null) { if (customResult === "") { return false; } text = customResult; } // Try file-based slash commands (markdown files from commands/ directories) // Only if text still starts with "/" (wasn't transformed by custom command) if (text.startsWith("/")) { text = expandSlashCommand(text, this.#slashCommands); } } // Expand file-based prompt templates if requested const expandedText = expandPromptTemplates ? expandPromptTemplate(text, [...this.#promptTemplates]) : text; // Magic keywords ("ultrathink", "orchestrate"): append hidden system notices after the // user's message that steer this turn. User-authored prompts only — synthetic / // agent-initiated turns never trigger them. const keywordNotices = options?.synthetic ? [] : this.#createMagicKeywordNotices(expandedText); // A user-initiated prompt (typed message or the `.`/`c` continue shortcut) // re-enables advisor auto-resume that a prior user interrupt suppressed. // Agent-initiated synthetic prompts (auto-continue, plan, reminders) do not. if (options?.userInitiated ?? !options?.synthetic) { this.#advisors.autoResumeSuppressed = false; this.#planModeReminderCount = 0; this.#planModeReminderAwaitingProgress = false; // A user turn owns the next decision; drop a queued forced choice from // a reminder continuation this prompt just preempted. this.#toolChoiceQueue.removeByLabel("plan-mode-decision"); } // If streaming, queue via steer() or followUp() based on option if (this.isStreaming) { const streamingBehavior = options?.streamingBehavior; if (!streamingBehavior) throw new AgentBusyError(); if (!(await this.#runUsageAwarePreflight())) return false; // Steer/follow-up the keyword notices BEFORE the queued user message so the // model reads the steering notice ahead of the prompt it modifies. for (const notice of keywordNotices) { await this.#queueCustomMessage(notice, streamingBehavior); } if (streamingBehavior === "followUp") { await this.#queueUserMessage(expandedText, options?.images, "followUp"); } else { await this.#queueUserMessage(expandedText, options?.images, "steer"); } return true; } // Skip eager preludes when the user has already queued a directive const hasPendingUserDirective = this.#toolChoiceQueue.inspect().includes("user-force"); const eagerTodoPrelude = !options?.synthetic && !hasPendingUserDirective ? this.#todo.createEagerTodoPrelude(expandedText) : undefined; const eagerTaskPrelude = !options?.synthetic && !hasPendingUserDirective ? this.#todo.createEagerTaskPrelude(expandedText) : undefined; const normalizedImages = await this.#normalizeImagesForModel(options?.images); const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }]; if (normalizedImages?.length) { userContent.push(...normalizedImages); } // Text-only model + image attachment: describe via a vision model and inject the // description as a hidden companion (the image stays in the visible user message). const imageDescriptionNotice = normalizedImages?.length ? await this.#buildImageDescriptionNotice(normalizedImages) : undefined; const promptAttribution = options?.attribution ?? (options?.synthetic ? "agent" : "user"); const message = options?.synthetic ? { role: "developer" as const, content: userContent, attribution: promptAttribution, timestamp: Date.now() } : { role: "user" as const, content: userContent, attribution: promptAttribution, timestamp: Date.now() }; const preludeMessages: AgentMessage[] = []; if (eagerTodoPrelude) { if (eagerTodoPrelude.toolChoice) { this.#toolChoiceQueue.pushOnce(eagerTodoPrelude.toolChoice, { label: "eager-todo", }); } preludeMessages.push(eagerTodoPrelude.message); } if (eagerTaskPrelude) { preludeMessages.push(eagerTaskPrelude); } try { await this.#promptWithMessage(message, expandedText, { ...options, images: normalizedImages, prependMessages: preludeMessages.length > 0 || keywordNotices.length > 0 || imageDescriptionNotice ? [...preludeMessages, ...keywordNotices, ...(imageDescriptionNotice ? [imageDescriptionNotice] : [])] : undefined, }); } finally { // Clean up residual eager-todo directive if the prompt never consumed it // (e.g., compaction aborted, validation failed). this.#toolChoiceQueue.removeByLabel("eager-todo"); } return true; } async promptCustomMessage( message: Pick, "customType" | "content" | "display" | "details" | "attribution">, options?: Pick & { queueChipText?: string; queueOnly?: boolean; }, ): Promise { const textContent = typeof message.content === "string" ? message.content : message.content .filter((content): content is TextContent => content.type === "text") .map(content => content.text) .join(""); let keywordNotices: CustomMessage[] = []; if (message.customType === SKILL_PROMPT_MESSAGE_TYPE && message.attribution === "user") { const details = message.details; let skillArgs = ""; if (details && typeof details === "object" && "args" in details && typeof details.args === "string") { skillArgs = details.args; } keywordNotices = this.#createMagicKeywordNotices(skillArgs); } if (options?.queueOnly) { const streamingBehavior = options?.streamingBehavior; if (!streamingBehavior) throw new AgentBusyError(); if (!(await this.#runUsageAwarePreflight())) return; for (const notice of keywordNotices) { await this.#queueCustomMessage(notice, streamingBehavior); } await this.#queueCustomMessage(message, streamingBehavior, options.queueChipText); return; } if (this.isStreaming) { const streamingBehavior = options?.streamingBehavior; if (!streamingBehavior) throw new AgentBusyError(); if (!(await this.#runUsageAwarePreflight())) return; for (const notice of keywordNotices) { await this.#queueCustomMessage(notice, streamingBehavior); } await this.#queueCustomMessage(message, streamingBehavior, options?.queueChipText); return; } const customMessage: CustomMessage = { role: "custom", customType: message.customType, content: message.content, display: message.display, details: message.details, attribution: message.attribution ?? "agent", timestamp: Date.now(), }; await this.#promptWithMessage(customMessage, textContent, { ...options, prependMessages: keywordNotices.length > 0 ? keywordNotices : undefined, }); } async #promptWithMessage( message: AgentMessage, expandedText: string, options?: Pick & { prependMessages?: AgentMessage[]; skipPostPromptRecoveryWait?: boolean; acceptTerminalEmptyStop?: boolean; }, ): Promise { this.#beginInFlight(); const generation = this.#promptGeneration; try { await this.#recovery.maybeRestoreRetryFallbackPrimary(); if (!(await this.#runUsageAwarePreflight())) return; // Flush any pending bash messages before the new prompt await this.#bash.flushPending(); this.#eval.flushPending(); this.#irc.flushPending(); this.#todo.resetCycle(); this.#resetPromptMaintenanceState(); this.#recovery.setAcceptTerminalEmptyStop(options?.acceptTerminalEmptyStop === true); // Validate model if (!this.model) { throw new Error( "No model selected.\n\n" + `Use /login, set an API key environment variable, or create ${getAgentDbPath()}\n\n` + "Then use /model to select a model.", ); } // Validate API key const apiKey = await this.#modelRegistry.getApiKey(this.model, this.sessionId); if (!apiKey) { throw new Error( `No API key found for ${this.model.provider}.\n\n` + `Use /login, set an API key environment variable, or create ${getAgentDbPath()}`, ); } // Recover a previously failed/incomplete assistant turn before sending. // Successful historical turns take the cheaper pre-prompt threshold path // below; re-running the full post-turn check on resume can synchronously // rewrite/re-render old context before the new prompt starts. const lastAssistant = this.#findLastAssistantMessage(); if ( lastAssistant && !options?.skipCompactionCheck && (lastAssistant.stopReason === "error" || lastAssistant.stopReason === "length") ) { await this.#maintenance.checkCompaction(lastAssistant, false, false, false); } await this.#prewalk.armPlanYoloIfNeeded(); // Build messages array (session context, eager todo prelude, then active prompt message) const messages: AgentMessage[] = []; const planReferenceMessage = await this.#buildPlanReferenceMessage?.(); if (planReferenceMessage) { messages.push(planReferenceMessage); } const planModeMessage = await this.#buildPlanModeMessage(); if (planModeMessage) { messages.push(planModeMessage); } const goalModeMessage = this.#buildGoalModeMessage(); if (goalModeMessage) { messages.push(goalModeMessage); } const vibeModeMessage = this.#buildVibeModeMessage(); if (vibeModeMessage) { messages.push(vibeModeMessage); } if (options?.prependMessages) { messages.push(...options.prependMessages); } // Early bail-out: if a newer abort/prompt cycle started during setup, // return before mutating shared state (nextTurn messages, system prompt). if (this.#promptGeneration !== generation) { return; } // A pending xd:// delta accompanies the next user-authored prompt, // never an agent-initiated continuation. const xdevMountNotice = isUserQueuedMessage(message) ? this.#takePendingXdevMountNotice() : undefined; if (xdevMountNotice) { messages.push(xdevMountNotice); } messages.push(message); // Inject any pending "nextTurn" messages as context alongside the user message for (const msg of this.#pendingNextTurnMessages) { messages.push(msg); } this.#pendingNextTurnMessages = []; // Auto-read @filepath mentions const fileMentions = extractFileMentions(expandedText); if (fileMentions.length > 0) { const fileMentionMessages = await generateFileMentionMessages(fileMentions, this.sessionManager.getCwd(), { autoResizeImages: this.settings.get("images.autoResize"), useHashLines: resolveFileDisplayMode(this).hashLines, snapshotStore: getFileSnapshotStore(this), }); for (const fileMentionMessage of fileMentionMessages) { messages.push(await this.#normalizeAgentMessageImages(fileMentionMessage)); } } // A prompt issued while the session is already disposing must still run: // the dispose-driven abort settles its turn (see "does not auto-retry // empty reasonless aborts once the session is disposing"). Only drop the // prompt when disposal began during the backend-transition await, where // resuming would start a turn on a torn-down session. const disposingBeforeTransition = this.#isDisposed; await this.#memory.transition; if ((this.#isDisposed && !disposingBeforeTransition) || this.#promptGeneration !== generation) return; const beforeAgentStartSystemPrompt = await this.#buildSystemPromptForAgentStart(expandedText); // Emit before_agent_start extension event if (this.#extensionRunner) { const result = await this.#extensionRunner.emitBeforeAgentStart( expandedText, options?.images, beforeAgentStartSystemPrompt, ); if (result?.messages) { const promptAttribution: "user" | "agent" | undefined = "attribution" in message ? message.attribution : undefined; for (const msg of result.messages) { const normalized = normalizeCustomMessagePayload(msg); const hasExplicitAttribution = msg !== null && typeof msg === "object" && !Array.isArray(msg) && (msg.attribution === "user" || msg.attribution === "agent"); messages.push( await this.#normalizeAgentMessageImages({ role: "custom", customType: normalized.customType, content: normalized.content, display: normalized.display, details: normalized.details, attribution: hasExplicitAttribution ? normalized.attribution : (promptAttribution ?? (message.role === "user" ? "user" : "agent")), timestamp: Date.now(), }), ); } } if (result?.systemPrompt !== undefined) { this.agent.setSystemPrompt(result.systemPrompt); } else { this.agent.setSystemPrompt(beforeAgentStartSystemPrompt); } } else { this.agent.setSystemPrompt(beforeAgentStartSystemPrompt); } // Bail out if a newer abort/prompt cycle has started since we began setup if (this.#promptGeneration !== generation) { return; } // Auto thinking: classify this real user turn and set the effective level // before the model request. Synthetic/tool-continuation turns (developer/ // custom roles) and non-auto sessions are skipped. Never blocks the turn — // failures fall back to a concrete level inside the helper. if (this.isAutoThinking && message.role === "user") { await this.#models.applyAutoThinkingLevel(expandedText, generation); if (this.#promptGeneration !== generation) { return; } } await this.#maintenance.runPrePromptCompactionIfNeeded(messages); if (this.#promptGeneration !== generation) { return; } const agentPromptOptions = options?.toolChoice ? { toolChoice: options.toolChoice } : undefined; const nonMessageTokens = computeNonMessageTokens(this); const contextWindow = this.model?.contextWindow ?? 0; const breakdown = this.getContextBreakdown({ contextWindow, pendingMessages: messages }); const promptTokens = breakdown?.usedTokens ?? nonMessageTokens + this.messages.reduce((sum, msg) => sum + estimateTokens(msg), 0) + messages.reduce((sum, msg) => sum + estimateTokens(msg), 0); this.#stats.setPendingSnapshot({ promptTokens, nonMessageTokens, cutoffCount: this.messages.length + messages.length, }); // Commit the plan-reference delivery flag only now that the message is // actually handed to agent.prompt. Every pre-send setup step above can // return (generation-bail) or throw (@-mention reads, before_agent_start // hooks, pre-prompt compaction) before this point; setting the flag at // construction time (#buildPlanReferenceMessage) stranded it `true` with // nothing delivered, so the retry skipped re-injection and the executor // lost the approved plan (issue #4094). The compaction-success resets // (issue #1246) still clear it for re-injection on the next turn. if (planReferenceMessage) { this.#planReferenceSent = true; } try { await this.#recovery.promptAgentWithIdleRetry(messages, agentPromptOptions); } finally { this.#stats.setPendingSnapshot(undefined); } if (!options?.skipPostPromptRecoveryWait) { await this.#waitForPostPromptRecovery(generation); } } finally { this.#endInFlight(); } } /** * Try to execute an extension command. Returns true if command was found and executed. */ async #tryExecuteExtensionCommand(text: string): Promise { if (!this.#extensionRunner) return false; // Parse command name and args const spaceIndex = text.indexOf(" "); const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1); const command = this.#extensionRunner.getCommand(commandName); if (!command) return false; // Get command context from extension runner (includes session control methods) const ctx = this.#extensionRunner.createCommandContext(); try { await command.handler(args, ctx); return true; } catch (err) { // Emit error via extension runner this.#extensionRunner.emitError({ extensionPath: `command:${commandName}`, event: "command", error: err instanceof Error ? err.message : String(err), }); return true; } } #createCommandContext(): ExtensionCommandContext { if (this.#extensionRunner) { return this.#extensionRunner.createCommandContext(); } return { ui: noOpUIContext, hasUI: false, cwd: this.sessionManager.getCwd(), sessionManager: this.sessionManager, modelRegistry: this.#modelRegistry, model: this.model ?? undefined, models: createExtensionModelQuery(this.#modelRegistry, this.settings, () => this.model ?? undefined), isIdle: () => !this.isStreaming, abort: () => { void this.abort(); }, hasPendingMessages: () => this.queuedMessageCount > 0, shutdown: () => { // Await the idempotent dispose() before exiting so the browser // reaper and other bounded teardown complete — a fire-and-forget // `void this.dispose()` raced process.exit() and could leave an // OMP-owned Chromium alive (#5643). void this.dispose().finally(() => process.exit(0)); }, getContextUsage: () => this.getContextUsage(), waitForIdle: () => this.waitForIdle(), newSession: async options => { const success = await this.newSession({ parentSession: options?.parentSession }); if (!success) { return { cancelled: true }; } if (options?.setup) { await options.setup(this.sessionManager); } return { cancelled: false }; }, branch: async entryId => { const result = await this.branch(entryId); return { cancelled: result.cancelled }; }, navigateTree: async (targetId, options) => { const result = await this.navigateTree(targetId, { summarize: options?.summarize }); return { cancelled: result.cancelled }; }, compact: async instructionsOrOptions => { const instructions = typeof instructionsOrOptions === "string" ? instructionsOrOptions : undefined; const options = instructionsOrOptions && typeof instructionsOrOptions === "object" ? instructionsOrOptions : undefined; await this.compact(instructions, options); }, switchSession: async sessionPath => { const success = await this.switchSession(sessionPath); return { cancelled: !success }; }, reload: async () => { await this.reload(); }, getSystemPrompt: () => this.systemPrompt, setInterval: (callback, ms, ...args) => this.#fallbackTimers().setInterval(callback, ms, ...args), setTimeout: (callback, ms, ...args) => this.#fallbackTimers().setTimeout(callback, ms, ...args), clearTimer: timer => this.#fallbackTimers().clear(timer), }; } /** Lazily create the runner-less command-context timer registry (#5664). */ #fallbackTimers(): ManagedTimers { this.#fallbackExtensionTimers ??= new ManagedTimers((event, error) => logger.warn("Extension timer callback threw", { event, error }), ); return this.#fallbackExtensionTimers; } /** * Try to execute a custom command. Returns the prompt string if found, null otherwise. * If the command returns void, returns empty string to indicate it was handled. */ async #tryExecuteCustomCommand(text: string): Promise { if (this.#customCommands.length === 0 && this.#mcpPromptCommands.length === 0) return null; // Parse command name and args const spaceIndex = text.indexOf(" "); const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); const argsString = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1); // Find matching command const loaded = this.#customCommands.find(c => c.command.name === commandName) ?? this.#mcpPromptCommands.find(c => c.command.name === commandName); if (!loaded) return null; // Get command context from extension runner (includes session control methods) const baseCtx = this.#createCommandContext(); const ctx = { ...baseCtx, hasQueuedMessages: baseCtx.hasPendingMessages, } as unknown as HookCommandContext; try { const args = parseCommandArgs(argsString); const result = await loaded.command.execute(args, ctx); // If result is a string, it's a prompt to send to LLM // If void/undefined, command handled everything return result ?? ""; } catch (err) { // Emit error via extension runner if (this.#extensionRunner) { this.#extensionRunner.emitError({ extensionPath: `custom-command:${commandName}`, event: "command", error: err instanceof Error ? err.message : String(err), }); } else { const message = err instanceof Error ? err.message : String(err); logger.error("Custom command failed", { commandName, error: message }); } return ""; // Command was handled (with error) } } /** * Queue a steering message to interrupt the agent mid-run. */ async steer(text: string, images?: ImageContent[]): Promise { if (text.startsWith("/")) { this.#throwIfExtensionCommand(text); } const expandedText = expandPromptTemplate(text, [...this.#promptTemplates]); if (!(await this.#runUsageAwarePreflight())) return; await this.#queueUserMessage(expandedText, images, "steer"); } /** * Queue a follow-up message to process after the agent would otherwise stop. * Set `options.synthetic` to enqueue a hidden developer message (agent-attributed * by default) instead of a user-attributed follow-up; the plan-approval flow * uses this to land its execution directive behind a queued user turn without * flipping advisor auto-resume. */ async followUp(text: string, images?: ImageContent[], options?: FollowUpOptions): Promise { if (text.startsWith("/")) { this.#throwIfExtensionCommand(text); } const expandedText = options?.expandPromptTemplates === false ? text : expandPromptTemplate(text, [...this.#promptTemplates]); if (!(await this.#runUsageAwarePreflight())) return; if (!options?.synthetic) { await this.#queueUserMessage(expandedText, images, "followUp"); return; } // Synthetic branch: agent-initiated hidden developer message. Bypass // #queueUserMessage (which clears advisor auto-resume suppression and // enqueues as a user-attributed message) and place the developer message // directly on the follow-up queue. const normalizedImages = await this.#normalizeImagesForModel(images); const content: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }]; if (normalizedImages?.length) { content.push(...normalizedImages); } const imageDescriptionNotice = normalizedImages?.length ? await this.#buildImageDescriptionNotice(normalizedImages) : undefined; if (imageDescriptionNotice) this.agent.followUp(imageDescriptionNotice); this.agent.followUp({ role: "developer", content, attribution: options.attribution ?? "agent", timestamp: Date.now(), }); this.#scheduleIdleQueueDrain(); } async #queueUserMessage( text: string, images: ImageContent[] | undefined, mode: "steer" | "followUp", ): Promise { // A queued user message (RPC/SDK/collab steer or follow-up, or a typed message // while streaming) is a deliberate resume; re-enable advisor auto-resume that // a user interrupt suppressed. this.#advisors.autoResumeSuppressed = false; const normalizedImages = await this.#normalizeImagesForModel(images); const content: (TextContent | ImageContent)[] = [{ type: "text", text }]; if (normalizedImages?.length) { content.push(...normalizedImages); } // Text-only model + image attachment: describe via a vision model and enqueue the // description as a hidden companion immediately before the user message. const imageDescriptionNotice = normalizedImages?.length ? await this.#buildImageDescriptionNotice(normalizedImages) : undefined; if (mode === "followUp") { if (imageDescriptionNotice) this.agent.followUp(imageDescriptionNotice); this.agent.followUp({ role: "user", content, attribution: "user", timestamp: Date.now(), }); } else { if (imageDescriptionNotice) this.agent.steer(imageDescriptionNotice); this.agent.steer({ role: "user", content, steering: true, attribution: "user", timestamp: Date.now(), }); } this.#scheduleIdleQueueDrain(); } #scheduleIdleQueueDrain(): void { this.#scheduleQueuedMessageDrain(); } #scheduleQueuedMessageDrain(): void { if (this.#queuedMessageDrainScheduled || !this.#canAutoContinueForFollowUp() || !this.agent.hasQueuedMessages()) { return; } this.#queuedMessageDrainScheduled = true; this.#scheduleAgentContinue({ shouldContinue: () => { this.#queuedMessageDrainScheduled = false; return this.#canAutoContinueForFollowUp() && this.agent.hasQueuedMessages(); }, onSkip: () => { this.#queuedMessageDrainScheduled = false; }, onError: () => { this.#queuedMessageDrainScheduled = false; }, }); } /** * Gate for idle-path queued-message auto-continue. See `#scheduleIdleQueueDrain` for rationale. */ #canAutoContinueForFollowUp(): boolean { if (this.isStreaming) return false; if (this.isRetrying) return false; // A queued steer resumes from ANY tail: Agent.continue() runs #runLoop(undefined), // whose initial steering poll injects the steer before the first provider call, so the // request tail becomes the steer (valid) regardless of any injected custom / bashExecution // / pythonExecution record a user interrupt left as the literal transcript tail. This is // why a queued user steer stranded behind a preserved advisor card (or a flushed IRC aside // / eval execution record) still resumes — no tail-role enumeration needed. if (this.agent.peekSteeringQueue().length > 0) return true; // Follow-up-only auto-resume stays suppressed while a deliberate user interrupt is in effect // (#advisorAutoResumeSuppressed, cleared on the next user prompt): the user stopped, so their // queued follow-up waits for an explicit resume — even if an interleaving IRC wake turn has // since left a provider-valid tail. if (this.#advisors.autoResumeSuppressed) return false; // Follow-up-only resume has no steer to inject, so Agent.continue() continues from the // existing context tail — which must itself be a valid provider tail. An injected // non-conversational tail (advisor card → `developer`, bash/python execution) would make // the first model call invalid, so leave the follow-up queued for the next explicit resume. const messages = this.agent.state.messages; const last = messages[messages.length - 1]; return last?.role === "assistant" || last?.role === "toolResult"; } queueDeferredMessage(message: CustomMessage): void { this.#queueHiddenNextTurnMessage(message, true); } #queueHiddenNextTurnMessage(message: CustomMessage, triggerTurn: boolean): void { this.#pendingNextTurnMessages.push(message); if (!triggerTurn) return; const generation = this.#promptGeneration; if (this.#scheduledHiddenNextTurnGeneration === generation) { return; } this.#scheduledHiddenNextTurnGeneration = generation; this.#schedulePostPromptTask( async () => { if (this.#scheduledHiddenNextTurnGeneration === generation) { this.#scheduledHiddenNextTurnGeneration = undefined; } if (this.#pendingNextTurnMessages.length === 0) { return; } try { await this.#promptQueuedHiddenNextTurnMessages(); } catch { // Leave the hidden next-turn messages queued for the next explicit prompt. } }, { generation, onSkip: () => { if (this.#scheduledHiddenNextTurnGeneration === generation) { this.#scheduledHiddenNextTurnGeneration = undefined; } }, }, ); } async #promptQueuedHiddenNextTurnMessages(): Promise { if (this.#pendingNextTurnMessages.length === 0) { return; } const queuedMessages = [...this.#pendingNextTurnMessages]; this.#pendingNextTurnMessages = []; const message = queuedMessages[queuedMessages.length - 1]; if (!message) { return; } const prependMessages = queuedMessages.slice(0, -1); const textContent = this.#getCustomMessageTextContent(message); try { await this.#promptWithMessage(message, textContent, { prependMessages, skipPostPromptRecoveryWait: true, }); } catch (error) { this.#pendingNextTurnMessages = [...queuedMessages, ...this.#pendingNextTurnMessages]; throw error; } } #getCustomMessageTextContent(message: Pick): string { if (typeof message.content === "string") { return message.content; } return message.content .filter((content): content is TextContent => content.type === "text") .map(content => content.text) .join(""); } /** * Throw an error if the text is an extension command. */ #throwIfExtensionCommand(text: string): void { if (!this.#extensionRunner) return; const spaceIndex = text.indexOf(" "); const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); const command = this.#extensionRunner.getCommand(commandName); if (command) { throw new Error( `Extension command "/${commandName}" cannot be queued. Use prompt() or execute the command when not streaming.`, ); } } async #promptAgentInitiatedMessage( message: CustomMessage, options?: { acceptTerminalEmptyStop?: boolean }, ): Promise { this.#beginInFlight(); try { if (!(await this.#runUsageAwarePreflight())) return; const acceptTerminalEmptyStop = options?.acceptTerminalEmptyStop === true; if (acceptTerminalEmptyStop) { this.#resetPromptMaintenanceState(); } this.#recovery.setAcceptTerminalEmptyStop(acceptTerminalEmptyStop); await this.agent.prompt(message); await this.#waitForPostPromptRecovery(); } finally { this.#recovery.setAcceptTerminalEmptyStop(false); this.#endInFlight(); } } /** Queue a custom message without starting a turn, matching steer/follow-up delivery. */ async #queueCustomMessage( message: Pick, "customType" | "content" | "display" | "details" | "attribution">, deliverAs: "steer" | "followUp", queueChipText?: string, ): Promise { const details = queueChipText !== undefined ? ({ ...((message.details && typeof message.details === "object" ? message.details : {}) as Record< string, unknown >), __queueChipText: queueChipText, } as T) : message.details; const appMessage: CustomMessage = { role: "custom", customType: message.customType, content: message.content, display: message.display, details, attribution: message.attribution ?? "agent", timestamp: Date.now(), }; const normalizedAppMessage = await this.#normalizeAgentMessageImages(appMessage); if (deliverAs === "followUp") { this.agent.followUp(normalizedAppMessage); } else { this.agent.steer(normalizedAppMessage); } this.#scheduleIdleQueueDrain(); } /** * Send a custom message to the session. Creates a CustomMessageEntry. * * Handles three cases: * - Streaming: queue as steer/follow-up or store for next turn * - Not streaming + triggerTurn: appends to state/session, starts new turn unless the client cannot own it * - Not streaming + no trigger: appends to state/session, no turn * * @returns true iff this call synchronously started a new turn (awaited * `agent.prompt`); false when the message was queued/appended without a turn * — including when `triggerTurn` is downgraded because the client defers * agent-initiated turns. Callers that must mirror the resulting `agent_end` * use this to avoid acting on a turn that never ran. */ async sendCustomMessage( message: CustomMessagePayload, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn"; queueChipText?: string; acceptTerminalEmptyStop?: boolean; }, ): Promise { const normalizedPayload = normalizeCustomMessagePayload(message); const details = options?.queueChipText && options.deliverAs !== "nextTurn" ? ({ ...((normalizedPayload.details && typeof normalizedPayload.details === "object" ? normalizedPayload.details : {}) as Record), __queueChipText: options.queueChipText, } as T) : normalizedPayload.details; const appMessage: CustomMessage = { role: "custom", customType: normalizedPayload.customType, content: normalizedPayload.content, display: normalizedPayload.display, details, attribution: normalizedPayload.attribution, timestamp: Date.now(), }; const normalizedAppMessage = await this.#normalizeAgentMessageImages(appMessage); if (this.isStreaming) { if (options?.deliverAs === "nextTurn") { this.#queueHiddenNextTurnMessage(normalizedAppMessage, options?.triggerTurn ?? false); return false; } if (!(await this.#runUsageAwarePreflight())) return false; if (options?.deliverAs === "followUp") { this.agent.followUp(normalizedAppMessage); } else { this.agent.steer(normalizedAppMessage); } this.#scheduleIdleQueueDrain(); return false; } if (options?.deliverAs === "nextTurn") { if (options?.triggerTurn) { if (this.#clientBridge?.deferAgentInitiatedTurns && !this.#allowAcpAgentInitiatedTurns) { this.#queueHiddenNextTurnMessage(normalizedAppMessage, false); return false; } await this.#promptAgentInitiatedMessage(normalizedAppMessage, { acceptTerminalEmptyStop: options.acceptTerminalEmptyStop === true, }); return true; } this.agent.appendMessage(normalizedAppMessage); this.sessionManager.appendCustomMessageEntry( normalizedAppMessage.customType, normalizedAppMessage.content, normalizedAppMessage.display, normalizedAppMessage.details, normalizedAppMessage.attribution, ); return false; } if (options?.triggerTurn) { if (this.#clientBridge?.deferAgentInitiatedTurns && !this.#allowAcpAgentInitiatedTurns) { this.#queueHiddenNextTurnMessage(normalizedAppMessage, false); return false; } await this.#promptAgentInitiatedMessage(normalizedAppMessage); return true; } this.agent.appendMessage(normalizedAppMessage); this.sessionManager.appendCustomMessageEntry( normalizedAppMessage.customType, normalizedAppMessage.content, normalizedAppMessage.display, normalizedAppMessage.details, normalizedAppMessage.attribution, ); return false; } /** * Send a user message through the prompt flow. * * Omitted `deliverAs` starts a turn when idle and queues as a steer while streaming. * Explicit `deliverAs` queues without starting a turn in either state. */ async sendUserMessage( content: string | (TextContent | ImageContent)[], options?: { deliverAs?: "steer" | "followUp" }, ): Promise { // Normalize content to text string + optional images let text: string; let images: ImageContent[] | undefined; if (typeof content === "string") { text = content; } else { const textParts: string[] = []; images = []; for (const part of content) { if (part.type === "text") { textParts.push(part.text); } else { images.push(part); } } text = textParts.join("\n"); if (images.length === 0) images = undefined; } if (options?.deliverAs && !(await this.#runUsageAwarePreflight())) return; if (options?.deliverAs === "followUp") { await this.#queueUserMessage(text, images, "followUp"); return; } if (options?.deliverAs === "steer") { await this.#queueUserMessage(text, images, "steer"); return; } // Use prompt() with expandPromptTemplates: false to skip command handling and template expansion. // `streamingBehavior: "steer"` preserves prompt-flow side effects during streaming while // covering the narrow race where a stream starts before prompt() acquires the turn. await this.prompt(text, { expandPromptTemplates: false, images, streamingBehavior: "steer", }); } /** Clear queued messages and return the user-restorable ones (text plus any attached images). * Only user-authored messages (plain user turns, `attribution:"user"` custom like `/skill`) are * returned for editor restore. Other queued messages stay in the agent-core queues so a continuing * stream still delivers them — EXCEPT on `forInterrupt` (Esc+abort), where only advisor cards are * kept (abort()'s #extractQueuedAdvisorCards preserves them as visible advice) and every other * non-user steer (hidden goal/plan/budget, IRC/extension asides) is dropped, so abort()'s * #drainStrandedQueuedMessages can't auto-resume the run the user just interrupted (the drain only * fires while agent.hasQueuedMessages()). Plain Alt+Up dequeue preserves those non-user steers. */ clearQueue(options?: { forInterrupt?: boolean }): { steering: RestoredQueuedMessage[]; followUp: RestoredQueuedMessage[]; } { const steeringAll = this.agent.peekSteeringQueue(); const followUpAll = this.agent.peekFollowUpQueue(); const steering = steeringAll.filter(isUserQueuedMessage).map(toRestoredQueuedMessage); const followUp = followUpAll.filter(isUserQueuedMessage).map(toRestoredQueuedMessage); const keep: (m: AgentMessage) => boolean = options?.forInterrupt ? isAdvisorCard : m => !isUserQueuedMessage(m) && !isHiddenUserCompanion(m); this.agent.replaceQueues(steeringAll.filter(keep), followUpAll.filter(keep)); return { steering, followUp }; } /** Number of pending displayable messages (includes steering, follow-up, and next-turn messages). * Reflects actual queued work (advisor cards included) — feeds hasPendingMessages()/RPC and the * empty-submit abort gate. The user-restorable subset is surfaced by getQueuedMessages()/clearQueue(). */ get queuedMessageCount(): number { return ( this.agent.peekSteeringQueue().filter(isDisplayableQueuedMessage).length + this.agent.peekFollowUpQueue().filter(isDisplayableQueuedMessage).length + this.#pendingNextTurnMessages.length ); } getQueuedMessages(): { steering: readonly string[]; followUp: readonly string[] } { return { steering: this.agent.peekSteeringQueue().filter(isUserQueuedMessage).map(queueChipText), followUp: this.agent.peekFollowUpQueue().filter(isUserQueuedMessage).map(queueChipText), }; } /** * Pop the last queued message (steering first, then follow-up). * Used by dequeue keybinding to restore messages to editor one at a time. * Steps over agent-authored queued messages (advisor cards, hidden/internal steers). */ popLastQueuedMessage(): RestoredQueuedMessage | undefined { const steering = this.agent.peekSteeringQueue(); const followUp = this.agent.peekFollowUpQueue(); const lastUserIndex = (queue: readonly AgentMessage[]): number => { for (let i = queue.length - 1; i >= 0; i--) { if (isUserQueuedMessage(queue[i])) return i; } return -1; }; // Notices queue immediately before their user message, so dropping the popped // prompt means also dropping the contiguous hidden-user companions right before // it — companions of other queued prompts stay put. const removeWithCompanions = (queue: readonly AgentMessage[], userIndex: number): AgentMessage[] => { let start = userIndex; while (start > 0 && isHiddenUserCompanion(queue[start - 1])) start--; const next = queue.slice(); next.splice(start, userIndex - start + 1); return next; }; const fromSteer = lastUserIndex(steering); if (fromSteer >= 0) { const removed = steering[fromSteer]; this.agent.replaceQueues(removeWithCompanions(steering, fromSteer), followUp.slice()); return toRestoredQueuedMessage(removed); } const fromFollowUp = lastUserIndex(followUp); if (fromFollowUp >= 0) { const removed = followUp[fromFollowUp]; this.agent.replaceQueues(steering.slice(), removeWithCompanions(followUp, fromFollowUp)); return toRestoredQueuedMessage(removed); } return undefined; } get skillsSettings(): SkillsSettings | undefined { return this.#tools.skillsSettings; } /** Skills loaded by SDK (empty if --no-skills or skills: [] was passed) */ get skills(): readonly Skill[] { return this.#tools.skills; } /** Skill loading warnings captured by SDK */ get skillWarnings(): readonly SkillWarning[] { return this.#tools.skillWarnings; } getTodoPhases(): TodoPhase[] { return this.#todo.phases; } setTodoPhases(phases: TodoPhase[]): void { this.#todo.setPhases(phases); } #buildReplanTitleContext(): string { return buildReplanTitleContext(this.agent.state.messages); } #scheduleReplanTitleRefresh(): void { // Headless subagent sessions have no operator-visible title, so a todo-init // replan refresh only burns a tiny-model call whose result lands in JSONL // and is never shown (issue #5910). In an interactive host the operator can // focus a live subagent from the Agent Hub, where the status line renders // its session name — so keep the refresh there and only skip subagents when // no focusable UI exists (print/RPC/ACP/eval/SDK/CI). if (this.#agentKind === "sub" && !isInteractiveHost()) return; if (this.#replanTitleRefreshInFlight) return; if (!this.settings.get("title.refreshOnReplan")) return; if (this.sessionManager.titleSource === "user") return; const context = this.#buildReplanTitleContext(); if (!context) return; const sessionId = this.sessionManager.getSessionId(); const refresh = this.#refreshTitleAfterReplan(context, sessionId) .catch(err => { logger.warn("title-generator: replan refresh failed", { sessionId, error: err instanceof Error ? err.message : String(err), }); }) .finally(() => { if (this.#replanTitleRefreshInFlight === refresh) { this.#replanTitleRefreshInFlight = undefined; } }); this.#replanTitleRefreshInFlight = refresh; } /** * Generate an automatic session title tied to this session's lifecycle. * Input and replan callers share the signal so disposal cancels provider and * local-worker requests instead of leaving background inference alive. */ generateTitle(firstMessage: string): Promise { return generateSessionTitle( firstMessage, this.#modelRegistry, this.settings, this.sessionId, this.model, provider => this.agent.metadataForProvider(provider), this.#titleSystemPrompt, this.#titleGenerationAbortController.signal, ); } async #refreshTitleAfterReplan(context: string, sessionId: string): Promise { const title = await this.generateTitle(context); if (!title) return; if (this.sessionManager.getSessionId() !== sessionId) return; if (!this.settings.get("title.refreshOnReplan")) return; if (this.sessionManager.titleSource === "user") return; const setSessionName = this.sessionManager.setSessionName as SetSessionNameWithTrigger; await setSessionName.call(this.sessionManager, title, "auto", "replan"); } /** Currently-applied {@link TITLE_SYSTEM.md} override, or undefined when the * bundled prompt is in effect. Consumed by {@link InteractiveMode} so the * first-input title path and the replan refresh share one source. */ get titleSystemPrompt(): string | undefined { return this.#titleSystemPrompt; } /** Replace the title-generation system prompt override. Called by * {@link InteractiveMode.refreshTitleSystemPrompt} after the session cwd * changes (e.g. `/move` relocation) so the next replan refresh resolves * against the destination project's override. */ setTitleSystemPrompt(prompt: string | undefined): void { this.#titleSystemPrompt = prompt; } /** * Abort current operation and wait for agent to become idle. * * `reason` (e.g. `USER_INTERRUPT_LABEL`) rides the agent's `AbortController` * and surfaces verbatim on the aborted assistant message's `errorMessage`, so * the transcript can distinguish a deliberate user interrupt from an opaque * abort. Omit it for internal/lifecycle aborts. */ async abort(options?: { goalReason?: "interrupted" | "internal"; reason?: string; /** Internal `/compact` startup keeps the manual-compaction marker alive while aborting the active turn. */ preserveCompaction?: boolean; }): Promise { const userInterrupt = options?.reason === USER_INTERRUPT_LABEL; this.#pendingAbortErrorId = userInterrupt ? AIError.create(AIError.Flag.UserInterrupt) : undefined; if (userInterrupt) this.#advisors.autoResumeSuppressed = true; // Pull advisor concerns out of the steer/follow-up queues before any await so // the post-abort stranded-message drain can't auto-resume the run on them. // They are re-recorded as visible advice once the agent settles (below). const strandedAdvisorCards = userInterrupt ? this.#extractQueuedAdvisorCards() : []; // Session switch/compact paths disconnect first; explicit aborts should // leave any queued steer/follow-up visible for the user rather than // auto-starting a fresh turn during cleanup. this.#abortInProgress = true; try { this.#abortAutolearnCapture(); for (const controller of this.#usagePreflightAbortControllers) controller.abort(); this.abortRetry(); this.#promptGeneration++; this.#scheduledHiddenNextTurnGeneration = undefined; if (options?.preserveCompaction) { // Manual `/compact` installed its own #compactionAbortController before // this internal abort and must keep it alive (that marker is what makes // isCompacting report true during startup). Any in-flight // auto-compaction MUST still be cancelled, though: otherwise a // background maintenance pass races the manual run and both // appendCompaction/replaceMessages, double-rewriting session history. this.#maintenance.abortAutomaticCompaction(); } else { this.abortCompaction(); } this.abortHandoff(); this.abortBash(); this.abortEval(); const postPromptDrain = this.#cancelPostPromptTasks(); this.agent.abort(options?.reason); await postPromptDrain; await this.agent.waitForIdle(); await this.#drainAutolearnCapture(); await this.#goalRuntime.onTaskAborted({ reason: options?.goalReason ?? "interrupted" }); // Clear prompt-in-flight state: waitForIdle resolves when the agent loop's finally // block runs, but nested prompt setup/finalizers may still be unwinding. Without this, // a subsequent prompt() can incorrectly observe the session as busy after an abort. this.#resetInFlight(); this.#resetSessionStopContinuationState(); this.#clearPendingSessionStopContinuations(); // Safety net: if the agent loop aborted without producing an assistant // message (e.g. failed before the first stream), the in-flight yield was // never resolved or rejected by the normal message_end path. Reject it now // so any requeue callback still fires and the queue stays consistent. if (this.#toolChoiceQueue.hasInFlight) { this.#toolChoiceQueue.reject("aborted"); } // Re-record advisor concerns the interrupt would otherwise strand, as // visible/persisted advice without triggering a turn (the agent is idle // now): cards steered into the queue before the user stopped, plus any // that arrived via enqueueAdvice mid-abort and were parked hidden in // #pendingNextTurnMessages while the turn was still tearing down. Other // deferred next-turn context (non-advisor) stays queued, in order. const parkedAdvisorCards = this.#pendingNextTurnMessages.filter(isAdvisorCard); if (parkedAdvisorCards.length > 0) { this.#pendingNextTurnMessages = this.#pendingNextTurnMessages.filter(m => !isAdvisorCard(m)); } for (const card of [...strandedAdvisorCards, ...parkedAdvisorCards]) { this.#preserveAdvisorCard(card); } } finally { this.#abortInProgress = false; this.#drainStrandedQueuedMessages(); } } /** * Start a new session, optionally with initial messages and parent tracking. * Clears all messages and starts a new session. * Listeners are preserved and will continue receiving events. * @param options - Optional initial messages and parent session path * @returns true if completed, false if cancelled by hook */ async newSession(options?: NewSessionOptions): Promise { this.#assertVibeSessionTransitionAllowed("start a new session"); const previousSessionFile = this.sessionFile; // Emit session_before_switch event with reason "new" (can be cancelled) if (this.#extensionRunner?.hasHandlers("session_before_switch")) { const result = (await this.#extensionRunner.emit({ type: "session_before_switch", reason: "new", })) as SessionBeforeSwitchResult | undefined; if (result?.cancel) { return false; } } this.#disconnectFromAgent(); await this.abort(); this.#cancelOwnAsyncJobs(); this.#closeAllProviderSessions("new session"); await this.#bash.flushPending(); const bashTransition = this.#bash.beginSessionTransition({ persistDetached: options?.drop !== true }); let sessionTransitioned = false; try { this.agent.reset(); if (options?.drop && previousSessionFile) { // Detach the advisor recorder feed and drain its writer BEFORE deleting the // old artifacts dir: `await this.abort()` only stops the primary, so a still- // running advisor turn could otherwise finish, emit `message_end`, and recreate // `/__advisor.jsonl`. #resetAdvisorSessionState (after newSession) re-primes // the advisor and re-attaches the feed at the new session's path. await this.#advisors.detachAndCloseRecorders(); try { await this.sessionManager.dropSession(previousSessionFile); } catch (err) { logger.error("Failed to delete session during /drop", { err }); } } else { await this.sessionManager.flush(); } await this.sessionManager.newSession({ ...options, additionalDirectories: this.settings.get("workspace.additionalDirectories"), }); this.#bash.markSessionTransition(bashTransition); sessionTransitioned = true; } finally { this.#bash.finishSessionTransition(bashTransition, sessionTransitioned); } this.#clearSessionScopedToolState(); this.#clearCheckpointRuntimeState(); this.setTodoPhases([]); this.#freshProviderSessionId = undefined; this.#clearInheritedProviderPromptCacheKey(); this.#syncAgentSessionId(); this.#memory.rekeyForCurrentSessionId(); await this.#memory.resetContextForNewTranscript(); this.#pendingNextTurnMessages = []; this.#scheduledHiddenNextTurnGeneration = undefined; this.sessionManager.appendThinkingLevelChange(this.thinkingLevel, this.configuredThinkingLevel()); this.sessionManager.appendServiceTierChange(this.#models.serviceTierEntry()); this.#todo.resetCycle(); this.#planReferenceSent = false; this.#planReferencePath = "local://PLAN.md"; this.#advisors.resetSessionState(); this.#reconnectToAgent(); // The workspace-roots block must reflect the new session's directory set, // not the previous session's — refresh before the next turn goes out. await this.refreshBaseSystemPrompt(); // Emit session_switch event with reason "new" to hooks if (this.#extensionRunner) { await this.#extensionRunner.emit({ type: "session_switch", reason: "new", previousSessionFile, }); } return true; } /** * Set a display name for the current session. */ setSessionName(name: string, source: "auto" | "user" = "auto", trigger?: SessionNameTrigger): Promise { const setSessionName = this.sessionManager.setSessionName as SetSessionNameWithTrigger; return setSessionName.call(this.sessionManager, name, source, trigger); } /** * Fork the current session, creating a new session file with the exact same state. * Copies all entries and artifacts to the new session. * Unlike newSession(), this preserves all messages in the agent state. * @returns true if completed, false if cancelled by hook or not persisting */ async fork(): Promise { this.#assertVibeSessionTransitionAllowed("fork the session"); const previousSessionFile = this.sessionFile; // Emit session_before_switch event with reason "fork" (can be cancelled) if (this.#extensionRunner?.hasHandlers("session_before_switch")) { const result = (await this.#extensionRunner.emit({ type: "session_before_switch", reason: "fork", })) as SessionBeforeSwitchResult | undefined; if (result?.cancel) { return false; } } await this.#bash.flushPending(); // Flush current session to ensure all entries are written await this.sessionManager.flush(); const bashTransition = this.#bash.beginSessionTransition(); // Fork the session (creates new session file with same entries) let forkResult: { oldSessionFile: string; newSessionFile: string } | undefined; try { forkResult = await this.sessionManager.fork(); } catch (error) { this.#bash.finishSessionTransition(bashTransition, false); throw error; } if (!forkResult) { this.#bash.finishSessionTransition(bashTransition, false); return false; } this.#bash.markSessionTransition(bashTransition); this.#bash.finishSessionTransition(bashTransition, true); // Copy artifacts directory if it exists const oldArtifactDir = forkResult.oldSessionFile.slice(0, -6); const newArtifactDir = forkResult.newSessionFile.slice(0, -6); try { const oldDirStat = await fs.promises.stat(oldArtifactDir); if (oldDirStat.isDirectory()) { await fs.promises.cp(oldArtifactDir, newArtifactDir, { recursive: true }); } } catch (err) { if (!isEnoent(err)) { logger.warn("Failed to copy artifacts during fork", { oldArtifactDir, newArtifactDir, error: err instanceof Error ? err.message : String(err), }); } } // Update agent session ID this.#freshProviderSessionId = undefined; this.#adoptInheritedProviderPromptCacheKey(); this.#syncAgentSessionId(); this.#memory.rekeyForCurrentSessionId(); await this.#memory.resetContextForNewTranscript(); // Emit session_switch event with reason "fork" to hooks if (this.#extensionRunner) { await this.#extensionRunner.emit({ type: "session_switch", reason: "fork", previousSessionFile, }); } return true; } /** Move the active session and artifacts after enforcing mode transition invariants. */ async moveSession(newCwd: string, targetSessionDir?: string): Promise { this.#assertVibeSessionTransitionAllowed("move the session"); await this.sessionManager.moveTo(newCwd, targetSessionDir); } // ========================================================================= // Model Management // ========================================================================= /** * Set model directly. * Validates that a credential source is configured (synchronously, without * refreshing OAuth or running command-backed key programs). Active switches * always take effect; if the current transcript is too large for the target * model, the next prompt's compaction/error path owns that recovery instead * of leaving the session pinned to the old model. * @throws Error if no API key available for the model */ async setModel( model: Model, role: string = "default", options?: { selector?: string; thinkingLevel?: ThinkingLevel; persist?: boolean; currentContextTokens?: number; }, ): Promise<{ switched: boolean }> { return this.#models.setModel(model, role, options); } /** Selects a model for this session without updating persisted model settings. */ setModelTemporary( model: Model, thinkingLevel?: ConfiguredThinkingLevel, options?: { ephemeral?: boolean }, ): Promise { return this.#models.setModelTemporary(model, thinkingLevel, options); } /** Cycles the scoped model set, or all available models when no scope exists. */ cycleModel(direction: "forward" | "backward" = "forward"): Promise { return this.#models.cycleModel(direction); } /** Resolves configured role models and the currently active role index. */ getRoleModelCycle(roleOrder: readonly string[]): RoleModelCycle | undefined { return this.#models.getRoleModelCycle(roleOrder); } /** Applies a resolved role model without changing global settings. */ applyRoleModel(entry: ResolvedRoleModel): Promise { return this.#models.applyRoleModel(entry); } /** Cycles the configured role models in the supplied order. */ cycleRoleModels( roleOrder: readonly string[], direction: "forward" | "backward" = "forward", ): Promise { return this.#models.cycleRoleModels(roleOrder, direction); } /** Lists available models after applying the configured enabled-model filter. */ getAvailableModels(): Model[] { return this.#models.getAvailableModels(); } /** Selects the session thinking level and optionally persists it as the default. */ setThinkingLevel(level: ConfiguredThinkingLevel | undefined, persist: boolean = false): void { this.#models.setThinkingLevel(level, persist); } /** Advances through the thinking selectors supported by the active model. */ cycleThinkingLevel(): ConfiguredThinkingLevel | undefined { return this.#models.cycleThinkingLevel(); } /** Reports whether `/fast` is enabled for the active model family. */ isFastModeEnabled(): boolean { return this.#models.isFastModeEnabled(); } /** Reports whether priority service is realized by the active model. */ isFastModeActive(): boolean { return this.#models.isFastModeActive(); } /** Sets or clears one model family's live service tier. */ setServiceTierFamily(family: ServiceTierFamily, tier: ServiceTier | undefined): void { this.#models.setServiceTierFamily(family, tier); } /** Enables or disables priority service for the active model family. */ setFastMode(enabled: boolean): boolean { return this.#models.setFastMode(enabled); } /** Toggles priority service for the active model family. */ toggleFastMode(): boolean { return this.#models.toggleFastMode(); } /** Lists thinking levels supported by the active model. */ getAvailableThinkingLevels(): ReadonlyArray { return this.#models.getAvailableThinkingLevels(); } // ========================================================================= // Message Queue Mode Management // ========================================================================= /** * Set steering mode. * Saves to settings. */ setSteeringMode(mode: "all" | "one-at-a-time"): void { this.agent.setSteeringMode(mode); this.settings.set("steeringMode", mode); } /** * Set follow-up mode. * Saves to settings. */ setFollowUpMode(mode: "all" | "one-at-a-time"): void { this.agent.setFollowUpMode(mode); this.settings.set("followUpMode", mode); } /** * Set interrupt mode. * Saves to settings. */ setInterruptMode(mode: "immediate" | "wait"): void { this.agent.setInterruptMode(mode); this.settings.set("interruptMode", mode); } /** * Cancel in-progress branch summarization. */ abortBranchSummary(): void { this.#branchSummaryAbortController?.abort(); } /** * Cancel in-progress handoff generation. */ abortHandoff(): void { this.#handoff.abortHandoff(); } /** * Check if handoff generation is in progress. */ get isGeneratingHandoff(): boolean { return this.#handoff.isGeneratingHandoff; } /** * Generate a handoff document with a oneshot LLM call, then start a new session with it. * * @param customInstructions Optional focus for the handoff document * @param options Handoff execution options * @returns The handoff document text, or undefined if cancelled/failed */ handoff(customInstructions?: string, options?: SessionHandoffOptions): Promise { return this.#handoff.handoff(customInstructions, options); } #isTerminalYieldToolResult(event: { toolName: string; isError?: boolean; result?: { details?: unknown } }): boolean { if (event.toolName !== "yield" || event.isError) return false; const details = event.result?.details; if (!details || typeof details !== "object") return true; const record = details as Record; return !( record.status === "success" && Array.isArray(record.type) && record.type.length > 0 && record.type.every(item => typeof item === "string") ); } #markTerminalYieldToolCall(toolCallId: string): void { this.#lastSuccessfulYieldToolCallId = toolCallId; this.#yieldTerminationPending = true; } #assistantMessageHasSuccessfulYieldToolCall(assistantMessage: AssistantMessage, toolCallId: string): boolean { const lastToolCall = assistantMessage.content .slice() .reverse() .find((content): content is ToolCall => content.type === "toolCall"); return lastToolCall?.name === "yield" && lastToolCall.id === toolCallId; } #assistantEndedWithSuccessfulYield(assistantMessage: AssistantMessage): boolean { const toolCallId = this.#lastSuccessfulYieldToolCallId; return toolCallId ? this.#assistantMessageHasSuccessfulYieldToolCall(assistantMessage, toolCallId) : false; } #findSuccessfulYieldAssistantMessage(messages: readonly AgentMessage[]): AssistantMessage | undefined { const toolCallId = this.#lastSuccessfulYieldToolCallId; if (!toolCallId) return undefined; for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i]; if (message.role !== "assistant") continue; if (this.#assistantMessageHasSuccessfulYieldToolCall(message, toolCallId)) return message; } return undefined; } #enforceRewindBeforeYield(): boolean { if (!this.#checkpointState || this.#pendingRewindReport) { return false; } const reminder = [ "", "You are in an active checkpoint. You MUST call rewind with your investigation findings before yielding. Do NOT yield without completing the checkpoint.", "", ].join("\n"); this.agent.appendMessage({ role: "developer", content: [{ type: "text", text: reminder }], attribution: "agent", timestamp: Date.now(), }); this.#scheduleAgentContinue({ generation: this.#promptGeneration }); return true; } #extractRewindReport(messages: AgentMessage[]): string | undefined { if (!this.#checkpointState) return undefined; if (this.#pendingRewindReport) return this.#pendingRewindReport; for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i]; if (message?.role !== "toolResult" || message.isError) continue; const semanticResult = semanticToolResult(message.toolName, message); if (semanticResult?.toolName !== "rewind") continue; const details = semanticResult.details; const detailReport = details && typeof details === "object" && "report" in details && typeof details.report === "string" ? details.report.trim() : ""; const textReport = message.content.find(part => part.type === "text")?.text.trim() ?? ""; const report = detailReport || textReport; return report.length > 0 ? report : undefined; } return undefined; } async #applyRewind(report: string, activeMessages?: AgentMessage[]): Promise { const checkpointState = this.#checkpointState; if (!checkpointState) { return; } this.#bash.withBranchTransition(() => { try { this.sessionManager.branchWithSummary(checkpointState.checkpointEntryId, report, { startedAt: checkpointState.startedAt, }); } catch (error) { logger.warn("Rewind branch checkpoint missing, falling back to root", { error: error instanceof Error ? error.message : String(error), }); this.sessionManager.branchWithSummary(null, report, { startedAt: checkpointState.startedAt }); } }); const rewoundAt = new Date().toISOString(); const details = { report, startedAt: checkpointState.startedAt, rewoundAt }; this.sessionManager.appendCustomMessageEntry( "rewind-report", prompt.render(rewindReportTemplate, { report }), false, details, "agent", ); this.#lastCompletedRewind = { report, startedAt: checkpointState.startedAt, rewoundAt }; if (activeMessages) { for (const message of activeMessages) { if (message.role === "toolResult" && semanticToolResult(message.toolName, message)?.toolName === "rewind") { this.#rewoundToolResultIds.add(message.toolCallId); } } } const sessionContext = this.buildDisplaySessionContext(); if (activeMessages) { activeMessages.splice(0, activeMessages.length, ...sessionContext.messages); } this.agent.replaceMessages(activeMessages ?? sessionContext.messages); this.#advisors.resetSessionState(); this.#todo.syncFromBranch(); this.#closeCodexProviderSessionsForHistoryRewrite(); this.#checkpointState = undefined; this.#pendingRewindReport = undefined; } /** Plan-mode decision affordances: `ask`, or plan approval via `write xd://propose`. */ #isPlanDecisionTool(toolCall: { name: string; arguments?: Record }): boolean { return toolCall.name === "ask" || isProposeToolCall(toolCall); } async #enforcePlanModeDecisionAtSettle(): Promise { if (!this.#planModeState?.enabled) { return false; } const assistantMessage = this.#findLastAssistantMessage(); if (!assistantMessage) { return false; } if (assistantMessage.stopReason === "error" || assistantMessage.stopReason === "aborted") { return false; } const calledDecisionTool = assistantMessage.content.some( content => content.type === "toolCall" && this.#isPlanDecisionTool(content), ); if (calledDecisionTool) { this.#planModeReminderCount = 0; this.#planModeReminderAwaitingProgress = false; return false; } const hasToolCall = assistantMessage.content.some(content => content.type === "toolCall"); if (hasToolCall) { return false; } if (this.#planModeReminderAwaitingProgress) { return false; } if (this.#planModeReminderCount >= PLAN_MODE_REMINDER_MAX) { logger.debug("Plan mode convergence: reminder cap reached; yielding to user"); return false; } const hasRequiredTools = this.#tools.registry.has("ask") && this.#tools.registry.has("write"); if (!hasRequiredTools) { logger.warn("Plan mode enforcement skipped because ask/write tools are unavailable", { activeToolNames: this.agent.state.tools.map(tool => tool.name), }); return false; } this.#planModeReminderCount++; this.#planModeReminderAwaitingProgress = true; this.#toolChoiceQueue.pushOnce("required", { label: "plan-mode-decision" }); const reminder = prompt.render(planModeToolDecisionReminderPrompt, { askToolName: "ask", }); const reminderMessage: Message = { role: "developer", content: [{ type: "text", text: reminder }], attribution: "agent", timestamp: Date.now(), }; this.agent.appendMessage(reminderMessage); this.sessionManager.appendMessage(reminderMessage); this.#scheduleAgentContinue({ generation: this.#promptGeneration, // If the continuation never runs (new prompt, dispose, compaction, // handoff), the forced choice must not leak onto an unrelated turn. onSkip: () => this.#toolChoiceQueue.removeByLabel("plan-mode-decision"), }); return true; } #setModelWithProviderSessionReset(model: Model): void { const currentModel = this.model; if (currentModel) { this.#closeProviderSessionsForModelSwitch(currentModel, model); if (!modelsAreEqual(currentModel, model)) { this.#clearInheritedProviderPromptCacheKey(); } } this.agent.setModel(model); // Re-evaluate append-only context mode — provider or setting may have changed this.#syncAppendOnlyContext(model); } #closeCodexProviderSessionsForHistoryRewrite(): void { const currentModel = this.model; if (currentModel?.api !== "openai-codex-responses") return; this.#closeProviderSessionsForModelSwitch(currentModel, currentModel); } #resetCodexProviderAfterCompaction(compaction: CodexCompactionContext): void { resetOpenAICodexHistoryAfterCompaction({ providerSessionState: this.#providerSessionState, sessionId: this.sessionId, compaction, }); } #resetCurrentResponsesProviderSession(reason: string): void { const currentModel = this.model; if (currentModel?.api !== "openai-responses" && currentModel?.api !== "openai-codex-responses") { return; } this.#closeProviderSessionsForModelSwitch(currentModel, currentModel); this.agent.appendOnlyContext?.invalidateForModelChange(); logger.debug("Reset Responses provider session after stale replay error", { provider: currentModel.provider, model: currentModel.id, api: currentModel.api, reason, }); } /** * Re-evaluate append-only context mode, creating or destroying the * manager as needed. Called on model switch AND setting change. */ #syncAppendOnlyContext(model: Model | null | undefined): void { const setting = this.settings.get("provider.appendOnlyContext") ?? "auto"; const enable = shouldEnableAppendOnlyContext(setting, model); const providerId = model?.provider; const prev = this.#lastAppendOnlyResolution; if (prev && prev.enable === enable && prev.providerId === providerId) return; this.#lastAppendOnlyResolution = { enable, providerId }; if (enable && !this.agent.appendOnlyContext) { this.agent.setAppendOnlyContext(new AppendOnlyContextManager()); } else if (enable && this.agent.appendOnlyContext) { // Already active — invalidate prefix + log so the next turn // rebuilds for the current model's normalization. this.agent.appendOnlyContext.invalidateForModelChange(); } else if (!enable && this.agent.appendOnlyContext) { this.agent.setAppendOnlyContext(undefined); } } #closeProviderSessionsForModelSwitch(currentModel: Model, nextModel: Model): void { const providerKeys = new Set(); if (currentModel.api === "openai-codex-responses" || nextModel.api === "openai-codex-responses") { providerKeys.add("openai-codex-responses"); } if (currentModel.api === "openai-responses") { providerKeys.add(`openai-responses:${currentModel.provider}`); } if (nextModel.api === "openai-responses") { providerKeys.add(`openai-responses:${nextModel.provider}`); } // `openai-completions` sessions are keyed `openai-completions:::` // and cache backend-specific decisions (strict-tools disable scopes, reasoning-effort // fallbacks). The resolved request base URL can differ from the catalog `model.baseUrl` // (Moonshot env override, Alibaba Coding Plan enterprise URL, Azure deployment URL), // so evict by provider prefix when the user moves away from that completions backend. let completionsPrefixToEvict: string | undefined; if (currentModel.api === "openai-completions") { const currentScope = `${currentModel.provider}:${currentModel.baseUrl ?? ""}`; const nextScope = nextModel.api === "openai-completions" ? `${nextModel.provider}:${nextModel.baseUrl ?? ""}` : undefined; if (currentScope !== nextScope) { completionsPrefixToEvict = `openai-completions:${currentModel.provider}:`; } } for (const providerKey of providerKeys) { const state = this.#providerSessionState.get(providerKey); if (!state) continue; try { state.close(); } catch (error) { logger.warn("Failed to close provider session state during model switch", { providerKey, error: String(error), }); } this.#providerSessionState.delete(providerKey); } if (completionsPrefixToEvict !== undefined) { for (const [key, state] of this.#providerSessionState) { if (!key.startsWith(completionsPrefixToEvict)) continue; try { state.close(); } catch (error) { logger.warn("Failed to close provider session state during model switch", { providerKey: key, error: String(error), }); } this.#providerSessionState.delete(key); } } } // ========================================================================= // Auto-Retry // ========================================================================= /** Cancel an in-progress retry. */ abortRetry(): void { this.#recovery.abortRetry(); } /** Whether auto-retry is currently in progress. */ get isRetrying(): boolean { return this.#recovery.isRetrying; } /** Whether auto-retry is enabled. */ get autoRetryEnabled(): boolean { return this.#recovery.autoRetryEnabled; } /** Toggle the auto-retry setting. */ setAutoRetryEnabled(enabled: boolean): void { this.#recovery.setAutoRetryEnabled(enabled); } /** Retry the last failed assistant turn when the session is idle. */ retry(): Promise { return this.#recovery.retry(); } // ========================================================================= // Bash Execution // ========================================================================= /** * Execute a bash command and retain the session/branch that owned its start. * @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.useUserShell If true, allow caller to request configured user-shell routing */ executeBash( command: string, onChunk?: (chunk: string) => void, options?: { excludeFromContext?: boolean; useUserShell?: boolean }, ): Promise { return this.#bash.executeBash(command, onChunk, options); } /** Record a bash result supplied outside executeBash in the current ownership scope. */ recordBashResult(command: string, result: BashResult, options?: { excludeFromContext?: boolean }): void { this.#bash.recordBashResult(command, result, options); } /** Cancel running bash commands. */ abortBash(): void { this.#bash.abort(); } /** Whether a bash command is currently running */ get isBashRunning(): boolean { return this.#bash.isRunning; } /** Whether there are pending bash messages waiting to be flushed */ get hasPendingBashMessages(): boolean { return this.#bash.hasPendingMessages; } // ========================================================================= // User-Initiated Python Execution // ========================================================================= /** * Execute Python code in the shared kernel. * Uses the same kernel session as eval's Python backend, allowing collaborative editing. * @param code The Python code to execute * @param onChunk Optional streaming callback for output * @param options.excludeFromContext If true, execution won't be sent to LLM ($$ prefix) */ executePython( code: string, onChunk?: (chunk: string) => void, options?: { excludeFromContext?: boolean }, ): Promise { return this.#eval.executePython(code, onChunk, options); } assertEvalExecutionAllowed(): void { this.#eval.assertExecutionAllowed(); } /** * Track Python work started outside AgentSession.executePython so dispose can await and abort it too. */ trackEvalExecution(execution: Promise, abortController: AbortController): Promise { return this.#eval.trackExecution(execution, abortController); } /** * Record a Python execution result in session history. */ recordPythonResult(code: string, result: PythonResult, options?: { excludeFromContext?: boolean }): void { this.#eval.recordPythonResult(code, result, options); } /** * Cancel running Python execution. */ abortEval(): void { this.#eval.abort(); } /** Whether a Python execution is currently running */ get isEvalRunning(): boolean { return this.#eval.isRunning; } /** Whether there are pending Python messages waiting to be flushed */ get hasPendingPythonMessages(): boolean { return this.#eval.hasPendingMessages; } /** * Flush pending Python messages to agent state and session. */ // ========================================================================= // IRC Delivery // ========================================================================= /** Surfaces and consumes pending IRC records before automatic injection. */ drainPendingIrcInboxMessages(agentId: string, opts?: { from?: string; limit?: number }): IrcMessage[] { return this.#irc.drainInboxMessages(agentId, opts); } /** Delivers an IRC message into this recipient session. */ deliverIrcMessage(msg: IrcMessage, opts?: { expectsReply?: boolean }): Promise<"injected" | "woken"> { return this.#irc.deliver(msg, opts); } /** Emits an IRC relay observation for UI rendering without persisting it. */ emitIrcRelayObservation(record: CustomMessage): void { this.#irc.emitRelayObservation(record); } /** * Run a single ephemeral side-channel turn against this session's current * model + system prompt + history. The main turn's tool catalog is sent * to preserve the prompt cache, but the model is reminded not to call * tools and any tool calls are discarded. The side request * does not block on, or interfere with, any in-flight main turn. The * session's history and persisted state are NOT modified by this call. * * Used by `BtwController` (`/btw`) and `OmfgController` (`/omfg`) to share * the snapshot + stream pipeline. The snapshot includes any in-flight * streaming assistant text so the model sees the half-finished response * rather than missing context. */ async runEphemeralTurn(args: { promptText: string; onTextDelta?: (delta: string) => void; signal?: AbortSignal; dedupeReply?: boolean; }): Promise<{ replyText: string; assistantMessage: AssistantMessage }> { const model = this.model; if (!model) { throw new Error("No active model on session"); } const cacheSessionId = this.sessionId; const snapshot = this.#buildEphemeralSnapshot(args.promptText); const llmMessages = await this.convertMessagesToLlm(snapshot, args.signal); const context = await this.agent.buildSideRequestContext(llmMessages); const options = this.prepareSimpleStreamOptions( { apiKey: this.#modelRegistry.resolver(model, cacheSessionId), // Side-channel turns must not share OpenAI/Codex append-only // conversation state with the main agent turn: IRC and /btw can run // while the main turn is mid-tool-call. Keep the prompt-cache key // stable, but give provider routing a unique request lineage. The // shared provider state map is still required so Codex can allocate // websocket state under that side-channel session id. sessionId: `${cacheSessionId}:side:${Snowflake.next()}`, promptCacheKey: cacheSessionId, preferWebsockets: this.#preferWebsockets, providerSessionState: this.#providerSessionState, reasoning: toReasoningEffort(this.thinkingLevel), disableReasoning: shouldDisableReasoning(this.thinkingLevel), hideThinkingSummary: this.agent.hideThinkingSummary, serviceTier: this.#models.effectiveServiceTier(model), signal: args.signal, }, model.provider, ); let providerReplyText = ""; let emittedReplyText = ""; let assistantMessage: AssistantMessage | undefined; const stream = await this.#sideStreamFn(model, obfuscateProviderContext(this.#obfuscator, context), options); for await (const event of stream) { if (event.type === "text_delta") { providerReplyText += event.delta; if (args.onTextDelta) { const readyText = this.#deobfuscatedProviderTextReadyForDelta(providerReplyText); if (readyText.length > emittedReplyText.length) { const delta = readyText.slice(emittedReplyText.length); emittedReplyText = readyText; args.onTextDelta(delta); } } continue; } if (event.type === "done") { // A well-formed provider "done" event carries `content: AssistantContentBlock[]`, // but a proxy/wrapper (custom extension providers, gateway-wrapped OAuth streams, // see #4323) can hand back a message whose `content` was dropped or replaced with // `undefined`. Downstream `.content.filter` at the sanitize step below would then // crash the recap turn with `TypeError: undefined is not an object (evaluating // 'H.content.filter')`. Normalize to `[]` so the recap surfaces an empty reply // instead of turning a malformed side-channel response into a session-mute crash. const rawContent = Array.isArray(event.message.content) ? event.message.content : []; assistantMessage = this.#obfuscator?.hasSecrets() ? { ...event.message, content: deobfuscateAssistantContent(this.#obfuscator, rawContent) } : { ...event.message, content: rawContent }; break; } if (event.type === "error") { throw new Error(event.error.errorMessage || "Ephemeral turn failed"); } } if (!assistantMessage) { throw new Error("Ephemeral turn ended without a final message"); } const replyText = this.#deobfuscateFromProvider(providerReplyText); if (args.onTextDelta && replyText.length > emittedReplyText.length) { args.onTextDelta(replyText.slice(emittedReplyText.length)); } const sanitizedMessage: AssistantMessage = { ...assistantMessage, content: assistantMessage.content.filter(block => block.type !== "toolCall"), }; return { replyText: args.dedupeReply === false ? replyText.trim() : dedupeEphemeralReply(replyText.trim()), assistantMessage: sanitizedMessage, }; } /** * Build a message snapshot for an ephemeral side-channel turn. Includes * the in-flight streaming assistant message (if any) so the model sees * the partial response in context, then appends the prompt as a virtual * user message. */ #buildEphemeralSnapshot(promptText: string): AgentMessage[] { const messages = [...this.messages]; const streaming = this.agent.state.streamMessage; if (streaming && streaming.role === "assistant" && Array.isArray(streaming.content)) { const preservedBlocks: AssistantMessage["content"] = []; // Preserve thinking blocks: DeepSeek-class encoders replay them as // `reasoning_content` and reject the request (HTTP 400) when the field // goes missing on a turn that previously emitted thinking. for (const c of streaming.content) { if (c.type === "thinking") preservedBlocks.push(c); } const streamingText = streaming.content .filter((c): c is TextContent => c.type === "text") .map(c => c.text) .join(""); if (streamingText) { preservedBlocks.push({ type: "text", text: streamingText }); } if (preservedBlocks.length > 0) { const normalized: AssistantMessage = { ...streaming, content: preservedBlocks, }; const lastMessage = messages.at(-1); if (lastMessage?.role === "assistant") { messages[messages.length - 1] = normalized; } else { messages.push(normalized); } } } messages.push({ role: "developer", content: [{ type: "text", text: sideChannelNoToolsReminder }], attribution: "agent", timestamp: Date.now(), }); messages.push({ role: "user", content: [{ type: "text", text: promptText }], attribution: "agent", timestamp: Date.now(), }); return messages; } // ========================================================================= // Session Management // ========================================================================= /** * Reload the current session from disk. * * Intended for extension commands and headless modes to re-read the current session * file and re-emit session_switch hooks. */ async reload(): Promise { const sessionFile = this.sessionFile; if (!sessionFile) return; await this.switchSession(sessionFile); } /** * Switch to a different session file. * Aborts current operation, loads messages, restores model/thinking. * Listeners are preserved and will continue receiving events. * @returns true if switch completed, false if cancelled by hook */ async switchSession(sessionPath: string): Promise { const previousSessionFile = this.sessionManager.getSessionFile(); const switchingToDifferentSession = previousSessionFile ? path.resolve(previousSessionFile) !== path.resolve(sessionPath) : true; // Emit session_before_switch event (can be cancelled) if (this.#extensionRunner?.hasHandlers("session_before_switch")) { const result = (await this.#extensionRunner.emit({ type: "session_before_switch", reason: "resume", targetSessionFile: sessionPath, })) as SessionBeforeSwitchResult | undefined; if (result?.cancel) { return false; } } this.#disconnectFromAgent(); await this.abort({ goalReason: "internal" }); await this.#sessionBeforeSwitchReconciler?.(); await this.#bash.flushPending(); // Flush pending writes before switching so restore snapshots reflect committed state. await this.sessionManager.flush(); const previousSessionState = this.sessionManager.captureState(); const bashTransition = this.#bash.beginSessionTransition(); // Only same-session reloads compare against the prior context to detect // rollback edits (`#didSessionMessagesChange` below). Building it for a // different-session switch is a pure waste — and on huge pre-fix sessions // it materializes every persisted snapcompact frame plus the // `openaiRemoteCompaction.replacementHistory` payload into messages, // blowing the heap before the new session even loads (issue #3846). The // error-recovery path rebuilds the context on demand from the restored // state instead. const previousSessionContext = switchingToDifferentSession ? undefined : this.buildDisplaySessionContext(); // switchSession replaces these arrays wholesale during load/rollback, so retaining // the existing message objects is sufficient and avoids structured-clone failures for // extension/custom metadata that is valid to persist but not cloneable. const previousAgentMessages = [...this.agent.state.messages]; const previousSteeringMessages = [...this.agent.peekSteeringQueue()]; const previousFollowUpMessages = [...this.agent.peekFollowUpQueue()]; const previousPendingNextTurnMessages = [...this.#pendingNextTurnMessages]; const previousScheduledHiddenNextTurnGeneration = this.#scheduledHiddenNextTurnGeneration; const previousModel = this.model; const previousThinkingLevel = this.thinkingLevel; const previousAutoThinking = this.isAutoThinking; const previousAutoResolvedLevel = this.autoResolvedThinkingLevel(); const previousServiceTierByFamily = this.serviceTierByFamily; const previousTools = [...this.agent.state.tools]; const previousBaseSystemPrompt = this.#tools.baseSystemPrompt; const previousSystemPrompt = this.agent.state.systemPrompt; const previousBaseSystemPromptBeforeMemoryPromotion = this.#memory.promotionSnapshot; const previousFreshProviderSessionId = this.#freshProviderSessionId; const previousInheritedProviderPromptCacheKey = this.#inheritedProviderPromptCacheKey; // Snapshot the full checkpoint runtime state: the success path calls // #rehydrateCheckpointRewindState(), which clears and rebuilds all four // fields from the target branch. On rollback every one must be restored, // or a failed switch leaks the target session's checkpoint state. const previousCheckpointState = this.#checkpointState; const previousPendingRewindReport = this.#pendingRewindReport; const previousLastCompletedRewind = this.#lastCompletedRewind; const previousRewoundToolResultIds = new Set(this.#rewoundToolResultIds); this.agent.clearAllQueues(); this.#pendingNextTurnMessages = []; this.#scheduledHiddenNextTurnGeneration = undefined; try { await this.sessionManager.setSessionFile(sessionPath); this.#bash.markSessionTransition(bashTransition); if (switchingToDifferentSession) { this.#freshProviderSessionId = undefined; this.#clearInheritedProviderPromptCacheKey(); this.#adoptInheritedProviderPromptCacheKey(); } this.#syncAgentSessionId(); this.#memory.rekeyForCurrentSessionId(); let sessionContext = this.buildDisplaySessionContext(); const didReloadConversationChange = previousSessionContext !== undefined && didSessionMessagesChange(previousSessionContext.messages, sessionContext.messages); this.#rehydrateCheckpointRewindState(); // Emit session_switch event to hooks if (this.#extensionRunner) { await this.#extensionRunner.emit({ type: "session_switch", reason: "resume", previousSessionFile, }); } this.agent.replaceMessages(sessionContext.messages); this.#advisors.resetSessionState(); this.#todo.syncFromBranch(); if (switchingToDifferentSession) { this.#closeAllProviderSessions("session switch"); } else if (didReloadConversationChange) { this.#closeAllProviderSessions("session reload"); } // Restore model if saved const targetModelStrings = getRestorableSessionModels( sessionContext.models, this.sessionManager.getLastModelChangeRole(), ); if (targetModelStrings.length > 0) { const availableModels = this.#modelRegistry.getAvailable(); let match: Model | undefined; for (const targetModelStr of targetModelStrings) { const slashIdx = targetModelStr.indexOf("/"); if (slashIdx <= 0) continue; const provider = targetModelStr.slice(0, slashIdx); const modelId = targetModelStr.slice(slashIdx + 1); match = availableModels.find(m => m.provider === provider && m.id === modelId); if (match) break; } if (match) { const currentModel = this.model; const shouldResetProviderState = switchingToDifferentSession || (currentModel !== undefined && (currentModel.provider !== match.provider || currentModel.id !== match.id || currentModel.api !== match.api)); if (shouldResetProviderState) { this.#setModelWithProviderSessionReset(match); } else { this.agent.setModel(match); } } } const model = this.model; if (model) { const interruptedTurnAbort = createInterruptedTurnAbortMessage(this.sessionManager.getBranch(), { api: model.api, provider: model.provider, model: model.id, }); if (interruptedTurnAbort) { this.sessionManager.appendMessage(interruptedTurnAbort); sessionContext = this.buildDisplaySessionContext(); this.agent.replaceMessages(sessionContext.messages); } } const hasThinkingEntry = this.sessionManager.getBranch().some(entry => entry.type === "thinking_level_change"); const hasServiceTierEntry = this.sessionManager .getBranch() .some(entry => entry.type === "service_tier_change"); const defaultThinkingLevel = parseConfiguredThinkingLevel(this.settings.get("defaultThinkingLevel")); const configuredServiceTierByFamily = buildServiceTierByFamily( this.settings.get("tier.openai"), this.settings.get("tier.anthropic"), this.settings.get("tier.google"), ); // Restore the thinking selector. Each change persists the configured // selector (`auto` or a concrete level), so prefer it: an `auto` session // resumes in auto mode (reclassifying the next turn) instead of freezing at // the last resolved level. Entries written before the `configured` field // existed fall back to the concrete level (legacy pin-on-resume behavior). // With no thinking entry, fall back to the global default so fresh sessions // still classify their first turn. const restoredConfigured = sessionContext.configuredThinkingLevel; const restoredThinkingLevel: ConfiguredThinkingLevel | undefined = hasThinkingEntry || (defaultThinkingLevel === AUTO_THINKING && sessionContext.thinkingLevel !== "off") ? restoredConfigured === AUTO_THINKING ? AUTO_THINKING : (sessionContext.thinkingLevel as ThinkingLevel | undefined) : defaultThinkingLevel; this.#models.restoreThinkingLevel(restoredThinkingLevel); this.#models.restoreServiceTiers( hasServiceTierEntry ? (sessionContext.serviceTier ?? {}) : configuredServiceTierByFamily, ); if (switchingToDifferentSession) { await this.#memory.resetContextForNewTranscript(); } if (switchingToDifferentSession) { this.#clearSessionScopedToolState(); } this.#reconnectToAgent(); try { await this.#sessionSwitchReconciler?.(); } catch (error) { logger.warn("Failed to reconcile session mode after switch", { targetSessionFile: sessionPath, error: String(error), }); } // Refresh the workspace-roots block to match the resumed session's directory set. // Wrapped so a rebuild failure (e.g. a gate that intentionally fails in tests) // doesn't roll back an otherwise-successful session switch. try { await this.refreshBaseSystemPrompt(); } catch (refreshErr) { logger.warn("Failed to refresh system prompt after session switch", { targetSessionFile: sessionPath, error: String(refreshErr), }); } this.#bash.finishSessionTransition(bashTransition, true); return true; } catch (error) { this.sessionManager.restoreState(previousSessionState); this.#freshProviderSessionId = previousFreshProviderSessionId; this.#syncAgentSessionId(previousSessionState.sessionId); this.#memory.rekeyForCurrentSessionId(); this.agent.setTools(previousTools); this.#tools.setBaseSystemPrompt(previousBaseSystemPrompt); this.#memory.restorePromotionSnapshot(previousBaseSystemPromptBeforeMemoryPromotion); this.agent.setSystemPrompt(previousSystemPrompt); this.agent.replaceMessages(previousAgentMessages); this.agent.replaceQueues(previousSteeringMessages, previousFollowUpMessages); this.#pendingNextTurnMessages = previousPendingNextTurnMessages; this.#scheduledHiddenNextTurnGeneration = previousScheduledHiddenNextTurnGeneration; this.#inheritedProviderPromptCacheKey = previousInheritedProviderPromptCacheKey; this.#checkpointState = previousCheckpointState; this.#pendingRewindReport = previousPendingRewindReport; this.#lastCompletedRewind = previousLastCompletedRewind; this.#rewoundToolResultIds = previousRewoundToolResultIds; if (previousModel) { this.agent.setModel(previousModel); } this.#models.restoreThinkingSnapshot(previousThinkingLevel, previousAutoThinking, previousAutoResolvedLevel); this.#models.restoreServiceTiers(previousServiceTierByFamily); this.#todo.syncFromBranch(); this.#advisors.resetAllRuntimes(); this.#reconnectToAgent(); try { await this.#sessionSwitchReconciler?.(); } catch (reconcileError) { logger.warn("Failed to reconcile session mode after switch rollback", { targetSessionFile: sessionPath, error: String(reconcileError), }); } this.#bash.finishSessionTransition(bashTransition, false); throw error; } } /** * Create a branch from a specific entry. * Emits before_branch/branch session events to hooks. * * @param entryId ID of the entry to branch from * @returns Object with: * - selectedText: The text of the selected user message (for editor pre-fill) * - cancelled: True if a hook cancelled the branch */ async branch(entryId: string): Promise<{ selectedText: string; cancelled: boolean; }> { const previousSessionFile = this.sessionFile; const selectedEntry = this.sessionManager.getEntry(entryId); if (selectedEntry?.type !== "message" || selectedEntry.message.role !== "user") { throw new Error("Invalid entry ID for branching"); } const selectedText = this.#extractUserMessageText(selectedEntry.message.content); let skipConversationRestore = false; // Emit session_before_branch event (can be cancelled) if (this.#extensionRunner?.hasHandlers("session_before_branch")) { const result = (await this.#extensionRunner.emit({ type: "session_before_branch", entryId, })) as SessionBeforeBranchResult | undefined; if (result?.cancel) { return { selectedText, cancelled: true }; } skipConversationRestore = result?.skipConversationRestore ?? false; } // Clear pending messages (bound to old session state) this.#pendingNextTurnMessages = []; this.#scheduledHiddenNextTurnGeneration = undefined; await this.#bash.flushPending(); // Flush pending writes before branching await this.sessionManager.flush(); const bashTransition = this.#bash.beginSessionTransition(); this.#cancelOwnAsyncJobs(); this.#abortAutolearnCapture(); await this.#drainAutolearnCapture(); let sessionTransitioned = false; try { if (!selectedEntry.parentId) { await this.sessionManager.newSession({ parentSession: previousSessionFile }); } else { this.sessionManager.createBranchedSession(selectedEntry.parentId); } this.#bash.markSessionTransition(bashTransition); sessionTransitioned = true; } finally { this.#bash.finishSessionTransition(bashTransition, sessionTransitioned); } this.#clearSessionScopedToolState(); this.#rehydrateCheckpointRewindState(); this.#todo.syncFromBranch(); this.#freshProviderSessionId = undefined; this.#clearInheritedProviderPromptCacheKey(); this.#syncAgentSessionId(); this.#memory.rekeyForCurrentSessionId(); await this.#memory.resetContextForNewTranscript(); // Reload messages from entries (works for both file and in-memory mode) const sessionContext = this.buildDisplaySessionContext(); // Emit session_branch event to hooks (after branch completes) if (this.#extensionRunner) { await this.#extensionRunner.emit({ type: "session_branch", previousSessionFile, }); } if (!skipConversationRestore) { this.agent.replaceMessages(sessionContext.messages); this.#advisors.resetSessionState(); this.#closeCodexProviderSessionsForHistoryRewrite(); } return { selectedText, cancelled: false }; } async branchFromBtw( question: string, assistantMessage: AssistantMessage, ): Promise<{ cancelled: boolean; sessionFile: string | undefined }> { const previousSessionFile = this.sessionFile; if (!this.sessionManager.getSessionFile()) { throw new Error("Cannot branch /btw: session is not persisted"); } const leafId = this.sessionManager.getLeafId(); if (!leafId) { throw new Error("Cannot branch /btw: current session has no leaf"); } if ( this.isBashRunning || this.isEvalRunning || this.isCompacting || this.isGeneratingHandoff || this.isRetrying ) { throw new Error("Cannot branch /btw while session maintenance or user work is still running"); } if (this.#extensionRunner?.hasHandlers("session_before_branch")) { const result = (await this.#extensionRunner.emit({ type: "session_before_branch", entryId: leafId, })) as SessionBeforeBranchResult | undefined; if (result?.cancel) { return { cancelled: true, sessionFile: previousSessionFile }; } } await this.#cancelPostPromptTasks(); if ( this.isBashRunning || this.isEvalRunning || this.isCompacting || this.isGeneratingHandoff || this.isRetrying ) { throw new Error("Cannot branch /btw while session maintenance or user work is still running"); } this.#pendingNextTurnMessages = []; this.#scheduledHiddenNextTurnGeneration = undefined; this.agent.replaceQueues([], []); if (this.isStreaming) { await this.abort({ goalReason: "internal", reason: "branching /btw" }); this.agent.replaceQueues([], []); } await this.#bash.flushPending(); await this.sessionManager.flush(); const bashTransition = this.#bash.beginSessionTransition(); this.#cancelOwnAsyncJobs(); this.#abortAutolearnCapture(); await this.#drainAutolearnCapture(); let sessionTransitioned = false; try { this.sessionManager.createBranchedSession(leafId); this.#bash.markSessionTransition(bashTransition); sessionTransitioned = true; } finally { this.#bash.finishSessionTransition(bashTransition, sessionTransitioned); } this.#clearSessionScopedToolState(); this.#rehydrateCheckpointRewindState(); this.sessionManager.appendMessage({ role: "user", content: [{ type: "text", text: question }], timestamp: Date.now(), }); this.sessionManager.appendMessage(sanitizeAssistantForReparentedHistory(assistantMessage)); this.#todo.syncFromBranch(); this.#freshProviderSessionId = undefined; this.#syncAgentSessionId(); this.#memory.rekeyForCurrentSessionId(); await this.#memory.resetContextForNewTranscript(); const sessionContext = this.buildDisplaySessionContext(); if (this.#extensionRunner) { await this.#extensionRunner.emit({ type: "session_branch", previousSessionFile, }); } this.agent.replaceMessages(sessionContext.messages); this.#advisors.resetSessionState(); this.#closeCodexProviderSessionsForHistoryRewrite(); return { cancelled: false, sessionFile: this.sessionFile }; } // ========================================================================= // Tree Navigation // ========================================================================= /** * Navigate to a different node in the session tree. * Unlike branch() which creates a new session file, this stays in the same file. * * @param targetId The entry ID to navigate to * @param options.summarize Whether user wants to summarize abandoned branch * @param options.customInstructions Custom instructions for summarizer * @returns Result with editorText (if user message) and cancelled status */ async navigateTree( targetId: string, options: { summarize?: boolean; customInstructions?: string; /** * Opts into the two-phase `ask` toolResult re-answer protocol * (issue #5642): set only by the interactive `/tree` selector, which * knows how to re-open the picker on `reopenAsk` and complete the * navigation with `reanswerAskResult`. Every other public caller * (extensions, hooks, ACP, session-extension actions) leaves this * unset and gets the pre-#5642 plain leaf move onto `ask` * toolResults instead — they have no picker to re-open and would * otherwise report a successful no-op navigation (roboomp review on * #5895). */ allowAskReopen?: boolean; /** * Completes an in-progress `ask` re-answer (issue #5642): the caller * already received `reopenAsk` from a prior call on the same * `targetId`, re-opened the picker, and is handing back the fresh * answer. Branches a new toolResult sibling instead of landing on * the original one. */ reanswerAskResult?: AgentToolResult; } = {}, ): Promise<{ editorText?: string; cancelled: boolean; aborted?: boolean; summaryEntry?: BranchSummaryEntry; /** Raw session context built during navigation — pass to renderInitialMessages to skip a second O(N) walk. */ sessionContext?: SessionContext; /** * Set when `targetId` is an `ask` toolResult, `options.allowAskReopen` * was set, and `options.reanswerAskResult` was not supplied: nothing was * mutated. The caller must re-open the ask picker with these * `questions`, then call `navigateTree(targetId, { ...options, * reanswerAskResult })` with the produced result to actually branch * (issue #5642). */ reopenAsk?: { toolCallId: string; questions: AskToolInput["questions"] }; }> { await this.#bash.flushPending(); const oldLeafId = this.sessionManager.getLeafId(); const targetEntry = this.sessionManager.getEntry(targetId); if (!targetEntry) { throw new Error(`Entry ${targetId} not found`); } const targetIsAskResult = targetEntry.type === "message" && targetEntry.message.role === "toolResult" && targetEntry.message.toolName === "ask"; // No-op if already at target — except mid-flight through the `ask` // re-answer protocol (issue #5642): a probe or completion call can // legitimately target the *current* leaf (e.g. the user interrupted // right after answering `ask`, before a follow-up assistant message // landed, or another caller navigated straight onto the ask result), // and must still return `reopenAsk` / branch the new answer instead of // silently reporting a no-op (chatgpt-codex review on #5895). if (targetId === oldLeafId && !(options.allowAskReopen && targetIsAskResult)) { return { cancelled: false }; } // Model required for summarization if (options.summarize && !this.model) { throw new Error("No model available for summarization"); } // `ask` toolResult, first pass: hand control back to the caller to // re-open the picker instead of landing on the stale answer in place. // Nothing is mutated here — see the `reanswerAskResult` branch below for // the actual sibling-branch construction once the caller has an answer. // Gated on `allowAskReopen` — callers that don't understand `reopenAsk` // fall straight through to the plain leaf move below instead of // reporting a successful no-op (roboomp review on #5895). if ( options.allowAskReopen && !options.reanswerAskResult && targetEntry.type === "message" && targetEntry.message.role === "toolResult" && targetEntry.message.toolName === "ask" ) { const toolCallId = targetEntry.message.toolCallId; const questions = this.#recoverAskReanswerQuestions(targetEntry.parentId, toolCallId); if (questions) { return { cancelled: false, reopenAsk: { toolCallId, questions } }; } // Original arguments couldn't be recovered (corrupted/legacy session // data) — fall through to a plain leaf move so navigation still works. } // Collect entries to summarize (from old leaf to common ancestor). For an // `ask` re-answer completion, the branch point is `targetEntry.parentId` // (the new sibling toolResult lands there, not on `targetId`) — anchor // the collection there too, or the old answer entry is neither on the // new branch nor included in the summary (chatgpt-codex review on // #5895). const summaryAnchorId = options.reanswerAskResult !== undefined && targetEntry.type === "message" && targetEntry.message.role === "toolResult" && targetEntry.message.toolName === "ask" && targetEntry.parentId !== null ? targetEntry.parentId : targetId; const { entries: entriesToSummarize, commonAncestorId } = collectEntriesForBranchSummary( this.sessionManager, oldLeafId, summaryAnchorId, ); // Prepare event data const preparation: TreePreparation = { targetId, oldLeafId, commonAncestorId, entriesToSummarize, userWantsSummary: options.summarize ?? false, }; // Set up abort controller for summarization this.#branchSummaryAbortController = new AbortController(); let hookSummary: { summary: string; details?: unknown } | undefined; let fromExtension = false; // Emit session_before_tree event if (this.#extensionRunner?.hasHandlers("session_before_tree")) { const result = (await this.#extensionRunner.emit({ type: "session_before_tree", preparation, signal: this.#branchSummaryAbortController.signal, })) as SessionBeforeTreeResult | undefined; if (result?.cancel) { return { cancelled: true }; } if (result?.summary && options.summarize) { hookSummary = result.summary; fromExtension = true; } } // Run default summarizer if needed let summaryText: string | undefined; let summaryDetails: unknown; if (options.summarize && entriesToSummarize.length > 0 && !hookSummary) { const model = this.model!; const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId); if (!apiKey) { throw new Error(`No API key for ${model.provider}`); } const branchSummarySettings = this.settings.getGroup("branchSummary"); const result = await generateBranchSummary(entriesToSummarize, { model, apiKey: this.#modelRegistry.resolver(model, this.sessionId), signal: this.#branchSummaryAbortController.signal, customInstructions: this.#obfuscateTextForProvider(options.customInstructions), reserveTokens: branchSummarySettings.reserveTokens, metadata: this.agent.metadataForProvider(model.provider), convertToLlm: messages => this.#convertToLlmForSideRequest(messages), telemetry: resolveTelemetry(this.agent.telemetry, this.sessionId), // Same per-provider concurrency cap rationale as the compaction // path above (chatgpt-codex review on #3751). completeImpl: async (requestModel, requestContext, requestOptions) => { const stream = await this.#sideStreamFn(requestModel, requestContext, requestOptions); return stream.result(); }, }); this.#branchSummaryAbortController = undefined; if (result.aborted) { return { cancelled: true, aborted: true }; } if (result.error) { throw new Error(result.error); } summaryText = result.summary; summaryDetails = { readFiles: result.readFiles || [], modifiedFiles: result.modifiedFiles || [], }; } else if (hookSummary) { summaryText = hookSummary.summary; summaryDetails = hookSummary.details; } // Determine the new leaf position based on target type let newLeafId: string | null; let editorText: string | undefined; if (targetEntry.type === "message" && targetEntry.message.role === "user") { // User message: leaf = parent (null if root), text goes to editor newLeafId = targetEntry.parentId; editorText = this.#extractUserMessageText(targetEntry.message.content); } else if (targetEntry.type === "custom_message" && targetEntry.customType !== SKILL_PROMPT_MESSAGE_TYPE) { // Custom message: leaf = parent (null if root), text goes to editor newLeafId = targetEntry.parentId; editorText = typeof targetEntry.content === "string" ? targetEntry.content : targetEntry.content .filter((c): c is { type: "text"; text: string } => c.type === "text") .map(c => c.text) .join(""); } else if ( targetEntry.type === "message" && targetEntry.message.role === "toolResult" && targetEntry.message.toolName === "ask" && options.reanswerAskResult ) { // `ask` toolResult, second pass: the caller re-opened the picker and // is handing back a fresh answer. Branch a *new* sibling toolResult // off the same `ask` toolCall instead of reusing `targetId` — the // original answer's branch stays reachable (issue #5642). const reanswer = options.reanswerAskResult; const toolResultMessage: ToolResultMessage = { role: "toolResult", toolCallId: targetEntry.message.toolCallId, toolName: "ask", content: reanswer.content, details: reanswer.details, isError: reanswer.isError === true, timestamp: Date.now(), }; newLeafId = this.sessionManager.appendMessageToBranch(toolResultMessage, targetEntry.parentId); } else { // Non-user message (or a user-invoked skill-prompt injection): land the // leaf on the selected node so it stays on the active branch. Skill // prompts are custom_message entries but must not be re-editable — their // content is a large expanded body, not a user turn (issue #5374). newLeafId = targetId; } // Switch leaf (with or without summary) // Summary is attached at the navigation target position (newLeafId), not the old branch const bashTransition = this.#bash.beginSessionTransition(); let summaryEntry: BranchSummaryEntry | undefined; let branchTransitioned = false; try { if (summaryText) { // Create summary at target position (can be null for root) const summaryId = this.sessionManager.branchWithSummary( newLeafId, summaryText, summaryDetails, fromExtension, ); summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry; } else if (newLeafId === null) { this.sessionManager.resetLeaf(); } else { this.sessionManager.branch(newLeafId); } this.#bash.markSessionTransition(bashTransition); branchTransitioned = true; } finally { this.#bash.finishSessionTransition(bashTransition, branchTransitioned); } // Update agent state — build display context to populate agent messages. const stateContext = this.sessionManager.buildSessionContext(); const displayContext = deobfuscateSessionContext(stateContext, this.#obfuscator); this.agent.replaceMessages(displayContext.messages); this.#rehydrateCheckpointRewindState(); this.#advisors.resetSessionState(); this.#todo.syncFromBranch(); this.#closeCodexProviderSessionsForHistoryRewrite(); this.#branchSummaryAbortController = undefined; // Emit session_tree event; only handlers can mutate session entries, so skip // the emit and the context rebuild when no handlers are registered (mirrors // the session_before_tree guard above). if (this.#extensionRunner?.hasHandlers("session_tree")) { await this.#extensionRunner.emit({ type: "session_tree", newLeafId: this.sessionManager.getLeafId(), oldLeafId, summaryEntry, fromExtension: summaryText ? fromExtension : undefined, }); const rawContext = this.sessionManager.buildSessionContext(); return { editorText, cancelled: false, summaryEntry, sessionContext: rawContext }; } return { editorText, cancelled: false, summaryEntry, sessionContext: stateContext }; } /** * Look up the `ask` toolCall's persisted `arguments` and validate them * back into `questions`, for `/tree` `ask` re-answer (issue #5642). Walks * up from the toolResult's parent past any interleaved ancestor entries * — sibling toolResults from other tool calls in the same turn (`ask` * runs `exclusive`, which only serializes *execution*, not persistence * order — roboomp review on #5895), and bookkeeping entries such as the * `tool_execution_start` custom entry `#recordToolExecutionStart()` * appends before every toolResult in real persisted sessions (chatgpt-codex * review on #5895) — until it finds the assistant entry that actually * emitted `toolCallId`. Stops at a `user` message (turn boundary) or a * dead end. Returns `undefined` when no ancestor entry holds a matching * `ask` toolCall, or the arguments can't be resolved — the caller falls * back to a plain leaf move rather than opening a picker with bad data. */ #recoverAskReanswerQuestions(parentId: string | null, toolCallId: string): AskToolInput["questions"] | undefined { let current = parentId; while (current !== null) { const entry = this.sessionManager.getEntry(current); if (!entry) return undefined; if (entry.type === "message") { if (entry.message.role === "assistant") { const toolCall = entry.message.content.find( (block): block is AgentToolCall => block.type === "toolCall" && block.id === toolCallId, ); if (!toolCall) return undefined; if (toolCall.name !== "ask") return undefined; const args = this.#obfuscator?.hasSecrets() ? deobfuscateToolArguments(this.#obfuscator, toolCall.arguments) : toolCall.arguments; return recoverAskQuestions(args); } if (entry.message.role === "user") return undefined; } current = entry.parentId; } return undefined; } /** * Build a standalone `AgentToolContext` for running `AskTool.execute()` * outside a normal agent turn, for `/tree` `ask` re-answer (issue #5642). * `SelectorController` has no reachable `ToolContextStore` (that store is * built inside `sdk.ts` and never threaded through to mode controllers), * so this mirrors `refreshMCPTools()`'s `getCustomToolContext` factory * with real session state instead of a `{ ... } as unknown as * AgentToolContext` cast that could silently compile with an incomplete * context (roboomp review on #5895) — every `CustomToolContext` field is * backed by live session state, so a future required field fails to * compile here instead of surfacing as `undefined` at runtime. */ buildAskReanswerContext(uiContext: ExtensionUIContext): AgentToolContext { return { sessionManager: this.sessionManager, modelRegistry: this.#modelRegistry, model: this.model, isIdle: () => !this.isStreaming, hasQueuedMessages: () => this.queuedMessageCount > 0, abort: () => { this.agent.abort(); }, settings: this.settings, ui: uiContext, hasUI: true, }; } /** * Get all user messages from session for branch selector. */ getUserMessagesForBranching(): Array<{ entryId: string; text: string }> { const entries = this.sessionManager.getEntries(); const result: Array<{ entryId: string; text: string }> = []; for (const entry of entries) { if (entry.type !== "message") continue; if (entry.message.role !== "user") continue; const text = this.#extractUserMessageText(entry.message.content); if (text) { result.push({ entryId: entry.id, text }); } } return result; } #extractUserMessageText(content: string | Array<{ type: string; text?: string }>): string { if (typeof content === "string") return content; if (Array.isArray(content)) { return content .filter((c): c is { type: "text"; text: string } => c.type === "text") .map(c => c.text) .join(""); } return ""; } /** * Get session statistics. */ getSessionStats(): SessionStats { return this.#stats.getSessionStats(); } /** * Get current context usage statistics. * Uses the last assistant message's usage data when available, * otherwise estimates tokens for all messages. */ getContextBreakdown(options?: { contextWindow?: number; pendingMessages?: AgentMessage[]; }): ContextUsageBreakdown | undefined { return this.#stats.getContextBreakdown(options); } getContextUsage(options?: { contextWindow?: number }): ContextUsage | undefined { return this.#stats.getContextUsage(options); } /** * Monotonic counter that changes whenever the in-flight pending context * snapshot is set or cleared. Status-line context memoization keys on this so * a value computed mid-turn cannot persist after the turn ends/aborts. */ get contextUsageRevision(): number { return this.#stats.revision; } async fetchUsageReports(signal?: AbortSignal): Promise { const authStorage = this.#modelRegistry.authStorage; if (!authStorage.fetchUsageReports) return null; return authStorage.fetchUsageReports({ baseUrlResolver: provider => { if (provider === "google-antigravity") { const mode = this.settings.get("providers.antigravityEndpoint"); if (mode === "sandbox") { return "https://daily-cloudcode-pa.sandbox.googleapis.com"; } else if (mode === "production") { return "https://daily-cloudcode-pa.googleapis.com"; } } return this.#modelRegistry.getProviderBaseUrl?.(provider); }, signal, }); } /** Models whose live `/usage` reports map to a quantitative provider scope. */ getUsageReportingModelSelectors(reports: readonly UsageReport[]): string[] { const modelsByProvider = new Map(); for (const model of this.#modelRegistry.getAvailable()) { const models = modelsByProvider.get(model.provider) ?? []; models.push(model); modelsByProvider.set(model.provider, models); } const selectors = new Set(); for (const [provider, models] of modelsByProvider) { const modelIds = this.#modelRegistry.authStorage.getUsageReportingModelIds( provider, models.map(model => model.id), reports, ); for (const modelId of modelIds) selectors.add(`${provider}/${modelId}`); } return [...selectors].sort((left, right) => left.localeCompare(right)); } /** List stored OAuth accounts for the current model provider and mark this session's active account. */ async listCurrentProviderOAuthAccounts(): Promise { const provider = this.model?.provider; if (!provider) return undefined; const authStorage = this.#modelRegistry.authStorage; await authStorage.reload(); return { provider, accounts: authStorage.listOAuthAccounts(provider, this.sessionId), }; } /** * Pin a stored OAuth account to the current model provider for this session. * Returns false while streaming or when the credential is no longer available. */ pinCurrentProviderOAuthAccount(credentialId: number): boolean { const provider = this.model?.provider; if (!provider || this.isStreaming) return false; return this.#modelRegistry.authStorage.pinSessionOAuthAccount(provider, this.sessionId, credentialId); } /** * Redeem one saved Codex rate-limit reset for a specific account, injecting * the provider base URL like {@link AgentSession.fetchUsageReports}. Powers * the `/usage reset` command and auto-redeem. Never throws for business * outcomes — inspect the returned `code`. */ async redeemResetCredit(target: ResetCreditTarget, signal?: AbortSignal): Promise { return this.#modelRegistry.authStorage.redeemResetCredit({ target, baseUrlResolver: provider => this.#modelRegistry.getProviderBaseUrl?.(provider), signal, }); } /** * List saved Codex rate-limit resets per stored account, fetched live from * the dedicated credits endpoint (bypasses the usage cache). Powers the * `/usage reset` account selector. */ async listResetCredits(signal?: AbortSignal): Promise { return this.#modelRegistry.authStorage.listResetCredits({ sessionId: this.sessionId, baseUrlResolver: provider => this.#modelRegistry.getProviderBaseUrl?.(provider), signal, }); } async #confirmCodexAutoRedeem(decision: CodexAutoRedeemRedeemDecision): Promise { const runner = this.#extensionRunner; if (!runner?.hasUI()) { this.emitNotice( "warning", "Codex saved reset is eligible, but auto-redeem is unset and no prompt UI is available. Run `/usage reset` or set codexResets.autoRedeem.", "codex-auto-reset", ); return false; } const who = decision.target.email ?? decision.target.accountId ?? "the active account"; const resetLabel = decision.availableCount === 1 ? "reset" : "resets"; try { const choice = await runner .getUIContext() .select( `Do you wanna redeem your reset?\n${who} is blocked by the weekly Codex limit for about ${formatDuration(decision.remainingMs)}. Spend 1 of ${decision.availableCount} saved ${resetLabel}?`, [ { label: "Yes", description: "Redeem now and remember yes for future eligible Codex weekly blocks.", }, { label: "No", description: "Do not auto-redeem saved Codex resets.", }, ], ); if (choice === "Yes") { this.settings.set("codexResets.autoRedeem", "yes"); return true; } if (choice === "No") { this.settings.set("codexResets.autoRedeem", "no"); } } catch (error) { logger.warn("codex-auto-reset prompt failed", { error: String(error) }); } return false; } /** * Auto-redeem hook for {@link AgentSession.#handleRetryableError}'s * usage-limit branch. Returns `true` only when a saved Codex reset was * actually spent (so the caller retries immediately). The "unset" mode is * reactive but asks before spending; "yes" skips that prompt, and "no" avoids * the eligibility IO entirely. The decision remains heavily gated — see * `./codex-auto-reset` and the design in `local://autoreset-spec.md`. * Per-account in-flight dedup lets concurrent sessions adopt one redeem * instead of double-spending. */ async #maybeAutoRedeemCodexReset(coordinator = defaultCodexAutoRedeemCoordinator): Promise { const cfg = this.settings.getGroup("codexResets"); const model = this.model; // Cheap exits before any IO. if (!shouldEvaluateCodexAutoRedeem(cfg.autoRedeem) || !model || model.provider !== "openai-codex") return false; const authStorage = this.#modelRegistry.authStorage; // Capture identity BEFORE awaits: markUsageLimitReached leaves the // usage-limit session credential sticky, so this names the blocked account. const identity = authStorage.getOAuthAccountIdentity("openai-codex", this.sessionId); const accountKey = (identity?.accountId ?? identity?.email)?.trim().toLowerCase(); if (!accountKey) return false; const existing = coordinator.inFlightByAccount.get(accountKey); if (existing) return existing; const run = (async (): Promise => { const reports = await this.fetchUsageReports(); const decision = evaluateCodexAutoRedeem({ nowMs: Date.now(), provider: model.provider, modelId: model.id, settings: { autoRedeem: true, minBlockedMinutes: Math.max(0, cfg.minBlockedMinutes), keepCredits: Math.max(0, Math.trunc(cfg.keepCredits)), }, identity, reports, attemptedBlockKeys: coordinator.attemptedBlockKeys, lastAttemptAtByAccount: coordinator.lastAttemptAtByAccount, }); if (!decision.redeem) { logger.debug("codex-auto-reset: skipped", { reason: decision.reason, account: accountKey }); return false; } if (shouldPromptCodexAutoRedeem(cfg.autoRedeem) && !(await this.#confirmCodexAutoRedeem(decision))) { return false; } // Commit the attempt BEFORE acting so this block can never re-enter. coordinator.attemptedBlockKeys.add(decision.blockKey); coordinator.lastAttemptAtByAccount.set(decision.accountKey, Date.now()); const who = decision.target.email ?? decision.target.accountId ?? "the active account"; const outcome = await authStorage.redeemResetCredit({ target: decision.target, baseUrlResolver: provider => this.#modelRegistry.getProviderBaseUrl?.(provider), // Not tied to the retry abort controller: aborting a consume // mid-flight leaves credit state unknown. signal: AbortSignal.timeout(15_000), }); switch (outcome.code) { case "reset": { const left = Math.max(0, decision.availableCount - 1); this.emitNotice( "info", `Auto-redeemed a saved Codex rate-limit reset for ${who} (${left} left); retrying now.`, "codex-auto-reset", ); void this.fetchUsageReports(); return true; } case "already_redeemed": this.emitNotice( "warning", "A saved Codex reset was already redeemed elsewhere; waiting for the window.", "codex-auto-reset", ); return false; case "no_credit": logger.debug("codex-auto-reset: no_credit (snapshot/live mismatch)", { account: accountKey }); return false; case "nothing_to_reset": this.emitNotice( "warning", "Codex reset reported nothing to reset; auto-redeem suppressed for this window.", "codex-auto-reset", ); return false; default: this.emitNotice("warning", `Codex auto-redeem failed (${outcome.code}).`, "codex-auto-reset"); return false; } })().finally(() => coordinator.inFlightByAccount.delete(accountKey)); coordinator.inFlightByAccount.set(accountKey, run); return run; } /** * Export session to HTML. * @param outputPath Optional output path * @param useUserThemes Bundle the dark and light TUI themes selected in settings */ async exportToHtml(outputPath?: string, useUserThemes = false): Promise { // Lazy import: the export module embeds the HTML template and pre-built // tool renderers as text; only `/export` should pay that load. const { exportSessionToHtml } = await import("../export/html"); return exportSessionToHtml(this.sessionManager, this.state, { outputPath, palette: useUserThemes ? "theme" : "web", themeNames: useUserThemes ? { dark: this.settings.get("theme.dark") ?? "titanium", light: this.settings.get("theme.light") ?? "light", } : undefined, }); } // ========================================================================= // Utilities // ========================================================================= /** * Get text content of last assistant message. * Useful for /copy command. * @returns Text content, or undefined if no assistant message exists */ getLastAssistantText(): string | undefined { const lastAssistant = this.#getLastCopyCandidateAssistantMessage(); if (!lastAssistant) return undefined; let text = ""; for (const content of lastAssistant.content) { if (content.type === "text") { text += content.text; } } return text.trim() || undefined; } hasCopyCandidateAssistantMessage(): boolean { return this.#getLastCopyCandidateAssistantMessage() !== undefined; } #getLastCopyCandidateAssistantMessage(): AssistantMessage | undefined { for (let i = this.messages.length - 1; i >= 0; i--) { const message = this.messages[i]; if (message.role !== "assistant") continue; const assistantMessage = message as AssistantMessage; // Skip aborted messages with no content if (assistantMessage.stopReason === "aborted" && assistantMessage.content.length === 0) continue; return assistantMessage; } return undefined; } /** * Get text content of the most recent visible handoff message. * Fresh handoff sessions store the handoff context as a custom message, not * an assistant message, so callers that copy the "last" message can use this * as a fallback before the new session has an assistant response. */ getLastVisibleHandoffText(): string | undefined { for (let i = this.messages.length - 1; i >= 0; i--) { const message = this.messages[i]; if (message.role !== "custom") continue; const customMessage = message as CustomMessage; if (customMessage.customType !== "handoff" || !customMessage.display) continue; if (typeof customMessage.content === "string") { return customMessage.content.trim() || undefined; } let text = ""; for (const content of customMessage.content) { if (content.type === "text") { text += content.text; } } return text.trim() || undefined; } return undefined; } /** * Format the entire session as plain text for clipboard export: system * prompt, model/thinking config, tool inventory, and the full transcript * rendered with markdown role headings (`## User`, `## Assistant`, * `### Tool Call`/`### Tool Result`). */ formatSessionAsText(): string { return formatSessionDumpText({ messages: this.messages, systemPrompt: this.agent.state.systemPrompt, model: this.agent.state.model, thinkingLevel: this.thinkingLevel, tools: this.agent.state.tools, inlineToolDescriptors: this.#pruneToolDescriptions, }); } /** * Dump the current session's LLM-facing request context as JSON to a * auto-named file in `os.tmpdir()`. This is the synchronous * `convertToLlm`-boundary snapshot — system prompt, tools (wire schemas), * thinking/service tier, and converted messages — with no network round-trip * and no arming flag, so advisor/side requests cannot intercept it. * * The file persists on disk and may contain the same raw context/secrets * as `/dump`; treat the path accordingly. * * @returns the written file path, or `undefined` when there are no messages. */ async dumpLlmRequestToTmpDir(): Promise { const messages = this.messages; if (messages.length === 0) return undefined; const llmMessages = await this.convertMessagesToLlm(messages); const payload = { model: this.agent.state.model ?? null, thinkingLevel: this.thinkingLevel ?? null, serviceTier: this.#models.serviceTierEntry(), systemPrompt: this.agent.state.systemPrompt, tools: this.agent.state.tools.map(tool => ({ name: tool.name, description: tool.description, parameters: toolWireSchema(tool), ...(tool.strict !== undefined ? { strict: tool.strict } : {}), ...(tool.customWireName ? { customWireName: tool.customWireName } : {}), })), messages: llmMessages, }; const filePath = path.join(os.tmpdir(), `omp-llm-request-${Snowflake.next()}.json`); await Bun.write(filePath, `${JSON.stringify(payload, null, 2)}\n`); return filePath; } /** * Enable or disable the advisor for this session. The setting is overridden for the session, * and the runtime is started or stopped to match. * * @returns true when the advisor is actively running after the call. */ setAdvisorEnabled(enabled: boolean): boolean { return this.#advisors.setAdvisorEnabled(enabled); } /** * Toggle the advisor setting and start/stop the runtime accordingly. * * @returns true when the advisor is actively running after the call. */ toggleAdvisorEnabled(): boolean { return this.#advisors.toggleAdvisorEnabled(); } /** * Replace the live advisor roster from an edited `WATCHDOG.yml` (the `/advisor * configure` save path). Swaps the configs + shared baseline, then rebuilds the * runtimes in place so the change applies without a restart. When the advisor is * disabled the new configs are simply stored for the next enable. * * @returns the number of advisors active after the rebuild. */ applyAdvisorConfigs(advisors: AdvisorConfig[], sharedInstructions: string | undefined): number { return this.#advisors.applyAdvisorConfigs(advisors, sharedInstructions); } /** * Whether the advisor setting is enabled for this session. */ isAdvisorEnabled(): boolean { return this.#advisors.isAdvisorEnabled(); } /** * Whether a live advisor agent is attached to this session. True only when * `advisor.enabled` is set AND a model resolved for the `advisor` role AND * the advisor applies to this agent kind — i.e. the actual runtime exists, * not merely the setting. Drives the status-line badge and `/dump advisor`. */ isAdvisorActive(): boolean { return this.#advisors.isAdvisorActive(); } /** * The names of the tools available to advisors this session (the pool a * `/advisor configure` editor lists). The advisor is a full agent, so this is the * full built tool set; a tool whose optional factory returns null (e.g. lsp with * no servers) is absent. */ getAdvisorAvailableToolNames(): string[] { return this.#advisors.getAdvisorAvailableToolNames(); } /** * The live advisor `Agent`, or `undefined` when no advisor runtime is * attached. Surfaced for diagnostics (`/dump advisor` already serializes * its transcript via {@link formatAdvisorHistoryAsText}) and so callers can * verify the advisor inherits the session's provider-shaping options * (`streamFn`, `promptCacheKey`, `providerSessionState`, ...). */ getAdvisorAgent(): Agent | undefined { return this.#advisors.getAdvisorAgent(); } /** * Lightweight advisor status for the status line: returns just the configured * flag and per-advisor name/status without computing token/cost breakdowns. * Avoids re-tokenizing the advisor transcript on every render frame. */ getAdvisorStatusOverview(): { configured: boolean; advisors: { name: string; status: AdvisorRuntimeStatus }[] } { return this.#advisors.getAdvisorStatusOverview(); } /** * Return structured advisor stats for the status command and TUI panel. */ getAdvisorStats(): AdvisorStats { return this.#advisors.getAdvisorStats(); } /** * Format a concise advisor status line for ACP/text output. */ formatAdvisorStatus(): string { return this.#advisors.formatAdvisorStatus(); } /** * Format the advisor agent's own transcript (its system prompt, config, * tools, and the markdown deltas it received plus its thinking/advise/read * calls) as plain text — the advisor-side equivalent of * {@link formatSessionAsText}. Returns null when no advisor is active. */ formatAdvisorHistoryAsText(options?: { compact?: boolean }): string | null { return this.#advisors.formatAdvisorHistoryAsText(options); } // ========================================================================= // Extension System // ========================================================================= /** * Check if extensions have handlers for a specific event type. */ hasExtensionHandlers(eventType: string): boolean { return this.#extensionRunner?.hasHandlers(eventType) ?? false; } /** * Get the extension runner (for setting UI context and error handlers). */ get extensionRunner(): ExtensionRunner | undefined { return this.#extensionRunner; } }