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 access7
* - Event subscription with automatic session persistence8
* - Model and thinking level management9
* - Compaction (manual and auto)10
* - Bash execution11
* - Session switching and branching12
*13
* Modes use this class and add their own I/O layer on top.14
*/16
import { readFileSync } from "node:fs";17
import { basename, dirname } from "node:path";18
import {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";34
import { contentText, getCurrentSystemMessage, retryDelayMs } from "@earendil-works/pi-ai";35
import 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";46
import {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";58
import { getThemeByName, theme } from "../modes/interactive/theme/theme.ts";59
import { stripFrontmatter } from "../utils/frontmatter.ts";60
import { processImage } from "../utils/image-process.ts";61
import { sleep } from "../utils/sleep.ts";62
import { normalizeToolResultImages } from "../utils/tool-result-images.ts";63
import { formatNoApiKeyFoundMessage, formatNoModelSelectedMessage } from "./auth-guidance.ts";64
import { type BashResult, executeBashWithOperations } from "./bash-executor.ts";65
import { generateBugReportSummary } from "./bug-report.ts";66
import type { CacheWarmer, CacheWarmingStatus } from "./cache-warmer.ts";67
import {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";80
import { DEFAULT_THINKING_LEVEL, THINKING_LEVEL_OPTIONS } from "./defaults.ts";81
import { exportSessionToHtml, type ToolHtmlRenderer } from "./export-html/index.ts";82
import { createToolHtmlRenderer } from "./export-html/tool-renderer.ts";83
import {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";115
import { emitSessionShutdownEvent } from "./extensions/runner.ts";116
import { type BashExecutionMessage, type CustomMessage, convertToLlm } from "./messages.ts";117
import { ModelRegistry } from "./model-registry.ts";118
import type { ModelRuntime } from "./model-runtime.ts";119
import { NestedToolCallRunner } from "./nested-tool-calls.ts";120
import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.ts";121
import type { ResourceExtensionPaths, ResourceLoader } from "./resource-loader.ts";122
import { exportSessionToJsonl } from "./session-export.ts";123
import {124
type BranchSummaryEntry,125
type CompactionEntry,126
type ContextEditEntry,127
getLatestCompactionEntry,128
type SessionEntry,129
SessionManager,130
type SessionProjection,131
} from "./session-manager.ts";132
import { type CacheWarmingMode, DEFAULT_TOOL_NAMES, type SettingsManager } from "./settings-manager.ts";133
import type { SlashCommandInfo } from "./slash-commands.ts";134
import { BUILTIN_PATH_PREFIX, createSyntheticSourceInfo, isSyntheticPath, type SourceInfo } from "./source-info.ts";135
import {136
buildSystemPrompt,137
buildSystemPromptSections,138
diffSystemPromptSections,139
type NormalizedBuildSystemPromptOptions,140
normalizeBuildSystemPromptOptions,141
} from "./system-prompt.ts";142
import { type BashOperations, createLocalBashOperations } from "./tools/bash.ts";143
import { createAllToolDefinitions } from "./tools/index.ts";144
import { createToolDefinitionFromAgentTool } from "./tools/tool-definition-wrapper.ts";145
import { addUsageToTotals, combineUsage, createUsageTotals } from "./usage-totals.ts";146
import {147
findLatestResponse,148
getBranchSelection,149
getVirtualModelState,150
isVirtualModel,151
VIRTUAL_MODEL_STATE_ENTRY,152
type VirtualModelStateData,153
} from "./virtual-models.ts";155
// ============================================================================156
// Skill Block Parsing157
// ============================================================================159
/** Parsed skill block from a user message */160
export interface ParsedSkillBlock {161
name: string;162
location: string;163
content: string;164
userMessage: string | undefined;165
}167
/**168
* Parse a skill block from message text.169
* Returns null if the text doesn't contain a skill block.170
*/171
export 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
}182
/** Tool execution events of calls a tool made through `ctx.executeTool()` carry `parentToolCallId`. */183
type WithParentToolCallId<E> = E extends {184
type: "tool_execution_start" | "tool_execution_update" | "tool_execution_end";185
}186
? E & { parentToolCallId?: string }187
: E;189
/** Session-specific events that extend the core AgentEvent */190
export 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 };233
/** Listener function for agent session events */234
export type AgentSessionEventListener = (event: AgentSessionEvent) => void;236
// ============================================================================237
// Types238
// ============================================================================240
function withoutDeletedHeaders(headers: ProviderHeaders | undefined): Record<string, string> | undefined {241
return headers242
? Object.fromEntries(Object.entries(headers).filter((entry): entry is [string, string] => entry[1] !== null))243
: undefined;244
}246
export 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 activates265
* 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 keep276
* 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
}285
export interface ExtensionBindings {286
uiContext?: ExtensionUIContext;287
mode?: ExtensionMode;288
commandContextActions?: ExtensionCommandContextActions;289
abortHandler?: () => void;290
shutdownHandler?: ShutdownHandler;291
onError?: ExtensionErrorListener;292
}294
export type QueuedInputDisposition = "handled" | "queued";295
export type PromptDisposition = QueuedInputDisposition | "started";297
/** Options for AgentSession.prompt() */298
export 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
}311
/** Options for model/thinking mutations. */312
export interface ModelMutationOptions {313
/** Persist the new value to global defaults. Defaults to session-only. */314
persist?: boolean;315
}317
/** Result from cycleModel() */318
export interface ModelCycleResult {319
model: Model<any>;320
thinkingLevel: ThinkingLevel;321
/** Whether cycling through scoped models (--models flag) or all available */322
isScoped: boolean;323
}325
/** Session statistics for /session command */326
export 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
}345
interface ToolDefinitionEntry {346
definition: ToolDefinition;347
sourceInfo: SourceInfo;348
}350
function estimateMessagesTokens(messages: AgentMessage[]): number {351
let tokens = 0;352
for (const message of messages) {353
tokens += estimateTokens(message);354
}355
return tokens;356
}358
// ============================================================================359
// AgentSession Class360
// ============================================================================362
export class AgentSession {363
readonly agent: Agent;364
readonly sessionManager: SessionManager;365
readonly settingsManager: SettingsManager;367
private _scopedModels: Array<{ model: Model<any>; thinkingLevel?: ThinkingLevel }>;369
// Event subscription state370
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;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[] = [];386
// Compaction state387
private _compactionAbortController: AbortController | undefined = undefined;388
private _autoCompactionAbortController: AbortController | undefined = undefined;389
private _overflowRecoveryAttempted = false;391
// Branch summarization state392
private _branchSummaryAbortController: AbortController | undefined = undefined;394
// Retry state395
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. The399
* retry is routed with it as `failed`, since the context no longer contains it.400
*/401
private _failedResponse: AssistantMessage | undefined;403
// Bash execution state404
private readonly _bashAbortControllers = new Set<AbortController>();405
private _pendingBashMessages: BashExecutionMessage[] = [];407
// Extension system408
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>> = [];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 MCP428
* servers that are still connecting. They are activated when they are registered, and dropped when429
* `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;445
private _modelRuntime: ModelRuntime;446
private _cacheWarmer?: Pick<CacheWarmer, "cancel" | "status" | "onAgentSettled" | "onModeChanged" | "onWarmed">;448
// Tool registry for extension getTools/setTools449
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();458
private _baseSystemPromptOptions!: NormalizedBuildSystemPromptOptions;459
/** Prompt options after before_agent_start mutations for the active run. */460
private _runSystemPromptOptions?: NormalizedBuildSystemPromptOptions;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" };483
// Always subscribe to agent events for internal handling484
// (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();493
this._buildRuntime({494
activeToolNames: this._initialActiveToolNames,495
includeAllExtensionTools: true,496
});497
if (this._initialActiveToolNames === undefined) this._restoreToolsFromTranscript();498
}500
get modelRuntime(): ModelRuntime {501
return this._modelRuntime;502
}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
}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
}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
}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
}583
/**584
* The model whose limits apply to `message`, or undefined when the message came from another585
* 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
}593
/**594
* Record the selection on the current branch when the branch implies another one, so a resume595
* restores it. Tree navigation can leave the latest `model_change` on another branch; responses596
* cannot record a virtual selection because they name physical models. Responses do record a597
* physical selection unless the branch holds a virtual one; checking a physical selection against598
* 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
}611
/** The model whose limits apply to the conversation. */612
private _limitsModel(): Model<any> | undefined {613
return this.routedModel?.model ?? this.model;614
}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 the620
* new runner without reinstalling hooks. Extension-specific tool wrappers are still used to adapt621
* registered tool execution to the extension context. Tool call and tool result interception now622
* 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
}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
}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
}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;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
});684
if (!hookResult && normalizedContent === content) {685
return undefined;686
}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
}698
/**699
* Run a call that the tool call `parentToolCallId` made through `ctx.executeTool()`. It goes700
* 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
}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
}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
}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 };788
// The selection stays in agent state; only this request uses the routed model. A routing789
// failure rejects, which ends the run with an error response. Only messages the user wrote790
// 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 change809
// 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
}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
}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
}870
private _installAgentNextTurnRefresh(): void {871
const previousPrepareNextTurnWithContext =872
this.agent.prepareNextTurnWithContext ??873
(this.agent.prepareNextTurn874
? 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;891
return {892
...previousSnapshot,893
context: {894
...nextContext,895
tools: this.agent.state.tools.slice(),896
},897
messages: updateMessage898
? [...(previousSnapshot?.messages ?? []), updateMessage]899
: previousSnapshot?.messages,900
model: this.agent.state.model,901
thinkingLevel: this.agent.state.thinkingLevel,902
};903
};904
}906
// =========================================================================907
// Event Subscription908
// =========================================================================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
}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
}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
}967
private _getPendingBoundaryMessages(): AgentMessage[] {968
return [...this.agent.peekQueuedMessages(), ...this._pendingCustomMessages];969
}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
}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
}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
}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
}1017
private _emitQueueUpdate(): void {1018
this._emit({1019
type: "queue_update",1020
steering: [...this._steeringMessages],1021
followUp: [...this._followUpMessages],1022
});1023
}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
}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
}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
}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
}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
}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 emitting1089
// This ensures the UI sees the updated queue state1090
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 first1095
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 queue1101
const followUpIndex = this._followUpMessages.indexOf(messageText);1102
if (followUpIndex !== -1) {1103
this._followUpMessages.splice(followUpIndex, 1);1104
this._emitQueueUpdate();1105
}1106
}1107
}1108
}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);1114
// Handle session persistence1115
if (event.type === "message_end") {1116
let entryId: string | undefined;1117
// Check if this is a custom message from extensions1118
if (event.message.role === "custom") {1119
// Persist as CustomMessageEntry1120
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 SessionMessageEntry1133
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 elsewhere1138
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
}1145
// Reset retry counter immediately on successful assistant response1146
// This prevents accumulation across multiple LLM calls within a turn1147
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
}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 be1160
// inserted without landing between a tool call and its result. Flushing after the1161
// extension and listener dispatch above also picks up messages that turn_end1162
// handlers queued.1163
if (event.type === "turn_end") {1164
this._lastAssistantToolResults = event.toolResults;1165
this._flushPendingCustomMessages();1166
}1167
};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
}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
}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
}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
}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
}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
}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
}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
}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 == null1301
? ({ ...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
}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);1342
// Return unsubscribe function for this specific listener1343
return () => {1344
const index = this._eventListeners.indexOf(listener);1345
if (index !== -1) {1346
this._eventListeners.splice(index, 1);1347
}1348
};1349
}1351
/** Disconnect from agent events during disposal. */1352
private _disconnectFromAgent(): void {1353
if (this._unsubscribeAgent) {1354
this._unsubscribeAgent();1355
this._unsubscribeAgent = undefined;1356
}1357
}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
}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
}1386
// =========================================================================1387
// Read-only State Access1388
// =========================================================================1390
/** Refresh the public finalized transcript from the canonical session projection. */1391
refreshContext(): void {1392
this._refreshFinalizedContext();1393
}1395
/** Full agent state */1396
get state(): AgentState {1397
return this.agent.state;1398
}1400
/** Current cache-warming state and the policy inputs that produced it. */1401
get cacheWarmingStatus(): CacheWarmingStatus | undefined {1402
return this._cacheWarmer?.status;1403
}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
}1411
/** Current model (may be undefined if not yet selected) */1412
get model(): Model<any> | undefined {1413
return this.agent.state.model;1414
}1416
/** Current thinking level */1417
get thinkingLevel(): ThinkingLevel {1418
return this.agent.state.thinkingLevel;1419
}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
}1429
/** Whether the session is currently processing an agent run or post-run continuation. */1430
get isStreaming(): boolean {1431
return this._isAgentRunActive;1432
}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
}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
}1444
/** Current retry attempt (0 if not retrying) */1445
get retryAttempt(): number {1446
return this._retryAttempt;1447
}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
}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
}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
}1478
getToolDefinition(name: string): ToolDefinition | undefined {1479
return this._toolDefinitions.get(name)?.definition;1480
}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
}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
}1503
private _isAllowedTool(name: string): boolean {1504
return (!this._allowedToolNames || this._allowedToolNames.has(name)) && !this._excludedToolNames?.has(name);1505
}1507
private _getToolExposure(name: string): ToolExposure {1508
return this._toolDefinitions.get(name)?.definition.exposure ?? "direct";1509
}1511
/**1512
* Tools callable through `ctx.executeTool()`: the active `direct` tools and every registered1513
* `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
}1522
/**1523
* Set the agent's tools for the given active tool names and return them. The active tools are1524
* the registered, non-hidden ones; they are declared to the model. Active tools with a1525
* `prepareLoadout` hook can change the declared descriptions and hide declarations from1526
* 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
}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 !== undefined1580
);1581
}1583
/** All messages including custom types like BashExecutionMessage */1584
get messages(): AgentMessage[] {1585
return this.agent.state.messages;1586
}1588
/** Current steering mode */1589
get steeringMode(): "all" | "one-at-a-time" {1590
return this.agent.steeringMode;1591
}1593
/** Current follow-up mode */1594
get followUpMode(): "all" | "one-at-a-time" {1595
return this.agent.followUpMode;1596
}1598
/** Current session file path, or undefined if sessions are disabled */1599
get sessionFile(): string | undefined {1600
return this.sessionManager.getSessionFile();1601
}1603
/** Current session ID */1604
get sessionId(): string {1605
return this.sessionManager.getSessionId();1606
}1608
/** Current session display name, if set */1609
get sessionName(): string | undefined {1610
return this.sessionManager.getSessionName();1611
}1613
/** Scoped models for cycling (from --models flag) */1614
get scopedModels(): ReadonlyArray<{ model: Model<any>; thinkingLevel?: ThinkingLevel }> {1615
return this._scopedModels;1616
}1618
/** Update scoped models for cycling */1619
setScopedModels(scopedModels: Array<{ model: Model<any>; thinkingLevel?: ThinkingLevel }>): void {1620
this._scopedModels = scopedModels;1621
}1623
/** File-based prompt templates */1624
get promptTemplates(): ReadonlyArray<PromptTemplate> {1625
return this._resourceLoader.getPrompts().prompts;1626
}1628
private _normalizePromptSnippet(text: string | undefined): string | undefined {1629
if (!text) return undefined;1630
const oneLine = text1631
.replace(/[\r\n]+/g, " ")1632
.replace(/\s+/g, " ")1633
.trim();1634
return oneLine.length > 0 ? oneLine : undefined;1635
}1637
private _normalizePromptGuidelines(guidelines: string[] | undefined): string[] {1638
if (!guidelines || guidelines.length === 0) {1639
return [];1640
}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
}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
}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;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
}1679
/**1680
* Apply a prompt and tool loadout for the next request. Sets the executable tools and1681
* returns a system message patching the prompt sections the model currently has (replayed1682
* from `messages`), or undefined when the prompt is unchanged. Tool changes are declared by1683
* the agent loop before the request.1684
*1685
* A forced prompt does not affect the transcript: the structured sections are still diffed1686
* and persisted, and the forced text is projected onto the request by1687
* {@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
}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 the1709
* head of the request; a mid-conversation system message would leave the original prompt1710
* in place. The forced text is a rendering of the current prompt, so the transcript keeps1711
* its structured sections and the request is projected instead: the system messages1712
* collapse into one head holding the forced text and the current tools. Runs after the1713
* `context` extension handlers.1714
*/1715
/**1716
* Remove the declarations that `prepareLoadout` hooks hide from every request. The whole1717
* transcript is filtered with the current set, so the projected declarations stay consistent1718
* 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
}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
}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 active1760
* 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
}1771
// =========================================================================1772
// Prompting1773
// =========================================================================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 now1781
// 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
}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();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
}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
}1837
if (await this._checkCompaction(message, true, toolResults)) {1838
return !this._agentRunAbortRequested;1839
}1841
// The low-level loop drains both queues before agent_end. Messages queued by1842
// agent_end handlers require a fresh run before pre-settlement handlers fire.1843
return !this._agentRunAbortRequested && this.agent.hasQueuedMessages();1844
}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
}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
}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
}1890
private async _normalizePromptImages(1891
images: ImageContent[] | undefined,1892
): Promise<{ images: ImageContent[]; hints: string[] }> {1893
if (!images) return { images: [], hints: [] };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
}1912
/**1913
* Send a prompt to the agent.1914
* - Handles extension commands (registered via pi.registerCommand) immediately, even during streaming1915
* - Expands file-based prompt templates by default1916
* - During streaming, queues via steer() or followUp() based on streamingBehavior option1917
* - Validates model and API key before sending (when not streaming)1918
* @throws Error if streaming and no streamingBehavior specified1919
* @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 send1934
preflightResult?.("handled");1935
return;1936
}1937
}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
}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;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
}1965
// If streaming, queue via steer() or followUp() based on option1966
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
}1981
// Flush any pending bash and custom messages before the new prompt1982
this._flushPendingBashMessages();1983
this._flushPendingCustomMessages();1985
// Validate model1986
if (!this.model) {1987
throw new Error(formatNoModelSelectedMessage());1988
}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
}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
}2012
// Emit before_agent_start before normalizing images so extension-driven model2013
// 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 live2022
// 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();2028
const normalized = await this._normalizePromptImages(currentImages);2029
const userText = normalized.hints.length > 0 ? `${expandedText}\n\n${normalized.hints.join("\n")}` : expandedText;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
});2041
// Inject any pending "nextTurn" messages as context alongside the user message2042
for (const msg of this._pendingNextTurnMessages) {2043
messages.push(msg);2044
}2045
this._pendingNextTurnMessages = [];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);2062
preflightResult?.("started");2063
await this._runAgentPrompt(messages);2064
}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 args2071
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);2075
const command = this._extensionRunner.getCommand(commandName);2076
if (!command) return false;2078
// Get command context from extension runner (includes session control methods)2079
const ctx = this._extensionRunner.createCommandContext();2081
try {2082
await command.handler(args, ctx);2083
return true;2084
} catch (err) {2085
// Emit error via extension runner2086
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
}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;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();2107
const skill = this.resourceLoader.getSkills().skills.find((s) => s.name === skillName);2108
if (!skill) return text; // Unknown skill, pass through2110
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 do2117
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 error2123
}2124
}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
}2136
const processedInput = await this._runInputHandlers(2137
text,2138
images,2139
source,2140
this.isStreaming ? behavior : undefined,2141
);2142
if (!processedInput) return "handled";2144
let expandedText = this._expandSkillCommand(processedInput.text);2145
expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]);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
}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 message2161
* @param options Input source; defaults to interactive2162
* @throws Error if text is an extension command2163
*/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
}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 message2177
* @param options Input source; defaults to interactive2178
* @throws Error if text is an extension command2179
*/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
}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
}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
}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);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
}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 queue2238
* - Streaming + triggerTurn false: appended to state/session once the current turn ends2239
* - Not streaming + triggerTurn: appends to state/session, starts new turn2240
* - Not streaming + no trigger: appends to state/session, no turn2241
*2242
* @param message Custom message with customType, content, display, details2243
* @param options.triggerTurn If true and not streaming, triggers a new LLM turn2244
* @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 its2275
// result, which providers that validate message order reject on replay. Defer2276
// to the end of the turn. Nothing is emitted yet: message events must not2277
// describe messages the session tree does not contain.2278
this._pendingCustomMessages.push(appMessage);2279
} else {2280
this._appendCustomMessage(appMessage);2281
}2282
}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
}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;2303
const pending = this._pendingCustomMessages;2304
this._pendingCustomMessages = [];2305
for (const appMessage of pending) {2306
this._appendCustomMessage(appMessage);2307
}2308
}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 images2323
let text: string;2324
let images: ImageContent[] | undefined;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
}2342
await this.prompt(text, {2343
expandPromptTemplates: options?.expandPromptTemplates ?? false,2344
streamingBehavior: options?.deliverAs,2345
images,2346
source: "extension",2347
});2348
}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 arrays2354
*/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
}2365
/** Number of pending messages (includes both steering and follow-up) */2366
get pendingMessageCount(): number {2367
return this._steeringMessages.length + this._followUpMessages.length;2368
}2370
/** Get pending steering messages (read-only) */2371
getSteeringMessages(): readonly string[] {2372
return this._steeringMessages;2373
}2375
/** Get pending follow-up messages (read-only) */2376
getFollowUpMessages(): readonly string[] {2377
return this._followUpMessages;2378
}2380
get resourceLoader(): ResourceLoader {2381
return this._resourceLoader;2382
}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
}2399
async waitForIdle(): Promise<void> {2400
if (this.isIdle) {2401
return;2402
}2403
await this._getIdleWaitPromise();2404
}2406
// =========================================================================2407
// Model Management2408
// =========================================================================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
}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 model2429
*/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
}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
}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);2449
await this._emitModelSelect(model, previousModel, "set");2450
}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;2456
this._scopedModels = [...this._scopedModels, { model }];2458
const enabledModels = this.settingsManager.getEnabledModels();2459
if (!enabledModels?.length) return;2461
const modelReference = `${model.provider}/${model.id}`;2462
if (enabledModels.some((pattern) => pattern.toLowerCase() === modelReference.toLowerCase())) return;2463
this.settingsManager.setEnabledModels([...enabledModels, modelReference]);2464
}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 available2471
*/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
}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;2494
const currentModel = this.model;2495
let currentIndex = scopedModels.findIndex((sm) => modelsAreEqual(sm.model, currentModel));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);2503
// Apply model2504
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
}2511
// Apply thinking level for the new model.2512
// - Explicit scoped model thinking level overrides defaults2513
// - Per-model thinking level overrides take priority over the global default2514
// setThinkingLevel clamps to model capabilities.2515
// Model persistence does not implicitly rewrite the global thinking default.2516
this.setThinkingLevel(thinkingLevel);2518
await this._emitModelSelect(next.model, currentModel, "cycle");2520
return { model: next.model, thinkingLevel: this.thinkingLevel, isScoped: true };2521
}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;2530
const currentModel = this.model;2531
let currentIndex = availableModels.findIndex((m) => modelsAreEqual(m, currentModel));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];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
}2546
// Apply thinking level for the new model.2547
// Model persistence does not implicitly rewrite the global thinking default.2548
this.setThinkingLevel(thinkingLevel);2550
await this._emitModelSelect(nextModel, currentModel, "cycle");2552
return { model: nextModel, thinkingLevel: this.thinkingLevel, isScoped: false };2553
}2555
// =========================================================================2556
// Thinking Level Management2557
// =========================================================================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);2569
// Only persist if actually changing2570
const previousLevel = this.agent.state.thinkingLevel;2571
const isChanging = effectiveLevel !== previousLevel;2573
this.agent.state.thinkingLevel = effectiveLevel;2575
if (options.persist) {2576
this.settingsManager.setDefaultThinkingLevel(level);2577
}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
}2590
/**2591
* Cycle to next thinking level.2592
* @returns New level, or undefined if model doesn't support thinking2593
*/2594
cycleThinkingLevel(options: ModelMutationOptions = {}): ThinkingLevel | undefined {2595
if (!this.supportsThinking()) return undefined;2597
const levels = this.getAvailableThinkingLevels();2598
const currentIndex = levels.indexOf(this.thinkingLevel);2599
const nextIndex = (currentIndex + 1) % levels.length;2600
const nextLevel = levels[nextIndex];2602
this.setThinkingLevel(nextLevel, options);2603
return nextLevel;2604
}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
}2615
/**2616
* Check if current model supports thinking/reasoning.2617
*/2618
supportsThinking(): boolean {2619
return !!this.model?.reasoning;2620
}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 one2627
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
}2636
private _clampThinkingLevel(level: ThinkingLevel, _availableLevels: ThinkingLevel[]): ThinkingLevel {2637
return this.model ? (clampThinkingLevel(this.model, level) as ThinkingLevel) : "off";2638
}2640
// =========================================================================2641
// Queue Mode Management2642
// =========================================================================2644
private syncQueueModesFromSettings(): void {2645
this.agent.steeringMode = this.settingsManager.getSteeringMode();2646
this.agent.followUpMode = this.settingsManager.getFollowUpMode();2647
}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
}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
}2667
// =========================================================================2668
// Compaction2669
// =========================================================================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, // sessionId2694
);2695
}2697
private _clearManualCompactionState(): void {2698
this._compactionAbortController = undefined;2699
this._resolveIdleWaitIfIdle();2700
}2702
/**2703
* Manually compact the session context.2704
*2705
* This is the manual entry point used by `/compact`, RPC, and extensions. It is2706
* separate from automatic threshold/overflow compaction, which enters through2707
* `_checkCompaction()` and `_runAutoCompaction()`. After preparation and the2708
* `session_before_compact` hook, both paths call the lower-level `compact()`2709
* function imported from `./compaction/index.ts`, unless the hook cancels or2710
* supplies a custom result.2711
*2712
* Aborts the current agent operation first. Manual compaction never retries or2713
* continues the interrupted agent turn.2714
*2715
* @param customInstructions Optional instructions for the compaction summary2716
*/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;2724
try {2725
const model = this.model;2726
if (!model) {2727
throw new Error(formatNoModelSelectedMessage());2728
}2730
const settings = this.settingsManager.getCompactionSettings(model);2731
const pathEntries = this.sessionManager.getBranch();2733
const preparation = prepareCompaction(pathEntries, settings);2734
if (!preparation) {2735
// Check why we can't compact2736
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
}2743
let extensionCompaction: CompactionResult | undefined;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;2756
if (result?.cancel) {2757
cancelledByExtension = true;2758
throw new Error("Compaction cancelled");2759
}2761
if (result?.compaction) {2762
extensionCompaction = result.compaction;2763
fromExtension = true;2764
}2765
}2767
let summary: string;2768
let firstKeptEntryId: string;2769
let tokensBefore: number;2770
let usage: Usage | undefined;2771
let details: unknown;2773
if (extensionCompaction) {2774
// Extension provided compaction content2775
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
}2796
if (this._compactionAbortController.signal.aborted) {2797
throw new Error("Compaction cancelled");2798
}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);2805
// Get the saved compaction entry for the extension event2806
const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary) as2807
| CompactionEntry2808
| undefined;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
}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
}2864
/**2865
* Cancel in-progress compaction (manual or auto).2866
*/2867
abortCompaction(): void {2868
this._compactionAbortController?.abort();2869
this._autoCompactionAbortController?.abort();2870
}2872
/**2873
* Cancel in-progress branch summarization.2874
*/2875
abortBranchSummary(): void {2876
this._branchSummaryAbortController?.abort();2877
}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 configured2887
* context window; compact but preserve the completed response.2888
* 3. Threshold without retry: valid or estimated context usage crossed the2889
* configured threshold; compact without retrying the completed response.2890
*2891
* Each case calls `_runAutoCompaction()`. After preparation and the2892
* `session_before_compact` hook, that method calls the lower-level `compact()`2893
* function imported from `./compaction/index.ts`, unless the hook cancels or2894
* supplies a custom result.2895
*2896
* @param assistantMessage The assistant message to check2897
* @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true2898
* @returns Whether the post-run loop should call `agent.continue()` for overflow recovery or queued messages2899
*/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;2908
// Skip if message was aborted (user cancelled) - unless skipAbortedCheck is false2909
if (skipAbortedCheck && assistantMessage.stopReason === "aborted") return false;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 model2914
// shouldn't trigger compaction for the new model. Under a virtual selection, the2915
// 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;2920
// Skip compaction checks if this assistant message is older than the latest2921
// compaction boundary. This prevents a stale pre-compaction usage/error2922
// 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
}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 = entriesAfterAssistant2947
.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";2966
// Case 2: the response completed successfully. Compact, but do not retry because2967
// agent.continue() cannot continue from a completed assistant response.2968
if (!willRetry) {2969
return await this._runAutoCompaction("overflow", false);2970
}2972
if (this._overflowRecoveryAttempted) {2973
const errorMessage = contextOverflow2974
? "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
}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
}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-usage3005
// 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 messages3019
// have stale usage reflecting the old (larger) context and would falsely3020
// 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
}3040
/**3041
* Execute threshold or overflow compaction. Manual compaction uses3042
* `AgentSession.compact()` instead. Both paths call the lower-level `compact()`3043
* function imported from `./compaction/index.ts` after preparation and extension3044
* interception.3045
*3046
* @param reason Automatic trigger selected by `_checkCompaction()`3047
* @param willRetry Whether to continue the interrupted turn after overflow compaction3048
* @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;3058
try {3059
if (!model) {3060
return false;3061
}3063
const pathEntries = this.sessionManager.getBranch();3064
const preparation = prepareCompaction(pathEntries, settings);3065
if (!preparation) {3066
return false;3067
}3069
abortController = new AbortController();3070
this._autoCompactionAbortController = abortController;3071
started = true;3072
this._emit({ type: "compaction_start", reason });3073
abortController.signal.throwIfAborted();3075
let extensionCompaction: CompactionResult | undefined;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;3088
if (extensionResult?.cancel) {3089
cancelledByExtension = true;3090
throw new Error("Compaction cancelled");3091
}3093
if (extensionResult?.compaction) {3094
extensionCompaction = extensionResult.compaction;3095
fromExtension = true;3096
}3097
}3098
abortController.signal.throwIfAborted();3100
let summary: string;3101
let firstKeptEntryId: string;3102
let tokensBefore: number;3103
let usage: Usage | undefined;3104
let details: unknown;3106
if (extensionCompaction) {3107
// Extension provided compaction content3108
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();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);3135
// Get the saved compaction entry for the extension event3136
const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary) as3137
| CompactionEntry3138
| undefined;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
}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 });3160
if (willRetry) return true;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 = aborted3170
? undefined3171
: 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
}3199
/**3200
* Toggle auto-compaction setting.3201
*/3202
setAutoCompactionEnabled(enabled: boolean): void {3203
this.settingsManager.setCompactionEnabled(enabled);3204
}3206
/** Whether auto-compaction is enabled */3207
get autoCompactionEnabled(): boolean {3208
return this.settingsManager.getCompactionEnabled();3209
}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
}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
}3237
private async extendResourcesFromExtensions(reason: "startup" | "reload"): Promise<void> {3238
if (!this._extensionRunner.hasHandlers("resources_discover")) {3239
return;3240
}3242
const { skillPaths, promptPaths, themePaths } = await this._extensionRunner.emitResourcesDiscover(3243
this._cwd,3244
reason,3245
);3247
if (skillPaths.length === 0 && promptPaths.length === 0 && themePaths.length === 0) {3248
return;3249
}3251
const extensionPaths: ResourceExtensionPaths = {3252
skillPaths: this.buildExtensionResourcePaths(skillPaths),3253
promptPaths: this.buildExtensionResourcePaths(promptPaths),3254
themePaths: this.buildExtensionResourcePaths(themePaths),3255
};3257
this._resourceLoader.extendResources(extensionPaths);3258
this._rebuildSystemPrompt(this.getActiveToolNames());3259
}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
}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
}3289
private _applyExtensionBindings(runner: ExtensionRunner): void {3290
runner.setUIContext(this._extensionUIContext, this._extensionMode);3291
runner.bindCommandContext(this._extensionCommandContextActions);3293
this._extensionErrorUnsubscriber?.();3294
this._extensionErrorUnsubscriber = this._extensionErrorListener3295
? runner.onError(this._extensionErrorListener)3296
: undefined;3297
}3299
private _refreshCurrentModelFromRegistry(): void {3300
const currentModel = this.model;3301
if (!currentModel) {3302
return;3303
}3305
const refreshedModel = this._modelRuntime.getModel(currentModel.provider, currentModel.id);3306
if (!refreshedModel || refreshedModel === currentModel) {3307
return;3308
}3310
this.agent.state.model = refreshedModel;3311
}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
}));3322
const templates: SlashCommandInfo[] = this.promptTemplates.map((template) => ({3323
name: template.name,3324
description: template.description,3325
source: "prompt",3326
sourceInfo: template.sourceInfo,3327
}));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
}));3336
return [...extensionCommands, ...templates, ...skills];3337
};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
}3448
private _refreshToolRegistry(options?: { activeToolNames?: string[]; includeAllExtensionTools?: boolean }): void {3449
// Tools that were already activated on registration. A tool whose exposure changes to3450
// `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;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
);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;3519
const nextActiveToolNames = (3520
options?.activeToolNames ? [...options.activeToolNames] : [...previousActiveToolNames]3521
).filter((name) => this._isAllowedTool(name));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);3544
this._setActiveTools([...new Set(nextActiveToolNames)]);3545
}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
}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
}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._baseToolsOverride3567
? 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
});3578
this._baseToolDefinitions = new Map(3579
Object.entries(baseToolDefinitions).map(([name, tool]) => [name, tool as ToolDefinition]),3580
);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
}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);3602
const defaultActiveToolNames = this._baseToolsOverride3603
? 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
}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 disabled3625
// during the session stay disabled unless the setting newly adds them.3626
const addedDefaultTools = this._usesDefaultTools3627
? (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
});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
}3652
// =========================================================================3653
// Auto-Retry3654
// =========================================================================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
}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 transient3669
* stream drop no longer fails the whole operation. `source` carries the context3670
* 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
}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
}3709
/**3710
* Prepare a retryable error for continuation with exponential backoff.3711
* @returns true if the caller should continue the agent, false otherwise3712
*/3713
private async _prepareRetry(message: AssistantMessage): Promise<boolean> {3714
const settings = this.settingsManager.getRetrySettings();3715
if (!settings.enabled) {3716
return false;3717
}3719
this._retryAttempt++;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
}3727
const delayMs = retryDelayMs(settings, this._retryAttempt);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
});3737
// Keep the failed attempt in raw history while durably omitting it from model projection.3738
this._omitRecoveryAttempt(message);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 up3746
this._finishCancelledRetry();3747
return false;3748
} finally {3749
this._retryAbortController = undefined;3750
}3752
return true;3753
}3755
/**3756
* Cancel in-progress retry.3757
*/3758
abortRetry(): void {3759
this._retryAbortController?.abort();3760
}3762
/** Whether auto-retry is currently in progress */3763
get isRetrying(): boolean {3764
return this._retryAbortController !== undefined;3765
}3767
/** Whether auto-retry is enabled */3768
get autoRetryEnabled(): boolean {3769
return this.settingsManager.getRetryEnabled();3770
}3772
/**3773
* Toggle auto-retry setting.3774
*/3775
setAutoRetryEnabled(enabled: boolean): void {3776
this.settingsManager.setRetryEnabled(enabled);3777
}3779
// =========================================================================3780
// Bash Execution3781
// =========================================================================3783
/**3784
* Execute a bash command.3785
* Adds result to agent context and session.3786
* @param command The bash command to execute3787
* @param onChunk Optional streaming callback for output3788
* @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 events3790
* @param options.operations Custom BashOperations for remote execution3791
*/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);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;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
);3819
this.recordBashResult(command, result, options);3820
return result;3821
} finally {3822
this._bashAbortControllers.delete(abortController);3823
}3824
}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
};3843
// If agent is streaming, defer adding to avoid breaking tool_use/tool_result ordering3844
if (this.isStreaming) {3845
// Queue for later - will be flushed on agent_end3846
this._pendingBashMessages.push(bashMessage);3847
} else {3848
this.sessionManager.appendMessage(bashMessage);3849
this._refreshFinalizedContext();3850
}3851
}3853
/**3854
* Cancel running bash command.3855
*/3856
abortBash(): void {3857
for (const abortController of [...this._bashAbortControllers]) {3858
abortController.abort();3859
}3860
}3862
/** Whether a bash command is currently running */3863
get isBashRunning(): boolean {3864
return this._bashAbortControllers.size > 0;3865
}3867
/** Whether there are pending bash messages waiting to be flushed */3868
get hasPendingBashMessages(): boolean {3869
return this._pendingBashMessages.length > 0;3870
}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;3879
for (const bashMessage of this._pendingBashMessages) {3880
this.sessionManager.appendMessage(bashMessage);3881
}3882
this._pendingBashMessages = [];3883
this._refreshFinalizedContext();3884
}3886
// =========================================================================3887
// Session Management3888
// =========================================================================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
}3900
// =========================================================================3901
// Tree Navigation3902
// =========================================================================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 to3909
* @param options.summarize Whether user wants to summarize abandoned branch3910
* @param options.customInstructions Custom instructions for summarizer3911
* @param options.replaceInstructions If true, customInstructions replaces the default prompt3912
* @param options.label Label to attach to the branch summary entry3913
* @returns Result with editorText (if user message) and cancelled status3914
*/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
}3928
const oldLeafId = this.sessionManager.getLeafId();3930
// No-op if already at target3931
if (targetId === oldLeafId) {3932
return { cancelled: false };3933
}3935
// Model required for summarization3936
if (options.summarize && !this.model) {3937
throw new Error("No model available for summarization");3938
}3940
const targetEntry = this.sessionManager.getEntry(targetId);3941
if (!targetEntry) {3942
throw new Error(`Entry ${targetId} not found`);3943
}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
);3952
// Prepare event data - mutable so extensions can override3953
let customInstructions = options.customInstructions;3954
let replaceInstructions = options.replaceInstructions;3955
let label = options.label;3957
const preparation: TreePreparation = {3958
targetId,3959
oldLeafId,3960
commonAncestorId,3961
entriesToSummarize,3962
userWantsSummary: options.summarize ?? false,3963
customInstructions,3964
replaceInstructions,3965
label,3966
};3968
// Set up abort controller for summarization3969
this._branchSummaryAbortController = new AbortController();3971
try {3972
let extensionSummary: { summary: string; details?: unknown; usage?: Usage } | undefined;3973
let fromExtension = false;3975
// Emit session_before_tree event3976
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;3983
if (result?.cancel) {3984
return { cancelled: true };3985
}3987
if (result?.summary && options.summarize) {3988
extensionSummary = result.summary;3989
fromExtension = true;3990
}3992
// Allow extensions to override instructions and label3993
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
}4004
// Run default summarizer if needed4005
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
}4039
// Determine the new leaf position based on target type4040
let newLeafId: string | null;4041
let editorText: string | undefined;4043
if (targetEntry.type === "message" && targetEntry.message.role === "user") {4044
// User message: leaf = parent (null if root), text goes to editor4045
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 editor4049
newLeafId = targetEntry.parentId;4050
editorText = contentText(targetEntry.content, "");4051
} else {4052
// Non-user message: leaf = selected node4053
newLeafId = targetId;4054
}4056
// Switch leaf (with or without summary)4057
// Summary is attached at the navigation target position (newLeafId), not the old branch4058
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;4070
// Attach label to the summary entry4071
if (label) {4072
this.sessionManager.appendLabelChange(summaryId, label);4073
}4074
} else if (newLeafId === null) {4075
// No summary, navigating to root - reset leaf4076
this.sessionManager.resetLeaf();4077
} else {4078
// No summary, navigating to non-root4079
this.sessionManager.branch(newLeafId);4080
}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
}4087
// Update finalized context from the canonical session projection.4088
this._refreshFinalizedContext();4089
this._restoreToolsFromTranscript();4091
// Emit session_tree event4092
await this._extensionRunner.emit({4093
type: "session_tree",4094
newLeafId: this.sessionManager.getLeafId(),4095
oldLeafId,4096
summaryEntry,4097
fromExtension: summaryText ? fromExtension : undefined,4098
});4100
// Emit to custom tools4102
return { editorText, cancelled: false, summaryEntry };4103
} finally {4104
this._branchSummaryAbortController = undefined;4105
this._resolveIdleWaitIfIdle();4106
}4107
}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 }> = [];4116
for (const entry of entries) {4117
if (entry.type !== "message") continue;4118
if (entry.message.role !== "user") continue;4120
const text = contentText(entry.message.content, "");4121
if (text) {4122
result.push({ entryId: entry.id, text });4123
}4124
}4126
return result;4127
}4129
/**4130
* Get session statistics. Aggregates over ALL session entries (including4131
* history that was compacted away), so token/cost totals reflect what was4132
* 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();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
}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
}4188
getContextUsage(): ContextUsage | undefined {4189
const model = this._limitsModel();4190
if (!model) return undefined;4192
const contextWindow = model.contextWindow ?? 0;4193
if (contextWindow <= 0) return undefined;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);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 = branch4218
.slice(compactionIndex + 1)4219
.some((entry) => projectedAssistants.has(entry.id));4220
if (!hasPostCompactionUsage) return { tokens: null, contextWindow, percent: null };4221
}4223
const estimate = estimateProjectedContextTokens(projection, branch);4224
const percent = (estimate.tokens / contextWindow) * 100;4226
return {4227
tokens: estimate.tokens,4228
contextWindow,4229
percent,4230
};4231
}4233
/**4234
* Export session to HTML.4235
* @param outputPath Optional output path (defaults to session directory)4236
* @param options Optional export presentation settings4237
* @returns Path to exported file4238
*/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
);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
});4251
return await exportSessionToHtml(this.sessionManager, this.state, {4252
outputPath,4253
themeName,4254
toolRenderer,4255
});4256
}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
}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
}4288
// =========================================================================4289
// Utilities4290
// =========================================================================4292
/**4293
* Get text content of last assistant message.4294
* Useful for /copy command.4295
* @returns Text content, or undefined if no assistant message exists4296
*/4297
getLastAssistantText(): string | undefined {4298
const lastAssistant = this.messages4299
.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 content4305
if (msg.stopReason === "aborted" && msg.content.length === 0) return false;4306
return true;4307
});4309
if (!lastAssistant) return undefined;4311
let text = "";4312
for (const content of (lastAssistant as AssistantMessage).content) {4313
if (content.type === "text") {4314
text += content.text;4315
}4316
}4318
return text.trim() || undefined;4319
}4321
// =========================================================================4322
// Extension System4323
// =========================================================================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
}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
}4342
/**4343
* Get the extension runner (for setting UI context and error handlers).4344
*/4345
get extensionRunner(): ExtensionRunner {4346
return this._extensionRunner;4347
}4348
}