@mariozechner/pi-coding-agent
Version:
Coding agent CLI with read, bash, edit, write tools and session management
1,184 lines • 107 kB
JavaScript
/**
* 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