ππ Agent

agent-session.ts

109 KB3328 lines
agent-session.ts
1/**
2 * AgentSession - Core abstraction for agent lifecycle and session management.
3 *
4 * This class is shared between all run modes (interactive, print, rpc).
5 * It encapsulates:
6 * - Agent state access
7 * - Event subscription with automatic session persistence
8 * - Model and thinking level management
9 * - Compaction (manual and auto)
10 * - Bash execution
11 * - Session switching and branching
12 *
13 * Modes use this class and add their own I/O layer on top.
14 */
15
16import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
17import { basename, dirname } from "node:path";
18import type {
19 Agent,
20 AgentEvent,
21 AgentMessage,
22 AgentState,
23 AgentTool,
24 PrepareNextTurnContext,
25 ThinkingLevel,
26} from "@earendil-works/pi-agent-core";
27import { contentText } from "@earendil-works/pi-ai";
28import type {
29 AssistantMessage,
30 AuthResult,
31 ImageContent,
32 Model,
33 ProviderHeaders,
34 TextContent,
35 Usage,
36} from "@earendil-works/pi-ai/compat";
37import {
38 clampThinkingLevel,
39 cleanupSessionResources,
40 getSupportedThinkingLevels,
41 isContextOverflow,
42 isRetryableAssistantError,
43 modelsAreEqual,
44 type RetryCallbacks,
45 resetApiProviders,
46 streamSimple,
47} from "@earendil-works/pi-ai/compat";
48import { getThemeByName, theme } from "../modes/interactive/theme/theme.ts";
49import { stripFrontmatter } from "../utils/frontmatter.ts";
50import { resolvePath } from "../utils/paths.ts";
51import { sleep } from "../utils/sleep.ts";
52import { formatNoApiKeyFoundMessage, formatNoModelSelectedMessage } from "./auth-guidance.ts";
53import { type BashResult, executeBashWithOperations } from "./bash-executor.ts";
54import {
55 type CompactionResult,
56 calculateContextTokens,
57 collectEntriesForBranchSummary,
58 compact,
59 estimateContextTokens,
60 estimateTokens,
61 generateBranchSummary,
62 prepareCompaction,
63 shouldCompact,
64} from "./compaction/index.ts";
65import { DEFAULT_THINKING_LEVEL } from "./defaults.ts";
66import { exportSessionToHtml, type ToolHtmlRenderer } from "./export-html/index.ts";
67import { createToolHtmlRenderer } from "./export-html/tool-renderer.ts";
68import {
69 type ContextUsage,
70 type ExtensionCommandContextActions,
71 type ExtensionErrorListener,
72 type ExtensionMode,
73 ExtensionRunner,
74 type ExtensionUIContext,
75 type InputSource,
76 type MessageEndEvent,
77 type MessageStartEvent,
78 type MessageUpdateEvent,
79 type ReplacedSessionContext,
80 type SessionBeforeCompactResult,
81 type SessionBeforeTreeResult,
82 type SessionStartEvent,
83 type ShutdownHandler,
84 type ToolDefinition,
85 type ToolExecutionEndEvent,
86 type ToolExecutionStartEvent,
87 type ToolExecutionUpdateEvent,
88 type ToolInfo,
89 type TreePreparation,
90 type TurnEndEvent,
91 type TurnStartEvent,
92 wrapRegisteredTools,
93} from "./extensions/index.ts";
94import { emitSessionShutdownEvent } from "./extensions/runner.ts";
95import type { BashExecutionMessage, CustomMessage } from "./messages.ts";
96import { ModelRegistry } from "./model-registry.ts";
97import type { ModelRuntime } from "./model-runtime.ts";
98import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.ts";
99import type { ResourceExtensionPaths, ResourceLoader } from "./resource-loader.ts";
100import type { BranchSummaryEntry, CompactionEntry, SessionEntry, SessionManager } from "./session-manager.ts";
101import { CURRENT_SESSION_VERSION, getLatestCompactionEntry, type SessionHeader } from "./session-manager.ts";
102import type { SettingsManager } from "./settings-manager.ts";
103import type { SlashCommandInfo } from "./slash-commands.ts";
104import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts";
105import { type BuildSystemPromptOptions, buildSystemPrompt } from "./system-prompt.ts";
106import { type BashOperations, createLocalBashOperations } from "./tools/bash.ts";
107import { createAllToolDefinitions } from "./tools/index.ts";
108import { createToolDefinitionFromAgentTool } from "./tools/tool-definition-wrapper.ts";
109import { addUsageToTotals, createUsageTotals } from "./usage-totals.ts";
110
111// ============================================================================
112// Skill Block Parsing
113// ============================================================================
114
115/** Parsed skill block from a user message */
116export interface ParsedSkillBlock {
117 name: string;
118 location: string;
119 content: string;
120 userMessage: string | undefined;
121}
122
123/**
124 * Parse a skill block from message text.
125 * Returns null if the text doesn't contain a skill block.
126 */
127export function parseSkillBlock(text: string): ParsedSkillBlock | null {
128 const match = text.match(/^<skill name="([^"]+)" location="([^"]+)">\n([\s\S]*?)\n<\/skill>(?:\n\n([\s\S]+))?$/);
129 if (!match) return null;
130 return {
131 name: match[1],
132 location: match[2],
133 content: match[3],
134 userMessage: match[4]?.trim() || undefined,
135 };
136}
137
138/** Session-specific events that extend the core AgentEvent */
139export type AgentSessionEvent =
140 | Exclude<AgentEvent, { type: "agent_end" }>
141 | {
142 type: "agent_end";
143 messages: AgentMessage[];
144 willRetry: boolean;
145 }
146 | { type: "agent_settled" }
147 | {
148 type: "queue_update";
149 steering: readonly string[];
150 followUp: readonly string[];
151 }
152 | { type: "compaction_start"; reason: "manual" | "threshold" | "overflow" }
153 | { type: "entry_appended"; entry: SessionEntry }
154 | { type: "session_info_changed"; name: string | undefined }
155 | { type: "thinking_level_changed"; level: ThinkingLevel }
156 | {
157 type: "compaction_end";
158 reason: "manual" | "threshold" | "overflow";
159 result: CompactionResult | undefined;
160 aborted: boolean;
161 willRetry: boolean;
162 errorMessage?: string;
163 }
164 | { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string }
165 | { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }
166 | {
167 type: "summarization_retry_scheduled";
168 attempt: number;
169 maxAttempts: number;
170 delayMs: number;
171 errorMessage: string;
172 }
173 | { type: "summarization_retry_attempt_start"; source: "branchSummary" }
174 | {
175 type: "summarization_retry_attempt_start";
176 source: "compaction";
177 reason: "manual" | "threshold" | "overflow";
178 }
179 | { type: "summarization_retry_finished" }
180 | { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }
181 | { type: "bash_execution_update"; id?: string; delta: string };
182
183/** Listener function for agent session events */
184export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
185
186// ============================================================================
187// Types
188// ============================================================================
189
190function withoutDeletedHeaders(headers: ProviderHeaders | undefined): Record<string, string> | undefined {
191 return headers
192 ? Object.fromEntries(Object.entries(headers).filter((entry): entry is [string, string] => entry[1] !== null))
193 : undefined;
194}
195
196export interface AgentSessionConfig {
197 agent: Agent;
198 sessionManager: SessionManager;
199 settingsManager: SettingsManager;
200 cwd: string;
201 /** Models to cycle through with Ctrl+P (from --models flag) */
202 scopedModels?: Array<{ model: Model<any>; thinkingLevel?: ThinkingLevel }>;
203 /** Resource loader for extensions, skills, prompts, themes, context files, and system prompt */
204 resourceLoader: ResourceLoader;
205 /** SDK custom tools registered outside extensions */
206 customTools?: ToolDefinition[];
207 /** Canonical model/auth runtime used by coding-agent internals. */
208 modelRuntime: ModelRuntime;
209 /** Initial active built-in tool names. Default: [read, bash, edit, write] */
210 initialActiveToolNames?: string[];
211 /** Optional allowlist of tool names. When provided, only these tool names are exposed. */
212 allowedToolNames?: string[];
213 /** Optional denylist of tool names. When provided, these tool names are not exposed. */
214 excludedToolNames?: string[];
215 /**
216 * Override base tools (useful for custom runtimes).
217 *
218 * These are synthesized into minimal ToolDefinitions internally so AgentSession can keep
219 * a definition-first registry even when callers provide plain AgentTool instances.
220 */
221 baseToolsOverride?: Record<string, AgentTool>;
222 /** Mutable ref used by Agent to access the current ExtensionRunner */
223 extensionRunnerRef?: { current?: ExtensionRunner };
224 /** Session start event metadata emitted when extensions bind to this runtime. */
225 sessionStartEvent?: SessionStartEvent;
226}
227
228export interface ExtensionBindings {
229 uiContext?: ExtensionUIContext;
230 mode?: ExtensionMode;
231 commandContextActions?: ExtensionCommandContextActions;
232 abortHandler?: () => void;
233 shutdownHandler?: ShutdownHandler;
234 onError?: ExtensionErrorListener;
235}
236
237/** Options for AgentSession.prompt() */
238export interface PromptOptions {
239 /** Whether to expand file-based prompt templates (default: true) */
240 expandPromptTemplates?: boolean;
241 /** Image attachments */
242 images?: ImageContent[];
243 /** When streaming, how to queue the message: "steer" (interrupt) or "followUp" (wait). Required if streaming. */
244 streamingBehavior?: "steer" | "followUp";
245 /** Source of input for extension input event handlers. Defaults to "interactive". */
246 source?: InputSource;
247 /** Internal hook used by RPC mode to observe prompt preflight acceptance or rejection. */
248 preflightResult?: (success: boolean) => void;
249}
250
251/** Result from cycleModel() */
252export interface ModelCycleResult {
253 model: Model<any>;
254 thinkingLevel: ThinkingLevel;
255 /** Whether cycling through scoped models (--models flag) or all available */
256 isScoped: boolean;
257}
258
259/** Session statistics for /session command */
260export interface SessionStats {
261 sessionFile: string | undefined;
262 sessionId: string;
263 userMessages: number;
264 assistantMessages: number;
265 toolCalls: number;
266 toolResults: number;
267 totalMessages: number;
268 tokens: {
269 input: number;
270 output: number;
271 cacheRead: number;
272 cacheWrite: number;
273 total: number;
274 };
275 cost: number;
276 contextUsage?: ContextUsage;
277}
278
279interface ToolDefinitionEntry {
280 definition: ToolDefinition;
281 sourceInfo: SourceInfo;
282}
283
284function estimateMessagesTokens(messages: AgentMessage[]): number {
285 let tokens = 0;
286 for (const message of messages) {
287 tokens += estimateTokens(message);
288 }
289 return tokens;
290}
291
292// ============================================================================
293// Constants
294// ============================================================================
295
296/** Standard thinking levels */
297const THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high"];
298
299// ============================================================================
300// AgentSession Class
301// ============================================================================
302
303export class AgentSession {
304 readonly agent: Agent;
305 readonly sessionManager: SessionManager;
306 readonly settingsManager: SettingsManager;
307
308 private _scopedModels: Array<{ model: Model<any>; thinkingLevel?: ThinkingLevel }>;
309
310 // Event subscription state
311 private _unsubscribeAgent?: () => void;
312 private _eventListeners: AgentSessionEventListener[] = [];
313 private _isAgentRunActive = false;
314 private _idleWaitPromise: Promise<void> | undefined;
315 private _resolveIdleWait: (() => void) | undefined;
316
317 /** Tracks pending steering messages for UI display. Removed when delivered. */
318 private _steeringMessages: string[] = [];
319 /** Tracks pending follow-up messages for UI display. Removed when delivered. */
320 private _followUpMessages: string[] = [];
321 /** Messages queued to be included with the next user prompt as context ("asides"). */
322 private _pendingNextTurnMessages: CustomMessage[] = [];
323
324 // Compaction state
325 private _compactionAbortController: AbortController | undefined = undefined;
326 private _autoCompactionAbortController: AbortController | undefined = undefined;
327 private _overflowRecoveryAttempted = false;
328
329 // Branch summarization state
330 private _branchSummaryAbortController: AbortController | undefined = undefined;
331
332 // Retry state
333 private _retryAbortController: AbortController | undefined = undefined;
334 private _retryAttempt = 0;
335
336 // Bash execution state
337 private readonly _bashAbortControllers = new Set<AbortController>();
338 private _pendingBashMessages: BashExecutionMessage[] = [];
339
340 // Extension system
341 private _extensionRunner!: ExtensionRunner;
342 private _turnIndex = 0;
343
344 private _resourceLoader: ResourceLoader;
345 private _customTools: ToolDefinition[];
346 private _baseToolDefinitions: Map<string, ToolDefinition> = new Map();
347 private _cwd: string;
348 private _extensionRunnerRef?: { current?: ExtensionRunner };
349 private _initialActiveToolNames?: string[];
350 private _allowedToolNames?: Set<string>;
351 private _excludedToolNames?: Set<string>;
352 private _baseToolsOverride?: Record<string, AgentTool>;
353 private _sessionStartEvent: SessionStartEvent;
354 private _extensionUIContext?: ExtensionUIContext;
355 private _extensionMode: ExtensionMode = "print";
356 private _extensionCommandContextActions?: ExtensionCommandContextActions;
357 private _extensionAbortHandler?: () => void;
358 private _extensionShutdownHandler?: ShutdownHandler;
359 private _extensionErrorListener?: ExtensionErrorListener;
360 private _extensionErrorUnsubscriber?: () => void;
361
362 private _modelRuntime: ModelRuntime;
363
364 // Tool registry for extension getTools/setTools
365 private _toolRegistry: Map<string, AgentTool> = new Map();
366 private _toolDefinitions: Map<string, ToolDefinitionEntry> = new Map();
367 private _toolPromptSnippets: Map<string, string> = new Map();
368 private _toolPromptGuidelines: Map<string, string[]> = new Map();
369
370 // Base system prompt (without extension appends) - used to apply fresh appends each turn
371 private _baseSystemPrompt = "";
372 private _baseSystemPromptOptions!: BuildSystemPromptOptions;
373 private _systemPromptOverride?: string;
374
375 constructor(config: AgentSessionConfig) {
376 this.agent = config.agent;
377 this.sessionManager = config.sessionManager;
378 this.settingsManager = config.settingsManager;
379 this._scopedModels = config.scopedModels ?? [];
380 this._resourceLoader = config.resourceLoader;
381 this._customTools = config.customTools ?? [];
382 this._cwd = config.cwd;
383 this._modelRuntime = config.modelRuntime;
384 this._extensionRunnerRef = config.extensionRunnerRef;
385 this._initialActiveToolNames = config.initialActiveToolNames;
386 this._allowedToolNames = config.allowedToolNames ? new Set(config.allowedToolNames) : undefined;
387 this._excludedToolNames = config.excludedToolNames ? new Set(config.excludedToolNames) : undefined;
388 this._baseToolsOverride = config.baseToolsOverride;
389 this._sessionStartEvent = config.sessionStartEvent ?? { type: "session_start", reason: "startup" };
390
391 // Always subscribe to agent events for internal handling
392 // (session persistence, extensions, auto-compaction, retry logic)
393 this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);
394 this._installAgentToolHooks();
395 this._installAgentNextTurnRefresh();
396
397 this._buildRuntime({
398 activeToolNames: this._initialActiveToolNames,
399 includeAllExtensionTools: true,
400 });
401 }
402
403 get modelRuntime(): ModelRuntime {
404 return this._modelRuntime;
405 }
406
407 private async _getRequiredRequestAuth(model: Model<any>): Promise<{
408 apiKey?: string;
409 headers?: Record<string, string>;
410 env?: Record<string, string>;
411 }> {
412 let result: AuthResult | undefined;
413 try {
414 result = await this._modelRuntime.getAuth(model);
415 } catch (error) {
416 const cause = error instanceof Error ? error.cause : undefined;
417 if (cause instanceof Error && cause.message === "authHeader requires a resolved API key") {
418 throw new Error(formatNoApiKeyFoundMessage(model.provider));
419 }
420 throw error;
421 }
422 if (result && (result.auth.apiKey || result.auth.headers)) {
423 return {
424 apiKey: result.auth.apiKey,
425 headers: withoutDeletedHeaders(result.auth.headers),
426 env: result.env,
427 };
428 }
429
430 const isOAuth = this._modelRuntime.isUsingOAuth(model.provider);
431 if (isOAuth) {
432 throw new Error(
433 `Authentication failed for "${model.provider}". ` +
434 `Credentials may have expired or network is unavailable. ` +
435 `Run '/login ${model.provider}' to re-authenticate.`,
436 );
437 }
438 throw new Error(formatNoApiKeyFoundMessage(model.provider));
439 }
440
441 private async _getSummarizationRequestAuth(model: Model<any>): Promise<{
442 apiKey?: string;
443 headers?: Record<string, string>;
444 env?: Record<string, string>;
445 }> {
446 if (this.agent.streamFunction === streamSimple) {
447 return this._getRequiredRequestAuth(model);
448 }
449
450 try {
451 const result = await this._modelRuntime.getAuth(model);
452 return result
453 ? { apiKey: result.auth.apiKey, headers: withoutDeletedHeaders(result.auth.headers), env: result.env }
454 : {};
455 } catch {
456 return {};
457 }
458 }
459
460 /**
461 * Install tool hooks once on the Agent instance.
462 *
463 * The callbacks read `this._extensionRunner` at execution time, so extension reload swaps in the
464 * new runner without reinstalling hooks. Extension-specific tool wrappers are still used to adapt
465 * registered tool execution to the extension context. Tool call and tool result interception now
466 * happens here instead of in wrappers.
467 */
468 private _installAgentToolHooks(): void {
469 this.agent.beforeToolCall = async ({ toolCall, args }) => {
470 const runner = this._extensionRunner;
471 if (!runner.hasHandlers("tool_call")) {
472 return undefined;
473 }
474
475 try {
476 return await runner.emitToolCall({
477 type: "tool_call",
478 toolName: toolCall.name,
479 toolCallId: toolCall.id,
480 input: args as Record<string, unknown>,
481 });
482 } catch (err) {
483 if (err instanceof Error) {
484 throw err;
485 }
486 throw new Error(`Extension failed, blocking execution: ${String(err)}`);
487 }
488 };
489
490 this.agent.afterToolCall = async ({ toolCall, args, result, isError }) => {
491 const runner = this._extensionRunner;
492 if (!runner.hasHandlers("tool_result")) {
493 return undefined;
494 }
495
496 const hookResult = await runner.emitToolResult({
497 type: "tool_result",
498 toolName: toolCall.name,
499 toolCallId: toolCall.id,
500 input: args as Record<string, unknown>,
501 content: result.content,
502 details: result.details,
503 isError,
504 usage: result.usage,
505 });
506
507 if (!hookResult) {
508 return undefined;
509 }
510
511 return {
512 content: hookResult.content,
513 details: hookResult.details,
514 isError: hookResult.isError ?? isError,
515 usage: hookResult.usage,
516 };
517 };
518 }
519
520 private _installAgentNextTurnRefresh(): void {
521 const previousPrepareNextTurnWithContext =
522 this.agent.prepareNextTurnWithContext ??
523 (this.agent.prepareNextTurn
524 ? async (_turn: PrepareNextTurnContext, signal?: AbortSignal) => await this.agent.prepareNextTurn?.(signal)
525 : undefined);
526 this.agent.prepareNextTurnWithContext = async (turn, signal) => {
527 const previousSnapshot = await previousPrepareNextTurnWithContext?.(turn, signal);
528 const previousContext = previousSnapshot?.context ?? turn.context;
529
530 return {
531 ...previousSnapshot,
532 context: {
533 ...previousContext,
534 systemPrompt: this._systemPromptOverride ?? this._baseSystemPrompt,
535 tools: this.agent.state.tools.slice(),
536 },
537 model: this.agent.state.model,
538 thinkingLevel: this.agent.state.thinkingLevel,
539 };
540 };
541 }
542
543 // =========================================================================
544 // Event Subscription
545 // =========================================================================
546
547 /** Emit an event to all listeners */
548 private _emit(event: AgentSessionEvent): void {
549 for (const l of this._eventListeners) {
550 l(event);
551 }
552 }
553
554 private _emitQueueUpdate(): void {
555 this._emit({
556 type: "queue_update",
557 steering: [...this._steeringMessages],
558 followUp: [...this._followUpMessages],
559 });
560 }
561
562 private _getIdleWaitPromise(): Promise<void> {
563 if (!this._idleWaitPromise) {
564 this._idleWaitPromise = new Promise((resolve) => {
565 this._resolveIdleWait = resolve;
566 });
567 }
568 return this._idleWaitPromise;
569 }
570
571 private _resolveIdleWaitIfIdle(): void {
572 if (this._isAgentRunActive || !this._resolveIdleWait) {
573 return;
574 }
575 const resolve = this._resolveIdleWait;
576 this._idleWaitPromise = undefined;
577 this._resolveIdleWait = undefined;
578 resolve();
579 }
580
581 private async _emitAgentSettled(): Promise<void> {
582 this._isAgentRunActive = false;
583 try {
584 await this._extensionRunner.emit({ type: "agent_settled" });
585 this._emit({ type: "agent_settled" });
586 } finally {
587 this._resolveIdleWaitIfIdle();
588 }
589 }
590
591 // Track last assistant message for auto-compaction check
592 private _lastAssistantMessage: AssistantMessage | undefined = undefined;
593
594 /** Internal handler for agent events - shared by subscribe and reconnect */
595 private _handleAgentEvent = async (event: AgentEvent): Promise<void> => {
596 // When a user message starts, check if it's from either queue and remove it BEFORE emitting
597 // This ensures the UI sees the updated queue state
598 if (event.type === "message_start" && event.message.role === "user") {
599 this._overflowRecoveryAttempted = false;
600 const messageText = contentText(event.message.content, "");
601 if (messageText) {
602 // Check steering queue first
603 const steeringIndex = this._steeringMessages.indexOf(messageText);
604 if (steeringIndex !== -1) {
605 this._steeringMessages.splice(steeringIndex, 1);
606 this._emitQueueUpdate();
607 } else {
608 // Check follow-up queue
609 const followUpIndex = this._followUpMessages.indexOf(messageText);
610 if (followUpIndex !== -1) {
611 this._followUpMessages.splice(followUpIndex, 1);
612 this._emitQueueUpdate();
613 }
614 }
615 }
616 }
617
618 // Emit to extensions first
619 await this._emitExtensionEvent(event);
620
621 // Notify all listeners
622 this._emit(event.type === "agent_end" ? { ...event, willRetry: this._willRetryAfterAgentEnd(event) } : event);
623
624 // Handle session persistence
625 if (event.type === "message_end") {
626 // Check if this is a custom message from extensions
627 if (event.message.role === "custom") {
628 // Persist as CustomMessageEntry
629 this.sessionManager.appendCustomMessageEntry(
630 event.message.customType,
631 event.message.content,
632 event.message.display,
633 event.message.details,
634 );
635 } else if (
636 event.message.role === "user" ||
637 event.message.role === "assistant" ||
638 event.message.role === "toolResult"
639 ) {
640 // Regular LLM message - persist as SessionMessageEntry
641 this.sessionManager.appendMessage(event.message);
642 }
643 // Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere
644
645 // Track assistant message for auto-compaction (checked on agent_end)
646 if (event.message.role === "assistant") {
647 this._lastAssistantMessage = event.message;
648
649 const assistantMsg = event.message as AssistantMessage;
650 if (assistantMsg.stopReason !== "error") {
651 this._overflowRecoveryAttempted = false;
652 }
653
654 // Reset retry counter immediately on successful assistant response
655 // This prevents accumulation across multiple LLM calls within a turn
656 if (assistantMsg.stopReason !== "error" && this._retryAttempt > 0) {
657 this._emit({
658 type: "auto_retry_end",
659 success: true,
660 attempt: this._retryAttempt,
661 });
662 this._retryAttempt = 0;
663 }
664 }
665 }
666 };
667
668 private _willRetryAfterAgentEnd(event: Extract<AgentEvent, { type: "agent_end" }>): boolean {
669 const settings = this.settingsManager.getRetrySettings();
670 if (!settings.enabled || this._retryAttempt >= settings.maxRetries) {
671 return false;
672 }
673
674 for (let i = event.messages.length - 1; i >= 0; i--) {
675 const message = event.messages[i];
676 if (message.role === "assistant") {
677 return this._isRetryableError(message as AssistantMessage);
678 }
679 }
680 return false;
681 }
682
683 /** Find the last assistant message in agent state (including aborted ones) */
684 private _findLastAssistantMessage(): AssistantMessage | undefined {
685 const messages = this.agent.state.messages;
686 for (let i = messages.length - 1; i >= 0; i--) {
687 const msg = messages[i];
688 if (msg.role === "assistant") {
689 return msg as AssistantMessage;
690 }
691 }
692 return undefined;
693 }
694
695 private _replaceMessageInPlace(target: AgentMessage, replacement: AgentMessage): void {
696 // Agent-core stores the finalized message object in its state before emitting message_end.
697 // SessionManager persistence happens later in _handleAgentEvent() with event.message.
698 // Mutating this object in place keeps agent state, later turn/agent events, listeners,
699 // and the eventual SessionManager.appendMessage(event.message) persistence in sync.
700 if (target === replacement) {
701 return;
702 }
703
704 const targetRecord = target as unknown as Record<string, unknown>;
705 for (const key of Object.keys(targetRecord)) {
706 delete targetRecord[key];
707 }
708 Object.assign(targetRecord, replacement);
709 }
710
711 /** Emit extension events based on agent events */
712 private async _emitExtensionEvent(event: AgentEvent): Promise<void> {
713 if (event.type === "agent_start") {
714 this._turnIndex = 0;
715 await this._extensionRunner.emit({ type: "agent_start" });
716 } else if (event.type === "agent_end") {
717 await this._extensionRunner.emit({ type: "agent_end", messages: event.messages });
718 } else if (event.type === "turn_start") {
719 const extensionEvent: TurnStartEvent = {
720 type: "turn_start",
721 turnIndex: this._turnIndex,
722 timestamp: Date.now(),
723 };
724 await this._extensionRunner.emit(extensionEvent);
725 } else if (event.type === "turn_end") {
726 const extensionEvent: TurnEndEvent = {
727 type: "turn_end",
728 turnIndex: this._turnIndex,
729 message: event.message,
730 toolResults: event.toolResults,
731 };
732 await this._extensionRunner.emit(extensionEvent);
733 this._turnIndex++;
734 } else if (event.type === "message_start") {
735 const extensionEvent: MessageStartEvent = {
736 type: "message_start",
737 message: event.message,
738 };
739 await this._extensionRunner.emit(extensionEvent);
740 } else if (event.type === "message_update") {
741 const extensionEvent: MessageUpdateEvent = {
742 type: "message_update",
743 message: event.message,
744 assistantMessageEvent: event.assistantMessageEvent,
745 };
746 await this._extensionRunner.emit(extensionEvent);
747 } else if (event.type === "message_end") {
748 const extensionEvent: MessageEndEvent = {
749 type: "message_end",
750 message: event.message,
751 };
752 const replacement = await this._extensionRunner.emitMessageEnd(extensionEvent);
753 if (replacement) {
754 // Untyped extension handlers can return messages with null/missing content;
755 // normalize so it never enters agent state or session history.
756 const normalized =
757 (replacement.role === "user" ||
758 replacement.role === "assistant" ||
759 replacement.role === "toolResult" ||
760 replacement.role === "custom") &&
761 replacement.content == null
762 ? ({ ...replacement, content: [] } as AgentMessage)
763 : replacement;
764 this._replaceMessageInPlace(event.message, normalized);
765 }
766 } else if (event.type === "tool_execution_start") {
767 const extensionEvent: ToolExecutionStartEvent = {
768 type: "tool_execution_start",
769 toolCallId: event.toolCallId,
770 toolName: event.toolName,
771 args: event.args,
772 };
773 await this._extensionRunner.emit(extensionEvent);
774 } else if (event.type === "tool_execution_update") {
775 const extensionEvent: ToolExecutionUpdateEvent = {
776 type: "tool_execution_update",
777 toolCallId: event.toolCallId,
778 toolName: event.toolName,
779 args: event.args,
780 partialResult: event.partialResult,
781 };
782 await this._extensionRunner.emit(extensionEvent);
783 } else if (event.type === "tool_execution_end") {
784 const extensionEvent: ToolExecutionEndEvent = {
785 type: "tool_execution_end",
786 toolCallId: event.toolCallId,
787 toolName: event.toolName,
788 result: event.result,
789 isError: event.isError,
790 };
791 await this._extensionRunner.emit(extensionEvent);
792 }
793 }
794
795 /**
796 * Subscribe to agent events.
797 * Session persistence is handled internally (saves messages on message_end).
798 * Multiple listeners can be added. Returns unsubscribe function for this listener.
799 */
800 subscribe(listener: AgentSessionEventListener): () => void {
801 this._eventListeners.push(listener);
802
803 // Return unsubscribe function for this specific listener
804 return () => {
805 const index = this._eventListeners.indexOf(listener);
806 if (index !== -1) {
807 this._eventListeners.splice(index, 1);
808 }
809 };
810 }
811
812 /**
813 * Temporarily disconnect from agent events.
814 * User listeners are preserved and will receive events again after resubscribe().
815 * Used internally during operations that need to pause event processing.
816 */
817 private _disconnectFromAgent(): void {
818 if (this._unsubscribeAgent) {
819 this._unsubscribeAgent();
820 this._unsubscribeAgent = undefined;
821 }
822 }
823
824 /**
825 * Reconnect to agent events after _disconnectFromAgent().
826 * Preserves all existing listeners.
827 */
828 private _reconnectToAgent(): void {
829 if (this._unsubscribeAgent) return; // Already connected
830 this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);
831 }
832
833 /**
834 * Remove all listeners and disconnect from agent.
835 * Call this when completely done with the session.
836 */
837 dispose(): void {
838 try {
839 this.abortRetry();
840 this.abortCompaction();
841 this.abortBranchSummary();
842 this.abortBash();
843 this.agent.abort();
844 } catch {
845 // Dispose must succeed even if an abort hook throws.
846 }
847
848 this._extensionRunner.invalidate(
849 "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().",
850 );
851 this._disconnectFromAgent();
852 this._eventListeners = [];
853 cleanupSessionResources(this.sessionId);
854 }
855
856 // =========================================================================
857 // Read-only State Access
858 // =========================================================================
859
860 /** Full agent state */
861 get state(): AgentState {
862 return this.agent.state;
863 }
864
865 /** Current model (may be undefined if not yet selected) */
866 get model(): Model<any> | undefined {
867 return this.agent.state.model;
868 }
869
870 /** Current thinking level */
871 get thinkingLevel(): ThinkingLevel {
872 return this.agent.state.thinkingLevel;
873 }
874
875 /** Whether the session is currently processing an agent run or post-run continuation. */
876 get isStreaming(): boolean {
877 return this._isAgentRunActive;
878 }
879
880 /** Whether the session has no active agent run, retry, auto-compaction, or queued continuation. */
881 get isIdle(): boolean {
882 return !this._isAgentRunActive;
883 }
884
885 /** Current effective system prompt (includes any per-turn extension modifications) */
886 get systemPrompt(): string {
887 return this.agent.state.systemPrompt;
888 }
889
890 /** Current retry attempt (0 if not retrying) */
891 get retryAttempt(): number {
892 return this._retryAttempt;
893 }
894
895 /**
896 * Get the names of currently active tools.
897 * Returns the names of tools currently set on the agent.
898 */
899 getActiveToolNames(): string[] {
900 return this.agent.state.tools.map((t) => t.name);
901 }
902
903 /**
904 * Get all configured tools with name, description, parameter schema, prompt guidelines, and source metadata.
905 */
906 getAllTools(): ToolInfo[] {
907 return Array.from(this._toolDefinitions.values()).map(({ definition, sourceInfo }) => ({
908 name: definition.name,
909 description: definition.description,
910 parameters: definition.parameters,
911 promptGuidelines: definition.promptGuidelines,
912 sourceInfo,
913 }));
914 }
915
916 getToolDefinition(name: string): ToolDefinition | undefined {
917 return this._toolDefinitions.get(name)?.definition;
918 }
919
920 /**
921 * Set active tools by name.
922 * Only tools in the registry can be enabled. Unknown tool names are ignored.
923 * Also rebuilds the system prompt to reflect the new tool set.
924 * Changes take effect on the next agent turn.
925 */
926 setActiveToolsByName(toolNames: string[]): void {
927 const tools: AgentTool[] = [];
928 const validToolNames: string[] = [];
929 for (const name of toolNames) {
930 const tool = this._toolRegistry.get(name);
931 if (tool) {
932 tools.push(tool);
933 validToolNames.push(name);
934 }
935 }
936 this.agent.state.tools = tools;
937
938 // Rebuild base system prompt with new tool set
939 this._baseSystemPrompt = this._rebuildSystemPrompt(validToolNames);
940 this.agent.state.systemPrompt = this._systemPromptOverride ?? this._baseSystemPrompt;
941 }
942
943 /** Whether compaction or branch summarization is currently running */
944 get isCompacting(): boolean {
945 return (
946 this._autoCompactionAbortController !== undefined ||
947 this._compactionAbortController !== undefined ||
948 this._branchSummaryAbortController !== undefined
949 );
950 }
951
952 /** All messages including custom types like BashExecutionMessage */
953 get messages(): AgentMessage[] {
954 return this.agent.state.messages;
955 }
956
957 /** Current steering mode */
958 get steeringMode(): "all" | "one-at-a-time" {
959 return this.agent.steeringMode;
960 }
961
962 /** Current follow-up mode */
963 get followUpMode(): "all" | "one-at-a-time" {
964 return this.agent.followUpMode;
965 }
966
967 /** Current session file path, or undefined if sessions are disabled */
968 get sessionFile(): string | undefined {
969 return this.sessionManager.getSessionFile();
970 }
971
972 /** Current session ID */
973 get sessionId(): string {
974 return this.sessionManager.getSessionId();
975 }
976
977 /** Current session display name, if set */
978 get sessionName(): string | undefined {
979 return this.sessionManager.getSessionName();
980 }
981
982 /** Scoped models for cycling (from --models flag) */
983 get scopedModels(): ReadonlyArray<{ model: Model<any>; thinkingLevel?: ThinkingLevel }> {
984 return this._scopedModels;
985 }
986
987 /** Update scoped models for cycling */
988 setScopedModels(scopedModels: Array<{ model: Model<any>; thinkingLevel?: ThinkingLevel }>): void {
989 this._scopedModels = scopedModels;
990 }
991
992 /** File-based prompt templates */
993 get promptTemplates(): ReadonlyArray<PromptTemplate> {
994 return this._resourceLoader.getPrompts().prompts;
995 }
996
997 private _normalizePromptSnippet(text: string | undefined): string | undefined {
998 if (!text) return undefined;
999 const oneLine = text
1000 .replace(/[\r\n]+/g, " ")
1001 .replace(/\s+/g, " ")
1002 .trim();
1003 return oneLine.length > 0 ? oneLine : undefined;
1004 }
1005
1006 private _normalizePromptGuidelines(guidelines: string[] | undefined): string[] {
1007 if (!guidelines || guidelines.length === 0) {
1008 return [];
1009 }
1010
1011 const unique = new Set<string>();
1012 for (const guideline of guidelines) {
1013 const normalized = guideline.trim();
1014 if (normalized.length > 0) {
1015 unique.add(normalized);
1016 }
1017 }
1018 return Array.from(unique);
1019 }
1020
1021 private _rebuildSystemPrompt(toolNames: string[]): string {
1022 const validToolNames = toolNames.filter((name) => this._toolRegistry.has(name));
1023 const toolSnippets: Record<string, string> = {};
1024 const promptGuidelines: string[] = [];
1025 for (const name of validToolNames) {
1026 const snippet = this._toolPromptSnippets.get(name);
1027 if (snippet) {
1028 toolSnippets[name] = snippet;
1029 }
1030
1031 const toolGuidelines = this._toolPromptGuidelines.get(name);
1032 if (toolGuidelines) {
1033 promptGuidelines.push(...toolGuidelines);
1034 }
1035 }
1036
1037 const loaderSystemPrompt = this._resourceLoader.getSystemPrompt();
1038 const loaderAppendSystemPrompt = this._resourceLoader.getAppendSystemPrompt();
1039 const appendSystemPrompt =
1040 loaderAppendSystemPrompt.length > 0 ? loaderAppendSystemPrompt.join("\n\n") : undefined;
1041 const loadedSkills = this._resourceLoader.getSkills().skills;
1042 const loadedContextFiles = this._resourceLoader.getAgentsFiles().agentsFiles;
1043
1044 this._baseSystemPromptOptions = {
1045 cwd: this._cwd,
1046 skills: loadedSkills,
1047 contextFiles: loadedContextFiles,
1048 customPrompt: loaderSystemPrompt,
1049 appendSystemPrompt,
1050 selectedTools: validToolNames,
1051 toolSnippets,
1052 promptGuidelines,
1053 };
1054 return buildSystemPrompt(this._baseSystemPromptOptions);
1055 }
1056
1057 // =========================================================================
1058 // Prompting
1059 // =========================================================================
1060
1061 private async _runAgentPrompt(messages: AgentMessage | AgentMessage[]): Promise<void> {
1062 this._isAgentRunActive = true;
1063 try {
1064 await this.agent.prompt(messages);
1065 while (await this._handlePostAgentRun()) {
1066 await this.agent.continue();
1067 }
1068 } finally {
1069 this._systemPromptOverride = undefined;
1070 this._flushPendingBashMessages();
1071 await this._emitAgentSettled();
1072 }
1073 }
1074
1075 private async _handlePostAgentRun(): Promise<boolean> {
1076 const msg = this._lastAssistantMessage;
1077 this._lastAssistantMessage = undefined;
1078 if (!msg) {
1079 return false;
1080 }
1081
1082 if (this._isRetryableError(msg) && (await this._prepareRetry(msg))) {
1083 return true;
1084 }
1085
1086 if (msg.stopReason === "error" && this._retryAttempt > 0) {
1087 this._emit({
1088 type: "auto_retry_end",
1089 success: false,
1090 attempt: this._retryAttempt,
1091 finalError: msg.errorMessage,
1092 });
1093 this._retryAttempt = 0;
1094 }
1095
1096 if (await this._checkCompaction(msg)) {
1097 return true;
1098 }
1099
1100 // The agent loop drains both queues before emitting agent_end. Any messages
1101 // here were queued by agent_end extension handlers and need a continuation.
1102 return this.agent.hasQueuedMessages();
1103 }
1104
1105 /**
1106 * Send a prompt to the agent.
1107 * - Handles extension commands (registered via pi.registerCommand) immediately, even during streaming
1108 * - Expands file-based prompt templates by default
1109 * - During streaming, queues via steer() or followUp() based on streamingBehavior option
1110 * - Validates model and API key before sending (when not streaming)
1111 * @throws Error if streaming and no streamingBehavior specified
1112 * @throws Error if no model selected or no API key available (when not streaming)
1113 */
1114 async prompt(text: string, options?: PromptOptions): Promise<void> {
1115 const expandPromptTemplates = options?.expandPromptTemplates ?? true;
1116 const preflightResult = options?.preflightResult;
1117 let messages: AgentMessage[] | undefined;
1118
1119 try {
1120 // Handle extension commands first (execute immediately, even during streaming)
1121 // Extension commands manage their own LLM interaction via pi.sendMessage()
1122 if (expandPromptTemplates && text.startsWith("/")) {
1123 const handled = await this._tryExecuteExtensionCommand(text);
1124 if (handled) {
1125 // Extension command executed, no prompt to send
1126 preflightResult?.(true);
1127 return;
1128 }
1129 }
1130
1131 // Emit input event for extension interception (before skill/template expansion)
1132 let currentText = text;
1133 let currentImages = options?.images;
1134 if (this._extensionRunner.hasHandlers("input")) {
1135 const inputResult = await this._extensionRunner.emitInput(
1136 currentText,
1137 currentImages,
1138 options?.source ?? "interactive",
1139 this.isStreaming ? options?.streamingBehavior : undefined,
1140 );
1141 if (inputResult.action === "handled") {
1142 preflightResult?.(true);
1143 return;
1144 }
1145 if (inputResult.action === "transform") {
1146 currentText = inputResult.text;
1147 currentImages = inputResult.images ?? currentImages;
1148 }
1149 }
1150
1151 // Expand skill commands (/skill:name args) and prompt templates (/template args)
1152 let expandedText = currentText;
1153 if (expandPromptTemplates) {
1154 expandedText = this._expandSkillCommand(expandedText);
1155 expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]);
1156 }
1157
1158 // If streaming, queue via steer() or followUp() based on option
1159 if (this.isStreaming) {
1160 if (!options?.streamingBehavior) {
1161 throw new Error(
1162 "Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message.",
1163 );
1164 }
1165 if (options.streamingBehavior === "followUp") {
1166 await this._queueFollowUp(expandedText, currentImages);
1167 } else {
1168 await this._queueSteer(expandedText, currentImages);
1169 }
1170 preflightResult?.(true);
1171 return;
1172 }
1173
1174 // Flush any pending bash messages before the new prompt
1175 this._flushPendingBashMessages();
1176
1177 // Validate model
1178 if (!this.model) {
1179 throw new Error(formatNoModelSelectedMessage());
1180 }
1181
1182 const hasConfiguredAuth =
1183 this._modelRuntime.hasConfiguredAuth(this.model.provider) ||
1184 (await this._modelRuntime.checkAuth(this.model.provider)) !== undefined;
1185 if (!hasConfiguredAuth) {
1186 const isOAuth = this._modelRuntime.isUsingOAuth(this.model.provider);
1187 if (isOAuth) {
1188 throw new Error(
1189 `Authentication failed for "${this.model.provider}". ` +
1190 `Credentials may have expired or network is unavailable. ` +
1191 `Run '/login ${this.model.provider}' to re-authenticate.`,
1192 );
1193 }
1194 throw new Error(formatNoApiKeyFoundMessage(this.model.provider));
1195 }
1196
1197 // Check if we need to compact before sending (catches aborted responses).
1198 // The user's new prompt is sent below, so do not call agent.continue() here.
1199 const lastAssistant = this._findLastAssistantMessage();
1200 if (lastAssistant) {
1201 await this._checkCompaction(lastAssistant, false);
1202 }
1203
1204 // Build messages array (custom message if any, then user message)
1205 messages = [];
1206
1207 // Add user message
1208 const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }];
1209 if (currentImages) {
1210 userContent.push(...currentImages);
1211 }
1212 messages.push({
1213 role: "user",
1214 content: userContent,
1215 timestamp: Date.now(),
1216 });
1217
1218 // Inject any pending "nextTurn" messages as context alongside the user message
1219 for (const msg of this._pendingNextTurnMessages) {
1220 messages.push(msg);
1221 }
1222 this._pendingNextTurnMessages = [];
1223
1224 // Emit before_agent_start extension event
1225 const result = await this._extensionRunner.emitBeforeAgentStart(
1226 expandedText,
1227 currentImages,
1228 this._baseSystemPrompt,
1229 this._baseSystemPromptOptions,
1230 );
1231 // Add all custom messages from extensions
1232 if (result?.messages) {
1233 for (const msg of result.messages) {
1234 messages.push({
1235 role: "custom",
1236 customType: msg.customType,
1237 // Untyped extensions can pass null/missing content; normalize at ingestion.
1238 content: msg.content ?? [],
1239 display: msg.display,
1240 details: msg.details,
1241 timestamp: Date.now(),
1242 });
1243 }
1244 }
1245 // Apply extension-modified system prompt, or reset to base
1246 if (result?.systemPrompt !== undefined) {
1247 this._systemPromptOverride = result.systemPrompt;
1248 this.agent.state.systemPrompt = result.systemPrompt;
1249 } else {
1250 // Ensure we're using the base prompt (in case previous turn had modifications)
1251 this._systemPromptOverride = undefined;
1252 this.agent.state.systemPrompt = this._baseSystemPrompt;
1253 }
1254 } catch (error) {
1255 preflightResult?.(false);
1256 throw error;
1257 }
1258
1259 if (!messages) {
1260 return;
1261 }
1262
1263 preflightResult?.(true);
1264 await this._runAgentPrompt(messages);
1265 }
1266
1267 /**
1268 * Try to execute an extension command. Returns true if command was found and executed.
1269 */
1270 private async _tryExecuteExtensionCommand(text: string): Promise<boolean> {
1271 // Parse command name and args
1272 const spaceIndex = text.indexOf(" ");
1273 const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
1274 const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1);
1275
1276 const command = this._extensionRunner.getCommand(commandName);
1277 if (!command) return false;
1278
1279 // Get command context from extension runner (includes session control methods)
1280 const ctx = this._extensionRunner.createCommandContext();
1281
1282 try {
1283 await command.handler(args, ctx);
1284 return true;
1285 } catch (err) {
1286 // Emit error via extension runner
1287 this._extensionRunner.emitError({
1288 extensionPath: `command:${commandName}`,
1289 event: "command",
1290 error: err instanceof Error ? err.message : String(err),
1291 });
1292 return true;
1293 }
1294 }
1295
1296 /**
1297 * Expand skill commands (/skill:name args) to their full content.
1298 * Returns the expanded text, or the original text if not a skill command or skill not found.
1299 * Emits errors via extension runner if file read fails.
1300 */
1301 private _expandSkillCommand(text: string): string {
1302 if (!text.startsWith("/skill:")) return text;
1303
1304 const spaceIndex = text.indexOf(" ");
1305 const skillName = spaceIndex === -1 ? text.slice(7) : text.slice(7, spaceIndex);
1306 const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim();
1307
1308 const skill = this.resourceLoader.getSkills().skills.find((s) => s.name === skillName);
1309 if (!skill) return text; // Unknown skill, pass through
1310
1311 try {
1312 const content = readFileSync(skill.filePath, "utf-8");
1313 const body = stripFrontmatter(content).trim();
1314 const skillBlock = `<skill name="${skill.name}" location="${skill.filePath}">\nReferences are relative to ${skill.baseDir}.\n\n${body}\n</skill>`;
1315 return args ? `${skillBlock}\n\n${args}` : skillBlock;
1316 } catch (err) {
1317 // Emit error like extension commands do
1318 this._extensionRunner.emitError({
1319 extensionPath: skill.filePath,
1320 event: "skill_expansion",
1321 error: err instanceof Error ? err.message : String(err),
1322 });
1323 return text; // Return original on error
1324 }
1325 }
1326
1327 /**
1328 * Queue a steering message while the agent is running.
1329 * Delivered after the current assistant turn finishes executing its tool calls,
1330 * before the next LLM call.
1331 * Expands skill commands and prompt templates. Errors on extension commands.
1332 * @param images Optional image attachments to include with the message
1333 * @throws Error if text is an extension command
1334 */
1335 async steer(text: string, images?: ImageContent[]): Promise<void> {
1336 // Check for extension commands (cannot be queued)
1337 if (text.startsWith("/")) {
1338 this._throwIfExtensionCommand(text);
1339 }
1340
1341 // Expand skill commands and prompt templates
1342 let expandedText = this._expandSkillCommand(text);
1343 expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]);
1344
1345 await this._queueSteer(expandedText, images);
1346 }
1347
1348 /**
1349 * Queue a follow-up message to be processed after the agent finishes.
1350 * Delivered only when agent has no more tool calls or steering messages.
1351 * Expands skill commands and prompt templates. Errors on extension commands.
1352 * @param images Optional image attachments to include with the message
1353 * @throws Error if text is an extension command
1354 */
1355 async followUp(text: string, images?: ImageContent[]): Promise<void> {
1356 // Check for extension commands (cannot be queued)
1357 if (text.startsWith("/")) {
1358 this._throwIfExtensionCommand(text);
1359 }
1360
1361 // Expand skill commands and prompt templates
1362 let expandedText = this._expandSkillCommand(text);
1363 expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]);
1364
1365 await this._queueFollowUp(expandedText, images);
1366 }
1367
1368 /**
1369 * Internal: Queue a steering message (already expanded, no extension command check).
1370 */
1371 private async _queueSteer(text: string, images?: ImageContent[]): Promise<void> {
1372 this._steeringMessages.push(text);
1373 this._emitQueueUpdate();
1374 const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
1375 if (images) {
1376 content.push(...images);
1377 }
1378 this.agent.steer({
1379 role: "user",
1380 content,
1381 timestamp: Date.now(),
1382 });
1383 }
1384
1385 /**
1386 * Internal: Queue a follow-up message (already expanded, no extension command check).
1387 */
1388 private async _queueFollowUp(text: string, images?: ImageContent[]): Promise<void> {
1389 this._followUpMessages.push(text);
1390 this._emitQueueUpdate();
1391 const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
1392 if (images) {
1393 content.push(...images);
1394 }
1395 this.agent.followUp({
1396 role: "user",
1397 content,
1398 timestamp: Date.now(),
1399 });
1400 }
1401
1402 /**
1403 * Throw an error if the text is an extension command.
1404 */
1405 private _throwIfExtensionCommand(text: string): void {
1406 const spaceIndex = text.indexOf(" ");
1407 const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
1408 const command = this._extensionRunner.getCommand(commandName);
1409
1410 if (command) {
1411 throw new Error(
1412 `Extension command "/${commandName}" cannot be queued. Use prompt() or execute the command when not streaming.`,
1413 );
1414 }
1415 }
1416
1417 /**
1418 * Send a custom message to the session. Creates a CustomMessageEntry.
1419 *
1420 * Handles three cases:
1421 * - Streaming: queues message, processed when loop pulls from queue
1422 * - Not streaming + triggerTurn: appends to state/session, starts new turn
1423 * - Not streaming + no trigger: appends to state/session, no turn
1424 *
1425 * @param message Custom message with customType, content, display, details
1426 * @param options.triggerTurn If true and not streaming, triggers a new LLM turn
1427 * @param options.deliverAs Delivery mode: "steer", "followUp", or "nextTurn"
1428 */
1429 async sendCustomMessage<T = unknown>(
1430 message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details">,
1431 options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
1432 ): Promise<void> {
1433 const appMessage = {
1434 role: "custom" as const,
1435 customType: message.customType,
1436 // Untyped extensions can pass null/missing content; normalize at ingestion.
1437 content: message.content ?? [],
1438 display: message.display,
1439 details: message.details,
1440 timestamp: Date.now(),
1441 } satisfies CustomMessage<T>;
1442 if (options?.deliverAs === "nextTurn") {
1443 this._pendingNextTurnMessages.push(appMessage);
1444 } else if (this.isStreaming) {
1445 if (options?.deliverAs === "followUp") {
1446 this.agent.followUp(appMessage);
1447 } else {
1448 this.agent.steer(appMessage);
1449 }
1450 } else if (options?.triggerTurn) {
1451 await this._runAgentPrompt(appMessage);
1452 } else {
1453 this.agent.state.messages.push(appMessage);
1454 this.sessionManager.appendCustomMessageEntry(
1455 message.customType,
1456 message.content,
1457 message.display,
1458 message.details,
1459 );
1460 this._emit({ type: "message_start", message: appMessage });
1461 this._emit({ type: "message_end", message: appMessage });
1462 }
1463 }
1464
1465 /**
1466 * Send a user message to the agent. Always triggers a turn.
1467 * When the agent is streaming, use deliverAs to specify how to queue the message.
1468 *
1469 * @param content User message content (string or content array)
1470 * @param options.deliverAs Delivery mode when streaming: "steer" or "followUp"
1471 */
1472 async sendUserMessage(
1473 content: string | (TextContent | ImageContent)[],
1474 options?: { deliverAs?: "steer" | "followUp" },
1475 ): Promise<void> {
1476 // Normalize content to text string + optional images
1477 let text: string;
1478 let images: ImageContent[] | undefined;
1479
1480 if (typeof content === "string") {
1481 text = content;
1482 } else {
1483 const textParts: string[] = [];
1484 images = [];
1485 for (const part of content) {
1486 if (part.type === "text") {
1487 textParts.push(part.text);
1488 } else {
1489 images.push(part);
1490 }
1491 }
1492 text = textParts.join("\n");
1493 if (images.length === 0) images = undefined;
1494 }
1495
1496 // Use prompt() with expandPromptTemplates: false to skip command handling and template expansion
1497 await this.prompt(text, {
1498 expandPromptTemplates: false,
1499 streamingBehavior: options?.deliverAs,
1500 images,
1501 source: "extension",
1502 });
1503 }
1504
1505 /**
1506 * Clear all queued messages and return them.
1507 * Useful for restoring to editor when user aborts.
1508 * @returns Object with steering and followUp arrays
1509 */
1510 clearQueue(): { steering: string[]; followUp: string[] } {
1511 const steering = [...this._steeringMessages];
1512 const followUp = [...this._followUpMessages];
1513 this._steeringMessages = [];
1514 this._followUpMessages = [];
1515 this.agent.clearAllQueues();
1516 this._emitQueueUpdate();
1517 return { steering, followUp };
1518 }
1519
1520 /** Number of pending messages (includes both steering and follow-up) */
1521 get pendingMessageCount(): number {
1522 return this._steeringMessages.length + this._followUpMessages.length;
1523 }
1524
1525 /** Get pending steering messages (read-only) */
1526 getSteeringMessages(): readonly string[] {
1527 return this._steeringMessages;
1528 }
1529
1530 /** Get pending follow-up messages (read-only) */
1531 getFollowUpMessages(): readonly string[] {
1532 return this._followUpMessages;
1533 }
1534
1535 get resourceLoader(): ResourceLoader {
1536 return this._resourceLoader;
1537 }
1538
1539 /**
1540 * Abort current operation and wait for agent to become idle.
1541 */
1542 async abort(): Promise<void> {
1543 this.abortRetry();
1544 this.agent.abort();
1545 await this.waitForIdle();
1546 }
1547
1548 async waitForIdle(): Promise<void> {
1549 if (this.isIdle) {
1550 return;
1551 }
1552 await this._getIdleWaitPromise();
1553 }
1554
1555 // =========================================================================
1556 // Model Management
1557 // =========================================================================
1558
1559 private async _emitModelSelect(
1560 nextModel: Model<any>,
1561 previousModel: Model<any> | undefined,
1562 source: "set" | "cycle" | "restore",
1563 ): Promise<void> {
1564 if (modelsAreEqual(previousModel, nextModel)) return;
1565 await this._extensionRunner.emit({
1566 type: "model_select",
1567 model: nextModel,
1568 previousModel,
1569 source,
1570 });
1571 }
1572
1573 /**
1574 * Set model directly.
1575 * Validates that auth is configured, saves to session and settings.
1576 * @throws Error if no auth is configured for the model
1577 */
1578 async setModel(model: Model<any>): Promise<void> {
1579 if (!(await this._modelRuntime.checkAuth(model.provider))) {
1580 throw new Error(`No API key for ${model.provider}/${model.id}`);
1581 }
1582
1583 const previousModel = this.model;
1584 const thinkingLevel = this._getThinkingLevelForModelSwitch();
1585 this.agent.state.model = model;
1586 this.sessionManager.appendModelChange(model.provider, model.id);
1587 this.settingsManager.setDefaultModelAndProvider(model.provider, model.id);
1588
1589 // Re-clamp thinking level for new model's capabilities
1590 this.setThinkingLevel(thinkingLevel);
1591
1592 await this._emitModelSelect(model, previousModel, "set");
1593 }
1594
1595 /**
1596 * Cycle to next/previous model.
1597 * Uses scoped models (from --models flag) if available, otherwise all available models.
1598 * @param direction - "forward" (default) or "backward"
1599 * @returns The new model info, or undefined if only one model available
1600 */
1601 async cycleModel(direction: "forward" | "backward" = "forward"): Promise<ModelCycleResult | undefined> {
1602 if (this._scopedModels.length > 0) {
1603 return this._cycleScopedModel(direction);
1604 }
1605 return this._cycleAvailableModel(direction);
1606 }
1607
1608 private async _cycleScopedModel(direction: "forward" | "backward"): Promise<ModelCycleResult | undefined> {
1609 const checks = await Promise.all(
1610 this._scopedModels.map(async (scoped) => ({
1611 scoped,
1612 auth: await this._modelRuntime.checkAuth(scoped.model.provider),
1613 })),
1614 );
1615 const scopedModels = checks.filter(({ auth }) => auth !== undefined).map(({ scoped }) => scoped);
1616 if (scopedModels.length <= 1) return undefined;
1617
1618 const currentModel = this.model;
1619 let currentIndex = scopedModels.findIndex((sm) => modelsAreEqual(sm.model, currentModel));
1620
1621 if (currentIndex === -1) currentIndex = 0;
1622 const len = scopedModels.length;
1623 const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len;
1624 const next = scopedModels[nextIndex];
1625 const thinkingLevel = this._getThinkingLevelForModelSwitch(next.thinkingLevel);
1626
1627 // Apply model
1628 this.agent.state.model = next.model;
1629 this.sessionManager.appendModelChange(next.model.provider, next.model.id);
1630 this.settingsManager.setDefaultModelAndProvider(next.model.provider, next.model.id);
1631
1632 // Apply thinking level.
1633 // - Explicit scoped model thinking level overrides current session level
1634 // - Undefined scoped model thinking level inherits the current session preference
1635 // setThinkingLevel clamps to model capabilities.
1636 this.setThinkingLevel(thinkingLevel);
1637
1638 await this._emitModelSelect(next.model, currentModel, "cycle");
1639
1640 return { model: next.model, thinkingLevel: this.thinkingLevel, isScoped: true };
1641 }
1642
1643 private async _cycleAvailableModel(direction: "forward" | "backward"): Promise<ModelCycleResult | undefined> {
1644 const availableModels = await this._modelRuntime.getAvailable();
1645 if (availableModels.length <= 1) return undefined;
1646
1647 const currentModel = this.model;
1648 let currentIndex = availableModels.findIndex((m) => modelsAreEqual(m, currentModel));
1649
1650 if (currentIndex === -1) currentIndex = 0;
1651 const len = availableModels.length;
1652 const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len;
1653 const nextModel = availableModels[nextIndex];
1654
1655 const thinkingLevel = this._getThinkingLevelForModelSwitch();
1656 this.agent.state.model = nextModel;
1657 this.sessionManager.appendModelChange(nextModel.provider, nextModel.id);
1658 this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id);
1659
1660 // Re-clamp thinking level for new model's capabilities
1661 this.setThinkingLevel(thinkingLevel);
1662
1663 await this._emitModelSelect(nextModel, currentModel, "cycle");
1664
1665 return { model: nextModel, thinkingLevel: this.thinkingLevel, isScoped: false };
1666 }
1667
1668 // =========================================================================
1669 // Thinking Level Management
1670 // =========================================================================
1671
1672 /**
1673 * Set thinking level.
1674 * Clamps to model capabilities based on available thinking levels.
1675 * Saves to session and settings only if the level actually changes.
1676 */
1677 setThinkingLevel(level: ThinkingLevel): void {
1678 const availableLevels = this.getAvailableThinkingLevels();
1679 const effectiveLevel = availableLevels.includes(level) ? level : this._clampThinkingLevel(level, availableLevels);
1680
1681 // Only persist if actually changing
1682 const previousLevel = this.agent.state.thinkingLevel;
1683 const isChanging = effectiveLevel !== previousLevel;
1684
1685 this.agent.state.thinkingLevel = effectiveLevel;
1686
1687 if (isChanging) {
1688 this.sessionManager.appendThinkingLevelChange(effectiveLevel);
1689 if (this.supportsThinking() || effectiveLevel !== "off") {
1690 this.settingsManager.setDefaultThinkingLevel(effectiveLevel);
1691 }
1692 this._emit({ type: "thinking_level_changed", level: effectiveLevel });
1693 void this._extensionRunner.emit({
1694 type: "thinking_level_select",
1695 level: effectiveLevel,
1696 previousLevel,
1697 });
1698 }
1699 }
1700
1701 /**
1702 * Cycle to next thinking level.
1703 * @returns New level, or undefined if model doesn't support thinking
1704 */
1705 cycleThinkingLevel(): ThinkingLevel | undefined {
1706 if (!this.supportsThinking()) return undefined;
1707
1708 const levels = this.getAvailableThinkingLevels();
1709 const currentIndex = levels.indexOf(this.thinkingLevel);
1710 const nextIndex = (currentIndex + 1) % levels.length;
1711 const nextLevel = levels[nextIndex];
1712
1713 this.setThinkingLevel(nextLevel);
1714 return nextLevel;
1715 }
1716
1717 /**
1718 * Get available thinking levels for current model.
1719 * The provider will clamp to what the specific model supports internally.
1720 */
1721 getAvailableThinkingLevels(): ThinkingLevel[] {
1722 if (!this.model) return THINKING_LEVELS;
1723 return getSupportedThinkingLevels(this.model) as ThinkingLevel[];
1724 }
1725
1726 /**
1727 * Check if current model supports thinking/reasoning.
1728 */
1729 supportsThinking(): boolean {
1730 return !!this.model?.reasoning;
1731 }
1732
1733 private _getThinkingLevelForModelSwitch(explicitLevel?: ThinkingLevel): ThinkingLevel {
1734 if (explicitLevel !== undefined) {
1735 return explicitLevel;
1736 }
1737 if (!this.supportsThinking()) {
1738 return this.settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL;
1739 }
1740 return this.thinkingLevel;
1741 }
1742
1743 private _clampThinkingLevel(level: ThinkingLevel, _availableLevels: ThinkingLevel[]): ThinkingLevel {
1744 return this.model ? (clampThinkingLevel(this.model, level) as ThinkingLevel) : "off";
1745 }
1746
1747 // =========================================================================
1748 // Queue Mode Management
1749 // =========================================================================
1750
1751 private syncQueueModesFromSettings(): void {
1752 this.agent.steeringMode = this.settingsManager.getSteeringMode();
1753 this.agent.followUpMode = this.settingsManager.getFollowUpMode();
1754 }
1755
1756 /**
1757 * Set steering message mode.
1758 * Saves to settings.
1759 */
1760 setSteeringMode(mode: "all" | "one-at-a-time"): void {
1761 this.agent.steeringMode = mode;
1762 this.settingsManager.setSteeringMode(mode);
1763 }
1764
1765 /**
1766 * Set follow-up message mode.
1767 * Saves to settings.
1768 */
1769 setFollowUpMode(mode: "all" | "one-at-a-time"): void {
1770 this.agent.followUpMode = mode;
1771 this.settingsManager.setFollowUpMode(mode);
1772 }
1773
1774 // =========================================================================
1775 // Compaction
1776 // =========================================================================
1777
1778 /**
1779 * Manually compact the session context.
1780 * Aborts current agent operation first.
1781 * @param customInstructions Optional instructions for the compaction summary
1782 */
1783 async compact(customInstructions?: string): Promise<CompactionResult> {
1784 this._disconnectFromAgent();
1785 await this.abort();
1786 this._compactionAbortController = new AbortController();
1787 this._emit({ type: "compaction_start", reason: "manual" });
1788
1789 try {
1790 if (!this.model) {
1791 throw new Error(formatNoModelSelectedMessage());
1792 }
1793
1794 const { apiKey, headers, env } = await this._getSummarizationRequestAuth(this.model);
1795
1796 const pathEntries = this.sessionManager.getBranch();
1797 const settings = this.settingsManager.getCompactionSettings();
1798
1799 const preparation = prepareCompaction(pathEntries, settings);
1800 if (!preparation) {
1801 // Check why we can't compact
1802 const lastEntry = pathEntries[pathEntries.length - 1];
1803 if (lastEntry?.type === "compaction") {
1804 throw new Error("Already compacted");
1805 }
1806 throw new Error("Nothing to compact (session too small)");
1807 }
1808
1809 let extensionCompaction: CompactionResult | undefined;
1810 let fromExtension = false;
1811
1812 if (this._extensionRunner.hasHandlers("session_before_compact")) {
1813 const result = (await this._extensionRunner.emit({
1814 type: "session_before_compact",
1815 preparation,
1816 branchEntries: pathEntries,
1817 customInstructions,
1818 reason: "manual",
1819 willRetry: false,
1820 signal: this._compactionAbortController.signal,
1821 })) as SessionBeforeCompactResult | undefined;
1822
1823 if (result?.cancel) {
1824 throw new Error("Compaction cancelled");
1825 }
1826
1827 if (result?.compaction) {
1828 extensionCompaction = result.compaction;
1829 fromExtension = true;
1830 }
1831 }
1832
1833 let summary: string;
1834 let firstKeptEntryId: string;
1835 let tokensBefore: number;
1836 let usage: Usage | undefined;
1837 let details: unknown;
1838
1839 if (extensionCompaction) {
1840 // Extension provided compaction content
1841 summary = extensionCompaction.summary;
1842 firstKeptEntryId = extensionCompaction.firstKeptEntryId;
1843 tokensBefore = extensionCompaction.tokensBefore;
1844 usage = extensionCompaction.usage;
1845 details = extensionCompaction.details;
1846 } else {
1847 // Generate compaction result
1848 const result = await compact(
1849 preparation,
1850 this.model,
1851 apiKey,
1852 headers,
1853 customInstructions,
1854 this._compactionAbortController.signal,
1855 this.thinkingLevel,
1856 this.agent.streamFunction,
1857 env,
1858 this.settingsManager.getRetrySettings(),
1859 this._summarizationRetryCallbacks({ source: "compaction", reason: "manual" }),
1860 );
1861 summary = result.summary;
1862 firstKeptEntryId = result.firstKeptEntryId;
1863 tokensBefore = result.tokensBefore;
1864 usage = result.usage;
1865 details = result.details;
1866 }
1867
1868 if (this._compactionAbortController.signal.aborted) {
1869 throw new Error("Compaction cancelled");
1870 }
1871
1872 this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension, usage);
1873 const newEntries = this.sessionManager.getEntries();
1874 const sessionContext = this.sessionManager.buildSessionContext();
1875 this.agent.state.messages = sessionContext.messages;
1876 const estimatedTokensAfter = estimateMessagesTokens(sessionContext.messages);
1877
1878 // Get the saved compaction entry for the extension event
1879 const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary) as
1880 | CompactionEntry
1881 | undefined;
1882
1883 if (this._extensionRunner && savedCompactionEntry) {
1884 await this._extensionRunner.emit({
1885 type: "session_compact",
1886 compactionEntry: savedCompactionEntry,
1887 fromExtension,
1888 reason: "manual",
1889 willRetry: false,
1890 });
1891 }
1892
1893 const compactionResult: CompactionResult = {
1894 summary,
1895 firstKeptEntryId,
1896 tokensBefore,
1897 estimatedTokensAfter,
1898 usage,
1899 details,
1900 };
1901 this._emit({
1902 type: "compaction_end",
1903 reason: "manual",
1904 result: compactionResult,
1905 aborted: false,
1906 willRetry: false,
1907 });
1908 return compactionResult;
1909 } catch (error) {
1910 const message = error instanceof Error ? error.message : String(error);
1911 const aborted = message === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError");
1912 this._emit({
1913 type: "compaction_end",
1914 reason: "manual",
1915 result: undefined,
1916 aborted,
1917 willRetry: false,
1918 errorMessage: aborted ? undefined : `Compaction failed: ${message}`,
1919 });
1920 throw error;
1921 } finally {
1922 this._compactionAbortController = undefined;
1923 this._reconnectToAgent();
1924 }
1925 }
1926
1927 /**
1928 * Cancel in-progress compaction (manual or auto).
1929 */
1930 abortCompaction(): void {
1931 this._compactionAbortController?.abort();
1932 this._autoCompactionAbortController?.abort();
1933 }
1934
1935 /**
1936 * Cancel in-progress branch summarization.
1937 */
1938 abortBranchSummary(): void {
1939 this._branchSummaryAbortController?.abort();
1940 }
1941
1942 /**
1943 * Check if compaction is needed and run it.
1944 * Called after agent_end and before prompt submission.
1945 *
1946 * Two cases:
1947 * 1. Overflow: LLM returned context overflow error, remove error message from agent state, compact, auto-retry
1948 * 2. Threshold: Context over threshold, compact, NO auto-retry (user continues manually)
1949 *
1950 * @param assistantMessage The assistant message to check
1951 * @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true
1952 */
1953 private async _checkCompaction(assistantMessage: AssistantMessage, skipAbortedCheck = true): Promise<boolean> {
1954 const settings = this.settingsManager.getCompactionSettings();
1955 if (!settings.enabled) return false;
1956
1957 // Skip if message was aborted (user cancelled) - unless skipAbortedCheck is false
1958 if (skipAbortedCheck && assistantMessage.stopReason === "aborted") return false;
1959
1960 const contextWindow = this.model?.contextWindow ?? 0;
1961
1962 // Skip overflow check if the message came from a different model.
1963 // This handles the case where user switched from a smaller-context model (e.g. opus)
1964 // to a larger-context model (e.g. codex) - the overflow error from the old model
1965 // shouldn't trigger compaction for the new model.
1966 const sameModel =
1967 this.model && assistantMessage.provider === this.model.provider && assistantMessage.model === this.model.id;
1968
1969 // Skip compaction checks if this assistant message is older than the latest
1970 // compaction boundary. This prevents a stale pre-compaction usage/error
1971 // from retriggering compaction on the first prompt after compaction.
1972 const compactionEntry = getLatestCompactionEntry(this.sessionManager.getBranch());
1973 const assistantIsFromBeforeCompaction =
1974 compactionEntry !== null && assistantMessage.timestamp <= new Date(compactionEntry.timestamp).getTime();
1975 if (assistantIsFromBeforeCompaction) {
1976 return false;
1977 }
1978
1979 // Case 1: Overflow - LLM returned context overflow error, or reported usage exceeded
1980 // the configured window. A successful response over the configured window should compact
1981 // but must not retry: the assistant answer already completed and agent.continue() cannot
1982 // continue from an assistant message.
1983 if (sameModel && isContextOverflow(assistantMessage, contextWindow)) {
1984 const willRetry = assistantMessage.stopReason !== "stop";
1985
1986 if (!willRetry) {
1987 return await this._runAutoCompaction("overflow", false);
1988 }
1989
1990 if (this._overflowRecoveryAttempted) {
1991 this._emit({
1992 type: "compaction_end",
1993 reason: "overflow",
1994 result: undefined,
1995 aborted: false,
1996 willRetry: false,
1997 errorMessage:
1998 "Context overflow recovery failed after one compact-and-retry attempt. Try reducing context or switching to a larger-context model.",
1999 });
2000 return false;
2001 }
2002
2003 this._overflowRecoveryAttempted = true;
2004 // Remove the error message from agent state (it IS saved to session for history,
2005 // but we don't want it in context for the retry)
2006 const messages = this.agent.state.messages;
2007 if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
2008 this.agent.state.messages = messages.slice(0, -1);
2009 }
2010 return await this._runAutoCompaction("overflow", willRetry);
2011 }
2012
2013 // Case 2: Threshold - context is getting large
2014 // For error messages or all-zero usage messages, estimate from the last valid response.
2015 // This ensures sessions that hit persistent API errors (e.g. 529) or malformed zero-usage
2016 // responses can still compact and do not reset context accounting.
2017 let contextTokens: number;
2018 const directContextTokens = assistantMessage.usage ? calculateContextTokens(assistantMessage.usage) : 0;
2019 if (assistantMessage.stopReason === "error" || directContextTokens === 0) {
2020 const messages = this.agent.state.messages;
2021 const estimate = estimateContextTokens(messages);
2022 if (estimate.lastUsageIndex === null) return false; // No usage data at all
2023 // Verify the usage source is post-compaction. Kept pre-compaction messages
2024 // have stale usage reflecting the old (larger) context and would falsely
2025 // trigger compaction right after one just finished.
2026 const usageMsg = messages[estimate.lastUsageIndex];
2027 if (
2028 compactionEntry &&
2029 usageMsg.role === "assistant" &&
2030 (usageMsg as AssistantMessage).timestamp <= new Date(compactionEntry.timestamp).getTime()
2031 ) {
2032 return false;
2033 }
2034 contextTokens = estimate.tokens;
2035 } else {
2036 contextTokens = directContextTokens;
2037 }
2038 if (shouldCompact(contextTokens, contextWindow, settings)) {
2039 return await this._runAutoCompaction("threshold", false);
2040 }
2041 return false;
2042 }
2043
2044 /**
2045 * Internal: Run auto-compaction with events.
2046 */
2047 private async _runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean): Promise<boolean> {
2048 const settings = this.settingsManager.getCompactionSettings();
2049 let started = false;
2050
2051 try {
2052 if (!this.model) {
2053 return false;
2054 }
2055
2056 let apiKey: string | undefined;
2057 let headers: Record<string, string> | undefined;
2058 let env: Record<string, string> | undefined;
2059 if (this.agent.streamFunction === streamSimple) {
2060 ({ apiKey, headers, env } = await this._getRequiredRequestAuth(this.model));
2061 } else {
2062 ({ apiKey, headers, env } = await this._getSummarizationRequestAuth(this.model));
2063 }
2064
2065 const pathEntries = this.sessionManager.getBranch();
2066
2067 const preparation = prepareCompaction(pathEntries, settings);
2068 if (!preparation) {
2069 return false;
2070 }
2071
2072 this._emit({ type: "compaction_start", reason });
2073 this._autoCompactionAbortController = new AbortController();
2074 started = true;
2075
2076 let extensionCompaction: CompactionResult | undefined;
2077 let fromExtension = false;
2078
2079 if (this._extensionRunner.hasHandlers("session_before_compact")) {
2080 const extensionResult = (await this._extensionRunner.emit({
2081 type: "session_before_compact",
2082 preparation,
2083 branchEntries: pathEntries,
2084 customInstructions: undefined,
2085 reason,
2086 willRetry,
2087 signal: this._autoCompactionAbortController.signal,
2088 })) as SessionBeforeCompactResult | undefined;
2089
2090 if (extensionResult?.cancel) {
2091 this._emit({
2092 type: "compaction_end",
2093 reason,
2094 result: undefined,
2095 aborted: true,
2096 willRetry: false,
2097 });
2098 return false;
2099 }
2100
2101 if (extensionResult?.compaction) {
2102 extensionCompaction = extensionResult.compaction;
2103 fromExtension = true;
2104 }
2105 }
2106
2107 let summary: string;
2108 let firstKeptEntryId: string;
2109 let tokensBefore: number;
2110 let usage: Usage | undefined;
2111 let details: unknown;
2112
2113 if (extensionCompaction) {
2114 // Extension provided compaction content
2115 summary = extensionCompaction.summary;
2116 firstKeptEntryId = extensionCompaction.firstKeptEntryId;
2117 tokensBefore = extensionCompaction.tokensBefore;
2118 usage = extensionCompaction.usage;
2119 details = extensionCompaction.details;
2120 } else {
2121 // Generate compaction result
2122 const compactResult = await compact(
2123 preparation,
2124 this.model,
2125 apiKey,
2126 headers,
2127 undefined,
2128 this._autoCompactionAbortController.signal,
2129 this.thinkingLevel,
2130 this.agent.streamFunction,
2131 env,
2132 this.settingsManager.getRetrySettings(),
2133 this._summarizationRetryCallbacks({ source: "compaction", reason }),
2134 );
2135 summary = compactResult.summary;
2136 firstKeptEntryId = compactResult.firstKeptEntryId;
2137 tokensBefore = compactResult.tokensBefore;
2138 usage = compactResult.usage;
2139 details = compactResult.details;
2140 }
2141
2142 if (this._autoCompactionAbortController.signal.aborted) {
2143 this._emit({
2144 type: "compaction_end",
2145 reason,
2146 result: undefined,
2147 aborted: true,
2148 willRetry: false,
2149 });
2150 return false;
2151 }
2152
2153 this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension, usage);
2154 const newEntries = this.sessionManager.getEntries();
2155 const sessionContext = this.sessionManager.buildSessionContext();
2156 this.agent.state.messages = sessionContext.messages;
2157 const estimatedTokensAfter = estimateMessagesTokens(sessionContext.messages);
2158
2159 // Get the saved compaction entry for the extension event
2160 const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary) as
2161 | CompactionEntry
2162 | undefined;
2163
2164 if (this._extensionRunner && savedCompactionEntry) {
2165 await this._extensionRunner.emit({
2166 type: "session_compact",
2167 compactionEntry: savedCompactionEntry,
2168 fromExtension,
2169 reason,
2170 willRetry,
2171 });
2172 }
2173
2174 const result: CompactionResult = {
2175 summary,
2176 firstKeptEntryId,
2177 tokensBefore,
2178 estimatedTokensAfter,
2179 usage,
2180 details,
2181 };
2182 this._emit({ type: "compaction_end", reason, result, aborted: false, willRetry });
2183
2184 if (willRetry) {
2185 const messages = this.agent.state.messages;
2186 const lastMsg = messages[messages.length - 1];
2187 if (lastMsg?.role === "assistant" && (lastMsg as AssistantMessage).stopReason === "error") {
2188 this.agent.state.messages = messages.slice(0, -1);
2189 }
2190 return true;
2191 }
2192
2193 // Auto-compaction can complete while follow-up/steering/custom messages are waiting.
2194 // Continue once so queued messages are delivered.
2195 return this.agent.hasQueuedMessages();
2196 } catch (error) {
2197 const errorMessage = error instanceof Error ? error.message : "compaction failed";
2198 if (started) {
2199 this._emit({
2200 type: "compaction_end",
2201 reason,
2202 result: undefined,
2203 aborted: false,
2204 willRetry: false,
2205 errorMessage:
2206 reason === "overflow"
2207 ? `Context overflow recovery failed: ${errorMessage}`
2208 : `Auto-compaction failed: ${errorMessage}`,
2209 });
2210 }
2211 return false;
2212 } finally {
2213 this._autoCompactionAbortController = undefined;
2214 }
2215 }
2216
2217 /**
2218 * Toggle auto-compaction setting.
2219 */
2220 setAutoCompactionEnabled(enabled: boolean): void {
2221 this.settingsManager.setCompactionEnabled(enabled);
2222 }
2223
2224 /** Whether auto-compaction is enabled */
2225 get autoCompactionEnabled(): boolean {
2226 return this.settingsManager.getCompactionEnabled();
2227 }
2228
2229 async bindExtensions(bindings: ExtensionBindings): Promise<void> {
2230 if (bindings.uiContext !== undefined) {
2231 this._extensionUIContext = bindings.uiContext;
2232 }
2233 if (bindings.mode !== undefined) {
2234 this._extensionMode = bindings.mode;
2235 }
2236 if (bindings.commandContextActions !== undefined) {
2237 this._extensionCommandContextActions = bindings.commandContextActions;
2238 }
2239 if (bindings.abortHandler !== undefined) {
2240 this._extensionAbortHandler = bindings.abortHandler;
2241 }
2242 if (bindings.shutdownHandler !== undefined) {
2243 this._extensionShutdownHandler = bindings.shutdownHandler;
2244 }
2245 if (bindings.onError !== undefined) {
2246 this._extensionErrorListener = bindings.onError;
2247 }
2248
2249 this._applyExtensionBindings(this._extensionRunner);
2250 await this._extensionRunner.emit(this._sessionStartEvent);
2251 await this.extendResourcesFromExtensions(this._sessionStartEvent.reason === "reload" ? "reload" : "startup");
2252 }
2253
2254 private async extendResourcesFromExtensions(reason: "startup" | "reload"): Promise<void> {
2255 if (!this._extensionRunner.hasHandlers("resources_discover")) {
2256 return;
2257 }
2258
2259 const { skillPaths, promptPaths, themePaths } = await this._extensionRunner.emitResourcesDiscover(
2260 this._cwd,
2261 reason,
2262 );
2263
2264 if (skillPaths.length === 0 && promptPaths.length === 0 && themePaths.length === 0) {
2265 return;
2266 }
2267
2268 const extensionPaths: ResourceExtensionPaths = {
2269 skillPaths: this.buildExtensionResourcePaths(skillPaths),
2270 promptPaths: this.buildExtensionResourcePaths(promptPaths),
2271 themePaths: this.buildExtensionResourcePaths(themePaths),
2272 };
2273
2274 this._resourceLoader.extendResources(extensionPaths);
2275 this._baseSystemPrompt = this._rebuildSystemPrompt(this.getActiveToolNames());
2276 this.agent.state.systemPrompt = this._baseSystemPrompt;
2277 }
2278
2279 private buildExtensionResourcePaths(entries: Array<{ path: string; extensionPath: string }>): Array<{
2280 path: string;
2281 metadata: { source: string; scope: "temporary"; origin: "top-level"; baseDir?: string };
2282 }> {
2283 return entries.map((entry) => {
2284 const source = this.getExtensionSourceLabel(entry.extensionPath);
2285 const baseDir = entry.extensionPath.startsWith("<") ? undefined : dirname(entry.extensionPath);
2286 return {
2287 path: entry.path,
2288 metadata: {
2289 source,
2290 scope: "temporary",
2291 origin: "top-level",
2292 baseDir,
2293 },
2294 };
2295 });
2296 }
2297
2298 private getExtensionSourceLabel(extensionPath: string): string {
2299 if (extensionPath.startsWith("<")) {
2300 return `extension:${extensionPath.replace(/[<>]/g, "")}`;
2301 }
2302 const base = basename(extensionPath);
2303 const name = base.replace(/\.(ts|js)$/, "");
2304 return `extension:${name}`;
2305 }
2306
2307 private _applyExtensionBindings(runner: ExtensionRunner): void {
2308 runner.setUIContext(this._extensionUIContext, this._extensionMode);
2309 runner.bindCommandContext(this._extensionCommandContextActions);
2310
2311 this._extensionErrorUnsubscriber?.();
2312 this._extensionErrorUnsubscriber = this._extensionErrorListener
2313 ? runner.onError(this._extensionErrorListener)
2314 : undefined;
2315 }
2316
2317 private _refreshCurrentModelFromRegistry(): void {
2318 const currentModel = this.model;
2319 if (!currentModel) {
2320 return;
2321 }
2322
2323 const refreshedModel = this._modelRuntime.getModel(currentModel.provider, currentModel.id);
2324 if (!refreshedModel || refreshedModel === currentModel) {
2325 return;
2326 }
2327
2328 this.agent.state.model = refreshedModel;
2329 }
2330
2331 private _bindExtensionCore(runner: ExtensionRunner): void {
2332 const getCommands = (): SlashCommandInfo[] => {
2333 const extensionCommands: SlashCommandInfo[] = runner.getRegisteredCommands().map((command) => ({
2334 name: command.invocationName,
2335 description: command.description,
2336 source: "extension",
2337 sourceInfo: command.sourceInfo,
2338 }));
2339
2340 const templates: SlashCommandInfo[] = this.promptTemplates.map((template) => ({
2341 name: template.name,
2342 description: template.description,
2343 source: "prompt",
2344 sourceInfo: template.sourceInfo,
2345 }));
2346
2347 const skills: SlashCommandInfo[] = this._resourceLoader.getSkills().skills.map((skill) => ({
2348 name: `skill:${skill.name}`,
2349 description: skill.description,
2350 source: "skill",
2351 sourceInfo: skill.sourceInfo,
2352 }));
2353
2354 return [...extensionCommands, ...templates, ...skills];
2355 };
2356
2357 runner.bindCore(
2358 {
2359 sendMessage: (message, options) => {
2360 this.sendCustomMessage(message, options).catch((err) => {
2361 runner.emitError({
2362 extensionPath: "<runtime>",
2363 event: "send_message",
2364 error: err instanceof Error ? err.message : String(err),
2365 });
2366 });
2367 },
2368 sendUserMessage: (content, options) => {
2369 this.sendUserMessage(content, options).catch((err) => {
2370 runner.emitError({
2371 extensionPath: "<runtime>",
2372 event: "send_user_message",
2373 error: err instanceof Error ? err.message : String(err),
2374 });
2375 });
2376 },
2377 appendEntry: (customType, data) => {
2378 const entryId = this.sessionManager.appendCustomEntry(customType, data);
2379 const entry = this.sessionManager.getEntry(entryId);
2380 if (entry) {
2381 this._emit({ type: "entry_appended", entry });
2382 }
2383 },
2384 setSessionName: (name) => {
2385 this.setSessionName(name);
2386 },
2387 getSessionName: () => {
2388 return this.sessionManager.getSessionName();
2389 },
2390 setLabel: (entryId, label) => {
2391 this.sessionManager.appendLabelChange(entryId, label);
2392 },
2393 getActiveTools: () => this.getActiveToolNames(),
2394 getAllTools: () => this.getAllTools(),
2395 setActiveTools: (toolNames) => this.setActiveToolsByName(toolNames),
2396 refreshTools: () => this._refreshToolRegistry(),
2397 getCommands,
2398 setModel: async (model) => {
2399 if (!this._modelRuntime.hasConfiguredAuth(model.provider)) return false;
2400 await this.setModel(model);
2401 return true;
2402 },
2403 getThinkingLevel: () => this.thinkingLevel,
2404 setThinkingLevel: (level) => this.setThinkingLevel(level),
2405 },
2406 {
2407 getModel: () => this.model,
2408 isIdle: () => this.isIdle,
2409 isProjectTrusted: () => this.settingsManager.isProjectTrusted(),
2410 getSignal: () => this.agent.signal,
2411 abort: () => {
2412 if (this._extensionAbortHandler) {
2413 this._extensionAbortHandler();
2414 return;
2415 }
2416 void this.abort();
2417 },
2418 hasPendingMessages: () => this.pendingMessageCount > 0,
2419 shutdown: () => {
2420 this._extensionShutdownHandler?.();
2421 },
2422 getContextUsage: () => this.getContextUsage(),
2423 compact: (options) => {
2424 void (async () => {
2425 try {
2426 const result = await this.compact(options?.customInstructions);
2427 options?.onComplete?.(result);
2428 } catch (error) {
2429 const err = error instanceof Error ? error : new Error(String(error));
2430 options?.onError?.(err);
2431 }
2432 })();
2433 },
2434 getSystemPrompt: () => this.systemPrompt,
2435 getSystemPromptOptions: () => this._baseSystemPromptOptions,
2436 },
2437 {
2438 registerProvider: (name, config) => {
2439 this._modelRuntime.registerProvider(name, config);
2440 this._refreshCurrentModelFromRegistry();
2441 },
2442 registerNativeProvider: (provider) => {
2443 this._modelRuntime.registerNativeProvider(provider);
2444 this._refreshCurrentModelFromRegistry();
2445 },
2446 unregisterProvider: (name) => {
2447 this._modelRuntime.unregisterProvider(name);
2448 this._refreshCurrentModelFromRegistry();
2449 },
2450 },
2451 );
2452 }
2453
2454 private _refreshToolRegistry(options?: { activeToolNames?: string[]; includeAllExtensionTools?: boolean }): void {
2455 const previousRegistryNames = new Set(this._toolRegistry.keys());
2456 const previousActiveToolNames = this.getActiveToolNames();
2457 const allowedToolNames = this._allowedToolNames;
2458 const excludedToolNames = this._excludedToolNames;
2459 const isAllowedTool = (name: string): boolean =>
2460 (!allowedToolNames || allowedToolNames.has(name)) && !excludedToolNames?.has(name);
2461
2462 const registeredTools = this._extensionRunner.getAllRegisteredTools();
2463 const allCustomTools = [
2464 ...registeredTools,
2465 ...this._customTools.map((definition) => ({
2466 definition,
2467 sourceInfo: createSyntheticSourceInfo(`<sdk:${definition.name}>`, { source: "sdk" }),
2468 })),
2469 ].filter((tool) => isAllowedTool(tool.definition.name));
2470 const definitionRegistry = new Map<string, ToolDefinitionEntry>(
2471 Array.from(this._baseToolDefinitions.entries())
2472 .filter(([name]) => isAllowedTool(name))
2473 .map(([name, definition]) => [
2474 name,
2475 {
2476 definition,
2477 sourceInfo: createSyntheticSourceInfo(`<builtin:${name}>`, { source: "builtin" }),
2478 },
2479 ]),
2480 );
2481 for (const tool of allCustomTools) {
2482 definitionRegistry.set(tool.definition.name, {
2483 definition: tool.definition,
2484 sourceInfo: tool.sourceInfo,
2485 });
2486 }
2487 this._toolDefinitions = definitionRegistry;
2488 this._toolPromptSnippets = new Map(
2489 Array.from(definitionRegistry.values())
2490 .map(({ definition }) => {
2491 const snippet = this._normalizePromptSnippet(definition.promptSnippet);
2492 return snippet ? ([definition.name, snippet] as const) : undefined;
2493 })
2494 .filter((entry): entry is readonly [string, string] => entry !== undefined),
2495 );
2496 this._toolPromptGuidelines = new Map(
2497 Array.from(definitionRegistry.values())
2498 .map(({ definition }) => {
2499 const guidelines = this._normalizePromptGuidelines(definition.promptGuidelines);
2500 return guidelines.length > 0 ? ([definition.name, guidelines] as const) : undefined;
2501 })
2502 .filter((entry): entry is readonly [string, string[]] => entry !== undefined),
2503 );
2504 const runner = this._extensionRunner;
2505 const wrappedExtensionTools = wrapRegisteredTools(allCustomTools, runner);
2506 const wrappedBuiltInTools = wrapRegisteredTools(
2507 Array.from(this._baseToolDefinitions.values())
2508 .filter((definition) => isAllowedTool(definition.name))
2509 .map((definition) => ({
2510 definition,
2511 sourceInfo: createSyntheticSourceInfo(`<builtin:${definition.name}>`, { source: "builtin" }),
2512 })),
2513 runner,
2514 );
2515
2516 const toolRegistry = new Map(wrappedBuiltInTools.map((tool) => [tool.name, tool]));
2517 for (const tool of wrappedExtensionTools as AgentTool[]) {
2518 toolRegistry.set(tool.name, tool);
2519 }
2520 this._toolRegistry = toolRegistry;
2521
2522 const nextActiveToolNames = (
2523 options?.activeToolNames ? [...options.activeToolNames] : [...previousActiveToolNames]
2524 ).filter((name) => isAllowedTool(name));
2525
2526 if (allowedToolNames) {
2527 for (const toolName of this._toolRegistry.keys()) {
2528 if (allowedToolNames.has(toolName)) {
2529 nextActiveToolNames.push(toolName);
2530 }
2531 }
2532 } else if (options?.includeAllExtensionTools) {
2533 for (const tool of wrappedExtensionTools) {
2534 nextActiveToolNames.push(tool.name);
2535 }
2536 } else if (!options?.activeToolNames) {
2537 for (const toolName of this._toolRegistry.keys()) {
2538 if (!previousRegistryNames.has(toolName)) {
2539 nextActiveToolNames.push(toolName);
2540 }
2541 }
2542 }
2543
2544 this.setActiveToolsByName([...new Set(nextActiveToolNames)]);
2545 }
2546
2547 private _buildRuntime(options: {
2548 activeToolNames?: string[];
2549 flagValues?: Map<string, boolean | string>;
2550 includeAllExtensionTools?: boolean;
2551 }): void {
2552 const autoResizeImages = this.settingsManager.getImageAutoResize();
2553 const shellCommandPrefix = this.settingsManager.getShellCommandPrefix();
2554 const shellPath = this.settingsManager.getShellPath();
2555 const baseToolDefinitions = this._baseToolsOverride
2556 ? Object.fromEntries(
2557 Object.entries(this._baseToolsOverride).map(([name, tool]) => [
2558 name,
2559 createToolDefinitionFromAgentTool(tool),
2560 ]),
2561 )
2562 : createAllToolDefinitions(this._cwd, {
2563 read: { autoResizeImages },
2564 bash: { commandPrefix: shellCommandPrefix, shellPath },
2565 });
2566
2567 this._baseToolDefinitions = new Map(
2568 Object.entries(baseToolDefinitions).map(([name, tool]) => [name, tool as ToolDefinition]),
2569 );
2570
2571 const extensionsResult = this._resourceLoader.getExtensions();
2572 if (options.flagValues) {
2573 for (const [name, value] of options.flagValues) {
2574 extensionsResult.runtime.flagValues.set(name, value);
2575 }
2576 }
2577
2578 this._extensionRunner = new ExtensionRunner(
2579 extensionsResult.extensions,
2580 extensionsResult.runtime,
2581 this._cwd,
2582 this.sessionManager,
2583 new ModelRegistry(this._modelRuntime),
2584 );
2585 if (this._extensionRunnerRef) {
2586 this._extensionRunnerRef.current = this._extensionRunner;
2587 }
2588 this._bindExtensionCore(this._extensionRunner);
2589 this._applyExtensionBindings(this._extensionRunner);
2590
2591 const defaultActiveToolNames = this._baseToolsOverride
2592 ? Object.keys(this._baseToolsOverride)
2593 : ["read", "bash", "edit", "write"];
2594 const baseActiveToolNames = options.activeToolNames ?? defaultActiveToolNames;
2595 this._refreshToolRegistry({
2596 activeToolNames: baseActiveToolNames,
2597 includeAllExtensionTools: options.includeAllExtensionTools,
2598 });
2599 }
2600
2601 async reload(options?: { beforeSessionStart?: () => void | Promise<void> }): Promise<void> {
2602 const previousFlagValues = this._extensionRunner.getFlagValues();
2603 await emitSessionShutdownEvent(this._extensionRunner, { type: "session_shutdown", reason: "reload" });
2604 await this.settingsManager.reload();
2605 this.syncQueueModesFromSettings();
2606 resetApiProviders();
2607 await this._resourceLoader.reload();
2608 this._buildRuntime({
2609 activeToolNames: this.getActiveToolNames(),
2610 flagValues: previousFlagValues,
2611 includeAllExtensionTools: true,
2612 });
2613
2614 const hasBindings =
2615 this._extensionUIContext ||
2616 this._extensionCommandContextActions ||
2617 this._extensionShutdownHandler ||
2618 this._extensionErrorListener;
2619 if (hasBindings) {
2620 await options?.beforeSessionStart?.();
2621 await this._extensionRunner.emit({ type: "session_start", reason: "reload" });
2622 await this.extendResourcesFromExtensions("reload");
2623 }
2624 }
2625
2626 // =========================================================================
2627 // Auto-Retry
2628 // =========================================================================
2629
2630 /**
2631 * Check if an error is retryable (overloaded, rate limit, server errors).
2632 * Context overflow errors are NOT retryable (handled by compaction instead).
2633 */
2634 private _isRetryableError(message: AssistantMessage): boolean {
2635 // Context overflow is handled by compaction, not retry.
2636 if (isContextOverflow(message, this.model?.contextWindow ?? 0)) return false;
2637 return isRetryableAssistantError(message);
2638 }
2639
2640 /**
2641 * Retry policy + callbacks shared by compaction and branch-summary summarization calls.
2642 * Uses the same `settings.retry` budget/backoff as agent-turn retries so a single transient
2643 * stream drop no longer fails the whole operation. `source` carries the context
2644 * the TUI needs to render the retry and recreate the underlying indicator.
2645 */
2646 private _summarizationRetryCallbacks(
2647 source: { source: "branchSummary" } | { source: "compaction"; reason: "manual" | "threshold" | "overflow" },
2648 ): RetryCallbacks {
2649 return {
2650 onRetryScheduled: (attempt, maxAttempts, delayMs, errorMessage) => {
2651 this._emit({
2652 type: "summarization_retry_scheduled",
2653 attempt,
2654 maxAttempts,
2655 delayMs,
2656 errorMessage,
2657 });
2658 },
2659 onRetryAttemptStart: () => {
2660 this._emit({
2661 type: "summarization_retry_attempt_start",
2662 ...source,
2663 });
2664 },
2665 onRetryFinished: () => {
2666 this._emit({ type: "summarization_retry_finished" });
2667 },
2668 };
2669 }
2670
2671 /**
2672 * Prepare a retryable error for continuation with exponential backoff.
2673 * @returns true if the caller should continue the agent, false otherwise
2674 */
2675 private async _prepareRetry(message: AssistantMessage): Promise<boolean> {
2676 const settings = this.settingsManager.getRetrySettings();
2677 if (!settings.enabled) {
2678 return false;
2679 }
2680
2681 this._retryAttempt++;
2682
2683 if (this._retryAttempt > settings.maxRetries) {
2684 // Preserve the completed attempt count so post-run handling can emit the final failure.
2685 this._retryAttempt--;
2686 return false;
2687 }
2688
2689 const delayMs = settings.baseDelayMs * 2 ** (this._retryAttempt - 1);
2690
2691 this._emit({
2692 type: "auto_retry_start",
2693 attempt: this._retryAttempt,
2694 maxAttempts: settings.maxRetries,
2695 delayMs,
2696 errorMessage: message.errorMessage || "Unknown error",
2697 });
2698
2699 // Remove error message from agent state (keep in session for history)
2700 const messages = this.agent.state.messages;
2701 if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
2702 this.agent.state.messages = messages.slice(0, -1);
2703 }
2704
2705 // Wait with exponential backoff (abortable)
2706 this._retryAbortController = new AbortController();
2707 try {
2708 await sleep(delayMs, this._retryAbortController.signal);
2709 } catch {
2710 // Aborted during sleep - emit end event so UI can clean up
2711 const attempt = this._retryAttempt;
2712 this._retryAttempt = 0;
2713 this._emit({
2714 type: "auto_retry_end",
2715 success: false,
2716 attempt,
2717 finalError: "Retry cancelled",
2718 });
2719 return false;
2720 } finally {
2721 this._retryAbortController = undefined;
2722 }
2723
2724 return true;
2725 }
2726
2727 /**
2728 * Cancel in-progress retry.
2729 */
2730 abortRetry(): void {
2731 this._retryAbortController?.abort();
2732 }
2733
2734 /** Whether auto-retry is currently in progress */
2735 get isRetrying(): boolean {
2736 return this._retryAbortController !== undefined;
2737 }
2738
2739 /** Whether auto-retry is enabled */
2740 get autoRetryEnabled(): boolean {
2741 return this.settingsManager.getRetryEnabled();
2742 }
2743
2744 /**
2745 * Toggle auto-retry setting.
2746 */
2747 setAutoRetryEnabled(enabled: boolean): void {
2748 this.settingsManager.setRetryEnabled(enabled);
2749 }
2750
2751 // =========================================================================
2752 // Bash Execution
2753 // =========================================================================
2754
2755 /**
2756 * Execute a bash command.
2757 * Adds result to agent context and session.
2758 * @param command The bash command to execute
2759 * @param onChunk Optional streaming callback for output
2760 * @param options.excludeFromContext If true, command output won't be sent to LLM (!! prefix)
2761 * @param options.id Optional identifier included in bash execution update events
2762 * @param options.operations Custom BashOperations for remote execution
2763 */
2764 async executeBash(
2765 command: string,
2766 onChunk?: (chunk: string) => void,
2767 options?: { excludeFromContext?: boolean; id?: string; operations?: BashOperations },
2768 ): Promise<BashResult> {
2769 const abortController = new AbortController();
2770 this._bashAbortControllers.add(abortController);
2771
2772 // Apply command prefix if configured (e.g., "shopt -s expand_aliases" for alias support)
2773 const prefix = this.settingsManager.getShellCommandPrefix();
2774 const shellPath = this.settingsManager.getShellPath();
2775 const resolvedCommand = prefix ? `${prefix}\n${command}` : command;
2776
2777 try {
2778 const result = await executeBashWithOperations(
2779 resolvedCommand,
2780 this.sessionManager.getCwd(),
2781 options?.operations ?? createLocalBashOperations({ shellPath }),
2782 {
2783 onChunk: (delta) => {
2784 onChunk?.(delta);
2785 this._emit({ type: "bash_execution_update", id: options?.id, delta });
2786 },
2787 signal: abortController.signal,
2788 },
2789 );
2790
2791 this.recordBashResult(command, result, options);
2792 return result;
2793 } finally {
2794 this._bashAbortControllers.delete(abortController);
2795 }
2796 }
2797
2798 /**
2799 * Record a bash execution result in session history.
2800 * Used by executeBash and by extensions that handle bash execution themselves.
2801 */
2802 recordBashResult(command: string, result: BashResult, options?: { excludeFromContext?: boolean }): void {
2803 const bashMessage: BashExecutionMessage = {
2804 role: "bashExecution",
2805 command,
2806 output: result.output,
2807 exitCode: result.exitCode,
2808 cancelled: result.cancelled,
2809 truncated: result.truncated,
2810 fullOutputPath: result.fullOutputPath,
2811 timestamp: Date.now(),
2812 excludeFromContext: options?.excludeFromContext,
2813 };
2814
2815 // If agent is streaming, defer adding to avoid breaking tool_use/tool_result ordering
2816 if (this.isStreaming) {
2817 // Queue for later - will be flushed on agent_end
2818 this._pendingBashMessages.push(bashMessage);
2819 } else {
2820 // Add to agent state immediately
2821 this.agent.state.messages.push(bashMessage);
2822
2823 // Save to session
2824 this.sessionManager.appendMessage(bashMessage);
2825 }
2826 }
2827
2828 /**
2829 * Cancel running bash command.
2830 */
2831 abortBash(): void {
2832 for (const abortController of [...this._bashAbortControllers]) {
2833 abortController.abort();
2834 }
2835 }
2836
2837 /** Whether a bash command is currently running */
2838 get isBashRunning(): boolean {
2839 return this._bashAbortControllers.size > 0;
2840 }
2841
2842 /** Whether there are pending bash messages waiting to be flushed */
2843 get hasPendingBashMessages(): boolean {
2844 return this._pendingBashMessages.length > 0;
2845 }
2846
2847 /**
2848 * Flush pending bash messages to agent state and session.
2849 * Called after agent turn completes to maintain proper message ordering.
2850 */
2851 private _flushPendingBashMessages(): void {
2852 if (this._pendingBashMessages.length === 0) return;
2853
2854 for (const bashMessage of this._pendingBashMessages) {
2855 // Add to agent state
2856 this.agent.state.messages.push(bashMessage);
2857
2858 // Save to session
2859 this.sessionManager.appendMessage(bashMessage);
2860 }
2861
2862 this._pendingBashMessages = [];
2863 }
2864
2865 // =========================================================================
2866 // Session Management
2867 // =========================================================================
2868
2869 /**
2870 * Set a display name for the current session.
2871 */
2872 setSessionName(name: string): void {
2873 this.sessionManager.appendSessionInfo(name);
2874 const event = { type: "session_info_changed", name: this.sessionManager.getSessionName() } as const;
2875 this._emit(event);
2876 void this._extensionRunner.emit(event);
2877 }
2878
2879 // =========================================================================
2880 // Tree Navigation
2881 // =========================================================================
2882
2883 /**
2884 * Navigate to a different node in the session tree.
2885 * Unlike fork() which creates a new session file, this stays in the same file.
2886 *
2887 * @param targetId The entry ID to navigate to
2888 * @param options.summarize Whether user wants to summarize abandoned branch
2889 * @param options.customInstructions Custom instructions for summarizer
2890 * @param options.replaceInstructions If true, customInstructions replaces the default prompt
2891 * @param options.label Label to attach to the branch summary entry
2892 * @returns Result with editorText (if user message) and cancelled status
2893 */
2894 async navigateTree(
2895 targetId: string,
2896 options: { summarize?: boolean; customInstructions?: string; replaceInstructions?: boolean; label?: string } = {},
2897 ): Promise<{ editorText?: string; cancelled: boolean; aborted?: boolean; summaryEntry?: BranchSummaryEntry }> {
2898 const oldLeafId = this.sessionManager.getLeafId();
2899
2900 // No-op if already at target
2901 if (targetId === oldLeafId) {
2902 return { cancelled: false };
2903 }
2904
2905 // Model required for summarization
2906 if (options.summarize && !this.model) {
2907 throw new Error("No model available for summarization");
2908 }
2909
2910 const targetEntry = this.sessionManager.getEntry(targetId);
2911 if (!targetEntry) {
2912 throw new Error(`Entry ${targetId} not found`);
2913 }
2914
2915 // Collect entries to summarize (from old leaf to common ancestor)
2916 const { entries: entriesToSummarize, commonAncestorId } = collectEntriesForBranchSummary(
2917 this.sessionManager,
2918 oldLeafId,
2919 targetId,
2920 );
2921
2922 // Prepare event data - mutable so extensions can override
2923 let customInstructions = options.customInstructions;
2924 let replaceInstructions = options.replaceInstructions;
2925 let label = options.label;
2926
2927 const preparation: TreePreparation = {
2928 targetId,
2929 oldLeafId,
2930 commonAncestorId,
2931 entriesToSummarize,
2932 userWantsSummary: options.summarize ?? false,
2933 customInstructions,
2934 replaceInstructions,
2935 label,
2936 };
2937
2938 // Set up abort controller for summarization
2939 this._branchSummaryAbortController = new AbortController();
2940
2941 try {
2942 let extensionSummary: { summary: string; details?: unknown; usage?: Usage } | undefined;
2943 let fromExtension = false;
2944
2945 // Emit session_before_tree event
2946 if (this._extensionRunner.hasHandlers("session_before_tree")) {
2947 const result = (await this._extensionRunner.emit({
2948 type: "session_before_tree",
2949 preparation,
2950 signal: this._branchSummaryAbortController.signal,
2951 })) as SessionBeforeTreeResult | undefined;
2952
2953 if (result?.cancel) {
2954 return { cancelled: true };
2955 }
2956
2957 if (result?.summary && options.summarize) {
2958 extensionSummary = result.summary;
2959 fromExtension = true;
2960 }
2961
2962 // Allow extensions to override instructions and label
2963 if (result?.customInstructions !== undefined) {
2964 customInstructions = result.customInstructions;
2965 }
2966 if (result?.replaceInstructions !== undefined) {
2967 replaceInstructions = result.replaceInstructions;
2968 }
2969 if (result?.label !== undefined) {
2970 label = result.label;
2971 }
2972 }
2973
2974 // Run default summarizer if needed
2975 let summaryText: string | undefined;
2976 let summaryDetails: unknown;
2977 let summaryUsage: Usage | undefined;
2978 if (options.summarize && entriesToSummarize.length > 0 && !extensionSummary) {
2979 const model = this.model!;
2980 const { apiKey, headers, env } = await this._getSummarizationRequestAuth(model);
2981 const branchSummarySettings = this.settingsManager.getBranchSummarySettings();
2982 const result = await generateBranchSummary(entriesToSummarize, {
2983 model,
2984 apiKey,
2985 headers,
2986 env,
2987 signal: this._branchSummaryAbortController.signal,
2988 customInstructions,
2989 replaceInstructions,
2990 reserveTokens: branchSummarySettings.reserveTokens,
2991 streamFn: this.agent.streamFunction,
2992 retry: this.settingsManager.getRetrySettings(),
2993 callbacks: this._summarizationRetryCallbacks({ source: "branchSummary" }),
2994 });
2995 if (result.aborted) {
2996 return { cancelled: true, aborted: true };
2997 }
2998 if (result.error) {
2999 throw new Error(result.error);
3000 }
3001 summaryText = result.summary;
3002 summaryUsage = result.usage;
3003 summaryDetails = {
3004 readFiles: result.readFiles || [],
3005 modifiedFiles: result.modifiedFiles || [],
3006 };
3007 } else if (extensionSummary) {
3008 summaryText = extensionSummary.summary;
3009 summaryDetails = extensionSummary.details;
3010 summaryUsage = extensionSummary.usage;
3011 }
3012
3013 // Determine the new leaf position based on target type
3014 let newLeafId: string | null;
3015 let editorText: string | undefined;
3016
3017 if (targetEntry.type === "message" && targetEntry.message.role === "user") {
3018 // User message: leaf = parent (null if root), text goes to editor
3019 newLeafId = targetEntry.parentId;
3020 editorText = contentText(targetEntry.message.content, "");
3021 } else if (targetEntry.type === "custom_message") {
3022 // Custom message: leaf = parent (null if root), text goes to editor
3023 newLeafId = targetEntry.parentId;
3024 editorText = contentText(targetEntry.content, "");
3025 } else {
3026 // Non-user message: leaf = selected node
3027 newLeafId = targetId;
3028 }
3029
3030 // Switch leaf (with or without summary)
3031 // Summary is attached at the navigation target position (newLeafId), not the old branch
3032 let summaryEntry: BranchSummaryEntry | undefined;
3033 if (summaryText) {
3034 // Create summary at target position (can be null for root)
3035 const summaryId = this.sessionManager.branchWithSummary(
3036 newLeafId,
3037 summaryText,
3038 summaryDetails,
3039 fromExtension,
3040 summaryUsage,
3041 );
3042 summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry;
3043
3044 // Attach label to the summary entry
3045 if (label) {
3046 this.sessionManager.appendLabelChange(summaryId, label);
3047 }
3048 } else if (newLeafId === null) {
3049 // No summary, navigating to root - reset leaf
3050 this.sessionManager.resetLeaf();
3051 } else {
3052 // No summary, navigating to non-root
3053 this.sessionManager.branch(newLeafId);
3054 }
3055
3056 // Attach label to target entry when not summarizing (no summary entry to label)
3057 if (label && !summaryText) {
3058 this.sessionManager.appendLabelChange(targetId, label);
3059 }
3060
3061 // Update agent state
3062 const sessionContext = this.sessionManager.buildSessionContext();
3063 this.agent.state.messages = sessionContext.messages;
3064
3065 // Emit session_tree event
3066 await this._extensionRunner.emit({
3067 type: "session_tree",
3068 newLeafId: this.sessionManager.getLeafId(),
3069 oldLeafId,
3070 summaryEntry,
3071 fromExtension: summaryText ? fromExtension : undefined,
3072 });
3073
3074 // Emit to custom tools
3075
3076 return { editorText, cancelled: false, summaryEntry };
3077 } finally {
3078 this._branchSummaryAbortController = undefined;
3079 }
3080 }
3081
3082 /**
3083 * Get all user messages from session for fork selector.
3084 */
3085 getUserMessagesForForking(): Array<{ entryId: string; text: string }> {
3086 const entries = this.sessionManager.getEntries();
3087 const result: Array<{ entryId: string; text: string }> = [];
3088
3089 for (const entry of entries) {
3090 if (entry.type !== "message") continue;
3091 if (entry.message.role !== "user") continue;
3092
3093 const text = contentText(entry.message.content, "");
3094 if (text) {
3095 result.push({ entryId: entry.id, text });
3096 }
3097 }
3098
3099 return result;
3100 }
3101
3102 /**
3103 * Get session statistics. Aggregates over ALL session entries (including
3104 * history that was compacted away), so token/cost totals reflect what was
3105 * actually billed across the session.
3106 */
3107 getSessionStats(): SessionStats {
3108 let userMessages = 0;
3109 let assistantMessages = 0;
3110 let toolResults = 0;
3111 let totalMessages = 0;
3112 let toolCalls = 0;
3113 const usageTotals = createUsageTotals();
3114
3115 for (const entry of this.sessionManager.getEntries()) {
3116 if ((entry.type === "branch_summary" || entry.type === "compaction") && entry.usage) {
3117 addUsageToTotals(usageTotals, entry.usage);
3118 }
3119 if (entry.type !== "message") continue;
3120 totalMessages++;
3121 const message = entry.message;
3122 if (message.role === "user") {
3123 userMessages++;
3124 } else if (message.role === "toolResult") {
3125 toolResults++;
3126 if (message.usage) {
3127 addUsageToTotals(usageTotals, message.usage);
3128 }
3129 } else if (message.role === "assistant") {
3130 assistantMessages++;
3131 const assistantMsg = message as AssistantMessage;
3132 if (Array.isArray(assistantMsg.content)) {
3133 toolCalls += assistantMsg.content.filter((c) => c.type === "toolCall").length;
3134 }
3135 addUsageToTotals(usageTotals, assistantMsg.usage);
3136 }
3137 }
3138
3139 return {
3140 sessionFile: this.sessionFile,
3141 sessionId: this.sessionId,
3142 userMessages,
3143 assistantMessages,
3144 toolCalls,
3145 toolResults,
3146 totalMessages,
3147 tokens: {
3148 input: usageTotals.input,
3149 output: usageTotals.output,
3150 cacheRead: usageTotals.cacheRead,
3151 cacheWrite: usageTotals.cacheWrite,
3152 total: usageTotals.input + usageTotals.output + usageTotals.cacheRead + usageTotals.cacheWrite,
3153 },
3154 cost: usageTotals.cost,
3155 contextUsage: this.getContextUsage(),
3156 };
3157 }
3158
3159 getContextUsage(): ContextUsage | undefined {
3160 const model = this.model;
3161 if (!model) return undefined;
3162
3163 const contextWindow = model.contextWindow ?? 0;
3164 if (contextWindow <= 0) return undefined;
3165
3166 // After compaction, the last assistant usage reflects pre-compaction context size.
3167 // We can only trust usage from an assistant that responded after the latest compaction.
3168 // If no such assistant exists, context token count is unknown until the next LLM response.
3169 const branchEntries = this.sessionManager.getBranch();
3170 const latestCompaction = getLatestCompactionEntry(branchEntries);
3171
3172 if (latestCompaction) {
3173 // Check if there's a valid assistant usage after the compaction boundary
3174 const compactionIndex = branchEntries.lastIndexOf(latestCompaction);
3175 let hasPostCompactionUsage = false;
3176 for (let i = branchEntries.length - 1; i > compactionIndex; i--) {
3177 const entry = branchEntries[i];
3178 if (entry.type === "message" && entry.message.role === "assistant") {
3179 const assistant = entry.message;
3180 if (assistant.stopReason !== "aborted" && assistant.stopReason !== "error") {
3181 const contextTokens = calculateContextTokens(assistant.usage);
3182 if (contextTokens > 0) {
3183 hasPostCompactionUsage = true;
3184 break;
3185 }
3186 }
3187 }
3188 }
3189
3190 if (!hasPostCompactionUsage) {
3191 return { tokens: null, contextWindow, percent: null };
3192 }
3193 }
3194
3195 const estimate = estimateContextTokens(this.messages);
3196 const percent = (estimate.tokens / contextWindow) * 100;
3197
3198 return {
3199 tokens: estimate.tokens,
3200 contextWindow,
3201 percent,
3202 };
3203 }
3204
3205 /**
3206 * Export session to HTML.
3207 * @param outputPath Optional output path (defaults to session directory)
3208 * @returns Path to exported file
3209 */
3210 async exportToHtml(outputPath?: string): Promise<string> {
3211 const configuredThemeName = this.settingsManager.getTheme();
3212 const themeName = configuredThemeName && getThemeByName(configuredThemeName) ? configuredThemeName : undefined;
3213
3214 // Create tool renderer if we have an extension runner (for custom tool HTML rendering)
3215 const toolRenderer: ToolHtmlRenderer = createToolHtmlRenderer({
3216 getToolDefinition: (name) => this.getToolDefinition(name),
3217 theme,
3218 cwd: this.sessionManager.getCwd(),
3219 });
3220
3221 return await exportSessionToHtml(this.sessionManager, this.state, {
3222 outputPath,
3223 themeName,
3224 toolRenderer,
3225 });
3226 }
3227
3228 /**
3229 * Export the current session branch to a JSONL file.
3230 * Writes the session header followed by all entries on the current branch path.
3231 * @param outputPath Target file path. If omitted, generates a timestamped file in cwd.
3232 * @returns The resolved output file path.
3233 */
3234 exportToJsonl(outputPath?: string): string {
3235 const filePath = resolvePath(
3236 outputPath ?? `session-${new Date().toISOString().replace(/[:.]/g, "-")}.jsonl`,
3237 process.cwd(),
3238 );
3239 const dir = dirname(filePath);
3240 if (!existsSync(dir)) {
3241 mkdirSync(dir, { recursive: true });
3242 }
3243
3244 const header: SessionHeader = {
3245 type: "session",
3246 version: CURRENT_SESSION_VERSION,
3247 id: this.sessionManager.getSessionId(),
3248 timestamp: new Date().toISOString(),
3249 cwd: this.sessionManager.getCwd(),
3250 };
3251
3252 const branchEntries = this.sessionManager.getBranch();
3253 const lines = [JSON.stringify(header)];
3254
3255 // Re-chain parentIds to form a linear sequence
3256 let prevId: string | null = null;
3257 for (const entry of branchEntries) {
3258 const linear = { ...entry, parentId: prevId };
3259 lines.push(JSON.stringify(linear));
3260 prevId = entry.id;
3261 }
3262
3263 writeFileSync(filePath, `${lines.join("\n")}\n`);
3264 return filePath;
3265 }
3266
3267 // =========================================================================
3268 // Utilities
3269 // =========================================================================
3270
3271 /**
3272 * Get text content of last assistant message.
3273 * Useful for /copy command.
3274 * @returns Text content, or undefined if no assistant message exists
3275 */
3276 getLastAssistantText(): string | undefined {
3277 const lastAssistant = this.messages
3278 .slice()
3279 .reverse()
3280 .find((m) => {
3281 if (m.role !== "assistant") return false;
3282 const msg = m as AssistantMessage;
3283 // Skip aborted messages with no content
3284 if (msg.stopReason === "aborted" && msg.content.length === 0) return false;
3285 return true;
3286 });
3287
3288 if (!lastAssistant) return undefined;
3289
3290 let text = "";
3291 for (const content of (lastAssistant as AssistantMessage).content) {
3292 if (content.type === "text") {
3293 text += content.text;
3294 }
3295 }
3296
3297 return text.trim() || undefined;
3298 }
3299
3300 // =========================================================================
3301 // Extension System
3302 // =========================================================================
3303
3304 createReplacedSessionContext(): ReplacedSessionContext {
3305 const context = Object.defineProperties(
3306 {},
3307 Object.getOwnPropertyDescriptors(this._extensionRunner.createCommandContext()),
3308 ) as ReplacedSessionContext;
3309 context.sendMessage = (message, options) => this.sendCustomMessage(message, options);
3310 context.sendUserMessage = (content, options) => this.sendUserMessage(content, options);
3311 return context;
3312 }
3313
3314 /**
3315 * Check if extensions have handlers for a specific event type.
3316 */
3317 hasExtensionHandlers(eventType: string): boolean {
3318 return this._extensionRunner.hasHandlers(eventType);
3319 }
3320
3321 /**
3322 * Get the extension runner (for setting UI context and error handlers).
3323 */
3324 get extensionRunner(): ExtensionRunner {
3325 return this._extensionRunner;
3326 }
3327}
3328