/** * 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, AppendOnlyContextManager, type AsideMessage, resolveTelemetry, ThinkingLevel, } from "@oh-my-pi/pi-agent-core"; import { AGGRESSIVE_SHAKE_CONFIG, AUTO_HANDOFF_THRESHOLD_FOCUS, applyShakeRegions, CompactionCancelledError, type CompactionPreparation, type CompactionResult, calculateContextTokens, calculatePromptTokens, collectEntriesForBranchSummary, collectShakeRegions, compact, DEFAULT_SHAKE_CONFIG, estimateTokens, generateBranchSummary, generateHandoff, prepareCompaction, resolveThresholdTokens, type ShakeConfig, type ShakeRegion, type SummaryOptions, shouldCompact, } from "@oh-my-pi/pi-agent-core/compaction"; import { DEFAULT_PRUNE_CONFIG, pruneSupersededToolResults, pruneToolOutputs, readToolSupersedeKey, } from "@oh-my-pi/pi-agent-core/compaction/pruning"; import type { ProtectedToolMatcher } from "@oh-my-pi/pi-agent-core/compaction/tool-protection"; import type { AssistantMessage, Context, ImageContent, Message, MessageAttribution, Model, ProviderResponseMetadata, ProviderSessionState, ResetCreditAccountStatus, ResetCreditRedeemOutcome, ResetCreditTarget, ServiceTier, SimpleStreamOptions, TextContent, ToolCall, ToolChoice, Usage, UsageReport, } from "@oh-my-pi/pi-ai"; import { calculateRateLimitBackoffMs, clearAnthropicFastModeFallback, deriveClaudeDeviceId, Effort, isContextOverflow, isUsageLimitError, parseRateLimitReason, resolveServiceTier, streamSimple, } from "@oh-my-pi/pi-ai"; import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking"; import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models"; import { countTokens, MacOSPowerAssertion } from "@oh-my-pi/pi-natives"; import { extractRetryHint, getAgentDbPath, getInstallId, isBunTestRuntime, isEnoent, isUnexpectedSocketCloseMessage, logger, prompt, relativePathWithinRoot, Snowflake, } from "@oh-my-pi/pi-utils"; import * as snapcompact from "@oh-my-pi/snapcompact"; import { type AsyncJob, type AsyncJobDeliveryState, AsyncJobManager } from "../async"; import { classifyDifficulty } from "../auto-thinking/classifier"; import { reset as resetCapabilities } from "../capability"; import type { Rule } from "../capability/rule"; import { shouldEnableAppendOnlyContext } from "../config/append-only-context-mode"; import type { ModelRegistry } from "../config/model-registry"; import { extractExplicitThinkingSelector, filterAvailableModelsByEnabledPatterns, formatModelSelectorValue, formatModelString, getModelMatchPreferences, parseModelString, type ResolvedModelRoleValue, resolveModelRoleValue, } from "../config/model-resolver"; import { MODEL_ROLE_IDS } from "../config/model-roles"; import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-templates"; import type { Settings, SkillsSettings } from "../config/settings"; import { onAppendOnlyModeChanged } from "../config/settings"; import { RawSseDebugBuffer } from "../debug/raw-sse-buffer"; import { loadCapability } from "../discovery"; import { expandApplyPatchToEntries, normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit"; import { getFileSnapshotStore } from "../edit/file-snapshot-store"; import { namespaceSessionId as namespacePythonSessionId } from "../eval/py"; import { disposeKernelSessionsByOwner, executePython as executePythonCommand, type PythonResult, } from "../eval/py/executor"; import { defaultEvalSessionId } from "../eval/session-id"; import { type BashResult, executeBash as executeBashCommand } from "../exec/bash-executor"; import type { TtsrManager, TtsrMatchContext } from "../export/ttsr"; import type { LoadedCustomCommand } from "../extensibility/custom-commands"; import type { CustomTool, CustomToolContext } from "../extensibility/custom-tools/types"; import { CustomToolAdapter } from "../extensibility/custom-tools/wrapper"; import type { ExtensionCommandContext, ExtensionRunner, ExtensionUIContext, MessageEndEvent, MessageStartEvent, MessageUpdateEvent, SessionBeforeBranchResult, SessionBeforeCompactResult, SessionBeforeSwitchResult, SessionBeforeTreeResult, ToolExecutionEndEvent, ToolExecutionStartEvent, ToolExecutionUpdateEvent, TreePreparation, TurnEndEvent, TurnStartEvent, } from "../extensibility/extensions"; import type { CompactOptions, ContextUsage } from "../extensibility/extensions/types"; import { ExtensionToolWrapper } from "../extensibility/extensions/wrapper"; 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 { Goal, GoalModeState } from "../goals/state"; import type { HindsightSessionState } from "../hindsight/state"; import { type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls"; import { IrcBus, type IrcMessage } from "../irc/bus"; import { resolveMemoryBackend } from "../memory-backend"; import { getMnemopiSessionState, type MnemopiSessionState, setMnemopiSessionState } from "../mnemopi/state"; import { containsOrchestrate, ORCHESTRATE_NOTICE } from "../modes/orchestrate"; import { getCurrentThemeName, 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, WORKFLOW_NOTICE } from "../modes/workflow"; import { createPlanReadMatcher } from "../plan-mode/plan-protection"; import type { PlanModeState } from "../plan-mode/state"; import autoContinuePrompt from "../prompts/system/auto-continue.md" with { type: "text" }; import eagerTodoPrompt from "../prompts/system/eager-todo.md" with { type: "text" }; import emptyStopRetryTemplate from "../prompts/system/empty-stop-retry.md" with { type: "text" }; import ircAutoReplyTemplate from "../prompts/system/irc-autoreply.md" with { type: "text" }; import ircIncomingTemplate from "../prompts/system/irc-incoming.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 ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" }; import ttsrToolReminderTemplate from "../prompts/system/ttsr-tool-reminder.md" with { type: "text" }; import { deobfuscateSessionContext, obfuscateProviderContext, obfuscateProviderTools, type SecretObfuscator, } from "../secrets/obfuscator"; import { invalidateHostMetadata } from "../ssh/connection-manager"; import { AUTO_THINKING, type ConfiguredThinkingLevel, clampAutoThinkingEffort, resolveProvisionalAutoLevel, resolveThinkingLevelForModel, shouldDisableReasoning, toReasoningEffort, } from "../thinking"; import { shutdownTinyTitleClient } from "../tiny/title-client"; import { countToolsForAutoDiscovery, resolveEffectiveToolDiscoveryMode } from "../tool-discovery/mode"; import { buildDiscoverableToolSearchIndex, collectDiscoverableTools, type DiscoverableTool, type DiscoverableToolSearchIndex, filterBySource, isMCPToolName, selectDiscoverableToolNamesByServer, } from "../tool-discovery/tool-index"; import { assertEditableFile } from "../tools/auto-generated-guard"; import type { CheckpointState } from "../tools/checkpoint"; import { outputMeta } from "../tools/output-meta"; import { normalizeLocalScheme, resolveToCwd } from "../tools/path-utils"; import { isAutoQaEnabled } from "../tools/report-tool-issue"; import { getLatestTodoPhasesFromEntries, type TodoItem, type TodoPhase } from "../tools/todo"; import { ToolAbortError, ToolError } from "../tools/tool-errors"; import { clampTimeout } from "../tools/tool-timeouts"; import { parseCommandArgs } from "../utils/command-args"; import { type EditMode, resolveEditMode } 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 { buildNamedToolChoice } from "../utils/tool-choice"; import type { AuthStorage } from "./auth-storage"; import type { ClientBridge, ClientBridgePermissionOption, ClientBridgePermissionOutcome } from "./client-bridge"; import { defaultCodexAutoRedeemCoordinator, evaluateCodexAutoRedeem } from "./codex-auto-reset"; import { type BashExecutionMessage, type CustomMessage, convertToLlm, type PythonExecutionMessage, readPendingDisplayTag, SILENT_ABORT_MARKER, SKILL_PROMPT_MESSAGE_TYPE, stripImagesFromMessage, } from "./messages"; import { formatSessionDumpText } from "./session-dump-format"; import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions, SessionContext, SessionManager, } from "./session-manager"; import { EPHEMERAL_MODEL_CHANGE_ROLE, getLatestCompactionEntry, getRestorableSessionModels } from "./session-manager"; import type { ShakeMode, ShakeResult } from "./shake-types"; import { ToolChoiceQueue } from "./tool-choice-queue"; import { YieldQueue } from "./yield-queue"; /** Session-specific events that extend the core AgentEvent */ export type AgentSessionEvent = | AgentEvent | { type: "auto_compaction_start"; reason: "threshold" | "overflow" | "idle" | "incomplete"; action: "context-full" | "handoff" | "shake" | "snapcompact"; } | { type: "auto_compaction_end"; action: "context-full" | "handoff" | "shake" | "snapcompact"; result: CompactionResult | undefined; aborted: boolean; willRetry: boolean; errorMessage?: string; /** True when compaction was skipped for a benign reason (no model, no candidates, nothing to compact). */ skipped?: boolean; } | { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string } | { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string } | { type: "retry_fallback_applied"; from: string; to: string; role: string } | { type: "retry_fallback_succeeded"; model: string; role: string } | { type: "ttsr_triggered"; rules: Rule[] } | { type: "todo_reminder"; todos: TodoItem[]; attempt: number; maxAttempts: number } | { type: "todo_auto_clear" } | { type: "irc_message"; message: CustomMessage } | { type: "notice"; level: "info" | "warning" | "error"; message: string; source?: string } | { type: "thinking_level_changed"; thinkingLevel: ThinkingLevel | undefined; /** The user-configured selector when it differs from the effective level (e.g. `auto`). */ configured?: ConfiguredThinkingLevel; /** The level `auto` resolved to this turn, once classified. */ resolved?: Effort; } | { type: "goal_updated"; goal: Goal | null; state?: GoalModeState }; /** Listener function for agent session events */ export type AgentSessionEventListener = (event: AgentSessionEvent) => void; export type CommandMetadataChangedListener = () => void | Promise; export type AsyncJobSnapshotItem = Pick; const EMPTY_STOP_MAX_RETRIES = 3; const RETRY_BACKOFF_MAX_DELAY_MS = 8_000; const RETRY_BACKOFF_JITTER_RATIO = 0.25; /** * Hysteresis band for the post-shake "did we actually create headroom?" check. * Shake counts as having resolved threshold pressure only when residual context * lands at or below `SHAKE_RECOVERY_BAND × threshold`. Re-checking against the * raw threshold lets shake keep reclaiming a trickle of the previous turn's * output and land just under the line every turn, sustaining the auto-continue * dead loop reported in #2275. */ const SHAKE_RECOVERY_BAND = 0.8; function calculateRetryBackoffDelayMs(baseDelayMs: number, attempt: number): number { const cappedDelayMs = Math.min(Math.max(0, baseDelayMs) * 2 ** Math.max(0, attempt - 1), RETRY_BACKOFF_MAX_DELAY_MS); const jitter = 1 - Math.random() * RETRY_BACKOFF_JITTER_RATIO; return cappedDelayMs * jitter; } /** * Slack added past a sibling credential's block expiry before retrying, so * the next getApiKey lands after the block has actually lapsed. */ const SIBLING_UNBLOCK_BUFFER_MS = 1_000; const NON_WHITESPACE_RE = /\S/; function hasNonWhitespace(value: string): boolean { return NON_WHITESPACE_RE.test(value); } export interface AsyncJobSnapshot { running: AsyncJobSnapshotItem[]; recent: AsyncJobSnapshotItem[]; delivery: AsyncJobDeliveryState; } export type { ShakeMode, ShakeResult }; // ============================================================================ // Types // ============================================================================ export interface AgentSessionConfig { agent: Agent; sessionManager: SessionManager; settings: Settings; /** Whether the caller explicitly requested yolo/auto-approve behavior for this session. */ autoApprove?: boolean; /** Models to cycle through with Ctrl+P (from --models flag) */ scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>; /** Initial session thinking selector. */ thinkingLevel?: ConfiguredThinkingLevel; /** Prompt templates for expansion */ promptTemplates?: PromptTemplate[]; /** File-based slash commands for expansion */ slashCommands?: FileSlashCommand[]; /** Extension runner (created in main.ts with wrapped tools) */ extensionRunner?: ExtensionRunner; /** Loaded skills (already discovered by SDK) */ skills?: Skill[]; /** Skill loading warnings (already captured by SDK) */ skillWarnings?: SkillWarning[]; /** Custom commands (TypeScript slash commands) */ customCommands?: LoadedCustomCommand[]; skillsSettings?: SkillsSettings; /** Model registry for API key resolution and model discovery */ modelRegistry: ModelRegistry; /** Tool registry for LSP and settings */ toolRegistry?: Map; /** Current session pre-LLM message transform pipeline */ transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => AgentMessage[] | Promise; /** Provider payload hook used by the active session request path */ onPayload?: SimpleStreamOptions["onPayload"]; /** Provider response hook used by the active session request path */ onResponse?: SimpleStreamOptions["onResponse"]; /** Raw SSE hook used by the active session request path */ onSseEvent?: SimpleStreamOptions["onSseEvent"]; /** Per-session raw SSE diagnostic buffer */ rawSseDebugBuffer?: RawSseDebugBuffer; /** Current session message-to-LLM conversion pipeline */ convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise; /** System prompt builder that can consider tool availability. Returns ordered provider-facing blocks. */ rebuildSystemPrompt?: (toolNames: string[], tools: Map) => Promise<{ systemPrompt: string[] }>; /** Rebuild the SSH tool from current capability discovery results. */ reloadSshTool?: () => Promise; requestedToolNames?: ReadonlySet; /** * Optional accessor for live MCP server instructions. Read by the session's * `rebuildSystemPrompt`-skip optimization to detect server-side instruction * changes (e.g. an MCP server upgrade) that would otherwise pass the tool-set * signature comparison and silently keep a stale prompt cached. */ getMcpServerInstructions?: () => Map | undefined; /** Enable hidden-by-default MCP tool discovery for this session. */ mcpDiscoveryEnabled?: boolean; /** MCP tool names to activate for the current session when discovery mode is enabled. */ initialSelectedMCPToolNames?: string[]; /** Whether constructor-provided MCP defaults should be persisted immediately. */ persistInitialMCPToolSelection?: boolean; /** MCP server names whose tools should seed discovery-mode sessions whenever those servers are connected. */ defaultSelectedMCPServerNames?: string[]; /** MCP tool names that should seed brand-new sessions created from this AgentSession. */ defaultSelectedMCPToolNames?: string[]; /** TTSR manager for time-traveling stream rules */ ttsrManager?: TtsrManager; /** Secret obfuscator for deobfuscating streaming edit content */ obfuscator?: SecretObfuscator; /** Inherited eval executor session id from a parent agent. */ parentEvalSessionId?: string; /** Logical owner for retained Python kernels created by this session. */ evalKernelOwnerId?: string; /** * AsyncJobManager that this session installed as the process-global instance. * Only set for top-level sessions; subagents inherit the parent's manager and * **MUST NOT** dispose it on their own teardown. */ ownedAsyncJobManager?: AsyncJobManager; /** * AsyncJobManager reachable by this session for scoped job actions. * * Top-level owners receive their own manager, subagents receive the inherited * parent manager, and secondary in-process top-level sessions receive * `undefined` so job snapshots and ACP drains cannot observe the primary's * state. */ asyncJobManager?: AsyncJobManager; /** Agent identity (registry id like "Main" or "Alice") used for IRC routing. */ agentId?: string; /** * Override the provider-facing session ID for all API requests from this session. * When absent, `sessionManager.getSessionId()` is used. Needed when benchmark or * SDK callers issue probes / prewarming with an explicit `--provider-session-id` * so that credential sticky selection is consistent with the session's streaming calls. */ providerSessionId?: string; } /** Options for AgentSession.prompt() */ export interface PromptOptions { /** Whether to expand file-based prompt templates (default: true) */ expandPromptTemplates?: boolean; /** Image attachments */ images?: ImageContent[]; /** When streaming, how to queue the message: "steer" (interrupt) or "followUp" (wait). */ streamingBehavior?: "steer" | "followUp"; /** Optional tool choice override for the next LLM call. */ toolChoice?: ToolChoice; /** Send as developer/system message instead of user. Providers that support it use the developer role; others fall back to user. */ synthetic?: boolean; /** Explicit billing/initiator attribution for the prompt. Defaults to user prompts as `user` and synthetic prompts as `agent`. */ attribution?: MessageAttribution; /** Skip pre-send compaction checks for this prompt (internal use for maintenance flows). */ skipCompactionCheck?: boolean; } /** Result from a handoff operation. */ export interface HandoffResult { document: string; savedPath?: string; } export interface SessionHandoffOptions { autoTriggered?: boolean; signal?: AbortSignal; } /** Result from cycleModel() */ export interface ModelCycleResult { model: Model; thinkingLevel: ThinkingLevel | undefined; /** Whether cycling through scoped models (--models flag) or all available */ isScoped: boolean; } /** Result from cycleRoleModels() */ export interface RoleModelCycleResult { model: Model; thinkingLevel: ThinkingLevel | undefined; role: string; } /** A configured role resolved to a concrete model, used by role cycling and * the plan-approval model slider. */ export interface ResolvedRoleModel { role: string; model: Model; thinkingLevel?: ThinkingLevel; explicitThinkingLevel: boolean; } /** The set of resolvable role models plus the index of the currently active * one within {@link ResolvedRoleModel.role} order. */ export interface RoleModelCycle { models: ResolvedRoleModel[]; currentIndex: number; } /** Session statistics for /session command */ export interface SessionStats { sessionFile: string | undefined; sessionId: string; userMessages: number; assistantMessages: number; toolCalls: number; toolResults: number; totalMessages: number; tokens: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number; }; premiumRequests: number; cost: number; } export interface FreshSessionResult { previousSessionId: string; sessionId: string; closedProviderSessions: number; } /** Internal marker for hook messages queued through the agent loop */ // ============================================================================ // Constants // ============================================================================ /** Standard thinking levels */ type RetryFallbackChains = Record; type RetryFallbackRevertPolicy = "never" | "cooldown-expiry"; interface RetryFallbackSelector { raw: string; provider: string; id: string; thinkingLevel: ThinkingLevel | undefined; } interface ActiveRetryFallbackState { role: string; originalSelector: string; originalThinkingLevel: ConfiguredThinkingLevel | undefined; lastAppliedFallbackThinkingLevel: ConfiguredThinkingLevel | undefined; pinned: boolean; } function parseRetryFallbackSelector(selector: string): RetryFallbackSelector | undefined { const trimmed = selector.trim(); if (!trimmed) return undefined; const parsed = parseModelString(trimmed); if (!parsed) return undefined; return { raw: trimmed, provider: parsed.provider, id: parsed.id, thinkingLevel: parsed.thinkingLevel, }; } function formatRetryFallbackSelector(model: Model, thinkingLevel: ThinkingLevel | undefined): string { const selector = formatModelString(model); return thinkingLevel ? `${selector}:${thinkingLevel}` : selector; } function formatRetryFallbackBaseSelector(selector: RetryFallbackSelector): string { return `${selector.provider}/${selector.id}`; } const EPHEMERAL_REPLY_MAX_BYTES = 4096; /** * Collapse degenerate ephemeral replies (/btw, /omfg side-channel turns). * Models occasionally loop on a single line (~16 reports of N-times-repeated * replies); compress runs longer than 3 down to one instance + `[…N×]`, then * cap at 4 KiB so a runaway reply can't flood the channel. */ function dedupeEphemeralReply(text: string): string { if (!text) return text; const lines = text.split("\n"); const out: string[] = []; let i = 0; while (i < lines.length) { let j = i + 1; while (j < lines.length && lines[j] === lines[i]) j++; const runLen = j - i; if (runLen > 3) { out.push(lines[i], `[…${runLen}×]`); } else { for (let k = 0; k < runLen; k++) out.push(lines[i]); } i = j; } let result = out.join("\n"); if (Buffer.byteLength(result, "utf8") > EPHEMERAL_REPLY_MAX_BYTES) { // Trim by characters until we're under the byte budget — handles multi-byte // glyphs at the boundary without splitting them. const suffix = "\n[…truncated]"; const budget = EPHEMERAL_REPLY_MAX_BYTES - Buffer.byteLength(suffix, "utf8"); while (Buffer.byteLength(result, "utf8") > budget) { result = result.slice(0, -1); } result += suffix; } return result; } /** * 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, 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: () => {}, }; function createHandoffContext(document: string): string { return `\n${document}\n\n\nThe above is a handoff document from a previous session. Use this context to continue the work seamlessly.`; } function createHandoffFileName(date = new Date()): string { const fileTimestamp = date.toISOString().replace(/[:.]/g, "-"); return `handoff-${fileTimestamp}.md`; } // ============================================================================ // ACP Permission Gate // ============================================================================ /** Tools that require user permission before execution when an ACP client is connected. */ const PERMISSION_REQUIRED_TOOLS = new Set(["bash", "edit", "delete", "move"]); /** Permission options presented to the client on each gated tool call. */ const PERMISSION_OPTIONS: ClientBridgePermissionOption[] = [ { optionId: "allow_once", name: "Allow once", kind: "allow_once" }, { optionId: "allow_always", name: "Always allow", kind: "allow_always" }, { optionId: "reject_once", name: "Reject", kind: "reject_once" }, { optionId: "reject_always", name: "Always reject", kind: "reject_always" }, ]; const PERMISSION_OPTIONS_BY_ID = new Map(PERMISSION_OPTIONS.map(option => [option.optionId, option])); function getStringProperty(value: Record, key: string): string | undefined { const candidate = value[key]; return typeof candidate === "string" ? candidate : undefined; } function collectStringPaths(value: unknown): string[] { return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; } function getEditDestructiveIntent(args: unknown): { kind: "delete" | "move"; paths: string[] } | undefined { if (!args || typeof args !== "object" || Array.isArray(args)) return undefined; const a = args as Record; const edits = Array.isArray(a.edits) ? a.edits : undefined; if (edits) { const path = getStringProperty(a, "path"); if (path) { for (const edit of edits) { if (!edit || typeof edit !== "object" || Array.isArray(edit)) continue; const op = getStringProperty(edit as Record, "op"); if (op === "delete") return { kind: "delete", paths: [path] }; } } for (const edit of edits) { if (!edit || typeof edit !== "object" || Array.isArray(edit)) continue; const entry = edit as Record; const op = getStringProperty(entry, "op"); const rename = getStringProperty(entry, "rename"); if (op !== "create" && rename) return { kind: "move", paths: path ? [path, rename] : [rename] }; } } const input = getStringProperty(a, "input"); if (input) { try { const entries = expandApplyPatchToEntries({ input }); const deleteEntry = entries.find(entry => entry.op === "delete"); if (deleteEntry) return { kind: "delete", paths: [deleteEntry.path] }; const moveEntry = entries.find(entry => entry.rename); if (moveEntry?.rename) return { kind: "move", paths: [moveEntry.path, moveEntry.rename] }; } catch { // If the edit input is not an apply_patch envelope, it is not a delete/move operation. } } return undefined; } function getPermissionIntent( toolName: string, args: unknown, ): { toolName: string; title: string; paths?: string[]; cacheKey: string } | undefined { const a = args && typeof args === "object" && !Array.isArray(args) ? (args as Record) : {}; if (toolName === "bash") { const cmd = getStringProperty(a, "command")?.slice(0, 80); return { toolName, title: cmd || toolName, cacheKey: toolName }; } if (toolName === "delete") { const p = getStringProperty(a, "path"); return { toolName, title: p ? `Delete ${p}` : toolName, paths: p ? [p] : undefined, cacheKey: toolName }; } if (toolName === "move") { const from = getStringProperty(a, "oldPath") ?? getStringProperty(a, "path") ?? getStringProperty(a, "from"); const to = getStringProperty(a, "newPath") ?? getStringProperty(a, "to") ?? getStringProperty(a, "destination"); if (from && to) return { toolName, title: `Move ${from} to ${to}`, paths: [from, to], cacheKey: toolName }; return { toolName, title: from ? `Move ${from}` : toolName, paths: from ? [from] : undefined, cacheKey: toolName, }; } if (toolName === "edit") { const intent = getEditDestructiveIntent(args); if (!intent) return undefined; if (intent.kind === "delete") { return { toolName, title: `Delete ${intent.paths[0] ?? "edit target"}`, paths: intent.paths, cacheKey: "edit:delete", }; } const from = intent.paths[0]; const to = intent.paths[1]; return { toolName, title: from && to ? `Move ${from} to ${to}` : `Move ${from ?? to ?? "edit target"}`, paths: intent.paths, cacheKey: "edit:move", }; } return undefined; } function extractPermissionLocations( args: unknown, cwd: string, explicitPaths?: string[], ): { path: string; line?: number }[] { if (!args || typeof args !== "object") return []; const a = args as Record; const out: { path: string; line?: number }[] = []; const pushPath = (value: unknown) => { if (typeof value !== "string" || value.length === 0) return; // ACP locations carry file paths that the editor host will open or focus; // they must be absolute or the client cannot resolve them. Resolve raw // tool args (often cwd-relative) against the session cwd before sending. let resolved: string; try { resolved = resolveToCwd(value, cwd); } catch { return; } if (out.some(location => location.path === resolved)) return; out.push({ path: resolved }); }; if (explicitPaths) { for (const p of explicitPaths) { pushPath(p); } return out; } pushPath(a.path); pushPath(a.file); for (const p of collectStringPaths(a.paths)) { pushPath(p); } pushPath(a.oldPath); pushPath(a.newPath); pushPath(a.from); pushPath(a.to); pushPath(a.source); pushPath(a.destination); return out; } // ============================================================================ // AgentSession Class // ============================================================================ /** Internal record stored in the steering/followUp display queues. The optional * `tag` is set only by `enqueueCustomMessageDisplay` (used for skill-prompt * custom messages queued during streaming) and is matched by the custom-role * `message_start` dequeue branch; user-message pushes leave it undefined and * rely on the existing text-equality match. `images` carries the original * (pre-normalization) image blocks so queue restoration (Esc / Alt+Up) can * hand them back to the editor instead of dropping them. */ type QueuedDisplayEntry = { text: string; tag?: string; images?: ImageContent[] }; /** Entry returned by {@link AgentSession.clearQueue} / {@link AgentSession.popLastQueuedMessage}. */ export type RestoredQueuedMessage = { text: string; images?: ImageContent[] }; export class AgentSession { readonly agent: Agent; readonly sessionManager: SessionManager; readonly settings: Settings; readonly yieldQueue: YieldQueue; fileSnapshotStore?: InMemorySnapshotStore; #autoApprove: boolean; #powerAssertion: MacOSPowerAssertion | undefined; readonly configWarnings: string[] = []; #scopedModels: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>; /** Effective, metadata-clamped thinking level applied to the agent (never `auto`). */ #thinkingLevel: ThinkingLevel | undefined; /** True when the user configured `auto`; the effective level is resolved per turn. */ #autoThinking: boolean = false; /** The level `auto` last resolved to (for UI); undefined until a turn is classified. */ #autoResolvedLevel: Effort | undefined; #promptTemplates: PromptTemplate[]; #slashCommands: FileSlashCommand[]; // Event subscription state #unsubscribeAgent?: () => void; #unsubscribeAppendOnly?: () => void; /** Last (enable, providerId) tuple resolved by `#syncAppendOnlyContext` — used to skip no-op invalidations. */ #lastAppendOnlyResolution?: { enable: boolean; providerId: string | undefined }; #eventListeners: AgentSessionEventListener[] = []; #commandMetadataChangedListeners: CommandMetadataChangedListener[] = []; /** Tracks pending steering messages for UI display. Removed when delivered. * Entry shape: `{ text }` for plain-text steers (user-message dequeue * matches by `.text`); `{ text, tag }` for queued custom messages (skill * invocations dispatched while streaming) — the custom-role dequeue * matches by `.tag` so duplicate-args queued skills cannot collide. */ #steeringMessages: QueuedDisplayEntry[] = []; /** Tracks pending follow-up messages for UI display. Removed when delivered. * See `#steeringMessages` for entry shape. */ #followUpMessages: QueuedDisplayEntry[] = []; /** Messages queued to be included with the next user prompt as context ("asides"). */ #pendingNextTurnMessages: CustomMessage[] = []; #scheduledHiddenNextTurnGeneration: number | undefined = undefined; #planModeState: PlanModeState | undefined; #goalModeState: GoalModeState | undefined; #goalRuntime: GoalRuntime; #goalTurnCounter = 0; #planReferenceSent = false; #planReferencePath = "local://PLAN.md"; #clientBridge: ClientBridge | undefined; #allowAcpAgentInitiatedTurns = false; /** Per-session memory of allow_always / reject_always decisions for gated tools. */ #acpPermissionDecisions: Map = new Map(); // Compaction state #compactionAbortController: AbortController | undefined = undefined; #autoCompactionAbortController: AbortController | undefined = undefined; // Branch summarization state #branchSummaryAbortController: AbortController | undefined = undefined; // Handoff state #handoffAbortController: AbortController | undefined = undefined; #skipPostTurnMaintenanceAssistantTimestamp: number | undefined = undefined; // Retry state #retryAbortController: AbortController | undefined = undefined; #retryAttempt = 0; #retryPromise: Promise | undefined = undefined; #retryResolve: (() => void) | undefined = undefined; #activeRetryFallback: ActiveRetryFallbackState | undefined = undefined; // Todo completion reminder state #todoReminderCount = 0; #todoPhases: TodoPhase[] = []; #toolChoiceQueue = new ToolChoiceQueue(); // Bash execution state #bashAbortControllers = new Set(); #pendingBashMessages: BashExecutionMessage[] = []; // Python execution state #evalAbortControllers = new Set(); #evalKernelOwnerId: string; #parentEvalSessionId: string | undefined; /** * 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; #pendingPythonMessages: PythonExecutionMessage[] = []; #activeEvalExecutions = new Set>(); #evalExecutionDisposing = false; // Incoming IRC messages received while a turn was streaming; drained as // non-interrupting asides at the next step boundary (see the aside provider). #pendingIrcAsides: CustomMessage[] = []; // Agent identity (registry id) used for IRC routing and job ownership. #agentId: string | undefined; #providerSessionId: string | undefined; #freshProviderSessionId: string | undefined; #isDisposed = false; // Extension system #extensionRunner: ExtensionRunner | undefined = undefined; #turnIndex = 0; #skills: Skill[]; #skillWarnings: SkillWarning[]; // Custom commands (TypeScript slash commands) #customCommands: LoadedCustomCommand[] = []; /** MCP prompt commands (updated dynamically when prompts are loaded) */ #mcpPromptCommands: LoadedCustomCommand[] = []; #skillsSettings: SkillsSettings | undefined; // Model registry for API key resolution #modelRegistry: ModelRegistry; // Tool registry and prompt builder for extensions #toolRegistry: Map; #transformContext: (messages: AgentMessage[], signal?: AbortSignal) => AgentMessage[] | Promise; #onPayload: SimpleStreamOptions["onPayload"] | undefined; #onResponse: SimpleStreamOptions["onResponse"] | undefined; #onSseEvent: SimpleStreamOptions["onSseEvent"] | undefined; #convertToLlm: (messages: AgentMessage[]) => Message[] | Promise; #rebuildSystemPrompt: | ((toolNames: string[], tools: Map) => Promise<{ systemPrompt: string[] }>) | undefined; #getMcpServerInstructions: (() => Map | undefined) | undefined; #reloadSshTool: (() => Promise) | undefined; #requestedToolNames: ReadonlySet | undefined; #baseSystemPrompt: string[]; /** * Signature of the (toolNames, tool descriptions) tuple passed to the most * recent successful `rebuildSystemPrompt` call. Used to skip redundant rebuilds * when MCP servers reconnect without changing their tool definitions, which is * the dominant cause of prompt-cache invalidation in long sessions. */ #lastAppliedToolSignature: string | undefined; /** * Model identifier (`provider/id`) currently rendered into `#baseSystemPrompt`. * The prompt surfaces the active model to the agent, so a model switch must * trigger a rebuild. Compared against the live model after every model change * to decide whether the cached prompt is stale. */ #promptModelKey: string | undefined; #mcpDiscoveryEnabled = false; #discoverableMCPTools = new Map(); #selectedMCPToolNames = new Set(); // Generic tool discovery (covers built-in + MCP + extension when tools.discoveryMode === "all") #discoverableToolSearchIndex: DiscoverableToolSearchIndex | null = null; #selectedDiscoveredToolNames = new Set(); #rpcHostToolNames = new Set(); #defaultSelectedMCPServerNames = new Set(); #defaultSelectedMCPToolNames = new Set(); #sessionDefaultSelectedMCPToolNames = new Map(); // TTSR manager for time-traveling stream rules #ttsrManager: TtsrManager | undefined = undefined; #pendingTtsrInjections: Rule[] = []; /** Per-tool TTSR rules whose `interruptMode` opted out of aborting the stream. * These are folded into the matched tool call's `toolResult` content as an * in-band system reminder, instead of spawning a separate follow-up turn. */ #perToolTtsrInjections = new Map(); #ttsrAbortPending = false; #ttsrRetryToken = 0; #ttsrResumePromise: Promise | undefined = undefined; #ttsrResumeResolve: (() => void) | undefined = undefined; /** One-shot flag set in InteractiveMode.#approvePlan(compactBeforeExecute=true) * before the plan-mode → compaction transition. Consumed inside * #handleAgentEvent for the matching `message_end` + `stopReason: "aborted"`; * cleared unconditionally by the caller's `finally` so it cannot leak into * later unrelated aborts (e.g. when compaction returns cancelled/failed * without producing an aborted message_end). */ #planCompactAbortPending = false; /** Monotonic counter for `enqueueCustomMessageDisplay` tag generation; * combined with `Date.now()` so tags stay unique even across rapid * same-tick enqueues. */ #customDisplayTagCounter = 0; #postPromptTasks = new Set>(); #postPromptTasksPromise: Promise | undefined = undefined; #postPromptTasksResolve: (() => void) | undefined = undefined; #postPromptTasksAbortController = new AbortController(); #streamingEditAbortTriggered = false; #streamingEditCheckedLineCounts = new Map(); #streamingEditPrecheckedToolCallIds = new Set(); #streamingEditFileCache = new Map(); #promptInFlightCount = 0; // 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 // has decremented #promptInFlightCount, hitting AgentBusyError. Flushed from // both #endInFlight (normal) and #resetInFlight (abort). #pendingAgentEndEmit: AgentSessionEvent | undefined; #obfuscator: SecretObfuscator | undefined; #checkpointState: CheckpointState | undefined = undefined; #pendingRewindReport: string | undefined = undefined; #lastSuccessfulYieldToolCallId: string | undefined = undefined; #emptyStopRetryCount = 0; #promptGeneration = 0; #providerSessionState = new Map(); #hindsightSessionState: HindsightSessionState | undefined = undefined; readonly rawSseDebugBuffer: RawSseDebugBuffer; #acquirePowerAssertion(): void { if (process.platform !== "darwin") return; if (isBunTestRuntime()) return; if (this.#powerAssertion) return; const idle = this.settings.get("power.preventIdleSleep"); const system = this.settings.get("power.preventSystemSleep"); const user = this.settings.get("power.declareUserActive"); const display = this.settings.get("power.preventDisplaySleep"); // All four off → user opted out; do nothing. if (!idle && !system && !user && !display) return; try { this.#powerAssertion = MacOSPowerAssertion.start({ reason: "Oh My Pi agent session", idle, system, user, display, }); } 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(); } } #resetInFlight(): void { this.#promptInFlightCount = 0; this.#releasePowerAssertion(); this.#flushPendingAgentEnd(); } #flushPendingAgentEnd(): void { const pending = this.#pendingAgentEndEmit; if (!pending) return; this.#pendingAgentEndEmit = undefined; this.#emit(pending); } constructor(config: AgentSessionConfig) { this.agent = config.agent; this.sessionManager = config.sessionManager; this.settings = config.settings; this.#autoApprove = config.autoApprove === true; // Power assertions are taken per turn (see #beginInFlight); nothing acquired here. this.#evalKernelOwnerId = config.evalKernelOwnerId ?? `agent-session:${Snowflake.next()}`; this.#parentEvalSessionId = config.parentEvalSessionId; this.#ownedAsyncJobManager = config.ownedAsyncJobManager; this.#asyncJobManager = config.asyncJobManager ?? config.ownedAsyncJobManager; this.#scopedModels = config.scopedModels ?? []; if (config.thinkingLevel === AUTO_THINKING) { // `auto` is session-level: keep the flag and show a provisional concrete // level (the agent's initial effort was already set by the caller) until // the first user turn is classified. this.#autoThinking = true; this.#thinkingLevel = resolveProvisionalAutoLevel(this.model); } else { this.#thinkingLevel = config.thinkingLevel; } this.#applyThinkingLevelToAgent(this.#thinkingLevel); this.#promptTemplates = config.promptTemplates ?? []; this.#slashCommands = config.slashCommands ?? []; this.#extensionRunner = config.extensionRunner; this.#skills = config.skills ?? []; this.#skillWarnings = config.skillWarnings ?? []; this.#customCommands = config.customCommands ?? []; this.#skillsSettings = config.skillsSettings; this.#modelRegistry = config.modelRegistry; this.#validateRetryFallbackChains(); this.#toolRegistry = config.toolRegistry ?? new Map(); this.#requestedToolNames = config.requestedToolNames; this.#transformContext = config.transformContext ?? (messages => messages); 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.#ingestProviderUsageHeaders(response, model); await configuredOnResponse(response, model); } : (response, model) => { this.rawSseDebugBuffer.recordResponse(response, model); this.#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.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 (see Agent.getAsideMessages), // so they reach the model between requests without waiting for a yield. this.agent.setAsideMessageProvider(() => { const pendingIrc = this.#pendingIrcAsides; this.#pendingIrcAsides = []; const thunks: AsideMessage[] = pendingIrc.map(record => () => record); thunks.push(...this.yieldQueue.drainLazy()); return thunks; }); this.#convertToLlm = config.convertToLlm ?? convertToLlm; this.#rebuildSystemPrompt = config.rebuildSystemPrompt; this.#getMcpServerInstructions = config.getMcpServerInstructions; this.#reloadSshTool = config.reloadSshTool; this.#baseSystemPrompt = this.agent.state.systemPrompt; this.#promptModelKey = this.#currentPromptModelKey(); this.#mcpDiscoveryEnabled = config.mcpDiscoveryEnabled ?? false; this.#setDiscoverableMCPTools(this.#collectDiscoverableMCPToolsFromRegistry()); this.#selectedMCPToolNames = new Set(config.initialSelectedMCPToolNames ?? []); this.#defaultSelectedMCPServerNames = new Set(config.defaultSelectedMCPServerNames ?? []); this.#defaultSelectedMCPToolNames = new Set(config.defaultSelectedMCPToolNames ?? []); this.#pruneSelectedMCPToolNames(); const persistedSelectedMCPToolNames = this.buildDisplaySessionContext().selectedMCPToolNames; const currentSelectedMCPToolNames = this.getSelectedMCPToolNames(); const persistInitialMCPToolSelection = config.persistInitialMCPToolSelection ?? this.sessionManager.getBranch().length === 0; if ( this.#mcpDiscoveryEnabled && persistInitialMCPToolSelection && !this.#selectedMCPToolNamesMatch(persistedSelectedMCPToolNames, currentSelectedMCPToolNames) ) { this.sessionManager.appendMCPToolSelection(currentSelectedMCPToolNames); } this.#rememberSessionDefaultSelectedMCPToolNames( this.sessionManager.getSessionFile(), this.#getConfiguredDefaultSelectedMCPToolNames(), ); this.#ttsrManager = config.ttsrManager; this.#obfuscator = config.obfuscator; this.#agentId = config.agentId; this.#providerSessionId = config.providerSessionId; this.agent.setAssistantMessageEventInterceptor((message, assistantMessageEvent) => { const event: AgentEvent = { type: "message_update", message, assistantMessageEvent, }; this.#preCacheStreamingEditFile(event); this.#maybeAbortStreamingEdit(event); }); // Per-tool TTSR reminders are folded into the matched tool's result via this hook. this.agent.afterToolCall = ctx => this.#ttsrAfterToolCall(ctx); this.agent.providerSessionState = this.#providerSessionState; this.#syncAgentSessionId(); this.#syncTodoPhasesFromBranch(); 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 }, ); }, }); // 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)); } /** 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; } /** Advance the tool-choice queue and return the next directive for the upcoming LLM call. */ nextToolChoice(): ToolChoice | undefined { return this.#toolChoiceQueue.nextToolChoice(); } /** * 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 use by the resolve tool. */ peekQueueInvoker(): ((input: unknown) => Promise | unknown) | undefined { return this.#toolChoiceQueue.peekInFlightInvoker(); } /** Standing (long-lived) handler the `resolve` tool falls back to when no * queue invoker is in flight. Used by plan mode so the agent can submit * approval via `resolve` without forcing the tool choice every turn. */ #standingResolveHandler: ((input: unknown) => Promise | unknown) | undefined; peekStandingResolveHandler(): ((input: unknown) => Promise | unknown) | undefined { return this.#standingResolveHandler; } setStandingResolveHandler(handler: ((input: unknown) => Promise | unknown) | null): void { this.#standingResolveHandler = handler ?? 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; } 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.#ttsrManager; } /** Whether a TTSR abort is pending (stream was aborted to inject rules) */ get isTtsrAbortPending(): boolean { return this.#ttsrAbortPending; } /** Whether the plan-mode → compaction transition's expected internal abort is * pending. Consumed by `#handleAgentEvent` to stamp `SILENT_ABORT_MARKER` * on the next aborted assistant message_end; cleared unconditionally by * `InteractiveMode.#approvePlan`'s `finally` block. */ get isPlanCompactAbortPending(): boolean { return this.#planCompactAbortPending; } /** Arm the silent-abort marker for the next aborted assistant message_end. * Caller MUST clear via `clearPlanCompactAbortPending()` in a `finally` * to guarantee no leak. */ markPlanCompactAbortPending(): void { this.#planCompactAbortPending = true; } /** Unconditionally clear the silent-abort flag. Idempotent: safe when the * flag was never set OR was already consumed by `#handleAgentEvent`. */ clearPlanCompactAbortPending(): void { this.#planCompactAbortPending = false; } /** Register a compact display string for a custom message that the caller is * about to dispatch via `promptCustomMessage` / `sendCustomMessage`. * Returns a stable tag the caller MUST embed in * `CustomMessage.details.__pendingDisplayTag` so the agent-side * `message_start` handler can remove the matching display entry when the * queued message is consumed. * * Does NOT push to the agent's steering/followUp queue — that happens * separately inside `sendCustomMessage`. */ enqueueCustomMessageDisplay(text: string, mode: "steer" | "followUp"): string { const tag = `omp-cmd-${Date.now()}-${++this.#customDisplayTagCounter}`; const displayText = text.trim(); if (!displayText) return tag; const entry: QueuedDisplayEntry = { text: displayText, tag }; if (mode === "steer") { this.#steeringMessages.push(entry); } else { this.#followUpMessages.push(entry); } return tag; } 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 }); } // ========================================================================= // 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 }); } #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; } async #emitSessionEvent(event: AgentSessionEvent): Promise { if (event.type === "message_update") { this.#emit(event); void this.#queueExtensionEvent(event); return; } await this.#emitExtensionEvent(event); // 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); } // Track last assistant message for auto-compaction check #lastAssistantMessage: AssistantMessage | undefined = undefined; /** Internal handler for agent events - shared by subscribe and reconnect */ #handleAgentEvent = async (event: AgentEvent): Promise => { // When a user message starts, check if it's from either queue and remove it BEFORE emitting // This ensures the UI sees the updated queue state if (event.type === "message_start" && event.message.role === "user") { const messageText = this.#getUserMessageText(event.message); if (messageText) { // Check steering queue first (match by .text on tagged records) const steeringIndex = this.#steeringMessages.findIndex(e => e.text === messageText); if (steeringIndex !== -1) { this.#steeringMessages.splice(steeringIndex, 1); } else { // Check follow-up queue const followUpIndex = this.#followUpMessages.findIndex(e => e.text === messageText); if (followUpIndex !== -1) { this.#followUpMessages.splice(followUpIndex, 1); } } } } // Tag-based dequeue for custom messages (skills queued via promptCustomMessage). // The InputController attached a stable tag via CustomMessage.details when it // registered the display chip; pull it back here to remove the matching entry // from the pending bar atomically with the agent's queue consumption. Match by // tag (not text) — two queued skills with identical args cannot collide. if (event.type === "message_start" && event.message.role === "custom") { const tag = readPendingDisplayTag(event.message.details); if (tag) { const steerIdx = this.#steeringMessages.findIndex(e => e.tag === tag); if (steerIdx !== -1) { this.#steeringMessages.splice(steerIdx, 1); } else { const followUpIdx = this.#followUpMessages.findIndex(e => e.tag === tag); if (followUpIdx !== -1) { this.#followUpMessages.splice(followUpIdx, 1); } } } } // Plan-mode → compaction 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; the caller's // `finally` (in `InteractiveMode.#approvePlan`) clears it again on // every terminal compaction outcome (`ok` / `cancelled` / `failed` / // throw) so a leaked flag cannot silence a later unrelated abort. if ( event.type === "message_end" && event.message.role === "assistant" && event.message.stopReason === "aborted" && this.#planCompactAbortPending ) { (event.message as AssistantMessage).errorMessage = SILENT_ABORT_MARKER; this.#planCompactAbortPending = false; } // 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 = obfuscator.deobfuscateObject(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, }); } await this.#emitSessionEvent(displayEvent); if (event.type === "turn_start") { this.#resetStreamingEditState(); // TTSR: Reset buffer on turn start this.#ttsrManager?.resetBuffer(); } // TTSR: Increment message count on turn end (for repeat-after-gap tracking) if (event.type === "turn_end" && this.#ttsrManager) { this.#ttsrManager.incrementMessageCount(); } // 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); } } if (event.type === "tool_execution_end" && event.toolName === "yield" && !event.isError) { this.#lastSuccessfulYieldToolCallId = event.toolCallId; } if (event.type === "turn_end" && this.#pendingRewindReport) { const report = this.#pendingRewindReport; this.#pendingRewindReport = undefined; await this.#applyRewind(report); } // TTSR: Check for pattern matches on assistant text/thinking and tool argument deltas if (event.type === "message_update" && this.#ttsrManager?.hasRules()) { const assistantEvent = event.assistantMessageEvent; let matchContext: TtsrMatchContext | undefined; let streamingToolCall: ToolCall | undefined; if (assistantEvent.type === "text_delta") { matchContext = { source: "text" }; } else if (assistantEvent.type === "thinking_delta") { matchContext = { source: "thinking" }; } else if (assistantEvent.type === "toolcall_delta") { streamingToolCall = this.#getStreamingToolCallBlock(event.message, assistantEvent.contentIndex); matchContext = this.#getTtsrToolMatchContext(streamingToolCall, assistantEvent.contentIndex); } if (matchContext && "delta" in assistantEvent) { const targetMessageTimestamp = event.message.role === "assistant" ? event.message.timestamp : undefined; const matches = this.#checkTtsrStream(assistantEvent.delta, matchContext, streamingToolCall); if (matches.length > 0 && this.#handleTtsrMatches(matches, matchContext, targetMessageTimestamp)) { return; } // ast-grep `astCondition` rules match against the reconstructed edit/write // snapshot, which only exists for tool argument streams. The native worker // call is async, so this path is awaited and self-throttled by the manager. if (matchContext.source === "tool" && this.#ttsrManager?.hasAstRules()) { const astMatches = await this.#checkTtsrAstStream(matchContext, streamingToolCall); if (astMatches.length > 0 && this.#handleTtsrMatches(astMatches, matchContext, targetMessageTimestamp)) { return; } } } } if ( event.type === "message_update" && (event.assistantMessageEvent.type === "toolcall_start" || event.assistantMessageEvent.type === "toolcall_delta" || event.assistantMessageEvent.type === "toolcall_end") ) { void this.#preCacheStreamingEditFile(event); } if ( event.type === "message_update" && (event.assistantMessageEvent.type === "toolcall_end" || event.assistantMessageEvent.type === "toolcall_delta") ) { this.#maybeAbortStreamingEdit(event); } // Handle session persistence if (event.type === "message_end") { // 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.#markTtsrInjected(this.#extractTtsrRuleNames(event.message.details)); } } else if ( event.message.role === "user" || event.message.role === "developer" || event.message.role === "assistant" || event.message.role === "toolResult" || event.message.role === "fileMention" ) { // Regular LLM message - persist as SessionMessageEntry this.sessionManager.appendMessage(event.message); } // Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere // Track assistant message for auto-compaction (checked on agent_end) if (event.message.role === "assistant") { this.#lastAssistantMessage = event.message; const assistantMsg = event.message as AssistantMessage; const currentGrantsAnthropicPriority = this.serviceTier === "priority" || this.serviceTier === "claude-only"; if (assistantMsg.disabledFeatures?.includes("priority") && currentGrantsAnthropicPriority) { this.setServiceTier(undefined); this.emitNotice( "warning", "Priority/fast mode rejected for this model; retried without it. Fast mode is now off.", "priority", ); } // Resolve TTSR resume gate before checking for new deferred injections. // Gate on #ttsrAbortPending, not stopReason: a non-TTSR abort (e.g. streaming // edit) also produces stopReason === "aborted" but has no continuation coming. // Only skip when #ttsrAbortPending is true (TTSR continuation is imminent). if (!this.#ttsrAbortPending) { this.#resolveTtsrResume(); } this.#queueDeferredTtsrInjectionIfNeeded(assistantMsg); if (this.#handoffAbortController) { this.#skipPostTurnMaintenanceAssistantTimestamp = assistantMsg.timestamp; } if ( assistantMsg.stopReason !== "error" && assistantMsg.stopReason !== "aborted" && !this.#isEmptyAssistantStop(assistantMsg) && this.#retryAttempt > 0 ) { if (this.#activeRetryFallback && this.model) { await this.#emitSessionEvent({ type: "retry_fallback_succeeded", model: formatRetryFallbackSelector(this.model, this.thinkingLevel), role: this.#activeRetryFallback.role, }); } await this.#emitSessionEvent({ type: "auto_retry_end", success: true, attempt: this.#retryAttempt, }); this.#retryAttempt = 0; } } if (event.message.role === "toolResult") { const { toolName, details, isError, content } = event.message as { toolName?: string; details?: { path?: string; phases?: TodoPhase[]; report?: string; startedAt?: string }; isError?: boolean; content?: Array; }; // Invalidate streaming edit cache when edit tool completes to prevent stale data if (toolName === "edit" && details?.path) { this.#invalidateFileCacheForPath(details.path); } if (toolName === "todo" && !isError && Array.isArray(details?.phases)) { this.setTodoPhases(details.phases); } 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 (toolName === "checkpoint" && !isError) { const checkpointEntryId = this.sessionManager.getEntries().at(-1)?.id ?? null; this.#checkpointState = { checkpointMessageCount: this.agent.state.messages.length, checkpointEntryId, startedAt: details?.startedAt ?? new Date().toISOString(), }; this.#pendingRewindReport = undefined; } if (toolName === "rewind" && !isError && this.#checkpointState) { const detailReport = typeof details?.report === "string" ? details.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 usage = this.getSessionStats().tokens; await this.#goalRuntime.onAgentEnd({ currentUsage: { input: usage.input, output: usage.output, cacheRead: usage.cacheRead, cacheWrite: usage.cacheWrite, }, }); const fallbackAssistant = [...event.messages] .reverse() .find((message): message is AssistantMessage => message.role === "assistant"); const msg = this.#lastAssistantMessage ?? fallbackAssistant; this.#lastAssistantMessage = undefined; if (!msg) { this.#lastSuccessfulYieldToolCallId = undefined; return; } // 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" && msg.errorMessage?.includes("GitHub Copilot authentication failed") ) { await this.#modelRegistry.authStorage.remove("github-copilot"); } if (this.#skipPostTurnMaintenanceAssistantTimestamp === msg.timestamp) { this.#skipPostTurnMaintenanceAssistantTimestamp = undefined; this.#lastSuccessfulYieldToolCallId = undefined; return; } if (this.#assistantEndedWithSuccessfulYield(msg)) { this.#lastSuccessfulYieldToolCallId = undefined; return; } this.#lastSuccessfulYieldToolCallId = undefined; if (await this.#handleEmptyAssistantStop(msg)) { return; } // Check for retryable errors first (overloaded, rate limit, server errors) if (this.#isRetryableError(msg)) { const didRetry = await this.#handleRetryableError(msg); if (didRetry) return; // Retry was initiated, don't proceed to compaction } this.#resolveRetry(); const compactionTask = this.#checkCompaction(msg); this.#trackPostPromptTask(compactionTask); const compactionDeferredHandoff = await compactionTask; // Check for incomplete todos only after a final assistant stop, not intermediate tool-use turns. const hasToolCalls = msg.content.some(content => content.type === "toolCall"); if (hasToolCalls) { return; } // When checkCompaction scheduled a deferred handoff, skip the rewind/todo passes: // any reminder we append here would race the handoff's session reset, and // #scheduleAgentContinue would start a fresh streaming turn alongside the handoff // LLM call (visible as "Auto-handoff" loader + an assistant message still streaming). if (compactionDeferredHandoff) { return; } if (msg.stopReason !== "error" && msg.stopReason !== "aborted") { if (this.#enforceRewindBeforeYield()) { return; } await this.#checkTodoCompletion(); } } }; /** Resolve the pending retry promise */ #resolveRetry(): void { if (this.#retryResolve) { this.#retryResolve(); this.#retryResolve = undefined; this.#retryPromise = undefined; } } /** Create the TTSR resume gate promise if one doesn't already exist. */ #ensureTtsrResumePromise(): void { if (this.#ttsrResumePromise) return; const { promise, resolve } = Promise.withResolvers(); this.#ttsrResumePromise = promise; this.#ttsrResumeResolve = resolve; } /** Resolve and clear the TTSR resume gate. */ #resolveTtsrResume(): void { if (!this.#ttsrResumeResolve) return; this.#ttsrResumeResolve(); this.#ttsrResumeResolve = undefined; this.#ttsrResumePromise = 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?: () => 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?.(); return; } if (options?.generation !== undefined && this.#promptGeneration !== options.generation) { options.onSkip?.(); return; } await task(signal); })(); this.#trackPostPromptTask(scheduled); } #scheduleAgentContinue(options?: { delayMs?: number; generation?: number; shouldContinue?: () => boolean; onSkip?: () => void; onError?: () => void; }): void { this.#schedulePostPromptTask( async () => { // 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 (this.isCompacting || this.isGeneratingHandoff) { options?.onSkip?.(); return; } if (options?.shouldContinue && !options.shouldContinue()) { options?.onSkip?.(); return; } try { await this.#maybeRestoreRetryFallbackPrimary(); await this.agent.continue(); } catch (error) { logger.warn("agent.continue failed after scheduling", { error: error instanceof Error ? error.message : String(error), }); options?.onError?.(); } }, { delayMs: options?.delayMs, generation: options?.generation, onSkip: options?.onSkip, }, ); } #scheduleAutoContinuePrompt(generation: number): void { const continuePrompt = async () => { await this.#promptWithMessage( { role: "developer", content: [{ type: "text", text: autoContinuePrompt }], attribution: "agent", timestamp: Date.now(), }, autoContinuePrompt, { skipPostPromptRecoveryWait: true }, ); }; this.#schedulePostPromptTask( async signal => { await Promise.resolve(); if (signal.aborted) return; await continuePrompt(); }, { generation }, ); } async #cancelPostPromptTasks(): Promise { this.#postPromptTasksAbortController.abort(); this.#postPromptTasksAbortController = new AbortController(); this.#resolveTtsrResume(); 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(): Promise { while (true) { if (this.#retryPromise) { await this.#retryPromise; continue; } if (this.#ttsrResumePromise) { await this.#ttsrResumePromise; 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; } } #formatTtsrAbortReason(rules: Rule[]): string { const label = rules.length === 1 ? "rule" : "rules"; const ruleNames = rules.map(rule => rule.name).join(", "); return `TTSR matched ${label}: ${ruleNames}`; } /** Get TTSR injection payload and clear pending injections. */ #getTtsrInjectionContent(): { content: string; rules: Rule[] } | undefined { if (this.#pendingTtsrInjections.length === 0) return undefined; const rules = this.#pendingTtsrInjections; const content = rules .map(r => prompt.render(ttsrInterruptTemplate, { name: r.name, path: this.#displayRulePath(r.path), content: r.content, }), ) .join("\n\n"); this.#pendingTtsrInjections = []; return { content, rules }; } /** * Render a rule's file path for model-facing TTSR injections without leaking * the absolute home directory: cwd-relative when the rule lives in the * project, `~`-relative when it lives under home, else the raw path. */ #displayRulePath(rulePath: string): string { const cwdRel = relativePathWithinRoot(this.sessionManager.getCwd(), rulePath) ?? this.#displayPathWithinRoot(this.sessionManager.getCwd(), rulePath); if (cwdRel) return cwdRel; const homeRel = relativePathWithinRoot(os.homedir(), rulePath); if (homeRel) return `~/${homeRel}`; return rulePath; } #displayPathWithinRoot(root: string, candidate: string): string | null { const relative = path.relative(path.resolve(root), path.resolve(candidate)); return relative && !relative.startsWith("..") && !path.isAbsolute(relative) ? relative : null; } #addPendingTtsrInjections(rules: Rule[]): void { const seen = new Set(this.#pendingTtsrInjections.map(rule => rule.name)); for (const rule of rules) { if (seen.has(rule.name)) continue; this.#pendingTtsrInjections.push(rule); seen.add(rule.name); } } /** Tool-call id whose argument deltas triggered a TTSR match, when known. */ #extractTtsrToolCallId(matchContext: TtsrMatchContext): string | undefined { if (matchContext.source !== "tool") return undefined; const key = matchContext.streamKey; if (typeof key !== "string" || !key.startsWith("toolcall:")) return undefined; const id = key.slice("toolcall:".length); return id.length > 0 ? id : undefined; } #addPerToolTtsrInjections(toolCallId: string, rules: Rule[]): void { const bucket = this.#perToolTtsrInjections.get(toolCallId) ?? []; const seen = new Set(bucket.map(rule => rule.name)); // Dedupe against rules already bucketed for other tool calls in this // same assistant message so one rule attaches to exactly one tool call. const claimedElsewhere = new Set(); for (const [otherId, otherBucket] of this.#perToolTtsrInjections) { if (otherId === toolCallId) continue; for (const rule of otherBucket) claimedElsewhere.add(rule.name); } const newlyAdded: string[] = []; for (const rule of rules) { if (seen.has(rule.name) || claimedElsewhere.has(rule.name)) continue; bucket.push(rule); seen.add(rule.name); newlyAdded.push(rule.name); } if (bucket.length === 0) return; this.#perToolTtsrInjections.set(toolCallId, bucket); // Claim the rules in the TTSR manager so subsequent deltas in this same // turn (e.g. a sibling tool call's argument stream) don't re-match them. // Persistence still happens in #ttsrAfterToolCall when the tool actually // produces a result we can fold the reminder into. if (newlyAdded.length > 0) { this.#ttsrManager?.markInjectedByNames(newlyAdded); } } /** `afterToolCall` hook: fold any per-tool TTSR reminders into the result. */ #ttsrAfterToolCall(ctx: AfterToolCallContext): AfterToolCallResult | undefined { const rules = this.#perToolTtsrInjections.get(ctx.toolCall.id); if (!rules || rules.length === 0) return undefined; this.#perToolTtsrInjections.delete(ctx.toolCall.id); const reminder = rules .map(r => prompt.render(ttsrToolReminderTemplate, { name: r.name, path: this.#displayRulePath(r.path), content: r.content, }), ) .join("\n\n"); // The TTSR manager was already claimed at bucket time; only persistence remains. const ruleNames = rules.map(r => r.name.trim()).filter(n => n.length > 0); if (ruleNames.length > 0) { this.sessionManager.appendTtsrInjection(ruleNames); } return { content: [{ type: "text", text: reminder }, ...ctx.result.content], }; } #extractTtsrRuleNames(details: unknown): string[] { if (!details || typeof details !== "object" || Array.isArray(details)) { return []; } const rules = (details as { rules?: unknown }).rules; if (!Array.isArray(rules)) { return []; } return rules.filter((ruleName): ruleName is string => typeof ruleName === "string"); } #markTtsrInjected(ruleNames: string[]): void { const uniqueRuleNames = Array.from( new Set(ruleNames.map(ruleName => ruleName.trim()).filter(ruleName => ruleName.length > 0)), ); if (uniqueRuleNames.length === 0) { return; } this.#ttsrManager?.markInjectedByNames(uniqueRuleNames); this.sessionManager.appendTtsrInjection(uniqueRuleNames); } #findTtsrAssistantIndex(targetTimestamp: number | undefined): number { const messages = this.agent.state.messages; for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i]; if (message.role !== "assistant") { continue; } if (targetTimestamp === undefined || message.timestamp === targetTimestamp) { return i; } } return -1; } #shouldInterruptForTtsrMatch(matches: Rule[], matchContext: TtsrMatchContext): boolean { const globalMode = this.#ttsrManager?.getSettings().interruptMode ?? "always"; for (const rule of matches) { const mode = rule.interruptMode ?? globalMode; if (mode === "never") continue; if (mode === "prose-only" && (matchContext.source === "text" || matchContext.source === "thinking")) return true; if (mode === "tool-only" && matchContext.source === "tool") return true; if (mode === "always") return true; } return false; } #queueDeferredTtsrInjectionIfNeeded(assistantMsg: AssistantMessage): void { if (assistantMsg.stopReason === "aborted" || assistantMsg.stopReason === "error") { // Tools that hadn't started by abort/error will never produce results to // fold injections into — drop their stale per-tool entries. this.#perToolTtsrInjections.clear(); } if (this.#ttsrAbortPending || this.#pendingTtsrInjections.length === 0) { return; } if (assistantMsg.stopReason === "aborted" || assistantMsg.stopReason === "error") { this.#pendingTtsrInjections = []; return; } const injection = this.#getTtsrInjectionContent(); if (!injection) { return; } this.agent.followUp({ role: "custom", customType: "ttsr-injection", content: injection.content, display: false, details: { rules: injection.rules.map(rule => rule.name) }, attribution: "agent", timestamp: Date.now(), }); this.#ensureTtsrResumePromise(); // Mark as injected after this custom message is delivered and persisted (handled in message_end). // followUp() only enqueues; resume on the next tick once streaming settles. this.#scheduleAgentContinue({ delayMs: 1, generation: this.#promptGeneration, onSkip: () => { this.#resolveTtsrResume(); }, shouldContinue: () => { if (this.agent.state.isStreaming || !this.agent.hasQueuedMessages()) { this.#resolveTtsrResume(); return false; } return true; }, onError: () => { this.#resolveTtsrResume(); }, }); } /** Extract the tool-call block a toolcall_delta event refers to, if present. */ #getStreamingToolCallBlock(message: AgentMessage, contentIndex: number): ToolCall | undefined { if (message.role !== "assistant") { return undefined; } const content = message.content; if (!Array.isArray(content) || contentIndex < 0 || contentIndex >= content.length) { return undefined; } const block = content[contentIndex]; if (!block || typeof block !== "object" || block.type !== "toolCall") { return undefined; } return block as ToolCall; } /** Build TTSR match context for tool call argument deltas. */ #getTtsrToolMatchContext(toolCall: ToolCall | undefined, contentIndex: number): TtsrMatchContext { const context: TtsrMatchContext = { source: "tool" }; if (!toolCall) { return context; } context.toolName = toolCall.name; context.streamKey = toolCall.id ? `toolcall:${toolCall.id}` : `tool:${toolCall.name}:${contentIndex}`; context.filePaths = this.#extractTtsrFilePathsFromArgs(toolCall.arguments); return context; } /** * Match a stream delta against TTSR rules. * * Tool argument streams prefer the tool's `matcherDigest` normalization — the * real content the call introduces — over the raw argument delta, so rule * conditions written against source text keep working regardless of the * tool's wire format (hashline patches, JSON-escaped strings, ...). */ #checkTtsrStream(delta: string, matchContext: TtsrMatchContext, toolCall: ToolCall | undefined): Rule[] { const manager = this.#ttsrManager; if (!manager) { return []; } const digest = this.#resolveTtsrMatcherDigest(toolCall); if (digest !== undefined) { return manager.checkSnapshot(digest, matchContext); } return manager.checkDelta(delta, matchContext); } /** Reconstruct the tool's normalized source snapshot via its `matcherDigest`, if any. */ #resolveTtsrMatcherDigest(toolCall: ToolCall | undefined): string | undefined { if (!toolCall) { return undefined; } const tools = this.agent.state.tools; const tool = tools.find(t => t.name === toolCall.name) ?? tools.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name); return tool?.matcherDigest?.(toolCall.arguments ?? {}); } /** * Match ast-grep `astCondition` rules against the reconstructed tool snapshot. * * Only edit/write tool streams expose a `matcherDigest`, which is the real source * the call introduces; AST matching needs that (and a language inferred from the * path argument), so non-digest streams never produce AST matches. */ async #checkTtsrAstStream(matchContext: TtsrMatchContext, toolCall: ToolCall | undefined): Promise { const manager = this.#ttsrManager; if (!manager) { return []; } const digest = this.#resolveTtsrMatcherDigest(toolCall); if (digest === undefined) { return []; } return manager.checkAstSnapshot(digest, matchContext); } /** * Route TTSR matches to either a per-tool injection or a stream-interrupting * retry. Returns true when the stream was aborted and the caller should stop * processing this event. */ #handleTtsrMatches( matches: Rule[], matchContext: TtsrMatchContext, targetMessageTimestamp: number | undefined, ): boolean { // Decide first: a non-interrupting tool-source match attaches to the // specific tool call's result instead of driving a loop-wide follow-up. const shouldInterrupt = this.#shouldInterruptForTtsrMatch(matches, matchContext); const perToolId = shouldInterrupt ? undefined : this.#extractTtsrToolCallId(matchContext); if (perToolId) { this.#addPerToolTtsrInjections(perToolId, matches); this.#emitSessionEvent({ type: "ttsr_triggered", rules: matches }).catch(() => {}); return false; } // Queue rules for injection; mark as injected only after successful enqueue. this.#addPendingTtsrInjections(matches); if (!shouldInterrupt) { return false; } // Abort the stream immediately — do not gate on extension callbacks this.#ttsrAbortPending = true; this.#ensureTtsrResumePromise(); this.agent.abort(this.#formatTtsrAbortReason(matches)); // Notify extensions (fire-and-forget, does not block abort) this.#emitSessionEvent({ type: "ttsr_triggered", rules: matches }).catch(() => {}); // Schedule retry after a short delay const retryToken = ++this.#ttsrRetryToken; const generation = this.#promptGeneration; this.#schedulePostPromptTask( async () => { if (this.#ttsrRetryToken !== retryToken) { this.#resolveTtsrResume(); return; } const targetAssistantIndex = this.#findTtsrAssistantIndex(targetMessageTimestamp); if (!this.#ttsrAbortPending || this.#promptGeneration !== generation || targetAssistantIndex === -1) { this.#ttsrAbortPending = false; this.#pendingTtsrInjections = []; this.#perToolTtsrInjections.clear(); this.#resolveTtsrResume(); return; } this.#ttsrAbortPending = false; this.#perToolTtsrInjections.clear(); const ttsrSettings = this.#ttsrManager?.getSettings(); if (ttsrSettings?.contextMode === "discard") { // Remove the partial/aborted assistant turn from agent state this.agent.replaceMessages(this.agent.state.messages.slice(0, targetAssistantIndex)); } // Inject TTSR rules as system reminder before retry const injection = this.#getTtsrInjectionContent(); if (injection) { const details = { rules: injection.rules.map(rule => rule.name) }; this.agent.appendMessage({ role: "custom", customType: "ttsr-injection", content: injection.content, display: false, details, attribution: "agent", timestamp: Date.now(), }); this.sessionManager.appendCustomMessageEntry( "ttsr-injection", injection.content, false, details, "agent", ); this.#markTtsrInjected(details.rules); } try { await this.agent.continue(); } catch { this.#resolveTtsrResume(); } }, { delayMs: 50 }, ); return true; } /** Extract path-like arguments from tool call payload for TTSR glob matching. */ #extractTtsrFilePathsFromArgs(args: unknown): string[] | undefined { if (!args || typeof args !== "object" || Array.isArray(args)) { return undefined; } const rawPaths: string[] = []; for (const [key, value] of Object.entries(args)) { const normalizedKey = key.toLowerCase(); if (typeof value === "string" && (normalizedKey === "path" || normalizedKey.endsWith("path"))) { rawPaths.push(value); continue; } if (Array.isArray(value) && (normalizedKey === "paths" || normalizedKey.endsWith("paths"))) { for (const candidate of value) { if (typeof candidate === "string") { rawPaths.push(candidate); } } } } const normalizedPaths = rawPaths.flatMap(pathValue => this.#normalizeTtsrPathCandidates(pathValue)); if (normalizedPaths.length === 0) { return undefined; } return Array.from(new Set(normalizedPaths)); } /** Convert a path argument into stable relative/absolute candidates for glob checks. */ #normalizeTtsrPathCandidates(rawPath: string): string[] { const trimmed = rawPath.trim(); if (trimmed.length === 0) { return []; } const normalizedInput = trimmed.replaceAll("\\", "/"); const candidates = new Set([normalizedInput]); if (normalizedInput.startsWith("./")) { candidates.add(normalizedInput.slice(2)); } const cwd = this.sessionManager.getCwd(); const absolutePath = path.isAbsolute(trimmed) ? path.normalize(trimmed) : path.resolve(cwd, trimmed); candidates.add(absolutePath.replaceAll("\\", "/")); const relativePath = path.relative(cwd, absolutePath).replaceAll("\\", "/"); if (relativePath && relativePath !== "." && !relativePath.startsWith("../") && relativePath !== "..") { candidates.add(relativePath); } return Array.from(candidates); } /** Extract text content from a message */ #getUserMessageText(message: Message): string { if (message.role !== "user") return ""; const content = message.content; if (typeof content === "string") return content; const textBlocks = content.filter(c => c.type === "text"); const text = textBlocks.map(c => (c as TextContent).text).join(""); if (text.length > 0) return text; const hasImages = content.some(c => c.type === "image"); return hasImages ? "[Image]" : ""; } /** 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; } #resetStreamingEditState(): void { this.#streamingEditAbortTriggered = false; this.#streamingEditCheckedLineCounts.clear(); this.#streamingEditPrecheckedToolCallIds.clear(); this.#streamingEditFileCache.clear(); } #getStreamingEditToolCall(event: AgentEvent): | { toolCall: ToolCall; path: string; resolvedPath: string; diff?: string; op?: string; rename?: string; } | undefined { if (event.type !== "message_update") return undefined; if (event.message.role !== "assistant") return undefined; const contentIndex = event.assistantMessageEvent.contentIndex ?? 0; const messageContent = event.message.content; if (!Array.isArray(messageContent) || contentIndex < 0 || contentIndex >= messageContent.length) { return undefined; } const toolCall = messageContent[contentIndex] as ToolCall; if (toolCall.name !== "edit") return undefined; const args = toolCall.arguments; if (!args || typeof args !== "object" || Array.isArray(args)) return undefined; if ("old_text" in args || "new_text" in args) return undefined; const path = typeof args.path === "string" ? args.path : undefined; if (!path) return undefined; // `local://` URLs (e.g. local://PLAN.md for plan-mode) resolve to a real // on-disk artifacts path; pre-caching works as long as we ask the // local-protocol handler. Other internal-scheme URLs (agent://, skill://, // rule://, mcp://, artifact://) have no stable filesystem representation; // skip pre-cache entirely for those — the edit tool itself will reject // them through its normal dispatch path. const resolvedPath = this.#resolveSessionFsPath(path); if (resolvedPath === undefined) return undefined; return { toolCall, path, resolvedPath, diff: typeof args.diff === "string" ? args.diff : undefined, op: typeof args.op === "string" ? args.op : undefined, rename: typeof args.rename === "string" ? args.rename : undefined, }; } #lastStreamingEditToolCallId: string | undefined; #abortStreamingEditForAutoGeneratedPath(toolCall: ToolCall, path: string, resolvedPath: string): void { if (this.#lastStreamingEditToolCallId === toolCall.id) return; this.#lastStreamingEditToolCallId = toolCall.id; void assertEditableFile(resolvedPath, path).catch(err => { // peekFile and other I/O can reject with ENOENT, etc. Only ToolError means // auto-generated detection; other failures are left for the edit tool. if (!(err instanceof ToolError)) return; if (this.#lastStreamingEditToolCallId !== toolCall.id) return; if (!this.#streamingEditAbortTriggered) { this.#streamingEditAbortTriggered = true; logger.warn("Streaming edit aborted due to auto-generated file guard", { toolCallId: toolCall.id, path, }); this.agent.abort(); } }); } #preCacheStreamingEditFile(event: AgentEvent): void { if (this.#streamingEditAbortTriggered) return; if (event.type !== "message_update") return; const assistantEvent = event.assistantMessageEvent; if ( assistantEvent.type !== "toolcall_start" && assistantEvent.type !== "toolcall_delta" && assistantEvent.type !== "toolcall_end" ) { return; } const streamingEdit = this.#getStreamingEditToolCall(event); if (!streamingEdit) return; // The auto-generated guard runs unconditionally: editing a generated file // is never the user's intent, and the cost of a false-positive abort is one // wasted turn vs. silently corrupting a regenerated source. const shouldCheckAutoGenerated = !streamingEdit.toolCall.id || !this.#streamingEditPrecheckedToolCallIds.has(streamingEdit.toolCall.id); if (shouldCheckAutoGenerated) { if (streamingEdit.toolCall.id) { this.#streamingEditPrecheckedToolCallIds.add(streamingEdit.toolCall.id); } this.#abortStreamingEditForAutoGeneratedPath( streamingEdit.toolCall, streamingEdit.path, streamingEdit.resolvedPath, ); } // File-cache priming feeds #maybeAbortStreamingEdit's removed-lines check, // which is the optional patch-preview verification gated by // edit.streamingAbort. Skip the read when the setting is off. if (this.settings.get("edit.streamingAbort")) { this.#ensureFileCache(streamingEdit.resolvedPath); } } #ensureFileCache(resolvedPath: string): void { if (this.#streamingEditFileCache.has(resolvedPath)) return; try { const rawText = fs.readFileSync(resolvedPath, "utf-8"); const { text } = stripBom(rawText); this.#streamingEditFileCache.set(resolvedPath, normalizeToLF(text)); } catch { // Don't cache on read errors (including ENOENT) - let the edit tool handle them } } /** Invalidate cache for a file after an edit completes to prevent stale data */ #invalidateFileCacheForPath(filePath: string): void { const resolvedPath = this.#resolveSessionFsPath(filePath); if (resolvedPath === undefined) return; this.#streamingEditFileCache.delete(resolvedPath); } /** * Resolve a path supplied to a tool to a real filesystem path. * * - `local://` URLs route through the local-protocol handler so they map * onto the session's on-disk artifacts directory; pre-caching, ENOENT * handling, and post-edit invalidation all work normally. * - Other internal-scheme URLs (agent://, skill://, rule://, mcp://, * artifact://) have no stable filesystem path; this returns `undefined` * so callers skip filesystem-only operations. * - Cwd-relative and absolute paths resolve via `resolveToCwd`. */ #resolveSessionFsPath(filePath: string): string | undefined { const normalized = normalizeLocalScheme(filePath); if (normalized.startsWith("local:")) { return resolveLocalUrlToPath(normalized, this.#localProtocolOptions()); } if ( normalized.startsWith("agent://") || normalized.startsWith("skill://") || normalized.startsWith("rule://") || normalized.startsWith("mcp://") || normalized.startsWith("artifact://") ) { return undefined; } return resolveToCwd(normalized, this.sessionManager.getCwd()); } #localProtocolOptions(): LocalProtocolOptions { return { getArtifactsDir: () => this.sessionManager.getArtifactsDir(), getSessionId: () => this.sessionManager.getSessionId(), }; } #maybeAbortStreamingEdit(event: AgentEvent): void { if (!this.settings.get("edit.streamingAbort")) return; if (this.#streamingEditAbortTriggered) return; if (event.type !== "message_update") return; const assistantEvent = event.assistantMessageEvent; if (assistantEvent.type !== "toolcall_end" && assistantEvent.type !== "toolcall_delta") return; const streamingEdit = this.#getStreamingEditToolCall(event); if (!streamingEdit?.toolCall.id) return; const { toolCall, path, resolvedPath, diff, op, rename } = streamingEdit; if (!diff) return; if (op && op !== "update") return; if (!diff.includes("\n")) return; const lastNewlineIndex = diff.lastIndexOf("\n"); if (lastNewlineIndex < 0) return; const diffForCheck = diff.endsWith("\n") ? diff : diff.slice(0, lastNewlineIndex + 1); if (diffForCheck.trim().length === 0) return; let normalizedDiff = normalizeDiff(diffForCheck.replace(/\r/g, "")); if (!normalizedDiff) return; // Deobfuscate the diff so removed lines match real file content if (this.#obfuscator) normalizedDiff = this.#obfuscator.deobfuscate(normalizedDiff); if (!normalizedDiff) return; const lines = normalizedDiff.split("\n"); const hasChangeLine = lines.some(line => line.startsWith("+") || line.startsWith("-")); if (!hasChangeLine) return; const lineCount = lines.length; const lastChecked = this.#streamingEditCheckedLineCounts.get(toolCall.id); if (lastChecked !== undefined && lineCount <= lastChecked) return; this.#streamingEditCheckedLineCounts.set(toolCall.id, lineCount); const removedLines = lines .filter(line => line.startsWith("-") && !line.startsWith("--- ")) .map(line => line.slice(1)); if (removedLines.length > 0) { let cachedContent = this.#streamingEditFileCache.get(resolvedPath); if (cachedContent === undefined) { this.#ensureFileCache(resolvedPath); cachedContent = this.#streamingEditFileCache.get(resolvedPath); } if (cachedContent !== undefined) { const missing = removedLines.find(line => !cachedContent.includes(normalizeToLF(line))); if (missing) { this.#streamingEditAbortTriggered = true; logger.warn("Streaming edit aborted due to patch preview failure", { toolCallId: toolCall.id, path, error: `Failed to find expected lines in ${path}:\n${missing}`, }); this.agent.abort(); } return; } if (assistantEvent.type === "toolcall_delta") return; void this.#checkRemovedLinesAsync(toolCall.id, path, resolvedPath, removedLines); return; } if (assistantEvent.type === "toolcall_delta") return; void this.#checkPreviewPatchAsync(toolCall.id, path, rename, normalizedDiff); } async #checkRemovedLinesAsync( toolCallId: string, path: string, resolvedPath: string, removedLines: string[], ): Promise { if (this.#streamingEditAbortTriggered) return; try { const { text } = stripBom(await Bun.file(resolvedPath).text()); const normalizedContent = normalizeToLF(text); const missing = removedLines.find(line => !normalizedContent.includes(normalizeToLF(line))); if (missing) { this.#streamingEditAbortTriggered = true; logger.warn("Streaming edit aborted due to patch preview failure", { toolCallId, path, error: `Failed to find expected lines in ${path}:\n${missing}`, }); this.agent.abort(); } } catch (err) { // Ignore ENOENT (file not found) - let the edit tool handle missing files // Also ignore other errors during async fallback if (!isEnoent(err)) { // Log unexpected errors but don't abort } } } async #checkPreviewPatchAsync( toolCallId: string, path: string, rename: string | undefined, normalizedDiff: string, ): Promise { if (this.#streamingEditAbortTriggered) return; try { await previewPatch( { path, op: "update", rename, diff: normalizedDiff }, { cwd: this.sessionManager.getCwd(), allowFuzzy: this.settings.get("edit.fuzzyMatch"), fuzzyThreshold: this.settings.get("edit.fuzzyThreshold"), }, ); } catch (error) { if (error instanceof ParseError) return; this.#streamingEditAbortTriggered = true; logger.warn("Streaming edit aborted due to patch preview failure", { toolCallId, path, error: error instanceof Error ? error.message : String(error), }); this.agent.abort(); } } /** 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" }); } else if (event.type === "agent_end") { await this.#extensionRunner.emit({ type: "agent_end", messages: event.messages }); } 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, }); } else if (event.type === "auto_retry_end") { await this.#extensionRunner.emit({ type: "auto_retry_end", success: event.success, attempt: event.attempt, finalError: event.finalError, }); } 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(); } /** * 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), ); } #rekeyHindsightMemoryForCurrentSessionId(): void { if (this.settings.get("memory.backend") !== "hindsight") return; const sid = this.agent.sessionId; if (!sid) return; this.getHindsightSessionState()?.setSessionId(sid); } #rekeyMnemopiMemoryForCurrentSessionId(): void { if (this.settings.get("memory.backend") !== "mnemopi") return; const sid = this.agent.sessionId; if (!sid) return; this.getMnemopiSessionState()?.setSessionId(sid); } /** New session file: reset auto-recall / retain-threshold counters for the new transcript. */ #resetHindsightConversationTrackingIfHindsight(): void { if (this.settings.get("memory.backend") !== "hindsight") return; const state = this.getHindsightSessionState(); if (!state || state.aliasOf) return; state.resetConversationTracking(); } #resetMnemopiConversationTrackingIfMnemopi(): void { if (this.settings.get("memory.backend") !== "mnemopi") return; const state = this.getMnemopiSessionState(); if (!state || state.aliasOf) return; state.resetConversationTracking(); } /** 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; } /** * Synchronously mark the session as disposing so new work is rejected * immediately: Python/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.#pendingIrcAsides = []; this.yieldQueue.clear(); this.agent.setAsideMessageProvider(undefined); this.#evalExecutionDisposing = true; } /** * Remove all listeners, flush pending writes, and disconnect from agent. * Call this when completely done with the session. */ async dispose(): Promise { this.beginDispose(); try { if (this.#extensionRunner?.hasHandlers("session_shutdown")) { await this.#extensionRunner.emit({ type: "session_shutdown" }); } } catch (error) { logger.warn("Failed to emit session_shutdown event", { error: String(error) }); } // Abort post-prompt work so the drain below can complete. Without this, a // deferred-handoff task that has already advanced into // `await this.handoff(...) → generateHandoff(...)` keeps awaiting a live LLM stream // — Promise.allSettled() in #cancelPostPromptTasks then waits forever, freezing // /exit and Ctrl+C-double-tap. The post-prompt task's own AbortSignal does not // propagate into the inner handoff/compaction controllers, so we abort them // explicitly. agent.abort() is needed for an agent.continue() that may have // raced the deferred handoff (its streaming loop is awaited by the wrapper IIFE). // // Tool work (bash/eval/python) is NOT aborted here — those have their own // dispose paths and shared kernels are contractually allowed to survive a // session's dispose. this.abortRetry(); this.abortCompaction(); this.agent.abort(); await this.#cancelPostPromptTasks(); // Cancel jobs this agent registered so a subagent's teardown doesn't // leak its background bash/task work into the parent's manager. Only // the session that owns the manager goes on to dispose it (which itself // nukes any leftover jobs and pending deliveries). this.#cancelOwnAsyncJobs(); const ownedAsyncManager = this.#ownedAsyncJobManager; if (ownedAsyncManager) { const drained = await ownedAsyncManager.dispose({ timeoutMs: 3_000 }); const deliveryState = ownedAsyncManager.getDeliveryState(); if (drained === false && deliveryState) { logger.warn("Async job completion deliveries still pending during dispose", { ...deliveryState }); } if (AsyncJobManager.instance() === ownedAsyncManager) { AsyncJobManager.setInstance(undefined); } } const pythonExecutionsSettled = await this.#prepareEvalExecutionsForDispose(); if (!pythonExecutionsSettled) { logger.warn( "Detaching retained Python kernel ownership during dispose while Python execution is still active", ); } await disposeKernelSessionsByOwner(this.#evalKernelOwnerId); await shutdownTinyTitleClient(); this.#releasePowerAssertion(); await this.sessionManager.close(); this.#closeAllProviderSessions("dispose"); // Flush the retain queue BEFORE clearing the session's pointer so // `HindsightRetainQueue.#doFlush` still sees `session.getHindsightSessionState() === state`. // Reversed, the spliced batch survives just long enough to fail the // identity check and get dropped with a `session vanished` warning. const hindsightState = this.getHindsightSessionState(); await hindsightState?.flushRetainQueue(); this.setHindsightSessionState(undefined); hindsightState?.dispose(); const mnemopiState = setMnemopiSessionState(this, undefined); await mnemopiState?.dispose(); this.#disconnectFromAgent(); if (this.#unsubscribeAppendOnly) { this.#unsubscribeAppendOnly(); this.#unsubscribeAppendOnly = 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.#rekeyHindsightMemoryForCurrentSessionId(); this.#rekeyMnemopiMemoryForCurrentSessionId(); 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; } /** Effective thinking level applied to the agent (the resolved level when `auto`). */ get thinkingLevel(): ThinkingLevel | undefined { return this.#thinkingLevel; } /** The selector the user configured: `auto` when auto mode is active, else the effective level. */ configuredThinkingLevel(): ConfiguredThinkingLevel | undefined { return this.#autoThinking ? AUTO_THINKING : this.#thinkingLevel; } /** True when `auto` thinking mode is active. */ get isAutoThinking(): boolean { return this.#autoThinking; } /** The level `auto` resolved to for the current turn (undefined until classified). */ autoResolvedThinkingLevel(): Effort | undefined { return this.#autoResolvedLevel; } get serviceTier(): ServiceTier | undefined { return this.agent.serviceTier; } /** Whether agent is currently streaming a response */ get isStreaming(): boolean { return this.agent.state.isStreaming || this.#promptInFlightCount > 0; } /** Wait until streaming and deferred recovery work are fully settled. */ async waitForIdle(): Promise { await this.agent.waitForIdle(); await this.#waitForPostPromptRecovery(); } 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 assistant message in agent state. */ getLastAssistantMessage(): AssistantMessage | undefined { return 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.#retryAttempt; } #collectDiscoverableMCPToolsFromRegistry(): Map { const mcpTools = filterBySource(collectDiscoverableTools(this.#toolRegistry.values()), "mcp"); return new Map(mcpTools.map(tool => [tool.name, tool] as const)); } #setDiscoverableMCPTools(discoverableMCPTools: Map): void { this.#discoverableMCPTools = discoverableMCPTools; this.#invalidateDiscoveryCaches(); } /** Single point for invalidating cached discovery indices. Call after any change that can * affect which tools should be discoverable: registry mutations (refreshMCPTools, * refreshRpcHostTools) or active-tool mutations (#applyActiveToolsByName). */ #invalidateDiscoveryCaches(): void { this.#discoverableToolSearchIndex = null; } #filterSelectableMCPToolNames(toolNames: Iterable): string[] { return Array.from(toolNames).filter(name => this.#discoverableMCPTools.has(name) && this.#toolRegistry.has(name)); } #getConfiguredDefaultSelectedMCPToolNames(): string[] { return this.#filterSelectableMCPToolNames([ ...this.#defaultSelectedMCPToolNames, ...selectDiscoverableToolNamesByServer( this.#discoverableMCPTools.values(), this.#defaultSelectedMCPServerNames, ), ]); } #pruneSelectedMCPToolNames(): void { this.#selectedMCPToolNames = new Set(this.#filterSelectableMCPToolNames(this.#selectedMCPToolNames)); } #selectedMCPToolNamesMatch(left: string[], right: string[]): boolean { return left.length === right.length && left.every((name, index) => name === right[index]); } #rememberSessionDefaultSelectedMCPToolNames( sessionFile: string | null | undefined, toolNames: Iterable, ): void { if (!sessionFile) return; this.#sessionDefaultSelectedMCPToolNames.set( path.resolve(sessionFile), this.#filterSelectableMCPToolNames(toolNames), ); } #getSessionDefaultSelectedMCPToolNames(sessionFile: string | null | undefined): string[] { if (!sessionFile) return []; return this.#sessionDefaultSelectedMCPToolNames.get(path.resolve(sessionFile)) ?? []; } #persistSelectedMCPToolNamesIfChanged(previousSelectedMCPToolNames: string[]): void { if (!this.#mcpDiscoveryEnabled) return; const nextSelectedMCPToolNames = this.getSelectedMCPToolNames(); if (this.#selectedMCPToolNamesMatch(previousSelectedMCPToolNames, nextSelectedMCPToolNames)) { return; } this.sessionManager.appendMCPToolSelection(nextSelectedMCPToolNames); } #getActiveNonMCPToolNames(): string[] { return this.getActiveToolNames().filter(name => !isMCPToolName(name) && this.#toolRegistry.has(name)); } /** * Get the names of currently active tools. * Returns the names of tools currently set on the agent. */ getActiveToolNames(): string[] { return this.agent.state.tools.map(t => t.name); } /** Whether the edit tool is registered in this session. */ get hasEditTool(): boolean { return this.#toolRegistry.has("edit"); } /** * Get a tool by name from the registry. */ getToolByName(name: string): AgentTool | undefined { return this.#toolRegistry.get(name); } /** * Get all configured tool names (built-in via --tools or default, plus custom tools). */ getAllToolNames(): string[] { return Array.from(this.#toolRegistry.keys()); } #getEditModeSession() { return { settings: this.settings, getActiveModelString: () => (this.model ? formatModelString(this.model) : undefined), } as const; } #resolveActiveEditMode(): EditMode { return resolveEditMode(this.#getEditModeSession()); } /** * Model key (`provider/id`) currently surfaced in the system prompt, or * undefined when the model is unset or `includeModelInPrompt` is disabled. */ #currentPromptModelKey(): string | undefined { if (!this.settings.get("includeModelInPrompt")) return undefined; return this.model ? formatModelString(this.model) : undefined; } async #syncAfterModelChange(previousEditMode: EditMode): Promise { const currentEditMode = this.#resolveActiveEditMode(); const editModeChanged = previousEditMode !== currentEditMode && this.getActiveToolNames().includes("edit"); // The system prompt may surface the active model; a switch makes the cached prompt stale. const modelChanged = this.#currentPromptModelKey() !== this.#promptModelKey; if (editModeChanged || modelChanged) { await this.refreshBaseSystemPrompt(); } } isMCPDiscoveryEnabled(): boolean { return this.#mcpDiscoveryEnabled; } /** * Flip MCP discovery on after deferred discovery learns the real tool count. * UI sessions resolve `tools.discoveryMode: "auto"` before MCP servers * connect, so a large MCP toolset discovered later must be able to upgrade * the session from the force-activate path to the discovery path. One-way: * discovery is never downgraded mid-session. */ enableMCPDiscovery(): void { this.#mcpDiscoveryEnabled = true; } getSelectedMCPToolNames(): string[] { if (!this.#mcpDiscoveryEnabled) { return this.getActiveToolNames().filter(name => isMCPToolName(name) && this.#toolRegistry.has(name)); } return this.#filterSelectableMCPToolNames(this.#selectedMCPToolNames); } async activateDiscoveredMCPTools(toolNames: string[]): Promise { const nextSelectedMCPToolNames = new Set(this.#selectedMCPToolNames); const activated: string[] = []; for (const name of toolNames) { if (!isMCPToolName(name) || !this.#discoverableMCPTools.has(name) || !this.#toolRegistry.has(name)) { continue; } nextSelectedMCPToolNames.add(name); activated.push(name); } if (activated.length === 0) { return []; } const nextActive = [ ...this.#getActiveNonMCPToolNames(), ...this.#filterSelectableMCPToolNames(nextSelectedMCPToolNames), ]; await this.setActiveToolsByName(nextActive); return [...new Set(activated)]; } // ── Generic tool discovery (covers built-in + MCP + extension) ──────────── /** Resolve effective discovery mode from the current registry size. */ #resolveEffectiveDiscoveryMode(): "off" | "mcp-only" | "all" { const mode = resolveEffectiveToolDiscoveryMode( this.settings, countToolsForAutoDiscovery(this.#toolRegistry.keys()), ); if (mode !== "off") return mode; return this.#mcpDiscoveryEnabled ? "mcp-only" : "off"; } isToolDiscoveryEnabled(): boolean { return this.#resolveEffectiveDiscoveryMode() !== "off"; } getDiscoverableTools(filter?: { source?: DiscoverableTool["source"] }): DiscoverableTool[] { // For "all" mode we combine built-in registry entries + MCP tools. // For "mcp-only" mode we only return MCP tools. const mode = this.#resolveEffectiveDiscoveryMode(); const activeNames = new Set(this.getActiveToolNames()); const mcpTools = Array.from(this.#discoverableMCPTools.values()).filter(t => !activeNames.has(t.name)); const builtinTools: DiscoverableTool[] = mode === "all" ? this.#collectDiscoverableBuiltinTools() : []; const allTools = [...builtinTools, ...mcpTools]; return filter?.source ? allTools.filter(t => t.source === filter.source) : allTools; } /** Collect built-in tools the model can discover via search_tool_bm25. Restricted to tool * definitions whose `loadMode === "discoverable"`. This keeps hidden/internal tools * (resolve, yield, report_finding, report_tool_issue) out of the index * and avoids mislabeling extension/custom default-inactive tools as built-ins. */ #collectDiscoverableBuiltinTools(): DiscoverableTool[] { const activeNames = new Set(this.getActiveToolNames()); const result: DiscoverableTool[] = []; for (const tool of this.#toolRegistry.values()) { if (tool.loadMode !== "discoverable") continue; if (activeNames.has(tool.name)) continue; const collected = collectDiscoverableTools([tool], { source: "builtin" }); result.push(...collected); } return result; } getDiscoverableToolSearchIndex(): DiscoverableToolSearchIndex { if (!this.#discoverableToolSearchIndex) { this.#discoverableToolSearchIndex = buildDiscoverableToolSearchIndex(this.getDiscoverableTools()); } return this.#discoverableToolSearchIndex; } /** Invalidate the generic search index cache (call after tool set changes). * Delegates to {@link #invalidateDiscoveryCaches} so all discovery-related caches stay in sync. */ #invalidateDiscoverableToolSearchIndex(): void { this.#invalidateDiscoveryCaches(); } getSelectedDiscoveredToolNames(): string[] { // Union of MCP-selected and generic non-MCP selected. Non-MCP selections are only // selected while they are still active; otherwise BM25 must be able to rediscover them. const activeNames = new Set(this.getActiveToolNames()); const mcpSelected = this.getSelectedMCPToolNames(); const nonMcpSelected = Array.from(this.#selectedDiscoveredToolNames).filter( name => activeNames.has(name) && this.#toolRegistry.has(name) && !isMCPToolName(name), ); return [...new Set([...mcpSelected, ...nonMcpSelected])]; } async activateDiscoveredTools(toolNames: string[]): Promise { const mcpNames = toolNames.filter(isMCPToolName); const nonMcpNames = toolNames.filter(name => !isMCPToolName(name)); const activated: string[] = []; // Activate MCP tools via existing path if (mcpNames.length > 0) { const activatedMcp = await this.activateDiscoveredMCPTools(mcpNames); activated.push(...activatedMcp); } // Activate non-MCP tools (built-ins that are in the registry but not currently active) if (nonMcpNames.length > 0) { const currentActiveNames = new Set(this.getActiveToolNames()); const newlyAdded: string[] = []; for (const name of nonMcpNames) { if (this.#toolRegistry.has(name) && !currentActiveNames.has(name)) { newlyAdded.push(name); this.#selectedDiscoveredToolNames.add(name); activated.push(name); } } if (newlyAdded.length > 0) { const nextActive = [...this.getActiveToolNames(), ...newlyAdded]; await this.setActiveToolsByName(nextActive); this.#invalidateDiscoverableToolSearchIndex(); } } return [...new Set(activated)]; } /** * Wrap a tool with a permission-gate proxy when an ACP client is connected. * Only wraps tools whose name is in PERMISSION_REQUIRED_TOOLS and only when * the bridge exposes `requestPermission`. No-ops for all other cases. * * When the user has explicitly opted into `yolo` / auto-approve behavior (via * the SDK/CLI `autoApprove` flag or a configured `tools.approvalMode: yolo`), * skips the gate unless the per-tool policy explicitly requires a prompt or * deny. The schema default is also `yolo`, so an explicit configuration or * explicit session flag is required: default-config ACP sessions keep the * client-side permission gate. */ #wrapToolForAcpPermission(tool: T): T { const bridge = this.#clientBridge; // Match the capability+method gating pattern used by read/write/bash. if (!bridge?.capabilities.requestPermission || !bridge.requestPermission) return tool; if (!PERMISSION_REQUIRED_TOOLS.has(tool.name)) return tool; // Skip the gate only on explicit yolo opt-in; honour per-tool policies // that require a prompt or deny (matching the normal approval wrapper). if (this.#isExplicitAutoApproveMode()) { const userPolicies = (this.settings.get("tools.approval") ?? {}) as Record; const toolPolicy = userPolicies[tool.name]; if (!toolPolicy || toolPolicy === "allow") return tool; } return new Proxy(tool, { get: (target, prop) => { if (prop !== "execute") return Reflect.get(target, prop, target); return async ( toolCallId: string, args: unknown, signal: AbortSignal | undefined, onUpdate: never, ctx: never, ) => { const permissionIntent = getPermissionIntent(target.name, args); if (!permissionIntent) { return await target.execute(toolCallId, args as never, signal, onUpdate, ctx); } const command = target.name === "bash" && args && typeof args === "object" && !Array.isArray(args) ? getStringProperty(args as Record, "command") : undefined; const commandContent = command ? [{ type: "content" as const, content: { type: "text" as const, text: `$ ${command}` } }] : undefined; // Short-circuit on persisted decisions. const persisted = this.#acpPermissionDecisions.get(permissionIntent.cacheKey); if (persisted === "allow_always") { return await target.execute(toolCallId, args as never, signal, onUpdate, ctx); } if (persisted === "reject_always") { throw new ToolError(`Tool call rejected by user (preference)`); } if (signal?.aborted) { throw new ToolAbortError("Permission request cancelled"); } type PermissionRaceResult = | { kind: "permission"; outcome: ClientBridgePermissionOutcome } | { kind: "aborted" }; const { promise: abortPromise, resolve: resolveAbort } = Promise.withResolvers(); const onAbort = () => resolveAbort({ kind: "aborted" }); signal?.addEventListener("abort", onAbort, { once: true }); let raced: PermissionRaceResult; try { const permissionPromise = bridge.requestPermission!( { toolCallId, toolName: target.name, title: permissionIntent.title, ...(target.name === "bash" ? { kind: "execute" } : {}), status: "pending", rawInput: args, ...(commandContent ? { content: commandContent } : {}), locations: extractPermissionLocations( args, this.sessionManager.getCwd(), permissionIntent.paths, ), }, PERMISSION_OPTIONS, signal, ).then(outcome => ({ kind: "permission" as const, outcome })); raced = await Promise.race([permissionPromise, abortPromise]); } finally { signal?.removeEventListener("abort", onAbort); } if (raced.kind === "aborted" || signal?.aborted) { throw new ToolAbortError("Permission request cancelled"); } const outcome = raced.outcome; if (outcome.outcome === "cancelled") { throw new ToolAbortError("Permission request cancelled"); } const selectedOption = PERMISSION_OPTIONS_BY_ID.get(outcome.optionId); if (!selectedOption) { throw new ToolError(`Tool permission response used unknown option ID: ${outcome.optionId}`); } if (selectedOption.kind === "allow_always") { this.#acpPermissionDecisions.set(permissionIntent.cacheKey, "allow_always"); } else if (selectedOption.kind === "reject_always") { this.#acpPermissionDecisions.set(permissionIntent.cacheKey, "reject_always"); } if (selectedOption.kind === "reject_once" || selectedOption.kind === "reject_always") { throw new ToolError(`Tool call rejected by user (${target.name})`); } return await target.execute(toolCallId, args as never, signal, onUpdate, ctx); }; }, }) as T; } #isExplicitAutoApproveMode(): boolean { return ( this.#autoApprove || (this.settings.isConfigured("tools.approvalMode") && this.settings.get("tools.approvalMode") === "yolo") ); } async #applyActiveToolsByName( toolNames: string[], options?: { persistMCPSelection?: boolean; previousSelectedMCPToolNames?: string[] }, ): Promise { toolNames = [...new Set(toolNames.map(name => name.toLowerCase()))]; const previousSelectedMCPToolNames = options?.previousSelectedMCPToolNames ?? this.getSelectedMCPToolNames(); const tools: AgentTool[] = []; const validToolNames: string[] = []; for (const name of toolNames) { const tool = this.#toolRegistry.get(name); if (tool) { tools.push(this.#wrapToolForAcpPermission(tool)); validToolNames.push(name); } } // Auto-QA tool must survive any runtime tool-set mutation. if (isAutoQaEnabled(this.settings) && !validToolNames.includes("report_tool_issue")) { const qaTool = this.#toolRegistry.get("report_tool_issue"); if (qaTool) { tools.push(this.#wrapToolForAcpPermission(qaTool)); validToolNames.push("report_tool_issue"); } } if (this.#mcpDiscoveryEnabled) { this.#selectedMCPToolNames = new Set( validToolNames.filter( name => isMCPToolName(name) && this.#discoverableMCPTools.has(name) && this.#toolRegistry.has(name), ), ); } const activeNameSet = new Set(validToolNames); for (const name of Array.from(this.#selectedDiscoveredToolNames)) { if (!activeNameSet.has(name) || isMCPToolName(name) || !this.#toolRegistry.has(name)) { this.#selectedDiscoveredToolNames.delete(name); } } this.agent.setTools(tools); // Active tool set changed → discoverable tool list (which excludes already-active tools) // is now stale. Invalidate before any prompt-template hook reads the discovery list. this.#invalidateDiscoveryCaches(); // Rebuild base system prompt with new tool set, but only when the tool set // actually changed. MCP servers can reconnect at arbitrary times and call // `refreshMCPTools` -> `#applyActiveToolsByName` even though the resulting // tool list is byte-identical. Skipping the rebuild keeps the system prompt // stable, which is required for Anthropic prompt caching to keep hitting. if (this.#rebuildSystemPrompt) { const signature = this.#computeAppliedToolSignature(validToolNames, tools); if (signature !== this.#lastAppliedToolSignature) { const built = await this.#rebuildSystemPrompt(validToolNames, this.#toolRegistry); this.#baseSystemPrompt = built.systemPrompt; this.agent.setSystemPrompt(this.#baseSystemPrompt); this.#lastAppliedToolSignature = signature; this.#promptModelKey = this.#currentPromptModelKey(); } } if (options?.persistMCPSelection !== false) { this.#persistSelectedMCPToolNamesIfChanged(previousSelectedMCPToolNames); } } /** * Reload the SSH tool from disk-backed capability discovery and make the * refreshed definition visible to the next model call without restarting. */ async refreshSshTool(options?: { activateIfAvailable?: boolean }): Promise { resetCapabilities(); if (!this.#reloadSshTool) return; const previousSshTool = this.#toolRegistry.get("ssh"); const previousActiveToolNames = this.getActiveToolNames(); const hadSshTool = previousSshTool !== undefined; const wasActive = previousActiveToolNames.includes("ssh"); const previousHostNames = previousSshTool && "hostNames" in previousSshTool && Array.isArray(previousSshTool.hostNames) ? [...previousSshTool.hostNames] : []; const candidateHostNames = new Set(previousHostNames); const capability = await loadCapability<{ name: string }>("ssh", { cwd: this.sessionManager.getCwd() }); for (const host of capability.items) { if (typeof host?.name === "string") { candidateHostNames.add(host.name); } } await invalidateHostMetadata(candidateHostNames); const sshAllowed = this.#requestedToolNames === undefined || this.#requestedToolNames.has("ssh"); const refreshedTool = await this.#reloadSshTool(); if (refreshedTool) { this.#toolRegistry.set(refreshedTool.name, refreshedTool); } else { this.#toolRegistry.delete("ssh"); this.#selectedDiscoveredToolNames.delete("ssh"); } const nextActive = previousActiveToolNames.filter(name => name !== "ssh" && this.#toolRegistry.has(name)); if (refreshedTool && sshAllowed && (wasActive || (options?.activateIfAvailable && !hadSshTool))) { nextActive.push(refreshedTool.name); } await this.#applyActiveToolsByName(nextActive); } /** * Set active tools by name. * Only tools in the registry can be enabled. Unknown tool names are ignored. * Also rebuilds the system prompt to reflect the new tool set. * Changes take effect before the next model call. */ async setActiveToolsByName(toolNames: string[]): Promise { await this.#applyActiveToolsByName(toolNames); } async #restoreMCPSelectionsForSessionContext( sessionContext: SessionContext, options?: { fallbackSelectedMCPToolNames?: Iterable }, ): Promise { if (!this.#mcpDiscoveryEnabled) return; const nextActiveNonMCPToolNames = this.#getActiveNonMCPToolNames(); const fallbackSelectedMCPToolNames = options?.fallbackSelectedMCPToolNames ?? this.#getConfiguredDefaultSelectedMCPToolNames(); const restoredMCPToolNames = sessionContext.hasPersistedMCPToolSelection ? this.#filterSelectableMCPToolNames(sessionContext.selectedMCPToolNames) : this.#filterSelectableMCPToolNames(fallbackSelectedMCPToolNames); this.#rememberSessionDefaultSelectedMCPToolNames( this.sessionFile, this.#getConfiguredDefaultSelectedMCPToolNames(), ); await this.#applyActiveToolsByName([...nextActiveNonMCPToolNames, ...restoredMCPToolNames], { persistMCPSelection: false, }); } /** Rebuild the base system prompt using the current active tool set. */ async refreshBaseSystemPrompt(): Promise { if (!this.#rebuildSystemPrompt) return; const activeToolNames = this.getActiveToolNames(); const built = await this.#rebuildSystemPrompt(activeToolNames, this.#toolRegistry); this.#baseSystemPrompt = built.systemPrompt; this.agent.setSystemPrompt(this.#baseSystemPrompt); this.#promptModelKey = this.#currentPromptModelKey(); // Refresh the cached signature so a subsequent `#applyActiveToolsByName` with // the same tool set does not re-rebuild on top of the explicit refresh we // just performed (and conversely, a different set forces a fresh rebuild). const activeTools = activeToolNames .map(name => this.#toolRegistry.get(name)) .filter((tool): tool is AgentTool => tool != null); this.#lastAppliedToolSignature = this.#computeAppliedToolSignature(activeToolNames, activeTools); } async #buildSystemPromptForAgentStart(promptText: string): Promise { const backend = await resolveMemoryBackend(this.settings); if (!backend.beforeAgentStartPrompt) return this.#baseSystemPrompt; try { const injected = await backend.beforeAgentStartPrompt(this, promptText); if (!injected) return this.#baseSystemPrompt; return [...this.#baseSystemPrompt, injected]; } catch (err) { logger.debug("Memory backend beforeAgentStartPrompt failed", { backend: backend.id, error: String(err), }); return this.#baseSystemPrompt; } } /** * Compose a stable signature for the inputs that `rebuildSystemPrompt` reads. * Two calls producing identical signatures are guaranteed to produce identical * system prompt bytes, so the rebuild can be skipped. * * The signature covers: * 1. Active tool names in order (the prompt renders them in this order). * 2. Active tool labels, descriptions, and wire-visible names — all are * rendered into the prompt body (see `system-prompt.md` `{{label}}: \`{{name}}\`` * and `toolPromptNames` in `buildSystemPrompt`). The wire name comes from * `tool.customWireName` and overrides the internal name on the model wire * (e.g. `edit` exposes itself as `apply_patch` to GPT-5 in apply_patch mode); * a stale wire name would desync prompt guidance from actual tool routing. * 3. When MCP discovery is on, every registry tool's name+label+description+ * customWireName, since `rebuildSystemPrompt` summarizes discoverable MCP * tools that are not in the active set. * 4. MCP server instructions text (per server), since `rebuildSystemPrompt` * embeds these in the appended prompt under "## MCP Server Instructions". * A server upgrade can change instructions while keeping tools identical. * * Settings-driven tool metadata is covered automatically: built-in tools that * depend on settings expose `description`/`label` via getters (see `TaskTool`, * `SearchToolBm25Tool`, `EditTool`), and the signature reads them live on every * call - so a settings flip that mutates the rendered string differs the signature * the next time `#applyActiveToolsByName` runs. Do not refactor `describeTool` to * cache per-tool strings without preserving this property. * * Inputs NOT covered: tool input schemas; memory instructions read from disk; * and SDK-init-time closure constants in `sdk.ts` (`repeatToolDescriptions`, * `eagerTasks`, `intentField`, `mcpDiscoveryEnabled`, `secretsEnabled`). The * closure-captured ones cannot change at runtime regardless of skip behavior. * For everything else, callers must explicitly call `refreshBaseSystemPrompt()` * after side-effecting changes; see e.g. the memory hooks and * `#syncAfterModelChange`. * * The current calendar date IS covered (appended as a segment) because * `buildSystemPrompt` injects it into the prompt body (`Today is '{{date}}'`). * Without this, a session spanning midnight with only tool-stable MCP * reconnects would keep yesterday's date indefinitely. */ #computeAppliedToolSignature(toolNames: string[], tools: AgentTool[]): string { // Order-preserving join: any reorder must produce a different signature so // the rebuild fires and the new tool list reaches the API. const nameSegment = toolNames.join("\u0001"); const describeTool = (tool: AgentTool): string => `${tool.name}=${tool.label ?? ""}|${tool.description ?? ""}|${tool.customWireName ?? ""}`; const descriptionSegment = tools.map(describeTool).join("\u0002"); let registrySegment = ""; if (this.#mcpDiscoveryEnabled) { // Registry iteration order is not load-bearing for the prompt content, so we // sort to keep the signature insensitive to incidental insertion order. const entries: string[] = []; for (const tool of this.#toolRegistry.values()) { entries.push(describeTool(tool)); } entries.sort(); registrySegment = entries.join("\u0004"); } let instructionsSegment = ""; const serverInstructions = this.#getMcpServerInstructions?.(); if (serverInstructions && serverInstructions.size > 0) { // Sort by server name so transport flap order does not perturb the signature. const entries: string[] = []; for (const [server, instructions] of serverInstructions) { entries.push(`${server}=${instructions}`); } entries.sort(); instructionsSegment = entries.join("\u0006"); } const date = new Date().toISOString().slice(0, 10); return `${nameSegment}\u0003${descriptionSegment}\u0005${registrySegment}\u0007${instructionsSegment}|${date}`; } /** * Replace MCP tools in the registry and recompute the visible MCP tool set immediately. * This allows /mcp add/remove/reauth to take effect without restarting the session. * * @param mcpTools The new MCP tools to register. * @param options.activateAll When true, force-activates every newly registered MCP tool * regardless of prior selection state. Used when an ACP client provisions MCP servers * for a session where MCP discovery is disabled. */ async refreshMCPTools(mcpTools: CustomTool[], options?: { activateAll?: boolean }): Promise { const previousSelectedMCPToolNames = this.getSelectedMCPToolNames(); const existingNames = Array.from(this.#toolRegistry.keys()); for (const name of existingNames) { if (isMCPToolName(name)) { this.#toolRegistry.delete(name); } } const getCustomToolContext = (): CustomToolContext => ({ sessionManager: this.sessionManager, modelRegistry: this.#modelRegistry, model: this.model, isIdle: () => !this.isStreaming, hasQueuedMessages: () => this.queuedMessageCount > 0, abort: () => { this.agent.abort(); }, }); for (const customTool of mcpTools) { const wrapped = CustomToolAdapter.wrap(customTool, getCustomToolContext) as AgentTool; const finalTool = ( this.#extensionRunner ? new ExtensionToolWrapper(wrapped, this.#extensionRunner) : wrapped ) as AgentTool; this.#toolRegistry.set(finalTool.name, finalTool); } this.#setDiscoverableMCPTools(this.#collectDiscoverableMCPToolsFromRegistry()); this.#pruneSelectedMCPToolNames(); if (!this.buildDisplaySessionContext().hasPersistedMCPToolSelection) { this.#selectedMCPToolNames = new Set([ ...this.#selectedMCPToolNames, ...this.#getConfiguredDefaultSelectedMCPToolNames(), ]); } this.#rememberSessionDefaultSelectedMCPToolNames( this.sessionFile, this.#getConfiguredDefaultSelectedMCPToolNames(), ); if (options?.activateAll) { // Force-activate every newly registered MCP tool. This path is used // when an ACP client provisions MCP servers for a session where MCP // discovery is disabled — without it, getSelectedMCPToolNames() // returns only already-active tools (circular deadlock: tools can // only become active if they're already active). const newMcpNames = mcpTools.map(t => t.name); const nextActive = [...new Set([...this.#getActiveNonMCPToolNames(), ...newMcpNames])]; await this.#applyActiveToolsByName(nextActive, { previousSelectedMCPToolNames }); return; } const nextActive = [...this.#getActiveNonMCPToolNames(), ...this.getSelectedMCPToolNames()]; await this.#applyActiveToolsByName(nextActive, { previousSelectedMCPToolNames }); } /** * Replace RPC host-owned tools and refresh the active tool set before the next model call. */ async refreshRpcHostTools(rpcTools: AgentTool[]): Promise { const nextToolNames = rpcTools.map(tool => tool.name); const uniqueToolNames = new Set(nextToolNames); if (uniqueToolNames.size !== nextToolNames.length) { throw new Error("RPC host tool names must be unique"); } for (const name of uniqueToolNames) { if (this.#toolRegistry.has(name) && !this.#rpcHostToolNames.has(name)) { throw new Error(`RPC host tool "${name}" conflicts with an existing tool`); } } const previousRpcHostToolNames = new Set(this.#rpcHostToolNames); const previousActiveToolNames = this.getActiveToolNames(); for (const name of previousRpcHostToolNames) { this.#toolRegistry.delete(name); } this.#rpcHostToolNames.clear(); for (const tool of rpcTools) { const finalTool = ( this.#extensionRunner ? new ExtensionToolWrapper(tool, this.#extensionRunner) : tool ) as AgentTool; this.#toolRegistry.set(finalTool.name, finalTool); this.#rpcHostToolNames.add(finalTool.name); } // Registry contents changed — invalidate discovery caches so the next BM25 lookup sees // the new RPC-host tool set. (#applyActiveToolsByName below also invalidates, but doing // it here too keeps the contract local to "registry mutated".) this.#invalidateDiscoveryCaches(); const activeNonRpcToolNames = previousActiveToolNames.filter(name => !previousRpcHostToolNames.has(name)); const preservedRpcToolNames = previousActiveToolNames.filter( name => previousRpcHostToolNames.has(name) && this.#rpcHostToolNames.has(name), ); const autoActivatedRpcToolNames = rpcTools .filter(tool => !tool.hidden && !previousRpcHostToolNames.has(tool.name)) .map(tool => tool.name); await this.#applyActiveToolsByName( Array.from(new Set([...activeNonRpcToolNames, ...preservedRpcToolNames, ...autoActivatedRpcToolNames])), ); } /** Whether auto-compaction is currently running */ get isCompacting(): boolean { return this.#autoCompactionAbortController !== undefined || this.#compactionAbortController !== undefined; } /** * 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; } /** All messages including custom types like BashExecutionMessage */ get messages(): AgentMessage[] { return this.agent.state.messages; } buildDisplaySessionContext(): SessionContext { return deobfuscateSessionContext(this.sessionManager.buildSessionContext(), this.#obfuscator); } /** * Full-history transcript for TUI display: every path entry in * chronological order with compactions rendered inline at the point they * fired (instead of replacing prior history). Display-only — NEVER feed * the result to `agent.replaceMessages` or a provider. */ buildTranscriptSessionContext(): SessionContext { return deobfuscateSessionContext(this.sessionManager.buildSessionContext({ transcript: true }), this.#obfuscator); } #obfuscateForProvider(value: T): T { if (!this.#obfuscator?.hasSecrets()) return value; return this.#obfuscator.obfuscateObject(value); } #obfuscateTextForProvider(text: string | undefined): string | undefined { if (!text || !this.#obfuscator?.hasSecrets()) return text; return this.#obfuscator.obfuscate(text); } #obfuscatePreparationForProvider(preparation: CompactionPreparation): CompactionPreparation { if (!this.#obfuscator?.hasSecrets()) return preparation; if (!preparation.previousSummary && !preparation.previousPreserveData) return preparation; return { ...preparation, previousSummary: preparation.previousSummary ? this.#obfuscator.obfuscate(preparation.previousSummary) : preparation.previousSummary, previousPreserveData: preparation.previousPreserveData ? this.#obfuscator.obfuscateObject(preparation.previousPreserveData) : preparation.previousPreserveData, }; } #deobfuscateFromProvider(text: string): string { if (!this.#obfuscator?.hasSecrets()) return text; return this.#obfuscator.deobfuscate(text); } #deobfuscatedProviderTextReadyForDelta(text: string): string { const deobfuscated = this.#deobfuscateFromProvider(text); if (!this.#obfuscator?.hasSecrets()) return deobfuscated; const pendingPlaceholderStart = deobfuscated.match(/#[A-Z0-9]{0,4}$/); if (pendingPlaceholderStart?.index === undefined) return deobfuscated; return deobfuscated.slice(0, pendingPlaceholderStart.index); } #convertToLlmForSideRequest(messages: AgentMessage[]): Message[] { return this.#obfuscateForProvider(convertToLlm(messages)); } /** Convert session messages using the same pre-LLM pipeline as the active session. */ async convertMessagesToLlm(messages: AgentMessage[], signal?: AbortSignal): Promise { const transformedMessages = await this.#transformContext(messages, signal); return await this.#convertToLlm(transformedMessages); } /** Apply session-level stream hooks to a direct side request. */ prepareSimpleStreamOptions(options: SimpleStreamOptions, provider = "anthropic"): SimpleStreamOptions { const sessionOnPayload = this.#onPayload; const sessionOnResponse = this.#onResponse; const sessionMetadata = this.agent.metadataForProvider(provider); const sessionOnSseEvent = this.#onSseEvent; const openrouterRoutingPreset = provider === "openrouter" ? this.settings.get("providers.openrouterVariant") : "default"; const openrouterVariant = openrouterRoutingPreset !== "default" && options.openrouterVariant === undefined ? openrouterRoutingPreset : undefined; if (!sessionOnPayload && !sessionOnResponse && !sessionMetadata && !sessionOnSseEvent && !openrouterVariant) return options; const preparedOptions: SimpleStreamOptions = openrouterVariant === undefined ? { ...options } : { ...options, openrouterVariant }; // Stamp session metadata (e.g. user_id={session_id}) onto direct-call requests so // they share the same session bucket as Agent.prompt-routed requests on Anthropic // OAuth. Caller-provided metadata wins so explicit overrides are respected. if (sessionMetadata && !options.metadata) { preparedOptions.metadata = sessionMetadata; } if (sessionOnPayload) { if (!options.onPayload) { preparedOptions.onPayload = sessionOnPayload; } else { const requestOnPayload = options.onPayload; preparedOptions.onPayload = async (payload, model) => { const sessionPayload = await sessionOnPayload(payload, model); const sessionResolvedPayload = sessionPayload ?? payload; const requestPayload = await requestOnPayload(sessionResolvedPayload, model); return requestPayload ?? sessionResolvedPayload; }; } } if (sessionOnResponse) { if (!options.onResponse) { preparedOptions.onResponse = sessionOnResponse; } else { const requestOnResponse = options.onResponse; preparedOptions.onResponse = async (response, model) => { await sessionOnResponse(response, model); await requestOnResponse(response, model); }; } } if (sessionOnSseEvent) { if (!options.onSseEvent) { preparedOptions.onSseEvent = sessionOnSseEvent; } else { const requestOnSseEvent = options.onSseEvent; preparedOptions.onSseEvent = (event, model) => { sessionOnSseEvent(event, model); requestOnSseEvent(event, model); }; } } return preparedOptions; } /** 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 { if (this.#parentEvalSessionId !== undefined) return this.#parentEvalSessionId; return defaultEvalSessionId({ cwd: this.sessionManager.getCwd(), getSessionFile: () => this.sessionManager.getSessionFile() ?? null, }); } /** 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.#scopedModels; } /** Prompt templates */ getPlanModeState(): PlanModeState | undefined { return this.#planModeState; } setPlanModeState(state: PlanModeState | undefined): void { this.#planModeState = state; if (state?.enabled) { this.#planReferenceSent = false; this.#planReferencePath = state.planFilePath; } } getGoalModeState(): GoalModeState | undefined { return this.#goalModeState; } setGoalModeState(state: GoalModeState | undefined): void { this.#goalModeState = state; } 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.#acpPermissionDecisions.clear(); const activeToolNames = this.getActiveToolNames(); const activeTools = activeToolNames .map(name => this.#toolRegistry.get(name)) .filter((tool): tool is AgentTool => tool !== undefined) .map(tool => this.#wrapToolForAcpPermission(tool)); this.agent.setTools(activeTools); } getCheckpointState(): CheckpointState | undefined { return this.#checkpointState; } setCheckpointState(state: CheckpointState | undefined): void { this.#checkpointState = state; if (!state) { 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, ); } resolveRoleModel(role: string): Model | undefined { return this.#resolveRoleModelFull(role, this.#modelRegistry.getAvailable(), this.model).model; } /** * 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.#resolveRoleModelFull(role, this.#modelRegistry.getAvailable(), this.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()); let planContent: string; try { planContent = await Bun.file(resolvedPlanPath).text(); } catch (error) { if (isEnoent(error)) { return null; } throw error; } const content = prompt.render(planModeReferencePrompt, { planFilePath, planContent, }); this.#planReferenceSent = true; 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", 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; return { role: "custom", customType: "goal-mode-context", content, display: false, attribution: "agent", timestamp: Date.now(), }; } async #normalizeMessageContentImages( content: string | (TextContent | ImageContent)[], ): Promise { if (typeof content === "string") return content; const images = content.filter((part): part is ImageContent => part.type === "image"); if (images.length === 0) return content; const normalizedImages = await normalizeModelContextImages(images); if (!normalizedImages) return content; let imageIndex = 0; return content.map(part => (part.type === "image" ? normalizedImages[imageIndex++]! : part)); } async #normalizeAgentMessageImages(message: T): Promise { if (!("content" in message)) return message; const content = message.content; if (typeof content !== "string" && !Array.isArray(content)) return message; const normalized = await this.#normalizeMessageContentImages(content as string | (TextContent | ImageContent)[]); if (normalized === content) return message; return { ...message, content: normalized } as T; } #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)) { keywordNotices.push({ role: "custom", customType: "workflow-notice", content: WORKFLOW_NOTICE, 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); // If streaming, queue via steer() or followUp() based on option if (this.isStreaming) { if (!options?.streamingBehavior) { throw new AgentBusyError(); } if (options.streamingBehavior === "followUp") { await this.#queueFollowUp(expandedText, options?.images); } else { await this.#queueSteer(expandedText, options?.images); } // Steer/follow-up the keyword notices alongside the queued user message. for (const notice of keywordNotices) { await this.sendCustomMessage(notice, { deliverAs: options.streamingBehavior }); } return true; } // Skip eager todo prelude when the user has already queued a directive const hasPendingUserDirective = this.#toolChoiceQueue.inspect().includes("user-force"); const eagerTodoPrelude = !options?.synthetic && !hasPendingUserDirective ? this.#createEagerTodoPrelude(expandedText) : undefined; const normalizedImages = await normalizeModelContextImages(options?.images); const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }]; if (normalizedImages) { userContent.push(...normalizedImages); } 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() }; if (eagerTodoPrelude) { this.#toolChoiceQueue.pushOnce(eagerTodoPrelude.toolChoice, { label: "eager-todo", }); } try { await this.#promptWithMessage(message, expandedText, { ...options, images: normalizedImages, prependMessages: eagerTodoPrelude ? [eagerTodoPrelude.message] : undefined, appendMessages: keywordNotices.length > 0 ? keywordNotices : 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"); } if (!options?.synthetic) { await this.#enforcePlanModeToolDecision(); } return true; } async promptCustomMessage( message: Pick, "customType" | "content" | "display" | "details" | "attribution">, options?: Pick, ): 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 (this.isStreaming) { if (!options?.streamingBehavior) { throw new AgentBusyError(); } await this.sendCustomMessage(message, { deliverAs: options.streamingBehavior }); for (const notice of keywordNotices) { await this.sendCustomMessage(notice, { deliverAs: options.streamingBehavior }); } 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, appendMessages: keywordNotices.length > 0 ? keywordNotices : undefined, }); } async #promptWithMessage( message: AgentMessage, expandedText: string, options?: Pick & { prependMessages?: AgentMessage[]; appendMessages?: AgentMessage[]; skipPostPromptRecoveryWait?: boolean; }, ): Promise { this.#beginInFlight(); const generation = this.#promptGeneration; try { // Flush any pending bash messages before the new prompt this.#flushPendingBashMessages(); this.#flushPendingPythonMessages(); this.#flushPendingIrcAsides(); // Reset todo reminder count on new user prompt this.#todoReminderCount = 0; this.#emptyStopRetryCount = 0; await this.#maybeRestoreRetryFallbackPrimary(); // 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()}`, ); } // Check if we need to compact before sending (catches aborted responses). Run // inline (allowDefer=false) so the handoff/maintenance fully settles before this // prompt's agent loop starts — otherwise a deferred handoff would fire on the // next microtask alongside the new turn. const lastAssistant = this.#findLastAssistantMessage(); if (lastAssistant && !options?.skipCompactionCheck) { await this.#checkCompaction(lastAssistant, false, false, false); } // 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); } if (options?.prependMessages) { messages.push(...options.prependMessages); } messages.push(message); // Inject the ultrathink notice (and any other per-turn appends) right after the // user message so the model reads it as part of the same turn. if (options?.appendMessages) { messages.push(...options.appendMessages); } // 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; } // 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)); } } 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) { messages.push( await this.#normalizeAgentMessageImages({ role: "custom", customType: msg.customType, content: msg.content, display: msg.display, details: msg.details, attribution: msg.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.#autoThinking && message.role === "user") { await this.#applyAutoThinkingLevel(expandedText, generation); if (this.#promptGeneration !== generation) { return; } } await this.#runPrePromptCompactionIfNeeded(messages); if (this.#promptGeneration !== generation) { return; } const agentPromptOptions = options?.toolChoice ? { toolChoice: options.toolChoice } : undefined; await this.#promptAgentWithIdleRetry(messages, agentPromptOptions); if (!options?.skipPostPromptRecoveryWait) { await this.#waitForPostPromptRecovery(); } } 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, isIdle: () => !this.isStreaming, abort: () => { void this.abort(); }, hasPendingMessages: () => this.queuedMessageCount > 0, shutdown: () => { void this.dispose(); 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, }; } /** * 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]); await this.#queueSteer(expandedText, images); } /** * Queue a follow-up message to process after the agent would otherwise stop. */ async followUp(text: string, images?: ImageContent[]): Promise { if (text.startsWith("/")) { this.#throwIfExtensionCommand(text); } const expandedText = expandPromptTemplate(text, [...this.#promptTemplates]); await this.#queueFollowUp(expandedText, images); } /** * Internal: Queue a steering message (already expanded, no extension command check). */ async #queueSteer(text: string, images?: ImageContent[]): Promise { const normalizedImages = await normalizeModelContextImages(images); const displayText = text || (images && images.length > 0 ? "[Image]" : ""); this.#steeringMessages.push({ text: displayText, images }); const content: (TextContent | ImageContent)[] = [{ type: "text", text }]; if (normalizedImages && normalizedImages.length > 0) { content.push(...normalizedImages); } this.agent.steer({ role: "user", content, steering: true, attribution: "user", timestamp: Date.now(), }); // A steer can land on an idle session: the caller checked isStreaming // before the (potentially slow) image normalization above, so the turn // may have ended in between. Without a drain the message would strand in // the queue until the next manual prompt — schedule an immediate continue, // mirroring #queueFollowUp's idle-path delivery. if (this.#canAutoContinueForFollowUp()) { this.#scheduleAgentContinue({ shouldContinue: () => this.#canAutoContinueForFollowUp() && this.agent.hasQueuedMessages(), }); } } /** * Internal: Queue a follow-up message (already expanded, no extension command check). */ async #queueFollowUp(text: string, images?: ImageContent[]): Promise { const normalizedImages = await normalizeModelContextImages(images); const displayText = text || (images && images.length > 0 ? "[Image]" : ""); this.#followUpMessages.push({ text: displayText, images }); const content: (TextContent | ImageContent)[] = [{ type: "text", text }]; if (normalizedImages && normalizedImages.length > 0) { content.push(...normalizedImages); } this.agent.followUp({ role: "user", content, attribution: "user", timestamp: Date.now(), }); // When fully idle AND the session is in a resumable assistant-ended state, // schedule an immediate continue so the queued follow-up is delivered // without waiting for the next user turn. We gate on isStreaming (model // actively producing), isRetrying (auto-retry backoff is sleeping between // attempts, #retryPromise set), and the last message being assistant — // agent.continue() only dequeues follow-ups from an assistant-ended state; // resuming from user/toolResult state runs an extra model call on the // stale prompt before draining the queue. if (this.#canAutoContinueForFollowUp()) { this.#scheduleAgentContinue({ shouldContinue: () => this.#canAutoContinueForFollowUp() && this.agent.hasQueuedMessages(), }); } } /** * Gate for idle-path follow-up auto-continue. See `#queueFollowUp` for rationale. */ #canAutoContinueForFollowUp(): boolean { if (this.isStreaming) return false; if (this.isRetrying) return false; const messages = this.agent.state.messages; const last = messages[messages.length - 1]; return last?.role === "assistant"; } 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.`, ); } } /** * 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 */ async sendCustomMessage( message: Pick, "customType" | "content" | "display" | "details" | "attribution">, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }, ): Promise { const appMessage: CustomMessage = { role: "custom", customType: message.customType, content: message.content, display: message.display, details: message.details, attribution: message.attribution ?? "agent", timestamp: Date.now(), }; const normalizedAppMessage = await this.#normalizeAgentMessageImages(appMessage); if (this.isStreaming) { if (options?.deliverAs === "nextTurn") { this.#queueHiddenNextTurnMessage(normalizedAppMessage, options?.triggerTurn ?? false); return; } if (options?.deliverAs === "followUp") { this.agent.followUp(normalizedAppMessage); } else { this.agent.steer(normalizedAppMessage); } return; } if (options?.deliverAs === "nextTurn") { if (options?.triggerTurn) { if (this.#clientBridge?.deferAgentInitiatedTurns && !this.#allowAcpAgentInitiatedTurns) { this.#queueHiddenNextTurnMessage(normalizedAppMessage, false); return; } await this.agent.prompt(normalizedAppMessage); return; } this.agent.appendMessage(normalizedAppMessage); this.sessionManager.appendCustomMessageEntry( normalizedAppMessage.customType, normalizedAppMessage.content, message.display, message.details, message.attribution ?? "agent", ); return; } if (options?.triggerTurn) { if (this.#clientBridge?.deferAgentInitiatedTurns && !this.#allowAcpAgentInitiatedTurns) { this.#queueHiddenNextTurnMessage(normalizedAppMessage, false); return; } await this.agent.prompt(normalizedAppMessage); return; } this.agent.appendMessage(normalizedAppMessage); this.sessionManager.appendCustomMessageEntry( normalizedAppMessage.customType, normalizedAppMessage.content, message.display, message.details, message.attribution ?? "agent", ); } /** * Send a user message to the agent. * When deliverAs is set, queue the message instead of starting a new turn. * * @param content User message content (string or content array) * @param options.deliverAs Delivery mode: "steer" or "followUp" */ async sendUserMessage( content: string | (TextContent | ImageContent)[], options?: { deliverAs?: "steer" | "followUp" }, ): Promise { // Normalize content to text string + optional images let text: string; let images: ImageContent[] | undefined; if (typeof content === "string") { text = content; } else { const textParts: string[] = []; images = []; for (const part of content) { if (part.type === "text") { textParts.push(part.text); } else { images.push(part); } } text = textParts.join("\n"); if (images.length === 0) images = undefined; } if (options?.deliverAs === "followUp") { await this.#queueFollowUp(text, images); return; } if (options?.deliverAs === "steer") { await this.#queueSteer(text, images); return; } // Use prompt() with expandPromptTemplates: false to skip command handling and template expansion await this.prompt(text, { expandPromptTemplates: false, images, }); } /** * Clear queued messages and return them (text plus any attached images). * Useful for restoring to editor when user aborts. The internal entry * arrays are handed out as-is — a `tag` (if any) is inert once the record * leaves the queue. */ clearQueue(): { steering: RestoredQueuedMessage[]; followUp: RestoredQueuedMessage[] } { const steering = this.#steeringMessages; const followUp = this.#followUpMessages; this.#steeringMessages = []; this.#followUpMessages = []; this.agent.clearAllQueues(); return { steering, followUp }; } /** Number of pending messages (includes steering, follow-up, and next-turn messages) */ get queuedMessageCount(): number { return this.#steeringMessages.length + this.#followUpMessages.length + this.#pendingNextTurnMessages.length; } /** Get pending messages (read-only). Returns the public text-only view; * internal `{text, tag?}` records are mapped to `.text` so callers * (`updatePendingMessagesDisplay`, `restoreQueuedMessagesToEditor`) see * the unchanged historical shape. */ getQueuedMessages(): { steering: readonly string[]; followUp: readonly string[] } { return { steering: this.#steeringMessages.map(e => e.text), followUp: this.#followUpMessages.map(e => e.text), }; } /** * Pop the last queued message (steering first, then follow-up). * Used by dequeue keybinding to restore messages to editor one at a time. * Returns the popped entry's text and images; the tag (if any) dies with * the record — no orphan state can outlive the queue entry. */ popLastQueuedMessage(): RestoredQueuedMessage | undefined { // Pop from steering first (LIFO) if (this.#steeringMessages.length > 0) { const entry = this.#steeringMessages.pop(); this.agent.popLastSteer(); return entry; } // Then from follow-up if (this.#followUpMessages.length > 0) { const entry = this.#followUpMessages.pop(); this.agent.popLastFollowUp(); return entry; } return undefined; } get skillsSettings(): SkillsSettings | undefined { return this.#skillsSettings; } /** Skills loaded by SDK (empty if --no-skills or skills: [] was passed) */ get skills(): readonly Skill[] { return this.#skills; } /** Skill loading warnings captured by SDK */ get skillWarnings(): readonly SkillWarning[] { return this.#skillWarnings; } getTodoPhases(): TodoPhase[] { return this.#cloneTodoPhases(this.#todoPhases); } setTodoPhases(phases: TodoPhase[]): void { this.#todoPhases = this.#cloneTodoPhases(phases); } #syncTodoPhasesFromBranch(): void { const phases = getLatestTodoPhasesFromEntries(this.sessionManager.getBranch()); // Strip completed/abandoned tasks — they were done in a previous run, // so they have no bearing on progress tracking for the new turn. for (const phase of phases) { phase.tasks = phase.tasks.filter(t => t.status !== "completed" && t.status !== "abandoned"); } this.setTodoPhases(phases.filter(p => p.tasks.length > 0)); } #cloneTodoPhases(phases: TodoPhase[]): TodoPhase[] { return phases.map(phase => ({ name: phase.name, tasks: phase.tasks.map(task => ({ content: task.content, status: task.status })), })); } // Auto-clear of completed/abandoned tasks was removed: the timer-driven // splice mutated canonical `#todoPhases` between tool calls, so the model // observed phase totals shrinking ("5 → 4") after marking tasks done. The // `tasks.todoClearDelay` setting is now inert; completed tasks survive // until the next explicit `todo` call removes them via `rm`/`drop`. /** * 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 }): Promise { this.abortRetry(); this.#promptGeneration++; this.#scheduledHiddenNextTurnGeneration = undefined; 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.#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(); // 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"); } } /** * Abort active work, then immediately resume the agent so queued steer/follow-up * messages drain instead of waiting for another natural turn boundary. */ async interruptAndFlushQueuedMessages(options?: { reason?: string }): Promise { if (!this.agent.hasQueuedMessages()) return; await this.abort({ reason: options?.reason }); if (!this.agent.hasQueuedMessages()) return; if (this.isCompacting || this.isGeneratingHandoff) return; await this.#maybeRestoreRetryFallbackPrimary(); await this.agent.continue(); } /** * 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 { const previousSessionFile = this.sessionFile; const nextDiscoverySessionToolNames = this.#mcpDiscoveryEnabled ? [ ...this.#getActiveNonMCPToolNames(), ...this.#filterSelectableMCPToolNames(this.#defaultSelectedMCPToolNames), ] : undefined; // 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"); this.agent.reset(); if (options?.drop && previousSessionFile) { 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); this.setTodoPhases([]); this.#freshProviderSessionId = undefined; this.#syncAgentSessionId(); this.#rekeyHindsightMemoryForCurrentSessionId(); this.#rekeyMnemopiMemoryForCurrentSessionId(); this.#resetHindsightConversationTrackingIfHindsight(); this.#resetMnemopiConversationTrackingIfMnemopi(); this.#steeringMessages = []; this.#followUpMessages = []; this.#pendingNextTurnMessages = []; this.#scheduledHiddenNextTurnGeneration = undefined; this.sessionManager.appendThinkingLevelChange(this.thinkingLevel); this.sessionManager.appendServiceTierChange(this.serviceTier ?? null); if (nextDiscoverySessionToolNames) { await this.#applyActiveToolsByName(nextDiscoverySessionToolNames, { persistMCPSelection: false }); if (this.getSelectedMCPToolNames().length > 0) { this.sessionManager.appendMCPToolSelection(this.getSelectedMCPToolNames()); } } this.#rememberSessionDefaultSelectedMCPToolNames( this.sessionFile, this.#getConfiguredDefaultSelectedMCPToolNames(), ); this.#todoReminderCount = 0; this.#planReferenceSent = false; this.#planReferencePath = "local://PLAN.md"; this.#reconnectToAgent(); // 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"): Promise { return this.sessionManager.setSessionName(name, source); } /** * 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 { 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; } } // Flush current session to ensure all entries are written await this.sessionManager.flush(); // Fork the session (creates new session file with same entries) const forkResult = await this.sessionManager.fork(); if (!forkResult) { return false; } // 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.#syncAgentSessionId(); this.#rekeyHindsightMemoryForCurrentSessionId(); this.#rekeyMnemopiMemoryForCurrentSessionId(); this.#resetMnemopiConversationTrackingIfMnemopi(); // Emit session_switch event with reason "fork" to hooks if (this.#extensionRunner) { await this.#extensionRunner.emit({ type: "session_switch", reason: "fork", previousSessionFile, }); } return true; } // ========================================================================= // Model Management // ========================================================================= /** * Set model directly. * Validates API key and saves to the active session. Persists settings only when requested. * @throws Error if no API key available for the model */ async setModel( model: Model, role: string = "default", options?: { selector?: string; thinkingLevel?: ThinkingLevel; persist?: boolean }, ): Promise { const previousEditMode = this.#resolveActiveEditMode(); const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId); if (!apiKey) { throw new Error(`No API key for ${model.provider}/${model.id}`); } this.#clearActiveRetryFallback(); this.#setModelWithProviderSessionReset(model); this.sessionManager.appendModelChange(`${model.provider}/${model.id}`, role); if (options?.persist) { this.settings.setModelRole( role, this.#formatRoleModelValue(role, model, options.selector, options.thinkingLevel), ); } this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`); // Re-apply thinking for the newly selected model. Prefer the model's // configured defaultLevel; otherwise preserve the current level (or auto). this.#reapplyThinkingLevel(model.thinking?.defaultLevel); await this.#syncAfterModelChange(previousEditMode); } /** * Set model temporarily (for this session only). * Validates API key, saves to session log but NOT to settings. * @throws Error if no API key available for the model */ async setModelTemporary( model: Model, thinkingLevel?: ThinkingLevel, options?: { ephemeral?: boolean }, ): Promise { const previousEditMode = this.#resolveActiveEditMode(); const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId); if (!apiKey) { throw new Error(`No API key for ${model.provider}/${model.id}`); } this.#clearActiveRetryFallback(); this.#setModelWithProviderSessionReset(model); this.sessionManager.appendModelChange( `${model.provider}/${model.id}`, options?.ephemeral ? EPHEMERAL_MODEL_CHANGE_ROLE : "temporary", ); this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`); // Apply explicit thinking level if given; otherwise prefer the model's // configured defaultLevel; otherwise re-clamp the current level (or auto). if (thinkingLevel !== undefined) { this.setThinkingLevel(thinkingLevel); } else { this.#reapplyThinkingLevel(model.thinking?.defaultLevel); } await this.#syncAfterModelChange(previousEditMode); } /** * Cycle to next/previous model. * Uses scoped models (from --models flag) if available, otherwise all available models. * @param direction - "forward" (default) or "backward" * @returns The new model info, or undefined if only one model available */ async cycleModel(direction: "forward" | "backward" = "forward"): Promise { if (this.#scopedModels.length > 0) { return this.#cycleScopedModel(direction); } return this.#cycleAvailableModel(direction); } /** * Resolve the configured role models in the given order plus the index of * the currently active one. Roles that have no configured model, or whose * configured model is not currently available, are skipped. The `default` * role falls back to the active model when no explicit assignment exists. * * Returns `undefined` only when there is no current model or no available * models at all; an empty `models` array is never returned (callers should * still guard on `models.length`). */ getRoleModelCycle(roleOrder: readonly string[]): RoleModelCycle | undefined { const availableModels = this.#modelRegistry.getAvailable(); if (availableModels.length === 0) return undefined; const currentModel = this.model; if (!currentModel) return undefined; const matchPreferences = getModelMatchPreferences(this.settings); const models: ResolvedRoleModel[] = []; for (const role of roleOrder) { const roleModelStr = role === "default" ? (this.settings.getModelRole("default") ?? `${currentModel.provider}/${currentModel.id}`) : this.settings.getModelRole(role); if (!roleModelStr) continue; const resolved = resolveModelRoleValue(roleModelStr, availableModels, { settings: this.settings, matchPreferences, modelRegistry: this.#modelRegistry, }); if (!resolved.model) continue; models.push({ role, model: resolved.model, thinkingLevel: resolved.thinkingLevel, explicitThinkingLevel: resolved.explicitThinkingLevel, }); } if (models.length === 0) return undefined; const lastRole = this.sessionManager.getLastModelChangeRole(); let currentIndex = lastRole ? models.findIndex(entry => entry.role === lastRole) : -1; if (currentIndex === -1) { currentIndex = models.findIndex(entry => modelsAreEqual(entry.model, currentModel)); } if (currentIndex === -1) currentIndex = 0; return { models, currentIndex }; } /** * Apply a resolved role model as the active model without changing global * settings. Shared with role cycling and the plan-approval model slider. */ async applyRoleModel(entry: ResolvedRoleModel): Promise { await this.setModel(entry.model, entry.role); if (entry.explicitThinkingLevel && entry.thinkingLevel !== undefined) { this.setThinkingLevel(entry.thinkingLevel); } } /** * Cycle through configured role models in a fixed order. * Skips missing roles and changes only the active session model. * @param roleOrder - Order of roles to cycle through (e.g., ["slow", "default", "smol"]) * @param direction - "forward" (default) or "backward" */ async cycleRoleModels( roleOrder: readonly string[], direction: "forward" | "backward" = "forward", ): Promise { const cycle = this.getRoleModelCycle(roleOrder); if (!cycle || cycle.models.length <= 1) return undefined; const step = direction === "backward" ? -1 : 1; const next = cycle.models[(cycle.currentIndex + step + cycle.models.length) % cycle.models.length]; await this.applyRoleModel(next); return { model: next.model, thinkingLevel: this.thinkingLevel, role: next.role }; } async #getScopedModelsWithApiKey(): Promise> { const apiKeysByProvider = new Map(); const result: Array<{ model: Model; thinkingLevel?: ThinkingLevel }> = []; for (const scoped of this.#scopedModels) { const provider = scoped.model.provider; let apiKey: string | undefined; if (apiKeysByProvider.has(provider)) { apiKey = apiKeysByProvider.get(provider); } else { apiKey = await this.#modelRegistry.getApiKeyForProvider(provider, this.sessionId); apiKeysByProvider.set(provider, apiKey); } if (apiKey) { result.push(scoped); } } return result; } async #cycleScopedModel(direction: "forward" | "backward"): Promise { const previousEditMode = this.#resolveActiveEditMode(); const scopedModels = await this.#getScopedModelsWithApiKey(); if (scopedModels.length <= 1) return undefined; const currentModel = this.model; let currentIndex = scopedModels.findIndex(sm => modelsAreEqual(sm.model, currentModel)); if (currentIndex === -1) currentIndex = 0; const len = scopedModels.length; const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len; const next = scopedModels[nextIndex]; // Apply model this.#clearActiveRetryFallback(); this.#setModelWithProviderSessionReset(next.model); this.sessionManager.appendModelChange(`${next.model.provider}/${next.model.id}`); this.settings.getStorage()?.recordModelUsage(`${next.model.provider}/${next.model.id}`); // Apply the scoped model's configured thinking level, preserving auto. this.setThinkingLevel(this.#autoThinking ? AUTO_THINKING : next.thinkingLevel); await this.#syncAfterModelChange(previousEditMode); return { model: next.model, thinkingLevel: this.thinkingLevel, isScoped: true }; } async #cycleAvailableModel(direction: "forward" | "backward"): Promise { const previousEditMode = this.#resolveActiveEditMode(); const availableModels = this.#modelRegistry.getAvailable(); if (availableModels.length <= 1) return undefined; const currentModel = this.model; let currentIndex = availableModels.findIndex(m => modelsAreEqual(m, currentModel)); if (currentIndex === -1) currentIndex = 0; const len = availableModels.length; const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len; const nextModel = availableModels[nextIndex]; const apiKey = await this.#modelRegistry.getApiKey(nextModel, this.sessionId); if (!apiKey) { throw new Error(`No API key for ${nextModel.provider}/${nextModel.id}`); } this.#clearActiveRetryFallback(); this.#setModelWithProviderSessionReset(nextModel); this.sessionManager.appendModelChange(`${nextModel.provider}/${nextModel.id}`); this.settings.getStorage()?.recordModelUsage(`${nextModel.provider}/${nextModel.id}`); // Re-apply the current thinking level (or auto) for the newly selected model this.#reapplyThinkingLevel(); await this.#syncAfterModelChange(previousEditMode); return { model: nextModel, thinkingLevel: this.thinkingLevel, isScoped: false }; } /** * Get all available models with valid API keys, filtered by `enabledModels` when configured. * See {@link filterAvailableModelsByEnabledPatterns} for supported pattern forms and limitations. */ getAvailableModels(): Model[] { const all = this.#modelRegistry.getAvailable(); const patterns = this.settings.get("enabledModels"); if (!patterns || patterns.length === 0) return all; return filterAvailableModelsByEnabledPatterns(all, patterns, this.#modelRegistry); } // ========================================================================= // Thinking Level Management // ========================================================================= #applyThinkingLevelToAgent(level: ThinkingLevel | undefined): void { this.agent.setThinkingLevel(toReasoningEffort(level)); this.agent.setDisableReasoning(shouldDisableReasoning(level)); } /** * Set the thinking level. `auto` enables per-turn classification; the selector * itself is never written to the session log, but resolved concrete levels are * persisted when real user turns are classified so resumed sessions keep the * last resolved effort instead of reverting to pending auto. */ setThinkingLevel(level: ConfiguredThinkingLevel | undefined, persist: boolean = false): void { if (level === AUTO_THINKING) { const provisional = resolveProvisionalAutoLevel(this.model); const wasAuto = this.#autoThinking; this.#autoThinking = true; this.#autoResolvedLevel = undefined; this.#thinkingLevel = provisional; this.#applyThinkingLevelToAgent(provisional); if (persist) { this.settings.set("defaultThinkingLevel", AUTO_THINKING); } if (!wasAuto || this.#thinkingLevel !== provisional) { this.#emit({ type: "thinking_level_changed", thinkingLevel: provisional, configured: AUTO_THINKING }); } return; } this.#autoThinking = false; this.#autoResolvedLevel = undefined; const effectiveLevel = resolveThinkingLevelForModel(this.model, level); const isChanging = effectiveLevel !== this.#thinkingLevel; this.#thinkingLevel = effectiveLevel; this.#applyThinkingLevelToAgent(effectiveLevel); if (isChanging) { this.sessionManager.appendThinkingLevelChange(effectiveLevel); if (persist && effectiveLevel !== undefined && effectiveLevel !== ThinkingLevel.Off) { this.settings.set("defaultThinkingLevel", effectiveLevel); } this.#emit({ type: "thinking_level_changed", thinkingLevel: effectiveLevel }); } } /** * Re-apply the active thinking selection after a model change. Preserves `auto` * (re-clamping the provisional level to the new model); otherwise re-applies the * preferred default or the current effective level. */ #reapplyThinkingLevel(preferredDefault?: ThinkingLevel): void { this.setThinkingLevel(this.#autoThinking ? AUTO_THINKING : (preferredDefault ?? this.#thinkingLevel)); } /** * Cycle to next thinking level: off → auto → minimal..xhigh → off. * @returns New selector, or undefined if model doesn't support thinking */ cycleThinkingLevel(): ConfiguredThinkingLevel | undefined { if (!this.model?.reasoning) return undefined; const levels: ConfiguredThinkingLevel[] = [ ThinkingLevel.Off, AUTO_THINKING, ...this.getAvailableThinkingLevels(), ]; const configured = this.configuredThinkingLevel(); const currentLevel = configured === ThinkingLevel.Inherit ? ThinkingLevel.Off : configured; const currentIndex = currentLevel ? levels.indexOf(currentLevel) : -1; const nextIndex = (currentIndex + 1) % levels.length; const nextLevel = levels[nextIndex]; if (!nextLevel) return undefined; this.setThinkingLevel(nextLevel); return nextLevel; } /** Timeout (ms) for per-turn auto-thinking classification before falling back. */ static readonly #AUTO_THINKING_TIMEOUT_MS = 4000; /** * Classify the current user turn and set the effective thinking level for it. * Bounded by a timeout + abort; on any failure (no smol model, timeout, parse * error) it falls back to the provisional concrete level and continues. Never * throws into the turn, and never clears `#autoThinking` (auto stays active). */ async #applyAutoThinkingLevel(promptText: string, generation: number): Promise { const model = this.model; if (!model?.reasoning) return; let resolved: Effort | undefined; if (this.#magicKeywordEnabled("ultrathink") && containsUltrathink(promptText)) { // The user explicitly asked for maximum thinking; bypass the classifier // and jump straight to the highest auto-supported level for this model. resolved = clampAutoThinkingEffort(model, Effort.XHigh); } else { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), AgentSession.#AUTO_THINKING_TIMEOUT_MS); try { resolved = await classifyDifficulty(promptText, { settings: this.settings, registry: this.#modelRegistry, model, sessionId: this.sessionId, signal: controller.signal, metadataResolver: provider => this.agent.metadataForProvider(provider), }); } catch (error) { logger.debug("auto-thinking: classification failed; using fallback level", { error: error instanceof Error ? error.message : String(error), }); } finally { clearTimeout(timer); } } // Drop the result if the turn was aborted/superseded while classifying. if (this.#promptGeneration !== generation || !this.#autoThinking) return; const effort = resolved ?? resolveProvisionalAutoLevel(model); if (effort === undefined) return; const shouldPersistResolution = this.#autoResolvedLevel !== effort; this.#autoResolvedLevel = effort; this.#thinkingLevel = effort; this.#applyThinkingLevelToAgent(effort); if (shouldPersistResolution) { this.sessionManager.appendThinkingLevelChange(effort); } this.#emit({ type: "thinking_level_changed", thinkingLevel: effort, configured: AUTO_THINKING, resolved: effort, }); } /** * True when *any* fast-mode-granting service tier is configured, regardless * of whether the active model's provider actually realizes it. Used by the * toggle (`/fast on|off`) so re-toggling a scoped tier (`openai-only`, * `claude-only`) doesn't silently broaden it to unscoped `priority`. * * For "is fast mode actually applied to the next request?" use * {@link isFastModeActive} instead — that one respects the model's provider. */ isFastModeEnabled(): boolean { return ( this.serviceTier === "priority" || this.serviceTier === "claude-only" || this.serviceTier === "openai-only" ); } /** * True when the configured `serviceTier` resolves to `"priority"` for the * *currently selected model's provider*. Returns false for scoped tiers * that don't match (e.g. `"openai-only"` on an anthropic model) and when * no model is selected. */ isFastModeActive(): boolean { return resolveServiceTier(this.serviceTier, this.model?.provider) === "priority"; } setServiceTier(serviceTier: ServiceTier | undefined): void { if (this.serviceTier === serviceTier) return; // Re-arming priority on Anthropic? Clear the per-session auto-fallback // sticky disable so the next request actually carries `speed: "fast"` // again. Without this, `/fast on` (or user switching to a tier that // grants anthropic priority) after an auto-disable is a silent no-op // and the warning notice fires every turn. if (serviceTier === "priority" || serviceTier === "claude-only") { clearAnthropicFastModeFallback(this.#providerSessionState); } this.agent.serviceTier = serviceTier; this.sessionManager.appendServiceTierChange(serviceTier ?? null); } setFastMode(enabled: boolean): void { if (enabled && this.isFastModeEnabled()) { // Already on under any scope — keep the user's scoped value. return; } this.setServiceTier(enabled ? "priority" : undefined); } toggleFastMode(): boolean { const enabled = !this.isFastModeEnabled(); this.setFastMode(enabled); return enabled; } /** * Get available thinking levels for current model. */ getAvailableThinkingLevels(): ReadonlyArray { if (!this.model) return []; return getSupportedEfforts(this.model); } // ========================================================================= // 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); } // ========================================================================= // Compaction // ========================================================================= /** * Append plan-read protection to a prune/shake config so the active plan * file survives compaction alongside skill reads (the config defaults * already carry skill protection). The matcher reads the current plan * reference path at match time, so retitled plans are covered. */ #withPlanProtection(config: T): T { const planMatcher = createPlanReadMatcher(() => this.#planReferencePath); return { ...config, protectedTools: [...config.protectedTools, planMatcher] }; } async #pruneToolOutputs(): Promise<{ prunedCount: number; tokensSaved: number } | undefined> { const branchEntries = this.sessionManager.getBranch(); const result = pruneToolOutputs(branchEntries, this.#withPlanProtection(DEFAULT_PRUNE_CONFIG)); if (result.prunedCount === 0) { return undefined; } await this.sessionManager.rewriteEntries(); const sessionContext = this.buildDisplaySessionContext(); this.agent.replaceMessages(sessionContext.messages); this.#syncTodoPhasesFromBranch(); this.#closeCodexProviderSessionsForHistoryRewrite(); return result; } /** * Per-turn supersede pass: prune older `read` results that a newer read of * the same file has made stale. Cache-aware (only fires when the suffix * after a candidate is small or the session has been idle long enough that * the provider prompt cache is cold), so it is cheap to run every turn. * Gated on the `compaction.supersedeReads` setting. */ async #pruneSupersededReads(): Promise<{ prunedCount: number; tokensSaved: number } | undefined> { if (!this.settings.getGroup("compaction").supersedeReads) return undefined; const branchEntries = this.sessionManager.getBranch(); const result = pruneSupersededToolResults( branchEntries, this.#withPlanProtection({ supersedeKey: readToolSupersedeKey, protectedTools: [...DEFAULT_PRUNE_CONFIG.protectedTools], }), ); if (result.prunedCount === 0) { return undefined; } await this.sessionManager.rewriteEntries(); const sessionContext = this.buildDisplaySessionContext(); this.agent.replaceMessages(sessionContext.messages); this.#syncTodoPhasesFromBranch(); this.#closeCodexProviderSessionsForHistoryRewrite(); return result; } /** * Strip image content blocks from every message on the current branch and * persist the rewrite. Walks `SessionManager.getBranch()` in place — both * `SessionMessageEntry.message` and `CustomMessageEntry.content` arrays * are mutated, then `rewriteEntries` durably commits the new shape. The * agent's runtime view is rebuilt from the freshly-mutated entries so any * provider sessions caching message identity (Codex Responses) are torn * down to force a clean replay on the next turn. * * No-op when the branch carries no images; returns `{ removed: 0 }` and * skips the disk rewrite. */ async dropImages(): Promise<{ removed: number }> { const branchEntries = this.sessionManager.getBranch(); let removed = 0; for (const entry of branchEntries) { if (entry.type === "message") { removed += stripImagesFromMessage(entry.message); continue; } if (entry.type === "custom_message" && typeof entry.content !== "string") { const kept: typeof entry.content = []; let dropped = 0; for (const part of entry.content) { if (part.type === "image") { dropped++; } else { kept.push(part); } } if (dropped > 0) { if (kept.length === 0) { kept.push({ type: "text", text: "[image removed]" }); } entry.content = kept; removed += dropped; } } } if (removed === 0) { return { removed: 0 }; } await this.sessionManager.rewriteEntries(); const sessionContext = this.buildDisplaySessionContext(); this.agent.replaceMessages(sessionContext.messages); this.#closeCodexProviderSessionsForHistoryRewrite(); return { removed }; } /** * Surgically reduce context by dropping heavy content ("shake"). * * - `images` delegates to {@link dropImages}. * - `elide` replaces whole tool-call results and large fenced/XML blocks * with short placeholders that embed an `artifact://` recovery link. * * Mutates the branch in place, persists via `rewriteEntries`, replays the * rebuilt context through the agent, and tears down provider sessions that * cache message identity — same rewrite contract as {@link dropImages}. * * No-op (zero counts) when nothing is eligible. */ async shake(mode: ShakeMode, opts: { config?: ShakeConfig; signal?: AbortSignal } = {}): Promise { if (mode === "images") { const { removed } = await this.dropImages(); return { mode, toolResultsDropped: 0, blocksDropped: 0, imagesDropped: removed, tokensFreed: 0 }; } const config = this.#withPlanProtection(opts.config ?? AGGRESSIVE_SHAKE_CONFIG); const regions = collectShakeRegions(this.sessionManager.getBranch(), config); if (regions.length === 0) { return { mode, toolResultsDropped: 0, blocksDropped: 0, tokensFreed: 0 }; } const artifactId = await this.#saveShakeArtifact(regions); const replacements = regions.map((region, index) => this.#shakeElidePlaceholder(region, index, artifactId)); let toolResultsDropped = 0; let blocksDropped = 0; let originalTokens = 0; let replacementTokens = 0; const items = regions.map((region, index) => { if (region.kind === "toolResult") toolResultsDropped++; else blocksDropped++; originalTokens += region.tokens; const replacement = replacements[index]; if (replacement.length > 0) replacementTokens += countTokens(replacement); return { region, replacement }; }); applyShakeRegions(items); await this.sessionManager.rewriteEntries(); const sessionContext = this.buildDisplaySessionContext(); this.agent.replaceMessages(sessionContext.messages); this.#closeCodexProviderSessionsForHistoryRewrite(); return { mode, toolResultsDropped, blocksDropped, tokensFreed: Math.max(0, originalTokens - replacementTokens), artifactId, }; } #shakeElidePlaceholder(region: ShakeRegion, index: number, artifactId: string | undefined): string { if (artifactId) { return `[shaken ~${region.tokens} tokens — recover: artifact://${artifactId} (region ${index + 1})]`; } return `[shaken ~${region.tokens} tokens]`; } /** * Concatenate the original region contents into one session artifact so the * agent can read them back via `artifact://`. Returns `undefined` when * the session is not persisted or the write fails — callers degrade to a * bare placeholder. */ async #saveShakeArtifact(regions: ShakeRegion[]): Promise { const parts: string[] = []; for (let i = 0; i < regions.length; i++) { const region = regions[i]; parts.push(`### region ${i + 1} (${region.label}, ~${region.tokens} tok)`, "", region.originalText, ""); } try { return await this.sessionManager.saveArtifact(parts.join("\n"), "shake"); } catch { return undefined; } } /** * Manually compact the session context. * Aborts current agent operation first. * @param customInstructions Optional instructions for the compaction summary * @param options Optional callbacks for completion/error handling */ async compact(customInstructions?: string, options?: CompactOptions): Promise { if (this.#compactionAbortController) { throw new Error("Compaction already in progress"); } this.#disconnectFromAgent(); await this.abort(); const compactionAbortController = new AbortController(); this.#compactionAbortController = compactionAbortController; try { if (!this.model) { throw new Error("No model selected"); } const compactionSettings = this.settings.getGroup("compaction"); const pathEntries = this.sessionManager.getBranch(); const preparation = prepareCompaction(pathEntries, compactionSettings); if (!preparation) { // Check why we can't compact const lastEntry = pathEntries[pathEntries.length - 1]; if (lastEntry?.type === "compaction") { throw new Error("Already compacted"); } throw new Error("Nothing to compact (session too small)"); } let hookCompaction: CompactionResult | undefined; let fromExtension = false; let preserveData: Record | undefined; if (this.#extensionRunner?.hasHandlers("session_before_compact")) { const result = (await this.#extensionRunner.emit({ type: "session_before_compact", preparation, branchEntries: pathEntries, customInstructions, signal: compactionAbortController.signal, })) as SessionBeforeCompactResult | undefined; if (result?.cancel) { throw new CompactionCancelledError(); } if (result?.compaction) { hookCompaction = result.compaction; fromExtension = true; } } const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction); // Strategy honored on manual /compact too. Custom instructions imply a // directed LLM summary; a text-only model cannot read the frames back — // both take the summarizer path (the latter loudly). const wantsSnapcompact = compactionPrep.kind !== "fromHook" && compactionSettings.strategy === "snapcompact" && !customInstructions; const snapcompactReady = wantsSnapcompact && this.model.input.includes("image"); if (wantsSnapcompact && !snapcompactReady) { this.emitNotice( "warning", `snapcompact needs a vision-capable model (${this.model.id} is text-only) — using an LLM summary instead`, "compaction", ); } let summary: string; let shortSummary: string | undefined; let firstKeptEntryId: string; let tokensBefore: number; let details: unknown; if (compactionPrep.kind === "fromHook") { summary = compactionPrep.summary; shortSummary = compactionPrep.shortSummary; firstKeptEntryId = compactionPrep.firstKeptEntryId; tokensBefore = compactionPrep.tokensBefore; details = compactionPrep.details; preserveData = compactionPrep.preserveData; } else if (snapcompactReady) { const snapcompactResult = await snapcompact.compact(preparation, { convertToLlm, model: this.model, shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")), // Providers with hard image caps (OpenRouter: 8) silently drop // frames past the cap — keep the archive within budget. maxFrames: snapcompact.providerFrameBudget(this.model.provider), }); summary = snapcompactResult.summary; shortSummary = snapcompactResult.shortSummary; firstKeptEntryId = snapcompactResult.firstKeptEntryId; tokensBefore = snapcompactResult.tokensBefore; details = snapcompactResult.details; preserveData = { ...(compactionPrep.preserveData ?? {}), ...(snapcompactResult.preserveData ?? {}) }; } else { // Generate compaction result. Only convert known abort-shaped // rejections (AbortError raised while the abort signal is set, // or an already-typed sentinel) into `CompactionCancelledError` // so downstream callers can discriminate cancel from generic // failure via `instanceof` without inspecting message strings. // Real compaction bugs (network, server, parsing, etc.) keep // their original shape — they must not be silently relabeled // as cancellations even if the signal happens to be aborted // for an unrelated reason. Assignments live inside the try // block because every catch path throws — the post-try reads // of the result-derived locals are reachable only on success. try { const result = await this.#compactWithFallbackModel( preparation, customInstructions, compactionAbortController.signal, { promptOverride: this.#obfuscateTextForProvider(compactionPrep.hookPrompt), extraContext: this.#obfuscateForProvider(compactionPrep.hookContext), remoteInstructions: this.#obfuscateForProvider(this.#baseSystemPrompt.join("\n\n")), convertToLlm: messages => this.#convertToLlmForSideRequest(messages), }, ); summary = result.summary; shortSummary = result.shortSummary; firstKeptEntryId = result.firstKeptEntryId; tokensBefore = result.tokensBefore; details = result.details; preserveData = { ...(compactionPrep.preserveData ?? {}), ...(result.preserveData ?? {}) }; } catch (err) { if (err instanceof CompactionCancelledError) { throw err; } if (compactionAbortController.signal.aborted && err instanceof Error && err.name === "AbortError") { throw new CompactionCancelledError(); } throw err; } } if (compactionAbortController.signal.aborted) { throw new CompactionCancelledError(); } this.sessionManager.appendCompaction( summary, shortSummary, firstKeptEntryId, tokensBefore, details, fromExtension, preserveData, ); const newEntries = this.sessionManager.getEntries(); const sessionContext = this.buildDisplaySessionContext(); this.agent.replaceMessages(sessionContext.messages); this.#syncTodoPhasesFromBranch(); this.#closeCodexProviderSessionsForHistoryRewrite(); // Get the saved compaction entry for the hook const savedCompactionEntry = newEntries.find(e => e.type === "compaction" && e.summary === summary) as | CompactionEntry | undefined; if (this.#extensionRunner && savedCompactionEntry) { await this.#extensionRunner.emit({ type: "session_compact", compactionEntry: savedCompactionEntry, fromExtension, }); } const compactionResult: CompactionResult = { summary, shortSummary, firstKeptEntryId, tokensBefore, details, preserveData, }; options?.onComplete?.(compactionResult); return compactionResult; } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); options?.onError?.(err); throw error; } finally { if (this.#compactionAbortController === compactionAbortController) { this.#compactionAbortController = undefined; } this.#reconnectToAgent(); } } /** * Ask the active memory backend for an extra-context block to splice into * the compaction summary prompt. Both the manual and auto compaction paths * funnel through this helper so the behaviour stays identical. * * Failures are swallowed: a memory backend going sideways MUST NOT block * compaction (which is itself the recovery path for context overflow). */ async #collectMemoryBackendContext(preparation: { messagesToSummarize: AgentMessage[]; turnPrefixMessages: AgentMessage[]; }): Promise { const backend = await resolveMemoryBackend(this.settings); if (!backend.preCompactionContext) return undefined; const messages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages); try { return await backend.preCompactionContext(messages, this.settings, this); } catch (err) { logger.debug("Memory backend preCompactionContext failed", { backend: backend.id, error: String(err), }); return undefined; } } /** * Cancel in-progress context maintenance (manual compaction, auto-compaction, or auto-handoff). */ abortCompaction(): void { this.#compactionAbortController?.abort(); this.#autoCompactionAbortController?.abort(); this.#handoffAbortController?.abort(); } /** Trigger idle compaction through the auto-compaction flow (with UI events). */ async runIdleCompaction(): Promise { if (this.isStreaming || this.isCompacting) return; await this.#runAutoCompaction("idle", false, true); } /** * Cancel in-progress branch summarization. */ abortBranchSummary(): void { this.#branchSummaryAbortController?.abort(); } /** * Cancel in-progress handoff generation. */ abortHandoff(): void { this.#handoffAbortController?.abort(); } /** * Check if handoff generation is in progress. */ get isGeneratingHandoff(): boolean { return this.#handoffAbortController !== undefined; } /** * 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 */ async handoff(customInstructions?: string, options?: SessionHandoffOptions): Promise { const entries = this.sessionManager.getBranch(); const messageCount = entries.filter(e => e.type === "message").length; if (messageCount < 2) { throw new Error("Nothing to hand off (no messages yet)"); } this.#skipPostTurnMaintenanceAssistantTimestamp = undefined; this.#handoffAbortController = new AbortController(); const handoffAbortController = this.#handoffAbortController; const handoffSignal = handoffAbortController.signal; const sourceSignal = options?.signal; const onSourceAbort = () => { if (!handoffSignal.aborted) { handoffAbortController.abort(); } }; if (sourceSignal) { sourceSignal.addEventListener("abort", onSourceAbort, { once: true }); if (sourceSignal.aborted) { onSourceAbort(); } } try { if (handoffSignal.aborted) { throw new Error("Handoff cancelled"); } const model = this.model; if (!model) { throw new Error("No model selected for handoff"); } const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId); if (!apiKey) { throw new Error(`No API key for ${model.provider}`); } const rawHandoffText = await generateHandoff( this.agent.state.messages, model, this.#modelRegistry.resolver(model, this.sessionId), { systemPrompt: this.#obfuscateForProvider(this.#baseSystemPrompt), tools: obfuscateProviderTools(this.#obfuscator, this.agent.state.tools), customInstructions: this.#obfuscateTextForProvider(customInstructions), convertToLlm: messages => this.#convertToLlmForSideRequest(messages), initiatorOverride: "agent", metadata: this.agent.metadataForProvider(model.provider), telemetry: resolveTelemetry(this.agent.telemetry, this.sessionId), // Honor the user's /model thinking selection on the handoff // path. Clamped per-model inside generateHandoff via // resolveCompactionEffort so unsupported-effort models don't // trip requireSupportedEffort. thinkingLevel: this.thinkingLevel, }, handoffSignal, ); const handoffText = this.#deobfuscateFromProvider(rawHandoffText); if (handoffSignal.aborted) { throw new Error("Handoff cancelled"); } if (!handoffText) { return undefined; } // Start a new session const previousSessionFile = this.sessionFile; await this.sessionManager.flush(); this.#cancelOwnAsyncJobs(); await this.sessionManager.newSession(previousSessionFile ? { parentSession: previousSessionFile } : undefined); this.agent.reset(); this.#freshProviderSessionId = undefined; this.#syncAgentSessionId(); this.#rekeyHindsightMemoryForCurrentSessionId(); this.#rekeyMnemopiMemoryForCurrentSessionId(); this.#resetHindsightConversationTrackingIfHindsight(); this.#resetMnemopiConversationTrackingIfMnemopi(); this.#steeringMessages = []; this.#followUpMessages = []; this.#pendingNextTurnMessages = []; this.#scheduledHiddenNextTurnGeneration = undefined; this.#todoReminderCount = 0; // Inject the handoff document as a custom message const handoffContent = createHandoffContext(handoffText); this.sessionManager.appendCustomMessageEntry("handoff", handoffContent, true, undefined, "agent"); await this.sessionManager.ensureOnDisk(); let savedPath: string | undefined; if (options?.autoTriggered && this.settings.get("compaction.handoffSaveToDisk")) { const artifactsDir = this.sessionManager.getArtifactsDir(); if (artifactsDir) { const handoffFilePath = path.join(artifactsDir, createHandoffFileName()); try { await Bun.write(handoffFilePath, `${handoffText}\n`); savedPath = handoffFilePath; } catch (error) { logger.warn("Failed to save handoff document to disk", { path: handoffFilePath, error: error instanceof Error ? error.message : String(error), }); } } else { logger.debug("Skipping handoff document save because session is not persisted"); } } // Rebuild agent messages from session const sessionContext = this.buildDisplaySessionContext(); this.agent.replaceMessages(sessionContext.messages); this.#syncTodoPhasesFromBranch(); return { document: handoffText, savedPath }; } catch (error) { if (handoffSignal.aborted || (error instanceof Error && error.name === "AbortError")) { throw new Error("Handoff cancelled"); } throw error; } finally { sourceSignal?.removeEventListener("abort", onSourceAbort); this.#handoffAbortController = undefined; } } #estimatePendingPromptTokens(messages: AgentMessage[]): number { let tokens = computeNonMessageTokens(this); for (const message of this.messages) { tokens += estimateTokens(message); } for (const message of messages) { tokens += estimateTokens(message); } return tokens; } async #runPrePromptCompactionIfNeeded(messages: AgentMessage[]): Promise { const model = this.model; if (!model) return; const contextWindow = model.contextWindow ?? 0; if (contextWindow <= 0) return; const compactionSettings = this.settings.getGroup("compaction"); const contextTokens = this.#estimatePendingPromptTokens(messages); if (!shouldCompact(contextTokens, contextWindow, compactionSettings)) return; logger.debug("Pre-prompt context maintenance triggered by pending prompt size", { contextTokens, contextWindow, model: `${model.provider}/${model.id}`, }); await this.#runAutoCompaction("threshold", false, false, false, { autoContinue: false }); } /** * Check if context maintenance or promotion is needed and run it. * Called after agent_end and before prompt submission. * * Four cases (in order): * 1. Input overflow + promotion: promote to larger model, retry without maintenance. * 2. Input overflow + no promotion target: run context maintenance, auto-retry on same model. * 3. Output incomplete (stopReason === "length", e.g. `response.incomplete`): the * model burned its output budget without producing an actionable deliverable * (reasoning-only or truncated). Drop the dead turn, try promotion, otherwise * run compaction/handoff and retry. * 4. Threshold: context over threshold, run context maintenance (no auto-retry). * * @param assistantMessage The assistant message to check * @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true * @param allowDefer If true, threshold-driven handoff strategy may schedule itself as a * deferred post-prompt task instead of running inline. Callers running inside the * `agent_end` handler set this to true so `session.prompt()` resolves cleanly; callers * on the pre-prompt path (where the next agent turn is about to start) set it to false * to avoid racing the deferred handoff against the new turn. * @param autoContinue Whether maintenance may schedule the agent-authored continuation prompt. * @returns true when a deferred handoff was scheduled. Callers MUST then skip any * subsequent `#scheduleAgentContinue` / reminder appends for this turn — the * handoff will replace session state and a concurrent `agent.continue()` would * stream into the soon-to-be-discarded session. */ async #checkCompaction( assistantMessage: AssistantMessage, skipAbortedCheck = true, allowDefer = true, autoContinue = true, ): Promise { // Skip if message was aborted (user cancelled) - unless skipAbortedCheck is false if (skipAbortedCheck && assistantMessage.stopReason === "aborted") return false; const contextWindow = this.model?.contextWindow ?? 0; const generation = this.#promptGeneration; // Skip overflow check if the message came from a different model. // This handles the case where user switched from a smaller-context model (e.g. opus) // to a larger-context model (e.g. codex) - the overflow error from the old model // shouldn't trigger compaction for the new model. const sameModel = this.model && assistantMessage.provider === this.model.provider && assistantMessage.model === this.model.id; // This handles the case where an error was kept after compaction (in the "kept" region). // The error shouldn't trigger another compaction since we already compacted. // Example: opus fails -> switch to codex -> compact -> switch back to opus -> opus error // is still in context but shouldn't trigger compaction again. const compactionEntry = getLatestCompactionEntry(this.sessionManager.getBranch()); const errorIsFromBeforeCompaction = compactionEntry !== null && assistantMessage.timestamp < new Date(compactionEntry.timestamp).getTime(); if (sameModel && !errorIsFromBeforeCompaction && isContextOverflow(assistantMessage, contextWindow)) { // Remove the error message from agent state (it IS saved to session for history, // but we don't want it in context for the retry) const messages = this.agent.state.messages; if (messages.length > 0 && messages[messages.length - 1].role === "assistant") { this.agent.replaceMessages(messages.slice(0, -1)); } // Try context promotion first - switch to a larger model and retry without compacting const promoted = await this.#tryContextPromotion(assistantMessage); if (promoted) { // Retry on the promoted (larger) model without compacting this.#scheduleAgentContinue({ delayMs: 100, generation }); return false; } // No promotion target available fall through to compaction const compactionSettings = this.settings.getGroup("compaction"); if (compactionSettings.enabled && compactionSettings.strategy !== "off") { await this.#runAutoCompaction("overflow", true, false, allowDefer, { autoContinue }); } return false; } // Case 3: Output-side incomplete — `response.incomplete` from OpenAI Responses // (and Codex) maps to stopReason === "length". The model burned its // `max_output_tokens` budget on reasoning/text and emitted no actionable // deliverable. Same recovery class as overflow: promotion if available, // otherwise compaction/handoff. Unlike overflow, the *input* is fine, so we // allow the handoff strategy to actually run. if (sameModel && !errorIsFromBeforeCompaction && assistantMessage.stopReason === "length") { const messages = this.agent.state.messages; if (messages.length > 0 && messages[messages.length - 1].role === "assistant") { this.agent.replaceMessages(messages.slice(0, -1)); } const promoted = await this.#tryContextPromotion(assistantMessage); if (promoted) { logger.debug("Context promotion triggered by response.incomplete (length stop)", { from: `${assistantMessage.provider}/${assistantMessage.model}`, }); this.#scheduleAgentContinue({ delayMs: 100, generation }); return false; } const incompleteCompactionSettings = this.settings.getGroup("compaction"); if (incompleteCompactionSettings.enabled && incompleteCompactionSettings.strategy !== "off") { logger.debug("Compaction triggered by response.incomplete (length stop, no promotion target)", { model: `${assistantMessage.provider}/${assistantMessage.model}`, strategy: incompleteCompactionSettings.strategy, }); await this.#runAutoCompaction("incomplete", true, false, allowDefer, { autoContinue, triggerContextTokens: calculateContextTokens(assistantMessage.usage), }); } else { // Neither promotion nor compaction is available — surface the dead-end so // the user understands why the turn yielded with nothing. logger.warn("response.incomplete with no recovery path (promotion + compaction both unavailable)", { model: `${assistantMessage.provider}/${assistantMessage.model}`, }); } return false; } // Supersede pass runs every turn, before any threshold gating: it is cheap // (bails when no candidate) and independent of the compaction setting. const supersedeResult = await this.#pruneSupersededReads(); const compactionSettings = this.settings.getGroup("compaction"); if (!compactionSettings.enabled || compactionSettings.strategy === "off") return false; // Case 4: Threshold - turn succeeded but context is getting large // Skip if this was an error (non-overflow errors don't have usage data) if (assistantMessage.stopReason === "error") return false; const pruneResult = await this.#pruneToolOutputs(); let contextTokens = calculateContextTokens(assistantMessage.usage); if (supersedeResult) { contextTokens = Math.max(0, contextTokens - supersedeResult.tokensSaved); } if (pruneResult) { contextTokens = Math.max(0, contextTokens - pruneResult.tokensSaved); } if (shouldCompact(contextTokens, contextWindow, compactionSettings)) { // Try promotion first — if a larger model is available, switch instead of compacting const promoted = await this.#tryContextPromotion(assistantMessage); if (!promoted) { return await this.#runAutoCompaction("threshold", false, false, allowDefer, { autoContinue, triggerContextTokens: contextTokens, }); } } return false; } #assistantEndedWithSuccessfulYield(assistantMessage: AssistantMessage): boolean { const toolCallId = this.#lastSuccessfulYieldToolCallId; if (!toolCallId) return false; const lastToolCall = assistantMessage.content .slice() .reverse() .find((content): content is ToolCall => content.type === "toolCall"); return lastToolCall?.name === "yield" && lastToolCall.id === toolCallId; } async #handleEmptyAssistantStop(assistantMessage: AssistantMessage): Promise { if (!this.#isEmptyAssistantStop(assistantMessage)) { this.#emptyStopRetryCount = 0; return false; } this.#emptyStopRetryCount++; if (this.#emptyStopRetryCount > EMPTY_STOP_MAX_RETRIES) { logger.warn("Assistant returned empty stop after retry cap", { attempts: this.#emptyStopRetryCount - 1, model: assistantMessage.model, provider: assistantMessage.provider, }); if (this.#retryAttempt > 0) { await this.#emitSessionEvent({ type: "auto_retry_end", success: false, attempt: this.#retryAttempt, finalError: "Assistant returned empty stop after retry cap", }); this.#retryAttempt = 0; } this.#resolveRetry(); // Tool-use orphans corrupt Anthropic message history (tool_result without // matching tool_use). Always remove them even when the retry cap is hit. if (assistantMessage.stopReason === "toolUse") { this.#removeEmptyStopFromActiveContext(assistantMessage); } return true; } this.#removeEmptyStopFromActiveContext(assistantMessage); this.agent.appendMessage({ role: "developer", content: [{ type: "text", text: this.#emptyStopRetryReminder() }], attribution: "agent", timestamp: Date.now(), }); this.#scheduleAgentContinue({ generation: this.#promptGeneration }); return true; } #isEmptyAssistantStop(assistantMessage: AssistantMessage): boolean { switch (assistantMessage.stopReason) { case "stop": for (const content of assistantMessage.content) { if (content.type === "toolCall") return false; if (content.type === "text" && hasNonWhitespace(content.text)) return false; if (content.type === "thinking" && hasNonWhitespace(content.thinking)) return false; } return true; case "toolUse": // An orphaned toolUse stop (no tool_use block) corrupts Anthropic history: // a later tool_result has nothing to anchor to. Thinking alone cannot anchor // a tool_result, so it does not rescue a toolUse stop here. for (const content of assistantMessage.content) { if (content.type === "toolCall") return false; if (content.type === "text" && hasNonWhitespace(content.text)) return false; } return true; default: return false; } } #emptyStopRetryReminder(): string { return prompt.render(emptyStopRetryTemplate, { retryCount: this.#emptyStopRetryCount, maxRetries: EMPTY_STOP_MAX_RETRIES, }); } #removeEmptyStopFromActiveContext(assistantMessage: AssistantMessage): void { const messages = this.agent.state.messages; const lastMessage = messages[messages.length - 1]; if ( lastMessage?.role === "assistant" && this.#isSameAssistantMessage(lastMessage as AssistantMessage, assistantMessage) ) { this.agent.replaceMessages(messages.slice(0, -1)); } const emptyStopEntry = this.sessionManager .getBranch() .slice() .reverse() .find( entry => entry.type === "message" && entry.message.role === "assistant" && this.#isSameAssistantMessage(entry.message as AssistantMessage, assistantMessage), ); if (!emptyStopEntry) { return; } if (emptyStopEntry.parentId === null) { this.sessionManager.resetLeaf(); } else { this.sessionManager.branch(emptyStopEntry.parentId); } } #isSameAssistantMessage(left: AssistantMessage, right: AssistantMessage): boolean { return ( left === right || (left.timestamp === right.timestamp && left.provider === right.provider && left.model === right.model && left.stopReason === right.stopReason) ); } #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; } async #applyRewind(report: string): Promise { const checkpointState = this.#checkpointState; if (!checkpointState) { return; } const safeCount = Math.max(0, Math.min(checkpointState.checkpointMessageCount, this.agent.state.messages.length)); this.agent.replaceMessages(this.agent.state.messages.slice(0, safeCount)); 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 details = { startedAt: checkpointState.startedAt, rewoundAt: new Date().toISOString() }; this.agent.appendMessage({ role: "custom", customType: "rewind-report", content: report, display: false, details, attribution: "agent", timestamp: Date.now(), }); this.sessionManager.appendCustomMessageEntry("rewind-report", report, false, details, "agent"); this.#checkpointState = undefined; this.#pendingRewindReport = undefined; } async #enforcePlanModeToolDecision(): Promise { if (!this.#planModeState?.enabled) { return; } const assistantMessage = this.#findLastAssistantMessage(); if (!assistantMessage) { return; } if (assistantMessage.stopReason === "error" || assistantMessage.stopReason === "aborted") { return; } const calledRequiredTool = assistantMessage.content.some( content => content.type === "toolCall" && (content.name === "ask" || content.name === "resolve"), ); if (calledRequiredTool) { return; } const hasRequiredTools = this.#toolRegistry.has("ask") && this.#toolRegistry.has("resolve"); if (!hasRequiredTools) { logger.warn("Plan mode enforcement skipped because ask/resolve tools are unavailable", { activeToolNames: this.agent.state.tools.map(tool => tool.name), }); return; } const reminder = prompt.render(planModeToolDecisionReminderPrompt, { askToolName: "ask", }); await this.prompt(reminder, { synthetic: true, expandPromptTemplates: false, toolChoice: "required", }); } #createEagerTodoPrelude(promptText: string): { message: AgentMessage; toolChoice: ToolChoice } | undefined { const eagerTodosEnabled = this.settings.get("todo.eager"); const todosEnabled = this.settings.get("todo.enabled"); if (!eagerTodosEnabled || !todosEnabled) { return undefined; } if (this.#planModeState?.enabled) { return undefined; } if (this.getTodoPhases().length > 0) { return undefined; } // Only inject on the first user message of the conversation. Subsequent user // turns must not receive the eager todo reminder — they often correct, clarify, // or redirect the prior task, and forcing a brand-new todo list there is wrong. const hasPriorUserMessage = this.agent.state.messages.some(m => m.role === "user"); if (hasPriorUserMessage) { return undefined; } const trimmedPromptText = promptText.trimEnd(); if (trimmedPromptText.endsWith("?") || trimmedPromptText.endsWith("!")) { return undefined; } // Must check the active tool set, not just the registry: tool discovery // (tools.discoveryMode === "all") can register `todo` while hiding it from // the exposed tools. Forcing a named tool_choice for an inactive tool makes // the provider reject the request (HTTP 400). if (!this.getActiveToolNames().includes("todo")) { logger.warn("Eager todo enforcement skipped because todo is not active", { activeToolNames: this.getActiveToolNames(), }); return undefined; } const todoToolChoice = buildNamedToolChoice("todo", this.model); if (!todoToolChoice) { logger.warn("Eager todo enforcement skipped because the current model does not support forcing todo", { modelApi: this.model?.api, modelId: this.model?.id, }); return undefined; } const eagerTodoReminder = prompt.render(eagerTodoPrompt); return { message: { role: "custom", customType: "eager-todo-prelude", content: eagerTodoReminder, display: false, attribution: "agent", timestamp: Date.now(), }, toolChoice: todoToolChoice, }; } /** * Check if agent stopped with incomplete todos and prompt to continue. */ async #checkTodoCompletion(): Promise { // Skip todo reminders when the most recent turn was driven by an explicit user force — // the user wanted exactly that tool, not a follow-up nag about incomplete todos. const lastServedLabel = this.#toolChoiceQueue.consumeLastServedLabel(); if (lastServedLabel === "user-force") { return; } const remindersEnabled = this.settings.get("todo.reminders"); const todosEnabled = this.settings.get("todo.enabled"); if (!remindersEnabled || !todosEnabled) { this.#todoReminderCount = 0; return; } const remindersMax = this.settings.get("todo.reminders.max"); if (this.#todoReminderCount >= remindersMax) { logger.debug("Todo completion: max reminders reached", { count: this.#todoReminderCount }); return; } const phases = this.getTodoPhases(); if (phases.length === 0) { this.#todoReminderCount = 0; return; } const incompleteByPhase = phases .map(phase => ({ name: phase.name, tasks: phase.tasks .filter( (task): task is TodoItem & { status: "pending" | "in_progress" } => task.status === "pending" || task.status === "in_progress", ) .map(task => ({ content: task.content, status: task.status })), })) .filter(phase => phase.tasks.length > 0); const incomplete = incompleteByPhase.flatMap(phase => phase.tasks); if (incomplete.length === 0) { this.#todoReminderCount = 0; return; } // Build reminder message this.#todoReminderCount++; const todoList = incompleteByPhase .map(phase => `- ${phase.name}\n${phase.tasks.map(task => ` - ${task.content}`).join("\n")}`) .join("\n"); const reminder = `\n` + `You stopped with ${incomplete.length} incomplete todo item(s):\n${todoList}\n\n` + `Please continue working on these tasks or mark them complete if finished.\n` + `(Reminder ${this.#todoReminderCount}/${remindersMax})\n` + ``; logger.debug("Todo completion: sending reminder", { incomplete: incomplete.length, attempt: this.#todoReminderCount, }); // Emit event for UI to render notification await this.#emitSessionEvent({ type: "todo_reminder", todos: incomplete, attempt: this.#todoReminderCount, maxAttempts: remindersMax, }); // Inject reminder and continue the conversation this.agent.appendMessage({ role: "developer", content: [{ type: "text", text: reminder }], attribution: "agent", timestamp: Date.now(), }); this.#scheduleAgentContinue({ generation: this.#promptGeneration }); } /** * Attempt context promotion to a larger model. * Returns true if promotion succeeded (caller should retry without compacting). */ async #tryContextPromotion(assistantMessage: AssistantMessage): Promise { const promotionSettings = this.settings.getGroup("contextPromotion"); if (!promotionSettings.enabled) return false; const currentModel = this.model; if (!currentModel) return false; if (assistantMessage.provider !== currentModel.provider || assistantMessage.model !== currentModel.id) return false; const contextWindow = currentModel.contextWindow ?? 0; if (contextWindow <= 0) return false; const targetModel = await this.#resolveContextPromotionTarget(currentModel, contextWindow); if (!targetModel) return false; try { await this.setModelTemporary(targetModel, undefined, { ephemeral: true }); logger.debug("Context promotion switched model on overflow", { from: `${currentModel.provider}/${currentModel.id}`, to: `${targetModel.provider}/${targetModel.id}`, }); return true; } catch (error) { logger.warn("Context promotion failed", { from: `${currentModel.provider}/${currentModel.id}`, to: `${targetModel.provider}/${targetModel.id}`, error: String(error), }); return false; } } async #resolveContextPromotionTarget(currentModel: Model, contextWindow: number): Promise { const availableModels = this.#modelRegistry.getAvailable(); if (availableModels.length === 0) return undefined; const candidate = this.#resolveContextPromotionConfiguredTarget(currentModel, availableModels); if (!candidate) return undefined; if (modelsAreEqual(candidate, currentModel)) return undefined; if (candidate.contextWindow <= contextWindow) return undefined; const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId); if (!apiKey) return undefined; return candidate; } #setModelWithProviderSessionReset(model: Model): void { const currentModel = this.model; if (currentModel) { this.#closeProviderSessionsForModelSwitch(currentModel, model); } 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); } #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}`); } 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); } } #normalizeProviderReplayValue(value: unknown): unknown { if (Array.isArray(value)) { return value.map(item => this.#normalizeProviderReplayValue(item)); } if (value && typeof value === "object") { return Object.fromEntries( Object.entries(value).map(([key, entryValue]) => [key, this.#normalizeProviderReplayValue(entryValue)]), ); } return value; } #normalizeSessionMessageForProviderReplay(message: AgentMessage): unknown { switch (message.role) { case "user": case "developer": return { role: message.role, content: this.#normalizeProviderReplayValue(message.content), providerPayload: message.providerPayload, }; case "assistant": { const isResponsesFamilyMessage = message.api === "openai-responses" || message.api === "openai-codex-responses"; return { role: message.role, content: isResponsesFamilyMessage && Array.isArray(message.content) ? message.content.flatMap(block => { if (block.type === "thinking") { return []; } if (block.type === "toolCall") { return [ { type: block.type, id: block.id, name: block.name, arguments: block.arguments, }, ]; } if (block.type === "text") { return [{ type: block.type, text: block.text, textSignature: block.textSignature }]; } return [this.#normalizeProviderReplayValue(block)]; }) : this.#normalizeProviderReplayValue(message.content), api: message.api, provider: message.provider, model: message.model, stopReason: message.stopReason, errorMessage: message.errorMessage, providerPayload: isResponsesFamilyMessage ? undefined : message.providerPayload, }; } case "toolResult": return { role: message.role, toolName: message.toolName, toolCallId: message.toolCallId, isError: message.isError, content: this.#normalizeProviderReplayValue(message.content), }; case "bashExecution": return { role: message.role, command: message.command, output: message.output, exitCode: message.exitCode, cancelled: message.cancelled, meta: message.meta ? { truncation: this.#normalizeProviderReplayValue(message.meta.truncation), limits: this.#normalizeProviderReplayValue(message.meta.limits), diagnostics: message.meta.diagnostics ? this.#normalizeProviderReplayValue({ summary: message.meta.diagnostics.summary, messages: message.meta.diagnostics.messages, }) : undefined, } : undefined, excludeFromContext: message.excludeFromContext, }; case "pythonExecution": return { role: message.role, code: message.code, output: message.output, exitCode: message.exitCode, cancelled: message.cancelled, meta: message.meta ? { truncation: this.#normalizeProviderReplayValue(message.meta.truncation), limits: this.#normalizeProviderReplayValue(message.meta.limits), diagnostics: message.meta.diagnostics ? this.#normalizeProviderReplayValue({ summary: message.meta.diagnostics.summary, messages: message.meta.diagnostics.messages, }) : undefined, } : undefined, excludeFromContext: message.excludeFromContext, }; case "custom": case "hookMessage": return { role: message.role, customType: message.customType, content: this.#normalizeProviderReplayValue(message.content), }; case "branchSummary": return { role: message.role, summary: message.summary }; case "compactionSummary": return { role: message.role, summary: message.summary, providerPayload: message.providerPayload, }; case "fileMention": return { role: message.role, files: message.files.map(file => ({ path: file.path, content: file.content, image: file.image, })), }; default: return this.#normalizeProviderReplayValue(message); } } #didSessionMessagesChange(previousMessages: AgentMessage[], nextMessages: AgentMessage[]): boolean { return ( JSON.stringify(previousMessages.map(message => this.#normalizeSessionMessageForProviderReplay(message))) !== JSON.stringify(nextMessages.map(message => this.#normalizeSessionMessageForProviderReplay(message))) ); } #getModelKey(model: Model): string { return `${model.provider}/${model.id}`; } #formatRoleModelValue( role: string, model: Model, selectorOverride?: string, thinkingLevelOverride?: ThinkingLevel, ): string { const modelKey = selectorOverride ?? `${model.provider}/${model.id}`; if (thinkingLevelOverride !== undefined) { return formatModelSelectorValue(modelKey, thinkingLevelOverride); } const existingRoleValue = this.settings.getModelRole(role); if (!existingRoleValue) return modelKey; const thinkingLevel = extractExplicitThinkingSelector(existingRoleValue, this.settings); return formatModelSelectorValue(modelKey, thinkingLevel); } #resolveContextPromotionConfiguredTarget(currentModel: Model, availableModels: Model[]): Model | undefined { const configuredTarget = currentModel.contextPromotionTarget?.trim(); if (!configuredTarget) return undefined; const parsed = parseModelString(configuredTarget); if (parsed) { const explicitModel = availableModels.find(m => m.provider === parsed.provider && m.id === parsed.id); if (explicitModel) return explicitModel; } return availableModels.find(m => m.provider === currentModel.provider && m.id === configuredTarget); } #resolveRoleModelFull( role: string, availableModels: Model[], currentModel: Model | undefined, ): ResolvedModelRoleValue { const roleModelStr = role === "default" ? (this.settings.getModelRole("default") ?? (currentModel ? `${currentModel.provider}/${currentModel.id}` : undefined)) : this.settings.getModelRole(role); if (!roleModelStr) { return { model: undefined, thinkingLevel: undefined, explicitThinkingLevel: false, warning: undefined }; } return resolveModelRoleValue(roleModelStr, availableModels, { settings: this.settings, matchPreferences: getModelMatchPreferences(this.settings), modelRegistry: this.#modelRegistry, }); } #getCompactionModelCandidates(availableModels: Model[]): Model[] { const candidates: Model[] = []; const seen = new Set(); const addCandidate = (model: Model | undefined): void => { if (!model) return; const key = this.#getModelKey(model); if (seen.has(key)) return; seen.add(key); candidates.push(model); }; const currentModel = this.model; // Prefer the active session's model: it's what the user is actively using, // and routing compaction to a different provider (e.g. an OpenAI default // model while the chat is on Anthropic) changes provider-specific behavior // like remote compaction endpoints. Role-based candidates only kick in // as auth fallbacks when the current model has no usable credentials. addCandidate(currentModel); for (const role of MODEL_ROLE_IDS) { addCandidate(this.#resolveRoleModelFull(role, availableModels, currentModel).model); } const sortedByContext = [...availableModels].sort((a, b) => b.contextWindow - a.contextWindow); for (const model of sortedByContext) { if (!seen.has(this.#getModelKey(model))) { addCandidate(model); break; } } return candidates; } #isCompactionAuthFailure(error: unknown): boolean { if (!(error instanceof Error)) return false; // Real provider 401/403 — surfaced as `.status` by the compaction layer // (see `createSummarizationError` in packages/agent/src/compaction/compaction.ts). // Without this branch, an expired/revoked Anthropic key would bypass the // authenticated-fallback path and dump the raw HTTP body into the UI. const status = (error as Error & { status?: number }).status; if (status === 401 || status === 403) return true; // pi-native gateway synthetic for "no credential configured" (issue #986). // Carries no HTTP status, so the legacy message regex stays. return /auth_unavailable|no auth available/i.test(error.message); } #buildCompactionAuthError(): Error { const currentModel = this.model; if (!currentModel) { return new Error( "Compaction requires a model with usable credentials, but no authenticated compaction model is available.", ); } return new Error( `Compaction requires usable credentials for ${currentModel.provider}/${currentModel.id}. ` + `Configure ${currentModel.provider} credentials or assign an authenticated fallback role such as modelRoles.smol.`, ); } async #compactWithFallbackModel( preparation: CompactionPreparation, customInstructions: string | undefined, signal: AbortSignal, options?: SummaryOptions, ): Promise { const candidates = this.#getCompactionModelCandidates(this.#modelRegistry.getAvailable()); const telemetry = resolveTelemetry(this.agent.telemetry, this.sessionId); for (const candidate of candidates) { const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId); if (!apiKey) continue; try { return await compact( this.#obfuscatePreparationForProvider(preparation), candidate, this.#modelRegistry.resolver(candidate, this.sessionId), this.#obfuscateTextForProvider(customInstructions), signal, { ...options, metadata: this.agent.metadataForProvider(candidate.provider), convertToLlm: messages => this.#convertToLlmForSideRequest(messages), telemetry, // Honor the user's /model thinking selection (incl. `off`) on // the manual `/compact` path. Clamped per-model inside compact() // via resolveCompactionEffort so unsupported-effort models // (xai-oauth/grok-build) don't trip requireSupportedEffort. thinkingLevel: this.thinkingLevel, }, ); } catch (error) { if (!this.#isCompactionAuthFailure(error)) { throw error; } } } throw this.#buildCompactionAuthError(); } async #prepareCompactionFromHooks( preparation: CompactionPreparation, hookCompaction: CompactionResult | undefined, ): Promise< | { kind: "fromHook"; summary: string; shortSummary: string | undefined; firstKeptEntryId: string; tokensBefore: number; details: unknown; preserveData: Record | undefined; } | { kind: "needsLlm"; hookContext: string[] | undefined; hookPrompt: string | undefined; preserveData: Record | undefined; } > { let hookContext: string[] | undefined; let hookPrompt: string | undefined; let preserveData: Record | undefined; if (!hookCompaction && this.#extensionRunner?.hasHandlers("session.compacting")) { const compactMessages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages); const result = (await this.#extensionRunner.emit({ type: "session.compacting", sessionId: this.sessionId, messages: compactMessages, })) as { context?: string[]; prompt?: string; preserveData?: Record } | undefined; hookContext = result?.context; hookPrompt = result?.prompt; preserveData = result?.preserveData; } const memoryBackendContext = await this.#collectMemoryBackendContext(preparation); if (memoryBackendContext) { hookContext = hookContext ? [...hookContext, memoryBackendContext] : [memoryBackendContext]; } if (hookCompaction) { preserveData ??= hookCompaction.preserveData; return { kind: "fromHook", summary: hookCompaction.summary, shortSummary: hookCompaction.shortSummary, firstKeptEntryId: hookCompaction.firstKeptEntryId, tokensBefore: hookCompaction.tokensBefore, details: hookCompaction.details, preserveData, }; } return { kind: "needsLlm", hookContext, hookPrompt, preserveData }; } /** * Internal: Run auto-compaction with events. * * @param allowDefer If true (default), threshold-driven handoff strategy is allowed to * schedule itself as a deferred post-prompt task and return `true` immediately. The * caller MUST treat that as "compaction will happen async — do not also schedule * `agent.continue()` for this turn", otherwise the deferred handoff races a fresh * streaming turn (the symptom: "Auto-handoff" loader + assistant message still * streaming). Callers on a path that is about to start a new agent turn (e.g. * the pre-prompt check in `#promptWithMessage`) pass `false` to force inline * execution so the handoff completes before the new turn begins. * @returns true when a deferred handoff was scheduled. Inline runs always return false. */ async #runAutoCompaction( reason: "overflow" | "threshold" | "idle" | "incomplete", willRetry: boolean, deferred = false, allowDefer = true, options: { autoContinue?: boolean; triggerContextTokens?: number } = {}, ): Promise { const compactionSettings = this.settings.getGroup("compaction"); if (compactionSettings.strategy === "off") return false; if (reason !== "idle" && !compactionSettings.enabled) return false; const generation = this.#promptGeneration; const shouldAutoContinue = options.autoContinue !== false && compactionSettings.autoContinue !== false; // Shake runs inline (cheap, no remote LLM). On overflow recovery, if shake // reclaims nothing we fall through to the summary-compaction body below so // the oversized input still gets resolved. if (compactionSettings.strategy === "shake") { const outcome = await this.#runAutoShake( reason, willRetry, generation, shouldAutoContinue, options.triggerContextTokens, ); if (outcome !== "fallback") return false; } // "overflow" and "incomplete" force inline execution because they are recovery // paths the caller wants resolved before scheduling the next turn. "idle" is // triggered by the idle loop and does its own scheduling. if ( !deferred && allowDefer && reason !== "overflow" && reason !== "incomplete" && reason !== "idle" && compactionSettings.strategy === "handoff" ) { this.#schedulePostPromptTask( async signal => { await Promise.resolve(); if (signal.aborted) return; await this.#runAutoCompaction(reason, willRetry, true); }, { generation }, ); return true; } // "overflow" forces context-full because the input itself is broken — a handoff // LLM call would hit the same overflow. "incomplete" is an output-side problem, // so a handoff request on the existing context is still viable. Snapcompact is // safe for every reason (it makes no LLM call at all) but requires a vision // model to be worth anything — fall back to context-full otherwise. let action: "context-full" | "handoff" | "snapcompact" = compactionSettings.strategy === "handoff" && reason !== "overflow" ? "handoff" : "context-full"; if (compactionSettings.strategy === "snapcompact") { if (this.model?.input.includes("image")) { action = "snapcompact"; } else { logger.warn("Snapcompact compaction requires a vision-capable model; falling back to context-full", { model: this.model?.id, }); this.emitNotice( "warning", `snapcompact needs a vision-capable model (${this.model?.id ?? "unknown"} is text-only) — using an LLM summary instead`, "compaction", ); } } await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action }); // Abort any older auto-compaction before installing this run's controller. this.#autoCompactionAbortController?.abort(); const autoCompactionAbortController = new AbortController(); this.#autoCompactionAbortController = autoCompactionAbortController; const autoCompactionSignal = autoCompactionAbortController.signal; try { if (compactionSettings.strategy === "handoff" && reason !== "overflow") { const handoffFocus = AUTO_HANDOFF_THRESHOLD_FOCUS; const handoffResult = await this.handoff(handoffFocus, { autoTriggered: true, signal: this.#autoCompactionAbortController.signal, }); if (!handoffResult) { const aborted = autoCompactionSignal.aborted; if (aborted) { await this.#emitSessionEvent({ type: "auto_compaction_end", action, result: undefined, aborted: true, willRetry: false, }); return false; } logger.warn("Auto-handoff returned no document; falling back to context-full maintenance", { reason, }); action = "context-full"; } if (handoffResult) { await this.#emitSessionEvent({ type: "auto_compaction_end", action, result: undefined, aborted: false, willRetry: false, }); if (!autoCompactionSignal.aborted && reason !== "idle" && shouldAutoContinue) { this.#scheduleAutoContinuePrompt(generation); } return false; } } if (!this.model) { await this.#emitSessionEvent({ type: "auto_compaction_end", action, result: undefined, aborted: false, willRetry: false, skipped: true, }); return false; } const availableModels = this.#modelRegistry.getAvailable(); if (availableModels.length === 0) { await this.#emitSessionEvent({ type: "auto_compaction_end", action, result: undefined, aborted: false, willRetry: false, skipped: true, }); return false; } const pathEntries = this.sessionManager.getBranch(); const preparation = prepareCompaction(pathEntries, compactionSettings); if (!preparation) { await this.#emitSessionEvent({ type: "auto_compaction_end", action, result: undefined, aborted: false, willRetry: false, skipped: true, }); if (!willRetry && this.agent.hasQueuedMessages()) { this.#scheduleAgentContinue({ delayMs: 100, generation, shouldContinue: () => this.agent.hasQueuedMessages(), }); } return false; } let hookCompaction: CompactionResult | undefined; let fromExtension = false; let preserveData: Record | undefined; if (this.#extensionRunner?.hasHandlers("session_before_compact")) { const hookResult = (await this.#extensionRunner.emit({ type: "session_before_compact", preparation, branchEntries: pathEntries, customInstructions: undefined, signal: autoCompactionSignal, })) as SessionBeforeCompactResult | undefined; if (hookResult?.cancel) { await this.#emitSessionEvent({ type: "auto_compaction_end", action, result: undefined, aborted: true, willRetry: false, }); return false; } if (hookResult?.compaction) { hookCompaction = hookResult.compaction; fromExtension = true; } } const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction); let summary: string; let shortSummary: string | undefined; let firstKeptEntryId: string; let tokensBefore: number; let details: unknown; if (compactionPrep.kind === "fromHook") { summary = compactionPrep.summary; shortSummary = compactionPrep.shortSummary; firstKeptEntryId = compactionPrep.firstKeptEntryId; tokensBefore = compactionPrep.tokensBefore; details = compactionPrep.details; preserveData = compactionPrep.preserveData; } else if (action === "snapcompact") { // Local, deterministic: render discarded history onto PNG frames. // No model candidates, no API key, no retry loop. const snapcompactResult = await snapcompact.compact(preparation, { convertToLlm, model: this.model, maxFrames: snapcompact.providerFrameBudget(this.model?.provider), }); summary = snapcompactResult.summary; shortSummary = snapcompactResult.shortSummary; firstKeptEntryId = snapcompactResult.firstKeptEntryId; tokensBefore = snapcompactResult.tokensBefore; details = snapcompactResult.details; preserveData = { ...(compactionPrep.preserveData ?? {}), ...(snapcompactResult.preserveData ?? {}) }; } else { const candidates = this.#getCompactionModelCandidates(availableModels); const retrySettings = this.settings.getGroup("retry"); const telemetry = resolveTelemetry(this.agent.telemetry, this.sessionId); let compactResult: CompactionResult | undefined; let lastError: unknown; for (const candidate of candidates) { const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId); if (!apiKey) continue; let attempt = 0; while (true) { try { compactResult = await compact( this.#obfuscatePreparationForProvider(preparation), candidate, this.#modelRegistry.resolver(candidate, this.sessionId), undefined, autoCompactionSignal, { promptOverride: this.#obfuscateTextForProvider(compactionPrep.hookPrompt), extraContext: this.#obfuscateForProvider(compactionPrep.hookContext), remoteInstructions: this.#obfuscateForProvider(this.#baseSystemPrompt.join("\n\n")), metadata: this.agent.metadataForProvider(candidate.provider), initiatorOverride: "agent", convertToLlm: messages => this.#convertToLlmForSideRequest(messages), telemetry, // Honor the user's /model thinking selection on the // auto-compaction path — the most-fired compaction // site. Clamped per-model inside compact() via // resolveCompactionEffort. thinkingLevel: this.thinkingLevel, }, ); break; } catch (error) { if (autoCompactionSignal.aborted) { throw error; } const message = error instanceof Error ? error.message : String(error); if (this.#isCompactionAuthFailure(error)) { lastError = this.#buildCompactionAuthError(); break; } const retryAfterMs = this.#parseRetryAfterMsFromError(message); const shouldRetry = retrySettings.enabled && attempt < retrySettings.maxRetries && (retryAfterMs !== undefined || this.#isTransientErrorMessage(message) || isUsageLimitError(message)); if (!shouldRetry) { lastError = error; break; } const baseDelayMs = retrySettings.baseDelayMs * 2 ** attempt; const delayMs = retryAfterMs !== undefined ? Math.max(baseDelayMs, retryAfterMs) : baseDelayMs; // If retry delay is too long (>30s), try next candidate instead of waiting const maxAcceptableDelayMs = 30_000; if (delayMs > maxAcceptableDelayMs) { const hasMoreCandidates = candidates.indexOf(candidate) < candidates.length - 1; if (hasMoreCandidates) { logger.warn("Auto-compaction retry delay too long, trying next model", { delayMs, retryAfterMs, error: message, model: `${candidate.provider}/${candidate.id}`, }); lastError = error; break; // Exit retry loop, continue to next candidate } // No more candidates - we have to wait } attempt++; logger.warn("Auto-compaction failed, retrying", { attempt, maxRetries: retrySettings.maxRetries, delayMs, retryAfterMs, error: message, model: `${candidate.provider}/${candidate.id}`, }); await scheduler.wait(delayMs, { signal: autoCompactionSignal }); } } if (compactResult) { break; } } if (!compactResult) { if (lastError) { throw lastError; } throw new Error("Compaction failed: no available model"); } summary = compactResult.summary; shortSummary = compactResult.shortSummary; firstKeptEntryId = compactResult.firstKeptEntryId; tokensBefore = compactResult.tokensBefore; details = compactResult.details; preserveData = { ...(compactionPrep.preserveData ?? {}), ...(compactResult.preserveData ?? {}) }; } if (autoCompactionSignal.aborted) { await this.#emitSessionEvent({ type: "auto_compaction_end", action, result: undefined, aborted: true, willRetry: false, }); return false; } this.sessionManager.appendCompaction( summary, shortSummary, firstKeptEntryId, tokensBefore, details, fromExtension, preserveData, ); const newEntries = this.sessionManager.getEntries(); const sessionContext = this.buildDisplaySessionContext(); this.agent.replaceMessages(sessionContext.messages); this.#syncTodoPhasesFromBranch(); this.#closeCodexProviderSessionsForHistoryRewrite(); // Get the saved compaction entry for the hook const savedCompactionEntry = newEntries.find(e => e.type === "compaction" && e.summary === summary) as | CompactionEntry | undefined; if (this.#extensionRunner && savedCompactionEntry) { await this.#extensionRunner.emit({ type: "session_compact", compactionEntry: savedCompactionEntry, fromExtension, }); } const result: CompactionResult = { summary, shortSummary, firstKeptEntryId, tokensBefore, details, preserveData, }; await this.#emitSessionEvent({ type: "auto_compaction_end", action, result, aborted: false, willRetry }); if (!willRetry && reason !== "idle" && shouldAutoContinue) { this.#scheduleAutoContinuePrompt(generation); } if (willRetry) { const messages = this.agent.state.messages; const lastMsg = messages[messages.length - 1]; if (lastMsg?.role === "assistant") { const lastAssistant = lastMsg as AssistantMessage; // Drop the prior turn before retry when it carries no actionable deliverable: // - "error": failure was kept in history but must not re-enter the next turn's prompt. // - reason === "incomplete" && stopReason === "length": truncated output (typically // reasoning-only) — re-running it produces the same dead-end. const shouldDrop = lastAssistant.stopReason === "error" || (reason === "incomplete" && lastAssistant.stopReason === "length"); if (shouldDrop) { this.agent.replaceMessages(messages.slice(0, -1)); } } this.#scheduleAgentContinue({ delayMs: 100, generation }); } else if (this.agent.hasQueuedMessages()) { // Auto-compaction can complete while follow-up/steering/custom messages are waiting. // Kick the loop so queued messages are actually delivered. this.#scheduleAgentContinue({ delayMs: 100, generation, shouldContinue: () => this.agent.hasQueuedMessages(), }); } } catch (error) { if (autoCompactionSignal.aborted) { await this.#emitSessionEvent({ type: "auto_compaction_end", action, result: undefined, aborted: true, willRetry: false, }); return false; } const errorMessage = error instanceof Error ? error.message : "compaction failed"; await this.#emitSessionEvent({ type: "auto_compaction_end", action, result: undefined, aborted: false, willRetry: false, errorMessage: reason === "overflow" ? `Context overflow recovery failed: ${errorMessage}` : reason === "incomplete" ? `Incomplete response recovery failed: ${errorMessage}` : `Auto-compaction failed: ${errorMessage}`, }); } finally { if (this.#autoCompactionAbortController === autoCompactionAbortController) { this.#autoCompactionAbortController = undefined; } } return false; } /** * Run a shake-strategy auto-maintenance pass. Emits the * `auto_compaction_start`/`auto_compaction_end` pair with a shake `action`, * runs {@link shake} inline against the protect-window config, and schedules * continuation exactly like the context-full tail. * * Returns `"fallback"` only for an overflow recovery where shake reclaimed * nothing (or threw) — the caller then runs the summary-compaction body so * the oversized input still gets resolved. Returns `"handled"` otherwise. */ async #runAutoShake( reason: "overflow" | "threshold" | "idle" | "incomplete", willRetry: boolean, generation: number, autoContinue: boolean, triggerContextTokens?: number, ): Promise<"handled" | "fallback"> { const action = "shake"; await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action }); this.#autoCompactionAbortController?.abort(); const controller = new AbortController(); this.#autoCompactionAbortController = controller; const signal = controller.signal; try { const result = await this.shake("elide", { config: DEFAULT_SHAKE_CONFIG, signal }); if (signal.aborted) { await this.#emitSessionEvent({ type: "auto_compaction_end", action, result: undefined, aborted: true, willRetry: false, }); return "handled"; } const reclaimed = result.toolResultsDropped + result.blocksDropped > 0; // Detect the dead-loop reported in issues #2119/#2275: the threshold check // fires, shake runs, but residual context is still above the configured // threshold. The next agent_end would re-trigger shake, which has nothing // new to drop on the second pass, so the loop spins until the user kills it. // Same hazard for "incomplete" (the retry would re-hit the length cap) and // for the existing "overflow + nothing reclaimed" case. In every recovery // reason we hand off to the summarization-driven context-full path so the // situation actually resolves; "idle" is exempt because its 60s+ timer // re-checks usage before re-firing and cannot dead-loop on its own. // // #2275: the post-shake check MUST be anchored on the same metric that // triggered compaction. The local estimator (`#estimatePendingPromptTokens`) // undercounts thinking-signature payloads, so on thinking-heavy sessions it // reads well below the provider-reported usage that fired the threshold. // When that estimate slips under the threshold, the fallback never fires // and the auto-continue prompt re-injects every turn. Prefer the trigger's // own `contextTokens` (provider-anchored) when the caller supplies it, and // add hysteresis (80% recovery band) so we don't oscillate at the boundary // while shake keeps reclaiming a trickle of the previous turn's output. const contextWindow = this.model?.contextWindow ?? 0; const compactionSettings = this.settings.getGroup("compaction"); let stillOverThreshold = false; if (contextWindow > 0) { if (typeof triggerContextTokens === "number" && Number.isFinite(triggerContextTokens)) { const correctedTokens = Math.max(0, triggerContextTokens - result.tokensFreed); const thresholdTokens = resolveThresholdTokens(contextWindow, compactionSettings); const recoveryBand = Math.floor(thresholdTokens * SHAKE_RECOVERY_BAND); stillOverThreshold = correctedTokens > recoveryBand; } else { const postShakeTokens = this.#estimatePendingPromptTokens([]); stillOverThreshold = shouldCompact(postShakeTokens, contextWindow, compactionSettings); } } const shouldFallBack = reason !== "idle" && ((reason === "overflow" && !reclaimed) || stillOverThreshold); if (shouldFallBack) { const errorMessage = reclaimed ? `Auto-shake reclaimed ~${result.tokensFreed} tokens but context is still above the threshold; falling back to context-full compaction.` : "Auto-shake found nothing eligible to drop; falling back to context-full compaction."; await this.#emitSessionEvent({ type: "auto_compaction_end", action, result: undefined, aborted: false, willRetry: false, skipped: !reclaimed, errorMessage, }); return "fallback"; } await this.#emitSessionEvent({ type: "auto_compaction_end", action, result: undefined, aborted: false, willRetry, skipped: !reclaimed, }); if (!willRetry && reason !== "idle" && autoContinue) { this.#scheduleAutoContinuePrompt(generation); } if (willRetry) { // The shake rebuild replays every entry, so a trailing error/length // assistant from the failed turn re-enters agent state — drop it before // retrying, same as the context-full tail. const messages = this.agent.state.messages; const lastMsg = messages[messages.length - 1]; if (lastMsg?.role === "assistant") { const lastAssistant = lastMsg as AssistantMessage; const shouldDrop = lastAssistant.stopReason === "error" || (reason === "incomplete" && lastAssistant.stopReason === "length"); if (shouldDrop) this.agent.replaceMessages(messages.slice(0, -1)); } this.#scheduleAgentContinue({ delayMs: 100, generation }); } else if (this.agent.hasQueuedMessages()) { this.#scheduleAgentContinue({ delayMs: 100, generation, shouldContinue: () => this.agent.hasQueuedMessages(), }); } return "handled"; } catch (error) { if (signal.aborted) { await this.#emitSessionEvent({ type: "auto_compaction_end", action, result: undefined, aborted: true, willRetry: false, }); return "handled"; } const message = error instanceof Error ? error.message : "shake failed"; await this.#emitSessionEvent({ type: "auto_compaction_end", action, result: undefined, aborted: false, willRetry: false, errorMessage: `Auto-shake failed: ${message}`, }); // Overflow still needs recovery even if shake threw. return reason === "overflow" ? "fallback" : "handled"; } finally { if (this.#autoCompactionAbortController === controller) { this.#autoCompactionAbortController = undefined; } } } /** * Toggle auto-compaction setting. */ setAutoCompactionEnabled(enabled: boolean): void { this.settings.set("compaction.enabled", enabled); if (enabled && this.settings.get("compaction.strategy") === "off") { this.settings.set("compaction.strategy", "context-full"); } } /** Whether auto-compaction is enabled */ get autoCompactionEnabled(): boolean { return this.settings.get("compaction.enabled") && this.settings.get("compaction.strategy") !== "off"; } // ========================================================================= // Auto-Retry // ========================================================================= /** * Check if an error is retryable (transient errors or usage limits). * Context overflow errors are NOT retryable (handled by compaction instead). * Usage-limit errors are retryable because the retry handler performs credential switching. */ #isRetryableError(message: AssistantMessage): boolean { if (message.stopReason !== "error" || !message.errorMessage) return false; // Context overflow is handled by compaction, not retry const contextWindow = this.model?.contextWindow ?? 0; if (isContextOverflow(message, contextWindow)) return false; if (this.#isClassifierRefusal(message)) return true; if (this.#isStaleOpenAIResponsesReplayError(message)) return true; const err = message.errorMessage; return this.#isTransientErrorMessage(err) || isUsageLimitError(err); } #isStaleOpenAIResponsesReplayError(message: AssistantMessage): boolean { const currentApi = this.model?.api; if ( message.api !== "openai-responses" && message.api !== "openai-codex-responses" && currentApi !== "openai-responses" && currentApi !== "openai-codex-responses" ) { return false; } const errorMessage = message.errorMessage; if (!errorMessage) return false; return ( /\bItem with id ['"][^'"]+['"] not found\.?/i.test(errorMessage) || (/previous[ _]?response/i.test(errorMessage) && /not[ _]?found|invalid|expired|stale|zero[ _-]?data[ _-]?retention/i.test(errorMessage)) ); } #isClassifierRefusal(message: AssistantMessage): boolean { if (message.stopReason !== "error") return false; const stopType = message.stopDetails?.type; return stopType === "refusal" || stopType === "sensitive"; } #isTransientErrorMessage(errorMessage: string): boolean { return ( this.#isTransientEnvelopeErrorMessage(errorMessage) || this.#isTransientTransportErrorMessage(errorMessage) ); } #isTransientEnvelopeErrorMessage(errorMessage: string): boolean { // Match Anthropic stream-envelope failures that indicate a broken stream before any content starts. return /anthropic stream envelope error:/i.test(errorMessage) && /before message_start/i.test(errorMessage); } #isTransientTransportErrorMessage(errorMessage: string): boolean { // Match: overloaded_error, provider returned error, rate limit, 429, 500, 502, 503, 504, // service unavailable, provider-suggested retry, network/connection/socket errors, fetch failed, // gateway upstream failures, terminated, retry delay exceeded, Bun HTTP/2 stream resets // (RST_STREAM / REFUSED_STREAM / ENHANCE_YOUR_CALM, surfaced verbatim from // src/http/h2_client/dispatch.zig) return ( isUnexpectedSocketCloseMessage(errorMessage) || /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|retry your request|network.?error|connection.?error|connection.?refused|other side closed|fetch failed|upstream.?connect|upstream.?request.?failed|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response|HTTP2(?:StreamReset|RefusedStream|EnhanceYourCalm)/i.test( errorMessage, ) ); } #getRetryFallbackChains(): RetryFallbackChains { const configuredChains = this.settings.get("retry.fallbackChains"); if (!configuredChains || typeof configuredChains !== "object") return {}; return configuredChains as RetryFallbackChains; } #validateRetryFallbackChains(): void { const configuredChains = this.settings.get("retry.fallbackChains"); if (configuredChains === undefined) return; if (!configuredChains || typeof configuredChains !== "object" || Array.isArray(configuredChains)) { const msg = "retry.fallbackChains must be a mapping of role names to selector arrays."; logger.warn(msg); this.configWarnings.push(msg); return; } for (const [role, chain] of Object.entries(configuredChains)) { if (!Array.isArray(chain)) { const msg = `Fallback chain for role '${role}' must be an array of selector strings.`; logger.warn(msg); this.configWarnings.push(msg); continue; } for (const selectorStr of chain) { if (typeof selectorStr !== "string") { const msg = `Fallback chain for role '${role}' contains a non-string selector.`; logger.warn(msg); this.configWarnings.push(msg); continue; } const parsed = parseRetryFallbackSelector(selectorStr); if (!parsed) { const msg = `Invalid fallback selector format in role '${role}': ${selectorStr}`; logger.warn(msg); this.configWarnings.push(msg); continue; } const exists = this.#modelRegistry.find(parsed.provider, parsed.id); if (!exists) { const msg = `Fallback chain for role '${role}' references unknown model: ${selectorStr}`; logger.warn(msg); this.configWarnings.push(msg); } } } } #getRetryFallbackRevertPolicy(): RetryFallbackRevertPolicy { return this.settings.get("retry.fallbackRevertPolicy") === "never" ? "never" : "cooldown-expiry"; } #getRetryFallbackPrimarySelector(role: string): RetryFallbackSelector | undefined { const configuredSelector = this.settings.getModelRole(role); return configuredSelector ? parseRetryFallbackSelector(configuredSelector) : undefined; } #clearActiveRetryFallback(): void { this.#activeRetryFallback = undefined; } #isRetryFallbackSelectorSuppressed(selector: RetryFallbackSelector): boolean { return this.#modelRegistry.isSelectorSuppressed(selector.raw); } #noteRetryFallbackCooldown(currentSelector: string, retryAfterMs: number | undefined, errorMessage: string): void { let cooldownMs = retryAfterMs; if (!cooldownMs || cooldownMs <= 0) { const reason = parseRateLimitReason(errorMessage); cooldownMs = reason === "UNKNOWN" ? 5 * 60 * 1000 : calculateRateLimitBackoffMs(reason); } this.#modelRegistry.suppressSelector(currentSelector, Date.now() + cooldownMs); } #resolveRetryFallbackRole(currentSelector: string): string | undefined { const parsedCurrent = parseRetryFallbackSelector(currentSelector); if (!parsedCurrent) return undefined; const currentBaseSelector = formatRetryFallbackBaseSelector(parsedCurrent); for (const role of Object.keys(this.#getRetryFallbackChains())) { const primarySelector = this.#getRetryFallbackPrimarySelector(role); if (!primarySelector) continue; if (primarySelector.raw === currentSelector) return role; if (formatRetryFallbackBaseSelector(primarySelector) === currentBaseSelector) return role; } return undefined; } #getRetryFallbackEffectiveChain(role: string): RetryFallbackSelector[] { const primarySelector = this.#getRetryFallbackPrimarySelector(role); if (!primarySelector) return []; const chain = [primarySelector]; const seen = new Set([primarySelector.raw]); for (const selector of this.#getRetryFallbackChains()[role] ?? []) { const parsed = parseRetryFallbackSelector(selector); if (!parsed || seen.has(parsed.raw)) continue; seen.add(parsed.raw); chain.push(parsed); } return chain; } #findRetryFallbackCandidates(role: string, currentSelector: string): RetryFallbackSelector[] { const chain = this.#getRetryFallbackEffectiveChain(role); if (chain.length <= 1) return []; const parsedCurrent = parseRetryFallbackSelector(currentSelector); const currentBaseSelector = parsedCurrent ? formatRetryFallbackBaseSelector(parsedCurrent) : undefined; const exactIndex = chain.findIndex(selector => selector.raw === currentSelector); if (exactIndex >= 0) return chain.slice(exactIndex + 1); const baseIndex = currentBaseSelector ? chain.findIndex(selector => formatRetryFallbackBaseSelector(selector) === currentBaseSelector) : -1; if (baseIndex >= 0) return chain.slice(baseIndex + 1); return chain.slice(1); } async #applyRetryFallbackCandidate( role: string, selector: RetryFallbackSelector, currentSelector: string, options?: { pinFallback?: boolean }, ): Promise { const candidate = this.#modelRegistry.find(selector.provider, selector.id); if (!candidate) { throw new Error(`Retry fallback model not found: ${selector.raw}`); } const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId); if (!apiKey) { throw new Error(`No API key for retry fallback ${selector.raw}`); } // Capture the configured selector (auto-aware) so a fallback chain preserves // `auto` instead of collapsing it to the level it resolved to this turn. const currentThinkingLevel = this.configuredThinkingLevel(); const nextThinkingLevel = selector.thinkingLevel ?? currentThinkingLevel; this.#setModelWithProviderSessionReset(candidate); this.sessionManager.appendModelChange(`${candidate.provider}/${candidate.id}`, EPHEMERAL_MODEL_CHANGE_ROLE); this.settings.getStorage()?.recordModelUsage(`${candidate.provider}/${candidate.id}`); this.setThinkingLevel(nextThinkingLevel); if (!this.#activeRetryFallback) { this.#activeRetryFallback = { role, originalSelector: currentSelector, originalThinkingLevel: currentThinkingLevel, lastAppliedFallbackThinkingLevel: nextThinkingLevel, pinned: options?.pinFallback === true, }; } else { this.#activeRetryFallback.lastAppliedFallbackThinkingLevel = nextThinkingLevel; this.#activeRetryFallback.pinned = this.#activeRetryFallback.pinned || options?.pinFallback === true; } await this.#emitSessionEvent({ type: "retry_fallback_applied", from: currentSelector, to: selector.raw, role, }); } async #tryRetryModelFallback(currentSelector: string, options?: { pinFallback?: boolean }): Promise { const role = this.#activeRetryFallback?.role ?? this.#resolveRetryFallbackRole(currentSelector); if (!role) return false; for (const selector of this.#findRetryFallbackCandidates(role, currentSelector)) { if (this.#isRetryFallbackSelectorSuppressed(selector)) continue; const candidate = this.#modelRegistry.find(selector.provider, selector.id); if (!candidate) continue; const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId); if (!apiKey) continue; await this.#applyRetryFallbackCandidate(role, selector, currentSelector, options); return true; } return false; } async #maybeRestoreRetryFallbackPrimary(): Promise { if (!this.#activeRetryFallback) return; if (this.#activeRetryFallback.pinned) return; if (this.#getRetryFallbackRevertPolicy() !== "cooldown-expiry") return; const { originalSelector: originalSelectorRaw, originalThinkingLevel, lastAppliedFallbackThinkingLevel, } = this.#activeRetryFallback; const originalSelector = parseRetryFallbackSelector(originalSelectorRaw); if (!originalSelector) { this.#clearActiveRetryFallback(); return; } const currentModel = this.model; if (!currentModel) return; const currentSelector = formatRetryFallbackSelector(currentModel, this.thinkingLevel); if (currentSelector === originalSelector.raw) { if (!this.#isRetryFallbackSelectorSuppressed(originalSelector)) { this.#clearActiveRetryFallback(); } return; } if (this.#isRetryFallbackSelectorSuppressed(originalSelector)) return; const primaryModel = this.#modelRegistry.find(originalSelector.provider, originalSelector.id); if (!primaryModel) return; const apiKey = await this.#modelRegistry.getApiKey(primaryModel, this.sessionId); if (!apiKey) return; const currentThinkingLevel = this.configuredThinkingLevel(); const thinkingToApply = currentThinkingLevel === lastAppliedFallbackThinkingLevel ? originalThinkingLevel : currentThinkingLevel; this.#setModelWithProviderSessionReset(primaryModel); this.sessionManager.appendModelChange(`${primaryModel.provider}/${primaryModel.id}`, EPHEMERAL_MODEL_CHANGE_ROLE); this.settings.getStorage()?.recordModelUsage(`${primaryModel.provider}/${primaryModel.id}`); this.setThinkingLevel(thinkingToApply); this.#clearActiveRetryFallback(); } #parseRetryAfterMsFromError(errorMessage: string): number | undefined { const now = Date.now(); const retryAfterMsMatch = /retry-after-ms\s*[:=]\s*(\d+)/i.exec(errorMessage); if (retryAfterMsMatch) { return Math.max(0, Number(retryAfterMsMatch[1])); } const retryAfterMatch = /retry-after\s*[:=]\s*([^\s,;]+)/i.exec(errorMessage); if (retryAfterMatch) { const value = retryAfterMatch[1]; const seconds = Number(value); if (!Number.isNaN(seconds)) { return Math.max(0, seconds * 1000); } const dateMs = Date.parse(value); if (!Number.isNaN(dateMs)) { return Math.max(0, dateMs - now); } } const retryHintMs = extractRetryHint(undefined, errorMessage); if (retryHintMs !== undefined) { return retryHintMs; } const resetMsMatch = /x-ratelimit-reset-ms\s*[:=]\s*(\d+)/i.exec(errorMessage); if (resetMsMatch) { const resetMs = Number(resetMsMatch[1]); if (!Number.isNaN(resetMs)) { if (resetMs > 1_000_000_000_000) { return Math.max(0, resetMs - now); } return Math.max(0, resetMs); } } const resetMatch = /x-ratelimit-reset\s*[:=]\s*(\d+)/i.exec(errorMessage); if (resetMatch) { const resetSeconds = Number(resetMatch[1]); if (!Number.isNaN(resetSeconds)) { if (resetSeconds > 1_000_000_000) { return Math.max(0, resetSeconds * 1000 - now); } return Math.max(0, resetSeconds * 1000); } } // Smart Fallback if no exact headers found return undefined; } /** * Handle retryable errors with exponential backoff. * @returns true if retry was initiated, false if max retries exceeded or disabled */ async #handleRetryableError(message: AssistantMessage): Promise { const retrySettings = this.settings.getGroup("retry"); if (!retrySettings.enabled) return false; const classifierRefusal = this.#isClassifierRefusal(message); const generation = this.#promptGeneration; this.#retryAttempt++; // Create retry promise on first attempt so waitForRetry() can await it // Ensure only one promise exists (avoid orphaned promises from concurrent calls) if (!this.#retryPromise) { const { promise, resolve } = Promise.withResolvers(); this.#retryPromise = promise; this.#retryResolve = resolve; } if (this.#retryAttempt > retrySettings.maxRetries) { // Max retries exceeded, emit final failure and reset await this.#emitSessionEvent({ type: "auto_retry_end", success: false, attempt: this.#retryAttempt - 1, finalError: message.errorMessage, }); this.#retryAttempt = 0; this.#resolveRetry(); // Resolve so waitForRetry() completes return false; } const errorMessage = message.errorMessage || "Unknown error"; const staleOpenAIResponsesReplayError = this.#isStaleOpenAIResponsesReplayError(message); const parsedRetryAfterMs = this.#parseRetryAfterMsFromError(errorMessage); let delayMs = staleOpenAIResponsesReplayError ? 0 : calculateRetryBackoffDelayMs(retrySettings.baseDelayMs, this.#retryAttempt); let switchedCredential = false; let switchedModel = false; // Set when a usage-limit error pinned the wait to credential // availability — suppresses the generic retry-after bump below. let usageLimitWaitMs: number | undefined; if (staleOpenAIResponsesReplayError) { this.#resetCurrentResponsesProviderSession("stale replay error"); } if (this.model && !staleOpenAIResponsesReplayError && isUsageLimitError(errorMessage)) { const retryAfterMs = parsedRetryAfterMs ?? calculateRateLimitBackoffMs(parseRateLimitReason(errorMessage)); const outcome = await this.#modelRegistry.authStorage.markUsageLimitReached( this.model.provider, this.sessionId, { retryAfterMs, baseUrl: this.model.baseUrl, modelId: this.model.id, }, ); if (outcome.switched) { switchedCredential = true; delayMs = 0; } else if (await this.#maybeAutoRedeemCodexReset()) { // A live usage-limit 429 on the active Codex account, with a banked // reset and the opt-in setting on: spend the reset and retry // immediately instead of waiting out the window. Runs after the // free sibling-switch above and before model fallback below. switchedCredential = true; delayMs = 0; } else { // No sibling credential is usable right now. Wait for whichever // comes first: the provider's retry-after window for the current // account, or the earliest moment a temporarily blocked sibling // frees up (e.g. a 60s post-401 block or a 5-min usage-probe // block) — the next attempt's getApiKey re-ranks and picks it up. // Without this, one short-lived sibling block escalates a // recoverable situation into the provider's multi-hour wait and // trips the fail-fast cap below. usageLimitWaitMs = retryAfterMs; if (outcome.retryAtMs !== undefined) { const siblingWaitMs = Math.max(0, outcome.retryAtMs - Date.now()) + SIBLING_UNBLOCK_BUFFER_MS; if (siblingWaitMs < usageLimitWaitMs) { usageLimitWaitMs = siblingWaitMs; } } if (usageLimitWaitMs > delayMs) { delayMs = usageLimitWaitMs; } } } const currentSelector = this.model ? formatRetryFallbackSelector(this.model, this.thinkingLevel) : undefined; if (!staleOpenAIResponsesReplayError && !switchedCredential && currentSelector) { if (retrySettings.modelFallback) { if (!classifierRefusal) { this.#noteRetryFallbackCooldown(currentSelector, parsedRetryAfterMs, errorMessage); } switchedModel = await this.#tryRetryModelFallback(currentSelector, { pinFallback: classifierRefusal }); } if (switchedModel) { delayMs = 0; } else if (usageLimitWaitMs === undefined && parsedRetryAfterMs && parsedRetryAfterMs > delayMs) { delayMs = parsedRetryAfterMs; } } if (classifierRefusal && !switchedModel) { this.#retryAttempt = 0; this.#resolveRetry(); return false; } // Fail-fast cap: if the provider asks us to wait longer than // retry.maxDelayMs and we have no fallback credential or model to // switch to, surface the error instead of sleeping. Defends against // 3-hour Anthropic rate-limit windows that would otherwise leave a // subagent (or interactive session) silently hung. The original // assistant error message is preserved in agent state so the caller // can act on it. const maxDelayMs = retrySettings.maxDelayMs; if (maxDelayMs > 0 && delayMs > maxDelayMs && !switchedCredential && !switchedModel) { const attempt = this.#retryAttempt; this.#retryAttempt = 0; await this.#emitSessionEvent({ type: "auto_retry_end", success: false, attempt, finalError: `Provider requested ${delayMs}ms wait, exceeds retry.maxDelayMs (${maxDelayMs}ms). Original error: ${errorMessage}`, }); this.#resolveRetry(); return false; } await this.#emitSessionEvent({ type: "auto_retry_start", attempt: this.#retryAttempt, maxAttempts: retrySettings.maxRetries, delayMs, errorMessage, }); // Remove error message from agent state (keep in session for history) const messages = this.agent.state.messages; if (messages.length > 0 && messages[messages.length - 1].role === "assistant") { this.agent.replaceMessages(messages.slice(0, -1)); } // Wait with exponential backoff (abortable). const retryAbortController = new AbortController(); this.#retryAbortController?.abort(); this.#retryAbortController = retryAbortController; try { await scheduler.wait(delayMs, { signal: retryAbortController.signal }); } catch { if (this.#retryAbortController !== retryAbortController) { return false; } // Aborted during sleep - emit end event so UI can clean up const attempt = this.#retryAttempt; this.#retryAttempt = 0; this.#retryAbortController = undefined; await this.#emitSessionEvent({ type: "auto_retry_end", success: false, attempt, finalError: "Retry cancelled", }); this.#resolveRetry(); return false; } if (this.#retryAbortController === retryAbortController) { this.#retryAbortController = undefined; } // Retry via continue() outside the agent_end event callback chain. this.#scheduleAgentContinue({ delayMs: 1, generation }); return true; } /** * Cancel in-progress retry. */ abortRetry(): void { this.#retryAbortController?.abort(); // Note: _retryAttempt is reset in the catch block of _autoRetry this.#resolveRetry(); } async #promptAgentWithIdleRetry(messages: AgentMessage[], options?: { toolChoice?: ToolChoice }): Promise { const deadline = Date.now() + 30_000; for (;;) { try { await this.agent.prompt(messages, options); return; } catch (err) { if (!(err instanceof AgentBusyError)) { throw err; } if (Date.now() >= deadline) { throw new Error("Timed out waiting for prior agent run to finish before prompting."); } await this.agent.waitForIdle(); } } } /** Whether auto-retry is currently in progress */ get isRetrying(): boolean { return this.#retryPromise !== undefined; } /** Whether auto-retry is enabled */ get autoRetryEnabled(): boolean { return this.settings.get("retry.enabled") ?? true; } /** * Toggle auto-retry setting. */ setAutoRetryEnabled(enabled: boolean): void { this.settings.set("retry.enabled", enabled); } /** * Manually retry the last failed assistant turn. * Removes the error message from agent state and re-attempts with a fresh retry budget. * @returns true if retry was initiated, false if no failed turn to retry or agent is busy */ async retry(): Promise { if (this.isStreaming || this.isCompacting || this.isRetrying) return false; const messages = this.agent.state.messages; const lastMsg = messages[messages.length - 1]; if (lastMsg?.role !== "assistant") return false; const assistantMsg = lastMsg as AssistantMessage; if (assistantMsg.stopReason !== "error" && assistantMsg.stopReason !== "aborted") return false; // Remove the failed/aborted assistant message (same as auto-retry does before re-attempting) this.agent.replaceMessages(messages.slice(0, -1)); // Reset retry budget for a fresh attempt this.#retryAttempt = 0; // Re-attempt the turn this.#scheduleAgentContinue({ delayMs: 1 }); return true; } // ========================================================================= // Bash Execution // ========================================================================= async #saveBashOriginalArtifact(originalText: string): Promise { try { return await this.sessionManager.saveArtifact(originalText, "bash-original"); } catch { return undefined; } } /** * Execute a bash command. * Adds result to agent context and session. * @param command The bash command to execute * @param onChunk Optional streaming callback for output * @param options.excludeFromContext If true, command output won't be sent to LLM (!! prefix) * @param options.useUserShell If true, allow caller to request configured user-shell routing */ async executeBash( command: string, onChunk?: (chunk: string) => void, options?: { excludeFromContext?: boolean; useUserShell?: boolean }, ): Promise { const excludeFromContext = options?.excludeFromContext === true; const cwd = this.sessionManager.getCwd(); if (this.#extensionRunner?.hasHandlers("user_bash")) { const hookResult = await this.#extensionRunner.emitUserBash({ type: "user_bash", command, excludeFromContext, cwd, }); if (hookResult?.result) { this.recordBashResult(command, hookResult.result, options); return hookResult.result; } } const abortController = new AbortController(); this.#bashAbortControllers.add(abortController); try { const result = await executeBashCommand(command, { onChunk, signal: abortController.signal, sessionKey: this.sessionId, timeout: clampTimeout("bash") * 1000, onMinimizedSave: originalText => this.#saveBashOriginalArtifact(originalText), useUserShell: options?.useUserShell, }); this.recordBashResult(command, result, options); return result; } finally { this.#bashAbortControllers.delete(abortController); } } /** * Record a bash execution result in session history. * Used by executeBash and by extensions that handle bash execution themselves. */ recordBashResult(command: string, result: BashResult, options?: { excludeFromContext?: boolean }): void { const meta = outputMeta().truncationFromSummary(result, { direction: "tail" }).get(); const bashMessage: BashExecutionMessage = { role: "bashExecution", command, output: result.output, exitCode: result.exitCode, cancelled: result.cancelled, truncated: result.truncated, meta, timestamp: Date.now(), excludeFromContext: options?.excludeFromContext, }; // If agent is streaming, defer adding to avoid breaking tool_use/tool_result ordering if (this.isStreaming) { // Queue for later - will be flushed on agent_end this.#pendingBashMessages.push(bashMessage); } else { // Add to agent state immediately this.agent.appendMessage(bashMessage); // Save to session this.sessionManager.appendMessage(bashMessage); } } /** * Cancel running bash command. */ abortBash(): void { for (const abortController of this.#bashAbortControllers) { abortController.abort(); } } /** Whether a bash command is currently running */ get isBashRunning(): boolean { return this.#bashAbortControllers.size > 0; } /** Whether there are pending bash messages waiting to be flushed */ get hasPendingBashMessages(): boolean { return this.#pendingBashMessages.length > 0; } /** * Flush pending bash messages to agent state and session. * Called after agent turn completes to maintain proper message ordering. */ #flushPendingBashMessages(): void { if (this.#pendingBashMessages.length === 0) return; for (const bashMessage of this.#pendingBashMessages) { // Add to agent state this.agent.appendMessage(bashMessage); // Save to session this.sessionManager.appendMessage(bashMessage); } this.#pendingBashMessages = []; } // ========================================================================= // 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) */ async executePython( code: string, onChunk?: (chunk: string) => void, options?: { excludeFromContext?: boolean }, ): Promise { const excludeFromContext = options?.excludeFromContext === true; const cwd = this.sessionManager.getCwd(); this.assertEvalExecutionAllowed(); const abortController = new AbortController(); const execution = (async (): Promise => { if (this.#extensionRunner?.hasHandlers("user_python")) { const hookResult = await this.#extensionRunner.emitUserPython({ type: "user_python", code, excludeFromContext, cwd, }); this.assertEvalExecutionAllowed(); if (hookResult?.result) { this.recordPythonResult(code, hookResult.result, options); return hookResult.result; } } // Use the same session ID as eval's Python backend for kernel sharing. const sessionId = this.getEvalSessionId() ?? defaultEvalSessionId({ cwd, getSessionFile: () => this.sessionManager.getSessionFile() ?? null, }); const result = await executePythonCommand(code, { cwd, sessionId: namespacePythonSessionId(sessionId), kernelOwnerId: this.#evalKernelOwnerId, kernelMode: this.settings.get("python.kernelMode"), interpreter: this.settings.get("python.interpreter")?.trim() || undefined, onChunk, signal: abortController.signal, }); this.recordPythonResult(code, result, options); return result; })(); return await this.trackEvalExecution(execution, abortController); } assertEvalExecutionAllowed(): void { if (this.#evalExecutionDisposing) { throw new Error("Python execution is unavailable while session disposal is in progress"); } } /** * Track Python work started outside AgentSession.executePython so dispose can await and abort it too. */ trackEvalExecution(execution: Promise, abortController: AbortController): Promise { this.#evalAbortControllers.add(abortController); this.#activeEvalExecutions.add(execution); void execution.then( () => { this.#evalAbortControllers.delete(abortController); this.#activeEvalExecutions.delete(execution); }, () => { this.#evalAbortControllers.delete(abortController); this.#activeEvalExecutions.delete(execution); }, ); return execution; } /** * Record a Python execution result in session history. */ recordPythonResult(code: string, result: PythonResult, options?: { excludeFromContext?: boolean }): void { const meta = outputMeta().truncationFromSummary(result, { direction: "tail" }).get(); const pythonMessage: PythonExecutionMessage = { role: "pythonExecution", code, output: result.output, exitCode: result.exitCode, cancelled: result.cancelled, truncated: result.truncated, meta, timestamp: Date.now(), excludeFromContext: options?.excludeFromContext, }; // If agent is streaming, defer adding to avoid breaking tool_use/tool_result ordering if (this.isStreaming) { this.#pendingPythonMessages.push(pythonMessage); } else { this.agent.appendMessage(pythonMessage); this.sessionManager.appendMessage(pythonMessage); } } /** * Cancel running Python execution. */ abortEval(): void { for (const abortController of this.#evalAbortControllers) { abortController.abort(); } } async #waitForEvalExecutionsToSettle(timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; while (this.#activeEvalExecutions.size > 0) { const remainingMs = deadline - Date.now(); if (remainingMs <= 0) { return false; } const settled = await Promise.race([ Promise.allSettled(Array.from(this.#activeEvalExecutions)).then(() => true), Bun.sleep(remainingMs).then(() => false), ]); if (!settled && this.#activeEvalExecutions.size > 0) { return false; } } return true; } async #prepareEvalExecutionsForDispose(): Promise { if (!(await this.#waitForEvalExecutionsToSettle(3_000))) { logger.warn("Aborting active Python execution during dispose before retained kernel cleanup"); this.abortEval(); if (!(await this.#waitForEvalExecutionsToSettle(1_000))) { logger.warn( "Python execution is still active after dispose aborted all active runs; retained kernel ownership will still be detached", ); return false; } } return true; } /** Whether a Python execution is currently running */ get isEvalRunning(): boolean { return this.#evalAbortControllers.size > 0; } /** Whether there are pending Python messages waiting to be flushed */ get hasPendingPythonMessages(): boolean { return this.#pendingPythonMessages.length > 0; } /** * Flush pending Python messages to agent state and session. */ #flushPendingPythonMessages(): void { if (this.#pendingPythonMessages.length === 0) return; for (const pythonMessage of this.#pendingPythonMessages) { this.agent.appendMessage(pythonMessage); this.sessionManager.appendMessage(pythonMessage); } this.#pendingPythonMessages = []; } // ========================================================================= // IRC Delivery // ========================================================================= /** * Deliver an IRC message into this session (recipient side; called by the * IrcBus). Emits the `irc_message` session event for UI cards and injects * the rendered message into the model's context as an `irc:incoming` * custom message: * * - mid-turn → queued on the aside channel and folded in at the next step * boundary (non-interrupting, like async-result deliveries) → "injected"; * - idle → starts a real turn with the message so the recipient wakes * → "woken". * * Never blocks on the recipient's turn: the wake turn is fire-and-forget. * * When the sender expects a reply (`send await:true`) and this session is * mid-turn with async execution disabled, the next step boundary may be * gated on the sender's own batch finishing (blocking task spawns), so a * real reply turn can never happen in time. In that case an ephemeral * side-channel auto-reply is generated from the current context (the old * `respondAsBackground` path) and sent back over the bus on this agent's * behalf. */ async deliverIrcMessage(msg: IrcMessage, opts?: { expectsReply?: boolean }): Promise<"injected" | "woken"> { if (this.#isDisposed) { throw new Error("Recipient session is disposed."); } const autoReply = (opts?.expectsReply ?? false) && this.isStreaming && !this.settings.get("async.enabled"); const record: CustomMessage = { role: "custom", customType: "irc:incoming", content: prompt.render(ircIncomingTemplate, { from: msg.from, message: msg.body, replyTo: msg.replyTo ?? "", autoReplied: autoReply, }), display: true, details: { id: msg.id, from: msg.from, message: msg.body, ...(msg.replyTo ? { replyTo: msg.replyTo } : {}) }, attribution: "agent", timestamp: msg.ts, }; void this.#emitSessionEvent({ type: "irc_message", message: record }); if (this.isStreaming) { this.#pendingIrcAsides.push(record); if (autoReply) void this.#runIrcAutoReply(msg); return "injected"; } // Idle: same wake primitive the yield queue uses for async-result // delivery — prompt the agent directly so a real turn runs. this.agent.prompt(record).catch(error => { logger.warn("IRC wake turn failed", { from: msg.from, to: msg.to, error: String(error) }); }); return "woken"; } /** * Generate and deliver an ephemeral auto-reply to `msg` on this agent's * behalf: a no-tools side-channel turn over the current history (same * pipeline as `/btw`), recorded into this session as an `irc:autoreply` * aside so the model knows what was said for it, and sent back to the * sender as a regular bus message (`replyTo: msg.id`) so their parked * `wait`/`await:true` resolves. Failures only log — the sender then hits * its normal wait timeout. */ async #runIrcAutoReply(msg: IrcMessage): Promise { try { const { replyText } = await this.runEphemeralTurn({ promptText: prompt.render(ircAutoReplyTemplate, { from: msg.from, message: msg.body, replyTo: msg.replyTo ?? "", }), }); const body = replyText.trim(); if (!body || this.#isDisposed) return; const record: CustomMessage = { role: "custom", customType: "irc:autoreply", content: `[IRC you → \`${msg.from}\` (auto)]\n\n${body}`, display: true, details: { to: msg.from, body, replyTo: msg.id }, attribution: "agent", timestamp: Date.now(), }; void this.#emitSessionEvent({ type: "irc_message", message: record }); // Asides drain at the next step boundary; anything left over is // flushed at the start of the next prompt (#flushPendingIrcAsides). this.#pendingIrcAsides.push(record); // `from` must be the id the sender addressed (msg.to) so their // from-filtered waiter matches. const receipt = await IrcBus.global().send({ from: msg.to, to: msg.from, body, replyTo: msg.id }); if (receipt.outcome === "failed") { logger.warn("IRC auto-reply delivery failed", { to: msg.from, error: receipt.error }); } } catch (error) { logger.warn("IRC auto-reply turn failed", { from: msg.from, error: String(error) }); } } /** * Emit an IRC relay observation event on this session for UI rendering only. * Does not persist the record to history. Called by the IrcBus to surface * agent↔agent traffic on the main session. */ emitIrcRelayObservation(record: CustomMessage): void { void this.#emitSessionEvent({ type: "irc_message", message: record }); } /** * Run a single ephemeral side-channel turn against this session's current * model + system prompt + history. No tools are used; 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 apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId); if (!apiKey) { throw new Error(`No API key for ${model.provider}/${model.id}`); } const snapshot = this.#buildEphemeralSnapshot(args.promptText); const llmMessages = await this.convertMessagesToLlm(snapshot, args.signal); const context: Context = { systemPrompt: this.systemPrompt, messages: llmMessages, // Empty tools array: with toolChoice="none" some encoders still serialize the // recipient's tool catalog and the model leaks raw call markup // (, DSML envelopes) into IRC replies. Stripping tools here // removes the surface entirely. tools: [], }; const cacheSessionId = this.sessionId; const options = this.prepareSimpleStreamOptions( { apiKey, // 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. sessionId: `${cacheSessionId}:side:${Snowflake.next()}`, promptCacheKey: cacheSessionId, preferWebsockets: false, reasoning: toReasoningEffort(this.thinkingLevel), disableReasoning: shouldDisableReasoning(this.thinkingLevel), hideThinkingSummary: this.agent.hideThinkingSummary, serviceTier: this.serviceTier, signal: args.signal, toolChoice: "none", }, model.provider, ); let providerReplyText = ""; let emittedReplyText = ""; let assistantMessage: AssistantMessage | undefined; const stream = streamSimple(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") { assistantMessage = this.#obfuscator?.hasSecrets() ? { ...event.message, content: this.#obfuscator.deobfuscateObject(event.message.content) } : event.message; 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)); } return { replyText: args.dedupeReply === false ? replyText.trim() : dedupeEphemeralReply(replyText.trim()), assistantMessage, }; } /** * 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") { 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: "user", content: [{ type: "text", text: promptText }], attribution: "agent", timestamp: Date.now(), }); return messages; } /** * Persist any IRC asides that missed their step-boundary injection (the * message landed after the turn's last aside drain). Called at the start * of the next prompt so the model still sees them. */ #flushPendingIrcAsides(): void { if (this.#pendingIrcAsides.length === 0) return; const records = this.#pendingIrcAsides; this.#pendingIrcAsides = []; for (const record of records) { // emitExternalEvent on message_end appends to agent state and dispatches // to all session listeners, which in turn handle TUI rendering and // sessionManager persistence via #handleAgentEvent. this.agent.emitExternalEvent({ type: "message_start", message: record }); this.agent.emitExternalEvent({ type: "message_end", message: record }); } } // ========================================================================= // 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(); // Flush pending writes before switching so restore snapshots reflect committed state. await this.sessionManager.flush(); const previousSessionState = this.sessionManager.captureState(); const previousSessionContext = 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.#steeringMessages]; const previousFollowUpMessages = [...this.#followUpMessages]; const previousPendingNextTurnMessages = [...this.#pendingNextTurnMessages]; const previousScheduledHiddenNextTurnGeneration = this.#scheduledHiddenNextTurnGeneration; const previousModel = this.model; const previousThinkingLevel = this.#thinkingLevel; const previousAutoThinking = this.#autoThinking; const previousAutoResolvedLevel = this.#autoResolvedLevel; const previousServiceTier = this.agent.serviceTier; const previousSelectedMCPToolNames = new Set(this.#selectedMCPToolNames); const previousTools = [...this.agent.state.tools]; const previousBaseSystemPrompt = this.#baseSystemPrompt; const previousSystemPrompt = this.agent.state.systemPrompt; const previousFreshProviderSessionId = this.#freshProviderSessionId; const previousFallbackSelectedMCPToolNames = previousSessionFile ? this.#getSessionDefaultSelectedMCPToolNames(previousSessionFile) : undefined; this.#steeringMessages = []; this.#followUpMessages = []; this.#pendingNextTurnMessages = []; this.#scheduledHiddenNextTurnGeneration = undefined; try { await this.sessionManager.setSessionFile(sessionPath); if (switchingToDifferentSession) { this.#freshProviderSessionId = undefined; } this.#syncAgentSessionId(); this.#rekeyHindsightMemoryForCurrentSessionId(); this.#rekeyMnemopiMemoryForCurrentSessionId(); const sessionContext = this.buildDisplaySessionContext(); const didReloadConversationChange = !switchingToDifferentSession && this.#didSessionMessagesChange(previousSessionContext.messages, sessionContext.messages); const fallbackSelectedMCPToolNames = this.#getSessionDefaultSelectedMCPToolNames(sessionPath); await this.#restoreMCPSelectionsForSessionContext(sessionContext, { fallbackSelectedMCPToolNames }); // 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.#syncTodoPhasesFromBranch(); 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 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 = this.settings.get("defaultThinkingLevel"); const configuredServiceTier = this.settings.get("serviceTier"); // Session log entries store only concrete levels. When `auto` has resolved // for a turn, the persisted context may already carry that concrete level // even if the branch scan races a just-flushed thinking entry under isolated // parallel test workers. Prefer the concrete context value in that case; // otherwise keep the configured `auto` selector so fresh sessions still // classify their first turn. const restoredThinkingLevel: ConfiguredThinkingLevel | undefined = hasThinkingEntry || (defaultThinkingLevel === AUTO_THINKING && sessionContext.thinkingLevel !== "off") ? (sessionContext.thinkingLevel as ThinkingLevel | undefined) : defaultThinkingLevel; if (restoredThinkingLevel === AUTO_THINKING) { this.#autoThinking = true; this.#autoResolvedLevel = undefined; this.#thinkingLevel = resolveProvisionalAutoLevel(this.model); } else { this.#autoThinking = false; this.#autoResolvedLevel = undefined; this.#thinkingLevel = resolveThinkingLevelForModel(this.model, restoredThinkingLevel); } this.#applyThinkingLevelToAgent(this.#thinkingLevel); this.agent.serviceTier = hasServiceTierEntry ? sessionContext.serviceTier : configuredServiceTier === "none" ? undefined : configuredServiceTier; if (switchingToDifferentSession) { this.#resetHindsightConversationTrackingIfHindsight(); this.#resetMnemopiConversationTrackingIfMnemopi(); } this.#reconnectToAgent(); try { await this.#sessionSwitchReconciler?.(); } catch (error) { logger.warn("Failed to reconcile session mode after switch", { targetSessionFile: sessionPath, error: String(error), }); } return true; } catch (error) { this.sessionManager.restoreState(previousSessionState); this.#freshProviderSessionId = previousFreshProviderSessionId; this.#syncAgentSessionId(previousSessionState.sessionId); this.#rekeyHindsightMemoryForCurrentSessionId(); this.#rekeyMnemopiMemoryForCurrentSessionId(); let restoreMcpError: unknown; try { await this.#restoreMCPSelectionsForSessionContext(previousSessionContext, { fallbackSelectedMCPToolNames: previousFallbackSelectedMCPToolNames, }); } catch (mcpError) { restoreMcpError = mcpError; logger.warn("Failed to restore MCP selections after switch error", { previousSessionFile, targetSessionFile: sessionPath, error: String(mcpError), }); this.#selectedMCPToolNames = new Set(previousSelectedMCPToolNames); this.agent.setTools(previousTools); this.#baseSystemPrompt = previousBaseSystemPrompt; this.agent.setSystemPrompt(previousSystemPrompt); } this.#baseSystemPrompt = previousBaseSystemPrompt; this.agent.setSystemPrompt(previousSystemPrompt); this.agent.replaceMessages(previousAgentMessages); this.#steeringMessages = previousSteeringMessages; this.#followUpMessages = previousFollowUpMessages; this.#pendingNextTurnMessages = previousPendingNextTurnMessages; this.#scheduledHiddenNextTurnGeneration = previousScheduledHiddenNextTurnGeneration; if (previousModel) { this.agent.setModel(previousModel); } this.#thinkingLevel = previousThinkingLevel; this.#autoThinking = previousAutoThinking; this.#autoResolvedLevel = previousAutoResolvedLevel; this.#applyThinkingLevelToAgent(previousThinkingLevel); this.agent.serviceTier = previousServiceTier; this.#syncTodoPhasesFromBranch(); this.#reconnectToAgent(); if (restoreMcpError) { throw restoreMcpError; } 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; // Flush pending writes before branching await this.sessionManager.flush(); this.#cancelOwnAsyncJobs(); if (!selectedEntry.parentId) { await this.sessionManager.newSession({ parentSession: previousSessionFile }); } else { this.sessionManager.createBranchedSession(selectedEntry.parentId); } this.#syncTodoPhasesFromBranch(); this.#freshProviderSessionId = undefined; this.#syncAgentSessionId(); this.#rekeyHindsightMemoryForCurrentSessionId(); this.#rekeyMnemopiMemoryForCurrentSessionId(); this.#resetHindsightConversationTrackingIfHindsight(); this.#resetMnemopiConversationTrackingIfMnemopi(); // Reload messages from entries (works for both file and in-memory mode) const sessionContext = this.buildDisplaySessionContext(); await this.#restoreMCPSelectionsForSessionContext(sessionContext); // 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.#closeCodexProviderSessionsForHistoryRewrite(); } return { selectedText, cancelled: false }; } // ========================================================================= // Tree Navigation // ========================================================================= /** * Navigate to a different node in the session tree. * Unlike branch() which creates a new session file, this stays in the same file. * * @param targetId The entry ID to navigate to * @param options.summarize Whether user wants to summarize abandoned branch * @param options.customInstructions Custom instructions for summarizer * @returns Result with editorText (if user message) and cancelled status */ async navigateTree( targetId: string, options: { summarize?: boolean; customInstructions?: string } = {}, ): Promise<{ editorText?: string; cancelled: boolean; aborted?: boolean; summaryEntry?: BranchSummaryEntry; /** Raw session context built during navigation — pass to renderInitialMessages to skip a second O(N) walk. */ sessionContext?: SessionContext; }> { const oldLeafId = this.sessionManager.getLeafId(); // No-op if already at target if (targetId === oldLeafId) { return { cancelled: false }; } // Model required for summarization if (options.summarize && !this.model) { throw new Error("No model available for summarization"); } const targetEntry = this.sessionManager.getEntry(targetId); if (!targetEntry) { throw new Error(`Entry ${targetId} not found`); } // Collect entries to summarize (from old leaf to common ancestor) const { entries: entriesToSummarize, commonAncestorId } = collectEntriesForBranchSummary( this.sessionManager, oldLeafId, targetId, ); // Prepare event data const preparation: TreePreparation = { targetId, oldLeafId, commonAncestorId, entriesToSummarize, userWantsSummary: options.summarize ?? false, }; // Set up abort controller for summarization this.#branchSummaryAbortController = new AbortController(); let 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), }); 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") { // Custom message: leaf = parent (null if root), text goes to editor newLeafId = targetEntry.parentId; editorText = typeof targetEntry.content === "string" ? targetEntry.content : targetEntry.content .filter((c): c is { type: "text"; text: string } => c.type === "text") .map(c => c.text) .join(""); } else { // Non-user message: leaf = selected node newLeafId = targetId; } // Switch leaf (with or without summary) // Summary is attached at the navigation target position (newLeafId), not the old branch let summaryEntry: BranchSummaryEntry | undefined; if (summaryText) { // Create summary at target position (can be null for root) const summaryId = this.sessionManager.branchWithSummary(newLeafId, summaryText, summaryDetails, fromExtension); summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry; } else if (newLeafId === null) { // No summary, navigating to root - reset leaf this.sessionManager.resetLeaf(); } else { // No summary, navigating to non-root this.sessionManager.branch(newLeafId); } // Update agent state — build display context to populate agent messages. const stateContext = this.sessionManager.buildSessionContext(); const displayContext = deobfuscateSessionContext(stateContext, this.#obfuscator); await this.#restoreMCPSelectionsForSessionContext(displayContext); this.agent.replaceMessages(displayContext.messages); this.#syncTodoPhasesFromBranch(); 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 }; } /** * 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 { const state = this.state; const userMessages = state.messages.filter(m => m.role === "user").length; const assistantMessages = state.messages.filter(m => m.role === "assistant").length; const toolResults = state.messages.filter(m => m.role === "toolResult").length; let toolCalls = 0; let totalInput = 0; let totalOutput = 0; let totalCacheRead = 0; let totalCacheWrite = 0; let totalCost = 0; let totalPremiumRequests = 0; const getTaskToolUsage = (details: unknown): Usage | undefined => { if (!details || typeof details !== "object") return undefined; const record = details as Record; const usage = record.usage; if (!usage || typeof usage !== "object") return undefined; return usage as Usage; }; for (const message of state.messages) { if (message.role === "assistant") { const assistantMsg = message as AssistantMessage; toolCalls += assistantMsg.content.filter(c => c.type === "toolCall").length; totalInput += assistantMsg.usage.input; totalOutput += assistantMsg.usage.output; totalCacheRead += assistantMsg.usage.cacheRead; totalCacheWrite += assistantMsg.usage.cacheWrite; totalPremiumRequests += assistantMsg.usage.premiumRequests ?? 0; totalCost += assistantMsg.usage.cost.total; } if (message.role === "toolResult" && message.toolName === "task") { const usage = getTaskToolUsage(message.details); if (usage) { totalInput += usage.input; totalOutput += usage.output; totalCacheRead += usage.cacheRead; totalCacheWrite += usage.cacheWrite; totalPremiumRequests += usage.premiumRequests ?? 0; totalCost += usage.cost.total; } } } return { sessionFile: this.sessionFile, sessionId: this.sessionId, userMessages, assistantMessages, toolCalls, toolResults, totalMessages: state.messages.length, tokens: { input: totalInput, output: totalOutput, cacheRead: totalCacheRead, cacheWrite: totalCacheWrite, total: totalInput + totalOutput + totalCacheRead + totalCacheWrite, }, cost: totalCost, premiumRequests: totalPremiumRequests, }; } /** * Get current context usage statistics. * Uses the last assistant message's usage data when available, * otherwise estimates tokens for all messages. */ getContextUsage(options?: { contextWindow?: number }): ContextUsage | undefined { const model = this.model; const contextWindow = options?.contextWindow ?? model?.contextWindow ?? 0; if (!Number.isFinite(contextWindow) || contextWindow <= 0) return undefined; // After compaction, the last assistant usage reflects pre-compaction context size. // We can only trust usage from an assistant that responded after the latest compaction. // If no such assistant exists, context token count is unknown until the next LLM response. const branchEntries = this.sessionManager.getBranch(); const latestCompaction = getLatestCompactionEntry(branchEntries); if (latestCompaction) { // Check if there's a valid assistant usage after the compaction boundary const compactionIndex = branchEntries.lastIndexOf(latestCompaction); let hasPostCompactionUsage = false; for (let i = branchEntries.length - 1; i > compactionIndex; i--) { const entry = branchEntries[i]; if (entry.type === "message" && entry.message.role === "assistant") { const assistant = entry.message; if (assistant.stopReason !== "aborted" && assistant.stopReason !== "error") { const contextTokens = calculateContextTokens(assistant.usage); if (contextTokens > 0) { hasPostCompactionUsage = true; } break; } } } if (!hasPostCompactionUsage) { return { tokens: null, contextWindow, percent: null }; } } const estimate = this.#estimateContextTokens(); const percent = (estimate.tokens / contextWindow) * 100; return { tokens: estimate.tokens, contextWindow, percent, }; } #ingestProviderUsageHeaders(response: ProviderResponseMetadata, model?: Model): void { if (model?.provider !== "anthropic") return; this.#modelRegistry.authStorage.ingestUsageHeaders("anthropic", response.headers, { sessionId: this.agent.sessionId, baseUrl: this.#modelRegistry.getProviderBaseUrl?.("anthropic"), }); } async fetchUsageReports(signal?: AbortSignal): Promise { const authStorage = this.#modelRegistry.authStorage; if (!authStorage.fetchUsageReports) return null; return authStorage.fetchUsageReports({ baseUrlResolver: provider => this.#modelRegistry.getProviderBaseUrl?.(provider), signal, }); } /** * 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, }); } /** * 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). Opt-in, reactive, and * 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 (!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: cfg.autoRedeem, 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 }); 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; } /** * Estimate context tokens from messages, using the last assistant usage when available. */ #estimateContextTokens(): { tokens: number; } { const messages = this.messages; // Find last assistant message with valid usage. let lastUsageIndex: number | null = null; let lastUsage: Usage | undefined; for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; if (msg.role === "assistant") { const assistantMsg = msg as AssistantMessage; if (assistantMsg.stopReason !== "aborted" && assistantMsg.stopReason !== "error" && assistantMsg.usage) { lastUsage = assistantMsg.usage; lastUsageIndex = i; break; } } } if (!lastUsage || lastUsageIndex === null) { // No usage data - estimate all messages let estimated = 0; for (const message of messages) { estimated += estimateTokens(message); } return { tokens: estimated, }; } const usageTokens = calculatePromptTokens(lastUsage); let trailingTokens = 0; for (let i = lastUsageIndex + 1; i < messages.length; i++) { trailingTokens += estimateTokens(messages[i]); } return { tokens: usageTokens + trailingTokens, }; } /** * Export session to HTML. * @param outputPath Optional output path (defaults to session directory) * @returns Path to exported file */ async exportToHtml(outputPath?: string): Promise { const themeName = getCurrentThemeName(); const { exportSessionToHtml } = await import("../export/html"); return exportSessionToHtml(this.sessionManager, this.state, { outputPath, themeName }); } // ========================================================================= // Utilities // ========================================================================= /** * Get text content of last assistant message. * Useful for /copy command. * @returns Text content, or undefined if no assistant message exists */ getLastAssistantText(): string | undefined { const lastAssistant = this.#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. * Includes user messages, assistant text, thinking blocks, tool calls, and tool results. */ 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, }); } // ========================================================================= // 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; } }