返回源码地图

packages/coding-agent/src/core/agent-session.ts

v1.0.0 · a13d35a742c6 · 06:请求、事件、恢复主干;非全文件逐行审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

1/**
2 * AgentSession - Core abstraction for agent lifecycle and session management.
3 *
4 * This class is shared between all run modes (interactive, print, rpc).
5 * It encapsulates:
6 * - Agent state access
7 * - Event subscription with automatic session persistence
8 * - Model and thinking level management
9 * - Compaction (manual and auto)
10 * - Bash execution
11 * - Session switching and branching
12 *
13 * Modes use this class and add their own I/O layer on top.
14 */
15
16import { readFileSync } from "node:fs";
17import { basename, dirname } from "node:path";
18import {
19 type AfterToolCallContext,
20 type AfterToolCallResult,
21 type Agent,
22 type AgentContext,
23 type AgentEvent,
24 type AgentMessage,
25 type AgentState,
26 type AgentTool,
27 type AgentToolCallOutcome,
28 type BeforeToolCallContext,
29 type BeforeToolCallResult,
30 type PrepareNextTurnContext,
31 runToolCall,
32 type ThinkingLevel,
33} from "@earendil-works/pi-agent-core";
34import { contentText, getCurrentSystemMessage, retryDelayMs } from "@earendil-works/pi-ai";
35import type {
36 AssistantMessage,
37 AuthResult,
38 ImageContent,
39 Model,
40 ProviderHeaders,
41 SystemMessage,
42 TextContent,
43 ToolResultMessage,
44 Usage,
45} from "@earendil-works/pi-ai/compat";
46import {
47 clampThinkingLevel,
48 cleanupSessionResources,
49 getSupportedThinkingLevels,
50 isContextOverflow,
51 isRecoverableLength,
52 isRetryableAssistantError,
53 modelsAreEqual,
54 type RetryCallbacks,
55 resetApiProviders,
56 streamSimple,
57} from "@earendil-works/pi-ai/compat";
58import { getThemeByName, theme } from "../modes/interactive/theme/theme.ts";
59import { stripFrontmatter } from "../utils/frontmatter.ts";
60import { processImage } from "../utils/image-process.ts";
61import { sleep } from "../utils/sleep.ts";
62import { normalizeToolResultImages } from "../utils/tool-result-images.ts";
63import { formatNoApiKeyFoundMessage, formatNoModelSelectedMessage } from "./auth-guidance.ts";
64import { type BashResult, executeBashWithOperations } from "./bash-executor.ts";
65import { generateBugReportSummary } from "./bug-report.ts";
66import type { CacheWarmer, CacheWarmingStatus } from "./cache-warmer.ts";
67import {
68 type CompactionPreparation,
69 type CompactionResult,
70 calculateContextTokens,
71 collectEntriesForBranchSummary,
72 compact,
73 estimateContextTokens,
74 estimateProjectedContextTokens,
75 estimateTokens,
76 generateBranchSummary,
77 prepareCompaction,
78 shouldCompact,
79} from "./compaction/index.ts";
80import { DEFAULT_THINKING_LEVEL, THINKING_LEVEL_OPTIONS } from "./defaults.ts";
81import { exportSessionToHtml, type ToolHtmlRenderer } from "./export-html/index.ts";
82import { createToolHtmlRenderer } from "./export-html/tool-renderer.ts";
83import {
84 type AgentActivityOutcome,
85 type BoundaryContextPreview,
86 type ContextUsage,
87 type ExecuteToolOptions,
88 type ExtensionCommandContextActions,
89 type ExtensionErrorListener,
90 type ExtensionMode,
91 ExtensionRunner,
92 type ExtensionUIContext,
93 type InputSource,
94 type MessageEndEvent,
95 type MessageStartEvent,
96 type MessageUpdateEvent,
97 type ReplacedSessionContext,
98 type SessionBeforeCompactResult,
99 type SessionBeforeTreeResult,
100 type SessionBoundaryDraft,
101 type SessionCompactFailedEvent,
102 type SessionStartEvent,
103 type ShutdownHandler,
104 type ToolDefinition,
105 type ToolExecutionEndEvent,
106 type ToolExecutionStartEvent,
107 type ToolExecutionUpdateEvent,
108 type ToolExposure,
109 type ToolInfo,
110 type ToolLoadout,
111 type TreePreparation,
112 type TurnStartEvent,
113 wrapRegisteredTools,
114} from "./extensions/index.ts";
115import { emitSessionShutdownEvent } from "./extensions/runner.ts";
116import { type BashExecutionMessage, type CustomMessage, convertToLlm } from "./messages.ts";
117import { ModelRegistry } from "./model-registry.ts";
118import type { ModelRuntime } from "./model-runtime.ts";
119import { NestedToolCallRunner } from "./nested-tool-calls.ts";
120import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.ts";
121import type { ResourceExtensionPaths, ResourceLoader } from "./resource-loader.ts";
122import { exportSessionToJsonl } from "./session-export.ts";
123import {
124 type BranchSummaryEntry,
125 type CompactionEntry,
126 type ContextEditEntry,
127 getLatestCompactionEntry,
128 type SessionEntry,
129 SessionManager,
130 type SessionProjection,
131} from "./session-manager.ts";
132import { type CacheWarmingMode, DEFAULT_TOOL_NAMES, type SettingsManager } from "./settings-manager.ts";
133import type { SlashCommandInfo } from "./slash-commands.ts";
134import { BUILTIN_PATH_PREFIX, createSyntheticSourceInfo, isSyntheticPath, type SourceInfo } from "./source-info.ts";
135import {
136 buildSystemPrompt,
137 buildSystemPromptSections,
138 diffSystemPromptSections,
139 type NormalizedBuildSystemPromptOptions,
140 normalizeBuildSystemPromptOptions,
141} from "./system-prompt.ts";
142import { type BashOperations, createLocalBashOperations } from "./tools/bash.ts";
143import { createAllToolDefinitions } from "./tools/index.ts";
144import { createToolDefinitionFromAgentTool } from "./tools/tool-definition-wrapper.ts";
145import { addUsageToTotals, combineUsage, createUsageTotals } from "./usage-totals.ts";
146import {
147 findLatestResponse,
148 getBranchSelection,
149 getVirtualModelState,
150 isVirtualModel,
151 VIRTUAL_MODEL_STATE_ENTRY,
152 type VirtualModelStateData,
153} from "./virtual-models.ts";
154
155// ============================================================================
156// Skill Block Parsing
157// ============================================================================
158
159/** Parsed skill block from a user message */
160export interface ParsedSkillBlock {
161 name: string;
162 location: string;
163 content: string;
164 userMessage: string | undefined;
165}
166
167/**
168 * Parse a skill block from message text.
169 * Returns null if the text doesn't contain a skill block.
170 */
171export function parseSkillBlock(text: string): ParsedSkillBlock | null {
172 const match = text.match(/^<skill name="([^"]+)" location="([^"]+)">\n([\s\S]*?)\n<\/skill>(?:\n\n([\s\S]+))?$/);
173 if (!match) return null;
174 return {
175 name: match[1],
176 location: match[2],
177 content: match[3],
178 userMessage: match[4]?.trim() || undefined,
179 };
180}
181
182/** Tool execution events of calls a tool made through `ctx.executeTool()` carry `parentToolCallId`. */
183type WithParentToolCallId<E> = E extends {
184 type: "tool_execution_start" | "tool_execution_update" | "tool_execution_end";
185}
186 ? E & { parentToolCallId?: string }
187 : E;
188
189/** Session-specific events that extend the core AgentEvent */
190export type AgentSessionEvent =
191 | WithParentToolCallId<Exclude<AgentEvent, { type: "agent_end" }>>
192 | {
193 type: "agent_end";
194 messages: AgentMessage[];
195 willRetry: boolean;
196 }
197 | { type: "agent_settled" }
198 | {
199 type: "queue_update";
200 steering: readonly string[];
201 followUp: readonly string[];
202 }
203 | { type: "compaction_start"; reason: "manual" | "threshold" | "overflow" }
204 | { type: "entry_appended"; entry: SessionEntry }
205 | { type: "session_info_changed"; name: string | undefined }
206 | { type: "thinking_level_changed"; level: ThinkingLevel }
207 | {
208 type: "compaction_end";
209 reason: "manual" | "threshold" | "overflow";
210 result: CompactionResult | undefined;
211 aborted: boolean;
212 willRetry: boolean;
213 errorMessage?: string;
214 }
215 | { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string }
216 | { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }
217 | {
218 type: "summarization_retry_scheduled";
219 attempt: number;
220 maxAttempts: number;
221 delayMs: number;
222 errorMessage: string;
223 }
224 | { type: "summarization_retry_attempt_start"; source: "branchSummary" }
225 | {
226 type: "summarization_retry_attempt_start";
227 source: "compaction";
228 reason: "manual" | "threshold" | "overflow";
229 }
230 | { type: "summarization_retry_finished" }
231 | { type: "bash_execution_update"; id?: string; delta: string };
232
233/** Listener function for agent session events */
234export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
235
236// ============================================================================
237// Types
238// ============================================================================
239
240function withoutDeletedHeaders(headers: ProviderHeaders | undefined): Record<string, string> | undefined {
241 return headers
242 ? Object.fromEntries(Object.entries(headers).filter((entry): entry is [string, string] => entry[1] !== null))
243 : undefined;
244}
245
246export interface AgentSessionConfig {
247 agent: Agent;
248 sessionManager: SessionManager;
249 settingsManager: SettingsManager;
250 cwd: string;
251 /** Models to cycle through with Ctrl+P (from --models flag) */
252 scopedModels?: Array<{ model: Model<any>; thinkingLevel?: ThinkingLevel }>;
253 /** Resource loader for extensions, skills, prompts, themes, context files, and system prompt */
254 resourceLoader: ResourceLoader;
255 /** SDK custom tools registered outside extensions */
256 customTools?: ToolDefinition[];
257 /** Canonical model/auth runtime used by coding-agent internals. */
258 modelRuntime: ModelRuntime;
259 /** Keeps the prompt cache entry of the last session request warm. */
260 cacheWarmer?: Pick<CacheWarmer, "cancel" | "status" | "onAgentSettled" | "onModeChanged" | "onWarmed">;
261 /** Initial active built-in tool names. Default: [read, bash, edit, write] */
262 initialActiveToolNames?: string[];
263 /**
264 * Whether the initial tools come from the `defaultTools` setting. When true, reload activates
265 * tools newly added to the setting. Tools removed from it stay active.
266 */
267 usesDefaultTools?: boolean;
268 /** Optional allowlist of tool names. When provided, only these tool names are exposed. */
269 allowedToolNames?: string[];
270 /** Optional denylist of tool names. When provided, these tool names are not exposed. */
271 excludedToolNames?: string[];
272 /**
273 * Override base tools (useful for custom runtimes).
274 *
275 * These are synthesized into minimal ToolDefinitions internally so AgentSession can keep
276 * a definition-first registry even when callers provide plain AgentTool instances.
277 */
278 baseToolsOverride?: Record<string, AgentTool>;
279 /** Mutable ref used by Agent to access the current ExtensionRunner */
280 extensionRunnerRef?: { current?: ExtensionRunner };
281 /** Session start event metadata emitted when extensions bind to this runtime. */
282 sessionStartEvent?: SessionStartEvent;
283}
284
285export interface ExtensionBindings {
286 uiContext?: ExtensionUIContext;
287 mode?: ExtensionMode;
288 commandContextActions?: ExtensionCommandContextActions;
289 abortHandler?: () => void;
290 shutdownHandler?: ShutdownHandler;
291 onError?: ExtensionErrorListener;
292}
293
294export type QueuedInputDisposition = "handled" | "queued";
295export type PromptDisposition = QueuedInputDisposition | "started";
296
297/** Options for AgentSession.prompt() */
298export interface PromptOptions {
299 /** Whether to dispatch extension commands and expand skill commands and prompt templates (default: true) */
300 expandPromptTemplates?: boolean;
301 /** Image attachments */
302 images?: ImageContent[];
303 /** When streaming, how to queue the message: "steer" (interrupt) or "followUp" (wait). Required if streaming. */
304 streamingBehavior?: "steer" | "followUp";
305 /** Source of input for extension input event handlers. Defaults to "interactive". */
306 source?: InputSource;
307 /** Internal hook used by RPC mode to observe how an accepted prompt was dispatched. Not called if the prompt is rejected. */
308 preflightResult?: (disposition: PromptDisposition) => void;
309}
310
311/** Options for model/thinking mutations. */
312export interface ModelMutationOptions {
313 /** Persist the new value to global defaults. Defaults to session-only. */
314 persist?: boolean;
315}
316
317/** Result from cycleModel() */
318export interface ModelCycleResult {
319 model: Model<any>;
320 thinkingLevel: ThinkingLevel;
321 /** Whether cycling through scoped models (--models flag) or all available */
322 isScoped: boolean;
323}
324
325/** Session statistics for /session command */
326export interface SessionStats {
327 sessionFile: string | undefined;
328 sessionId: string;
329 userMessages: number;
330 assistantMessages: number;
331 toolCalls: number;
332 toolResults: number;
333 totalMessages: number;
334 tokens: {
335 input: number;
336 output: number;
337 cacheRead: number;
338 cacheWrite: number;
339 total: number;
340 };
341 cost: number;
342 contextUsage?: ContextUsage;
343}
344
345interface ToolDefinitionEntry {
346 definition: ToolDefinition;
347 sourceInfo: SourceInfo;
348}
349
350function estimateMessagesTokens(messages: AgentMessage[]): number {
351 let tokens = 0;
352 for (const message of messages) {
353 tokens += estimateTokens(message);
354 }
355 return tokens;
356}
357
358// ============================================================================
359// AgentSession Class
360// ============================================================================
361
362export class AgentSession {
363 readonly agent: Agent;
364 readonly sessionManager: SessionManager;
365 readonly settingsManager: SettingsManager;
366
367 private _scopedModels: Array<{ model: Model<any>; thinkingLevel?: ThinkingLevel }>;
368
369 // Event subscription state
370 private _unsubscribeAgent?: () => void;
371 private _eventListeners: AgentSessionEventListener[] = [];
372 private _isAgentRunActive = false;
373 private _agentRunAbortRequested = false;
374 private _idleWaitPromise: Promise<void> | undefined;
375 private _resolveIdleWait: (() => void) | undefined;
376
377 /** Tracks pending steering messages for UI display. Removed when delivered. */
378 private _steeringMessages: string[] = [];
379 /** Tracks pending follow-up messages for UI display. Removed when delivered. */
380 private _followUpMessages: string[] = [];
381 /** Messages queued to be included with the next user prompt as context ("asides"). */
382 private _pendingNextTurnMessages: CustomMessage[] = [];
383 /** Context-only custom messages queued during a run, flushed once the current turn's tool results are in. */
384 private _pendingCustomMessages: CustomMessage[] = [];
385
386 // Compaction state
387 private _compactionAbortController: AbortController | undefined = undefined;
388 private _autoCompactionAbortController: AbortController | undefined = undefined;
389 private _overflowRecoveryAttempted = false;
390
391 // Branch summarization state
392 private _branchSummaryAbortController: AbortController | undefined = undefined;
393
394 // Retry state
395 private _retryAbortController: AbortController | undefined = undefined;
396 private _retryAttempt = 0;
397 /**
398 * Failed response that the next request repeats, set by auto-retry and overflow recovery. The
399 * retry is routed with it as `failed`, since the context no longer contains it.
400 */
401 private _failedResponse: AssistantMessage | undefined;
402
403 // Bash execution state
404 private readonly _bashAbortControllers = new Set<AbortController>();
405 private _pendingBashMessages: BashExecutionMessage[] = [];
406
407 // Extension system
408 private _extensionRunner!: ExtensionRunner;
409 private _turnIndex = 0;
410 private readonly _entryIdsByMessage = new WeakMap<object, string>();
411 private readonly _boundaryDispatchedMessages = new WeakSet<object>();
412 private _lastAssistantMessage: AssistantMessage | undefined;
413 private _lastAssistantToolResults: AgentMessage[] = [];
414 private _lastActivityOutcome: AgentActivityOutcome = "completed";
415 private _isBeforeSettle = false;
416 private _abortDuringBeforeSettle = false;
417 private _isEmittingAgentSettled = false;
418 private readonly _deferredSettledActions: Array<() => Promise<void>> = [];
419
420 private _resourceLoader: ResourceLoader;
421 private _customTools: ToolDefinition[];
422 private _baseToolDefinitions: Map<string, ToolDefinition> = new Map();
423 private _cwd: string;
424 private _extensionRunnerRef?: { current?: ExtensionRunner };
425 private _initialActiveToolNames?: string[];
426 /**
427 * Tools of the restored or reloaded loadout that are not registered yet, such as tools of MCP
428 * servers that are still connecting. They are activated when they are registered, and dropped when
429 * `setActiveToolsByName()` deactivates a tool or the next agent run starts.
430 */
431 private _pendingToolNames = new Set<string>();
432 private _usesDefaultTools: boolean;
433 private _allowedToolNames?: Set<string>;
434 private _excludedToolNames?: Set<string>;
435 private _baseToolsOverride?: Record<string, AgentTool>;
436 private _sessionStartEvent: SessionStartEvent;
437 private _extensionUIContext?: ExtensionUIContext;
438 private _extensionMode: ExtensionMode = "print";
439 private _extensionCommandContextActions?: ExtensionCommandContextActions;
440 private _extensionAbortHandler?: () => void;
441 private _extensionShutdownHandler?: ShutdownHandler;
442 private _extensionErrorListener?: ExtensionErrorListener;
443 private _extensionErrorUnsubscriber?: () => void;
444
445 private _modelRuntime: ModelRuntime;
446 private _cacheWarmer?: Pick<CacheWarmer, "cancel" | "status" | "onAgentSettled" | "onModeChanged" | "onWarmed">;
447
448 // Tool registry for extension getTools/setTools
449 private _toolRegistry: Map<string, AgentTool> = new Map();
450 /** Created on the first `ctx.executeTool()` call. */
451 private _nestedToolCalls: NestedToolCallRunner | undefined;
452 /** Declared tools whose declarations requests leave out, from `prepareLoadout` hooks. */
453 private _hiddenDeclarations: ReadonlySet<string> = new Set();
454 private _toolDefinitions: Map<string, ToolDefinitionEntry> = new Map();
455 private _toolPromptSnippets: Map<string, string> = new Map();
456 private _toolPromptGuidelines: Map<string, string[]> = new Map();
457
458 private _baseSystemPromptOptions!: NormalizedBuildSystemPromptOptions;
459 /** Prompt options after before_agent_start mutations for the active run. */
460 private _runSystemPromptOptions?: NormalizedBuildSystemPromptOptions;
461
462 constructor(config: AgentSessionConfig) {
463 this.agent = config.agent;
464 this.sessionManager = config.sessionManager;
465 this.settingsManager = config.settingsManager;
466 this._scopedModels = config.scopedModels ?? [];
467 this._resourceLoader = config.resourceLoader;
468 this._customTools = config.customTools ?? [];
469 this._cwd = config.cwd;
470 this._modelRuntime = config.modelRuntime;
471 this._cacheWarmer = config.cacheWarmer;
472 if (this._cacheWarmer) {
473 this._cacheWarmer.onWarmed = (entry) => this._emit({ type: "entry_appended", entry });
474 }
475 this._extensionRunnerRef = config.extensionRunnerRef;
476 this._initialActiveToolNames = config.initialActiveToolNames;
477 this._usesDefaultTools = config.usesDefaultTools ?? false;
478 this._allowedToolNames = config.allowedToolNames ? new Set(config.allowedToolNames) : undefined;
479 this._excludedToolNames = config.excludedToolNames ? new Set(config.excludedToolNames) : undefined;
480 this._baseToolsOverride = config.baseToolsOverride;
481 this._sessionStartEvent = config.sessionStartEvent ?? { type: "session_start", reason: "startup" };
482
483 // Always subscribe to agent events for internal handling
484 // (session persistence, extensions, auto-compaction, retry logic)
485 this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);
486 this._installAgentToolHooks();
487 this._installAgentNextTurnRefresh();
488 this._installAgentRequestProjection();
489 this._installAgentBoundaryHooks();
490 this._installHiddenDeclarationsProjection();
491 this._installAgentForcedPromptProjection();
492
493 this._buildRuntime({
494 activeToolNames: this._initialActiveToolNames,
495 includeAllExtensionTools: true,
496 });
497 if (this._initialActiveToolNames === undefined) this._restoreToolsFromTranscript();
498 }
499
500 get modelRuntime(): ModelRuntime {
501 return this._modelRuntime;
502 }
503
504 private async _getRequiredRequestAuth(
505 model: Model<any>,
506 signal?: AbortSignal,
507 ): Promise<{
508 model: Model<any>;
509 apiKey?: string;
510 headers?: Record<string, string>;
511 env?: Record<string, string>;
512 }> {
513 let result: AuthResult | undefined;
514 try {
515 result = await this._modelRuntime.getAuth(model, { signal });
516 } catch (error) {
517 const cause = error instanceof Error ? error.cause : undefined;
518 if (cause instanceof Error && cause.message === "authHeader requires a resolved API key") {
519 throw new Error(formatNoApiKeyFoundMessage(model.provider));
520 }
521 throw error;
522 }
523 if (result && (result.auth.apiKey || result.auth.headers)) {
524 const requestModel = result.auth.baseUrl ? { ...model, baseUrl: result.auth.baseUrl } : model;
525 return {
526 model: requestModel,
527 apiKey: result.auth.apiKey,
528 headers: withoutDeletedHeaders(result.auth.headers),
529 env: result.env,
530 };
531 }
532
533 const isOAuth = this._modelRuntime.isUsingOAuth(model.provider);
534 if (isOAuth) {
535 throw new Error(
536 `Authentication failed for "${model.provider}". ` +
537 `Credentials may have expired or network is unavailable. ` +
538 `Run '/login ${model.provider}' to re-authenticate.`,
539 );
540 }
541 throw new Error(formatNoApiKeyFoundMessage(model.provider));
542 }
543
544 private async _getSummarizationRequestAuth(
545 selectedModel: Model<any>,
546 signal?: AbortSignal,
547 ): Promise<{
548 model: Model<any>;
549 apiKey?: string;
550 headers?: Record<string, string>;
551 env?: Record<string, string>;
552 thinkingLevel: ThinkingLevel;
553 }> {
554 // Route a virtual model first: summaries size their input and output from the model they get.
555 const { model, thinkingLevel } = isVirtualModel(selectedModel)
556 ? await this._modelRuntime.resolveModel(selectedModel, convertToLlm(this.messages), {
557 reason: "direct",
558 thinkingLevel: this.thinkingLevel,
559 signal,
560 })
561 : { model: selectedModel, thinkingLevel: this.thinkingLevel };
562 if (this.agent.streamFunction === streamSimple) {
563 return { ...(await this._getRequiredRequestAuth(model, signal)), thinkingLevel };
564 }
565
566 try {
567 const result = await this._modelRuntime.getAuth(model, { signal });
568 if (!result) return { model, thinkingLevel };
569 const requestModel = result.auth.baseUrl ? { ...model, baseUrl: result.auth.baseUrl } : model;
570 return {
571 model: requestModel,
572 apiKey: result.auth.apiKey,
573 headers: withoutDeletedHeaders(result.auth.headers),
574 env: result.env,
575 thinkingLevel,
576 };
577 } catch (error) {
578 if (signal?.aborted) throw error;
579 return { model, thinkingLevel };
580 }
581 }
582
583 /**
584 * The model whose limits apply to `message`, or undefined when the message came from another
585 * model. Under a virtual selection, that is the physical model that produced it.
586 */
587 private _modelForMessage(message: AssistantMessage): Model<any> | undefined {
588 const model = this.model;
589 if (model && isVirtualModel(model)) return this._modelRuntime.getPhysicalModel(message.provider, message.model);
590 return model?.provider === message.provider && model.id === message.model ? model : undefined;
591 }
592
593 /**
594 * Record the selection on the current branch when the branch implies another one, so a resume
595 * restores it. Tree navigation can leave the latest `model_change` on another branch; responses
596 * cannot record a virtual selection because they name physical models. Responses do record a
597 * physical selection unless the branch holds a virtual one; checking a physical selection against
598 * responses would record it on every prompt while `prepareRequest` redirects to another model.
599 */
600 private _recordSelection(): void {
601 const model = this.model;
602 if (!model) return;
603 const getModel = (provider: string, modelId: string) => this._modelRuntime.getModel(provider, modelId);
604 const recorded = getBranchSelection(this.sessionManager.getBranch(), getModel);
605 if (!recorded || (recorded.provider === model.provider && recorded.modelId === model.id)) return;
606 const recordedModel = getModel(recorded.provider, recorded.modelId);
607 if (!isVirtualModel(model) && !(recordedModel && isVirtualModel(recordedModel))) return;
608 this.sessionManager.appendModelChange(model.provider, model.id);
609 }
610
611 /** The model whose limits apply to the conversation. */
612 private _limitsModel(): Model<any> | undefined {
613 return this.routedModel?.model ?? this.model;
614 }
615
616 /**
617 * Install tool hooks once on the Agent instance.
618 *
619 * The callbacks read `this._extensionRunner` at execution time, so extension reload swaps in the
620 * new runner without reinstalling hooks. Extension-specific tool wrappers are still used to adapt
621 * registered tool execution to the extension context. Tool call and tool result interception now
622 * happens here instead of in wrappers.
623 */
624 private _installAgentToolHooks(): void {
625 this.agent.beforeToolCall = (context) => this._beforeToolCall(context);
626 this.agent.afterToolCall = (context) => this._afterToolCall(context);
627 }
628
629 /** `tool_call` handlers. `parentToolCallId` is set for calls another tool made. */
630 private async _beforeToolCall(
631 { toolCall, args }: BeforeToolCallContext,
632 parentToolCallId?: string,
633 ): Promise<BeforeToolCallResult | undefined> {
634 const runner = this._extensionRunner;
635 if (!runner.hasHandlers("tool_call")) {
636 return undefined;
637 }
638
639 try {
640 return await runner.emitToolCall({
641 type: "tool_call",
642 toolName: toolCall.name,
643 toolCallId: toolCall.id,
644 ...(parentToolCallId ? { parentToolCallId } : {}),
645 input: args as Record<string, unknown>,
646 });
647 } catch (err) {
648 if (err instanceof Error) {
649 throw err;
650 }
651 throw new Error(`Extension failed, blocking execution: ${String(err)}`);
652 }
653 }
654
655 /** `tool_result` handlers and image normalization. `parentToolCallId` is set for calls another tool made. */
656 private async _afterToolCall(
657 { toolCall, args, result, isError }: AfterToolCallContext,
658 parentToolCallId?: string,
659 ): Promise<AfterToolCallResult | undefined> {
660 const runner = this._extensionRunner;
661 const hookResult = runner.hasHandlers("tool_result")
662 ? await runner.emitToolResult({
663 type: "tool_result",
664 toolName: toolCall.name,
665 toolCallId: toolCall.id,
666 ...(parentToolCallId ? { parentToolCallId } : {}),
667 input: args as Record<string, unknown>,
668 content: result.content,
669 details: result.details,
670 ...(result.structuredContent === undefined ? {} : { structuredContent: result.structuredContent }),
671 isError,
672 usage: result.usage,
673 })
674 : undefined;
675
676 const content = hookResult?.content ?? result.content ?? [];
677 // Runs after the extension hook so images injected or replaced by extensions are normalized too.
678 const resizeOptions = this._limitsModel()?.inputLimits?.images?.resize;
679 const normalizedContent = await normalizeToolResultImages(content, {
680 autoResizeImages: this.settingsManager.getImageAutoResize(),
681 ...(resizeOptions ? { resizeOptions } : {}),
682 });
683
684 if (!hookResult && normalizedContent === content) {
685 return undefined;
686 }
687
688 // The hook result already dropped structured content that replaced content no longer matches.
689 return {
690 content: normalizedContent,
691 details: hookResult?.details,
692 structuredContent: hookResult ? hookResult.structuredContent : result.structuredContent,
693 isError: hookResult?.isError ?? isError,
694 usage: hookResult?.usage,
695 };
696 }
697
698 /**
699 * Run a call that the tool call `parentToolCallId` made through `ctx.executeTool()`. It goes
700 * through the agent's tool pipeline with the session's hooks, against the callable tools.
701 */
702 private async _executeNestedToolCall(
703 parentToolCallId: string,
704 name: string,
705 args: unknown,
706 options: ExecuteToolOptions,
707 ): Promise<AgentToolCallOutcome> {
708 this._nestedToolCalls ??= new NestedToolCallRunner({
709 getTools: () => this._getCallableTools(),
710 isSequential: () => this.agent.toolExecution === "sequential",
711 runToolCall: (toolCall, parentId, signal, onUpdate) => {
712 const assistantMessage = this._findLastAssistantMessage();
713 if (!assistantMessage) {
714 return Promise.resolve({
715 toolCall,
716 result: { content: [{ type: "text", text: "No assistant message issued this call" }], details: {} },
717 isError: true,
718 });
719 }
720 return runToolCall(toolCall, {
721 tools: this._getCallableTools(),
722 assistantMessage,
723 context: { messages: this.agent.state.messages, tools: this.agent.state.tools },
724 beforeToolCall: (context) => this._beforeToolCall(context, parentId),
725 afterToolCall: (context) => this._afterToolCall(context, parentId),
726 signal,
727 onUpdate,
728 });
729 },
730 emit: async (event) => {
731 await this._extensionRunner.emit(event);
732 this._emit(event);
733 },
734 });
735 return this._nestedToolCalls.execute(parentToolCallId, name, args, options);
736 }
737
738 /** Whether `projection`, the current session projection, exceeds the compaction threshold of `model`. */
739 private _exceedsCompactionThreshold(model: Model<any>, projection: SessionProjection): boolean {
740 if (model.contextWindow <= 0) return false;
741 return shouldCompact(
742 estimateProjectedContextTokens(projection, this.sessionManager.getBranch()).tokens,
743 model.contextWindow,
744 this.settingsManager.getCompactionSettings(this.model),
745 );
746 }
747
748 private async _compactBeforeNextAssistantResponse(context: AgentContext): Promise<AgentContext> {
749 const projection = this.sessionManager.buildSessionProjection();
750 // A virtual selection is checked in prepareRequest, against the model the request is routed to.
751 const model = this.model;
752 if (!model || isVirtualModel(model) || !this._exceedsCompactionThreshold(model, projection)) {
753 return { ...context, messages: projection.messages };
754 }
755 await this._runAutoCompaction("threshold", false);
756 return { ...context, messages: this.sessionManager.buildSessionProjection().messages };
757 }
758
759 private _installAgentRequestProjection(): void {
760 const previousPrepareRequest = this.agent.prepareRequest;
761 this.agent.prepareRequest = async (request, signal) => {
762 const failed = this._failedResponse;
763 this._failedResponse = undefined;
764 const prepare = async () => {
765 const projection = this.sessionManager.buildSessionProjection();
766 const canonicalContext = {
767 ...request.context,
768 messages: projection.messages,
769 // Messages declare the provider-visible loadout; context.tools keeps executable implementations.
770 tools: this.agent.state.tools.slice(),
771 };
772 const previous = await previousPrepareRequest?.(
773 {
774 ...request,
775 context: canonicalContext,
776 model: this.agent.state.model,
777 thinkingLevel: this.agent.state.thinkingLevel,
778 },
779 signal,
780 );
781 return { previous, context: previous?.context ?? canonicalContext, projection };
782 };
783 let { previous, context, projection } = await prepare();
784 const model = previous?.model ?? this.agent.state.model;
785 const thinkingLevel = previous?.thinkingLevel ?? this.agent.state.thinkingLevel;
786 if (!isVirtualModel(model)) return { ...previous, context, model, thinkingLevel };
787
788 // The selection stays in agent state; only this request uses the routed model. A routing
789 // failure rejects, which ends the run with an error response. Only messages the user wrote
790 // start a turn; extension messages can follow them, e.g. from before_agent_start.
791 const lastResponse = context.messages.findLastIndex((message) => message.role === "assistant");
792 const userTurn = context.messages.slice(lastResponse + 1).some((message) => message.role === "user");
793 const state = getVirtualModelState(this.sessionManager.getBranch(), model.provider, model.id);
794 const route = await this._modelRuntime.resolveModel(model, convertToLlm(context.messages), {
795 reason: failed ? "retry" : userTurn ? "user" : "continuation",
796 thinkingLevel,
797 signal,
798 failed,
799 state,
800 });
801 if (route.state !== undefined && route.state !== state) {
802 const data: VirtualModelStateData = { provider: model.provider, modelId: model.id, state: route.state };
803 const entry = this.sessionManager.getEntry(
804 this.sessionManager.appendCustomEntry(VIRTUAL_MODEL_STATE_ENTRY, data),
805 );
806 if (entry) this._emit({ type: "entry_appended", entry });
807 }
808 // The route stands: the router already decided this request. The state entry does not change
809 // the projection.
810 if (this._exceedsCompactionThreshold(route.model, projection)) {
811 await this._runAutoCompaction("threshold", false);
812 ({ previous, context } = await prepare());
813 }
814 return { ...previous, context, model: route.model, thinkingLevel: route.thinkingLevel };
815 };
816 }
817
818 private async _dispatchTurnEndBoundary(
819 message: AssistantMessage,
820 toolResults: ToolResultMessage[],
821 ): Promise<boolean> {
822 this._lastActivityOutcome =
823 message.stopReason === "aborted" ? "aborted" : message.stopReason === "error" ? "error" : "completed";
824 const messageEntryId = this._findPersistedMessageEntryId(message);
825 if (!this._extensionRunner.hasHandlers("turn_end")) return false;
826 if (!messageEntryId) {
827 this._extensionRunner.emitError({
828 extensionPath: "<boundary>",
829 event: "turn_end",
830 error: "turn_end could not resolve the persisted assistant entry ID",
831 });
832 return false;
833 }
834 const toolResultEntryIds = toolResults.flatMap((result) => {
835 const entryId = this._findPersistedMessageEntryId(result);
836 return entryId ? [entryId] : [];
837 });
838 const boundary = await this._extensionRunner.emitBoundary(
839 {
840 type: "turn_end",
841 turnIndex: this._turnIndex,
842 message,
843 toolResults,
844 messageEntryId,
845 toolResultEntryIds,
846 outcome: this._lastActivityOutcome,
847 },
848 (entries) => this._buildBoundaryContext(entries, "turn_end"),
849 );
850 this._commitBoundaryDrafts(boundary.entries);
851 if (boundary.continue && !this._buildBoundaryContext([], "turn_end").canContinue) {
852 this._reportInvalidBoundaryContinuation("turn_end");
853 return false;
854 }
855 return boundary.continue;
856 }
857
858 private _installAgentBoundaryHooks(): void {
859 const previousFinishTurn = this.agent.finishTurn;
860 this.agent.finishTurn = async (turn, signal) => {
861 this._boundaryDispatchedMessages.add(turn.message);
862 const extensionContinue = await this._dispatchTurnEndBoundary(turn.message, turn.toolResults);
863 const previousDecision = await previousFinishTurn?.(turn, signal);
864 if (previousDecision?.action === "end") return previousDecision;
865 if (extensionContinue || previousDecision?.action === "continue") return { action: "continue" };
866 return undefined;
867 };
868 }
869
870 private _installAgentNextTurnRefresh(): void {
871 const previousPrepareNextTurnWithContext =
872 this.agent.prepareNextTurnWithContext ??
873 (this.agent.prepareNextTurn
874 ? async (_turn: PrepareNextTurnContext, signal?: AbortSignal) => await this.agent.prepareNextTurn?.(signal)
875 : undefined);
876 this.agent.prepareNextTurnWithContext = async (turn, signal) => {
877 const context = await this._compactBeforeNextAssistantResponse(turn.context);
878 const previousSnapshot = await previousPrepareNextTurnWithContext?.({ ...turn, context }, signal);
879 const nextContext = previousSnapshot?.context ?? context;
880 const runOptions = this._runSystemPromptOptions ?? this._baseSystemPromptOptions;
881 const options = normalizeBuildSystemPromptOptions({
882 ...runOptions,
883 selectedTools: this.getActiveToolNames(),
884 toolSnippets: { ...this._baseSystemPromptOptions.toolSnippets, ...runOptions.toolSnippets },
885 toolGuidelines: { ...this._baseSystemPromptOptions.toolGuidelines, ...runOptions.toolGuidelines },
886 });
887 const updateMessage = this._preparePromptAndToolLoadout(options, nextContext.messages);
888 // Keep session.systemPrompt and ctx.getSystemPrompt() in step with what the provider sees.
889 this._runSystemPromptOptions = options;
890
891 return {
892 ...previousSnapshot,
893 context: {
894 ...nextContext,
895 tools: this.agent.state.tools.slice(),
896 },
897 messages: updateMessage
898 ? [...(previousSnapshot?.messages ?? []), updateMessage]
899 : previousSnapshot?.messages,
900 model: this.agent.state.model,
901 thinkingLevel: this.agent.state.thinkingLevel,
902 };
903 };
904 }
905
906 // =========================================================================
907 // Event Subscription
908 // =========================================================================
909
910 private _refreshFinalizedContext(): void {
911 const projection = this.sessionManager.buildSessionProjection();
912 for (const entry of projection.entries) {
913 for (const message of entry.messages) this._entryIdsByMessage.set(message, entry.sourceEntry.id);
914 }
915 this.agent.state.messages = projection.messages;
916 }
917
918 private _applyBoundaryDrafts(manager: SessionManager, drafts: SessionBoundaryDraft[]): SessionEntry[] {
919 const appended: SessionEntry[] = [];
920 for (const draft of drafts) {
921 let entryId: string;
922 switch (draft.type) {
923 case "custom":
924 entryId = manager.appendCustomEntry(draft.customType, draft.data);
925 break;
926 case "custom_message":
927 entryId = manager.appendCustomMessageEntry(
928 draft.customType,
929 draft.content,
930 draft.display,
931 draft.details,
932 );
933 break;
934 case "context_edit":
935 entryId = manager.appendContextEdit(draft.targetId, draft.replacement);
936 break;
937 case "compaction": {
938 const tokensBefore = estimateProjectedContextTokens(
939 manager.buildSessionProjection(),
940 manager.getBranch(),
941 ).tokens;
942 entryId = manager.appendCompaction(
943 draft.summary,
944 draft.firstKeptEntryId,
945 tokensBefore,
946 draft.details,
947 true,
948 draft.usage,
949 );
950 break;
951 }
952 }
953 const entry = manager.getEntry(entryId);
954 if (entry) appended.push(entry);
955 }
956 return appended;
957 }
958
959 private _createBoundaryPreviewManager(drafts: SessionBoundaryDraft[]): SessionManager {
960 const header = this.sessionManager.getHeader();
961 if (!header) throw new Error("Session header is missing");
962 const manager = SessionManager.inMemory(this._cwd, undefined, [header, ...this.sessionManager.getBranch()]);
963 this._applyBoundaryDrafts(manager, drafts);
964 return manager;
965 }
966
967 private _getPendingBoundaryMessages(): AgentMessage[] {
968 return [...this.agent.peekQueuedMessages(), ...this._pendingCustomMessages];
969 }
970
971 private _buildBoundaryContext(
972 drafts: SessionBoundaryDraft[],
973 boundary: "turn_end" | "agent_before_settle",
974 ): BoundaryContextPreview {
975 const projection = this._createBoundaryPreviewManager(drafts).buildSessionProjection();
976 const pendingMessages = this._getPendingBoundaryMessages();
977 const llmMessages = convertToLlm(projection.messages);
978 const finalRole = llmMessages[llmMessages.length - 1]?.role;
979 const hasNonSystemContext = llmMessages.some((message) => message.role !== "system");
980 const contextCanContinue = hasNonSystemContext && finalRole !== "assistant";
981 const pendingCustomContext = this._pendingCustomMessages.length > 0;
982 return {
983 contextEntries: projection.entries,
984 contextMessages: projection.messages,
985 llmMessages,
986 pendingMessages,
987 canContinue:
988 contextCanContinue ||
989 pendingCustomContext ||
990 (boundary === "turn_end"
991 ? this.agent.hasQueuedMessages()
992 : finalRole === "assistant" && this.agent.hasQueuedMessages()),
993 };
994 }
995
996 private _commitBoundaryDrafts(drafts: SessionBoundaryDraft[]): void {
997 const appended = this._applyBoundaryDrafts(this.sessionManager, drafts);
998 this._refreshFinalizedContext();
999 for (const entry of appended) this._emit({ type: "entry_appended", entry });
1000 }
1001
1002 private _reportInvalidBoundaryContinuation(event: "turn_end" | "agent_before_settle"): void {
1003 this._extensionRunner.emitError({
1004 extensionPath: "<boundary>",
1005 event,
1006 error: `${event} requested continuation without runnable model context`,
1007 });
1008 }
1009
1010 /** Emit an event to all listeners */
1011 private _emit(event: AgentSessionEvent): void {
1012 for (const l of this._eventListeners) {
1013 l(event);
1014 }
1015 }
1016
1017 private _emitQueueUpdate(): void {
1018 this._emit({
1019 type: "queue_update",
1020 steering: [...this._steeringMessages],
1021 followUp: [...this._followUpMessages],
1022 });
1023 }
1024
1025 private async _emitSessionCompactFailed(event: Omit<SessionCompactFailedEvent, "type">): Promise<void> {
1026 if (this._extensionRunner.hasHandlers("session_compact_failed")) {
1027 await this._extensionRunner.emit({ type: "session_compact_failed", ...event });
1028 }
1029 }
1030
1031 private _getIdleWaitPromise(): Promise<void> {
1032 if (!this._idleWaitPromise) {
1033 this._idleWaitPromise = new Promise((resolve) => {
1034 this._resolveIdleWait = resolve;
1035 });
1036 }
1037 return this._idleWaitPromise;
1038 }
1039
1040 private _resolveIdleWaitIfIdle(): void {
1041 if (!this.isIdle || !this._resolveIdleWait) {
1042 return;
1043 }
1044 const resolve = this._resolveIdleWait;
1045 this._idleWaitPromise = undefined;
1046 this._resolveIdleWait = undefined;
1047 resolve();
1048 }
1049
1050 private async _emitAgentSettled(): Promise<void> {
1051 this._cacheWarmer?.onAgentSettled();
1052 this._isAgentRunActive = false;
1053 this._isEmittingAgentSettled = true;
1054 try {
1055 await this._extensionRunner.emit({ type: "agent_settled" });
1056 this._emit({ type: "agent_settled" });
1057 } finally {
1058 this._isEmittingAgentSettled = false;
1059 }
1060
1061 const deferred = this._deferredSettledActions.splice(0);
1062 if (deferred.length > 0) {
1063 try {
1064 for (const action of deferred) await action();
1065 } finally {
1066 this._resolveIdleWaitIfIdle();
1067 }
1068 return;
1069 }
1070 this._resolveIdleWaitIfIdle();
1071 }
1072
1073 /** Internal handler for agent events - shared by subscribe and reconnect */
1074 private _handleAgentEvent = async (event: AgentEvent): Promise<void> => {
1075 // Record the calls a tool made through ctx.executeTool() and their usage on its result message.
1076 if (this._nestedToolCalls) {
1077 if (event.type === "message_start" && event.message.role === "toolResult") {
1078 const message = event.message;
1079 const summary = this._nestedToolCalls.takeRecord(message.toolCallId);
1080 if (summary?.calls) message.nestedCalls = summary.calls;
1081 if (summary?.usage) {
1082 message.usage = message.usage ? combineUsage(message.usage, summary.usage) : summary.usage;
1083 }
1084 } else if (event.type === "agent_end") {
1085 this._nestedToolCalls.clear();
1086 }
1087 }
1088 // When a user message starts, check if it's from either queue and remove it BEFORE emitting
1089 // This ensures the UI sees the updated queue state
1090 if (event.type === "message_start" && event.message.role === "user") {
1091 this._overflowRecoveryAttempted = false;
1092 const messageText = contentText(event.message.content, "");
1093 if (messageText) {
1094 // Check steering queue first
1095 const steeringIndex = this._steeringMessages.indexOf(messageText);
1096 if (steeringIndex !== -1) {
1097 this._steeringMessages.splice(steeringIndex, 1);
1098 this._emitQueueUpdate();
1099 } else {
1100 // Check follow-up queue
1101 const followUpIndex = this._followUpMessages.indexOf(messageText);
1102 if (followUpIndex !== -1) {
1103 this._followUpMessages.splice(followUpIndex, 1);
1104 this._emitQueueUpdate();
1105 }
1106 }
1107 }
1108 }
1109
1110 // Emit to extensions first, then notify public listeners.
1111 await this._emitExtensionEvent(event);
1112 this._emit(event.type === "agent_end" ? { ...event, willRetry: this._willRetryAfterAgentEnd(event) } : event);
1113
1114 // Handle session persistence
1115 if (event.type === "message_end") {
1116 let entryId: string | undefined;
1117 // Check if this is a custom message from extensions
1118 if (event.message.role === "custom") {
1119 // Persist as CustomMessageEntry
1120 entryId = this.sessionManager.appendCustomMessageEntry(
1121 event.message.customType,
1122 event.message.content,
1123 event.message.display,
1124 event.message.details,
1125 );
1126 } else if (
1127 event.message.role === "system" ||
1128 event.message.role === "user" ||
1129 event.message.role === "assistant" ||
1130 event.message.role === "toolResult"
1131 ) {
1132 // Regular LLM message - persist as SessionMessageEntry
1133 entryId = this.sessionManager.appendMessage(event.message);
1134 }
1135 if (entryId) this._entryIdsByMessage.set(event.message, entryId);
1136 // Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere
1137
1138 if (event.message.role === "assistant") {
1139 const assistantMsg = event.message as AssistantMessage;
1140 this._lastAssistantMessage = assistantMsg;
1141 if (assistantMsg.stopReason !== "error" && assistantMsg.stopReason !== "length") {
1142 this._overflowRecoveryAttempted = false;
1143 }
1144
1145 // Reset retry counter immediately on successful assistant response
1146 // This prevents accumulation across multiple LLM calls within a turn
1147 if (assistantMsg.stopReason !== "error" && this._retryAttempt > 0) {
1148 this._emit({
1149 type: "auto_retry_end",
1150 success: true,
1151 attempt: this._retryAttempt,
1152 });
1153 this._retryAttempt = 0;
1154 }
1155 }
1156 }
1157
1158 // A turn ends after its assistant message and every tool result has been appended,
1159 // so this is the first point in the run where a context-only custom message can be
1160 // inserted without landing between a tool call and its result. Flushing after the
1161 // extension and listener dispatch above also picks up messages that turn_end
1162 // handlers queued.
1163 if (event.type === "turn_end") {
1164 this._lastAssistantToolResults = event.toolResults;
1165 this._flushPendingCustomMessages();
1166 }
1167 };
1168
1169 private _willRetryAfterAgentEnd(event: Extract<AgentEvent, { type: "agent_end" }>): boolean {
1170 if (this._agentRunAbortRequested) return false;
1171 const settings = this.settingsManager.getRetrySettings();
1172 if (!settings.enabled || this._retryAttempt >= settings.maxRetries) {
1173 return false;
1174 }
1175
1176 for (let i = event.messages.length - 1; i >= 0; i--) {
1177 const message = event.messages[i];
1178 if (message.role === "assistant") {
1179 return this._isRetryableError(message as AssistantMessage);
1180 }
1181 }
1182 return false;
1183 }
1184
1185 private _findPersistedMessageEntryId(message: AgentMessage): string | undefined {
1186 const mapped = this._entryIdsByMessage.get(message);
1187 if (mapped) return mapped;
1188 for (const entry of [...this.sessionManager.getBranch()].reverse()) {
1189 if (entry.type === "message" && entry.message === message) return entry.id;
1190 }
1191
1192 const messageIndex = this.agent.state.messages.indexOf(message);
1193 if (messageIndex < 0) return undefined;
1194 const projection = this.sessionManager.buildSessionProjection();
1195 let projectedIndex = 0;
1196 for (const entry of projection.entries) {
1197 for (let i = 0; i < entry.messages.length; i++) {
1198 if (projectedIndex === messageIndex) {
1199 this._entryIdsByMessage.set(message, entry.sourceEntry.id);
1200 return entry.sourceEntry.id;
1201 }
1202 projectedIndex++;
1203 }
1204 }
1205 return undefined;
1206 }
1207
1208 private _omitRecoveryAttempt(message: AssistantMessage, toolResults: AgentMessage[] = []): void {
1209 const targets = [message, ...toolResults];
1210 const targetIds = targets.map((target) => this._findPersistedMessageEntryId(target));
1211 const unresolvedProjectedTarget = targets.some(
1212 (target, index) => targetIds[index] === undefined && this.agent.state.messages.includes(target),
1213 );
1214 if (unresolvedProjectedTarget) {
1215 throw new Error("Cannot persist recovery omission because a projected message has no source entry");
1216 }
1217 for (const targetId of targetIds) {
1218 if (!targetId) continue;
1219 const editId = this.sessionManager.appendContextEdit(targetId, null);
1220 const entry = this.sessionManager.getEntry(editId);
1221 if (entry) this._emit({ type: "entry_appended", entry });
1222 }
1223 this._refreshFinalizedContext();
1224 }
1225
1226 /** Find the last assistant message in agent state (including aborted ones) */
1227 private _findLastAssistantMessage(): AssistantMessage | undefined {
1228 const messages = this.agent.state.messages;
1229 for (let i = messages.length - 1; i >= 0; i--) {
1230 const msg = messages[i];
1231 if (msg.role === "assistant") {
1232 return msg as AssistantMessage;
1233 }
1234 }
1235 return undefined;
1236 }
1237
1238 private _replaceMessageInPlace(target: AgentMessage, replacement: AgentMessage): void {
1239 // Agent-core stores the finalized message object in its state before emitting message_end.
1240 // SessionManager persistence happens later in _handleAgentEvent() with event.message.
1241 // Mutating this object in place keeps agent state, later turn/agent events, listeners,
1242 // and the eventual SessionManager.appendMessage(event.message) persistence in sync.
1243 if (target === replacement) {
1244 return;
1245 }
1246
1247 const targetRecord = target as unknown as Record<string, unknown>;
1248 for (const key of Object.keys(targetRecord)) {
1249 delete targetRecord[key];
1250 }
1251 Object.assign(targetRecord, replacement);
1252 }
1253
1254 /** Emit extension events based on agent events */
1255 private async _emitExtensionEvent(event: AgentEvent): Promise<void> {
1256 if (event.type === "agent_start") {
1257 this._turnIndex = 0;
1258 await this._extensionRunner.emit({ type: "agent_start" });
1259 } else if (event.type === "agent_end") {
1260 await this._extensionRunner.emit({ type: "agent_end", messages: event.messages });
1261 } else if (event.type === "turn_start") {
1262 const extensionEvent: TurnStartEvent = {
1263 type: "turn_start",
1264 turnIndex: this._turnIndex,
1265 timestamp: Date.now(),
1266 };
1267 await this._extensionRunner.emit(extensionEvent);
1268 } else if (event.type === "turn_end") {
1269 if (event.message.role === "assistant" && !this._boundaryDispatchedMessages.delete(event.message)) {
1270 await this._dispatchTurnEndBoundary(event.message, event.toolResults);
1271 }
1272 this._turnIndex++;
1273 } else if (event.type === "message_start") {
1274 const extensionEvent: MessageStartEvent = {
1275 type: "message_start",
1276 message: event.message,
1277 };
1278 await this._extensionRunner.emit(extensionEvent);
1279 } else if (event.type === "message_update") {
1280 const extensionEvent: MessageUpdateEvent = {
1281 type: "message_update",
1282 message: event.message,
1283 assistantMessageEvent: event.assistantMessageEvent,
1284 };
1285 await this._extensionRunner.emit(extensionEvent);
1286 } else if (event.type === "message_end") {
1287 const extensionEvent: MessageEndEvent = {
1288 type: "message_end",
1289 message: event.message,
1290 };
1291 const replacement = await this._extensionRunner.emitMessageEnd(extensionEvent);
1292 if (replacement) {
1293 // Untyped extension handlers can return messages with null/missing content;
1294 // normalize so it never enters agent state or session history.
1295 const normalized =
1296 (replacement.role === "user" ||
1297 replacement.role === "assistant" ||
1298 replacement.role === "toolResult" ||
1299 replacement.role === "custom") &&
1300 replacement.content == null
1301 ? ({ ...replacement, content: [] } as AgentMessage)
1302 : replacement;
1303 this._replaceMessageInPlace(event.message, normalized);
1304 }
1305 } else if (event.type === "tool_execution_start") {
1306 const extensionEvent: ToolExecutionStartEvent = {
1307 type: "tool_execution_start",
1308 toolCallId: event.toolCallId,
1309 toolName: event.toolName,
1310 args: event.args,
1311 };
1312 await this._extensionRunner.emit(extensionEvent);
1313 } else if (event.type === "tool_execution_update") {
1314 const extensionEvent: ToolExecutionUpdateEvent = {
1315 type: "tool_execution_update",
1316 toolCallId: event.toolCallId,
1317 toolName: event.toolName,
1318 args: event.args,
1319 partialResult: event.partialResult,
1320 };
1321 await this._extensionRunner.emit(extensionEvent);
1322 } else if (event.type === "tool_execution_end") {
1323 const extensionEvent: ToolExecutionEndEvent = {
1324 type: "tool_execution_end",
1325 toolCallId: event.toolCallId,
1326 toolName: event.toolName,
1327 result: event.result,
1328 isError: event.isError,
1329 };
1330 await this._extensionRunner.emit(extensionEvent);
1331 }
1332 }
1333
1334 /**
1335 * Subscribe to agent events.
1336 * Session persistence is handled internally (saves messages on message_end).
1337 * Multiple listeners can be added. Returns unsubscribe function for this listener.
1338 */
1339 subscribe(listener: AgentSessionEventListener): () => void {
1340 this._eventListeners.push(listener);
1341
1342 // Return unsubscribe function for this specific listener
1343 return () => {
1344 const index = this._eventListeners.indexOf(listener);
1345 if (index !== -1) {
1346 this._eventListeners.splice(index, 1);
1347 }
1348 };
1349 }
1350
1351 /** Disconnect from agent events during disposal. */
1352 private _disconnectFromAgent(): void {
1353 if (this._unsubscribeAgent) {
1354 this._unsubscribeAgent();
1355 this._unsubscribeAgent = undefined;
1356 }
1357 }
1358
1359 /**
1360 * Remove all listeners and disconnect from agent.
1361 * Call this when completely done with the session.
1362 */
1363 dispose(): void {
1364 try {
1365 this.abortRetry();
1366 this.abortCompaction();
1367 this.abortBranchSummary();
1368 this.abortBash();
1369 this.agent.abort();
1370 } catch {
1371 // Dispose must succeed even if an abort hook throws.
1372 }
1373
1374 this._extensionRunner.invalidate(
1375 "This extension ctx is stale after session replacement or reload. Do not use a captured pi or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload(). For newSession, fork, and switchSession, move post-replacement work into withSession and use the ctx passed to withSession. For reload, do not use the old ctx after await ctx.reload().",
1376 );
1377 this._disconnectFromAgent();
1378 this._eventListeners = [];
1379 if (this._cacheWarmer) {
1380 this._cacheWarmer.onWarmed = undefined;
1381 this._cacheWarmer.cancel();
1382 }
1383 cleanupSessionResources(this.sessionId);
1384 }
1385
1386 // =========================================================================
1387 // Read-only State Access
1388 // =========================================================================
1389
1390 /** Refresh the public finalized transcript from the canonical session projection. */
1391 refreshContext(): void {
1392 this._refreshFinalizedContext();
1393 }
1394
1395 /** Full agent state */
1396 get state(): AgentState {
1397 return this.agent.state;
1398 }
1399
1400 /** Current cache-warming state and the policy inputs that produced it. */
1401 get cacheWarmingStatus(): CacheWarmingStatus | undefined {
1402 return this._cacheWarmer?.status;
1403 }
1404
1405 /** Persist the cache-warming mode and immediately reconcile active warming. */
1406 setCacheWarmingMode(mode: CacheWarmingMode): void {
1407 this.settingsManager.setCacheWarmingMode(mode);
1408 this._cacheWarmer?.onModeChanged();
1409 }
1410
1411 /** Current model (may be undefined if not yet selected) */
1412 get model(): Model<any> | undefined {
1413 return this.agent.state.model;
1414 }
1415
1416 /** Current thinking level */
1417 get thinkingLevel(): ThinkingLevel {
1418 return this.agent.state.thinkingLevel;
1419 }
1420
1421 /** Under a virtual selection, the physical model and thinking level of the latest successful response. */
1422 get routedModel(): { model: Model<any>; thinkingLevel?: ThinkingLevel } | undefined {
1423 if (!this.model || !isVirtualModel(this.model)) return undefined;
1424 const latest = findLatestResponse(this.agent.state.messages);
1425 const model = latest && this._modelRuntime.getPhysicalModel(latest.provider, latest.model);
1426 return model && { model, thinkingLevel: latest?.thinkingLevel };
1427 }
1428
1429 /** Whether the session is currently processing an agent run or post-run continuation. */
1430 get isStreaming(): boolean {
1431 return this._isAgentRunActive;
1432 }
1433
1434 /** Whether the session has no active agent run, compaction, branch summary, retry, or queued continuation. */
1435 get isIdle(): boolean {
1436 return !this._isAgentRunActive && !this.isCompacting;
1437 }
1438
1439 /** Current effective system prompt, including changes not yet sent to the model. */
1440 get systemPrompt(): string {
1441 return buildSystemPrompt(this._runSystemPromptOptions ?? this._baseSystemPromptOptions);
1442 }
1443
1444 /** Current retry attempt (0 if not retrying) */
1445 get retryAttempt(): number {
1446 return this._retryAttempt;
1447 }
1448
1449 /**
1450 * Get the names of currently active tools, which are the tools declared to the model.
1451 * Tools with `codemode` or `deferred` exposure are callable from other tools without being active.
1452 */
1453 getActiveToolNames(): string[] {
1454 return this.agent.state.tools.map((t) => t.name);
1455 }
1456
1457 /** Get the names of the tools that tools can call through `ctx.executeTool()`. */
1458 getCallableToolNames(): string[] {
1459 return this._getCallableTools().map((t) => t.name);
1460 }
1461
1462 /**
1463 * Get all configured tools with name, description, parameter schema, prompt guidelines, and source metadata.
1464 */
1465 getAllTools(): ToolInfo[] {
1466 return Array.from(this._toolDefinitions.values()).map(({ definition, sourceInfo }) => ({
1467 name: definition.name,
1468 description: definition.description,
1469 parameters: definition.parameters,
1470 promptGuidelines: definition.promptGuidelines,
1471 exposure: this._getToolExposure(definition.name),
1472 ...(definition.namespace ? { namespace: definition.namespace } : {}),
1473 ...(definition.annotations ? { annotations: { ...definition.annotations } } : {}),
1474 sourceInfo,
1475 }));
1476 }
1477
1478 getToolDefinition(name: string): ToolDefinition | undefined {
1479 return this._toolDefinitions.get(name)?.definition;
1480 }
1481
1482 /**
1483 * Set active tools by name.
1484 * Only tools in the registry can be enabled. Unknown and hidden tool names are ignored.
1485 * Also rebuilds the system prompt to reflect the new tool set.
1486 * Changes take effect on the next agent turn.
1487 */
1488 setActiveToolsByName(toolNames: string[]): void {
1489 const previous = this.getActiveToolNames();
1490 this._setActiveTools(toolNames);
1491 // A loadout that deactivates a tool replaces the restored one, whose pending tools are dropped.
1492 // One that only adds tools, like activating tool_search, keeps them.
1493 const active = new Set(this.getActiveToolNames());
1494 if (previous.some((name) => !active.has(name))) this._pendingToolNames.clear();
1495 }
1496
1497 private _setActiveTools(toolNames: string[]): void {
1498 const tools = this._applyToolLoadout(toolNames);
1499 for (const tool of tools) this._pendingToolNames.delete(tool.name);
1500 this._rebuildSystemPrompt(tools.map((tool) => tool.name));
1501 }
1502
1503 private _isAllowedTool(name: string): boolean {
1504 return (!this._allowedToolNames || this._allowedToolNames.has(name)) && !this._excludedToolNames?.has(name);
1505 }
1506
1507 private _getToolExposure(name: string): ToolExposure {
1508 return this._toolDefinitions.get(name)?.definition.exposure ?? "direct";
1509 }
1510
1511 /**
1512 * Tools callable through `ctx.executeTool()`: the active `direct` tools and every registered
1513 * `codemode` or `deferred` tool.
1514 */
1515 private _getCallableTools(active: ReadonlySet<string> = new Set(this.getActiveToolNames())): AgentTool[] {
1516 return [...this._toolRegistry.values()].filter((tool) => {
1517 const exposure = this._getToolExposure(tool.name);
1518 return exposure === "codemode" || exposure === "deferred" || (exposure === "direct" && active.has(tool.name));
1519 });
1520 }
1521
1522 /**
1523 * Set the agent's tools for the given active tool names and return them. The active tools are
1524 * the registered, non-hidden ones; they are declared to the model. Active tools with a
1525 * `prepareLoadout` hook can change the declared descriptions and hide declarations from
1526 * requests (see {@link _installHiddenDeclarationsProjection}).
1527 */
1528 private _applyToolLoadout(toolNames: string[]): AgentTool[] {
1529 const tools = [...new Set(toolNames)].flatMap((name) => {
1530 const tool = this._toolRegistry.get(name);
1531 return tool && this._getToolExposure(name) !== "hidden" ? [tool] : [];
1532 });
1533 const hooks = tools.flatMap((tool) => {
1534 const entry = this._toolDefinitions.get(tool.name);
1535 return entry?.definition.prepareLoadout ? [entry] : [];
1536 });
1537 const hidden = new Set<string>();
1538 let declared = tools;
1539 if (hooks.length > 0) {
1540 const loadout: ToolLoadout = {
1541 declared: tools,
1542 callable: this._getCallableTools(new Set(tools.map((tool) => tool.name))),
1543 registered: [...this._toolRegistry.values()],
1544 getExposure: (name) => this._getToolExposure(name),
1545 getNamespace: (name) => this._toolDefinitions.get(name)?.definition.namespace,
1546 };
1547 const descriptions = new Map<string, string>();
1548 for (const { definition, sourceInfo } of hooks) {
1549 try {
1550 const changes = definition.prepareLoadout?.(loadout);
1551 for (const [name, description] of Object.entries(changes?.descriptions ?? {})) {
1552 descriptions.set(name, description);
1553 }
1554 for (const name of changes?.hiddenDeclarations ?? []) hidden.add(name);
1555 } catch (error) {
1556 this._extensionRunner.emitError({
1557 extensionPath: sourceInfo.path,
1558 event: "prepare_loadout",
1559 error: error instanceof Error ? error.message : String(error),
1560 stack: error instanceof Error ? error.stack : undefined,
1561 });
1562 }
1563 }
1564 declared = tools.map((tool) => {
1565 const description = descriptions.get(tool.name);
1566 return description === undefined ? tool : { ...tool, description };
1567 });
1568 }
1569 this._hiddenDeclarations = hidden;
1570 this.agent.state.tools = declared;
1571 return declared;
1572 }
1573
1574 /** Whether compaction or branch summarization is currently running */
1575 get isCompacting(): boolean {
1576 return (
1577 this._autoCompactionAbortController !== undefined ||
1578 this._compactionAbortController !== undefined ||
1579 this._branchSummaryAbortController !== undefined
1580 );
1581 }
1582
1583 /** All messages including custom types like BashExecutionMessage */
1584 get messages(): AgentMessage[] {
1585 return this.agent.state.messages;
1586 }
1587
1588 /** Current steering mode */
1589 get steeringMode(): "all" | "one-at-a-time" {
1590 return this.agent.steeringMode;
1591 }
1592
1593 /** Current follow-up mode */
1594 get followUpMode(): "all" | "one-at-a-time" {
1595 return this.agent.followUpMode;
1596 }
1597
1598 /** Current session file path, or undefined if sessions are disabled */
1599 get sessionFile(): string | undefined {
1600 return this.sessionManager.getSessionFile();
1601 }
1602
1603 /** Current session ID */
1604 get sessionId(): string {
1605 return this.sessionManager.getSessionId();
1606 }
1607
1608 /** Current session display name, if set */
1609 get sessionName(): string | undefined {
1610 return this.sessionManager.getSessionName();
1611 }
1612
1613 /** Scoped models for cycling (from --models flag) */
1614 get scopedModels(): ReadonlyArray<{ model: Model<any>; thinkingLevel?: ThinkingLevel }> {
1615 return this._scopedModels;
1616 }
1617
1618 /** Update scoped models for cycling */
1619 setScopedModels(scopedModels: Array<{ model: Model<any>; thinkingLevel?: ThinkingLevel }>): void {
1620 this._scopedModels = scopedModels;
1621 }
1622
1623 /** File-based prompt templates */
1624 get promptTemplates(): ReadonlyArray<PromptTemplate> {
1625 return this._resourceLoader.getPrompts().prompts;
1626 }
1627
1628 private _normalizePromptSnippet(text: string | undefined): string | undefined {
1629 if (!text) return undefined;
1630 const oneLine = text
1631 .replace(/[\r\n]+/g, " ")
1632 .replace(/\s+/g, " ")
1633 .trim();
1634 return oneLine.length > 0 ? oneLine : undefined;
1635 }
1636
1637 private _normalizePromptGuidelines(guidelines: string[] | undefined): string[] {
1638 if (!guidelines || guidelines.length === 0) {
1639 return [];
1640 }
1641
1642 const unique = new Set<string>();
1643 for (const guideline of guidelines) {
1644 const normalized = guideline.trim();
1645 if (normalized.length > 0) {
1646 unique.add(normalized);
1647 }
1648 }
1649 return Array.from(unique);
1650 }
1651
1652 private _rebuildSystemPrompt(toolNames: string[]): void {
1653 const validToolNames = toolNames.filter((name) => this._toolRegistry.has(name));
1654 const toolSnippets: Record<string, string> = {};
1655 for (const name of this._toolRegistry.keys()) {
1656 const snippet = this._toolPromptSnippets.get(name);
1657 // Tools without a snippet are not listed. Hidden tools are only callable through another tool.
1658 if (snippet && !this._hiddenDeclarations.has(name)) toolSnippets[name] = snippet;
1659 }
1660
1661 const loaderSystemPrompt = this._resourceLoader.getSystemPrompt();
1662 const loaderAppendSystemPrompt = this._resourceLoader.getAppendSystemPrompt();
1663 const appendSystemPrompt = loaderAppendSystemPrompt.length > 0 ? loaderAppendSystemPrompt.join("\n\n") : "";
1664 const loadedSkills = this._resourceLoader.getSkills().skills;
1665 const loadedContextFiles = this._resourceLoader.getAgentsFiles().agentsFiles;
1666
1667 this._baseSystemPromptOptions = normalizeBuildSystemPromptOptions({
1668 cwd: this._cwd,
1669 skills: loadedSkills,
1670 contextFiles: loadedContextFiles,
1671 customPrompt: loaderSystemPrompt,
1672 appendSystemPrompt,
1673 selectedTools: validToolNames,
1674 toolSnippets,
1675 toolGuidelines: Object.fromEntries(this._toolPromptGuidelines),
1676 });
1677 }
1678
1679 /**
1680 * Apply a prompt and tool loadout for the next request. Sets the executable tools and
1681 * returns a system message patching the prompt sections the model currently has (replayed
1682 * from `messages`), or undefined when the prompt is unchanged. Tool changes are declared by
1683 * the agent loop before the request.
1684 *
1685 * A forced prompt does not affect the transcript: the structured sections are still diffed
1686 * and persisted, and the forced text is projected onto the request by
1687 * {@link _installAgentForcedPromptProjection}.
1688 */
1689 private _preparePromptAndToolLoadout(
1690 options: NormalizedBuildSystemPromptOptions,
1691 messages: AgentMessage[] = this.agent.state.messages,
1692 ): SystemMessage | undefined {
1693 options.selectedTools = this._applyToolLoadout(options.selectedTools).map((tool) => tool.name);
1694 // The tool list must match the declarations the request carries, so hidden tools are not listed.
1695 options.toolSnippets = Object.fromEntries(
1696 Object.entries(options.toolSnippets).filter(([name]) => !this._hiddenDeclarations.has(name)),
1697 );
1698 const sections = diffSystemPromptSections(
1699 getCurrentSystemMessage(messages)?.sections ?? {},
1700 buildSystemPromptSections(options),
1701 );
1702 return sections ? { role: "system", content: "", sections, timestamp: Date.now() } : undefined;
1703 }
1704
1705 /**
1706 * Send a forced prompt as the provider's leading system prompt without recording it.
1707 *
1708 * A `before_agent_start` handler that returns `systemPrompt` needs that exact text at the
1709 * head of the request; a mid-conversation system message would leave the original prompt
1710 * in place. The forced text is a rendering of the current prompt, so the transcript keeps
1711 * its structured sections and the request is projected instead: the system messages
1712 * collapse into one head holding the forced text and the current tools. Runs after the
1713 * `context` extension handlers.
1714 */
1715 /**
1716 * Remove the declarations that `prepareLoadout` hooks hide from every request. The whole
1717 * transcript is filtered with the current set, so the projected declarations stay consistent
1718 * across requests and only change when the loadout does.
1719 */
1720 private _installHiddenDeclarationsProjection(): void {
1721 const previousTransformContext = this.agent.transformContext;
1722 this.agent.transformContext = async (messages, signal) => {
1723 const transformed = previousTransformContext ? await previousTransformContext(messages, signal) : messages;
1724 const hidden = this._hiddenDeclarations;
1725 if (hidden.size === 0) return transformed;
1726 return transformed.map((message) => {
1727 if (message.role !== "system" || (!message.toolsAdded && !message.toolsRemoved)) return message;
1728 const { toolsAdded, toolsRemoved, ...rest } = message;
1729 const added = toolsAdded?.filter((tool) => !hidden.has(tool.name)) ?? [];
1730 const removed = toolsRemoved?.filter((tool) => !hidden.has(tool.name)) ?? [];
1731 return {
1732 ...rest,
1733 ...(added.length > 0 ? { toolsAdded: added } : {}),
1734 ...(removed.length > 0 ? { toolsRemoved: removed } : {}),
1735 };
1736 });
1737 };
1738 }
1739
1740 private _installAgentForcedPromptProjection(): void {
1741 const previousTransformContext = this.agent.transformContext;
1742 this.agent.transformContext = async (messages, signal) => {
1743 const transformed = previousTransformContext ? await previousTransformContext(messages, signal) : messages;
1744 const forced = this._runSystemPromptOptions?.forceSystemPrompt;
1745 if (forced === undefined) return transformed;
1746 const current = getCurrentSystemMessage(transformed);
1747 const head: SystemMessage = {
1748 role: "system",
1749 content: forced,
1750 ...(current?.toolsAdded ? { toolsAdded: current.toolsAdded } : {}),
1751 timestamp: current?.timestamp ?? Date.now(),
1752 };
1753 return [head, ...transformed.filter((message) => message.role !== "system")];
1754 };
1755 }
1756
1757 /**
1758 * Restore the active tool loadout declared by the session transcript, if it declares one.
1759 * Tools reachable only from other tools are never declared, but they do not depend on the active
1760 * set, so the transcript's declarations are the whole loadout.
1761 */
1762 private _restoreToolsFromTranscript(): void {
1763 this._pendingToolNames.clear();
1764 const current = getCurrentSystemMessage(this.sessionManager.buildSessionContext().messages);
1765 if (!current) return;
1766 const names = (current.toolsAdded ?? []).map((tool) => tool.name);
1767 this._pendingToolNames = new Set(names.filter((name) => this._isAllowedTool(name)));
1768 this._setActiveTools(names);
1769 }
1770
1771 // =========================================================================
1772 // Prompting
1773 // =========================================================================
1774
1775 private async _runAgentPrompt(messages: AgentMessage | AgentMessage[]): Promise<void> {
1776 this._agentRunAbortRequested = false;
1777 // Compaction before the prompt may have scheduled a retry; the new prompt replaces it.
1778 this._failedResponse = undefined;
1779 this._recordSelection();
1780 // The run records the loadout in the transcript; restored tools that did not register by now
1781 // are dropped, so a tool that never registers does not stay pending.
1782 this._pendingToolNames.clear();
1783 this._isAgentRunActive = true;
1784 try {
1785 await this.agent.prompt(messages);
1786 while (!this._agentRunAbortRequested) {
1787 if (await this._handlePostAgentRun()) {
1788 if (this._agentRunAbortRequested) break;
1789 await this.agent.continue();
1790 continue;
1791 }
1792 if (this._agentRunAbortRequested || !(await this._runBeforeSettleBoundary())) break;
1793 if (this._agentRunAbortRequested) break;
1794 await this.agent.continue();
1795 }
1796 } finally {
1797 if (this._agentRunAbortRequested) this._finishCancelledRetry();
1798 this._failedResponse = undefined;
1799 this._runSystemPromptOptions = undefined;
1800 this._flushPendingBashMessages();
1801 this._flushPendingCustomMessages();
1802 await this._emitAgentSettled();
1803 }
1804 }
1805
1806 private async _handlePostAgentRun(): Promise<boolean> {
1807 const message = this._lastAssistantMessage;
1808 const toolResults = this._lastAssistantToolResults;
1809 this._lastAssistantMessage = undefined;
1810 this._lastAssistantToolResults = [];
1811 if (this._agentRunAbortRequested) {
1812 this._finishCancelledRetry();
1813 return false;
1814 }
1815 if (!message) return this.agent.hasQueuedMessages();
1816
1817 if (this._isRetryableError(message) && (await this._prepareRetry(message))) {
1818 if (this._agentRunAbortRequested) this._finishCancelledRetry();
1819 this._failedResponse = message;
1820 return !this._agentRunAbortRequested;
1821 }
1822 if (this._agentRunAbortRequested) {
1823 this._finishCancelledRetry();
1824 return false;
1825 }
1826
1827 if (message.stopReason === "error" && this._retryAttempt > 0) {
1828 this._emit({
1829 type: "auto_retry_end",
1830 success: false,
1831 attempt: this._retryAttempt,
1832 finalError: message.errorMessage,
1833 });
1834 this._retryAttempt = 0;
1835 }
1836
1837 if (await this._checkCompaction(message, true, toolResults)) {
1838 return !this._agentRunAbortRequested;
1839 }
1840
1841 // The low-level loop drains both queues before agent_end. Messages queued by
1842 // agent_end handlers require a fresh run before pre-settlement handlers fire.
1843 return !this._agentRunAbortRequested && this.agent.hasQueuedMessages();
1844 }
1845
1846 private async _runBeforeSettleBoundary(): Promise<boolean> {
1847 if (!this._extensionRunner.hasHandlers("agent_before_settle")) return this.agent.hasQueuedMessages();
1848 this._isBeforeSettle = true;
1849 this._abortDuringBeforeSettle = false;
1850 try {
1851 const result = await this._extensionRunner.emitBoundary(
1852 { type: "agent_before_settle", outcome: this._lastActivityOutcome },
1853 (entries) => this._buildBoundaryContext(entries, "agent_before_settle"),
1854 );
1855 this._commitBoundaryDrafts(result.entries);
1856 this._flushPendingCustomMessages();
1857 const finalContext = this._buildBoundaryContext([], "agent_before_settle");
1858 if (this._abortDuringBeforeSettle) return false;
1859 const shouldContinue = result.continue || this.agent.hasQueuedMessages();
1860 if (shouldContinue && !finalContext.canContinue) {
1861 if (result.continue) this._reportInvalidBoundaryContinuation("agent_before_settle");
1862 return false;
1863 }
1864 return shouldContinue;
1865 } finally {
1866 this._isBeforeSettle = false;
1867 }
1868 }
1869
1870 private async _runInputHandlers(
1871 text: string,
1872 images: ImageContent[] | undefined,
1873 source: InputSource,
1874 streamingBehavior?: "steer" | "followUp",
1875 ): Promise<{ text: string; images: ImageContent[] | undefined } | undefined> {
1876 if (!this._extensionRunner.hasHandlers("input")) {
1877 return { text, images };
1878 }
1879
1880 const inputResult = await this._extensionRunner.emitInput(text, images, source, streamingBehavior);
1881 if (inputResult.action === "handled") {
1882 return undefined;
1883 }
1884 if (inputResult.action === "transform") {
1885 return { text: inputResult.text, images: inputResult.images ?? images };
1886 }
1887 return { text, images };
1888 }
1889
1890 private async _normalizePromptImages(
1891 images: ImageContent[] | undefined,
1892 ): Promise<{ images: ImageContent[]; hints: string[] }> {
1893 if (!images) return { images: [], hints: [] };
1894
1895 const normalizedImages: ImageContent[] = [];
1896 const hints: string[] = [];
1897 for (const image of images) {
1898 const processed = await processImage(Buffer.from(image.data, "base64"), image.mimeType, {
1899 autoResizeImages: this.settingsManager.getImageAutoResize(),
1900 resizeOptions: this._limitsModel()?.inputLimits?.images?.resize,
1901 });
1902 if (!processed.ok) {
1903 hints.push(processed.message);
1904 continue;
1905 }
1906 normalizedImages.push({ type: "image", data: processed.data, mimeType: processed.mimeType });
1907 hints.push(...processed.hints);
1908 }
1909 return { images: normalizedImages, hints };
1910 }
1911
1912 /**
1913 * Send a prompt to the agent.
1914 * - Handles extension commands (registered via pi.registerCommand) immediately, even during streaming
1915 * - Expands file-based prompt templates by default
1916 * - During streaming, queues via steer() or followUp() based on streamingBehavior option
1917 * - Validates model and API key before sending (when not streaming)
1918 * @throws Error if streaming and no streamingBehavior specified
1919 * @throws Error if no model selected or no API key available (when not streaming)
1920 */
1921 async prompt(text: string, options?: PromptOptions): Promise<void> {
1922 if (this._isEmittingAgentSettled) {
1923 this._deferredSettledActions.push(async () => await this.prompt(text, options));
1924 return;
1925 }
1926 const expandPromptTemplates = options?.expandPromptTemplates ?? true;
1927 const preflightResult = options?.preflightResult;
1928 // Handle extension commands first (execute immediately, even during streaming)
1929 // Extension commands manage their own LLM interaction via pi.sendMessage()
1930 if (expandPromptTemplates && text.startsWith("/")) {
1931 const handled = await this._tryExecuteExtensionCommand(text);
1932 if (handled) {
1933 // Extension command executed, no prompt to send
1934 preflightResult?.("handled");
1935 return;
1936 }
1937 }
1938
1939 if (this._compactionAbortController !== undefined) {
1940 throw new Error(
1941 "Cannot submit a prompt while compaction is in progress. Wait for compaction to finish and retry.",
1942 );
1943 }
1944
1945 // Emit input event for extension interception (before skill/template expansion)
1946 const processedInput = await this._runInputHandlers(
1947 text,
1948 options?.images,
1949 options?.source ?? "interactive",
1950 this.isStreaming ? options?.streamingBehavior : undefined,
1951 );
1952 if (!processedInput) {
1953 preflightResult?.("handled");
1954 return;
1955 }
1956 const { text: currentText, images: currentImages } = processedInput;
1957
1958 // Expand skill commands (/skill:name args) and prompt templates (/template args)
1959 let expandedText = currentText;
1960 if (expandPromptTemplates) {
1961 expandedText = this._expandSkillCommand(expandedText);
1962 expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]);
1963 }
1964
1965 // If streaming, queue via steer() or followUp() based on option
1966 if (this.isStreaming) {
1967 if (!options?.streamingBehavior) {
1968 throw new Error(
1969 "Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message.",
1970 );
1971 }
1972 if (options.streamingBehavior === "followUp") {
1973 await this._queueFollowUp(expandedText, currentImages);
1974 } else {
1975 await this._queueSteer(expandedText, currentImages);
1976 }
1977 preflightResult?.("queued");
1978 return;
1979 }
1980
1981 // Flush any pending bash and custom messages before the new prompt
1982 this._flushPendingBashMessages();
1983 this._flushPendingCustomMessages();
1984
1985 // Validate model
1986 if (!this.model) {
1987 throw new Error(formatNoModelSelectedMessage());
1988 }
1989
1990 const hasConfiguredAuth =
1991 this._modelRuntime.hasConfiguredAuth(this.model.provider) ||
1992 (await this._modelRuntime.checkAuth(this.model.provider)) !== undefined;
1993 if (!hasConfiguredAuth) {
1994 const isOAuth = this._modelRuntime.isUsingOAuth(this.model.provider);
1995 if (isOAuth) {
1996 throw new Error(
1997 `Authentication failed for "${this.model.provider}". ` +
1998 `Credentials may have expired or network is unavailable. ` +
1999 `Run '/login ${this.model.provider}' to re-authenticate.`,
2000 );
2001 }
2002 throw new Error(formatNoApiKeyFoundMessage(this.model.provider));
2003 }
2004
2005 // Check if we need to compact before sending (catches aborted responses).
2006 // The user's new prompt is sent below, so do not call agent.continue() here.
2007 const lastAssistant = this._findLastAssistantMessage();
2008 if (lastAssistant) {
2009 await this._checkCompaction(lastAssistant, false);
2010 }
2011
2012 // Emit before_agent_start before normalizing images so extension-driven model
2013 // selection determines the resize profile used for the request and history.
2014 const selectedToolsBefore = this._baseSystemPromptOptions.selectedTools;
2015 const result = await this._extensionRunner.emitBeforeAgentStart(
2016 expandedText,
2017 currentImages,
2018 this._baseSystemPromptOptions,
2019 );
2020 // Handlers may edit event.systemPromptOptions.selectedTools or call setActiveTools(),
2021 // which updates the live loadout instead. An explicit edit wins; otherwise the live
2022 // loadout is authoritative, so a setActiveTools() call is not undone here.
2023 const handlerEditedTools =
2024 result.systemPromptOptions.selectedTools.length !== selectedToolsBefore.length ||
2025 result.systemPromptOptions.selectedTools.some((name, index) => name !== selectedToolsBefore[index]);
2026 if (!handlerEditedTools) result.systemPromptOptions.selectedTools = this.getActiveToolNames();
2027
2028 const normalized = await this._normalizePromptImages(currentImages);
2029 const userText = normalized.hints.length > 0 ? `${expandedText}\n\n${normalized.hints.join("\n")}` : expandedText;
2030
2031 // Build messages only after hooks and image normalization have completed.
2032 const messages: AgentMessage[] = [];
2033 const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: userText }];
2034 userContent.push(...normalized.images);
2035 messages.push({
2036 role: "user",
2037 content: userContent,
2038 timestamp: Date.now(),
2039 });
2040
2041 // Inject any pending "nextTurn" messages as context alongside the user message
2042 for (const msg of this._pendingNextTurnMessages) {
2043 messages.push(msg);
2044 }
2045 this._pendingNextTurnMessages = [];
2046
2047 for (const msg of result.messages) {
2048 messages.push({
2049 role: "custom",
2050 customType: msg.customType,
2051 // Untyped extensions can pass null/missing content; normalize at ingestion.
2052 content: msg.content ?? [],
2053 display: msg.display,
2054 details: msg.details,
2055 timestamp: Date.now(),
2056 });
2057 }
2058 const updateMessage = this._preparePromptAndToolLoadout(result.systemPromptOptions);
2059 this._runSystemPromptOptions = result.systemPromptOptions;
2060 if (updateMessage) messages.unshift(updateMessage);
2061
2062 preflightResult?.("started");
2063 await this._runAgentPrompt(messages);
2064 }
2065
2066 /**
2067 * Try to execute an extension command. Returns true if command was found and executed.
2068 */
2069 private async _tryExecuteExtensionCommand(text: string): Promise<boolean> {
2070 // Parse command name and args
2071 const spaceIndex = text.indexOf(" ");
2072 const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
2073 const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1);
2074
2075 const command = this._extensionRunner.getCommand(commandName);
2076 if (!command) return false;
2077
2078 // Get command context from extension runner (includes session control methods)
2079 const ctx = this._extensionRunner.createCommandContext();
2080
2081 try {
2082 await command.handler(args, ctx);
2083 return true;
2084 } catch (err) {
2085 // Emit error via extension runner
2086 this._extensionRunner.emitError({
2087 extensionPath: `command:${commandName}`,
2088 event: "command",
2089 error: err instanceof Error ? err.message : String(err),
2090 });
2091 return true;
2092 }
2093 }
2094
2095 /**
2096 * Expand skill commands (/skill:name args) to their full content.
2097 * Returns the expanded text, or the original text if not a skill command or skill not found.
2098 * Emits errors via extension runner if file read fails.
2099 */
2100 private _expandSkillCommand(text: string): string {
2101 if (!text.startsWith("/skill:")) return text;
2102
2103 const spaceIndex = text.indexOf(" ");
2104 const skillName = spaceIndex === -1 ? text.slice(7) : text.slice(7, spaceIndex);
2105 const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim();
2106
2107 const skill = this.resourceLoader.getSkills().skills.find((s) => s.name === skillName);
2108 if (!skill) return text; // Unknown skill, pass through
2109
2110 try {
2111 const content = readFileSync(skill.filePath, "utf-8");
2112 const body = stripFrontmatter(content).trim();
2113 const skillBlock = `<skill name="${skill.name}" location="${skill.filePath}">\nReferences are relative to ${skill.baseDir}.\n\n${body}\n</skill>`;
2114 return args ? `${skillBlock}\n\n${args}` : skillBlock;
2115 } catch (err) {
2116 // Emit error like extension commands do
2117 this._extensionRunner.emitError({
2118 extensionPath: skill.filePath,
2119 event: "skill_expansion",
2120 error: err instanceof Error ? err.message : String(err),
2121 });
2122 return text; // Return original on error
2123 }
2124 }
2125
2126 private async _queueUserInput(
2127 text: string,
2128 images: ImageContent[] | undefined,
2129 behavior: "steer" | "followUp",
2130 source: InputSource,
2131 ): Promise<QueuedInputDisposition> {
2132 if (text.startsWith("/")) {
2133 this._throwIfExtensionCommand(text);
2134 }
2135
2136 const processedInput = await this._runInputHandlers(
2137 text,
2138 images,
2139 source,
2140 this.isStreaming ? behavior : undefined,
2141 );
2142 if (!processedInput) return "handled";
2143
2144 let expandedText = this._expandSkillCommand(processedInput.text);
2145 expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]);
2146
2147 if (behavior === "steer") {
2148 await this._queueSteer(expandedText, processedInput.images);
2149 } else {
2150 await this._queueFollowUp(expandedText, processedInput.images);
2151 }
2152 return "queued";
2153 }
2154
2155 /**
2156 * Queue a steering message while the agent is running.
2157 * Delivered after the current assistant turn finishes executing its tool calls,
2158 * before the next LLM call.
2159 * Expands skill commands and prompt templates. Errors on extension commands.
2160 * @param images Optional image attachments to include with the message
2161 * @param options Input source; defaults to interactive
2162 * @throws Error if text is an extension command
2163 */
2164 async steer(
2165 text: string,
2166 images?: ImageContent[],
2167 options?: { source?: InputSource },
2168 ): Promise<QueuedInputDisposition> {
2169 return this._queueUserInput(text, images, "steer", options?.source ?? "interactive");
2170 }
2171
2172 /**
2173 * Queue a follow-up message to be processed after the agent finishes.
2174 * Delivered only when agent has no more tool calls or steering messages.
2175 * Expands skill commands and prompt templates. Errors on extension commands.
2176 * @param images Optional image attachments to include with the message
2177 * @param options Input source; defaults to interactive
2178 * @throws Error if text is an extension command
2179 */
2180 async followUp(
2181 text: string,
2182 images?: ImageContent[],
2183 options?: { source?: InputSource },
2184 ): Promise<QueuedInputDisposition> {
2185 return this._queueUserInput(text, images, "followUp", options?.source ?? "interactive");
2186 }
2187
2188 /**
2189 * Internal: Queue a steering message (already expanded, no extension command check).
2190 */
2191 private async _queueSteer(text: string, images?: ImageContent[]): Promise<void> {
2192 this._steeringMessages.push(text);
2193 this._emitQueueUpdate();
2194 const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
2195 if (images) {
2196 content.push(...images);
2197 }
2198 this.agent.steer({
2199 role: "user",
2200 content,
2201 timestamp: Date.now(),
2202 });
2203 }
2204
2205 /**
2206 * Internal: Queue a follow-up message (already expanded, no extension command check).
2207 */
2208 private async _queueFollowUp(text: string, images?: ImageContent[]): Promise<void> {
2209 this._followUpMessages.push(text);
2210 this._emitQueueUpdate();
2211 const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
2212 if (images) {
2213 content.push(...images);
2214 }
2215 this.agent.followUp({ role: "user", content, timestamp: Date.now() });
2216 }
2217
2218 /**
2219 * Throw an error if the text is an extension command.
2220 */
2221 private _throwIfExtensionCommand(text: string): void {
2222 const spaceIndex = text.indexOf(" ");
2223 const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
2224 const command = this._extensionRunner.getCommand(commandName);
2225
2226 if (command) {
2227 throw new Error(
2228 `Extension command "/${commandName}" cannot be queued. Use prompt() or execute the command when not streaming.`,
2229 );
2230 }
2231 }
2232
2233 /**
2234 * Send a custom message to the session. Creates a CustomMessageEntry.
2235 *
2236 * Handles four cases:
2237 * - Streaming: queues message, processed when loop pulls from queue
2238 * - Streaming + triggerTurn false: appended to state/session once the current turn ends
2239 * - Not streaming + triggerTurn: appends to state/session, starts new turn
2240 * - Not streaming + no trigger: appends to state/session, no turn
2241 *
2242 * @param message Custom message with customType, content, display, details
2243 * @param options.triggerTurn If true and not streaming, triggers a new LLM turn
2244 * @param options.deliverAs Delivery mode: "steer", "followUp", or "nextTurn"
2245 */
2246 async sendCustomMessage<T = unknown>(
2247 message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details">,
2248 options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
2249 ): Promise<void> {
2250 const appMessage = {
2251 role: "custom" as const,
2252 customType: message.customType,
2253 // Untyped extensions can pass null/missing content; normalize at ingestion.
2254 content: message.content ?? [],
2255 display: message.display,
2256 details: message.details,
2257 timestamp: Date.now(),
2258 } satisfies CustomMessage<T>;
2259 if (options?.deliverAs === "nextTurn") {
2260 this._pendingNextTurnMessages.push(appMessage);
2261 } else if (this.isStreaming && options?.triggerTurn !== false) {
2262 if (options?.deliverAs === "followUp") {
2263 this.agent.followUp(appMessage);
2264 } else {
2265 this.agent.steer(appMessage);
2266 }
2267 } else if (options?.triggerTurn) {
2268 if (this._isEmittingAgentSettled) {
2269 this._deferredSettledActions.push(async () => await this._runAgentPrompt(appMessage));
2270 return;
2271 }
2272 await this._runAgentPrompt(appMessage);
2273 } else if (this.isStreaming) {
2274 // Appending now would put the message between an assistant tool call and its
2275 // result, which providers that validate message order reject on replay. Defer
2276 // to the end of the turn. Nothing is emitted yet: message events must not
2277 // describe messages the session tree does not contain.
2278 this._pendingCustomMessages.push(appMessage);
2279 } else {
2280 this._appendCustomMessage(appMessage);
2281 }
2282 }
2283
2284 private _appendCustomMessage(appMessage: CustomMessage): void {
2285 this.sessionManager.appendCustomMessageEntry(
2286 appMessage.customType,
2287 appMessage.content,
2288 appMessage.display,
2289 appMessage.details,
2290 );
2291 this._refreshFinalizedContext();
2292 this._emit({ type: "message_start", message: appMessage });
2293 this._emit({ type: "message_end", message: appMessage });
2294 }
2295
2296 /**
2297 * Append custom messages queued while the agent was running.
2298 * Called once the current turn's tool results are in agent state and session history.
2299 */
2300 private _flushPendingCustomMessages(): void {
2301 if (this._pendingCustomMessages.length === 0) return;
2302
2303 const pending = this._pendingCustomMessages;
2304 this._pendingCustomMessages = [];
2305 for (const appMessage of pending) {
2306 this._appendCustomMessage(appMessage);
2307 }
2308 }
2309
2310 /**
2311 * Send a user message to the agent. Always triggers a turn.
2312 * When the agent is streaming, use deliverAs to specify how to queue the message.
2313 *
2314 * @param content User message content (string or content array)
2315 * @param options.deliverAs Delivery mode when streaming: "steer" or "followUp"
2316 * @param options.expandPromptTemplates Whether to dispatch extension commands and expand skill commands and prompt templates. Default: false.
2317 */
2318 async sendUserMessage(
2319 content: string | (TextContent | ImageContent)[],
2320 options?: { deliverAs?: "steer" | "followUp"; expandPromptTemplates?: boolean },
2321 ): Promise<void> {
2322 // Normalize content to text string + optional images
2323 let text: string;
2324 let images: ImageContent[] | undefined;
2325
2326 if (typeof content === "string") {
2327 text = content;
2328 } else {
2329 const textParts: string[] = [];
2330 images = [];
2331 for (const part of content) {
2332 if (part.type === "text") {
2333 textParts.push(part.text);
2334 } else {
2335 images.push(part);
2336 }
2337 }
2338 text = textParts.join("\n");
2339 if (images.length === 0) images = undefined;
2340 }
2341
2342 await this.prompt(text, {
2343 expandPromptTemplates: options?.expandPromptTemplates ?? false,
2344 streamingBehavior: options?.deliverAs,
2345 images,
2346 source: "extension",
2347 });
2348 }
2349
2350 /**
2351 * Clear all queued messages and return them.
2352 * Useful for restoring to editor when user aborts.
2353 * @returns Object with steering and followUp arrays
2354 */
2355 clearQueue(): { steering: string[]; followUp: string[] } {
2356 const steering = [...this._steeringMessages];
2357 const followUp = [...this._followUpMessages];
2358 this._steeringMessages = [];
2359 this._followUpMessages = [];
2360 this.agent.clearAllQueues();
2361 this._emitQueueUpdate();
2362 return { steering, followUp };
2363 }
2364
2365 /** Number of pending messages (includes both steering and follow-up) */
2366 get pendingMessageCount(): number {
2367 return this._steeringMessages.length + this._followUpMessages.length;
2368 }
2369
2370 /** Get pending steering messages (read-only) */
2371 getSteeringMessages(): readonly string[] {
2372 return this._steeringMessages;
2373 }
2374
2375 /** Get pending follow-up messages (read-only) */
2376 getFollowUpMessages(): readonly string[] {
2377 return this._followUpMessages;
2378 }
2379
2380 get resourceLoader(): ResourceLoader {
2381 return this._resourceLoader;
2382 }
2383
2384 /**
2385 * Abort current operation and wait for agent to become idle.
2386 */
2387 async abort(): Promise<void> {
2388 if (this._isAgentRunActive) {
2389 this._agentRunAbortRequested = true;
2390 }
2391 this.abortRetry();
2392 this.abortCompaction();
2393 this.abortBranchSummary();
2394 if (this._isBeforeSettle) this._abortDuringBeforeSettle = true;
2395 this.agent.abort();
2396 await this.waitForIdle();
2397 }
2398
2399 async waitForIdle(): Promise<void> {
2400 if (this.isIdle) {
2401 return;
2402 }
2403 await this._getIdleWaitPromise();
2404 }
2405
2406 // =========================================================================
2407 // Model Management
2408 // =========================================================================
2409
2410 private async _emitModelSelect(
2411 nextModel: Model<any>,
2412 previousModel: Model<any> | undefined,
2413 source: "set" | "cycle" | "restore",
2414 ): Promise<void> {
2415 if (modelsAreEqual(previousModel, nextModel)) return;
2416 await this._extensionRunner.emit({
2417 type: "model_select",
2418 model: nextModel,
2419 previousModel,
2420 source,
2421 });
2422 }
2423
2424 /**
2425 * Set model directly.
2426 * Validates that auth is configured and saves to the session transcript.
2427 * Persists to global defaults only when options.persist is true.
2428 * @throws Error if no auth is configured for the model
2429 */
2430 async setModel(model: Model<any>, options: ModelMutationOptions = {}): Promise<void> {
2431 if (!(await this._modelRuntime.checkAuth(model.provider))) {
2432 throw new Error(`No API key for ${model.provider}/${model.id}`);
2433 }
2434
2435 const previousModel = this.model;
2436 const thinkingLevel = this._getThinkingLevelForModelSwitch(model);
2437 this.agent.state.model = model;
2438 this.sessionManager.appendModelChange(model.provider, model.id);
2439 if (options.persist) {
2440 this.settingsManager.setDefaultModelAndProvider(model.provider, model.id);
2441 this._addPersistedDefaultToNonEmptyScope(model);
2442 }
2443
2444 // Apply thinking level for the new model.
2445 // Per-model thinking level overrides take priority over the global default.
2446 // Model persistence does not implicitly rewrite the global thinking default.
2447 this.setThinkingLevel(thinkingLevel);
2448
2449 await this._emitModelSelect(model, previousModel, "set");
2450 }
2451
2452 private _addPersistedDefaultToNonEmptyScope(model: Model<any>): void {
2453 if (this._scopedModels.length === 0) return;
2454 if (this._scopedModels.some((scoped) => modelsAreEqual(scoped.model, model))) return;
2455
2456 this._scopedModels = [...this._scopedModels, { model }];
2457
2458 const enabledModels = this.settingsManager.getEnabledModels();
2459 if (!enabledModels?.length) return;
2460
2461 const modelReference = `${model.provider}/${model.id}`;
2462 if (enabledModels.some((pattern) => pattern.toLowerCase() === modelReference.toLowerCase())) return;
2463 this.settingsManager.setEnabledModels([...enabledModels, modelReference]);
2464 }
2465
2466 /**
2467 * Cycle to next/previous model.
2468 * Uses scoped models (from --models flag) if available, otherwise all available models.
2469 * @param direction - "forward" (default) or "backward"
2470 * @returns The new model info, or undefined if only one model available
2471 */
2472 async cycleModel(
2473 direction: "forward" | "backward" = "forward",
2474 options: ModelMutationOptions = {},
2475 ): Promise<ModelCycleResult | undefined> {
2476 if (this._scopedModels.length > 0) {
2477 return this._cycleScopedModel(direction, options);
2478 }
2479 return this._cycleAvailableModel(direction, options);
2480 }
2481
2482 private async _cycleScopedModel(
2483 direction: "forward" | "backward",
2484 options: ModelMutationOptions,
2485 ): Promise<ModelCycleResult | undefined> {
2486 const availableIds = new Set(
2487 this._modelRuntime.getAvailableSnapshot().map((model) => `${model.provider}\0${model.id}`),
2488 );
2489 const scopedModels = this._scopedModels.filter((scoped) =>
2490 availableIds.has(`${scoped.model.provider}\0${scoped.model.id}`),
2491 );
2492 if (scopedModels.length <= 1) return undefined;
2493
2494 const currentModel = this.model;
2495 let currentIndex = scopedModels.findIndex((sm) => modelsAreEqual(sm.model, currentModel));
2496
2497 if (currentIndex === -1) currentIndex = 0;
2498 const len = scopedModels.length;
2499 const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len;
2500 const next = scopedModels[nextIndex];
2501 const thinkingLevel = this._getThinkingLevelForModelSwitch(next.model, next.thinkingLevel);
2502
2503 // Apply model
2504 this.agent.state.model = next.model;
2505 this.sessionManager.appendModelChange(next.model.provider, next.model.id);
2506 if (options.persist) {
2507 this.settingsManager.setDefaultModelAndProvider(next.model.provider, next.model.id);
2508 this._addPersistedDefaultToNonEmptyScope(next.model);
2509 }
2510
2511 // Apply thinking level for the new model.
2512 // - Explicit scoped model thinking level overrides defaults
2513 // - Per-model thinking level overrides take priority over the global default
2514 // setThinkingLevel clamps to model capabilities.
2515 // Model persistence does not implicitly rewrite the global thinking default.
2516 this.setThinkingLevel(thinkingLevel);
2517
2518 await this._emitModelSelect(next.model, currentModel, "cycle");
2519
2520 return { model: next.model, thinkingLevel: this.thinkingLevel, isScoped: true };
2521 }
2522
2523 private async _cycleAvailableModel(
2524 direction: "forward" | "backward",
2525 options: ModelMutationOptions,
2526 ): Promise<ModelCycleResult | undefined> {
2527 const availableModels = this._modelRuntime.getAvailableSnapshot();
2528 if (availableModels.length <= 1) return undefined;
2529
2530 const currentModel = this.model;
2531 let currentIndex = availableModels.findIndex((m) => modelsAreEqual(m, currentModel));
2532
2533 if (currentIndex === -1) currentIndex = 0;
2534 const len = availableModels.length;
2535 const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len;
2536 const nextModel = availableModels[nextIndex];
2537
2538 const thinkingLevel = this._getThinkingLevelForModelSwitch(nextModel);
2539 this.agent.state.model = nextModel;
2540 this.sessionManager.appendModelChange(nextModel.provider, nextModel.id);
2541 if (options.persist) {
2542 this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id);
2543 this._addPersistedDefaultToNonEmptyScope(nextModel);
2544 }
2545
2546 // Apply thinking level for the new model.
2547 // Model persistence does not implicitly rewrite the global thinking default.
2548 this.setThinkingLevel(thinkingLevel);
2549
2550 await this._emitModelSelect(nextModel, currentModel, "cycle");
2551
2552 return { model: nextModel, thinkingLevel: this.thinkingLevel, isScoped: false };
2553 }
2554
2555 // =========================================================================
2556 // Thinking Level Management
2557 // =========================================================================
2558
2559 /**
2560 * Set thinking level.
2561 * Clamps to model capabilities based on available thinking levels.
2562 * Saves the clamped level to the session transcript only if the level actually changes.
2563 * Persists the requested level to global defaults only when options.persist is true.
2564 */
2565 setThinkingLevel(level: ThinkingLevel, options: ModelMutationOptions = {}): void {
2566 const availableLevels = this.getAvailableThinkingLevels();
2567 const effectiveLevel = availableLevels.includes(level) ? level : this._clampThinkingLevel(level, availableLevels);
2568
2569 // Only persist if actually changing
2570 const previousLevel = this.agent.state.thinkingLevel;
2571 const isChanging = effectiveLevel !== previousLevel;
2572
2573 this.agent.state.thinkingLevel = effectiveLevel;
2574
2575 if (options.persist) {
2576 this.settingsManager.setDefaultThinkingLevel(level);
2577 }
2578
2579 if (isChanging) {
2580 this.sessionManager.appendThinkingLevelChange(effectiveLevel);
2581 this._emit({ type: "thinking_level_changed", level: effectiveLevel });
2582 void this._extensionRunner.emit({
2583 type: "thinking_level_select",
2584 level: effectiveLevel,
2585 previousLevel,
2586 });
2587 }
2588 }
2589
2590 /**
2591 * Cycle to next thinking level.
2592 * @returns New level, or undefined if model doesn't support thinking
2593 */
2594 cycleThinkingLevel(options: ModelMutationOptions = {}): ThinkingLevel | undefined {
2595 if (!this.supportsThinking()) return undefined;
2596
2597 const levels = this.getAvailableThinkingLevels();
2598 const currentIndex = levels.indexOf(this.thinkingLevel);
2599 const nextIndex = (currentIndex + 1) % levels.length;
2600 const nextLevel = levels[nextIndex];
2601
2602 this.setThinkingLevel(nextLevel, options);
2603 return nextLevel;
2604 }
2605
2606 /**
2607 * Get available thinking levels for current model.
2608 * The provider will clamp to what the specific model supports internally.
2609 */
2610 getAvailableThinkingLevels(): ThinkingLevel[] {
2611 if (!this.model) return [...THINKING_LEVEL_OPTIONS];
2612 return getSupportedThinkingLevels(this.model) as ThinkingLevel[];
2613 }
2614
2615 /**
2616 * Check if current model supports thinking/reasoning.
2617 */
2618 supportsThinking(): boolean {
2619 return !!this.model?.reasoning;
2620 }
2621
2622 private _getThinkingLevelForModelSwitch(targetModel?: Model<any>, explicitLevel?: ThinkingLevel): ThinkingLevel {
2623 if (explicitLevel !== undefined) {
2624 return explicitLevel;
2625 }
2626 // Per-model default takes priority when switching to a model that has one
2627 if (targetModel) {
2628 const perModel = this.settingsManager.getModelThinkingLevel(targetModel.provider, targetModel.id);
2629 if (perModel !== undefined) {
2630 return perModel;
2631 }
2632 }
2633 return this.settingsManager.getDefaultThinkingLevel() ?? this.thinkingLevel ?? DEFAULT_THINKING_LEVEL;
2634 }
2635
2636 private _clampThinkingLevel(level: ThinkingLevel, _availableLevels: ThinkingLevel[]): ThinkingLevel {
2637 return this.model ? (clampThinkingLevel(this.model, level) as ThinkingLevel) : "off";
2638 }
2639
2640 // =========================================================================
2641 // Queue Mode Management
2642 // =========================================================================
2643
2644 private syncQueueModesFromSettings(): void {
2645 this.agent.steeringMode = this.settingsManager.getSteeringMode();
2646 this.agent.followUpMode = this.settingsManager.getFollowUpMode();
2647 }
2648
2649 /**
2650 * Set steering message mode.
2651 * Saves to settings.
2652 */
2653 setSteeringMode(mode: "all" | "one-at-a-time"): void {
2654 this.agent.steeringMode = mode;
2655 this.settingsManager.setSteeringMode(mode);
2656 }
2657
2658 /**
2659 * Set follow-up message mode.
2660 * Saves to settings.
2661 */
2662 setFollowUpMode(mode: "all" | "one-at-a-time"): void {
2663 this.agent.followUpMode = mode;
2664 this.settingsManager.setFollowUpMode(mode);
2665 }
2666
2667 // =========================================================================
2668 // Compaction
2669 // =========================================================================
2670
2671 /** Generate Pi's built-in compaction summary for manual and automatic compaction. */
2672 private async _runDefaultCompaction(
2673 preparation: CompactionPreparation,
2674 model: Model<any>,
2675 customInstructions: string | undefined,
2676 signal: AbortSignal,
2677 reason: "manual" | "threshold" | "overflow",
2678 ): Promise<CompactionResult> {
2679 // Resolve the request only when Pi summarizes itself: routing may call models or fail.
2680 const request = await this._getSummarizationRequestAuth(model, signal);
2681 return compact(
2682 preparation,
2683 request.model,
2684 request.apiKey,
2685 request.headers,
2686 customInstructions,
2687 signal,
2688 request.thinkingLevel,
2689 this.agent.streamFunction,
2690 request.env,
2691 this.settingsManager.getRetrySettings(),
2692 this._summarizationRetryCallbacks({ source: "compaction", reason }),
2693 undefined, // sessionId
2694 );
2695 }
2696
2697 private _clearManualCompactionState(): void {
2698 this._compactionAbortController = undefined;
2699 this._resolveIdleWaitIfIdle();
2700 }
2701
2702 /**
2703 * Manually compact the session context.
2704 *
2705 * This is the manual entry point used by `/compact`, RPC, and extensions. It is
2706 * separate from automatic threshold/overflow compaction, which enters through
2707 * `_checkCompaction()` and `_runAutoCompaction()`. After preparation and the
2708 * `session_before_compact` hook, both paths call the lower-level `compact()`
2709 * function imported from `./compaction/index.ts`, unless the hook cancels or
2710 * supplies a custom result.
2711 *
2712 * Aborts the current agent operation first. Manual compaction never retries or
2713 * continues the interrupted agent turn.
2714 *
2715 * @param customInstructions Optional instructions for the compaction summary
2716 */
2717 async compact(customInstructions?: string): Promise<CompactionResult> {
2718 await this.abort();
2719 this._compactionAbortController = new AbortController();
2720 this._emit({ type: "compaction_start", reason: "manual" });
2721 let fromExtension = false;
2722 let cancelledByExtension = false;
2723
2724 try {
2725 const model = this.model;
2726 if (!model) {
2727 throw new Error(formatNoModelSelectedMessage());
2728 }
2729
2730 const settings = this.settingsManager.getCompactionSettings(model);
2731 const pathEntries = this.sessionManager.getBranch();
2732
2733 const preparation = prepareCompaction(pathEntries, settings);
2734 if (!preparation) {
2735 // Check why we can't compact
2736 const lastEntry = pathEntries[pathEntries.length - 1];
2737 if (lastEntry?.type === "compaction") {
2738 throw new Error("Already compacted");
2739 }
2740 throw new Error("Nothing to compact (session too small)");
2741 }
2742
2743 let extensionCompaction: CompactionResult | undefined;
2744
2745 if (this._extensionRunner.hasHandlers("session_before_compact")) {
2746 const result = (await this._extensionRunner.emit({
2747 type: "session_before_compact",
2748 preparation,
2749 branchEntries: pathEntries,
2750 customInstructions,
2751 reason: "manual",
2752 willRetry: false,
2753 signal: this._compactionAbortController.signal,
2754 })) as SessionBeforeCompactResult | undefined;
2755
2756 if (result?.cancel) {
2757 cancelledByExtension = true;
2758 throw new Error("Compaction cancelled");
2759 }
2760
2761 if (result?.compaction) {
2762 extensionCompaction = result.compaction;
2763 fromExtension = true;
2764 }
2765 }
2766
2767 let summary: string;
2768 let firstKeptEntryId: string;
2769 let tokensBefore: number;
2770 let usage: Usage | undefined;
2771 let details: unknown;
2772
2773 if (extensionCompaction) {
2774 // Extension provided compaction content
2775 summary = extensionCompaction.summary;
2776 firstKeptEntryId = extensionCompaction.firstKeptEntryId;
2777 tokensBefore = extensionCompaction.tokensBefore;
2778 usage = extensionCompaction.usage;
2779 details = extensionCompaction.details;
2780 } else {
2781 // Shared default summary generator, also used by automatic compaction.
2782 const result = await this._runDefaultCompaction(
2783 preparation,
2784 model,
2785 customInstructions,
2786 this._compactionAbortController.signal,
2787 "manual",
2788 );
2789 summary = result.summary;
2790 firstKeptEntryId = result.firstKeptEntryId;
2791 tokensBefore = result.tokensBefore;
2792 usage = result.usage;
2793 details = result.details;
2794 }
2795
2796 if (this._compactionAbortController.signal.aborted) {
2797 throw new Error("Compaction cancelled");
2798 }
2799
2800 this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension, usage);
2801 const newEntries = this.sessionManager.getEntries();
2802 this._refreshFinalizedContext();
2803 const estimatedTokensAfter = estimateMessagesTokens(this.sessionManager.buildSessionProjection().messages);
2804
2805 // Get the saved compaction entry for the extension event
2806 const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary) as
2807 | CompactionEntry
2808 | undefined;
2809
2810 if (this._extensionRunner && savedCompactionEntry) {
2811 await this._extensionRunner.emit({
2812 type: "session_compact",
2813 compactionEntry: savedCompactionEntry,
2814 fromExtension,
2815 reason: "manual",
2816 willRetry: false,
2817 });
2818 }
2819
2820 const compactionResult: CompactionResult = {
2821 summary,
2822 firstKeptEntryId,
2823 tokensBefore,
2824 estimatedTokensAfter,
2825 usage,
2826 details,
2827 };
2828 // compaction_end listeners may submit queued prompts, so expose idle state before notifying them.
2829 this._clearManualCompactionState();
2830 this._emit({
2831 type: "compaction_end",
2832 reason: "manual",
2833 result: compactionResult,
2834 aborted: false,
2835 willRetry: false,
2836 });
2837 return compactionResult;
2838 } catch (error) {
2839 const message = error instanceof Error ? error.message : String(error);
2840 const aborted = this._compactionAbortController.signal.aborted || cancelledByExtension;
2841 const errorMessage = aborted ? undefined : `Compaction failed: ${message}`;
2842 this._clearManualCompactionState();
2843 this._emit({
2844 type: "compaction_end",
2845 reason: "manual",
2846 result: undefined,
2847 aborted,
2848 willRetry: false,
2849 errorMessage,
2850 });
2851 await this._emitSessionCompactFailed({
2852 reason: "manual",
2853 errorMessage,
2854 aborted,
2855 willRetry: false,
2856 fromExtension,
2857 });
2858 throw error;
2859 } finally {
2860 this._clearManualCompactionState();
2861 }
2862 }
2863
2864 /**
2865 * Cancel in-progress compaction (manual or auto).
2866 */
2867 abortCompaction(): void {
2868 this._compactionAbortController?.abort();
2869 this._autoCompactionAbortController?.abort();
2870 }
2871
2872 /**
2873 * Cancel in-progress branch summarization.
2874 */
2875 abortBranchSummary(): void {
2876 this._branchSummaryAbortController?.abort();
2877 }
2878
2879 /**
2880 * Dispatch automatic compaction after `agent_end` or before prompt submission.
2881 * Manual compaction does not call this method; it enters through `compact()`.
2882 *
2883 * Automatic cases:
2884 * 1. Overflow with retry: a context-overflow error or recoverable length stop;
2885 * remove the failed assistant message, compact, and retry the turn once.
2886 * 2. Overflow without retry: a successful response exceeded the configured
2887 * context window; compact but preserve the completed response.
2888 * 3. Threshold without retry: valid or estimated context usage crossed the
2889 * configured threshold; compact without retrying the completed response.
2890 *
2891 * Each case calls `_runAutoCompaction()`. After preparation and the
2892 * `session_before_compact` hook, that method calls the lower-level `compact()`
2893 * function imported from `./compaction/index.ts`, unless the hook cancels or
2894 * supplies a custom result.
2895 *
2896 * @param assistantMessage The assistant message to check
2897 * @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true
2898 * @returns Whether the post-run loop should call `agent.continue()` for overflow recovery or queued messages
2899 */
2900 private async _checkCompaction(
2901 assistantMessage: AssistantMessage,
2902 skipAbortedCheck = true,
2903 toolResults: AgentMessage[] = [],
2904 ): Promise<boolean> {
2905 const settings = this.settingsManager.getCompactionSettings(this.model);
2906 if (!settings.enabled) return false;
2907
2908 // Skip if message was aborted (user cancelled) - unless skipAbortedCheck is false
2909 if (skipAbortedCheck && assistantMessage.stopReason === "aborted") return false;
2910
2911 // Skip overflow check if the message came from a different model.
2912 // This handles the case where user switched from a smaller-context model (e.g. opus)
2913 // to a larger-context model (e.g. codex) - the overflow error from the old model
2914 // shouldn't trigger compaction for the new model. Under a virtual selection, the
2915 // physical model that produced the message supplies the limits.
2916 const messageModel = this._modelForMessage(assistantMessage);
2917 const sameModel = messageModel !== undefined;
2918 const contextWindow = (messageModel ?? this.model)?.contextWindow ?? 0;
2919
2920 // Skip compaction checks if this assistant message is older than the latest
2921 // compaction boundary. This prevents a stale pre-compaction usage/error
2922 // from retriggering compaction on the first prompt after compaction.
2923 const compactionEntry = getLatestCompactionEntry(this.sessionManager.getBranch());
2924 const assistantIsFromBeforeCompaction =
2925 compactionEntry !== null && assistantMessage.timestamp <= new Date(compactionEntry.timestamp).getTime();
2926 if (assistantIsFromBeforeCompaction) {
2927 return false;
2928 }
2929
2930 // Automatic cases 1 and 2: context overflow.
2931 // A length stop is recoverable when output ended below the model's original desired limit,
2932 // independent of the configured context size or any context-clamped provider request limit.
2933 const currentProjection = this.sessionManager.buildSessionProjection();
2934 const assistantEntryId = this._findPersistedMessageEntryId(assistantMessage);
2935 const assistantIsProjected =
2936 assistantEntryId === undefined ||
2937 currentProjection.entries.some(
2938 (entry) =>
2939 entry.sourceEntry.id === assistantEntryId &&
2940 entry.messages.some((message) => message.role === "assistant"),
2941 );
2942 const branch = this.sessionManager.getBranch();
2943 const assistantIndex = assistantEntryId ? branch.findIndex((entry) => entry.id === assistantEntryId) : -1;
2944 const entriesAfterAssistant = assistantIndex >= 0 ? branch.slice(assistantIndex + 1) : [];
2945 const hasPostAssistantContextEdit = entriesAfterAssistant.some((entry) => entry.type === "context_edit");
2946 const latestAssistantEdit = entriesAfterAssistant
2947 .filter(
2948 (entry): entry is ContextEditEntry => entry.type === "context_edit" && entry.targetId === assistantEntryId,
2949 )
2950 .at(-1);
2951 const assistantRetainedForExplicitRecovery =
2952 assistantEntryId === undefined ||
2953 (!entriesAfterAssistant.some((entry) => entry.type === "compaction") &&
2954 latestAssistantEdit?.replacement !== null);
2955 const assistantUsageMatchesProjection = assistantIsProjected && !hasPostAssistantContextEdit;
2956 const explicitOverflow = assistantMessage.stopReason === "error" && isContextOverflow(assistantMessage);
2957 const contextOverflow =
2958 sameModel &&
2959 ((explicitOverflow && assistantRetainedForExplicitRecovery) ||
2960 (assistantUsageMatchesProjection && isContextOverflow(assistantMessage, contextWindow)));
2961 const recoverableLength =
2962 sameModel && assistantIsProjected && isRecoverableLength(assistantMessage, messageModel.maxTokens);
2963 if (contextOverflow || recoverableLength) {
2964 const willRetry = assistantMessage.stopReason !== "stop";
2965
2966 // Case 2: the response completed successfully. Compact, but do not retry because
2967 // agent.continue() cannot continue from a completed assistant response.
2968 if (!willRetry) {
2969 return await this._runAutoCompaction("overflow", false);
2970 }
2971
2972 if (this._overflowRecoveryAttempted) {
2973 const errorMessage = contextOverflow
2974 ? "Context overflow recovery failed after one compact-and-retry attempt. Try reducing context or switching to a larger-context model."
2975 : "Truncated response recovery failed after one compact-and-retry attempt.";
2976 this._emit({
2977 type: "compaction_end",
2978 reason: "overflow",
2979 result: undefined,
2980 aborted: false,
2981 willRetry: false,
2982 errorMessage,
2983 });
2984 await this._emitSessionCompactFailed({
2985 reason: "overflow",
2986 errorMessage,
2987 aborted: false,
2988 willRetry: false,
2989 fromExtension: false,
2990 });
2991 return false;
2992 }
2993
2994 // Persistently omit the selected final attempt before post-run recovery compaction.
2995 this._overflowRecoveryAttempted = true;
2996 this._omitRecoveryAttempt(assistantMessage, toolResults);
2997 const retry = await this._runAutoCompaction("overflow", willRetry);
2998 if (retry) this._failedResponse = assistantMessage;
2999 return retry;
3000 }
3001
3002 // Case 3: threshold compaction without retry.
3003 // For error messages or all-zero usage messages, estimate from the last valid response.
3004 // This ensures sessions that hit persistent API errors (e.g. 529) or malformed zero-usage
3005 // responses can still compact and do not reset context accounting.
3006 let contextTokens: number;
3007 const projection = currentProjection;
3008 const hasContextEdits = projection.entries.some((entry) => entry.sourceEntry.type === "context_edit");
3009 const directContextTokens = assistantMessage.usage ? calculateContextTokens(assistantMessage.usage) : 0;
3010 if (hasContextEdits) {
3011 contextTokens = estimateProjectedContextTokens(projection, branch).tokens;
3012 } else if (assistantMessage.stopReason === "error" || directContextTokens === 0) {
3013 const messages = this.agent.state.messages;
3014 const estimate = estimateContextTokens(messages);
3015 // Without provider usage, estimate.tokens is the pure message-size estimate.
3016 // Only usage-backed estimates need the stale pre-compaction check.
3017 if (estimate.lastUsageIndex !== null) {
3018 // Verify the usage source is post-compaction. Kept pre-compaction messages
3019 // have stale usage reflecting the old (larger) context and would falsely
3020 // trigger compaction right after one just finished.
3021 const usageMsg = messages[estimate.lastUsageIndex];
3022 if (
3023 compactionEntry &&
3024 usageMsg.role === "assistant" &&
3025 (usageMsg as AssistantMessage).timestamp <= new Date(compactionEntry.timestamp).getTime()
3026 ) {
3027 return false;
3028 }
3029 }
3030 contextTokens = estimate.tokens;
3031 } else {
3032 contextTokens = directContextTokens;
3033 }
3034 if (shouldCompact(contextTokens, contextWindow, settings)) {
3035 return await this._runAutoCompaction("threshold", false);
3036 }
3037 return false;
3038 }
3039
3040 /**
3041 * Execute threshold or overflow compaction. Manual compaction uses
3042 * `AgentSession.compact()` instead. Both paths call the lower-level `compact()`
3043 * function imported from `./compaction/index.ts` after preparation and extension
3044 * interception.
3045 *
3046 * @param reason Automatic trigger selected by `_checkCompaction()`
3047 * @param willRetry Whether to continue the interrupted turn after overflow compaction
3048 * @returns Whether the post-run loop should call `agent.continue()`
3049 */
3050 private async _runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean): Promise<boolean> {
3051 const model = this.model;
3052 const settings = this.settingsManager.getCompactionSettings(model);
3053 let abortController: AbortController | undefined;
3054 let started = false;
3055 let fromExtension = false;
3056 let cancelledByExtension = false;
3057
3058 try {
3059 if (!model) {
3060 return false;
3061 }
3062
3063 const pathEntries = this.sessionManager.getBranch();
3064 const preparation = prepareCompaction(pathEntries, settings);
3065 if (!preparation) {
3066 return false;
3067 }
3068
3069 abortController = new AbortController();
3070 this._autoCompactionAbortController = abortController;
3071 started = true;
3072 this._emit({ type: "compaction_start", reason });
3073 abortController.signal.throwIfAborted();
3074
3075 let extensionCompaction: CompactionResult | undefined;
3076
3077 if (this._extensionRunner.hasHandlers("session_before_compact")) {
3078 const extensionResult = (await this._extensionRunner.emit({
3079 type: "session_before_compact",
3080 preparation,
3081 branchEntries: pathEntries,
3082 customInstructions: undefined,
3083 reason,
3084 willRetry,
3085 signal: abortController.signal,
3086 })) as SessionBeforeCompactResult | undefined;
3087
3088 if (extensionResult?.cancel) {
3089 cancelledByExtension = true;
3090 throw new Error("Compaction cancelled");
3091 }
3092
3093 if (extensionResult?.compaction) {
3094 extensionCompaction = extensionResult.compaction;
3095 fromExtension = true;
3096 }
3097 }
3098 abortController.signal.throwIfAborted();
3099
3100 let summary: string;
3101 let firstKeptEntryId: string;
3102 let tokensBefore: number;
3103 let usage: Usage | undefined;
3104 let details: unknown;
3105
3106 if (extensionCompaction) {
3107 // Extension provided compaction content
3108 summary = extensionCompaction.summary;
3109 firstKeptEntryId = extensionCompaction.firstKeptEntryId;
3110 tokensBefore = extensionCompaction.tokensBefore;
3111 usage = extensionCompaction.usage;
3112 details = extensionCompaction.details;
3113 } else {
3114 // Shared default summary generator, also used by manual compaction.
3115 const compactResult = await this._runDefaultCompaction(
3116 preparation,
3117 model,
3118 undefined,
3119 abortController.signal,
3120 reason,
3121 );
3122 summary = compactResult.summary;
3123 firstKeptEntryId = compactResult.firstKeptEntryId;
3124 tokensBefore = compactResult.tokensBefore;
3125 usage = compactResult.usage;
3126 details = compactResult.details;
3127 }
3128 abortController.signal.throwIfAborted();
3129
3130 this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension, usage);
3131 const newEntries = this.sessionManager.getEntries();
3132 this._refreshFinalizedContext();
3133 const estimatedTokensAfter = estimateMessagesTokens(this.sessionManager.buildSessionProjection().messages);
3134
3135 // Get the saved compaction entry for the extension event
3136 const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary) as
3137 | CompactionEntry
3138 | undefined;
3139
3140 if (this._extensionRunner && savedCompactionEntry) {
3141 await this._extensionRunner.emit({
3142 type: "session_compact",
3143 compactionEntry: savedCompactionEntry,
3144 fromExtension,
3145 reason,
3146 willRetry,
3147 });
3148 }
3149
3150 const result: CompactionResult = {
3151 summary,
3152 firstKeptEntryId,
3153 tokensBefore,
3154 estimatedTokensAfter,
3155 usage,
3156 details,
3157 };
3158 this._emit({ type: "compaction_end", reason, result, aborted: false, willRetry });
3159
3160 if (willRetry) return true;
3161
3162 // Auto-compaction can complete while follow-up/steering/custom messages are waiting.
3163 // Continue once so queued messages are delivered.
3164 return this.agent.hasQueuedMessages();
3165 } catch (error) {
3166 const message = error instanceof Error ? error.message : "compaction failed";
3167 const aborted = abortController?.signal.aborted === true || cancelledByExtension;
3168 if (started) {
3169 const errorMessage = aborted
3170 ? undefined
3171 : reason === "overflow"
3172 ? `Context overflow recovery failed: ${message}`
3173 : `Auto-compaction failed: ${message}`;
3174 this._emit({
3175 type: "compaction_end",
3176 reason,
3177 result: undefined,
3178 aborted,
3179 willRetry: false,
3180 errorMessage,
3181 });
3182 await this._emitSessionCompactFailed({
3183 reason,
3184 errorMessage,
3185 aborted,
3186 willRetry: false,
3187 fromExtension,
3188 });
3189 }
3190 return false;
3191 } finally {
3192 if (this._autoCompactionAbortController === abortController) {
3193 this._autoCompactionAbortController = undefined;
3194 }
3195 this._resolveIdleWaitIfIdle();
3196 }
3197 }
3198
3199 /**
3200 * Toggle auto-compaction setting.
3201 */
3202 setAutoCompactionEnabled(enabled: boolean): void {
3203 this.settingsManager.setCompactionEnabled(enabled);
3204 }
3205
3206 /** Whether auto-compaction is enabled */
3207 get autoCompactionEnabled(): boolean {
3208 return this.settingsManager.getCompactionEnabled();
3209 }
3210
3211 async bindExtensions(bindings: ExtensionBindings): Promise<void> {
3212 if (bindings.uiContext !== undefined) {
3213 this._extensionUIContext = bindings.uiContext;
3214 }
3215 if (bindings.mode !== undefined) {
3216 this._extensionMode = bindings.mode;
3217 }
3218 if (bindings.commandContextActions !== undefined) {
3219 this._extensionCommandContextActions = bindings.commandContextActions;
3220 }
3221 if (bindings.abortHandler !== undefined) {
3222 this._extensionAbortHandler = bindings.abortHandler;
3223 }
3224 if (bindings.shutdownHandler !== undefined) {
3225 this._extensionShutdownHandler = bindings.shutdownHandler;
3226 }
3227 if (bindings.onError !== undefined) {
3228 this._extensionErrorListener = bindings.onError;
3229 }
3230
3231 this._applyExtensionBindings(this._extensionRunner);
3232 await this._extensionRunner.emit(this._sessionStartEvent);
3233 this._extensionRunner.reportUnhandledMcpServers();
3234 await this.extendResourcesFromExtensions(this._sessionStartEvent.reason === "reload" ? "reload" : "startup");
3235 }
3236
3237 private async extendResourcesFromExtensions(reason: "startup" | "reload"): Promise<void> {
3238 if (!this._extensionRunner.hasHandlers("resources_discover")) {
3239 return;
3240 }
3241
3242 const { skillPaths, promptPaths, themePaths } = await this._extensionRunner.emitResourcesDiscover(
3243 this._cwd,
3244 reason,
3245 );
3246
3247 if (skillPaths.length === 0 && promptPaths.length === 0 && themePaths.length === 0) {
3248 return;
3249 }
3250
3251 const extensionPaths: ResourceExtensionPaths = {
3252 skillPaths: this.buildExtensionResourcePaths(skillPaths),
3253 promptPaths: this.buildExtensionResourcePaths(promptPaths),
3254 themePaths: this.buildExtensionResourcePaths(themePaths),
3255 };
3256
3257 this._resourceLoader.extendResources(extensionPaths);
3258 this._rebuildSystemPrompt(this.getActiveToolNames());
3259 }
3260
3261 private buildExtensionResourcePaths(entries: Array<{ path: string; extensionPath: string }>): Array<{
3262 path: string;
3263 metadata: { source: string; scope: "temporary"; origin: "top-level"; baseDir?: string };
3264 }> {
3265 return entries.map((entry) => {
3266 const source = this.getExtensionSourceLabel(entry.extensionPath);
3267 const baseDir = isSyntheticPath(entry.extensionPath) ? undefined : dirname(entry.extensionPath);
3268 return {
3269 path: entry.path,
3270 metadata: {
3271 source,
3272 scope: "temporary",
3273 origin: "top-level",
3274 baseDir,
3275 },
3276 };
3277 });
3278 }
3279
3280 private getExtensionSourceLabel(extensionPath: string): string {
3281 if (isSyntheticPath(extensionPath)) {
3282 return `extension:${extensionPath.replace(/[<>]/g, "")}`;
3283 }
3284 const base = basename(extensionPath);
3285 const name = base.replace(/\.(ts|js)$/, "");
3286 return `extension:${name}`;
3287 }
3288
3289 private _applyExtensionBindings(runner: ExtensionRunner): void {
3290 runner.setUIContext(this._extensionUIContext, this._extensionMode);
3291 runner.bindCommandContext(this._extensionCommandContextActions);
3292
3293 this._extensionErrorUnsubscriber?.();
3294 this._extensionErrorUnsubscriber = this._extensionErrorListener
3295 ? runner.onError(this._extensionErrorListener)
3296 : undefined;
3297 }
3298
3299 private _refreshCurrentModelFromRegistry(): void {
3300 const currentModel = this.model;
3301 if (!currentModel) {
3302 return;
3303 }
3304
3305 const refreshedModel = this._modelRuntime.getModel(currentModel.provider, currentModel.id);
3306 if (!refreshedModel || refreshedModel === currentModel) {
3307 return;
3308 }
3309
3310 this.agent.state.model = refreshedModel;
3311 }
3312
3313 private _bindExtensionCore(runner: ExtensionRunner): void {
3314 const getCommands = (): SlashCommandInfo[] => {
3315 const extensionCommands: SlashCommandInfo[] = runner.getRegisteredCommands().map((command) => ({
3316 name: command.invocationName,
3317 description: command.description,
3318 source: "extension",
3319 sourceInfo: command.sourceInfo,
3320 }));
3321
3322 const templates: SlashCommandInfo[] = this.promptTemplates.map((template) => ({
3323 name: template.name,
3324 description: template.description,
3325 source: "prompt",
3326 sourceInfo: template.sourceInfo,
3327 }));
3328
3329 const skills: SlashCommandInfo[] = this._resourceLoader.getSkills().skills.map((skill) => ({
3330 name: `skill:${skill.name}`,
3331 description: skill.description,
3332 source: "skill",
3333 sourceInfo: skill.sourceInfo,
3334 }));
3335
3336 return [...extensionCommands, ...templates, ...skills];
3337 };
3338
3339 runner.bindCore(
3340 {
3341 sendMessage: (message, options) => {
3342 this.sendCustomMessage(message, options).catch((err) => {
3343 runner.emitError({
3344 extensionPath: "<runtime>",
3345 event: "send_message",
3346 error: err instanceof Error ? err.message : String(err),
3347 });
3348 });
3349 },
3350 sendUserMessage: (content, options) => {
3351 this.sendUserMessage(content, options).catch((err) => {
3352 runner.emitError({
3353 extensionPath: "<runtime>",
3354 event: "send_user_message",
3355 error: err instanceof Error ? err.message : String(err),
3356 });
3357 });
3358 },
3359 appendEntry: (customType, data) => {
3360 const entryId = this.sessionManager.appendCustomEntry(customType, data);
3361 const entry = this.sessionManager.getEntry(entryId);
3362 if (entry) {
3363 this._emit({ type: "entry_appended", entry });
3364 }
3365 },
3366 setSessionName: (name) => {
3367 this.setSessionName(name);
3368 },
3369 getSessionName: () => {
3370 return this.sessionManager.getSessionName();
3371 },
3372 setLabel: (entryId, label) => {
3373 this.sessionManager.appendLabelChange(entryId, label);
3374 },
3375 getActiveTools: () => this.getActiveToolNames(),
3376 getAllTools: () => this.getAllTools(),
3377 getSettings: () => this.settingsManager.getSettings(),
3378 setActiveTools: (toolNames) => this.setActiveToolsByName(toolNames),
3379 refreshTools: () => this._refreshToolRegistry(),
3380 getCommands,
3381 setModel: async (model) => {
3382 if (!this._modelRuntime.hasConfiguredAuth(model.provider)) return false;
3383 await this.setModel(model);
3384 return true;
3385 },
3386 getThinkingLevel: () => this.thinkingLevel,
3387 setThinkingLevel: (level) => this.setThinkingLevel(level),
3388 },
3389 {
3390 getModel: () => this.model,
3391 getScopedModels: () => this._scopedModels,
3392 isIdle: () => this.isIdle,
3393 isProjectTrusted: () => this.settingsManager.isProjectTrusted(),
3394 getSignal: () => this.agent.signal,
3395 abort: () => {
3396 if (this._extensionAbortHandler) {
3397 this._extensionAbortHandler();
3398 return;
3399 }
3400 void this.abort();
3401 },
3402 hasPendingMessages: () => this.pendingMessageCount > 0,
3403 shutdown: () => {
3404 this._extensionShutdownHandler?.();
3405 },
3406 getContextUsage: () => this.getContextUsage(),
3407 compact: (options) => {
3408 void (async () => {
3409 try {
3410 const result = await this.compact(options?.customInstructions);
3411 options?.onComplete?.(result);
3412 } catch (error) {
3413 const err = error instanceof Error ? error : new Error(String(error));
3414 options?.onError?.(err);
3415 }
3416 })();
3417 },
3418 getSystemPrompt: () => this.systemPrompt,
3419 getSystemPromptOptions: () => this._baseSystemPromptOptions,
3420 executeTool: (callerId, name, args, options) => this._executeNestedToolCall(callerId, name, args, options),
3421 getCallableTools: () => this._getCallableTools(),
3422 },
3423 {
3424 registerProvider: (name, config) => {
3425 this._modelRuntime.registerProvider(name, config);
3426 this._refreshCurrentModelFromRegistry();
3427 },
3428 registerNativeProvider: (provider) => {
3429 this._modelRuntime.registerNativeProvider(provider);
3430 this._refreshCurrentModelFromRegistry();
3431 },
3432 unregisterProvider: (name) => {
3433 this._modelRuntime.unregisterProvider(name);
3434 this._refreshCurrentModelFromRegistry();
3435 },
3436 registerVirtualModel: (definition) => {
3437 this._modelRuntime.registerVirtualModel(definition);
3438 this._refreshCurrentModelFromRegistry();
3439 },
3440 unregisterVirtualModel: (provider, id) => {
3441 this._modelRuntime.unregisterVirtualModel(provider, id);
3442 this._refreshCurrentModelFromRegistry();
3443 },
3444 },
3445 );
3446 }
3447
3448 private _refreshToolRegistry(options?: { activeToolNames?: string[]; includeAllExtensionTools?: boolean }): void {
3449 // Tools that were already activated on registration. A tool whose exposure changes to
3450 // `direct` or `model-only` (for example from `hidden`) is activated like a new tool.
3451 const previousActivatedOnRegistration = new Set(
3452 [...this._toolRegistry.keys()].filter((name) => this._isActivatedOnRegistration(name)),
3453 );
3454 const previousActiveToolNames = this.getActiveToolNames();
3455 const allowedToolNames = this._allowedToolNames;
3456
3457 const registeredTools = this._extensionRunner.getAllRegisteredTools();
3458 const allCustomTools = [
3459 ...registeredTools,
3460 ...this._customTools.map((definition) => ({
3461 definition,
3462 sourceInfo: createSyntheticSourceInfo(`<sdk:${definition.name}>`, { source: "sdk" }),
3463 })),
3464 ].filter((tool) => this._isAllowedTool(tool.definition.name));
3465 const definitionRegistry = new Map<string, ToolDefinitionEntry>(
3466 Array.from(this._baseToolDefinitions.entries())
3467 .filter(([name]) => this._isAllowedTool(name))
3468 .map(([name, definition]) => [
3469 name,
3470 {
3471 definition,
3472 sourceInfo: createSyntheticSourceInfo(`${BUILTIN_PATH_PREFIX}${name}`, { source: "builtin" }),
3473 },
3474 ]),
3475 );
3476 for (const tool of allCustomTools) {
3477 definitionRegistry.set(tool.definition.name, {
3478 definition: tool.definition,
3479 sourceInfo: tool.sourceInfo,
3480 });
3481 }
3482 this._toolDefinitions = definitionRegistry;
3483 this._toolPromptSnippets = new Map(
3484 Array.from(definitionRegistry.values())
3485 .map(({ definition }) => {
3486 const snippet = this._normalizePromptSnippet(definition.promptSnippet);
3487 return snippet ? ([definition.name, snippet] as const) : undefined;
3488 })
3489 .filter((entry): entry is readonly [string, string] => entry !== undefined),
3490 );
3491 this._toolPromptGuidelines = new Map(
3492 Array.from(definitionRegistry.values())
3493 .map(({ definition }) => {
3494 const guidelines = this._normalizePromptGuidelines(definition.promptGuidelines);
3495 return guidelines.length > 0 ? ([definition.name, guidelines] as const) : undefined;
3496 })
3497 .filter((entry): entry is readonly [string, string[]] => entry !== undefined),
3498 );
3499 const runner = this._extensionRunner;
3500 const wrappedExtensionTools = wrapRegisteredTools(allCustomTools, runner);
3501 const wrappedBuiltInTools = wrapRegisteredTools(
3502 Array.from(this._baseToolDefinitions.values())
3503 .filter((definition) => this._isAllowedTool(definition.name))
3504 .map((definition) => ({
3505 definition,
3506 sourceInfo: createSyntheticSourceInfo(`${BUILTIN_PATH_PREFIX}${definition.name}`, {
3507 source: "builtin",
3508 }),
3509 })),
3510 runner,
3511 );
3512
3513 const toolRegistry = new Map(wrappedBuiltInTools.map((tool) => [tool.name, tool]));
3514 for (const tool of wrappedExtensionTools as AgentTool[]) {
3515 toolRegistry.set(tool.name, tool);
3516 }
3517 this._toolRegistry = toolRegistry;
3518
3519 const nextActiveToolNames = (
3520 options?.activeToolNames ? [...options.activeToolNames] : [...previousActiveToolNames]
3521 ).filter((name) => this._isAllowedTool(name));
3522
3523 if (allowedToolNames) {
3524 for (const toolName of this._toolRegistry.keys()) {
3525 // Naming a tool activates it even when it is not active by default.
3526 if (allowedToolNames.has(toolName) && this._isDeclarable(toolName)) {
3527 nextActiveToolNames.push(toolName);
3528 }
3529 }
3530 } else if (options?.includeAllExtensionTools) {
3531 for (const tool of wrappedExtensionTools) {
3532 if (this._isActivatedOnRegistration(tool.name)) nextActiveToolNames.push(tool.name);
3533 }
3534 } else if (!options?.activeToolNames) {
3535 for (const toolName of this._toolRegistry.keys()) {
3536 if (!previousActivatedOnRegistration.has(toolName) && this._isActivatedOnRegistration(toolName)) {
3537 nextActiveToolNames.push(toolName);
3538 }
3539 }
3540 }
3541 // Pending tools that are registered now become active.
3542 nextActiveToolNames.push(...this._pendingToolNames);
3543
3544 this._setActiveTools([...new Set(nextActiveToolNames)]);
3545 }
3546
3547 /** Whether activating the tool declares it to the model. */
3548 private _isDeclarable(name: string): boolean {
3549 const exposure = this._getToolExposure(name);
3550 return exposure === "direct" || exposure === "model-only";
3551 }
3552
3553 /** Whether registering the tool activates it, which declares it to the model. */
3554 private _isActivatedOnRegistration(name: string): boolean {
3555 return this._isDeclarable(name) && this._toolDefinitions.get(name)?.definition.defaultActive !== false;
3556 }
3557
3558 private _buildRuntime(options: {
3559 activeToolNames?: string[];
3560 flagValues?: Map<string, boolean | string>;
3561 includeAllExtensionTools?: boolean;
3562 }): void {
3563 const autoResizeImages = this.settingsManager.getImageAutoResize();
3564 const shellCommandPrefix = this.settingsManager.getShellCommandPrefix();
3565 const shellPath = this.settingsManager.getShellPath();
3566 const baseToolDefinitions = this._baseToolsOverride
3567 ? Object.fromEntries(
3568 Object.entries(this._baseToolsOverride).map(([name, tool]) => [
3569 name,
3570 createToolDefinitionFromAgentTool(tool),
3571 ]),
3572 )
3573 : createAllToolDefinitions(this._cwd, {
3574 read: { autoResizeImages },
3575 bash: { commandPrefix: shellCommandPrefix, shellPath },
3576 });
3577
3578 this._baseToolDefinitions = new Map(
3579 Object.entries(baseToolDefinitions).map(([name, tool]) => [name, tool as ToolDefinition]),
3580 );
3581
3582 const extensionsResult = this._resourceLoader.getExtensions();
3583 if (options.flagValues) {
3584 for (const [name, value] of options.flagValues) {
3585 extensionsResult.runtime.flagValues.set(name, value);
3586 }
3587 }
3588
3589 this._extensionRunner = new ExtensionRunner(
3590 extensionsResult.extensions,
3591 extensionsResult.runtime,
3592 this._cwd,
3593 this.sessionManager,
3594 new ModelRegistry(this._modelRuntime),
3595 );
3596 if (this._extensionRunnerRef) {
3597 this._extensionRunnerRef.current = this._extensionRunner;
3598 }
3599 this._bindExtensionCore(this._extensionRunner);
3600 this._applyExtensionBindings(this._extensionRunner);
3601
3602 const defaultActiveToolNames = this._baseToolsOverride
3603 ? Object.keys(this._baseToolsOverride)
3604 : ["read", "bash", "edit", "write"];
3605 const baseActiveToolNames = options.activeToolNames ?? defaultActiveToolNames;
3606 this._refreshToolRegistry({
3607 activeToolNames: baseActiveToolNames,
3608 includeAllExtensionTools: options.includeAllExtensionTools,
3609 });
3610 }
3611
3612 async reload(options?: { beforeSessionStart?: () => void | Promise<void> }): Promise<void> {
3613 const oldRunner = this._extensionRunner;
3614 const previousFlagValues = oldRunner.getFlagValues();
3615 await emitSessionShutdownEvent(oldRunner, { type: "session_shutdown", reason: "reload" });
3616 oldRunner.invalidate();
3617 const previousDefaultTools = new Set(
3618 this._usesDefaultTools ? (this.settingsManager.getDefaultTools() ?? DEFAULT_TOOL_NAMES) : [],
3619 );
3620 await this.settingsManager.reload();
3621 this.syncQueueModesFromSettings();
3622 resetApiProviders();
3623 await this._resourceLoader.reload();
3624 // Activate tools newly added to defaultTools. Removed ones stay active, and tools disabled
3625 // during the session stay disabled unless the setting newly adds them.
3626 const addedDefaultTools = this._usesDefaultTools
3627 ? (this.settingsManager.getDefaultTools() ?? DEFAULT_TOOL_NAMES).filter(
3628 (name) => !previousDefaultTools.has(name),
3629 )
3630 : [];
3631 // Tools the new extensions register later, such as MCP tools, are pending until then.
3632 for (const name of this.getActiveToolNames()) this._pendingToolNames.add(name);
3633 this._buildRuntime({
3634 activeToolNames: [...this.getActiveToolNames(), ...addedDefaultTools],
3635 flagValues: previousFlagValues,
3636 includeAllExtensionTools: true,
3637 });
3638
3639 const hasBindings =
3640 this._extensionUIContext ||
3641 this._extensionCommandContextActions ||
3642 this._extensionShutdownHandler ||
3643 this._extensionErrorListener;
3644 if (hasBindings) {
3645 await options?.beforeSessionStart?.();
3646 await this._extensionRunner.emit({ type: "session_start", reason: "reload" });
3647 this._extensionRunner.reportUnhandledMcpServers();
3648 await this.extendResourcesFromExtensions("reload");
3649 }
3650 }
3651
3652 // =========================================================================
3653 // Auto-Retry
3654 // =========================================================================
3655
3656 /**
3657 * Check if an error is retryable (overloaded, rate limit, server errors).
3658 * Context overflow errors are NOT retryable (handled by compaction instead).
3659 */
3660 private _isRetryableError(message: AssistantMessage): boolean {
3661 // Context overflow is handled by compaction, not retry.
3662 if (isContextOverflow(message, (this._modelForMessage(message) ?? this.model)?.contextWindow ?? 0)) return false;
3663 return isRetryableAssistantError(message);
3664 }
3665
3666 /**
3667 * Retry policy + callbacks shared by compaction and branch-summary summarization calls.
3668 * Uses the same `settings.retry` budget/backoff as agent-turn retries so a single transient
3669 * stream drop no longer fails the whole operation. `source` carries the context
3670 * the TUI needs to render the retry and recreate the underlying indicator.
3671 */
3672 private _summarizationRetryCallbacks(
3673 source: { source: "branchSummary" } | { source: "compaction"; reason: "manual" | "threshold" | "overflow" },
3674 ): RetryCallbacks {
3675 return {
3676 onRetryScheduled: (attempt, maxAttempts, delayMs, errorMessage) => {
3677 this._emit({
3678 type: "summarization_retry_scheduled",
3679 attempt,
3680 maxAttempts,
3681 delayMs,
3682 errorMessage,
3683 });
3684 },
3685 onRetryAttemptStart: () => {
3686 this._emit({
3687 type: "summarization_retry_attempt_start",
3688 ...source,
3689 });
3690 },
3691 onRetryFinished: () => {
3692 this._emit({ type: "summarization_retry_finished" });
3693 },
3694 };
3695 }
3696
3697 private _finishCancelledRetry(): void {
3698 if (this._retryAttempt === 0) return;
3699 const attempt = this._retryAttempt;
3700 this._retryAttempt = 0;
3701 this._emit({
3702 type: "auto_retry_end",
3703 success: false,
3704 attempt,
3705 finalError: "Retry cancelled",
3706 });
3707 }
3708
3709 /**
3710 * Prepare a retryable error for continuation with exponential backoff.
3711 * @returns true if the caller should continue the agent, false otherwise
3712 */
3713 private async _prepareRetry(message: AssistantMessage): Promise<boolean> {
3714 const settings = this.settingsManager.getRetrySettings();
3715 if (!settings.enabled) {
3716 return false;
3717 }
3718
3719 this._retryAttempt++;
3720
3721 if (this._retryAttempt > settings.maxRetries) {
3722 // Preserve the completed attempt count so post-run handling can emit the final failure.
3723 this._retryAttempt--;
3724 return false;
3725 }
3726
3727 const delayMs = retryDelayMs(settings, this._retryAttempt);
3728
3729 this._emit({
3730 type: "auto_retry_start",
3731 attempt: this._retryAttempt,
3732 maxAttempts: settings.maxRetries,
3733 delayMs,
3734 errorMessage: message.errorMessage || "Unknown error",
3735 });
3736
3737 // Keep the failed attempt in raw history while durably omitting it from model projection.
3738 this._omitRecoveryAttempt(message);
3739
3740 // Wait with exponential backoff (abortable)
3741 this._retryAbortController = new AbortController();
3742 try {
3743 await sleep(delayMs, this._retryAbortController.signal);
3744 } catch {
3745 // Aborted during sleep - emit end event so UI can clean up
3746 this._finishCancelledRetry();
3747 return false;
3748 } finally {
3749 this._retryAbortController = undefined;
3750 }
3751
3752 return true;
3753 }
3754
3755 /**
3756 * Cancel in-progress retry.
3757 */
3758 abortRetry(): void {
3759 this._retryAbortController?.abort();
3760 }
3761
3762 /** Whether auto-retry is currently in progress */
3763 get isRetrying(): boolean {
3764 return this._retryAbortController !== undefined;
3765 }
3766
3767 /** Whether auto-retry is enabled */
3768 get autoRetryEnabled(): boolean {
3769 return this.settingsManager.getRetryEnabled();
3770 }
3771
3772 /**
3773 * Toggle auto-retry setting.
3774 */
3775 setAutoRetryEnabled(enabled: boolean): void {
3776 this.settingsManager.setRetryEnabled(enabled);
3777 }
3778
3779 // =========================================================================
3780 // Bash Execution
3781 // =========================================================================
3782
3783 /**
3784 * Execute a bash command.
3785 * Adds result to agent context and session.
3786 * @param command The bash command to execute
3787 * @param onChunk Optional streaming callback for output
3788 * @param options.excludeFromContext If true, command output won't be sent to LLM (!! prefix)
3789 * @param options.id Optional identifier included in bash execution update events
3790 * @param options.operations Custom BashOperations for remote execution
3791 */
3792 async executeBash(
3793 command: string,
3794 onChunk?: (chunk: string) => void,
3795 options?: { excludeFromContext?: boolean; id?: string; operations?: BashOperations },
3796 ): Promise<BashResult> {
3797 const abortController = new AbortController();
3798 this._bashAbortControllers.add(abortController);
3799
3800 // Apply command prefix if configured (e.g., "shopt -s expand_aliases" for alias support)
3801 const prefix = this.settingsManager.getShellCommandPrefix();
3802 const shellPath = this.settingsManager.getShellPath();
3803 const resolvedCommand = prefix ? `${prefix}\n${command}` : command;
3804
3805 try {
3806 const result = await executeBashWithOperations(
3807 resolvedCommand,
3808 this.sessionManager.getCwd(),
3809 options?.operations ?? createLocalBashOperations({ shellPath }),
3810 {
3811 onChunk: (delta) => {
3812 onChunk?.(delta);
3813 this._emit({ type: "bash_execution_update", id: options?.id, delta });
3814 },
3815 signal: abortController.signal,
3816 },
3817 );
3818
3819 this.recordBashResult(command, result, options);
3820 return result;
3821 } finally {
3822 this._bashAbortControllers.delete(abortController);
3823 }
3824 }
3825
3826 /**
3827 * Record a bash execution result in session history.
3828 * Used by executeBash and by extensions that handle bash execution themselves.
3829 */
3830 recordBashResult(command: string, result: BashResult, options?: { excludeFromContext?: boolean }): void {
3831 const bashMessage: BashExecutionMessage = {
3832 role: "bashExecution",
3833 command,
3834 output: result.output,
3835 exitCode: result.exitCode,
3836 cancelled: result.cancelled,
3837 truncated: result.truncated,
3838 fullOutputPath: result.fullOutputPath,
3839 timestamp: Date.now(),
3840 excludeFromContext: options?.excludeFromContext,
3841 };
3842
3843 // If agent is streaming, defer adding to avoid breaking tool_use/tool_result ordering
3844 if (this.isStreaming) {
3845 // Queue for later - will be flushed on agent_end
3846 this._pendingBashMessages.push(bashMessage);
3847 } else {
3848 this.sessionManager.appendMessage(bashMessage);
3849 this._refreshFinalizedContext();
3850 }
3851 }
3852
3853 /**
3854 * Cancel running bash command.
3855 */
3856 abortBash(): void {
3857 for (const abortController of [...this._bashAbortControllers]) {
3858 abortController.abort();
3859 }
3860 }
3861
3862 /** Whether a bash command is currently running */
3863 get isBashRunning(): boolean {
3864 return this._bashAbortControllers.size > 0;
3865 }
3866
3867 /** Whether there are pending bash messages waiting to be flushed */
3868 get hasPendingBashMessages(): boolean {
3869 return this._pendingBashMessages.length > 0;
3870 }
3871
3872 /**
3873 * Flush pending bash messages to agent state and session.
3874 * Called after agent turn completes to maintain proper message ordering.
3875 */
3876 private _flushPendingBashMessages(): void {
3877 if (this._pendingBashMessages.length === 0) return;
3878
3879 for (const bashMessage of this._pendingBashMessages) {
3880 this.sessionManager.appendMessage(bashMessage);
3881 }
3882 this._pendingBashMessages = [];
3883 this._refreshFinalizedContext();
3884 }
3885
3886 // =========================================================================
3887 // Session Management
3888 // =========================================================================
3889
3890 /**
3891 * Set a display name for the current session.
3892 */
3893 setSessionName(name: string): void {
3894 this.sessionManager.appendSessionInfo(name);
3895 const event = { type: "session_info_changed", name: this.sessionManager.getSessionName() } as const;
3896 this._emit(event);
3897 void this._extensionRunner.emit(event);
3898 }
3899
3900 // =========================================================================
3901 // Tree Navigation
3902 // =========================================================================
3903
3904 /**
3905 * Navigate to a different node in the session tree.
3906 * Unlike fork() which creates a new session file, this stays in the same file.
3907 *
3908 * @param targetId The entry ID to navigate to
3909 * @param options.summarize Whether user wants to summarize abandoned branch
3910 * @param options.customInstructions Custom instructions for summarizer
3911 * @param options.replaceInstructions If true, customInstructions replaces the default prompt
3912 * @param options.label Label to attach to the branch summary entry
3913 * @returns Result with editorText (if user message) and cancelled status
3914 */
3915 async navigateTree(
3916 targetId: string,
3917 options: { summarize?: boolean; customInstructions?: string; replaceInstructions?: boolean; label?: string } = {},
3918 ): Promise<{ editorText?: string; cancelled: boolean; aborted?: boolean; summaryEntry?: BranchSummaryEntry }> {
3919 if (this.isStreaming) {
3920 throw new Error("Wait for the current response to finish before navigating the session tree.");
3921 }
3922 if (this.isCompacting) {
3923 throw new Error(
3924 "Wait for the current compaction or tree navigation to finish before navigating the session tree.",
3925 );
3926 }
3927
3928 const oldLeafId = this.sessionManager.getLeafId();
3929
3930 // No-op if already at target
3931 if (targetId === oldLeafId) {
3932 return { cancelled: false };
3933 }
3934
3935 // Model required for summarization
3936 if (options.summarize && !this.model) {
3937 throw new Error("No model available for summarization");
3938 }
3939
3940 const targetEntry = this.sessionManager.getEntry(targetId);
3941 if (!targetEntry) {
3942 throw new Error(`Entry ${targetId} not found`);
3943 }
3944
3945 // Collect entries to summarize (from old leaf to common ancestor)
3946 const { entries: entriesToSummarize, commonAncestorId } = collectEntriesForBranchSummary(
3947 this.sessionManager,
3948 oldLeafId,
3949 targetId,
3950 );
3951
3952 // Prepare event data - mutable so extensions can override
3953 let customInstructions = options.customInstructions;
3954 let replaceInstructions = options.replaceInstructions;
3955 let label = options.label;
3956
3957 const preparation: TreePreparation = {
3958 targetId,
3959 oldLeafId,
3960 commonAncestorId,
3961 entriesToSummarize,
3962 userWantsSummary: options.summarize ?? false,
3963 customInstructions,
3964 replaceInstructions,
3965 label,
3966 };
3967
3968 // Set up abort controller for summarization
3969 this._branchSummaryAbortController = new AbortController();
3970
3971 try {
3972 let extensionSummary: { summary: string; details?: unknown; usage?: Usage } | undefined;
3973 let fromExtension = false;
3974
3975 // Emit session_before_tree event
3976 if (this._extensionRunner.hasHandlers("session_before_tree")) {
3977 const result = (await this._extensionRunner.emit({
3978 type: "session_before_tree",
3979 preparation,
3980 signal: this._branchSummaryAbortController.signal,
3981 })) as SessionBeforeTreeResult | undefined;
3982
3983 if (result?.cancel) {
3984 return { cancelled: true };
3985 }
3986
3987 if (result?.summary && options.summarize) {
3988 extensionSummary = result.summary;
3989 fromExtension = true;
3990 }
3991
3992 // Allow extensions to override instructions and label
3993 if (result?.customInstructions !== undefined) {
3994 customInstructions = result.customInstructions;
3995 }
3996 if (result?.replaceInstructions !== undefined) {
3997 replaceInstructions = result.replaceInstructions;
3998 }
3999 if (result?.label !== undefined) {
4000 label = result.label;
4001 }
4002 }
4003
4004 // Run default summarizer if needed
4005 let summaryText: string | undefined;
4006 let summaryDetails: unknown;
4007 let summaryUsage: Usage | undefined;
4008 if (options.summarize && entriesToSummarize.length > 0 && !extensionSummary) {
4009 const signal = this._branchSummaryAbortController.signal;
4010 const branchSummarySettings = this.settingsManager.getBranchSummarySettings();
4011 const result = await generateBranchSummary(entriesToSummarize, {
4012 ...(await this._getSummarizationRequestAuth(this.model!, signal)),
4013 signal,
4014 customInstructions,
4015 replaceInstructions,
4016 reserveTokens: branchSummarySettings.reserveTokens,
4017 streamFn: this.agent.streamFunction,
4018 retry: this.settingsManager.getRetrySettings(),
4019 callbacks: this._summarizationRetryCallbacks({ source: "branchSummary" }),
4020 });
4021 if (result.aborted) {
4022 return { cancelled: true, aborted: true };
4023 }
4024 if (result.error) {
4025 throw new Error(result.error);
4026 }
4027 summaryText = result.summary;
4028 summaryUsage = result.usage;
4029 summaryDetails = {
4030 readFiles: result.readFiles || [],
4031 modifiedFiles: result.modifiedFiles || [],
4032 };
4033 } else if (extensionSummary) {
4034 summaryText = extensionSummary.summary;
4035 summaryDetails = extensionSummary.details;
4036 summaryUsage = extensionSummary.usage;
4037 }
4038
4039 // Determine the new leaf position based on target type
4040 let newLeafId: string | null;
4041 let editorText: string | undefined;
4042
4043 if (targetEntry.type === "message" && targetEntry.message.role === "user") {
4044 // User message: leaf = parent (null if root), text goes to editor
4045 newLeafId = targetEntry.parentId;
4046 editorText = contentText(targetEntry.message.content, "");
4047 } else if (targetEntry.type === "custom_message") {
4048 // Custom message: leaf = parent (null if root), text goes to editor
4049 newLeafId = targetEntry.parentId;
4050 editorText = contentText(targetEntry.content, "");
4051 } else {
4052 // Non-user message: leaf = selected node
4053 newLeafId = targetId;
4054 }
4055
4056 // Switch leaf (with or without summary)
4057 // Summary is attached at the navigation target position (newLeafId), not the old branch
4058 let summaryEntry: BranchSummaryEntry | undefined;
4059 if (summaryText) {
4060 // Create summary at target position (can be null for root)
4061 const summaryId = this.sessionManager.branchWithSummary(
4062 newLeafId,
4063 summaryText,
4064 summaryDetails,
4065 fromExtension,
4066 summaryUsage,
4067 );
4068 summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry;
4069
4070 // Attach label to the summary entry
4071 if (label) {
4072 this.sessionManager.appendLabelChange(summaryId, label);
4073 }
4074 } else if (newLeafId === null) {
4075 // No summary, navigating to root - reset leaf
4076 this.sessionManager.resetLeaf();
4077 } else {
4078 // No summary, navigating to non-root
4079 this.sessionManager.branch(newLeafId);
4080 }
4081
4082 // Attach label to target entry when not summarizing (no summary entry to label)
4083 if (label && !summaryText) {
4084 this.sessionManager.appendLabelChange(targetId, label);
4085 }
4086
4087 // Update finalized context from the canonical session projection.
4088 this._refreshFinalizedContext();
4089 this._restoreToolsFromTranscript();
4090
4091 // Emit session_tree event
4092 await this._extensionRunner.emit({
4093 type: "session_tree",
4094 newLeafId: this.sessionManager.getLeafId(),
4095 oldLeafId,
4096 summaryEntry,
4097 fromExtension: summaryText ? fromExtension : undefined,
4098 });
4099
4100 // Emit to custom tools
4101
4102 return { editorText, cancelled: false, summaryEntry };
4103 } finally {
4104 this._branchSummaryAbortController = undefined;
4105 this._resolveIdleWaitIfIdle();
4106 }
4107 }
4108
4109 /**
4110 * Get all user messages from session for fork selector.
4111 */
4112 getUserMessagesForForking(): Array<{ entryId: string; text: string }> {
4113 const entries = this.sessionManager.getEntries();
4114 const result: Array<{ entryId: string; text: string }> = [];
4115
4116 for (const entry of entries) {
4117 if (entry.type !== "message") continue;
4118 if (entry.message.role !== "user") continue;
4119
4120 const text = contentText(entry.message.content, "");
4121 if (text) {
4122 result.push({ entryId: entry.id, text });
4123 }
4124 }
4125
4126 return result;
4127 }
4128
4129 /**
4130 * Get session statistics. Aggregates over ALL session entries (including
4131 * history that was compacted away), so token/cost totals reflect what was
4132 * actually billed across the session.
4133 */
4134 getSessionStats(): SessionStats {
4135 let userMessages = 0;
4136 let assistantMessages = 0;
4137 let toolResults = 0;
4138 let totalMessages = 0;
4139 let toolCalls = 0;
4140 const usageTotals = createUsageTotals();
4141
4142 for (const entry of this.sessionManager.getEntries()) {
4143 if (entry.type === "usage") {
4144 addUsageToTotals(usageTotals, entry.usage);
4145 } else if ((entry.type === "branch_summary" || entry.type === "compaction") && entry.usage) {
4146 addUsageToTotals(usageTotals, entry.usage);
4147 }
4148 if (entry.type !== "message") continue;
4149 totalMessages++;
4150 const message = entry.message;
4151 if (message.role === "user") {
4152 userMessages++;
4153 } else if (message.role === "toolResult") {
4154 toolResults++;
4155 if (message.usage) {
4156 addUsageToTotals(usageTotals, message.usage);
4157 }
4158 } else if (message.role === "assistant") {
4159 assistantMessages++;
4160 const assistantMsg = message as AssistantMessage;
4161 if (Array.isArray(assistantMsg.content)) {
4162 toolCalls += assistantMsg.content.filter((c) => c.type === "toolCall").length;
4163 }
4164 addUsageToTotals(usageTotals, assistantMsg.usage);
4165 }
4166 }
4167
4168 return {
4169 sessionFile: this.sessionFile,
4170 sessionId: this.sessionId,
4171 userMessages,
4172 assistantMessages,
4173 toolCalls,
4174 toolResults,
4175 totalMessages,
4176 tokens: {
4177 input: usageTotals.input,
4178 output: usageTotals.output,
4179 cacheRead: usageTotals.cacheRead,
4180 cacheWrite: usageTotals.cacheWrite,
4181 total: usageTotals.input + usageTotals.output + usageTotals.cacheRead + usageTotals.cacheWrite,
4182 },
4183 cost: usageTotals.cost,
4184 contextUsage: this.getContextUsage(),
4185 };
4186 }
4187
4188 getContextUsage(): ContextUsage | undefined {
4189 const model = this._limitsModel();
4190 if (!model) return undefined;
4191
4192 const contextWindow = model.contextWindow ?? 0;
4193 if (contextWindow <= 0) return undefined;
4194
4195 // After compaction, the last assistant usage reflects pre-compaction context size.
4196 // We can only trust usage from an assistant that responded after the latest compaction.
4197 // If no such assistant exists, context token count is unknown until the next LLM response.
4198 const projection = this.sessionManager.buildSessionProjection();
4199 const branch = this.sessionManager.getBranch();
4200 const latestCompaction = getLatestCompactionEntry(branch);
4201
4202 if (latestCompaction) {
4203 const projectedAssistants = new Set(
4204 projection.entries.flatMap((entry) =>
4205 entry.messages.some(
4206 (message) =>
4207 message.role === "assistant" &&
4208 message.stopReason !== "aborted" &&
4209 message.stopReason !== "error" &&
4210 calculateContextTokens(message.usage) > 0,
4211 )
4212 ? [entry.sourceEntry.id]
4213 : [],
4214 ),
4215 );
4216 const compactionIndex = branch.findIndex((entry) => entry.id === latestCompaction.id);
4217 const hasPostCompactionUsage = branch
4218 .slice(compactionIndex + 1)
4219 .some((entry) => projectedAssistants.has(entry.id));
4220 if (!hasPostCompactionUsage) return { tokens: null, contextWindow, percent: null };
4221 }
4222
4223 const estimate = estimateProjectedContextTokens(projection, branch);
4224 const percent = (estimate.tokens / contextWindow) * 100;
4225
4226 return {
4227 tokens: estimate.tokens,
4228 contextWindow,
4229 percent,
4230 };
4231 }
4232
4233 /**
4234 * Export session to HTML.
4235 * @param outputPath Optional output path (defaults to session directory)
4236 * @param options Optional export presentation settings
4237 * @returns Path to exported file
4238 */
4239 async exportToHtml(outputPath?: string, options: { themeName?: string } = {}): Promise<string> {
4240 const themeName = [options.themeName, this.settingsManager.getTheme()].find(
4241 (candidate) => candidate !== undefined && getThemeByName(candidate) !== undefined,
4242 );
4243
4244 // Create tool renderer if we have an extension runner (for custom tool HTML rendering)
4245 const toolRenderer: ToolHtmlRenderer = createToolHtmlRenderer({
4246 getToolDefinition: (name) => this.getToolDefinition(name),
4247 theme,
4248 cwd: this.sessionManager.getCwd(),
4249 });
4250
4251 return await exportSessionToHtml(this.sessionManager, this.state, {
4252 outputPath,
4253 themeName,
4254 toolRenderer,
4255 });
4256 }
4257
4258 /**
4259 * Export the current session branch to a JSONL file.
4260 * Writes the session header followed by all entries on the current branch path.
4261 * @param outputPath Target file path. If omitted, generates a timestamped file in cwd.
4262 * @returns The resolved output file path.
4263 */
4264 exportToJsonl(outputPath?: string): string {
4265 return exportSessionToJsonl(this.sessionManager, outputPath);
4266 }
4267
4268 /**
4269 * Ask the current model to describe what went wrong in this session for a bug report.
4270 * Used when the user declines to share the transcript itself.
4271 */
4272 async summarizeForBugReport(options: { hint?: string; signal: AbortSignal }): Promise<string> {
4273 const model = this.model;
4274 if (!model) {
4275 throw new Error("No model selected");
4276 }
4277 return generateBugReportSummary({
4278 ...(await this._getSummarizationRequestAuth(model, options.signal)),
4279 messages: this.messages,
4280 hint: options.hint,
4281 signal: options.signal,
4282 streamFn: this.agent.streamFunction,
4283 retry: this.settingsManager.getRetrySettings(),
4284 sessionId: this.sessionId,
4285 });
4286 }
4287
4288 // =========================================================================
4289 // Utilities
4290 // =========================================================================
4291
4292 /**
4293 * Get text content of last assistant message.
4294 * Useful for /copy command.
4295 * @returns Text content, or undefined if no assistant message exists
4296 */
4297 getLastAssistantText(): string | undefined {
4298 const lastAssistant = this.messages
4299 .slice()
4300 .reverse()
4301 .find((m) => {
4302 if (m.role !== "assistant") return false;
4303 const msg = m as AssistantMessage;
4304 // Skip aborted messages with no content
4305 if (msg.stopReason === "aborted" && msg.content.length === 0) return false;
4306 return true;
4307 });
4308
4309 if (!lastAssistant) return undefined;
4310
4311 let text = "";
4312 for (const content of (lastAssistant as AssistantMessage).content) {
4313 if (content.type === "text") {
4314 text += content.text;
4315 }
4316 }
4317
4318 return text.trim() || undefined;
4319 }
4320
4321 // =========================================================================
4322 // Extension System
4323 // =========================================================================
4324
4325 createReplacedSessionContext(): ReplacedSessionContext {
4326 const context = Object.defineProperties(
4327 {},
4328 Object.getOwnPropertyDescriptors(this._extensionRunner.createCommandContext()),
4329 ) as ReplacedSessionContext;
4330 context.sendMessage = (message, options) => this.sendCustomMessage(message, options);
4331 context.sendUserMessage = (content, options) => this.sendUserMessage(content, options);
4332 return context;
4333 }
4334
4335 /**
4336 * Check if extensions have handlers for a specific event type.
4337 */
4338 hasExtensionHandlers(eventType: string): boolean {
4339 return this._extensionRunner.hasHandlers(eventType);
4340 }
4341
4342 /**
4343 * Get the extension runner (for setting UI context and error handlers).
4344 */
4345 get extensionRunner(): ExtensionRunner {
4346 return this._extensionRunner;
4347 }
4348}