UNPKG

@mariozechner/pi-coding-agent

Version:

Coding agent CLI with read, bash, edit, write tools and session management

1,184 lines 107 kB
/** * AgentSession - Core abstraction for agent lifecycle and session management. * * This class is shared between all run modes (interactive, print, rpc). * It encapsulates: * - Agent state access * - Event subscription with automatic session persistence * - Model and thinking level management * - Compaction (manual and auto) * - Bash execution * - Session switching and branching * * Modes use this class and add their own I/O layer on top. */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { basename, dirname, join, resolve } from "node:path"; import { isContextOverflow, modelsAreEqual, resetApiProviders, supportsXhigh } from "@mariozechner/pi-ai"; import { getDocsPath } from "../config.js"; import { theme } from "../modes/interactive/theme/theme.js"; import { stripFrontmatter } from "../utils/frontmatter.js"; import { sleep } from "../utils/sleep.js"; import { executeBashWithOperations } from "./bash-executor.js"; import { calculateContextTokens, collectEntriesForBranchSummary, compact, estimateContextTokens, generateBranchSummary, prepareCompaction, shouldCompact, } from "./compaction/index.js"; import { DEFAULT_THINKING_LEVEL } from "./defaults.js"; import { exportSessionToHtml } from "./export-html/index.js"; import { createToolHtmlRenderer } from "./export-html/tool-renderer.js"; import { ExtensionRunner, wrapRegisteredTools, } from "./extensions/index.js"; import { emitSessionShutdownEvent } from "./extensions/runner.js"; import { expandPromptTemplate } from "./prompt-templates.js"; import { CURRENT_SESSION_VERSION, getLatestCompactionEntry } from "./session-manager.js"; import { createSyntheticSourceInfo } from "./source-info.js"; import { buildSystemPrompt } from "./system-prompt.js"; import { createLocalBashOperations } from "./tools/bash.js"; import { createAllToolDefinitions } from "./tools/index.js"; import { createToolDefinitionFromAgentTool } from "./tools/tool-definition-wrapper.js"; /** * Parse a skill block from message text. * Returns null if the text doesn't contain a skill block. */ export function parseSkillBlock(text) { const match = text.match(/^<skill name="([^"]+)" location="([^"]+)">\n([\s\S]*?)\n<\/skill>(?:\n\n([\s\S]+))?$/); if (!match) return null; return { name: match[1], location: match[2], content: match[3], userMessage: match[4]?.trim() || undefined, }; } // ============================================================================ // Constants // ============================================================================ /** Standard thinking levels */ const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high"]; /** Thinking levels including xhigh (for supported models) */ const THINKING_LEVELS_WITH_XHIGH = ["off", "minimal", "low", "medium", "high", "xhigh"]; // ============================================================================ // AgentSession Class // ============================================================================ export class AgentSession { agent; sessionManager; settingsManager; _scopedModels; // Event subscription state _unsubscribeAgent; _eventListeners = []; _agentEventQueue = Promise.resolve(); /** Tracks pending steering messages for UI display. Removed when delivered. */ _steeringMessages = []; /** Tracks pending follow-up messages for UI display. Removed when delivered. */ _followUpMessages = []; /** Messages queued to be included with the next user prompt as context ("asides"). */ _pendingNextTurnMessages = []; // Compaction state _compactionAbortController = undefined; _autoCompactionAbortController = undefined; _overflowRecoveryAttempted = false; // Branch summarization state _branchSummaryAbortController = undefined; // Retry state _retryAbortController = undefined; _retryAttempt = 0; _retryPromise = undefined; _retryResolve = undefined; // Bash execution state _bashAbortController = undefined; _pendingBashMessages = []; // Extension system _extensionRunner; _turnIndex = 0; _resourceLoader; _customTools; _baseToolDefinitions = new Map(); _cwd; _extensionRunnerRef; _initialActiveToolNames; _allowedToolNames; _baseToolsOverride; _sessionStartEvent; _extensionUIContext; _extensionCommandContextActions; _extensionShutdownHandler; _extensionErrorListener; _extensionErrorUnsubscriber; // Model registry for API key resolution _modelRegistry; // Tool registry for extension getTools/setTools _toolRegistry = new Map(); _toolDefinitions = new Map(); _toolPromptSnippets = new Map(); _toolPromptGuidelines = new Map(); // Base system prompt (without extension appends) - used to apply fresh appends each turn _baseSystemPrompt = ""; _baseSystemPromptOptions; constructor(config) { this.agent = config.agent; this.sessionManager = config.sessionManager; this.settingsManager = config.settingsManager; this._scopedModels = config.scopedModels ?? []; this._resourceLoader = config.resourceLoader; this._customTools = config.customTools ?? []; this._cwd = config.cwd; this._modelRegistry = config.modelRegistry; this._extensionRunnerRef = config.extensionRunnerRef; this._initialActiveToolNames = config.initialActiveToolNames; this._allowedToolNames = config.allowedToolNames ? new Set(config.allowedToolNames) : undefined; this._baseToolsOverride = config.baseToolsOverride; this._sessionStartEvent = config.sessionStartEvent ?? { type: "session_start", reason: "startup" }; // Always subscribe to agent events for internal handling // (session persistence, extensions, auto-compaction, retry logic) this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent); this._installAgentToolHooks(); this._buildRuntime({ activeToolNames: this._initialActiveToolNames, includeAllExtensionTools: true, }); } /** Model registry for API key resolution and model discovery */ get modelRegistry() { return this._modelRegistry; } async _getRequiredRequestAuth(model) { const result = await this._modelRegistry.getApiKeyAndHeaders(model); if (!result.ok) { throw new Error(result.error); } if (result.apiKey) { return { apiKey: result.apiKey, headers: result.headers }; } const isOAuth = this._modelRegistry.isUsingOAuth(model); if (isOAuth) { throw new Error(`Authentication failed for "${model.provider}". ` + `Credentials may have expired or network is unavailable. ` + `Run '/login ${model.provider}' to re-authenticate.`); } throw new Error(`No API key found for ${model.provider}.\n\n` + `Use /login or set an API key environment variable. See ${join(getDocsPath(), "providers.md")}`); } /** * Install tool hooks once on the Agent instance. * * The callbacks read `this._extensionRunner` at execution time, so extension reload swaps in the * new runner without reinstalling hooks. Extension-specific tool wrappers are still used to adapt * registered tool execution to the extension context. Tool call and tool result interception now * happens here instead of in wrappers. */ _installAgentToolHooks() { this.agent.beforeToolCall = async ({ toolCall, args }) => { const runner = this._extensionRunner; if (!runner.hasHandlers("tool_call")) { return undefined; } await this._agentEventQueue; try { return await runner.emitToolCall({ type: "tool_call", toolName: toolCall.name, toolCallId: toolCall.id, input: args, }); } catch (err) { if (err instanceof Error) { throw err; } throw new Error(`Extension failed, blocking execution: ${String(err)}`); } }; this.agent.afterToolCall = async ({ toolCall, args, result, isError }) => { const runner = this._extensionRunner; if (!runner.hasHandlers("tool_result")) { return undefined; } const hookResult = await runner.emitToolResult({ type: "tool_result", toolName: toolCall.name, toolCallId: toolCall.id, input: args, content: result.content, details: result.details, isError, }); if (!hookResult) { return undefined; } return { content: hookResult.content, details: hookResult.details, isError: hookResult.isError ?? isError, }; }; } // ========================================================================= // Event Subscription // ========================================================================= /** Emit an event to all listeners */ _emit(event) { for (const l of this._eventListeners) { l(event); } } _emitQueueUpdate() { this._emit({ type: "queue_update", steering: [...this._steeringMessages], followUp: [...this._followUpMessages], }); } // Track last assistant message for auto-compaction check _lastAssistantMessage = undefined; /** Internal handler for agent events - shared by subscribe and reconnect */ _handleAgentEvent = (event) => { // Create retry promise synchronously before queueing async processing. // Agent.emit() calls this handler synchronously, and prompt() calls waitForRetry() // as soon as agent.prompt() resolves. If _retryPromise is created only inside // _processAgentEvent, slow earlier queued events can delay agent_end processing // and waitForRetry() can miss the in-flight retry. this._createRetryPromiseForAgentEnd(event); this._agentEventQueue = this._agentEventQueue.then(() => this._processAgentEvent(event), () => this._processAgentEvent(event)); // Keep queue alive if an event handler fails this._agentEventQueue.catch(() => { }); }; _createRetryPromiseForAgentEnd(event) { if (event.type !== "agent_end" || this._retryPromise) { return; } const settings = this.settingsManager.getRetrySettings(); if (!settings.enabled) { return; } const lastAssistant = this._findLastAssistantInMessages(event.messages); if (!lastAssistant || !this._isRetryableError(lastAssistant)) { return; } this._retryPromise = new Promise((resolve) => { this._retryResolve = resolve; }); } _findLastAssistantInMessages(messages) { for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i]; if (message.role === "assistant") { return message; } } return undefined; } async _processAgentEvent(event) { // When a user message starts, check if it's from either queue and remove it BEFORE emitting // This ensures the UI sees the updated queue state if (event.type === "message_start" && event.message.role === "user") { this._overflowRecoveryAttempted = false; const messageText = this._getUserMessageText(event.message); if (messageText) { // Check steering queue first const steeringIndex = this._steeringMessages.indexOf(messageText); if (steeringIndex !== -1) { this._steeringMessages.splice(steeringIndex, 1); this._emitQueueUpdate(); } else { // Check follow-up queue const followUpIndex = this._followUpMessages.indexOf(messageText); if (followUpIndex !== -1) { this._followUpMessages.splice(followUpIndex, 1); this._emitQueueUpdate(); } } } } // Emit to extensions first await this._emitExtensionEvent(event); // Notify all listeners this._emit(event); // Handle session persistence if (event.type === "message_end") { // Check if this is a custom message from extensions if (event.message.role === "custom") { // Persist as CustomMessageEntry this.sessionManager.appendCustomMessageEntry(event.message.customType, event.message.content, event.message.display, event.message.details); } else if (event.message.role === "user" || event.message.role === "assistant" || event.message.role === "toolResult") { // Regular LLM message - persist as SessionMessageEntry this.sessionManager.appendMessage(event.message); } // Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere // Track assistant message for auto-compaction (checked on agent_end) if (event.message.role === "assistant") { this._lastAssistantMessage = event.message; const assistantMsg = event.message; if (assistantMsg.stopReason !== "error") { this._overflowRecoveryAttempted = false; } // Reset retry counter immediately on successful assistant response // This prevents accumulation across multiple LLM calls within a turn if (assistantMsg.stopReason !== "error" && this._retryAttempt > 0) { this._emit({ type: "auto_retry_end", success: true, attempt: this._retryAttempt, }); this._retryAttempt = 0; } } } // Check auto-retry and auto-compaction after agent completes if (event.type === "agent_end" && this._lastAssistantMessage) { const msg = this._lastAssistantMessage; this._lastAssistantMessage = undefined; // Check for retryable errors first (overloaded, rate limit, server errors) if (this._isRetryableError(msg)) { const didRetry = await this._handleRetryableError(msg); if (didRetry) return; // Retry was initiated, don't proceed to compaction } this._resolveRetry(); await this._checkCompaction(msg); } } /** Resolve the pending retry promise */ _resolveRetry() { if (this._retryResolve) { this._retryResolve(); this._retryResolve = undefined; this._retryPromise = undefined; } } /** Extract text content from a message */ _getUserMessageText(message) { if (message.role !== "user") return ""; const content = message.content; if (typeof content === "string") return content; const textBlocks = content.filter((c) => c.type === "text"); return textBlocks.map((c) => c.text).join(""); } /** Find the last assistant message in agent state (including aborted ones) */ _findLastAssistantMessage() { const messages = this.agent.state.messages; for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; if (msg.role === "assistant") { return msg; } } return undefined; } /** Emit extension events based on agent events */ async _emitExtensionEvent(event) { if (event.type === "agent_start") { this._turnIndex = 0; await this._extensionRunner.emit({ type: "agent_start" }); } else if (event.type === "agent_end") { await this._extensionRunner.emit({ type: "agent_end", messages: event.messages }); } else if (event.type === "turn_start") { const extensionEvent = { type: "turn_start", turnIndex: this._turnIndex, timestamp: Date.now(), }; await this._extensionRunner.emit(extensionEvent); } else if (event.type === "turn_end") { const extensionEvent = { type: "turn_end", turnIndex: this._turnIndex, message: event.message, toolResults: event.toolResults, }; await this._extensionRunner.emit(extensionEvent); this._turnIndex++; } else if (event.type === "message_start") { const extensionEvent = { type: "message_start", message: event.message, }; await this._extensionRunner.emit(extensionEvent); } else if (event.type === "message_update") { const extensionEvent = { type: "message_update", message: event.message, assistantMessageEvent: event.assistantMessageEvent, }; await this._extensionRunner.emit(extensionEvent); } else if (event.type === "message_end") { const extensionEvent = { type: "message_end", message: event.message, }; await this._extensionRunner.emit(extensionEvent); } else if (event.type === "tool_execution_start") { const extensionEvent = { type: "tool_execution_start", toolCallId: event.toolCallId, toolName: event.toolName, args: event.args, }; await this._extensionRunner.emit(extensionEvent); } else if (event.type === "tool_execution_update") { const extensionEvent = { type: "tool_execution_update", toolCallId: event.toolCallId, toolName: event.toolName, args: event.args, partialResult: event.partialResult, }; await this._extensionRunner.emit(extensionEvent); } else if (event.type === "tool_execution_end") { const extensionEvent = { type: "tool_execution_end", toolCallId: event.toolCallId, toolName: event.toolName, result: event.result, isError: event.isError, }; await this._extensionRunner.emit(extensionEvent); } } /** * Subscribe to agent events. * Session persistence is handled internally (saves messages on message_end). * Multiple listeners can be added. Returns unsubscribe function for this listener. */ subscribe(listener) { this._eventListeners.push(listener); // Return unsubscribe function for this specific listener return () => { const index = this._eventListeners.indexOf(listener); if (index !== -1) { this._eventListeners.splice(index, 1); } }; } /** * Temporarily disconnect from agent events. * User listeners are preserved and will receive events again after resubscribe(). * Used internally during operations that need to pause event processing. */ _disconnectFromAgent() { if (this._unsubscribeAgent) { this._unsubscribeAgent(); this._unsubscribeAgent = undefined; } } /** * Reconnect to agent events after _disconnectFromAgent(). * Preserves all existing listeners. */ _reconnectToAgent() { if (this._unsubscribeAgent) return; // Already connected this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent); } /** * Remove all listeners and disconnect from agent. * Call this when completely done with the session. */ dispose() { this._disconnectFromAgent(); this._eventListeners = []; } // ========================================================================= // Read-only State Access // ========================================================================= /** Full agent state */ get state() { return this.agent.state; } /** Current model (may be undefined if not yet selected) */ get model() { return this.agent.state.model; } /** Current thinking level */ get thinkingLevel() { return this.agent.state.thinkingLevel; } /** Whether agent is currently streaming a response */ get isStreaming() { return this.agent.state.isStreaming; } /** Current effective system prompt (includes any per-turn extension modifications) */ get systemPrompt() { return this.agent.state.systemPrompt; } /** Current retry attempt (0 if not retrying) */ get retryAttempt() { return this._retryAttempt; } /** * Get the names of currently active tools. * Returns the names of tools currently set on the agent. */ getActiveToolNames() { return this.agent.state.tools.map((t) => t.name); } /** * Get all configured tools with name, description, parameter schema, and source metadata. */ getAllTools() { return Array.from(this._toolDefinitions.values()).map(({ definition, sourceInfo }) => ({ name: definition.name, description: definition.description, parameters: definition.parameters, sourceInfo, })); } getToolDefinition(name) { return this._toolDefinitions.get(name)?.definition; } /** * Set active tools by name. * Only tools in the registry can be enabled. Unknown tool names are ignored. * Also rebuilds the system prompt to reflect the new tool set. * Changes take effect on the next agent turn. */ setActiveToolsByName(toolNames) { const tools = []; const validToolNames = []; for (const name of toolNames) { const tool = this._toolRegistry.get(name); if (tool) { tools.push(tool); validToolNames.push(name); } } this.agent.state.tools = tools; // Rebuild base system prompt with new tool set this._baseSystemPrompt = this._rebuildSystemPrompt(validToolNames); this.agent.state.systemPrompt = this._baseSystemPrompt; } /** Whether compaction or branch summarization is currently running */ get isCompacting() { return (this._autoCompactionAbortController !== undefined || this._compactionAbortController !== undefined || this._branchSummaryAbortController !== undefined); } /** All messages including custom types like BashExecutionMessage */ get messages() { return this.agent.state.messages; } /** Current steering mode */ get steeringMode() { return this.agent.steeringMode; } /** Current follow-up mode */ get followUpMode() { return this.agent.followUpMode; } /** Current session file path, or undefined if sessions are disabled */ get sessionFile() { return this.sessionManager.getSessionFile(); } /** Current session ID */ get sessionId() { return this.sessionManager.getSessionId(); } /** Current session display name, if set */ get sessionName() { return this.sessionManager.getSessionName(); } /** Scoped models for cycling (from --models flag) */ get scopedModels() { return this._scopedModels; } /** Update scoped models for cycling */ setScopedModels(scopedModels) { this._scopedModels = scopedModels; } /** File-based prompt templates */ get promptTemplates() { return this._resourceLoader.getPrompts().prompts; } _normalizePromptSnippet(text) { if (!text) return undefined; const oneLine = text .replace(/[\r\n]+/g, " ") .replace(/\s+/g, " ") .trim(); return oneLine.length > 0 ? oneLine : undefined; } _normalizePromptGuidelines(guidelines) { if (!guidelines || guidelines.length === 0) { return []; } const unique = new Set(); for (const guideline of guidelines) { const normalized = guideline.trim(); if (normalized.length > 0) { unique.add(normalized); } } return Array.from(unique); } _rebuildSystemPrompt(toolNames) { const validToolNames = toolNames.filter((name) => this._toolRegistry.has(name)); const toolSnippets = {}; const promptGuidelines = []; for (const name of validToolNames) { const snippet = this._toolPromptSnippets.get(name); if (snippet) { toolSnippets[name] = snippet; } const toolGuidelines = this._toolPromptGuidelines.get(name); if (toolGuidelines) { promptGuidelines.push(...toolGuidelines); } } const loaderSystemPrompt = this._resourceLoader.getSystemPrompt(); const loaderAppendSystemPrompt = this._resourceLoader.getAppendSystemPrompt(); const appendSystemPrompt = loaderAppendSystemPrompt.length > 0 ? loaderAppendSystemPrompt.join("\n\n") : undefined; const loadedSkills = this._resourceLoader.getSkills().skills; const loadedContextFiles = this._resourceLoader.getAgentsFiles().agentsFiles; this._baseSystemPromptOptions = { cwd: this._cwd, skills: loadedSkills, contextFiles: loadedContextFiles, customPrompt: loaderSystemPrompt, appendSystemPrompt, selectedTools: validToolNames, toolSnippets, promptGuidelines, }; return buildSystemPrompt(this._baseSystemPromptOptions); } // ========================================================================= // Prompting // ========================================================================= /** * Send a prompt to the agent. * - Handles extension commands (registered via pi.registerCommand) immediately, even during streaming * - Expands file-based prompt templates by default * - During streaming, queues via steer() or followUp() based on streamingBehavior option * - Validates model and API key before sending (when not streaming) * @throws Error if streaming and no streamingBehavior specified * @throws Error if no model selected or no API key available (when not streaming) */ async prompt(text, options) { const expandPromptTemplates = options?.expandPromptTemplates ?? true; const preflightResult = options?.preflightResult; let messages; try { // Handle extension commands first (execute immediately, even during streaming) // Extension commands manage their own LLM interaction via pi.sendMessage() if (expandPromptTemplates && text.startsWith("/")) { const handled = await this._tryExecuteExtensionCommand(text); if (handled) { // Extension command executed, no prompt to send preflightResult?.(true); return; } } // Emit input event for extension interception (before skill/template expansion) let currentText = text; let currentImages = options?.images; if (this._extensionRunner.hasHandlers("input")) { const inputResult = await this._extensionRunner.emitInput(currentText, currentImages, options?.source ?? "interactive"); if (inputResult.action === "handled") { preflightResult?.(true); return; } if (inputResult.action === "transform") { currentText = inputResult.text; currentImages = inputResult.images ?? currentImages; } } // Expand skill commands (/skill:name args) and prompt templates (/template args) let expandedText = currentText; if (expandPromptTemplates) { expandedText = this._expandSkillCommand(expandedText); expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]); } // If streaming, queue via steer() or followUp() based on option if (this.isStreaming) { if (!options?.streamingBehavior) { throw new Error("Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message."); } if (options.streamingBehavior === "followUp") { await this._queueFollowUp(expandedText, currentImages); } else { await this._queueSteer(expandedText, currentImages); } preflightResult?.(true); return; } // Flush any pending bash messages before the new prompt this._flushPendingBashMessages(); // Validate model if (!this.model) { throw new Error("No model selected.\n\n" + `Use /login or set an API key environment variable. See ${join(getDocsPath(), "providers.md")}\n\n` + "Then use /model to select a model."); } if (!this._modelRegistry.hasConfiguredAuth(this.model)) { const isOAuth = this._modelRegistry.isUsingOAuth(this.model); if (isOAuth) { throw new Error(`Authentication failed for "${this.model.provider}". ` + `Credentials may have expired or network is unavailable. ` + `Run '/login ${this.model.provider}' to re-authenticate.`); } throw new Error(`No API key found for ${this.model.provider}.\n\n` + `Use /login or set an API key environment variable. See ${join(getDocsPath(), "providers.md")}`); } // Check if we need to compact before sending (catches aborted responses) const lastAssistant = this._findLastAssistantMessage(); if (lastAssistant) { await this._checkCompaction(lastAssistant, false); } // Build messages array (custom message if any, then user message) messages = []; // Add user message const userContent = [{ type: "text", text: expandedText }]; if (currentImages) { userContent.push(...currentImages); } messages.push({ role: "user", content: userContent, timestamp: Date.now(), }); // Inject any pending "nextTurn" messages as context alongside the user message for (const msg of this._pendingNextTurnMessages) { messages.push(msg); } this._pendingNextTurnMessages = []; // Emit before_agent_start extension event const result = await this._extensionRunner.emitBeforeAgentStart(expandedText, currentImages, this._baseSystemPrompt, this._baseSystemPromptOptions); // Add all custom messages from extensions if (result?.messages) { for (const msg of result.messages) { messages.push({ role: "custom", customType: msg.customType, content: msg.content, display: msg.display, details: msg.details, timestamp: Date.now(), }); } } // Apply extension-modified system prompt, or reset to base if (result?.systemPrompt) { this.agent.state.systemPrompt = result.systemPrompt; } else { // Ensure we're using the base prompt (in case previous turn had modifications) this.agent.state.systemPrompt = this._baseSystemPrompt; } } catch (error) { preflightResult?.(false); throw error; } if (!messages) { return; } preflightResult?.(true); await this.agent.prompt(messages); await this.waitForRetry(); } /** * Try to execute an extension command. Returns true if command was found and executed. */ async _tryExecuteExtensionCommand(text) { // Parse command name and args const spaceIndex = text.indexOf(" "); const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1); const command = this._extensionRunner.getCommand(commandName); if (!command) return false; // Get command context from extension runner (includes session control methods) const ctx = this._extensionRunner.createCommandContext(); try { await command.handler(args, ctx); return true; } catch (err) { // Emit error via extension runner this._extensionRunner.emitError({ extensionPath: `command:${commandName}`, event: "command", error: err instanceof Error ? err.message : String(err), }); return true; } } /** * Expand skill commands (/skill:name args) to their full content. * Returns the expanded text, or the original text if not a skill command or skill not found. * Emits errors via extension runner if file read fails. */ _expandSkillCommand(text) { if (!text.startsWith("/skill:")) return text; const spaceIndex = text.indexOf(" "); const skillName = spaceIndex === -1 ? text.slice(7) : text.slice(7, spaceIndex); const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim(); const skill = this.resourceLoader.getSkills().skills.find((s) => s.name === skillName); if (!skill) return text; // Unknown skill, pass through try { const content = readFileSync(skill.filePath, "utf-8"); const body = stripFrontmatter(content).trim(); const skillBlock = `<skill name="${skill.name}" location="${skill.filePath}">\nReferences are relative to ${skill.baseDir}.\n\n${body}\n</skill>`; return args ? `${skillBlock}\n\n${args}` : skillBlock; } catch (err) { // Emit error like extension commands do this._extensionRunner.emitError({ extensionPath: skill.filePath, event: "skill_expansion", error: err instanceof Error ? err.message : String(err), }); return text; // Return original on error } } /** * Queue a steering message while the agent is running. * Delivered after the current assistant turn finishes executing its tool calls, * before the next LLM call. * Expands skill commands and prompt templates. Errors on extension commands. * @param images Optional image attachments to include with the message * @throws Error if text is an extension command */ async steer(text, images) { // Check for extension commands (cannot be queued) if (text.startsWith("/")) { this._throwIfExtensionCommand(text); } // Expand skill commands and prompt templates let expandedText = this._expandSkillCommand(text); expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]); await this._queueSteer(expandedText, images); } /** * Queue a follow-up message to be processed after the agent finishes. * Delivered only when agent has no more tool calls or steering messages. * Expands skill commands and prompt templates. Errors on extension commands. * @param images Optional image attachments to include with the message * @throws Error if text is an extension command */ async followUp(text, images) { // Check for extension commands (cannot be queued) if (text.startsWith("/")) { this._throwIfExtensionCommand(text); } // Expand skill commands and prompt templates let expandedText = this._expandSkillCommand(text); expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]); await this._queueFollowUp(expandedText, images); } /** * Internal: Queue a steering message (already expanded, no extension command check). */ async _queueSteer(text, images) { this._steeringMessages.push(text); this._emitQueueUpdate(); const content = [{ type: "text", text }]; if (images) { content.push(...images); } this.agent.steer({ role: "user", content, timestamp: Date.now(), }); } /** * Internal: Queue a follow-up message (already expanded, no extension command check). */ async _queueFollowUp(text, images) { this._followUpMessages.push(text); this._emitQueueUpdate(); const content = [{ type: "text", text }]; if (images) { content.push(...images); } this.agent.followUp({ role: "user", content, timestamp: Date.now(), }); } /** * Throw an error if the text is an extension command. */ _throwIfExtensionCommand(text) { const spaceIndex = text.indexOf(" "); const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); const command = this._extensionRunner.getCommand(commandName); if (command) { throw new Error(`Extension command "/${commandName}" cannot be queued. Use prompt() or execute the command when not streaming.`); } } /** * Send a custom message to the session. Creates a CustomMessageEntry. * * Handles three cases: * - Streaming: queues message, processed when loop pulls from queue * - Not streaming + triggerTurn: appends to state/session, starts new turn * - Not streaming + no trigger: appends to state/session, no turn * * @param message Custom message with customType, content, display, details * @param options.triggerTurn If true and not streaming, triggers a new LLM turn * @param options.deliverAs Delivery mode: "steer", "followUp", or "nextTurn" */ async sendCustomMessage(message, options) { const appMessage = { role: "custom", customType: message.customType, content: message.content, display: message.display, details: message.details, timestamp: Date.now(), }; if (options?.deliverAs === "nextTurn") { this._pendingNextTurnMessages.push(appMessage); } else if (this.isStreaming) { if (options?.deliverAs === "followUp") { this.agent.followUp(appMessage); } else { this.agent.steer(appMessage); } } else if (options?.triggerTurn) { await this.agent.prompt(appMessage); } else { this.agent.state.messages.push(appMessage); this.sessionManager.appendCustomMessageEntry(message.customType, message.content, message.display, message.details); this._emit({ type: "message_start", message: appMessage }); this._emit({ type: "message_end", message: appMessage }); } } /** * Send a user message to the agent. Always triggers a turn. * When the agent is streaming, use deliverAs to specify how to queue the message. * * @param content User message content (string or content array) * @param options.deliverAs Delivery mode when streaming: "steer" or "followUp" */ async sendUserMessage(content, options) { // Normalize content to text string + optional images let text; let images; if (typeof content === "string") { text = content; } else { const textParts = []; images = []; for (const part of content) { if (part.type === "text") { textParts.push(part.text); } else { images.push(part); } } text = textParts.join("\n"); if (images.length === 0) images = undefined; } // Use prompt() with expandPromptTemplates: false to skip command handling and template expansion await this.prompt(text, { expandPromptTemplates: false, streamingBehavior: options?.deliverAs, images, source: "extension", }); } /** * Clear all queued messages and return them. * Useful for restoring to editor when user aborts. * @returns Object with steering and followUp arrays */ clearQueue() { const steering = [...this._steeringMessages]; const followUp = [...this._followUpMessages]; this._steeringMessages = []; this._followUpMessages = []; this.agent.clearAllQueues(); this._emitQueueUpdate(); return { steering, followUp }; } /** Number of pending messages (includes both steering and follow-up) */ get pendingMessageCount() { return this._steeringMessages.length + this._followUpMessages.length; } /** Get pending steering messages (read-only) */ getSteeringMessages() { return this._steeringMessages; } /** Get pending follow-up messages (read-only) */ getFollowUpMessages() { return this._followUpMessages; } get resourceLoader() { return this._resourceLoader; } /** * Abort current operation and wait for agent to become idle. */ async abort() { this.abortRetry(); this.agent.abort(); await this.agent.waitForIdle(); } // ========================================================================= // Model Management // ========================================================================= async _emitModelSelect(nextModel, previousModel, source) { if (modelsAreEqual(previousModel, nextModel)) return; await this._extensionRunner.emit({ type: "model_select", model: nextModel, previousModel, source, }); } /** * Set model directly. * Validates that auth is configured, saves to session and settings. * @throws Error if no auth is configured for the model */ async setModel(model) { if (!this._modelRegistry.hasConfiguredAuth(model)) { throw new Error(`No API key for ${model.provider}/${model.id}`); } const previousModel = this.model; const thinkingLevel = this._getThinkingLevelForModelSwitch(); this.agent.state.model = model; this.sessionManager.appendModelChange(model.provider, model.id); this.settingsManager.setDefaultModelAndProvider(model.provider, model.id); // Re-clamp thinking level for new model's capabilities this.setThinkingLevel(thinkingLevel); await this._emitModelSelect(model, previousModel, "set"); } /** * Cycle to next/previous model. * Uses scoped models (from --models flag) if available, otherwise all available models. * @param direction - "forward" (default) or "backward" * @returns The new model info, or undefined if only one model available */ async cycleModel(direction = "forward") { if (this._scopedModels.length > 0) { return this._cycleScopedModel(direction); } return this._cycleAvailableModel(direction); } async _cycleScopedModel(direction) { const scopedModels = this._scopedModels.filter((scoped) => this._modelRegistry.hasConfiguredAuth(scoped.model)); if (scopedModels.length <= 1) return undefined; const currentModel = this.model; let currentIndex = scopedModels.findIndex((sm) => modelsAreEqual(sm.model, currentModel)); if (currentIndex === -1) currentIndex = 0; const len = scopedModels.length; const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len; const next = scopedModels[nextIndex]; const thinkingLevel = this._getThinkingLevelForModelSwitch(next.thinkingLevel); // Apply model this.agent.state.model = next.model; this.sessionManager.appendModelChange(next.model.provider, next.model.id); this.settingsManager.setDefaultModelAndProvider(next.model.provider, next.model.id); // Apply thinking level. // - Explicit scoped model thinking level overrides current session level // - Undefined scoped model thinking level inherits the current session preference // setThinkingLevel clamps to model capabilities. this.setThinkingLevel(thinkingLevel); await this._emitModelSelect(next.model, currentModel, "cycle"); return { model: next.model, thinkingLevel: this.thinkingLevel, isScoped: true }; } async _cycleAvailableModel(direction) { const availableModels = await this._modelRegistry.getAvailable(); if (availableModels.length <= 1) return undefined; const currentModel = this.model; let currentIndex = availableModels.findIndex((m) => modelsAreEqual(m, currentModel)); if (currentIndex === -1) currentIndex = 0; const len = availableModels.length; const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len; const nextModel = availableModels[nextIndex]; const thinkingLevel = this._getThinkingLevelForModelSwitch(); this.agent.state.model = nextModel; this.sessionManager.appendModelChange(nextModel.provider, nextModel.id); this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id); // Re-clamp thinking level for new model's capabilities this.setThinkingLevel(thinkingLevel); await this._emitModelSelect(nextModel, currentModel, "cycle"); return { model: nextModel, thinkingLevel: this.thinkingLevel, isScoped: false }; } // ========================================================================= // Thinking Level Management // ========================================================================= /** * Set thinking level. * Clamps to model capabilities based on available thinking levels. * Saves to session and settings only if the level actually changes. */ setThinkingLevel(level) { const availableLevels = this.getAvailableThinkingLevels(); const effectiveLevel = availableLevels.includes(level) ? level : this._clampThinkingLevel(level, availableLevels); // Only persist if actually changing const isChanging = effectiveLevel !== this.agent.state.thinkingLevel; this.agent.state.thinkingLevel = effectiveLevel; if (isChanging) { this.sessionManager.appendThinkingLevelChange(effectiveLevel); if (this.supportsThinking() || effectiveLevel !== "off") { this.settingsManager.setDefaultThinkingLevel(effectiveLevel); } } } /** * Cycle to next thinking level. * @returns New level, or undefined if model doesn't support thinking */ cycleThinkingLevel() { if (!this.supportsThinking()) return undefined; const levels = this.getAvailableThinkingLevels(); const currentIndex = levels.indexOf(this.thinkingLevel); const nextIndex = (currentIndex + 1) % levels.length; const nextLevel = levels[nextIndex]; this.setThinkingLevel(nextLevel); return nextLevel; } /** * Get available thinking levels for current model. * The provider will clamp to what the specific model supports internally. */ getAvailableThinkingLevels() { if (!this.supportsThinking()) return ["off"]; return this.supportsXhighThinking() ? THINKING_LEVELS_WITH_XHIGH : THINKING_LEVELS; } /** * Check if current model supports xhigh thinking level. */ supportsXhighThinking() { return this.model ? supportsXhigh(this.model) : false; } /** * Check if curre