Files
oh-my-pi/packages/coding-agent/src/session/agent-session.ts
T
can1357 3676b5d97f feat: implemented client usage tracking and reporting in the auth broker
- Add usage history, reporting, and client summary endpoints to the auth broker.
- Implement SQLite persistence, batching, and periodic flushing for observed client usage.
- Integrate usage reporting into the coding agent session for completed assistant messages.
- Update stats aggregator and dashboard to retrieve usage history snapshots from the broker.
2026-07-24 15:20:46 +02:00

8241 lines
311 KiB
TypeScript

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