Files
oh-my-pi/packages/coding-agent/src/session/agent-session.ts
T
can1357 f874837d9c feat(coding-agent): added ultrathink keyword detection and prompt notice injection
- Added a new ultrathink mode module with standalone, case-insensitive detection, rainbow editor highlighting, and a hidden notice payload.
- Updated `CustomEditor` and the shared TUI `Editor` to support optional zero-width text decoration and apply the `ultrathink` styling during input rendering.
- Extended `AgentSession` prompt handling to append the hidden ultrathink notice after user turns in both streaming and non-streaming message flows, excluding synthetic messages.
2026-05-29 07:12:43 +02:00

8928 lines
317 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 * as crypto from "node:crypto";
import * as fs from "node:fs";
import * as path from "node:path";
import { scheduler } from "node:timers/promises";
import { isPromise } from "node:util/types";
import type { InMemorySnapshotStore } from "@oh-my-pi/hashline";
import {
type AfterToolCallContext,
type AfterToolCallResult,
type Agent,
AgentBusyError,
type AgentEvent,
type AgentMessage,
type AgentState,
type AgentTool,
AppendOnlyContextManager,
resolveTelemetry,
ThinkingLevel,
} from "@oh-my-pi/pi-agent-core";
import {
AUTO_HANDOFF_THRESHOLD_FOCUS,
CompactionCancelledError,
type CompactionPreparation,
type CompactionResult,
calculateContextTokens,
calculatePromptTokens,
collectEntriesForBranchSummary,
compact,
estimateTokens,
generateBranchSummary,
generateHandoff,
prepareCompaction,
type SummaryOptions,
shouldCompact,
} from "@oh-my-pi/pi-agent-core/compaction";
import { DEFAULT_PRUNE_CONFIG, pruneToolOutputs } from "@oh-my-pi/pi-agent-core/compaction/pruning";
import type {
AssistantMessage,
Context,
Effort,
ImageContent,
Message,
MessageAttribution,
Model,
ProviderSessionState,
ServiceTier,
SimpleStreamOptions,
TextContent,
ToolCall,
ToolChoice,
Usage,
UsageReport,
} from "@oh-my-pi/pi-ai";
import {
calculateRateLimitBackoffMs,
clearAnthropicFastModeFallback,
getSupportedEfforts,
isContextOverflow,
isUsageLimitError,
modelsAreEqual,
parseRateLimitReason,
resolveServiceTier,
streamSimple,
} from "@oh-my-pi/pi-ai";
import { MacOSPowerAssertion } from "@oh-my-pi/pi-natives";
import {
extractRetryHint,
getAgentDbPath,
isEnoent,
isUnexpectedSocketCloseMessage,
logger,
prompt,
Snowflake,
} from "@oh-my-pi/pi-utils";
import { type AsyncJob, type AsyncJobDeliveryState, AsyncJobManager } from "../async";
import { reset as resetCapabilities } from "../capability";
import type { Rule } from "../capability/rule";
import { MODEL_ROLE_IDS, type ModelRegistry } from "../config/model-registry";
import {
extractExplicitThinkingSelector,
formatModelSelectorValue,
formatModelString,
parseModelString,
type ResolvedModelRoleValue,
resolveModelRoleValue,
} from "../config/model-resolver";
import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-templates";
import type { Settings, SkillsSettings } from "../config/settings";
import { onAppendOnlyModeChanged } from "../config/settings";
import { RawSseDebugBuffer } from "../debug/raw-sse-buffer";
import { loadCapability } from "../discovery";
import { expandApplyPatchToEntries, normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit";
import { getFileSnapshotStore } from "../edit/file-snapshot-store";
import { namespaceSessionId as namespacePythonSessionId } from "../eval/py";
import {
disposeKernelSessionsByOwner,
executePython as executePythonCommand,
type PythonResult,
} from "../eval/py/executor";
import { defaultEvalSessionId } from "../eval/session-id";
import { type BashResult, executeBash as executeBashCommand } from "../exec/bash-executor";
import { exportSessionToHtml } from "../export/html";
import type { TtsrManager, TtsrMatchContext } from "../export/ttsr";
import type { LoadedCustomCommand } from "../extensibility/custom-commands";
import type { CustomTool, CustomToolContext } from "../extensibility/custom-tools/types";
import { CustomToolAdapter } from "../extensibility/custom-tools/wrapper";
import type {
ExtensionCommandContext,
ExtensionRunner,
ExtensionUIContext,
MessageEndEvent,
MessageStartEvent,
MessageUpdateEvent,
SessionBeforeBranchResult,
SessionBeforeCompactResult,
SessionBeforeSwitchResult,
SessionBeforeTreeResult,
ToolExecutionEndEvent,
ToolExecutionStartEvent,
ToolExecutionUpdateEvent,
TreePreparation,
TurnEndEvent,
TurnStartEvent,
} from "../extensibility/extensions";
import type { CompactOptions, ContextUsage } from "../extensibility/extensions/types";
import { ExtensionToolWrapper } from "../extensibility/extensions/wrapper";
import type { HookCommandContext } from "../extensibility/hooks/types";
import type { Skill, SkillWarning } from "../extensibility/skills";
import { expandSlashCommand, type FileSlashCommand } from "../extensibility/slash-commands";
import { GoalRuntime } from "../goals/runtime";
import type { Goal, GoalModeState } from "../goals/state";
import type { HindsightSessionState } from "../hindsight/state";
import { type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
import { resolveMemoryBackend } from "../memory-backend";
import { getCurrentThemeName, theme } from "../modes/theme/theme";
import { containsUltrathink, ULTRATHINK_NOTICE } from "../modes/ultrathink";
import type { PlanModeState } from "../plan-mode/state";
import autoContinuePrompt from "../prompts/system/auto-continue.md" with { type: "text" };
import eagerTodoPrompt from "../prompts/system/eager-todo.md" with { type: "text" };
import ircIncomingTemplate from "../prompts/system/irc-incoming.md" with { type: "text" };
import planModeActivePrompt from "../prompts/system/plan-mode-active.md" with { type: "text" };
import planModeReferencePrompt from "../prompts/system/plan-mode-reference.md" with { type: "text" };
import planModeToolDecisionReminderPrompt from "../prompts/system/plan-mode-tool-decision-reminder.md" with {
type: "text",
};
import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" };
import ttsrToolReminderTemplate from "../prompts/system/ttsr-tool-reminder.md" with { type: "text" };
import { type AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry";
import { deobfuscateSessionContext, type SecretObfuscator } from "../secrets/obfuscator";
import { invalidateHostMetadata } from "../ssh/connection-manager";
import { resolveThinkingLevelForModel, toReasoningEffort } from "../thinking";
import {
buildDiscoverableToolSearchIndex,
collectDiscoverableTools,
type DiscoverableTool,
type DiscoverableToolSearchIndex,
filterBySource,
isMCPToolName,
selectDiscoverableToolNamesByServer,
} from "../tool-discovery/tool-index";
import { assertEditableFile } from "../tools/auto-generated-guard";
import type { CheckpointState } from "../tools/checkpoint";
import { outputMeta } from "../tools/output-meta";
import { normalizeLocalScheme, resolveToCwd } from "../tools/path-utils";
import { isAutoQaEnabled } from "../tools/report-tool-issue";
import { getLatestTodoPhasesFromEntries, type TodoItem, type TodoPhase } from "../tools/todo-write";
import { ToolAbortError, ToolError } from "../tools/tool-errors";
import { clampTimeout } from "../tools/tool-timeouts";
import { parseCommandArgs } from "../utils/command-args";
import { type EditMode, resolveEditMode } from "../utils/edit-mode";
import { resolveFileDisplayMode } from "../utils/file-display-mode";
import { extractFileMentions, generateFileMentionMessages } from "../utils/file-mentions";
import { buildNamedToolChoice } from "../utils/tool-choice";
import type { AuthStorage } from "./auth-storage";
import type { ClientBridge, ClientBridgePermissionOption, ClientBridgePermissionOutcome } from "./client-bridge";
import {
type BashExecutionMessage,
type CompactionSummaryMessage,
type CustomMessage,
convertToLlm,
type FileMentionMessage,
type PythonExecutionMessage,
readPendingDisplayTag,
SILENT_ABORT_MARKER,
stripImagesFromMessage,
} from "./messages";
import { formatSessionDumpText } from "./session-dump-format";
import type {
BranchSummaryEntry,
CompactionEntry,
NewSessionOptions,
SessionContext,
SessionManager,
} from "./session-manager";
import { getLatestCompactionEntry } from "./session-manager";
import { ToolChoiceQueue } from "./tool-choice-queue";
import { YieldQueue } from "./yield-queue";
/** Session-specific events that extend the core AgentEvent */
export type AgentSessionEvent =
| AgentEvent
| {
type: "auto_compaction_start";
reason: "threshold" | "overflow" | "idle" | "incomplete";
action: "context-full" | "handoff";
}
| {
type: "auto_compaction_end";
action: "context-full" | "handoff";
result: CompactionResult | undefined;
aborted: boolean;
willRetry: boolean;
errorMessage?: string;
/** True when compaction was skipped for a benign reason (no model, no candidates, nothing to compact). */
skipped?: boolean;
}
| { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string }
| { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }
| { type: "retry_fallback_applied"; from: string; to: string; role: string }
| { type: "retry_fallback_succeeded"; model: string; role: string }
| { type: "ttsr_triggered"; rules: Rule[] }
| { type: "todo_reminder"; todos: TodoItem[]; attempt: number; maxAttempts: number }
| { type: "todo_auto_clear" }
| { type: "irc_message"; message: CustomMessage }
| { type: "notice"; level: "info" | "warning" | "error"; message: string; source?: string }
| { type: "thinking_level_changed"; thinkingLevel: ThinkingLevel | undefined }
| { type: "goal_updated"; goal: Goal | null; state?: GoalModeState };
/** Listener function for agent session events */
export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
export type AsyncJobSnapshotItem = Pick<AsyncJob, "id" | "type" | "status" | "label" | "startTime">;
export interface AsyncJobSnapshot {
running: AsyncJobSnapshotItem[];
recent: AsyncJobSnapshotItem[];
delivery: AsyncJobDeliveryState;
}
// ============================================================================
// Types
// ============================================================================
export interface AgentSessionConfig {
agent: Agent;
sessionManager: SessionManager;
settings: Settings;
/** Models to cycle through with Ctrl+P (from --models flag) */
scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>;
/** Initial session thinking selector. */
thinkingLevel?: ThinkingLevel;
/** Prompt templates for expansion */
promptTemplates?: PromptTemplate[];
/** File-based slash commands for expansion */
slashCommands?: FileSlashCommand[];
/** Extension runner (created in main.ts with wrapped tools) */
extensionRunner?: ExtensionRunner;
/** Loaded skills (already discovered by SDK) */
skills?: Skill[];
/** Skill loading warnings (already captured by SDK) */
skillWarnings?: SkillWarning[];
/** Custom commands (TypeScript slash commands) */
customCommands?: LoadedCustomCommand[];
skillsSettings?: SkillsSettings;
/** Model registry for API key resolution and model discovery */
modelRegistry: ModelRegistry;
/** Tool registry for LSP and settings */
toolRegistry?: Map<string, AgentTool>;
/** Current session pre-LLM message transform pipeline */
transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => AgentMessage[] | Promise<AgentMessage[]>;
/** Provider payload hook used by the active session request path */
onPayload?: SimpleStreamOptions["onPayload"];
/** Provider response hook used by the active session request path */
onResponse?: SimpleStreamOptions["onResponse"];
/** Raw SSE hook used by the active session request path */
onSseEvent?: SimpleStreamOptions["onSseEvent"];
/** Per-session raw SSE diagnostic buffer */
rawSseDebugBuffer?: RawSseDebugBuffer;
/** Current session message-to-LLM conversion pipeline */
convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
/** System prompt builder that can consider tool availability. Returns ordered provider-facing blocks. */
rebuildSystemPrompt?: (toolNames: string[], tools: Map<string, AgentTool>) => Promise<{ systemPrompt: string[] }>;
/** Rebuild the SSH tool from current capability discovery results. */
reloadSshTool?: () => Promise<AgentTool | null>;
requestedToolNames?: ReadonlySet<string>;
/**
* Optional accessor for live MCP server instructions. Read by the session's
* `rebuildSystemPrompt`-skip optimization to detect server-side instruction
* changes (e.g. an MCP server upgrade) that would otherwise pass the tool-set
* signature comparison and silently keep a stale prompt cached.
*/
getMcpServerInstructions?: () => Map<string, string> | undefined;
/** Enable hidden-by-default MCP tool discovery for this session. */
mcpDiscoveryEnabled?: boolean;
/** MCP tool names to activate for the current session when discovery mode is enabled. */
initialSelectedMCPToolNames?: string[];
/** Whether constructor-provided MCP defaults should be persisted immediately. */
persistInitialMCPToolSelection?: boolean;
/** MCP server names whose tools should seed discovery-mode sessions whenever those servers are connected. */
defaultSelectedMCPServerNames?: string[];
/** MCP tool names that should seed brand-new sessions created from this AgentSession. */
defaultSelectedMCPToolNames?: string[];
/** TTSR manager for time-traveling stream rules */
ttsrManager?: TtsrManager;
/** Secret obfuscator for deobfuscating streaming edit content */
obfuscator?: SecretObfuscator;
/** Inherited eval executor session id from a parent agent. */
parentEvalSessionId?: string;
/** Logical owner for retained Python kernels created by this session. */
evalKernelOwnerId?: string;
/**
* AsyncJobManager that this session installed as the process-global instance.
* Only set for top-level sessions; subagents inherit the parent's manager and
* **MUST NOT** dispose it on their own teardown.
*/
ownedAsyncJobManager?: AsyncJobManager;
/** Agent identity (registry id like "0-Main" or "3-Alice") used for IRC routing. */
agentId?: string;
/** Shared agent registry (for forwarding IRC observations to the main session UI). */
agentRegistry?: AgentRegistry;
/**
* Override the provider-facing session ID for all API requests from this session.
* When absent, `sessionManager.getSessionId()` is used. Needed when benchmark or
* SDK callers issue probes / prewarming with an explicit `--provider-session-id`
* so that credential sticky selection is consistent with the session's streaming calls.
*/
providerSessionId?: string;
}
/** Options for AgentSession.prompt() */
export interface PromptOptions {
/** Whether to expand file-based prompt templates (default: true) */
expandPromptTemplates?: boolean;
/** Image attachments */
images?: ImageContent[];
/** When streaming, how to queue the message: "steer" (interrupt) or "followUp" (wait). */
streamingBehavior?: "steer" | "followUp";
/** Optional tool choice override for the next LLM call. */
toolChoice?: ToolChoice;
/** Send as developer/system message instead of user. Providers that support it use the developer role; others fall back to user. */
synthetic?: boolean;
/** Explicit billing/initiator attribution for the prompt. Defaults to user prompts as `user` and synthetic prompts as `agent`. */
attribution?: MessageAttribution;
/** Skip pre-send compaction checks for this prompt (internal use for maintenance flows). */
skipCompactionCheck?: boolean;
}
/** Result from a handoff operation. */
export interface HandoffResult {
document: string;
savedPath?: string;
}
export interface SessionHandoffOptions {
autoTriggered?: boolean;
signal?: AbortSignal;
}
/** Result from cycleModel() */
export interface ModelCycleResult {
model: Model;
thinkingLevel: ThinkingLevel | undefined;
/** Whether cycling through scoped models (--models flag) or all available */
isScoped: boolean;
}
/** Result from cycleRoleModels() */
export interface RoleModelCycleResult {
model: Model;
thinkingLevel: ThinkingLevel | undefined;
role: string;
}
/** Session statistics for /session command */
export interface SessionStats {
sessionFile: string | undefined;
sessionId: string;
userMessages: number;
assistantMessages: number;
toolCalls: number;
toolResults: number;
totalMessages: number;
tokens: {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
total: number;
};
premiumRequests: number;
cost: number;
}
/** Internal marker for hook messages queued through the agent loop */
// ============================================================================
// Constants
// ============================================================================
/** Standard thinking levels */
type RetryFallbackChains = Record<string, string[]>;
type RetryFallbackRevertPolicy = "never" | "cooldown-expiry";
interface RetryFallbackSelector {
raw: string;
provider: string;
id: string;
thinkingLevel: ThinkingLevel | undefined;
}
interface ActiveRetryFallbackState {
role: string;
originalSelector: string;
originalThinkingLevel: ThinkingLevel | undefined;
lastAppliedFallbackThinkingLevel: ThinkingLevel | undefined;
}
function parseRetryFallbackSelector(selector: string): RetryFallbackSelector | undefined {
const trimmed = selector.trim();
if (!trimmed) return undefined;
const parsed = parseModelString(trimmed);
if (!parsed) return undefined;
return {
raw: trimmed,
provider: parsed.provider,
id: parsed.id,
thinkingLevel: parsed.thinkingLevel,
};
}
function formatRetryFallbackSelector(model: Model, thinkingLevel: ThinkingLevel | undefined): string {
const selector = formatModelString(model);
return thinkingLevel ? `${selector}:${thinkingLevel}` : selector;
}
function formatRetryFallbackBaseSelector(selector: RetryFallbackSelector): string {
return `${selector.provider}/${selector.id}`;
}
const IRC_REPLY_MAX_BYTES = 4096;
/**
* Collapse degenerate IRC ephemeral replies before they hit the relay.
* Models occasionally loop on a single line (~16 reports of N-times-repeated
* replies); compress runs longer than 3 down to one instance + `[…N×]`, then
* cap at 4 KiB so a runaway reply can't flood the channel.
*/
function dedupeIrcReply(text: string): string {
if (!text) return text;
const lines = text.split("\n");
const out: string[] = [];
let i = 0;
while (i < lines.length) {
let j = i + 1;
while (j < lines.length && lines[j] === lines[i]) j++;
const runLen = j - i;
if (runLen > 3) {
out.push(lines[i], `[…${runLen}×]`);
} else {
for (let k = 0; k < runLen; k++) out.push(lines[i]);
}
i = j;
}
let result = out.join("\n");
if (Buffer.byteLength(result, "utf8") > IRC_REPLY_MAX_BYTES) {
// Trim by characters until we're under the byte budget — handles multi-byte
// glyphs at the boundary without splitting them.
const suffix = "\n[…truncated]";
const budget = IRC_REPLY_MAX_BYTES - Buffer.byteLength(suffix, "utf8");
while (Buffer.byteLength(result, "utf8") > budget) {
result = result.slice(0, -1);
}
result += suffix;
}
return result;
}
/**
* Build the per-request `metadata` payload for the Anthropic provider, shaped
* like real Claude Code's `getAPIMetadata` output (`{ session_id, account_uuid,
* device_id }`) so the backend buckets requests under one session and attributes
* them to the authenticated OAuth account when available. Resolved at request
* time so token refreshes and login/logout transitions don't strand a stale
* account UUID in memory. `account_uuid` and `device_id` are omitted for
* non-Anthropic providers to avoid leaking the user's Claude identity to
* third-party APIs (including Anthropic-format-compatible proxies such as
* cloudflare-ai-gateway or gitlab-duo).
*
* `provider` is the target provider string (e.g. `"anthropic"`) and gates the
* `account_uuid` and `device_id` lookups — only `"anthropic"` requests carry them.
*
* `sessionId` is forwarded to the auth-storage session-sticky lookup so that
* multi-credential setups attribute to the same OAuth account used for the
* actual API request rather than always picking the first credential.
*
* `authStorage` is treated as optional so test fixtures that stub `modelRegistry`
* without a real storage layer still work; the resolver simply skips the lookup
* and emits `{ session_id }` alone, matching the no-OAuth-credential path.
*/
function buildSessionMetadata(
sessionId: string,
provider: string,
authStorage: AuthStorage | undefined,
): Record<string, unknown> {
const userId: Record<string, string> = { session_id: sessionId };
// Only look up account_uuid when the request is going to Anthropic. Injecting
// a Claude OAuth account_uuid into requests bound for other providers (including
// Anthropic-format-compatible proxies like cloudflare-ai-gateway or gitlab-duo)
// would leak the user's Anthropic identity to unrelated third-party APIs.
if (provider === "anthropic") {
const accountUuid = authStorage?.getOAuthAccountId("anthropic", sessionId);
if (typeof accountUuid === "string" && accountUuid.length > 0) {
userId.account_uuid = accountUuid;
// Derive device_id from account_uuid so the payload matches the real CC
// getAPIMetadata shape without hardware fingerprinting. A SHA-256 of a
// namespaced account UUID produces a stable 64-hex value that is
// indistinguishable from a randomly generated device ID on the wire, is
// deterministic per account (survives reinstalls), and is auditable: it
// is derived solely from the OAuth UUID the user already consented to
// share with Anthropic. Omitted when no OAuth credential is available
// (API-key callers) to avoid sending a hash of an empty string.
userId.device_id = crypto.createHash("sha256").update(`omp-device-id-v1:${accountUuid}`).digest("hex");
}
}
return { user_id: JSON.stringify(userId) };
}
const noOpUIContext: ExtensionUIContext = {
select: async (_title, _options, _dialogOptions) => undefined,
confirm: async (_title, _message, _dialogOptions) => false,
input: async (_title, _placeholder, _dialogOptions) => undefined,
notify: () => {},
onTerminalInput: () => () => {},
setStatus: () => {},
setWorkingMessage: () => {},
setWidget: () => {},
setTitle: () => {},
custom: async () => undefined as never,
setEditorText: () => {},
pasteToEditor: () => {},
getEditorText: () => "",
editor: async () => undefined,
get theme() {
return theme;
},
getAllThemes: () => Promise.resolve([]),
getTheme: () => Promise.resolve(undefined),
setTheme: _theme => Promise.resolve({ success: false, error: "UI not available" }),
setFooter: () => {},
setHeader: () => {},
setEditorComponent: () => {},
getToolsExpanded: () => false,
setToolsExpanded: () => {},
};
function createHandoffContext(document: string): string {
return `<handoff-context>\n${document}\n</handoff-context>\n\nThe above is a handoff document from a previous session. Use this context to continue the work seamlessly.`;
}
function createHandoffFileName(date = new Date()): string {
const fileTimestamp = date.toISOString().replace(/[:.]/g, "-");
return `handoff-${fileTimestamp}.md`;
}
// ============================================================================
// ACP Permission Gate
// ============================================================================
/** Tools that require user permission before execution when an ACP client is connected. */
const PERMISSION_REQUIRED_TOOLS = new Set(["bash", "edit", "delete", "move"]);
/** Permission options presented to the client on each gated tool call. */
const PERMISSION_OPTIONS: ClientBridgePermissionOption[] = [
{ optionId: "allow_once", name: "Allow once", kind: "allow_once" },
{ optionId: "allow_always", name: "Always allow", kind: "allow_always" },
{ optionId: "reject_once", name: "Reject", kind: "reject_once" },
{ optionId: "reject_always", name: "Always reject", kind: "reject_always" },
];
const PERMISSION_OPTIONS_BY_ID = new Map(PERMISSION_OPTIONS.map(option => [option.optionId, option]));
function getStringProperty(value: Record<string, unknown>, key: string): string | undefined {
const candidate = value[key];
return typeof candidate === "string" ? candidate : undefined;
}
function collectStringPaths(value: unknown): string[] {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
}
function getEditDestructiveIntent(args: unknown): { kind: "delete" | "move"; paths: string[] } | undefined {
if (!args || typeof args !== "object" || Array.isArray(args)) return undefined;
const a = args as Record<string, unknown>;
const edits = Array.isArray(a.edits) ? a.edits : undefined;
if (edits) {
const path = getStringProperty(a, "path");
if (path) {
for (const edit of edits) {
if (!edit || typeof edit !== "object" || Array.isArray(edit)) continue;
const op = getStringProperty(edit as Record<string, unknown>, "op");
if (op === "delete") return { kind: "delete", paths: [path] };
}
}
for (const edit of edits) {
if (!edit || typeof edit !== "object" || Array.isArray(edit)) continue;
const entry = edit as Record<string, unknown>;
const op = getStringProperty(entry, "op");
const rename = getStringProperty(entry, "rename");
if (op !== "create" && rename) return { kind: "move", paths: path ? [path, rename] : [rename] };
}
}
const input = getStringProperty(a, "input");
if (input) {
try {
const entries = expandApplyPatchToEntries({ input });
const deleteEntry = entries.find(entry => entry.op === "delete");
if (deleteEntry) return { kind: "delete", paths: [deleteEntry.path] };
const moveEntry = entries.find(entry => entry.rename);
if (moveEntry?.rename) return { kind: "move", paths: [moveEntry.path, moveEntry.rename] };
} catch {
// If the edit input is not an apply_patch envelope, it is not a delete/move operation.
}
}
return undefined;
}
function getPermissionIntent(
toolName: string,
args: unknown,
): { toolName: string; title: string; paths?: string[]; cacheKey: string } | undefined {
const a = args && typeof args === "object" && !Array.isArray(args) ? (args as Record<string, unknown>) : {};
if (toolName === "bash") {
const cmd = getStringProperty(a, "command")?.slice(0, 80);
return { toolName, title: cmd || toolName, cacheKey: toolName };
}
if (toolName === "delete") {
const p = getStringProperty(a, "path");
return { toolName, title: p ? `Delete ${p}` : toolName, paths: p ? [p] : undefined, cacheKey: toolName };
}
if (toolName === "move") {
const from = getStringProperty(a, "oldPath") ?? getStringProperty(a, "path") ?? getStringProperty(a, "from");
const to = getStringProperty(a, "newPath") ?? getStringProperty(a, "to") ?? getStringProperty(a, "destination");
if (from && to) return { toolName, title: `Move ${from} to ${to}`, paths: [from, to], cacheKey: toolName };
return {
toolName,
title: from ? `Move ${from}` : toolName,
paths: from ? [from] : undefined,
cacheKey: toolName,
};
}
if (toolName === "edit") {
const intent = getEditDestructiveIntent(args);
if (!intent) return undefined;
if (intent.kind === "delete") {
return {
toolName,
title: `Delete ${intent.paths[0] ?? "edit target"}`,
paths: intent.paths,
cacheKey: "edit:delete",
};
}
const from = intent.paths[0];
const to = intent.paths[1];
return {
toolName,
title: from && to ? `Move ${from} to ${to}` : `Move ${from ?? to ?? "edit target"}`,
paths: intent.paths,
cacheKey: "edit:move",
};
}
return undefined;
}
function extractPermissionLocations(
args: unknown,
cwd: string,
explicitPaths?: string[],
): { path: string; line?: number }[] {
if (!args || typeof args !== "object") return [];
const a = args as Record<string, unknown>;
const out: { path: string; line?: number }[] = [];
const pushPath = (value: unknown) => {
if (typeof value !== "string" || value.length === 0) return;
// ACP locations carry file paths that the editor host will open or focus;
// they must be absolute or the client cannot resolve them. Resolve raw
// tool args (often cwd-relative) against the session cwd before sending.
let resolved: string;
try {
resolved = resolveToCwd(value, cwd);
} catch {
return;
}
if (out.some(location => location.path === resolved)) return;
out.push({ path: resolved });
};
if (explicitPaths) {
for (const p of explicitPaths) {
pushPath(p);
}
return out;
}
pushPath(a.path);
pushPath(a.file);
for (const p of collectStringPaths(a.paths)) {
pushPath(p);
}
pushPath(a.oldPath);
pushPath(a.newPath);
pushPath(a.from);
pushPath(a.to);
pushPath(a.source);
pushPath(a.destination);
return out;
}
// ============================================================================
// AgentSession Class
// ============================================================================
/** Internal record stored in the steering/followUp display queues. The optional
* `tag` is set only by `enqueueCustomMessageDisplay` (used for skill-prompt
* custom messages queued during streaming) and is matched by the custom-role
* `message_start` dequeue branch; user-message pushes leave it undefined and
* rely on the existing text-equality match. */
type QueuedDisplayEntry = { text: string; tag?: string };
export class AgentSession {
readonly agent: Agent;
readonly sessionManager: SessionManager;
readonly settings: Settings;
readonly yieldQueue: YieldQueue;
fileSnapshotStore?: InMemorySnapshotStore;
#powerAssertion: MacOSPowerAssertion | undefined;
readonly configWarnings: string[] = [];
#scopedModels: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>;
#thinkingLevel: ThinkingLevel | undefined;
#promptTemplates: PromptTemplate[];
#slashCommands: FileSlashCommand[];
// Event subscription state
#unsubscribeAgent?: () => void;
#unsubscribeAppendOnly?: () => void;
/** Last (enable, providerId) tuple resolved by `#syncAppendOnlyContext` — used to skip no-op invalidations. */
#lastAppendOnlyResolution?: { enable: boolean; providerId: string | undefined };
#eventListeners: AgentSessionEventListener[] = [];
/** Tracks pending steering messages for UI display. Removed when delivered.
* Entry shape: `{ text }` for plain-text steers (user-message dequeue
* matches by `.text`); `{ text, tag }` for queued custom messages (skill
* invocations dispatched while streaming) — the custom-role dequeue
* matches by `.tag` so duplicate-args queued skills cannot collide. */
#steeringMessages: QueuedDisplayEntry[] = [];
/** Tracks pending follow-up messages for UI display. Removed when delivered.
* See `#steeringMessages` for entry shape. */
#followUpMessages: QueuedDisplayEntry[] = [];
/** Messages queued to be included with the next user prompt as context ("asides"). */
#pendingNextTurnMessages: CustomMessage[] = [];
#scheduledHiddenNextTurnGeneration: number | undefined = undefined;
#planModeState: PlanModeState | undefined;
#goalModeState: GoalModeState | undefined;
#goalRuntime: GoalRuntime;
#goalTurnCounter = 0;
#planReferenceSent = false;
#planReferencePath = "local://PLAN.md";
#clientBridge: ClientBridge | undefined;
#allowAcpAgentInitiatedTurns = false;
/** Per-session memory of allow_always / reject_always decisions for gated tools. */
#acpPermissionDecisions: Map<string, "allow_always" | "reject_always"> = new Map();
// Compaction state
#compactionAbortController: AbortController | undefined = undefined;
#autoCompactionAbortController: AbortController | undefined = undefined;
// Branch summarization state
#branchSummaryAbortController: AbortController | undefined = undefined;
// Handoff state
#handoffAbortController: AbortController | undefined = undefined;
#skipPostTurnMaintenanceAssistantTimestamp: number | undefined = undefined;
// Retry state
#retryAbortController: AbortController | undefined = undefined;
#retryAttempt = 0;
#retryPromise: Promise<void> | undefined = undefined;
#retryResolve: (() => void) | undefined = undefined;
#activeRetryFallback: ActiveRetryFallbackState | undefined = undefined;
// Todo completion reminder state
#todoReminderCount = 0;
#todoPhases: TodoPhase[] = [];
#toolChoiceQueue = new ToolChoiceQueue();
// Bash execution state
#bashAbortControllers = new Set<AbortController>();
#pendingBashMessages: BashExecutionMessage[] = [];
// Python execution state
#evalAbortControllers = new Set<AbortController>();
#evalKernelOwnerId: string;
#parentEvalSessionId: string | undefined;
/**
* AsyncJobManager owned by this session (top-level only). Subagents leave
* this undefined and **MUST NOT** dispose the global instance on teardown.
*/
readonly #ownedAsyncJobManager: AsyncJobManager | undefined;
#pendingPythonMessages: PythonExecutionMessage[] = [];
#activeEvalExecutions = new Set<Promise<unknown>>();
#evalExecutionDisposing = false;
// Background-channel IRC exchanges queued while the recipient was streaming.
// Drained into history (via emitExternalEvent) once the recipient becomes idle.
#pendingBackgroundExchanges: CustomMessage[][] = [];
#scheduledBackgroundExchangeFlush = false;
// Agent identity + registry for IRC relay forwarding to the main session UI.
#agentId: string | undefined;
#agentRegistry: AgentRegistry | undefined;
#providerSessionId: string | undefined;
#isDisposed = false;
// Extension system
#extensionRunner: ExtensionRunner | undefined = undefined;
#turnIndex = 0;
#skills: Skill[];
#skillWarnings: SkillWarning[];
// Custom commands (TypeScript slash commands)
#customCommands: LoadedCustomCommand[] = [];
/** MCP prompt commands (updated dynamically when prompts are loaded) */
#mcpPromptCommands: LoadedCustomCommand[] = [];
#skillsSettings: SkillsSettings | undefined;
// Model registry for API key resolution
#modelRegistry: ModelRegistry;
// Tool registry and prompt builder for extensions
#toolRegistry: Map<string, AgentTool>;
#transformContext: (messages: AgentMessage[], signal?: AbortSignal) => AgentMessage[] | Promise<AgentMessage[]>;
#onPayload: SimpleStreamOptions["onPayload"] | undefined;
#onResponse: SimpleStreamOptions["onResponse"] | undefined;
#onSseEvent: SimpleStreamOptions["onSseEvent"] | undefined;
#convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
#rebuildSystemPrompt:
| ((toolNames: string[], tools: Map<string, AgentTool>) => Promise<{ systemPrompt: string[] }>)
| undefined;
#getMcpServerInstructions: (() => Map<string, string> | undefined) | undefined;
#reloadSshTool: (() => Promise<AgentTool | null>) | undefined;
#requestedToolNames: ReadonlySet<string> | undefined;
#baseSystemPrompt: string[];
/**
* Signature of the (toolNames, tool descriptions) tuple passed to the most
* recent successful `rebuildSystemPrompt` call. Used to skip redundant rebuilds
* when MCP servers reconnect without changing their tool definitions, which is
* the dominant cause of prompt-cache invalidation in long sessions.
*/
#lastAppliedToolSignature: string | undefined;
#mcpDiscoveryEnabled = false;
#discoverableMCPTools = new Map<string, DiscoverableTool>();
#selectedMCPToolNames = new Set<string>();
// Generic tool discovery (covers built-in + MCP + extension when tools.discoveryMode === "all")
#discoverableToolSearchIndex: DiscoverableToolSearchIndex | null = null;
#selectedDiscoveredToolNames = new Set<string>();
#rpcHostToolNames = new Set<string>();
#defaultSelectedMCPServerNames = new Set<string>();
#defaultSelectedMCPToolNames = new Set<string>();
#sessionDefaultSelectedMCPToolNames = new Map<string, string[]>();
// TTSR manager for time-traveling stream rules
#ttsrManager: TtsrManager | undefined = undefined;
#pendingTtsrInjections: Rule[] = [];
/** Per-tool TTSR rules whose `interruptMode` opted out of aborting the stream.
* These are folded into the matched tool call's `toolResult` content as an
* in-band system reminder, instead of spawning a separate follow-up turn. */
#perToolTtsrInjections = new Map<string, Rule[]>();
#ttsrAbortPending = false;
#ttsrRetryToken = 0;
#ttsrResumePromise: Promise<void> | undefined = undefined;
#ttsrResumeResolve: (() => void) | undefined = undefined;
/** One-shot flag set in InteractiveMode.#approvePlan(compactBeforeExecute=true)
* before the plan-mode → compaction transition. Consumed inside
* #handleAgentEvent for the matching `message_end` + `stopReason: "aborted"`;
* cleared unconditionally by the caller's `finally` so it cannot leak into
* later unrelated aborts (e.g. when compaction returns cancelled/failed
* without producing an aborted message_end). */
#planCompactAbortPending = false;
/** Monotonic counter for `enqueueCustomMessageDisplay` tag generation;
* combined with `Date.now()` so tags stay unique even across rapid
* same-tick enqueues. */
#customDisplayTagCounter = 0;
#postPromptTasks = new Set<Promise<unknown>>();
#postPromptTasksPromise: Promise<void> | undefined = undefined;
#postPromptTasksResolve: (() => void) | undefined = undefined;
#postPromptTasksAbortController = new AbortController();
#streamingEditAbortTriggered = false;
#streamingEditCheckedLineCounts = new Map<string, number>();
#streamingEditPrecheckedToolCallIds = new Set<string>();
#streamingEditFileCache = new Map<string, string>();
#promptInFlightCount = 0;
// Wire-level agent_end emission deferred until #promptInFlightCount drops to 0.
// Internal extension hooks and post-emit work (auto-retry, auto-compaction, todo
// checks in #handleAgentEvent) still fire on the original schedule — only the
// `#emit(event)` that reaches external subscribers (rpc-mode stdout, ACP bridge,
// Cursor exec, TUI listeners) is held back. Without this, a client that resumes
// on `agent_end` can fire its next `prompt` before #promptWithMessage's finally
// has decremented #promptInFlightCount, hitting AgentBusyError. Flushed from
// both #endInFlight (normal) and #resetInFlight (abort).
#pendingAgentEndEmit: AgentSessionEvent | undefined;
#obfuscator: SecretObfuscator | undefined;
#checkpointState: CheckpointState | undefined = undefined;
#pendingRewindReport: string | undefined = undefined;
#lastSuccessfulYieldToolCallId: string | undefined = undefined;
#promptGeneration = 0;
#providerSessionState = new Map<string, ProviderSessionState>();
#hindsightSessionState: HindsightSessionState | undefined = undefined;
readonly rawSseDebugBuffer: RawSseDebugBuffer;
#acquirePowerAssertion(): void {
if (process.platform !== "darwin") return;
if (this.#powerAssertion) return;
const idle = this.settings.get("power.preventIdleSleep");
const system = this.settings.get("power.preventSystemSleep");
const user = this.settings.get("power.declareUserActive");
const display = this.settings.get("power.preventDisplaySleep");
// All four off → user opted out; do nothing.
if (!idle && !system && !user && !display) return;
try {
this.#powerAssertion = MacOSPowerAssertion.start({
reason: "Oh My Pi agent session",
idle,
system,
user,
display,
});
} catch (error) {
logger.warn("Failed to acquire macOS power assertion", { error: String(error) });
}
}
#releasePowerAssertion(): void {
const assertion = this.#powerAssertion;
this.#powerAssertion = undefined;
if (!assertion) return;
try {
assertion.stop();
} catch (error) {
logger.warn("Failed to release macOS power assertion", { error: String(error) });
}
}
#beginInFlight(): void {
this.#promptInFlightCount++;
if (this.#promptInFlightCount === 1) {
this.#acquirePowerAssertion();
}
}
#endInFlight(): void {
this.#promptInFlightCount = Math.max(0, this.#promptInFlightCount - 1);
if (this.#promptInFlightCount === 0) {
this.#releasePowerAssertion();
this.#flushPendingAgentEnd();
}
}
#resetInFlight(): void {
this.#promptInFlightCount = 0;
this.#releasePowerAssertion();
this.#flushPendingAgentEnd();
}
#flushPendingAgentEnd(): void {
const pending = this.#pendingAgentEndEmit;
if (!pending) return;
this.#pendingAgentEndEmit = undefined;
this.#emit(pending);
}
constructor(config: AgentSessionConfig) {
this.agent = config.agent;
this.sessionManager = config.sessionManager;
this.settings = config.settings;
// Power assertions are taken per turn (see #beginInFlight); nothing acquired here.
this.#evalKernelOwnerId = config.evalKernelOwnerId ?? `agent-session:${Snowflake.next()}`;
this.#parentEvalSessionId = config.parentEvalSessionId;
this.#ownedAsyncJobManager = config.ownedAsyncJobManager;
this.#scopedModels = config.scopedModels ?? [];
this.#thinkingLevel = config.thinkingLevel;
this.#promptTemplates = config.promptTemplates ?? [];
this.#slashCommands = config.slashCommands ?? [];
this.#extensionRunner = config.extensionRunner;
this.#skills = config.skills ?? [];
this.#skillWarnings = config.skillWarnings ?? [];
this.#customCommands = config.customCommands ?? [];
this.#skillsSettings = config.skillsSettings;
this.#modelRegistry = config.modelRegistry;
this.#validateRetryFallbackChains();
this.#toolRegistry = config.toolRegistry ?? new Map();
this.#requestedToolNames = config.requestedToolNames;
this.#transformContext = config.transformContext ?? (messages => messages);
this.#onPayload = config.onPayload;
this.rawSseDebugBuffer = config.rawSseDebugBuffer ?? new RawSseDebugBuffer();
// Avoid wrapping in an `async` closure when no user callback is configured: the
// outer await on `#onResponse` (provider-response.ts) tolerates a sync void return,
// and skipping the wrapper drops a per-event `newPromiseCapability` allocation that
// shows up as ~3.5% self time in streaming profiles.
const configuredOnResponse = config.onResponse;
this.#onResponse = configuredOnResponse
? async (response, model) => {
this.rawSseDebugBuffer.recordResponse(response, model);
await configuredOnResponse(response, model);
}
: (response, model) => {
this.rawSseDebugBuffer.recordResponse(response, model);
};
const configuredOnSseEvent = config.onSseEvent;
this.#onSseEvent = configuredOnSseEvent
? (event, model) => {
this.rawSseDebugBuffer.recordEvent(event, model);
configuredOnSseEvent(event, model);
}
: (event, model) => {
this.rawSseDebugBuffer.recordEvent(event, model);
};
this.agent.setProviderResponseInterceptor(this.#onResponse);
this.agent.setRawSseEventInterceptor(this.#onSseEvent);
this.yieldQueue = new YieldQueue({
isStreaming: () => this.isStreaming,
injectStreaming: message => this.agent.followUp(message),
injectIdle: async messages => {
const first = messages[0];
if (!first) return;
await this.agent.prompt(messages.length === 1 ? first : messages);
},
scheduleIdleFlush: run => {
this.#schedulePostPromptTask(
async () => {
await run();
},
{ delayMs: 1 },
);
},
});
this.agent.setOnBeforeYield(() => this.yieldQueue.flush("streaming"));
this.#convertToLlm = config.convertToLlm ?? convertToLlm;
this.#rebuildSystemPrompt = config.rebuildSystemPrompt;
this.#getMcpServerInstructions = config.getMcpServerInstructions;
this.#reloadSshTool = config.reloadSshTool;
this.#baseSystemPrompt = this.agent.state.systemPrompt;
this.#mcpDiscoveryEnabled = config.mcpDiscoveryEnabled ?? false;
this.#setDiscoverableMCPTools(this.#collectDiscoverableMCPToolsFromRegistry());
this.#selectedMCPToolNames = new Set(config.initialSelectedMCPToolNames ?? []);
this.#defaultSelectedMCPServerNames = new Set(config.defaultSelectedMCPServerNames ?? []);
this.#defaultSelectedMCPToolNames = new Set(config.defaultSelectedMCPToolNames ?? []);
this.#pruneSelectedMCPToolNames();
const persistedSelectedMCPToolNames = this.buildDisplaySessionContext().selectedMCPToolNames;
const currentSelectedMCPToolNames = this.getSelectedMCPToolNames();
const persistInitialMCPToolSelection =
config.persistInitialMCPToolSelection ?? this.sessionManager.getBranch().length === 0;
if (
this.#mcpDiscoveryEnabled &&
persistInitialMCPToolSelection &&
!this.#selectedMCPToolNamesMatch(persistedSelectedMCPToolNames, currentSelectedMCPToolNames)
) {
this.sessionManager.appendMCPToolSelection(currentSelectedMCPToolNames);
}
this.#rememberSessionDefaultSelectedMCPToolNames(
this.sessionManager.getSessionFile(),
this.#getConfiguredDefaultSelectedMCPToolNames(),
);
this.#ttsrManager = config.ttsrManager;
this.#obfuscator = config.obfuscator;
this.#agentId = config.agentId;
this.#agentRegistry = config.agentRegistry;
this.#providerSessionId = config.providerSessionId;
this.agent.setAssistantMessageEventInterceptor((message, assistantMessageEvent) => {
const event: AgentEvent = {
type: "message_update",
message,
assistantMessageEvent,
};
this.#preCacheStreamingEditFile(event);
this.#maybeAbortStreamingEdit(event);
});
// Per-tool TTSR reminders are folded into the matched tool's result via this hook.
this.agent.afterToolCall = ctx => this.#ttsrAfterToolCall(ctx);
this.agent.providerSessionState = this.#providerSessionState;
this.#syncAgentSessionId();
this.#syncTodoPhasesFromBranch();
this.#goalRuntime = new GoalRuntime({
getState: () => this.#goalModeState,
setState: state => {
this.#goalModeState = state;
},
getCurrentUsage: () => {
const usage = this.getSessionStats().tokens;
return {
input: usage.input,
output: usage.output,
cacheRead: usage.cacheRead,
cacheWrite: usage.cacheWrite,
};
},
emit: event => {
if (event.type === "goal_updated") {
return this.#emitSessionEvent({ type: "goal_updated", goal: event.goal, state: event.state });
}
},
persist: (mode, state) => {
if (mode === "none") {
this.sessionManager.appendModeChange("none");
} else if (state) {
this.sessionManager.appendModeChange(mode, { goal: state.goal });
}
},
sendHiddenMessage: async message => {
await this.sendCustomMessage(
{
customType: message.customType,
content: message.content,
display: false,
attribution: "agent",
},
{ deliverAs: message.deliverAs },
);
},
});
// Always subscribe to agent events for internal handling
// (session persistence, hooks, auto-compaction, retry logic)
this.#unsubscribeAgent = this.agent.subscribe(this.#handleAgentEvent);
// Re-evaluate append-only context mode when the setting changes at runtime.
this.#unsubscribeAppendOnly = onAppendOnlyModeChanged(_value => this.#syncAppendOnlyContext(this.model));
}
/** Model registry for API key resolution and model discovery */
get modelRegistry(): ModelRegistry {
return this.#modelRegistry;
}
/** Advance the tool-choice queue and return the next directive for the upcoming LLM call. */
nextToolChoice(): ToolChoice | undefined {
return this.#toolChoiceQueue.nextToolChoice();
}
/**
* Force the next model call to target a specific active tool, then terminate
* the agent loop. Pushes a two-step sequence [forced, "none"] so the model
* calls exactly the forced tool once and then cannot call another.
*/
setForcedToolChoice(toolName: string): void {
if (!this.getActiveToolNames().includes(toolName)) {
throw new Error(`Tool "${toolName}" is not currently active.`);
}
const forced = buildNamedToolChoice(toolName, this.model);
if (!forced || typeof forced === "string") {
throw new Error("Current model does not support forcing a specific tool.");
}
this.#toolChoiceQueue.pushSequence([forced, "none"], {
label: "user-force",
onRejected: () => "requeue",
});
}
/** The tool-choice queue: forces forthcoming tool invocations and carries handlers. */
get toolChoiceQueue(): ToolChoiceQueue {
return this.#toolChoiceQueue;
}
/** Peek the in-flight directive's invocation handler for use by the resolve tool. */
peekQueueInvoker(): ((input: unknown) => Promise<unknown> | unknown) | undefined {
return this.#toolChoiceQueue.peekInFlightInvoker();
}
/** Standing (long-lived) handler the `resolve` tool falls back to when no
* queue invoker is in flight. Used by plan mode so the agent can submit
* approval via `resolve` without forcing the tool choice every turn. */
#standingResolveHandler: ((input: unknown) => Promise<unknown> | unknown) | undefined;
peekStandingResolveHandler(): ((input: unknown) => Promise<unknown> | unknown) | undefined {
return this.#standingResolveHandler;
}
setStandingResolveHandler(handler: ((input: unknown) => Promise<unknown> | unknown) | null): void {
this.#standingResolveHandler = handler ?? undefined;
}
/** Provider-scoped mutable state store for transport/session caches. */
get providerSessionState(): Map<string, ProviderSessionState> {
return this.#providerSessionState;
}
getHindsightSessionState(): HindsightSessionState | undefined {
return this.#hindsightSessionState;
}
setHindsightSessionState(state: HindsightSessionState | undefined): HindsightSessionState | undefined {
const previous = this.#hindsightSessionState;
this.#hindsightSessionState = state;
return previous;
}
/** TTSR manager for time-traveling stream rules */
get ttsrManager(): TtsrManager | undefined {
return this.#ttsrManager;
}
/** Whether a TTSR abort is pending (stream was aborted to inject rules) */
get isTtsrAbortPending(): boolean {
return this.#ttsrAbortPending;
}
/** Whether the plan-mode → compaction transition's expected internal abort is
* pending. Consumed by `#handleAgentEvent` to stamp `SILENT_ABORT_MARKER`
* on the next aborted assistant message_end; cleared unconditionally by
* `InteractiveMode.#approvePlan`'s `finally` block. */
get isPlanCompactAbortPending(): boolean {
return this.#planCompactAbortPending;
}
/** Arm the silent-abort marker for the next aborted assistant message_end.
* Caller MUST clear via `clearPlanCompactAbortPending()` in a `finally`
* to guarantee no leak. */
markPlanCompactAbortPending(): void {
this.#planCompactAbortPending = true;
}
/** Unconditionally clear the silent-abort flag. Idempotent: safe when the
* flag was never set OR was already consumed by `#handleAgentEvent`. */
clearPlanCompactAbortPending(): void {
this.#planCompactAbortPending = false;
}
/** Register a compact display string for a custom message that the caller is
* about to dispatch via `promptCustomMessage` / `sendCustomMessage`.
* Returns a stable tag the caller MUST embed in
* `CustomMessage.details.__pendingDisplayTag` so the agent-side
* `message_start` handler can remove the matching display entry when the
* queued message is consumed.
*
* Does NOT push to the agent's steering/followUp queue — that happens
* separately inside `sendCustomMessage`. */
enqueueCustomMessageDisplay(text: string, mode: "steer" | "followUp"): string {
const tag = `omp-cmd-${Date.now()}-${++this.#customDisplayTagCounter}`;
const displayText = text.trim();
if (!displayText) return tag;
const entry: QueuedDisplayEntry = { text: displayText, tag };
if (mode === "steer") {
this.#steeringMessages.push(entry);
} else {
this.#followUpMessages.push(entry);
}
return tag;
}
getAsyncJobSnapshot(options?: { recentLimit?: number }): AsyncJobSnapshot | null {
const manager = AsyncJobManager.instance();
if (!manager) return null;
const ownerFilter = this.#agentId ? { ownerId: this.#agentId } : undefined;
const running = manager.getRunningJobs(ownerFilter).map(job => ({
id: job.id,
type: job.type,
status: job.status,
label: job.label,
startTime: job.startTime,
}));
const recent = manager.getRecentJobs(options?.recentLimit ?? 5, ownerFilter).map(job => ({
id: job.id,
type: job.type,
status: job.status,
label: job.label,
startTime: job.startTime,
}));
const delivery = manager.getDeliveryState(ownerFilter);
return { running, recent, delivery };
}
/**
* Cancel async jobs registered by *this* agent only. Used by lifecycle
* transitions (newSession, switchSession, handoff, dispose) so a subagent
* cleans up its own background work without touching its parent's jobs.
* No-op when no manager is installed or this session has no agent id.
*/
#cancelOwnAsyncJobs(): void {
if (!this.#agentId) return;
AsyncJobManager.instance()?.cancelAll({ ownerId: this.#agentId });
}
// =========================================================================
// Event Subscription
// =========================================================================
/** Emit an event to all listeners */
#emit(event: AgentSessionEvent): void {
// Copy array before iteration to avoid mutation during iteration.
const listeners = [...this.#eventListeners];
for (const l of listeners) {
try {
const result = l(event) as unknown;
// Listener may be an async function whose returned Promise we don't await;
// attach a catch so a rejection does not become an unhandled rejection.
if (isPromise(result)) {
result.catch(err => {
logger.warn("AgentSession listener rejected", {
error: err instanceof Error ? err.message : String(err),
});
});
}
} catch (err) {
logger.warn("AgentSession listener threw", {
error: err instanceof Error ? err.message : String(err),
});
}
}
}
/**
* Emit a UI-only notice to the session. Surfaces in interactive mode as a
* `showWarning` / `showError` / `showStatus` line; non-interactive modes
* receive the event through the normal subscribe stream.
*
* Notices are NOT added to agent state and never reach the LLM — use this
* for out-of-band conditions the user should see but the model shouldn't
* react to (e.g. background queue flush failures).
*/
emitNotice(level: "info" | "warning" | "error", message: string, source?: string): void {
this.#emit({ type: "notice", level, message, source });
}
#queuedExtensionEvents: Promise<void> = Promise.resolve();
#queueExtensionEvent(event: AgentSessionEvent): Promise<void> {
const emit = async () => {
await this.#emitExtensionEvent(event);
};
const queued = this.#queuedExtensionEvents.then(emit, emit);
this.#queuedExtensionEvents = queued.catch(() => {});
return queued;
}
async #emitSessionEvent(event: AgentSessionEvent): Promise<void> {
if (event.type === "message_update") {
this.#emit(event);
void this.#queueExtensionEvent(event);
return;
}
await this.#emitExtensionEvent(event);
// Hold the wire-level agent_end until in-flight prompts unwind. Subscribers
// (rpc-mode, ACP, Cursor) treat agent_end as the "session is idle" signal;
// emitting while #promptInFlightCount > 0 lets a client fire its next
// `prompt` into a session that still reports isStreaming === true. Flush
// happens in #endInFlight / #resetInFlight. A later agent_end (e.g. from
// an auto-compaction turn that starts before the original prompt unwinds)
// supersedes the pending one, which is what subscribers want — they only
// care about the final settle.
if (event.type === "agent_end" && this.#promptInFlightCount > 0) {
this.#pendingAgentEndEmit = event;
return;
}
this.#emit(event);
}
// Track last assistant message for auto-compaction check
#lastAssistantMessage: AssistantMessage | undefined = undefined;
/** Internal handler for agent events - shared by subscribe and reconnect */
#handleAgentEvent = async (event: AgentEvent): Promise<void> => {
// 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") {
const messageText = this.#getUserMessageText(event.message);
if (messageText) {
// Check steering queue first (match by .text on tagged records)
const steeringIndex = this.#steeringMessages.findIndex(e => e.text === messageText);
if (steeringIndex !== -1) {
this.#steeringMessages.splice(steeringIndex, 1);
} else {
// Check follow-up queue
const followUpIndex = this.#followUpMessages.findIndex(e => e.text === messageText);
if (followUpIndex !== -1) {
this.#followUpMessages.splice(followUpIndex, 1);
}
}
}
}
// Tag-based dequeue for custom messages (skills queued via promptCustomMessage).
// The InputController attached a stable tag via CustomMessage.details when it
// registered the display chip; pull it back here to remove the matching entry
// from the pending bar atomically with the agent's queue consumption. Match by
// tag (not text) — two queued skills with identical args cannot collide.
if (event.type === "message_start" && event.message.role === "custom") {
const tag = readPendingDisplayTag(event.message.details);
if (tag) {
const steerIdx = this.#steeringMessages.findIndex(e => e.tag === tag);
if (steerIdx !== -1) {
this.#steeringMessages.splice(steerIdx, 1);
} else {
const followUpIdx = this.#followUpMessages.findIndex(e => e.tag === tag);
if (followUpIdx !== -1) {
this.#followUpMessages.splice(followUpIdx, 1);
}
}
}
}
// Plan-mode → compaction transition: stamp `SILENT_ABORT_MARKER` on the
// persisted message BEFORE the obfuscator's display-side copy below.
// Invariant (must hold across refactors): this branch precedes the
// `let displayEvent = event; ... displayEvent = { ...event, message: { ...message, content: deobfuscated } }`
// block. After stamping, both `displayEvent.message` (via the spread)
// and `event.message` (in-place mutation, used by SessionManager
// persistence) carry the marker, guaranteeing streaming render and
// history replay branch identically. The one-shot flag is consumed
// here, scoped strictly to this aborted message_end; the caller's
// `finally` (in `InteractiveMode.#approvePlan`) clears it again on
// every terminal compaction outcome (`ok` / `cancelled` / `failed` /
// throw) so a leaked flag cannot silence a later unrelated abort.
if (
event.type === "message_end" &&
event.message.role === "assistant" &&
event.message.stopReason === "aborted" &&
this.#planCompactAbortPending
) {
(event.message as AssistantMessage).errorMessage = SILENT_ABORT_MARKER;
this.#planCompactAbortPending = false;
}
// Deobfuscate assistant message content for display emission — the LLM echoes back
// obfuscated placeholders, but listeners (TUI, extensions, exporters) must see real
// values. The original event.message stays obfuscated so the persistence path below
// writes `#HASH#` tokens to the session file; convertToLlm re-obfuscates outbound
// traffic on the next turn. Walks text, thinking, and toolCall arguments/intent.
let displayEvent: AgentEvent = event;
const obfuscator = this.#obfuscator;
if (obfuscator && event.type === "message_end" && event.message.role === "assistant") {
const message = event.message;
const deobfuscatedContent = obfuscator.deobfuscateObject(message.content);
if (deobfuscatedContent !== message.content) {
displayEvent = { ...event, message: { ...message, content: deobfuscatedContent } };
}
}
if (event.type === "turn_start") {
const usage = this.getSessionStats().tokens;
this.#goalRuntime.onTurnStart(`turn-${++this.#goalTurnCounter}`, {
input: usage.input,
output: usage.output,
cacheRead: usage.cacheRead,
cacheWrite: usage.cacheWrite,
});
}
await this.#emitSessionEvent(displayEvent);
if (event.type === "turn_start") {
this.#resetStreamingEditState();
// TTSR: Reset buffer on turn start
this.#ttsrManager?.resetBuffer();
}
// TTSR: Increment message count on turn end (for repeat-after-gap tracking)
if (event.type === "turn_end" && this.#ttsrManager) {
this.#ttsrManager.incrementMessageCount();
}
// Finalize the tool-choice queue's in-flight yield after tools have executed.
// This must happen at turn_end (not message_end) because onInvoked handlers
// run during tool execution, which happens between message_end and turn_end.
if (event.type === "turn_end" && this.#toolChoiceQueue.hasInFlight) {
const msg = event.message as AssistantMessage;
if (msg.stopReason === "aborted" || msg.stopReason === "error") {
this.#toolChoiceQueue.reject(msg.stopReason === "error" ? "error" : "aborted");
} else {
this.#toolChoiceQueue.resolve();
}
}
if (event.type === "tool_execution_end") {
if (event.toolName === "goal") {
await this.#goalRuntime.onGoalToolCompleted();
} else {
await this.#goalRuntime.onToolCompleted(event.toolName);
}
}
if (event.type === "tool_execution_end" && event.toolName === "yield" && !event.isError) {
this.#lastSuccessfulYieldToolCallId = event.toolCallId;
}
if (event.type === "turn_end" && this.#pendingRewindReport) {
const report = this.#pendingRewindReport;
this.#pendingRewindReport = undefined;
await this.#applyRewind(report);
}
// TTSR: Check for pattern matches on assistant text/thinking and tool argument deltas
if (event.type === "message_update" && this.#ttsrManager?.hasRules()) {
const assistantEvent = event.assistantMessageEvent;
let matchContext: TtsrMatchContext | undefined;
if (assistantEvent.type === "text_delta") {
matchContext = { source: "text" };
} else if (assistantEvent.type === "thinking_delta") {
matchContext = { source: "thinking" };
} else if (assistantEvent.type === "toolcall_delta") {
matchContext = this.#getTtsrToolMatchContext(event.message, assistantEvent.contentIndex);
}
if (matchContext && "delta" in assistantEvent) {
const matches = this.#ttsrManager.checkDelta(assistantEvent.delta, matchContext);
if (matches.length > 0) {
// Decide first: a non-interrupting tool-source match attaches to the
// specific tool call's result instead of driving a loop-wide follow-up.
const shouldInterrupt = this.#shouldInterruptForTtsrMatch(matches, matchContext);
const perToolId = shouldInterrupt ? undefined : this.#extractTtsrToolCallId(matchContext);
if (perToolId) {
this.#addPerToolTtsrInjections(perToolId, matches);
this.#emitSessionEvent({ type: "ttsr_triggered", rules: matches }).catch(() => {});
} else {
// Queue rules for injection; mark as injected only after successful enqueue.
this.#addPendingTtsrInjections(matches);
if (shouldInterrupt) {
// Abort the stream immediately — do not gate on extension callbacks
this.#ttsrAbortPending = true;
this.#ensureTtsrResumePromise();
this.agent.abort();
// Notify extensions (fire-and-forget, does not block abort)
this.#emitSessionEvent({ type: "ttsr_triggered", rules: matches }).catch(() => {});
// Schedule retry after a short delay
const retryToken = ++this.#ttsrRetryToken;
const generation = this.#promptGeneration;
const targetMessageTimestamp =
event.message.role === "assistant" ? event.message.timestamp : undefined;
this.#schedulePostPromptTask(
async () => {
if (this.#ttsrRetryToken !== retryToken) {
this.#resolveTtsrResume();
return;
}
const targetAssistantIndex = this.#findTtsrAssistantIndex(targetMessageTimestamp);
if (
!this.#ttsrAbortPending ||
this.#promptGeneration !== generation ||
targetAssistantIndex === -1
) {
this.#ttsrAbortPending = false;
this.#pendingTtsrInjections = [];
this.#perToolTtsrInjections.clear();
this.#resolveTtsrResume();
return;
}
this.#ttsrAbortPending = false;
this.#perToolTtsrInjections.clear();
const ttsrSettings = this.#ttsrManager?.getSettings();
if (ttsrSettings?.contextMode === "discard") {
// Remove the partial/aborted assistant turn from agent state
this.agent.replaceMessages(this.agent.state.messages.slice(0, targetAssistantIndex));
}
// Inject TTSR rules as system reminder before retry
const injection = this.#getTtsrInjectionContent();
if (injection) {
const details = { rules: injection.rules.map(rule => rule.name) };
this.agent.appendMessage({
role: "custom",
customType: "ttsr-injection",
content: injection.content,
display: false,
details,
attribution: "agent",
timestamp: Date.now(),
});
this.sessionManager.appendCustomMessageEntry(
"ttsr-injection",
injection.content,
false,
details,
"agent",
);
this.#markTtsrInjected(details.rules);
}
try {
await this.agent.continue();
} catch {
this.#resolveTtsrResume();
}
},
{ delayMs: 50 },
);
return;
}
}
}
}
}
if (
event.type === "message_update" &&
(event.assistantMessageEvent.type === "toolcall_start" ||
event.assistantMessageEvent.type === "toolcall_delta" ||
event.assistantMessageEvent.type === "toolcall_end")
) {
void this.#preCacheStreamingEditFile(event);
}
if (
event.type === "message_update" &&
(event.assistantMessageEvent.type === "toolcall_end" || event.assistantMessageEvent.type === "toolcall_delta")
) {
this.#maybeAbortStreamingEdit(event);
}
// Handle session persistence
if (event.type === "message_end") {
// Check if this is a hook/custom message
if (event.message.role === "hookMessage" || event.message.role === "custom") {
// Persist as CustomMessageEntry
this.sessionManager.appendCustomMessageEntry(
event.message.customType,
event.message.content,
event.message.display,
event.message.details,
event.message.attribution ?? "agent",
);
if (event.message.role === "custom" && event.message.customType === "ttsr-injection") {
this.#markTtsrInjected(this.#extractTtsrRuleNames(event.message.details));
}
} else if (
event.message.role === "user" ||
event.message.role === "developer" ||
event.message.role === "assistant" ||
event.message.role === "toolResult" ||
event.message.role === "fileMention"
) {
// 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 as AssistantMessage;
const currentGrantsAnthropicPriority =
this.serviceTier === "priority" || this.serviceTier === "claude-only";
if (assistantMsg.disabledFeatures?.includes("priority") && currentGrantsAnthropicPriority) {
this.setServiceTier(undefined);
this.emitNotice(
"warning",
"Priority/fast mode rejected for this model; retried without it. Fast mode is now off.",
"priority",
);
}
// Resolve TTSR resume gate before checking for new deferred injections.
// Gate on #ttsrAbortPending, not stopReason: a non-TTSR abort (e.g. streaming
// edit) also produces stopReason === "aborted" but has no continuation coming.
// Only skip when #ttsrAbortPending is true (TTSR continuation is imminent).
if (!this.#ttsrAbortPending) {
this.#resolveTtsrResume();
}
this.#queueDeferredTtsrInjectionIfNeeded(assistantMsg);
if (this.#handoffAbortController) {
this.#skipPostTurnMaintenanceAssistantTimestamp = assistantMsg.timestamp;
}
if (
assistantMsg.stopReason !== "error" &&
assistantMsg.stopReason !== "aborted" &&
this.#retryAttempt > 0
) {
if (this.#activeRetryFallback && this.model) {
await this.#emitSessionEvent({
type: "retry_fallback_succeeded",
model: formatRetryFallbackSelector(this.model, this.thinkingLevel),
role: this.#activeRetryFallback.role,
});
}
await this.#emitSessionEvent({
type: "auto_retry_end",
success: true,
attempt: this.#retryAttempt,
});
this.#retryAttempt = 0;
}
}
if (event.message.role === "toolResult") {
const { toolName, details, isError, content } = event.message as {
toolName?: string;
details?: { path?: string; phases?: TodoPhase[]; report?: string; startedAt?: string };
isError?: boolean;
content?: Array<TextContent | ImageContent>;
};
// Invalidate streaming edit cache when edit tool completes to prevent stale data
if (toolName === "edit" && details?.path) {
this.#invalidateFileCacheForPath(details.path);
}
if (toolName === "todo_write" && !isError && Array.isArray(details?.phases)) {
this.setTodoPhases(details.phases);
}
if (toolName === "todo_write" && isError) {
const errorText = content?.find(part => part.type === "text")?.text;
const reminderText = [
"<system-reminder>",
"todo_write failed, so todo progress is not visible to the user.",
errorText ? `Failure: ${errorText}` : "Failure: todo_write returned an error.",
"Fix the todo payload and call todo_write again before continuing.",
"</system-reminder>",
].join("\n");
await this.sendCustomMessage(
{
customType: "todo-write-error-reminder",
content: reminderText,
display: false,
details: { toolName, errorText },
},
{ deliverAs: "nextTurn" },
);
}
if (toolName === "checkpoint" && !isError) {
const checkpointEntryId = this.sessionManager.getEntries().at(-1)?.id ?? null;
this.#checkpointState = {
checkpointMessageCount: this.agent.state.messages.length,
checkpointEntryId,
startedAt: details?.startedAt ?? new Date().toISOString(),
};
this.#pendingRewindReport = undefined;
}
if (toolName === "rewind" && !isError && this.#checkpointState) {
const detailReport = typeof details?.report === "string" ? details.report.trim() : "";
const textReport = content?.find(part => part.type === "text")?.text?.trim() ?? "";
const report = detailReport || textReport;
if (report.length > 0) {
this.#pendingRewindReport = report;
}
}
}
}
// Check auto-retry and auto-compaction after agent completes
if (event.type === "agent_end") {
const usage = this.getSessionStats().tokens;
await this.#goalRuntime.onAgentEnd({
currentUsage: {
input: usage.input,
output: usage.output,
cacheRead: usage.cacheRead,
cacheWrite: usage.cacheWrite,
},
});
const fallbackAssistant = [...event.messages]
.reverse()
.find((message): message is AssistantMessage => message.role === "assistant");
const msg = this.#lastAssistantMessage ?? fallbackAssistant;
this.#lastAssistantMessage = undefined;
if (!msg) {
this.#lastSuccessfulYieldToolCallId = undefined;
return;
}
// Invalidate GitHub Copilot credentials on auth failure so stale tokens
// aren't reused on the next request
if (
msg.stopReason === "error" &&
msg.provider === "github-copilot" &&
msg.errorMessage?.includes("GitHub Copilot authentication failed")
) {
await this.#modelRegistry.authStorage.remove("github-copilot");
}
if (this.#skipPostTurnMaintenanceAssistantTimestamp === msg.timestamp) {
this.#skipPostTurnMaintenanceAssistantTimestamp = undefined;
this.#lastSuccessfulYieldToolCallId = undefined;
return;
}
if (this.#assistantEndedWithSuccessfulYield(msg)) {
this.#lastSuccessfulYieldToolCallId = undefined;
return;
}
this.#lastSuccessfulYieldToolCallId = 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();
const compactionTask = this.#checkCompaction(msg);
this.#trackPostPromptTask(compactionTask);
const compactionDeferredHandoff = await compactionTask;
// Check for incomplete todos only after a final assistant stop, not intermediate tool-use turns.
const hasToolCalls = msg.content.some(content => content.type === "toolCall");
if (hasToolCalls) {
return;
}
// When checkCompaction scheduled a deferred handoff, skip the rewind/todo passes:
// any reminder we append here would race the handoff's session reset, and
// #scheduleAgentContinue would start a fresh streaming turn alongside the handoff
// LLM call (visible as "Auto-handoff" loader + an assistant message still streaming).
if (compactionDeferredHandoff) {
return;
}
if (msg.stopReason !== "error" && msg.stopReason !== "aborted") {
if (this.#enforceRewindBeforeYield()) {
return;
}
await this.#checkTodoCompletion();
}
}
};
/** Resolve the pending retry promise */
#resolveRetry(): void {
if (this.#retryResolve) {
this.#retryResolve();
this.#retryResolve = undefined;
this.#retryPromise = undefined;
}
}
/** Create the TTSR resume gate promise if one doesn't already exist. */
#ensureTtsrResumePromise(): void {
if (this.#ttsrResumePromise) return;
const { promise, resolve } = Promise.withResolvers<void>();
this.#ttsrResumePromise = promise;
this.#ttsrResumeResolve = resolve;
}
/** Resolve and clear the TTSR resume gate. */
#resolveTtsrResume(): void {
if (!this.#ttsrResumeResolve) return;
this.#ttsrResumeResolve();
this.#ttsrResumeResolve = undefined;
this.#ttsrResumePromise = undefined;
}
#ensurePostPromptTasksPromise(): void {
if (this.#postPromptTasksPromise) return;
const { promise, resolve } = Promise.withResolvers<void>();
this.#postPromptTasksPromise = promise;
this.#postPromptTasksResolve = resolve;
}
#resolvePostPromptTasks(): void {
if (!this.#postPromptTasksResolve) return;
this.#postPromptTasksResolve();
this.#postPromptTasksResolve = undefined;
this.#postPromptTasksPromise = undefined;
}
#trackPostPromptTask(task: Promise<unknown>): void {
this.#postPromptTasks.add(task);
this.#ensurePostPromptTasksPromise();
void task
.catch(() => {})
.finally(() => {
this.#postPromptTasks.delete(task);
if (this.#postPromptTasks.size === 0) {
this.#resolvePostPromptTasks();
}
});
}
#schedulePostPromptTask(
task: (signal: AbortSignal) => Promise<void>,
options?: { delayMs?: number; generation?: number; onSkip?: () => void },
): void {
const delayMs = options?.delayMs ?? 0;
const signal = this.#postPromptTasksAbortController.signal;
const scheduled = (async () => {
if (delayMs > 0) {
try {
await scheduler.wait(delayMs, { signal });
} catch {
return;
}
}
if (signal.aborted) {
options?.onSkip?.();
return;
}
if (options?.generation !== undefined && this.#promptGeneration !== options.generation) {
options.onSkip?.();
return;
}
await task(signal);
})();
this.#trackPostPromptTask(scheduled);
}
#scheduleAgentContinue(options?: {
delayMs?: number;
generation?: number;
shouldContinue?: () => boolean;
onSkip?: () => void;
onError?: () => void;
}): void {
this.#schedulePostPromptTask(
async () => {
// Defense in depth: if compaction/handoff slipped onto the post-prompt queue
// alongside us (e.g. via a scheduler we don't own), refuse to start a fresh
// streaming turn — agent.continue() here would race the handoff's session
// reset. The first-class fix is in #checkCompaction/the agent_end handler,
// but this guard catches anything that bypasses that path.
if (this.isCompacting || this.isGeneratingHandoff) {
options?.onSkip?.();
return;
}
if (options?.shouldContinue && !options.shouldContinue()) {
options?.onSkip?.();
return;
}
try {
await this.#maybeRestoreRetryFallbackPrimary();
await this.agent.continue();
} catch (error) {
logger.warn("agent.continue failed after scheduling", {
error: error instanceof Error ? error.message : String(error),
});
options?.onError?.();
}
},
{
delayMs: options?.delayMs,
generation: options?.generation,
onSkip: options?.onSkip,
},
);
}
#scheduleAutoContinuePrompt(generation: number): void {
const continuePrompt = async () => {
await this.#promptWithMessage(
{
role: "developer",
content: [{ type: "text", text: autoContinuePrompt }],
attribution: "agent",
timestamp: Date.now(),
},
autoContinuePrompt,
{ skipPostPromptRecoveryWait: true },
);
};
this.#schedulePostPromptTask(
async signal => {
await Promise.resolve();
if (signal.aborted) return;
await continuePrompt();
},
{ generation },
);
}
async #cancelPostPromptTasks(): Promise<void> {
this.#postPromptTasksAbortController.abort();
this.#postPromptTasksAbortController = new AbortController();
this.#resolveTtsrResume();
const pendingTasks = Array.from(this.#postPromptTasks);
if (pendingTasks.length === 0) {
this.#resolvePostPromptTasks();
return;
}
await Promise.allSettled(pendingTasks);
if (this.#postPromptTasks.size === 0) {
this.#resolvePostPromptTasks();
}
}
/**
* Wait for retry, TTSR resume, and any background continuation to settle.
* Loops because a TTSR continuation can trigger a retry (or vice-versa),
* and fire-and-forget `agent.continue()` may still be streaming after
* the TTSR resume gate resolves.
*/
async #waitForPostPromptRecovery(): Promise<void> {
while (true) {
if (this.#retryPromise) {
await this.#retryPromise;
continue;
}
if (this.#ttsrResumePromise) {
await this.#ttsrResumePromise;
continue;
}
if (this.#postPromptTasksPromise) {
await this.#postPromptTasksPromise;
continue;
}
// Tracked post-prompt tasks cover deferred continuations scheduled from
// event handlers. Keep the streaming fallback for direct agent activity
// outside the scheduler.
if (this.agent.state.isStreaming) {
await this.agent.waitForIdle();
continue;
}
break;
}
}
/** Get TTSR injection payload and clear pending injections. */
#getTtsrInjectionContent(): { content: string; rules: Rule[] } | undefined {
if (this.#pendingTtsrInjections.length === 0) return undefined;
const rules = this.#pendingTtsrInjections;
const content = rules
.map(r => prompt.render(ttsrInterruptTemplate, { name: r.name, path: r.path, content: r.content }))
.join("\n\n");
this.#pendingTtsrInjections = [];
return { content, rules };
}
#addPendingTtsrInjections(rules: Rule[]): void {
const seen = new Set(this.#pendingTtsrInjections.map(rule => rule.name));
for (const rule of rules) {
if (seen.has(rule.name)) continue;
this.#pendingTtsrInjections.push(rule);
seen.add(rule.name);
}
}
/** Tool-call id whose argument deltas triggered a TTSR match, when known. */
#extractTtsrToolCallId(matchContext: TtsrMatchContext): string | undefined {
if (matchContext.source !== "tool") return undefined;
const key = matchContext.streamKey;
if (typeof key !== "string" || !key.startsWith("toolcall:")) return undefined;
const id = key.slice("toolcall:".length);
return id.length > 0 ? id : undefined;
}
#addPerToolTtsrInjections(toolCallId: string, rules: Rule[]): void {
const bucket = this.#perToolTtsrInjections.get(toolCallId) ?? [];
const seen = new Set(bucket.map(rule => rule.name));
// Dedupe against rules already bucketed for other tool calls in this
// same assistant message so one rule attaches to exactly one tool call.
const claimedElsewhere = new Set<string>();
for (const [otherId, otherBucket] of this.#perToolTtsrInjections) {
if (otherId === toolCallId) continue;
for (const rule of otherBucket) claimedElsewhere.add(rule.name);
}
const newlyAdded: string[] = [];
for (const rule of rules) {
if (seen.has(rule.name) || claimedElsewhere.has(rule.name)) continue;
bucket.push(rule);
seen.add(rule.name);
newlyAdded.push(rule.name);
}
if (bucket.length === 0) return;
this.#perToolTtsrInjections.set(toolCallId, bucket);
// Claim the rules in the TTSR manager so subsequent deltas in this same
// turn (e.g. a sibling tool call's argument stream) don't re-match them.
// Persistence still happens in #ttsrAfterToolCall when the tool actually
// produces a result we can fold the reminder into.
if (newlyAdded.length > 0) {
this.#ttsrManager?.markInjectedByNames(newlyAdded);
}
}
/** `afterToolCall` hook: fold any per-tool TTSR reminders into the result. */
#ttsrAfterToolCall(ctx: AfterToolCallContext): AfterToolCallResult | undefined {
const rules = this.#perToolTtsrInjections.get(ctx.toolCall.id);
if (!rules || rules.length === 0) return undefined;
this.#perToolTtsrInjections.delete(ctx.toolCall.id);
const reminder = rules
.map(r => prompt.render(ttsrToolReminderTemplate, { name: r.name, path: r.path, content: r.content }))
.join("\n\n");
// The TTSR manager was already claimed at bucket time; only persistence remains.
const ruleNames = rules.map(r => r.name.trim()).filter(n => n.length > 0);
if (ruleNames.length > 0) {
this.sessionManager.appendTtsrInjection(ruleNames);
}
return {
content: [{ type: "text", text: reminder }, ...ctx.result.content],
};
}
#extractTtsrRuleNames(details: unknown): string[] {
if (!details || typeof details !== "object" || Array.isArray(details)) {
return [];
}
const rules = (details as { rules?: unknown }).rules;
if (!Array.isArray(rules)) {
return [];
}
return rules.filter((ruleName): ruleName is string => typeof ruleName === "string");
}
#markTtsrInjected(ruleNames: string[]): void {
const uniqueRuleNames = Array.from(
new Set(ruleNames.map(ruleName => ruleName.trim()).filter(ruleName => ruleName.length > 0)),
);
if (uniqueRuleNames.length === 0) {
return;
}
this.#ttsrManager?.markInjectedByNames(uniqueRuleNames);
this.sessionManager.appendTtsrInjection(uniqueRuleNames);
}
#findTtsrAssistantIndex(targetTimestamp: number | undefined): number {
const messages = this.agent.state.messages;
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (message.role !== "assistant") {
continue;
}
if (targetTimestamp === undefined || message.timestamp === targetTimestamp) {
return i;
}
}
return -1;
}
#shouldInterruptForTtsrMatch(matches: Rule[], matchContext: TtsrMatchContext): boolean {
const globalMode = this.#ttsrManager?.getSettings().interruptMode ?? "always";
for (const rule of matches) {
const mode = rule.interruptMode ?? globalMode;
if (mode === "never") continue;
if (mode === "prose-only" && (matchContext.source === "text" || matchContext.source === "thinking"))
return true;
if (mode === "tool-only" && matchContext.source === "tool") return true;
if (mode === "always") return true;
}
return false;
}
#queueDeferredTtsrInjectionIfNeeded(assistantMsg: AssistantMessage): void {
if (assistantMsg.stopReason === "aborted" || assistantMsg.stopReason === "error") {
// Tools that hadn't started by abort/error will never produce results to
// fold injections into — drop their stale per-tool entries.
this.#perToolTtsrInjections.clear();
}
if (this.#ttsrAbortPending || this.#pendingTtsrInjections.length === 0) {
return;
}
if (assistantMsg.stopReason === "aborted" || assistantMsg.stopReason === "error") {
this.#pendingTtsrInjections = [];
return;
}
const injection = this.#getTtsrInjectionContent();
if (!injection) {
return;
}
this.agent.followUp({
role: "custom",
customType: "ttsr-injection",
content: injection.content,
display: false,
details: { rules: injection.rules.map(rule => rule.name) },
attribution: "agent",
timestamp: Date.now(),
});
this.#ensureTtsrResumePromise();
// Mark as injected after this custom message is delivered and persisted (handled in message_end).
// followUp() only enqueues; resume on the next tick once streaming settles.
this.#scheduleAgentContinue({
delayMs: 1,
generation: this.#promptGeneration,
onSkip: () => {
this.#resolveTtsrResume();
},
shouldContinue: () => {
if (this.agent.state.isStreaming || !this.agent.hasQueuedMessages()) {
this.#resolveTtsrResume();
return false;
}
return true;
},
onError: () => {
this.#resolveTtsrResume();
},
});
}
/** Build TTSR match context for tool call argument deltas. */
#getTtsrToolMatchContext(message: AgentMessage, contentIndex: number): TtsrMatchContext {
const context: TtsrMatchContext = { source: "tool" };
if (message.role !== "assistant") {
return context;
}
const content = message.content;
if (!Array.isArray(content) || contentIndex < 0 || contentIndex >= content.length) {
return context;
}
const block = content[contentIndex];
if (!block || typeof block !== "object" || block.type !== "toolCall") {
return context;
}
const toolCall = block as ToolCall;
context.toolName = toolCall.name;
context.streamKey = toolCall.id ? `toolcall:${toolCall.id}` : `tool:${toolCall.name}:${contentIndex}`;
context.filePaths = this.#extractTtsrFilePathsFromArgs(toolCall.arguments);
return context;
}
/** Extract path-like arguments from tool call payload for TTSR glob matching. */
#extractTtsrFilePathsFromArgs(args: unknown): string[] | undefined {
if (!args || typeof args !== "object" || Array.isArray(args)) {
return undefined;
}
const rawPaths: string[] = [];
for (const [key, value] of Object.entries(args)) {
const normalizedKey = key.toLowerCase();
if (typeof value === "string" && (normalizedKey === "path" || normalizedKey.endsWith("path"))) {
rawPaths.push(value);
continue;
}
if (Array.isArray(value) && (normalizedKey === "paths" || normalizedKey.endsWith("paths"))) {
for (const candidate of value) {
if (typeof candidate === "string") {
rawPaths.push(candidate);
}
}
}
}
const normalizedPaths = rawPaths.flatMap(pathValue => this.#normalizeTtsrPathCandidates(pathValue));
if (normalizedPaths.length === 0) {
return undefined;
}
return Array.from(new Set(normalizedPaths));
}
/** Convert a path argument into stable relative/absolute candidates for glob checks. */
#normalizeTtsrPathCandidates(rawPath: string): string[] {
const trimmed = rawPath.trim();
if (trimmed.length === 0) {
return [];
}
const normalizedInput = trimmed.replaceAll("\\", "/");
const candidates = new Set<string>([normalizedInput]);
if (normalizedInput.startsWith("./")) {
candidates.add(normalizedInput.slice(2));
}
const cwd = this.sessionManager.getCwd();
const absolutePath = path.isAbsolute(trimmed) ? path.normalize(trimmed) : path.resolve(cwd, trimmed);
candidates.add(absolutePath.replaceAll("\\", "/"));
const relativePath = path.relative(cwd, absolutePath).replaceAll("\\", "/");
if (relativePath && relativePath !== "." && !relativePath.startsWith("../") && relativePath !== "..") {
candidates.add(relativePath);
}
return Array.from(candidates);
}
/** Extract text content from a message */
#getUserMessageText(message: Message): string {
if (message.role !== "user") return "";
const content = message.content;
if (typeof content === "string") return content;
const textBlocks = content.filter(c => c.type === "text");
const text = textBlocks.map(c => (c as TextContent).text).join("");
if (text.length > 0) return text;
const hasImages = content.some(c => c.type === "image");
return hasImages ? "[Image]" : "";
}
/** Find the last assistant message in agent state (including aborted ones) */
#findLastAssistantMessage(): AssistantMessage | undefined {
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 as AssistantMessage;
}
}
return undefined;
}
#resetStreamingEditState(): void {
this.#streamingEditAbortTriggered = false;
this.#streamingEditCheckedLineCounts.clear();
this.#streamingEditPrecheckedToolCallIds.clear();
this.#streamingEditFileCache.clear();
}
#getStreamingEditToolCall(event: AgentEvent):
| {
toolCall: ToolCall;
path: string;
resolvedPath: string;
diff?: string;
op?: string;
rename?: string;
}
| undefined {
if (event.type !== "message_update") return undefined;
if (event.message.role !== "assistant") return undefined;
const contentIndex = event.assistantMessageEvent.contentIndex ?? 0;
const messageContent = event.message.content;
if (!Array.isArray(messageContent) || contentIndex < 0 || contentIndex >= messageContent.length) {
return undefined;
}
const toolCall = messageContent[contentIndex] as ToolCall;
if (toolCall.name !== "edit") return undefined;
const args = toolCall.arguments;
if (!args || typeof args !== "object" || Array.isArray(args)) return undefined;
if ("old_text" in args || "new_text" in args) return undefined;
const path = typeof args.path === "string" ? args.path : undefined;
if (!path) return undefined;
// `local://` URLs (e.g. local://PLAN.md for plan-mode) resolve to a real
// on-disk artifacts path; pre-caching works as long as we ask the
// local-protocol handler. Other internal-scheme URLs (agent://, skill://,
// rule://, mcp://, artifact://) have no stable filesystem representation;
// skip pre-cache entirely for those — the edit tool itself will reject
// them through its normal dispatch path.
const resolvedPath = this.#resolveSessionFsPath(path);
if (resolvedPath === undefined) return undefined;
return {
toolCall,
path,
resolvedPath,
diff: typeof args.diff === "string" ? args.diff : undefined,
op: typeof args.op === "string" ? args.op : undefined,
rename: typeof args.rename === "string" ? args.rename : undefined,
};
}
#lastStreamingEditToolCallId: string | undefined;
#abortStreamingEditForAutoGeneratedPath(toolCall: ToolCall, path: string, resolvedPath: string): void {
if (this.#lastStreamingEditToolCallId === toolCall.id) return;
this.#lastStreamingEditToolCallId = toolCall.id;
void assertEditableFile(resolvedPath, path).catch(err => {
// peekFile and other I/O can reject with ENOENT, etc. Only ToolError means
// auto-generated detection; other failures are left for the edit tool.
if (!(err instanceof ToolError)) return;
if (this.#lastStreamingEditToolCallId !== toolCall.id) return;
if (!this.#streamingEditAbortTriggered) {
this.#streamingEditAbortTriggered = true;
logger.warn("Streaming edit aborted due to auto-generated file guard", {
toolCallId: toolCall.id,
path,
});
this.agent.abort();
}
});
}
#preCacheStreamingEditFile(event: AgentEvent): void {
if (this.#streamingEditAbortTriggered) return;
if (event.type !== "message_update") return;
const assistantEvent = event.assistantMessageEvent;
if (
assistantEvent.type !== "toolcall_start" &&
assistantEvent.type !== "toolcall_delta" &&
assistantEvent.type !== "toolcall_end"
) {
return;
}
const streamingEdit = this.#getStreamingEditToolCall(event);
if (!streamingEdit) return;
// The auto-generated guard runs unconditionally: editing a generated file
// is never the user's intent, and the cost of a false-positive abort is one
// wasted turn vs. silently corrupting a regenerated source.
const shouldCheckAutoGenerated =
!streamingEdit.toolCall.id || !this.#streamingEditPrecheckedToolCallIds.has(streamingEdit.toolCall.id);
if (shouldCheckAutoGenerated) {
if (streamingEdit.toolCall.id) {
this.#streamingEditPrecheckedToolCallIds.add(streamingEdit.toolCall.id);
}
this.#abortStreamingEditForAutoGeneratedPath(
streamingEdit.toolCall,
streamingEdit.path,
streamingEdit.resolvedPath,
);
}
// File-cache priming feeds #maybeAbortStreamingEdit's removed-lines check,
// which is the optional patch-preview verification gated by
// edit.streamingAbort. Skip the read when the setting is off.
if (this.settings.get("edit.streamingAbort")) {
this.#ensureFileCache(streamingEdit.resolvedPath);
}
}
#ensureFileCache(resolvedPath: string): void {
if (this.#streamingEditFileCache.has(resolvedPath)) return;
try {
const rawText = fs.readFileSync(resolvedPath, "utf-8");
const { text } = stripBom(rawText);
this.#streamingEditFileCache.set(resolvedPath, normalizeToLF(text));
} catch {
// Don't cache on read errors (including ENOENT) - let the edit tool handle them
}
}
/** Invalidate cache for a file after an edit completes to prevent stale data */
#invalidateFileCacheForPath(filePath: string): void {
const resolvedPath = this.#resolveSessionFsPath(filePath);
if (resolvedPath === undefined) return;
this.#streamingEditFileCache.delete(resolvedPath);
}
/**
* Resolve a path supplied to a tool to a real filesystem path.
*
* - `local://` URLs route through the local-protocol handler so they map
* onto the session's on-disk artifacts directory; pre-caching, ENOENT
* handling, and post-edit invalidation all work normally.
* - Other internal-scheme URLs (agent://, skill://, rule://, mcp://,
* artifact://) have no stable filesystem path; this returns `undefined`
* so callers skip filesystem-only operations.
* - Cwd-relative and absolute paths resolve via `resolveToCwd`.
*/
#resolveSessionFsPath(filePath: string): string | undefined {
const normalized = normalizeLocalScheme(filePath);
if (normalized.startsWith("local:")) {
return resolveLocalUrlToPath(normalized, this.#localProtocolOptions());
}
if (
normalized.startsWith("agent://") ||
normalized.startsWith("skill://") ||
normalized.startsWith("rule://") ||
normalized.startsWith("mcp://") ||
normalized.startsWith("artifact://")
) {
return undefined;
}
return resolveToCwd(normalized, this.sessionManager.getCwd());
}
#localProtocolOptions(): LocalProtocolOptions {
return {
getArtifactsDir: () => this.sessionManager.getArtifactsDir(),
getSessionId: () => this.sessionManager.getSessionId(),
};
}
#maybeAbortStreamingEdit(event: AgentEvent): void {
if (!this.settings.get("edit.streamingAbort")) return;
if (this.#streamingEditAbortTriggered) return;
if (event.type !== "message_update") return;
const assistantEvent = event.assistantMessageEvent;
if (assistantEvent.type !== "toolcall_end" && assistantEvent.type !== "toolcall_delta") return;
const streamingEdit = this.#getStreamingEditToolCall(event);
if (!streamingEdit?.toolCall.id) return;
const { toolCall, path, resolvedPath, diff, op, rename } = streamingEdit;
if (!diff) return;
if (op && op !== "update") return;
if (!diff.includes("\n")) return;
const lastNewlineIndex = diff.lastIndexOf("\n");
if (lastNewlineIndex < 0) return;
const diffForCheck = diff.endsWith("\n") ? diff : diff.slice(0, lastNewlineIndex + 1);
if (diffForCheck.trim().length === 0) return;
let normalizedDiff = normalizeDiff(diffForCheck.replace(/\r/g, ""));
if (!normalizedDiff) return;
// Deobfuscate the diff so removed lines match real file content
if (this.#obfuscator) normalizedDiff = this.#obfuscator.deobfuscate(normalizedDiff);
if (!normalizedDiff) return;
const lines = normalizedDiff.split("\n");
const hasChangeLine = lines.some(line => line.startsWith("+") || line.startsWith("-"));
if (!hasChangeLine) return;
const lineCount = lines.length;
const lastChecked = this.#streamingEditCheckedLineCounts.get(toolCall.id);
if (lastChecked !== undefined && lineCount <= lastChecked) return;
this.#streamingEditCheckedLineCounts.set(toolCall.id, lineCount);
const removedLines = lines
.filter(line => line.startsWith("-") && !line.startsWith("--- "))
.map(line => line.slice(1));
if (removedLines.length > 0) {
let cachedContent = this.#streamingEditFileCache.get(resolvedPath);
if (cachedContent === undefined) {
this.#ensureFileCache(resolvedPath);
cachedContent = this.#streamingEditFileCache.get(resolvedPath);
}
if (cachedContent !== undefined) {
const missing = removedLines.find(line => !cachedContent.includes(normalizeToLF(line)));
if (missing) {
this.#streamingEditAbortTriggered = true;
logger.warn("Streaming edit aborted due to patch preview failure", {
toolCallId: toolCall.id,
path,
error: `Failed to find expected lines in ${path}:\n${missing}`,
});
this.agent.abort();
}
return;
}
if (assistantEvent.type === "toolcall_delta") return;
void this.#checkRemovedLinesAsync(toolCall.id, path, resolvedPath, removedLines);
return;
}
if (assistantEvent.type === "toolcall_delta") return;
void this.#checkPreviewPatchAsync(toolCall.id, path, rename, normalizedDiff);
}
async #checkRemovedLinesAsync(
toolCallId: string,
path: string,
resolvedPath: string,
removedLines: string[],
): Promise<void> {
if (this.#streamingEditAbortTriggered) return;
try {
const { text } = stripBom(await Bun.file(resolvedPath).text());
const normalizedContent = normalizeToLF(text);
const missing = removedLines.find(line => !normalizedContent.includes(normalizeToLF(line)));
if (missing) {
this.#streamingEditAbortTriggered = true;
logger.warn("Streaming edit aborted due to patch preview failure", {
toolCallId,
path,
error: `Failed to find expected lines in ${path}:\n${missing}`,
});
this.agent.abort();
}
} catch (err) {
// Ignore ENOENT (file not found) - let the edit tool handle missing files
// Also ignore other errors during async fallback
if (!isEnoent(err)) {
// Log unexpected errors but don't abort
}
}
}
async #checkPreviewPatchAsync(
toolCallId: string,
path: string,
rename: string | undefined,
normalizedDiff: string,
): Promise<void> {
if (this.#streamingEditAbortTriggered) return;
try {
await previewPatch(
{ path, op: "update", rename, diff: normalizedDiff },
{
cwd: this.sessionManager.getCwd(),
allowFuzzy: this.settings.get("edit.fuzzyMatch"),
fuzzyThreshold: this.settings.get("edit.fuzzyThreshold"),
},
);
} catch (error) {
if (error instanceof ParseError) return;
this.#streamingEditAbortTriggered = true;
logger.warn("Streaming edit aborted due to patch preview failure", {
toolCallId,
path,
error: error instanceof Error ? error.message : String(error),
});
this.agent.abort();
}
}
/** Emit extension events based on session events */
async #emitExtensionEvent(event: AgentSessionEvent): Promise<void> {
if (!this.#extensionRunner) return;
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 hookEvent: TurnStartEvent = {
type: "turn_start",
turnIndex: this.#turnIndex,
timestamp: Date.now(),
};
await this.#extensionRunner.emit(hookEvent);
} else if (event.type === "turn_end") {
const hookEvent: TurnEndEvent = {
type: "turn_end",
turnIndex: this.#turnIndex,
message: event.message,
toolResults: event.toolResults,
};
await this.#extensionRunner.emit(hookEvent);
this.#turnIndex++;
} else if (event.type === "message_start") {
const extensionEvent: MessageStartEvent = {
type: "message_start",
message: event.message,
};
await this.#extensionRunner.emit(extensionEvent);
} else if (event.type === "message_update") {
const extensionEvent: MessageUpdateEvent = {
type: "message_update",
message: event.message,
assistantMessageEvent: event.assistantMessageEvent,
};
await this.#extensionRunner.emit(extensionEvent);
} else if (event.type === "message_end") {
const extensionEvent: MessageEndEvent = {
type: "message_end",
message: event.message,
};
await this.#extensionRunner.emit(extensionEvent);
} else if (event.type === "tool_execution_start") {
const extensionEvent: ToolExecutionStartEvent = {
type: "tool_execution_start",
toolCallId: event.toolCallId,
toolName: event.toolName,
args: event.args,
intent: event.intent,
};
await this.#extensionRunner.emit(extensionEvent);
} else if (event.type === "tool_execution_update") {
const extensionEvent: ToolExecutionUpdateEvent = {
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: ToolExecutionEndEvent = {
type: "tool_execution_end",
toolCallId: event.toolCallId,
toolName: event.toolName,
result: event.result,
isError: event.isError ?? false,
};
await this.#extensionRunner.emit(extensionEvent);
} else if (event.type === "auto_compaction_start") {
await this.#extensionRunner.emit({
type: "auto_compaction_start",
reason: event.reason,
action: event.action,
});
} else if (event.type === "auto_compaction_end") {
await this.#extensionRunner.emit({
type: "auto_compaction_end",
action: event.action,
result: event.result,
aborted: event.aborted,
willRetry: event.willRetry,
errorMessage: event.errorMessage,
skipped: event.skipped,
});
} else if (event.type === "auto_retry_start") {
await this.#extensionRunner.emit({
type: "auto_retry_start",
attempt: event.attempt,
maxAttempts: event.maxAttempts,
delayMs: event.delayMs,
errorMessage: event.errorMessage,
});
} else if (event.type === "auto_retry_end") {
await this.#extensionRunner.emit({
type: "auto_retry_end",
success: event.success,
attempt: event.attempt,
finalError: event.finalError,
});
} else if (event.type === "ttsr_triggered") {
await this.#extensionRunner.emit({ type: "ttsr_triggered", rules: event.rules });
} else if (event.type === "todo_reminder") {
await this.#extensionRunner.emit({
type: "todo_reminder",
todos: event.todos,
attempt: event.attempt,
maxAttempts: event.maxAttempts,
});
} else if (event.type === "goal_updated") {
await this.#extensionRunner.emit({
type: "goal_updated",
goal: event.goal,
state: event.state,
});
}
}
/**
* 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: AgentSessionEventListener): () => void {
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(): void {
if (this.#unsubscribeAgent) {
this.#unsubscribeAgent();
this.#unsubscribeAgent = undefined;
}
}
/**
* Reconnect to agent events after _disconnectFromAgent().
* Preserves all existing listeners.
*/
#reconnectToAgent(): void {
if (this.#unsubscribeAgent) return; // Already connected
this.#unsubscribeAgent = this.agent.subscribe(this.#handleAgentEvent);
}
/**
* Set agent.sessionId from the session manager and install a dynamic
* metadata resolver so every API request carries `metadata.user_id` shaped
* like real Claude Code's `getAPIMetadata` output: `{ session_id,
* account_uuid }` (the latter only when an Anthropic OAuth credential with
* a known account UUID is loaded). Resolving live keeps the value in sync
* with auth-state changes (login/logout, token refresh that surfaces a new
* account uuid) without needing to re-call `#syncAgentSessionId()` on every
* such event.
*/
#syncAgentSessionId(sessionId?: string): void {
const sid = this.#providerSessionId ?? sessionId ?? this.sessionManager.getSessionId();
this.agent.sessionId = sid;
this.agent.setMetadataResolver((provider: string) =>
buildSessionMetadata(sid, provider, this.#modelRegistry.authStorage),
);
}
#rekeyHindsightMemoryForCurrentSessionId(): void {
if (resolveMemoryBackend(this.settings).id !== "hindsight") return;
const sid = this.agent.sessionId;
if (!sid) return;
this.getHindsightSessionState()?.setSessionId(sid);
}
/** New session file: reset auto-recall / retain-threshold counters for the new transcript. */
#resetHindsightConversationTrackingIfHindsight(): void {
if (resolveMemoryBackend(this.settings).id !== "hindsight") return;
const state = this.getHindsightSessionState();
if (!state || state.aliasOf) return;
state.resetConversationTracking();
}
/**
* Remove all listeners, flush pending writes, and disconnect from agent.
* Call this when completely done with the session.
*/
async dispose(): Promise<void> {
this.#isDisposed = true;
this.#pendingBackgroundExchanges = [];
this.yieldQueue.clear();
this.agent.setOnBeforeYield(undefined);
this.#evalExecutionDisposing = true;
try {
if (this.#extensionRunner?.hasHandlers("session_shutdown")) {
await this.#extensionRunner.emit({ type: "session_shutdown" });
}
} catch (error) {
logger.warn("Failed to emit session_shutdown event", { error: String(error) });
}
// Abort post-prompt work so the drain below can complete. Without this, a
// deferred-handoff task that has already advanced into
// `await this.handoff(...) → generateHandoff(...)` keeps awaiting a live LLM stream
// — Promise.allSettled() in #cancelPostPromptTasks then waits forever, freezing
// /exit and Ctrl+C-double-tap. The post-prompt task's own AbortSignal does not
// propagate into the inner handoff/compaction controllers, so we abort them
// explicitly. agent.abort() is needed for an agent.continue() that may have
// raced the deferred handoff (its streaming loop is awaited by the wrapper IIFE).
//
// Tool work (bash/eval/python) is NOT aborted here — those have their own
// dispose paths and shared kernels are contractually allowed to survive a
// session's dispose.
this.abortRetry();
this.abortCompaction();
this.agent.abort();
await this.#cancelPostPromptTasks();
// Cancel jobs this agent registered so a subagent's teardown doesn't
// leak its background bash/task work into the parent's manager. Only
// the session that owns the manager goes on to dispose it (which itself
// nukes any leftover jobs and pending deliveries).
this.#cancelOwnAsyncJobs();
const ownedAsyncManager = this.#ownedAsyncJobManager;
if (ownedAsyncManager) {
const drained = await ownedAsyncManager.dispose({ timeoutMs: 3_000 });
const deliveryState = ownedAsyncManager.getDeliveryState();
if (drained === false && deliveryState) {
logger.warn("Async job completion deliveries still pending during dispose", { ...deliveryState });
}
if (AsyncJobManager.instance() === ownedAsyncManager) {
AsyncJobManager.setInstance(undefined);
}
}
const pythonExecutionsSettled = await this.#prepareEvalExecutionsForDispose();
if (!pythonExecutionsSettled) {
logger.warn(
"Detaching retained Python kernel ownership during dispose while Python execution is still active",
);
}
await disposeKernelSessionsByOwner(this.#evalKernelOwnerId);
this.#releasePowerAssertion();
await this.sessionManager.close();
this.#closeAllProviderSessions("dispose");
const hindsightState = this.setHindsightSessionState(undefined);
await hindsightState?.flushRetainQueue();
hindsightState?.dispose();
this.#disconnectFromAgent();
if (this.#unsubscribeAppendOnly) {
this.#unsubscribeAppendOnly();
this.#unsubscribeAppendOnly = undefined;
}
this.#eventListeners = [];
}
#closeAllProviderSessions(reason: string): void {
for (const [providerKey, state] of this.#providerSessionState) {
try {
state.close();
} catch (error) {
logger.warn("Failed to close provider session state", {
providerKey,
reason,
error: String(error),
});
}
}
this.#providerSessionState.clear();
}
// =========================================================================
// Read-only State Access
// =========================================================================
/** Full agent state */
get state(): AgentState {
return this.agent.state;
}
/** Current model (may be undefined if not yet selected) */
get model(): Model | undefined {
return this.agent.state.model;
}
/** Current thinking level */
get thinkingLevel(): ThinkingLevel | undefined {
return this.#thinkingLevel;
}
get serviceTier(): ServiceTier | undefined {
return this.agent.serviceTier;
}
/** Whether agent is currently streaming a response */
get isStreaming(): boolean {
return this.agent.state.isStreaming || this.#promptInFlightCount > 0;
}
/** Wait until streaming and deferred recovery work are fully settled. */
async waitForIdle(): Promise<void> {
await this.agent.waitForIdle();
await this.#waitForPostPromptRecovery();
}
async drainAsyncJobDeliveriesForAcp(options?: { timeoutMs?: number }): Promise<boolean> {
const manager = AsyncJobManager.instance();
if (!manager) return false;
const ownerFilter = this.#agentId ? { ownerId: this.#agentId } : undefined;
const before = manager.getDeliveryState(ownerFilter);
if (before.queued === 0 && !before.delivering) return false;
const previousAllowAcpAgentInitiatedTurns = this.#allowAcpAgentInitiatedTurns;
this.#allowAcpAgentInitiatedTurns = true;
try {
const drained = await manager.drainDeliveries({ timeoutMs: options?.timeoutMs, filter: ownerFilter });
const after = manager.getDeliveryState(ownerFilter);
return drained && (before.queued !== after.queued || before.delivering !== after.delivering);
} finally {
this.#allowAcpAgentInitiatedTurns = previousAllowAcpAgentInitiatedTurns;
}
}
/** Most recent assistant message in agent state. */
getLastAssistantMessage(): AssistantMessage | undefined {
return this.#findLastAssistantMessage();
}
/** Current effective system prompt blocks (includes any per-turn extension modifications) */
get systemPrompt(): string[] {
return this.agent.state.systemPrompt;
}
/** Current retry attempt (0 if not retrying) */
get retryAttempt(): number {
return this.#retryAttempt;
}
#collectDiscoverableMCPToolsFromRegistry(): Map<string, DiscoverableTool> {
const mcpTools = filterBySource(collectDiscoverableTools(this.#toolRegistry.values()), "mcp");
return new Map(mcpTools.map(tool => [tool.name, tool] as const));
}
#setDiscoverableMCPTools(discoverableMCPTools: Map<string, DiscoverableTool>): void {
this.#discoverableMCPTools = discoverableMCPTools;
this.#invalidateDiscoveryCaches();
}
/** Single point for invalidating cached discovery indices. Call after any change that can
* affect which tools should be discoverable: registry mutations (refreshMCPTools,
* refreshRpcHostTools) or active-tool mutations (#applyActiveToolsByName). */
#invalidateDiscoveryCaches(): void {
this.#discoverableToolSearchIndex = null;
}
#filterSelectableMCPToolNames(toolNames: Iterable<string>): string[] {
return Array.from(toolNames).filter(name => this.#discoverableMCPTools.has(name) && this.#toolRegistry.has(name));
}
#getConfiguredDefaultSelectedMCPToolNames(): string[] {
return this.#filterSelectableMCPToolNames([
...this.#defaultSelectedMCPToolNames,
...selectDiscoverableToolNamesByServer(
this.#discoverableMCPTools.values(),
this.#defaultSelectedMCPServerNames,
),
]);
}
#pruneSelectedMCPToolNames(): void {
this.#selectedMCPToolNames = new Set(this.#filterSelectableMCPToolNames(this.#selectedMCPToolNames));
}
#selectedMCPToolNamesMatch(left: string[], right: string[]): boolean {
return left.length === right.length && left.every((name, index) => name === right[index]);
}
#rememberSessionDefaultSelectedMCPToolNames(
sessionFile: string | null | undefined,
toolNames: Iterable<string>,
): void {
if (!sessionFile) return;
this.#sessionDefaultSelectedMCPToolNames.set(
path.resolve(sessionFile),
this.#filterSelectableMCPToolNames(toolNames),
);
}
#getSessionDefaultSelectedMCPToolNames(sessionFile: string | null | undefined): string[] {
if (!sessionFile) return [];
return this.#sessionDefaultSelectedMCPToolNames.get(path.resolve(sessionFile)) ?? [];
}
#persistSelectedMCPToolNamesIfChanged(previousSelectedMCPToolNames: string[]): void {
if (!this.#mcpDiscoveryEnabled) return;
const nextSelectedMCPToolNames = this.getSelectedMCPToolNames();
if (this.#selectedMCPToolNamesMatch(previousSelectedMCPToolNames, nextSelectedMCPToolNames)) {
return;
}
this.sessionManager.appendMCPToolSelection(nextSelectedMCPToolNames);
}
#getActiveNonMCPToolNames(): string[] {
return this.getActiveToolNames().filter(name => !isMCPToolName(name) && this.#toolRegistry.has(name));
}
/**
* Get the names of currently active tools.
* Returns the names of tools currently set on the agent.
*/
getActiveToolNames(): string[] {
return this.agent.state.tools.map(t => t.name);
}
/** Whether the edit tool is registered in this session. */
get hasEditTool(): boolean {
return this.#toolRegistry.has("edit");
}
/**
* Get a tool by name from the registry.
*/
getToolByName(name: string): AgentTool | undefined {
return this.#toolRegistry.get(name);
}
/**
* Get all configured tool names (built-in via --tools or default, plus custom tools).
*/
getAllToolNames(): string[] {
return Array.from(this.#toolRegistry.keys());
}
#getEditModeSession() {
return {
settings: this.settings,
getActiveModelString: () => (this.model ? formatModelString(this.model) : undefined),
} as const;
}
#resolveActiveEditMode(): EditMode {
return resolveEditMode(this.#getEditModeSession());
}
async #syncEditToolModeAfterModelChange(previousEditMode: EditMode): Promise<void> {
const currentEditMode = this.#resolveActiveEditMode();
if (previousEditMode !== currentEditMode && this.getActiveToolNames().includes("edit")) {
await this.refreshBaseSystemPrompt();
}
}
isMCPDiscoveryEnabled(): boolean {
return this.#mcpDiscoveryEnabled;
}
getSelectedMCPToolNames(): string[] {
if (!this.#mcpDiscoveryEnabled) {
return this.getActiveToolNames().filter(name => isMCPToolName(name) && this.#toolRegistry.has(name));
}
return this.#filterSelectableMCPToolNames(this.#selectedMCPToolNames);
}
async activateDiscoveredMCPTools(toolNames: string[]): Promise<string[]> {
const nextSelectedMCPToolNames = new Set(this.#selectedMCPToolNames);
const activated: string[] = [];
for (const name of toolNames) {
if (!isMCPToolName(name) || !this.#discoverableMCPTools.has(name) || !this.#toolRegistry.has(name)) {
continue;
}
nextSelectedMCPToolNames.add(name);
activated.push(name);
}
if (activated.length === 0) {
return [];
}
const nextActive = [
...this.#getActiveNonMCPToolNames(),
...this.#filterSelectableMCPToolNames(nextSelectedMCPToolNames),
];
await this.setActiveToolsByName(nextActive);
return [...new Set(activated)];
}
// ── Generic tool discovery (covers built-in + MCP + extension) ────────────
/** Resolve effective discovery mode: tools.discoveryMode wins; mcp.discoveryMode is back-compat alias. */
#resolveEffectiveDiscoveryMode(): "off" | "mcp-only" | "all" {
const toolsMode = this.settings.get("tools.discoveryMode");
if (toolsMode !== "off") return toolsMode as "off" | "mcp-only" | "all";
if (this.settings.get("mcp.discoveryMode")) return "mcp-only";
return "off";
}
isToolDiscoveryEnabled(): boolean {
return this.#resolveEffectiveDiscoveryMode() !== "off";
}
getDiscoverableTools(filter?: { source?: DiscoverableTool["source"] }): DiscoverableTool[] {
// For "all" mode we combine built-in registry entries + MCP tools.
// For "mcp-only" mode we only return MCP tools.
const mode = this.#resolveEffectiveDiscoveryMode();
const activeNames = new Set(this.getActiveToolNames());
const mcpTools = Array.from(this.#discoverableMCPTools.values()).filter(t => !activeNames.has(t.name));
const builtinTools: DiscoverableTool[] = mode === "all" ? this.#collectDiscoverableBuiltinTools() : [];
const allTools = [...builtinTools, ...mcpTools];
return filter?.source ? allTools.filter(t => t.source === filter.source) : allTools;
}
/** Collect built-in tools the model can discover via search_tool_bm25. Restricted to tool
* definitions whose `loadMode === "discoverable"`. This keeps hidden/internal tools
* (resolve, yield, report_finding, report_tool_issue) out of the index
* and avoids mislabeling extension/custom default-inactive tools as built-ins. */
#collectDiscoverableBuiltinTools(): DiscoverableTool[] {
const activeNames = new Set(this.getActiveToolNames());
const result: DiscoverableTool[] = [];
for (const tool of this.#toolRegistry.values()) {
if (tool.loadMode !== "discoverable") continue;
if (activeNames.has(tool.name)) continue;
const collected = collectDiscoverableTools([tool], { source: "builtin" });
result.push(...collected);
}
return result;
}
getDiscoverableToolSearchIndex(): DiscoverableToolSearchIndex {
if (!this.#discoverableToolSearchIndex) {
this.#discoverableToolSearchIndex = buildDiscoverableToolSearchIndex(this.getDiscoverableTools());
}
return this.#discoverableToolSearchIndex;
}
/** Invalidate the generic search index cache (call after tool set changes).
* Delegates to {@link #invalidateDiscoveryCaches} so all discovery-related caches stay in sync. */
#invalidateDiscoverableToolSearchIndex(): void {
this.#invalidateDiscoveryCaches();
}
getSelectedDiscoveredToolNames(): string[] {
// Union of MCP-selected and generic non-MCP selected. Non-MCP selections are only
// selected while they are still active; otherwise BM25 must be able to rediscover them.
const activeNames = new Set(this.getActiveToolNames());
const mcpSelected = this.getSelectedMCPToolNames();
const nonMcpSelected = Array.from(this.#selectedDiscoveredToolNames).filter(
name => activeNames.has(name) && this.#toolRegistry.has(name) && !isMCPToolName(name),
);
return [...new Set([...mcpSelected, ...nonMcpSelected])];
}
async activateDiscoveredTools(toolNames: string[]): Promise<string[]> {
const mcpNames = toolNames.filter(isMCPToolName);
const nonMcpNames = toolNames.filter(name => !isMCPToolName(name));
const activated: string[] = [];
// Activate MCP tools via existing path
if (mcpNames.length > 0) {
const activatedMcp = await this.activateDiscoveredMCPTools(mcpNames);
activated.push(...activatedMcp);
}
// Activate non-MCP tools (built-ins that are in the registry but not currently active)
if (nonMcpNames.length > 0) {
const currentActiveNames = new Set(this.getActiveToolNames());
const newlyAdded: string[] = [];
for (const name of nonMcpNames) {
if (this.#toolRegistry.has(name) && !currentActiveNames.has(name)) {
newlyAdded.push(name);
this.#selectedDiscoveredToolNames.add(name);
activated.push(name);
}
}
if (newlyAdded.length > 0) {
const nextActive = [...this.getActiveToolNames(), ...newlyAdded];
await this.setActiveToolsByName(nextActive);
this.#invalidateDiscoverableToolSearchIndex();
}
}
return [...new Set(activated)];
}
/**
* Wrap a tool with a permission-gate proxy when an ACP client is connected.
* Only wraps tools whose name is in PERMISSION_REQUIRED_TOOLS and only when
* the bridge exposes `requestPermission`. No-ops for all other cases.
*/
#wrapToolForAcpPermission<T extends AgentTool>(tool: T): T {
const bridge = this.#clientBridge;
// Match the capability+method gating pattern used by read/write/bash.
if (!bridge?.capabilities.requestPermission || !bridge.requestPermission) return tool;
if (!PERMISSION_REQUIRED_TOOLS.has(tool.name)) return tool;
return new Proxy(tool, {
get: (target, prop) => {
if (prop !== "execute") return Reflect.get(target, prop, target);
return async (
toolCallId: string,
args: unknown,
signal: AbortSignal | undefined,
onUpdate: never,
ctx: never,
) => {
const permissionIntent = getPermissionIntent(target.name, args);
if (!permissionIntent) {
return await target.execute(toolCallId, args as never, signal, onUpdate, ctx);
}
const command =
target.name === "bash" && args && typeof args === "object" && !Array.isArray(args)
? getStringProperty(args as Record<string, unknown>, "command")
: undefined;
const commandContent = command
? [{ type: "content" as const, content: { type: "text" as const, text: `$ ${command}` } }]
: undefined;
// Short-circuit on persisted decisions.
const persisted = this.#acpPermissionDecisions.get(permissionIntent.cacheKey);
if (persisted === "allow_always") {
return await target.execute(toolCallId, args as never, signal, onUpdate, ctx);
}
if (persisted === "reject_always") {
throw new ToolError(`Tool call rejected by user (preference)`);
}
if (signal?.aborted) {
throw new ToolAbortError("Permission request cancelled");
}
type PermissionRaceResult =
| { kind: "permission"; outcome: ClientBridgePermissionOutcome }
| { kind: "aborted" };
const { promise: abortPromise, resolve: resolveAbort } = Promise.withResolvers<PermissionRaceResult>();
const onAbort = () => resolveAbort({ kind: "aborted" });
signal?.addEventListener("abort", onAbort, { once: true });
let raced: PermissionRaceResult;
try {
const permissionPromise = bridge.requestPermission!(
{
toolCallId,
toolName: target.name,
title: permissionIntent.title,
...(target.name === "bash" ? { kind: "execute" } : {}),
status: "pending",
rawInput: args,
...(commandContent ? { content: commandContent } : {}),
locations: extractPermissionLocations(
args,
this.sessionManager.getCwd(),
permissionIntent.paths,
),
},
PERMISSION_OPTIONS,
signal,
).then(outcome => ({ kind: "permission" as const, outcome }));
raced = await Promise.race([permissionPromise, abortPromise]);
} finally {
signal?.removeEventListener("abort", onAbort);
}
if (raced.kind === "aborted" || signal?.aborted) {
throw new ToolAbortError("Permission request cancelled");
}
const outcome = raced.outcome;
if (outcome.outcome === "cancelled") {
throw new ToolAbortError("Permission request cancelled");
}
const selectedOption = PERMISSION_OPTIONS_BY_ID.get(outcome.optionId);
if (!selectedOption) {
throw new ToolError(`Tool permission response used unknown option ID: ${outcome.optionId}`);
}
if (selectedOption.kind === "allow_always") {
this.#acpPermissionDecisions.set(permissionIntent.cacheKey, "allow_always");
} else if (selectedOption.kind === "reject_always") {
this.#acpPermissionDecisions.set(permissionIntent.cacheKey, "reject_always");
}
if (selectedOption.kind === "reject_once" || selectedOption.kind === "reject_always") {
throw new ToolError(`Tool call rejected by user (${target.name})`);
}
return await target.execute(toolCallId, args as never, signal, onUpdate, ctx);
};
},
}) as T;
}
async #applyActiveToolsByName(
toolNames: string[],
options?: { persistMCPSelection?: boolean; previousSelectedMCPToolNames?: string[] },
): Promise<void> {
toolNames = [...new Set(toolNames.map(name => name.toLowerCase()))];
const previousSelectedMCPToolNames = options?.previousSelectedMCPToolNames ?? this.getSelectedMCPToolNames();
const tools: AgentTool[] = [];
const validToolNames: string[] = [];
for (const name of toolNames) {
const tool = this.#toolRegistry.get(name);
if (tool) {
tools.push(this.#wrapToolForAcpPermission(tool));
validToolNames.push(name);
}
}
// Auto-QA tool must survive any runtime tool-set mutation.
if (isAutoQaEnabled(this.settings) && !validToolNames.includes("report_tool_issue")) {
const qaTool = this.#toolRegistry.get("report_tool_issue");
if (qaTool) {
tools.push(this.#wrapToolForAcpPermission(qaTool));
validToolNames.push("report_tool_issue");
}
}
if (this.#mcpDiscoveryEnabled) {
this.#selectedMCPToolNames = new Set(
validToolNames.filter(
name => isMCPToolName(name) && this.#discoverableMCPTools.has(name) && this.#toolRegistry.has(name),
),
);
}
const activeNameSet = new Set(validToolNames);
for (const name of Array.from(this.#selectedDiscoveredToolNames)) {
if (!activeNameSet.has(name) || isMCPToolName(name) || !this.#toolRegistry.has(name)) {
this.#selectedDiscoveredToolNames.delete(name);
}
}
this.agent.setTools(tools);
// Active tool set changed → discoverable tool list (which excludes already-active tools)
// is now stale. Invalidate before any prompt-template hook reads the discovery list.
this.#invalidateDiscoveryCaches();
// Rebuild base system prompt with new tool set, but only when the tool set
// actually changed. MCP servers can reconnect at arbitrary times and call
// `refreshMCPTools` -> `#applyActiveToolsByName` even though the resulting
// tool list is byte-identical. Skipping the rebuild keeps the system prompt
// stable, which is required for Anthropic prompt caching to keep hitting.
if (this.#rebuildSystemPrompt) {
const signature = this.#computeAppliedToolSignature(validToolNames, tools);
if (signature !== this.#lastAppliedToolSignature) {
const built = await this.#rebuildSystemPrompt(validToolNames, this.#toolRegistry);
this.#baseSystemPrompt = built.systemPrompt;
this.agent.setSystemPrompt(this.#baseSystemPrompt);
this.#lastAppliedToolSignature = signature;
}
}
if (options?.persistMCPSelection !== false) {
this.#persistSelectedMCPToolNamesIfChanged(previousSelectedMCPToolNames);
}
}
/**
* Reload the SSH tool from disk-backed capability discovery and make the
* refreshed definition visible to the next model call without restarting.
*/
async refreshSshTool(options?: { activateIfAvailable?: boolean }): Promise<void> {
resetCapabilities();
if (!this.#reloadSshTool) return;
const previousSshTool = this.#toolRegistry.get("ssh");
const previousActiveToolNames = this.getActiveToolNames();
const hadSshTool = previousSshTool !== undefined;
const wasActive = previousActiveToolNames.includes("ssh");
const previousHostNames =
previousSshTool && "hostNames" in previousSshTool && Array.isArray(previousSshTool.hostNames)
? [...previousSshTool.hostNames]
: [];
const candidateHostNames = new Set(previousHostNames);
const capability = await loadCapability<{ name: string }>("ssh", { cwd: this.sessionManager.getCwd() });
for (const host of capability.items) {
if (typeof host?.name === "string") {
candidateHostNames.add(host.name);
}
}
await invalidateHostMetadata(candidateHostNames);
const sshAllowed = this.#requestedToolNames === undefined || this.#requestedToolNames.has("ssh");
const refreshedTool = await this.#reloadSshTool();
if (refreshedTool) {
this.#toolRegistry.set(refreshedTool.name, refreshedTool);
} else {
this.#toolRegistry.delete("ssh");
this.#selectedDiscoveredToolNames.delete("ssh");
}
const nextActive = previousActiveToolNames.filter(name => name !== "ssh" && this.#toolRegistry.has(name));
if (refreshedTool && sshAllowed && (wasActive || (options?.activateIfAvailable && !hadSshTool))) {
nextActive.push(refreshedTool.name);
}
await this.#applyActiveToolsByName(nextActive);
}
/**
* 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 before the next model call.
*/
async setActiveToolsByName(toolNames: string[]): Promise<void> {
await this.#applyActiveToolsByName(toolNames);
}
async #restoreMCPSelectionsForSessionContext(
sessionContext: SessionContext,
options?: { fallbackSelectedMCPToolNames?: Iterable<string> },
): Promise<void> {
if (!this.#mcpDiscoveryEnabled) return;
const nextActiveNonMCPToolNames = this.#getActiveNonMCPToolNames();
const fallbackSelectedMCPToolNames =
options?.fallbackSelectedMCPToolNames ?? this.#getConfiguredDefaultSelectedMCPToolNames();
const restoredMCPToolNames = sessionContext.hasPersistedMCPToolSelection
? this.#filterSelectableMCPToolNames(sessionContext.selectedMCPToolNames)
: this.#filterSelectableMCPToolNames(fallbackSelectedMCPToolNames);
this.#rememberSessionDefaultSelectedMCPToolNames(
this.sessionFile,
this.#getConfiguredDefaultSelectedMCPToolNames(),
);
await this.#applyActiveToolsByName([...nextActiveNonMCPToolNames, ...restoredMCPToolNames], {
persistMCPSelection: false,
});
}
/** Rebuild the base system prompt using the current active tool set. */
async refreshBaseSystemPrompt(): Promise<void> {
if (!this.#rebuildSystemPrompt) return;
const activeToolNames = this.getActiveToolNames();
const built = await this.#rebuildSystemPrompt(activeToolNames, this.#toolRegistry);
this.#baseSystemPrompt = built.systemPrompt;
this.agent.setSystemPrompt(this.#baseSystemPrompt);
// Refresh the cached signature so a subsequent `#applyActiveToolsByName` with
// the same tool set does not re-rebuild on top of the explicit refresh we
// just performed (and conversely, a different set forces a fresh rebuild).
const activeTools = activeToolNames
.map(name => this.#toolRegistry.get(name))
.filter((tool): tool is AgentTool => tool != null);
this.#lastAppliedToolSignature = this.#computeAppliedToolSignature(activeToolNames, activeTools);
}
async #buildSystemPromptForAgentStart(promptText: string): Promise<string[]> {
const backend = resolveMemoryBackend(this.settings);
if (!backend.beforeAgentStartPrompt) return this.#baseSystemPrompt;
try {
const injected = await backend.beforeAgentStartPrompt(this, promptText);
if (!injected) return this.#baseSystemPrompt;
return [...this.#baseSystemPrompt, injected];
} catch (err) {
logger.debug("Memory backend beforeAgentStartPrompt failed", {
backend: backend.id,
error: String(err),
});
return this.#baseSystemPrompt;
}
}
/**
* Compose a stable signature for the inputs that `rebuildSystemPrompt` reads.
* Two calls producing identical signatures are guaranteed to produce identical
* system prompt bytes, so the rebuild can be skipped.
*
* The signature covers:
* 1. Active tool names in order (the prompt renders them in this order).
* 2. Active tool labels, descriptions, and wire-visible names — all are
* rendered into the prompt body (see `system-prompt.md` `{{label}}: \`{{name}}\``
* and `toolPromptNames` in `buildSystemPrompt`). The wire name comes from
* `tool.customWireName` and overrides the internal name on the model wire
* (e.g. `edit` exposes itself as `apply_patch` to GPT-5 in apply_patch mode);
* a stale wire name would desync prompt guidance from actual tool routing.
* 3. When MCP discovery is on, every registry tool's name+label+description+
* customWireName, since `rebuildSystemPrompt` summarizes discoverable MCP
* tools that are not in the active set.
* 4. MCP server instructions text (per server), since `rebuildSystemPrompt`
* embeds these in the appended prompt under "## MCP Server Instructions".
* A server upgrade can change instructions while keeping tools identical.
*
* Settings-driven tool metadata is covered automatically: built-in tools that
* depend on settings expose `description`/`label` via getters (see `TaskTool`,
* `SearchToolBm25Tool`, `EditTool`), and the signature reads them live on every
* call - so a settings flip that mutates the rendered string differs the signature
* the next time `#applyActiveToolsByName` runs. Do not refactor `describeTool` to
* cache per-tool strings without preserving this property.
*
* Inputs NOT covered: tool input schemas; memory instructions read from disk;
* and SDK-init-time closure constants in `sdk.ts` (`repeatToolDescriptions`,
* `eagerTasks`, `intentField`, `mcpDiscoveryEnabled`, `secretsEnabled`). The
* closure-captured ones cannot change at runtime regardless of skip behavior.
* For everything else, callers must explicitly call `refreshBaseSystemPrompt()`
* after side-effecting changes; see e.g. the memory hooks and
* `#syncEditToolModeAfterModelChange`.
*
* The current calendar date IS covered (appended as a segment) because
* `buildSystemPrompt` injects it into the prompt body (`Today is '{{date}}'`).
* Without this, a session spanning midnight with only tool-stable MCP
* reconnects would keep yesterday's date indefinitely.
*/
#computeAppliedToolSignature(toolNames: string[], tools: AgentTool[]): string {
// Order-preserving join: any reorder must produce a different signature so
// the rebuild fires and the new tool list reaches the API.
const nameSegment = toolNames.join("\u0001");
const describeTool = (tool: AgentTool): string =>
`${tool.name}=${tool.label ?? ""}|${tool.description ?? ""}|${tool.customWireName ?? ""}`;
const descriptionSegment = tools.map(describeTool).join("\u0002");
let registrySegment = "";
if (this.#mcpDiscoveryEnabled) {
// Registry iteration order is not load-bearing for the prompt content, so we
// sort to keep the signature insensitive to incidental insertion order.
const entries: string[] = [];
for (const tool of this.#toolRegistry.values()) {
entries.push(describeTool(tool));
}
entries.sort();
registrySegment = entries.join("\u0004");
}
let instructionsSegment = "";
const serverInstructions = this.#getMcpServerInstructions?.();
if (serverInstructions && serverInstructions.size > 0) {
// Sort by server name so transport flap order does not perturb the signature.
const entries: string[] = [];
for (const [server, instructions] of serverInstructions) {
entries.push(`${server}=${instructions}`);
}
entries.sort();
instructionsSegment = entries.join("\u0006");
}
const date = new Date().toISOString().slice(0, 10);
return `${nameSegment}\u0003${descriptionSegment}\u0005${registrySegment}\u0007${instructionsSegment}|${date}`;
}
/**
* Replace MCP tools in the registry and recompute the visible MCP tool set immediately.
* This allows /mcp add/remove/reauth to take effect without restarting the session.
*/
async refreshMCPTools(mcpTools: CustomTool[]): Promise<void> {
const previousSelectedMCPToolNames = this.getSelectedMCPToolNames();
const existingNames = Array.from(this.#toolRegistry.keys());
for (const name of existingNames) {
if (isMCPToolName(name)) {
this.#toolRegistry.delete(name);
}
}
const getCustomToolContext = (): CustomToolContext => ({
sessionManager: this.sessionManager,
modelRegistry: this.#modelRegistry,
model: this.model,
isIdle: () => !this.isStreaming,
hasQueuedMessages: () => this.queuedMessageCount > 0,
abort: () => {
this.agent.abort();
},
});
for (const customTool of mcpTools) {
const wrapped = CustomToolAdapter.wrap(customTool, getCustomToolContext) as AgentTool;
const finalTool = (
this.#extensionRunner ? new ExtensionToolWrapper(wrapped, this.#extensionRunner) : wrapped
) as AgentTool;
this.#toolRegistry.set(finalTool.name, finalTool);
}
this.#setDiscoverableMCPTools(this.#collectDiscoverableMCPToolsFromRegistry());
this.#pruneSelectedMCPToolNames();
if (!this.buildDisplaySessionContext().hasPersistedMCPToolSelection) {
this.#selectedMCPToolNames = new Set([
...this.#selectedMCPToolNames,
...this.#getConfiguredDefaultSelectedMCPToolNames(),
]);
}
this.#rememberSessionDefaultSelectedMCPToolNames(
this.sessionFile,
this.#getConfiguredDefaultSelectedMCPToolNames(),
);
const nextActive = [...this.#getActiveNonMCPToolNames(), ...this.getSelectedMCPToolNames()];
await this.#applyActiveToolsByName(nextActive, { previousSelectedMCPToolNames });
}
/**
* Replace RPC host-owned tools and refresh the active tool set before the next model call.
*/
async refreshRpcHostTools(rpcTools: AgentTool[]): Promise<void> {
const nextToolNames = rpcTools.map(tool => tool.name);
const uniqueToolNames = new Set(nextToolNames);
if (uniqueToolNames.size !== nextToolNames.length) {
throw new Error("RPC host tool names must be unique");
}
for (const name of uniqueToolNames) {
if (this.#toolRegistry.has(name) && !this.#rpcHostToolNames.has(name)) {
throw new Error(`RPC host tool "${name}" conflicts with an existing tool`);
}
}
const previousRpcHostToolNames = new Set(this.#rpcHostToolNames);
const previousActiveToolNames = this.getActiveToolNames();
for (const name of previousRpcHostToolNames) {
this.#toolRegistry.delete(name);
}
this.#rpcHostToolNames.clear();
for (const tool of rpcTools) {
const finalTool = (
this.#extensionRunner ? new ExtensionToolWrapper(tool, this.#extensionRunner) : tool
) as AgentTool;
this.#toolRegistry.set(finalTool.name, finalTool);
this.#rpcHostToolNames.add(finalTool.name);
}
// Registry contents changed — invalidate discovery caches so the next BM25 lookup sees
// the new RPC-host tool set. (#applyActiveToolsByName below also invalidates, but doing
// it here too keeps the contract local to "registry mutated".)
this.#invalidateDiscoveryCaches();
const activeNonRpcToolNames = previousActiveToolNames.filter(name => !previousRpcHostToolNames.has(name));
const preservedRpcToolNames = previousActiveToolNames.filter(
name => previousRpcHostToolNames.has(name) && this.#rpcHostToolNames.has(name),
);
const autoActivatedRpcToolNames = rpcTools
.filter(tool => !tool.hidden && !previousRpcHostToolNames.has(tool.name))
.map(tool => tool.name);
await this.#applyActiveToolsByName(
Array.from(new Set([...activeNonRpcToolNames, ...preservedRpcToolNames, ...autoActivatedRpcToolNames])),
);
}
/** Whether auto-compaction is currently running */
get isCompacting(): boolean {
return this.#autoCompactionAbortController !== undefined || this.#compactionAbortController !== undefined;
}
/**
* Whether idle-flush tasks, auto-continuations, or other short-lived
* post-prompt work are pending. True in the brief window after
* `session.prompt()` returns but before a scheduled background delivery
* (e.g. an async-job result) has finished its own streaming turn.
* Loop-mode and similar auto-submit paths should treat this as a block
* to avoid racing against the delivery turn.
*/
get hasPostPromptWork(): boolean {
return this.#postPromptTasks.size > 0;
}
/** All messages including custom types like BashExecutionMessage */
get messages(): AgentMessage[] {
return this.agent.state.messages;
}
buildDisplaySessionContext(): SessionContext {
return deobfuscateSessionContext(this.sessionManager.buildSessionContext(), this.#obfuscator);
}
/** Convert session messages using the same pre-LLM pipeline as the active session. */
async convertMessagesToLlm(messages: AgentMessage[], signal?: AbortSignal): Promise<Message[]> {
const transformedMessages = await this.#transformContext(messages, signal);
return await this.#convertToLlm(transformedMessages);
}
/** Apply session-level stream hooks to a direct side request. */
prepareSimpleStreamOptions(options: SimpleStreamOptions, provider = "anthropic"): SimpleStreamOptions {
const sessionOnPayload = this.#onPayload;
const sessionOnResponse = this.#onResponse;
const sessionMetadata = this.agent.metadataForProvider(provider);
const sessionOnSseEvent = this.#onSseEvent;
const openrouterRoutingPreset =
provider === "openrouter" ? this.settings.get("providers.openrouterVariant") : "default";
const openrouterVariant =
openrouterRoutingPreset !== "default" && options.openrouterVariant === undefined
? openrouterRoutingPreset
: undefined;
if (!sessionOnPayload && !sessionOnResponse && !sessionMetadata && !sessionOnSseEvent && !openrouterVariant)
return options;
const preparedOptions: SimpleStreamOptions =
openrouterVariant === undefined ? { ...options } : { ...options, openrouterVariant };
// Stamp session metadata (e.g. user_id={session_id}) onto direct-call requests so
// they share the same session bucket as Agent.prompt-routed requests on Anthropic
// OAuth. Caller-provided metadata wins so explicit overrides are respected.
if (sessionMetadata && !options.metadata) {
preparedOptions.metadata = sessionMetadata;
}
if (sessionOnPayload) {
if (!options.onPayload) {
preparedOptions.onPayload = sessionOnPayload;
} else {
const requestOnPayload = options.onPayload;
preparedOptions.onPayload = async (payload, model) => {
const sessionPayload = await sessionOnPayload(payload, model);
const sessionResolvedPayload = sessionPayload ?? payload;
const requestPayload = await requestOnPayload(sessionResolvedPayload, model);
return requestPayload ?? sessionResolvedPayload;
};
}
}
if (sessionOnResponse) {
if (!options.onResponse) {
preparedOptions.onResponse = sessionOnResponse;
} else {
const requestOnResponse = options.onResponse;
preparedOptions.onResponse = async (response, model) => {
await sessionOnResponse(response, model);
await requestOnResponse(response, model);
};
}
}
if (sessionOnSseEvent) {
if (!options.onSseEvent) {
preparedOptions.onSseEvent = sessionOnSseEvent;
} else {
const requestOnSseEvent = options.onSseEvent;
preparedOptions.onSseEvent = (event, model) => {
sessionOnSseEvent(event, model);
requestOnSseEvent(event, model);
};
}
}
return preparedOptions;
}
/** Current steering mode */
get steeringMode(): "all" | "one-at-a-time" {
return this.agent.getSteeringMode();
}
/** Current follow-up mode */
get followUpMode(): "all" | "one-at-a-time" {
return this.agent.getFollowUpMode();
}
/** Current interrupt mode */
get interruptMode(): "immediate" | "wait" {
return this.agent.getInterruptMode();
}
/** Current session file path, or undefined if sessions are disabled */
get sessionFile(): string | undefined {
return this.sessionManager.getSessionFile();
}
/** Current session ID */
get sessionId(): string {
return this.#providerSessionId ?? this.sessionManager.getSessionId();
}
getEvalSessionId(): string | null {
if (this.#parentEvalSessionId !== undefined) return this.#parentEvalSessionId;
return defaultEvalSessionId({
cwd: this.sessionManager.getCwd(),
getSessionFile: () => this.sessionManager.getSessionFile() ?? null,
});
}
/** Current session display name, if set */
get sessionName(): string | undefined {
return this.sessionManager.getSessionName();
}
/** Scoped models for cycling (from --models flag) */
get scopedModels(): ReadonlyArray<{ model: Model; thinkingLevel?: ThinkingLevel }> {
return this.#scopedModels;
}
/** Prompt templates */
getPlanModeState(): PlanModeState | undefined {
return this.#planModeState;
}
setPlanModeState(state: PlanModeState | undefined): void {
this.#planModeState = state;
if (state?.enabled) {
this.#planReferenceSent = false;
this.#planReferencePath = state.planFilePath;
}
}
getGoalModeState(): GoalModeState | undefined {
return this.#goalModeState;
}
setGoalModeState(state: GoalModeState | undefined): void {
this.#goalModeState = state;
}
get goalRuntime(): GoalRuntime {
return this.#goalRuntime;
}
markPlanReferenceSent(): void {
this.#planReferenceSent = true;
}
setPlanReferencePath(path: string): void {
this.#planReferencePath = path;
}
getPlanReferencePath(): string {
return this.#planReferencePath;
}
get clientBridge(): ClientBridge | undefined {
return this.#clientBridge;
}
setClientBridge(bridge: ClientBridge | undefined): void {
this.#clientBridge = bridge;
this.#acpPermissionDecisions.clear();
const activeToolNames = this.getActiveToolNames();
const activeTools = activeToolNames
.map(name => this.#toolRegistry.get(name))
.filter((tool): tool is AgentTool => tool !== undefined)
.map(tool => this.#wrapToolForAcpPermission(tool));
this.agent.setTools(activeTools);
}
getCheckpointState(): CheckpointState | undefined {
return this.#checkpointState;
}
setCheckpointState(state: CheckpointState | undefined): void {
this.#checkpointState = state;
if (!state) {
this.#pendingRewindReport = undefined;
}
}
/**
* Inject the plan mode context message into the conversation history.
*/
async sendPlanModeContext(options?: { deliverAs?: "steer" | "followUp" | "nextTurn" }): Promise<void> {
const message = await this.#buildPlanModeMessage();
if (!message) return;
await this.sendCustomMessage(
{
customType: message.customType,
content: message.content,
display: message.display,
details: message.details,
},
options ? { deliverAs: options.deliverAs } : undefined,
);
}
async sendGoalModeContext(options?: { deliverAs?: "steer" | "followUp" | "nextTurn" }): Promise<void> {
const message = this.#buildGoalModeMessage();
if (!message) return;
await this.sendCustomMessage(
{
customType: message.customType,
content: message.content,
display: message.display,
details: message.details,
attribution: message.attribution,
},
options ? { deliverAs: options.deliverAs } : undefined,
);
}
resolveRoleModel(role: string): Model | undefined {
return this.#resolveRoleModelFull(role, this.#modelRegistry.getAvailable(), this.model).model;
}
/**
* Resolve a role to its model AND thinking level.
* Unlike resolveRoleModel(), this preserves the thinking level suffix
* from role configuration (e.g., "anthropic/claude-sonnet-4-5:xhigh").
*/
resolveRoleModelWithThinking(role: string): ResolvedModelRoleValue {
return this.#resolveRoleModelFull(role, this.#modelRegistry.getAvailable(), this.model);
}
get promptTemplates(): ReadonlyArray<PromptTemplate> {
return this.#promptTemplates;
}
/** Replace file-based slash commands used for prompt expansion. */
setSlashCommands(slashCommands: FileSlashCommand[]): void {
this.#slashCommands = [...slashCommands];
}
/** Custom commands (TypeScript slash commands and MCP prompts) */
get customCommands(): ReadonlyArray<LoadedCustomCommand> {
if (this.#mcpPromptCommands.length === 0) return this.#customCommands;
return [...this.#customCommands, ...this.#mcpPromptCommands];
}
/** Update the MCP prompt commands list. Called when server prompts are (re)loaded. */
setMCPPromptCommands(commands: LoadedCustomCommand[]): void {
this.#mcpPromptCommands = commands;
}
// =========================================================================
// Prompting
// =========================================================================
/**
* Build a plan mode message.
* Returns null if plan mode is not enabled.
* @returns The plan mode message, or null if plan mode is not enabled.
*/
async #buildPlanReferenceMessage(): Promise<CustomMessage | null> {
if (this.#planModeState?.enabled) return null;
if (this.#planReferenceSent) return null;
const planFilePath = this.#planReferencePath;
const resolvedPlanPath = resolveLocalUrlToPath(planFilePath, this.#localProtocolOptions());
let planContent: string;
try {
planContent = await Bun.file(resolvedPlanPath).text();
} catch (error) {
if (isEnoent(error)) {
return null;
}
throw error;
}
const content = prompt.render(planModeReferencePrompt, {
planFilePath,
planContent,
});
this.#planReferenceSent = true;
return {
role: "custom",
customType: "plan-mode-reference",
content,
display: false,
attribution: "agent",
timestamp: Date.now(),
};
}
async #buildPlanModeMessage(): Promise<CustomMessage | null> {
const state = this.#planModeState;
if (!state?.enabled) return null;
const sessionPlanUrl = "local://PLAN.md";
const resolvedPlanPath = state.planFilePath.startsWith("local:")
? resolveLocalUrlToPath(normalizeLocalScheme(state.planFilePath), this.#localProtocolOptions())
: resolveToCwd(state.planFilePath, this.sessionManager.getCwd());
const resolvedSessionPlan = resolveLocalUrlToPath(sessionPlanUrl, this.#localProtocolOptions());
const displayPlanPath =
state.planFilePath.startsWith("local:") || resolvedPlanPath !== resolvedSessionPlan
? state.planFilePath
: sessionPlanUrl;
const planExists = fs.existsSync(resolvedPlanPath);
const content = prompt.render(planModeActivePrompt, {
planFilePath: displayPlanPath,
planExists,
askToolName: "ask",
writeToolName: "write",
editToolName: "edit",
reentry: state.reentry ?? false,
iterative: state.workflow === "iterative",
});
return {
role: "custom",
customType: "plan-mode-context",
content,
display: false,
attribution: "agent",
timestamp: Date.now(),
};
}
#buildGoalModeMessage(): CustomMessage | null {
const content = this.#goalRuntime.buildActivePrompt();
if (!content) return null;
return {
role: "custom",
customType: "goal-mode-context",
content,
display: false,
attribution: "agent",
timestamp: Date.now(),
};
}
/**
* 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: string, options?: PromptOptions): Promise<void> {
const expandPromptTemplates = options?.expandPromptTemplates ?? true;
// Handle extension commands first (execute immediately, even during streaming)
if (expandPromptTemplates && text.startsWith("/")) {
const handled = await this.#tryExecuteExtensionCommand(text);
if (handled) {
return;
}
// Try custom commands (TypeScript slash commands)
const customResult = await this.#tryExecuteCustomCommand(text);
if (customResult !== null) {
if (customResult === "") {
return;
}
text = customResult;
}
// Try file-based slash commands (markdown files from commands/ directories)
// Only if text still starts with "/" (wasn't transformed by custom command)
if (text.startsWith("/")) {
text = expandSlashCommand(text, this.#slashCommands);
}
}
// Expand file-based prompt templates if requested
const expandedText = expandPromptTemplates ? expandPromptTemplate(text, [...this.#promptTemplates]) : text;
// "ultrathink" keyword: nudge the model toward careful multi-step reasoning by
// appending a hidden notice after the user's message. User-authored prompts only —
// synthetic/agent-initiated turns never trigger it.
const ultrathinkNotice: CustomMessage | undefined =
!options?.synthetic && containsUltrathink(expandedText)
? {
role: "custom",
customType: "ultrathink-notice",
content: ULTRATHINK_NOTICE,
display: false,
attribution: "user",
timestamp: Date.now(),
}
: undefined;
// If streaming, queue via steer() or followUp() based on option
if (this.isStreaming) {
if (!options?.streamingBehavior) {
throw new AgentBusyError();
}
if (options.streamingBehavior === "followUp") {
await this.#queueFollowUp(expandedText, options?.images);
} else {
await this.#queueSteer(expandedText, options?.images);
}
// Steer/follow-up the ultrathink notice alongside the queued user message.
if (ultrathinkNotice) {
await this.sendCustomMessage(ultrathinkNotice, { deliverAs: options.streamingBehavior });
}
return;
}
// Skip eager todo prelude when the user has already queued a directive
const hasPendingUserDirective = this.#toolChoiceQueue.inspect().includes("user-force");
const eagerTodoPrelude =
!options?.synthetic && !hasPendingUserDirective ? this.#createEagerTodoPrelude(expandedText) : undefined;
const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }];
if (options?.images) {
userContent.push(...options.images);
}
const promptAttribution = options?.attribution ?? (options?.synthetic ? "agent" : "user");
const message = options?.synthetic
? { role: "developer" as const, content: userContent, attribution: promptAttribution, timestamp: Date.now() }
: { role: "user" as const, content: userContent, attribution: promptAttribution, timestamp: Date.now() };
if (eagerTodoPrelude) {
this.#toolChoiceQueue.pushOnce(eagerTodoPrelude.toolChoice, {
label: "eager-todo",
});
}
try {
await this.#promptWithMessage(message, expandedText, {
...options,
prependMessages: eagerTodoPrelude ? [eagerTodoPrelude.message] : undefined,
appendMessages: ultrathinkNotice ? [ultrathinkNotice] : undefined,
});
} finally {
// Clean up residual eager-todo directive if the prompt never consumed it
// (e.g., compaction aborted, validation failed).
this.#toolChoiceQueue.removeByLabel("eager-todo");
}
if (!options?.synthetic) {
await this.#enforcePlanModeToolDecision();
}
}
async promptCustomMessage<T = unknown>(
message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
options?: Pick<PromptOptions, "streamingBehavior" | "toolChoice">,
): Promise<void> {
const textContent =
typeof message.content === "string"
? message.content
: message.content
.filter((content): content is TextContent => content.type === "text")
.map(content => content.text)
.join("");
if (this.isStreaming) {
if (!options?.streamingBehavior) {
throw new AgentBusyError();
}
await this.sendCustomMessage(message, { deliverAs: options.streamingBehavior });
return;
}
const customMessage: CustomMessage<T> = {
role: "custom",
customType: message.customType,
content: message.content,
display: message.display,
details: message.details,
attribution: message.attribution ?? "agent",
timestamp: Date.now(),
};
await this.#promptWithMessage(customMessage, textContent, options);
}
async #promptWithMessage(
message: AgentMessage,
expandedText: string,
options?: Pick<PromptOptions, "toolChoice" | "images" | "skipCompactionCheck"> & {
prependMessages?: AgentMessage[];
appendMessages?: AgentMessage[];
skipPostPromptRecoveryWait?: boolean;
},
): Promise<void> {
this.#beginInFlight();
const generation = this.#promptGeneration;
try {
// Flush any pending bash messages before the new prompt
this.#flushPendingBashMessages();
this.#flushPendingPythonMessages();
this.#flushPendingBackgroundExchanges();
// Reset todo reminder count on new user prompt
this.#todoReminderCount = 0;
await this.#maybeRestoreRetryFallbackPrimary();
// Validate model
if (!this.model) {
throw new Error(
"No model selected.\n\n" +
`Use /login, set an API key environment variable, or create ${getAgentDbPath()}\n\n` +
"Then use /model to select a model.",
);
}
// Validate API key
const apiKey = await this.#modelRegistry.getApiKey(this.model, this.sessionId);
if (!apiKey) {
throw new Error(
`No API key found for ${this.model.provider}.\n\n` +
`Use /login, set an API key environment variable, or create ${getAgentDbPath()}`,
);
}
// Check if we need to compact before sending (catches aborted responses). Run
// inline (allowDefer=false) so the handoff/maintenance fully settles before this
// prompt's agent loop starts — otherwise a deferred handoff would fire on the
// next microtask alongside the new turn.
const lastAssistant = this.#findLastAssistantMessage();
if (lastAssistant && !options?.skipCompactionCheck) {
await this.#checkCompaction(lastAssistant, false, false);
}
// Build messages array (session context, eager todo prelude, then active prompt message)
const messages: AgentMessage[] = [];
const planReferenceMessage = await this.#buildPlanReferenceMessage?.();
if (planReferenceMessage) {
messages.push(planReferenceMessage);
}
const planModeMessage = await this.#buildPlanModeMessage();
if (planModeMessage) {
messages.push(planModeMessage);
}
const goalModeMessage = this.#buildGoalModeMessage();
if (goalModeMessage) {
messages.push(goalModeMessage);
}
if (options?.prependMessages) {
messages.push(...options.prependMessages);
}
messages.push(message);
// Inject the ultrathink notice (and any other per-turn appends) right after the
// user message so the model reads it as part of the same turn.
if (options?.appendMessages) {
messages.push(...options.appendMessages);
}
// Early bail-out: if a newer abort/prompt cycle started during setup,
// return before mutating shared state (nextTurn messages, system prompt).
if (this.#promptGeneration !== generation) {
return;
}
// Inject any pending "nextTurn" messages as context alongside the user message
for (const msg of this.#pendingNextTurnMessages) {
messages.push(msg);
}
this.#pendingNextTurnMessages = [];
// Auto-read @filepath mentions
const fileMentions = extractFileMentions(expandedText);
if (fileMentions.length > 0) {
const fileMentionMessages = await generateFileMentionMessages(fileMentions, this.sessionManager.getCwd(), {
autoResizeImages: this.settings.get("images.autoResize"),
useHashLines: resolveFileDisplayMode(this).hashLines,
snapshotStore: getFileSnapshotStore(this),
});
messages.push(...fileMentionMessages);
}
const beforeAgentStartSystemPrompt = await this.#buildSystemPromptForAgentStart(expandedText);
// Emit before_agent_start extension event
if (this.#extensionRunner) {
const result = await this.#extensionRunner.emitBeforeAgentStart(
expandedText,
options?.images,
beforeAgentStartSystemPrompt,
);
if (result?.messages) {
const promptAttribution: "user" | "agent" | undefined =
"attribution" in message ? message.attribution : undefined;
for (const msg of result.messages) {
messages.push({
role: "custom",
customType: msg.customType,
content: msg.content,
display: msg.display,
details: msg.details,
attribution: msg.attribution ?? promptAttribution ?? (message.role === "user" ? "user" : "agent"),
timestamp: Date.now(),
});
}
}
if (result?.systemPrompt !== undefined) {
this.agent.setSystemPrompt(result.systemPrompt);
} else {
this.agent.setSystemPrompt(beforeAgentStartSystemPrompt);
}
} else {
this.agent.setSystemPrompt(beforeAgentStartSystemPrompt);
}
// Bail out if a newer abort/prompt cycle has started since we began setup
if (this.#promptGeneration !== generation) {
return;
}
const agentPromptOptions = options?.toolChoice ? { toolChoice: options.toolChoice } : undefined;
await this.#promptAgentWithIdleRetry(messages, agentPromptOptions);
if (!options?.skipPostPromptRecoveryWait) {
await this.#waitForPostPromptRecovery();
}
} finally {
this.#endInFlight();
}
}
/**
* Try to execute an extension command. Returns true if command was found and executed.
*/
async #tryExecuteExtensionCommand(text: string): Promise<boolean> {
if (!this.#extensionRunner) return false;
// 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;
}
}
#createCommandContext(): ExtensionCommandContext {
if (this.#extensionRunner) {
return this.#extensionRunner.createCommandContext();
}
return {
ui: noOpUIContext,
hasUI: false,
cwd: this.sessionManager.getCwd(),
sessionManager: this.sessionManager,
modelRegistry: this.#modelRegistry,
model: this.model ?? undefined,
isIdle: () => !this.isStreaming,
abort: () => {
void this.abort();
},
hasPendingMessages: () => this.queuedMessageCount > 0,
shutdown: () => {
void this.dispose();
process.exit(0);
},
getContextUsage: () => this.getContextUsage(),
waitForIdle: () => this.waitForIdle(),
newSession: async options => {
const success = await this.newSession({ parentSession: options?.parentSession });
if (!success) {
return { cancelled: true };
}
if (options?.setup) {
await options.setup(this.sessionManager);
}
return { cancelled: false };
},
branch: async entryId => {
const result = await this.branch(entryId);
return { cancelled: result.cancelled };
},
navigateTree: async (targetId, options) => {
const result = await this.navigateTree(targetId, { summarize: options?.summarize });
return { cancelled: result.cancelled };
},
compact: async instructionsOrOptions => {
const instructions = typeof instructionsOrOptions === "string" ? instructionsOrOptions : undefined;
const options =
instructionsOrOptions && typeof instructionsOrOptions === "object" ? instructionsOrOptions : undefined;
await this.compact(instructions, options);
},
switchSession: async sessionPath => {
const success = await this.switchSession(sessionPath);
return { cancelled: !success };
},
reload: async () => {
await this.reload();
},
getSystemPrompt: () => this.systemPrompt,
};
}
/**
* Try to execute a custom command. Returns the prompt string if found, null otherwise.
* If the command returns void, returns empty string to indicate it was handled.
*/
async #tryExecuteCustomCommand(text: string): Promise<string | null> {
if (this.#customCommands.length === 0 && this.#mcpPromptCommands.length === 0) return null;
// Parse command name and args
const spaceIndex = text.indexOf(" ");
const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
const argsString = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1);
// Find matching command
const loaded =
this.#customCommands.find(c => c.command.name === commandName) ??
this.#mcpPromptCommands.find(c => c.command.name === commandName);
if (!loaded) return null;
// Get command context from extension runner (includes session control methods)
const baseCtx = this.#createCommandContext();
const ctx = {
...baseCtx,
hasQueuedMessages: baseCtx.hasPendingMessages,
} as unknown as HookCommandContext;
try {
const args = parseCommandArgs(argsString);
const result = await loaded.command.execute(args, ctx);
// If result is a string, it's a prompt to send to LLM
// If void/undefined, command handled everything
return result ?? "";
} catch (err) {
// Emit error via extension runner
if (this.#extensionRunner) {
this.#extensionRunner.emitError({
extensionPath: `custom-command:${commandName}`,
event: "command",
error: err instanceof Error ? err.message : String(err),
});
} else {
const message = err instanceof Error ? err.message : String(err);
logger.error("Custom command failed", { commandName, error: message });
}
return ""; // Command was handled (with error)
}
}
/**
* Queue a steering message to interrupt the agent mid-run.
*/
async steer(text: string, images?: ImageContent[]): Promise<void> {
if (text.startsWith("/")) {
this.#throwIfExtensionCommand(text);
}
const expandedText = expandPromptTemplate(text, [...this.#promptTemplates]);
await this.#queueSteer(expandedText, images);
}
/**
* Queue a follow-up message to process after the agent would otherwise stop.
*/
async followUp(text: string, images?: ImageContent[]): Promise<void> {
if (text.startsWith("/")) {
this.#throwIfExtensionCommand(text);
}
const expandedText = expandPromptTemplate(text, [...this.#promptTemplates]);
await this.#queueFollowUp(expandedText, images);
}
/**
* Internal: Queue a steering message (already expanded, no extension command check).
*/
async #queueSteer(text: string, images?: ImageContent[]): Promise<void> {
const displayText = text || (images && images.length > 0 ? "[Image]" : "");
this.#steeringMessages.push({ text: displayText });
const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
if (images && images.length > 0) {
content.push(...images);
}
this.agent.steer({
role: "user",
content,
attribution: "user",
timestamp: Date.now(),
});
}
/**
* Internal: Queue a follow-up message (already expanded, no extension command check).
*/
async #queueFollowUp(text: string, images?: ImageContent[]): Promise<void> {
const displayText = text || (images && images.length > 0 ? "[Image]" : "");
this.#followUpMessages.push({ text: displayText });
const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
if (images && images.length > 0) {
content.push(...images);
}
this.agent.followUp({
role: "user",
content,
attribution: "user",
timestamp: Date.now(),
});
// When fully idle AND the session is in a resumable assistant-ended state,
// schedule an immediate continue so the queued follow-up is delivered
// without waiting for the next user turn. We gate on isStreaming (model
// actively producing), isRetrying (auto-retry backoff is sleeping between
// attempts, #retryPromise set), and the last message being assistant —
// agent.continue() only dequeues follow-ups from an assistant-ended state;
// resuming from user/toolResult state runs an extra model call on the
// stale prompt before draining the queue.
if (this.#canAutoContinueForFollowUp()) {
this.#scheduleAgentContinue({
shouldContinue: () => this.#canAutoContinueForFollowUp() && this.agent.hasQueuedMessages(),
});
}
}
/**
* Gate for idle-path follow-up auto-continue. See `#queueFollowUp` for rationale.
*/
#canAutoContinueForFollowUp(): boolean {
if (this.isStreaming) return false;
if (this.isRetrying) return false;
const messages = this.agent.state.messages;
const last = messages[messages.length - 1];
return last?.role === "assistant";
}
queueDeferredMessage(message: CustomMessage): void {
this.#queueHiddenNextTurnMessage(message, true);
}
#queueHiddenNextTurnMessage(message: CustomMessage, triggerTurn: boolean): void {
this.#pendingNextTurnMessages.push(message);
if (!triggerTurn) return;
const generation = this.#promptGeneration;
if (this.#scheduledHiddenNextTurnGeneration === generation) {
return;
}
this.#scheduledHiddenNextTurnGeneration = generation;
this.#schedulePostPromptTask(
async () => {
if (this.#scheduledHiddenNextTurnGeneration === generation) {
this.#scheduledHiddenNextTurnGeneration = undefined;
}
if (this.#pendingNextTurnMessages.length === 0) {
return;
}
try {
await this.#promptQueuedHiddenNextTurnMessages();
} catch {
// Leave the hidden next-turn messages queued for the next explicit prompt.
}
},
{
generation,
onSkip: () => {
if (this.#scheduledHiddenNextTurnGeneration === generation) {
this.#scheduledHiddenNextTurnGeneration = undefined;
}
},
},
);
}
async #promptQueuedHiddenNextTurnMessages(): Promise<void> {
if (this.#pendingNextTurnMessages.length === 0) {
return;
}
const queuedMessages = [...this.#pendingNextTurnMessages];
this.#pendingNextTurnMessages = [];
const message = queuedMessages[queuedMessages.length - 1];
if (!message) {
return;
}
const prependMessages = queuedMessages.slice(0, -1);
const textContent = this.#getCustomMessageTextContent(message);
try {
await this.#promptWithMessage(message, textContent, {
prependMessages,
skipPostPromptRecoveryWait: true,
});
} catch (error) {
this.#pendingNextTurnMessages = [...queuedMessages, ...this.#pendingNextTurnMessages];
throw error;
}
}
#getCustomMessageTextContent(message: Pick<CustomMessage, "content">): string {
if (typeof message.content === "string") {
return message.content;
}
return message.content
.filter((content): content is TextContent => content.type === "text")
.map(content => content.text)
.join("");
}
/**
* Throw an error if the text is an extension command.
*/
#throwIfExtensionCommand(text: string): void {
if (!this.#extensionRunner) return;
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: queue as steer/follow-up or store for next turn
* - Not streaming + triggerTurn: appends to state/session, starts new turn unless the client cannot own it
* - Not streaming + no trigger: appends to state/session, no turn
*/
async sendCustomMessage<T = unknown>(
message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
): Promise<void> {
const appMessage: CustomMessage<T> = {
role: "custom",
customType: message.customType,
content: message.content,
display: message.display,
details: message.details,
attribution: message.attribution ?? "agent",
timestamp: Date.now(),
};
if (this.isStreaming) {
if (options?.deliverAs === "nextTurn") {
this.#queueHiddenNextTurnMessage(appMessage, options?.triggerTurn ?? false);
return;
}
if (options?.deliverAs === "followUp") {
this.agent.followUp(appMessage);
} else {
this.agent.steer(appMessage);
}
return;
}
if (options?.deliverAs === "nextTurn") {
if (options?.triggerTurn) {
if (this.#clientBridge?.deferAgentInitiatedTurns && !this.#allowAcpAgentInitiatedTurns) {
this.#queueHiddenNextTurnMessage(appMessage, false);
return;
}
await this.agent.prompt(appMessage);
return;
}
this.agent.appendMessage(appMessage);
this.sessionManager.appendCustomMessageEntry(
message.customType,
message.content,
message.display,
message.details,
message.attribution ?? "agent",
);
return;
}
if (options?.triggerTurn) {
if (this.#clientBridge?.deferAgentInitiatedTurns && !this.#allowAcpAgentInitiatedTurns) {
this.#queueHiddenNextTurnMessage(appMessage, false);
return;
}
await this.agent.prompt(appMessage);
return;
}
this.agent.appendMessage(appMessage);
this.sessionManager.appendCustomMessageEntry(
message.customType,
message.content,
message.display,
message.details,
message.attribution ?? "agent",
);
}
/**
* Send a user message to the agent.
* When deliverAs is set, queue the message instead of starting a new turn.
*
* @param content User message content (string or content array)
* @param options.deliverAs Delivery mode: "steer" or "followUp"
*/
async sendUserMessage(
content: string | (TextContent | ImageContent)[],
options?: { deliverAs?: "steer" | "followUp" },
): Promise<void> {
// Normalize content to text string + optional images
let text: string;
let images: ImageContent[] | undefined;
if (typeof content === "string") {
text = content;
} else {
const textParts: string[] = [];
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;
}
if (options?.deliverAs === "followUp") {
await this.#queueFollowUp(text, images);
return;
}
if (options?.deliverAs === "steer") {
await this.#queueSteer(text, images);
return;
}
// Use prompt() with expandPromptTemplates: false to skip command handling and template expansion
await this.prompt(text, {
expandPromptTemplates: false,
images,
});
}
/**
* Clear queued messages and return them.
* Useful for restoring to editor when user aborts.
*/
clearQueue(): { steering: string[]; followUp: string[] } {
const steering = this.#steeringMessages.map(e => e.text);
const followUp = this.#followUpMessages.map(e => e.text);
this.#steeringMessages = [];
this.#followUpMessages = [];
this.agent.clearAllQueues();
return { steering, followUp };
}
/** Number of pending messages (includes steering, follow-up, and next-turn messages) */
get queuedMessageCount(): number {
return this.#steeringMessages.length + this.#followUpMessages.length + this.#pendingNextTurnMessages.length;
}
/** Get pending messages (read-only). Returns the public text-only view;
* internal `{text, tag?}` records are mapped to `.text` so callers
* (`updatePendingMessagesDisplay`, `restoreQueuedMessagesToEditor`) see
* the unchanged historical shape. */
getQueuedMessages(): { steering: readonly string[]; followUp: readonly string[] } {
return {
steering: this.#steeringMessages.map(e => e.text),
followUp: this.#followUpMessages.map(e => e.text),
};
}
/**
* Pop the last queued message (steering first, then follow-up).
* Used by dequeue keybinding to restore messages to editor one at a time.
* Returns the popped entry's `.text`; the tag (if any) dies with the
* record — no orphan state can outlive the queue entry.
*/
popLastQueuedMessage(): string | undefined {
// Pop from steering first (LIFO)
if (this.#steeringMessages.length > 0) {
const entry = this.#steeringMessages.pop();
this.agent.popLastSteer();
return entry?.text;
}
// Then from follow-up
if (this.#followUpMessages.length > 0) {
const entry = this.#followUpMessages.pop();
this.agent.popLastFollowUp();
return entry?.text;
}
return undefined;
}
get skillsSettings(): SkillsSettings | undefined {
return this.#skillsSettings;
}
/** Skills loaded by SDK (empty if --no-skills or skills: [] was passed) */
get skills(): readonly Skill[] {
return this.#skills;
}
/** Skill loading warnings captured by SDK */
get skillWarnings(): readonly SkillWarning[] {
return this.#skillWarnings;
}
getTodoPhases(): TodoPhase[] {
return this.#cloneTodoPhases(this.#todoPhases);
}
setTodoPhases(phases: TodoPhase[]): void {
this.#todoPhases = this.#cloneTodoPhases(phases);
}
#syncTodoPhasesFromBranch(): void {
const phases = getLatestTodoPhasesFromEntries(this.sessionManager.getBranch());
// Strip completed/abandoned tasks — they were done in a previous run,
// so they have no bearing on progress tracking for the new turn.
for (const phase of phases) {
phase.tasks = phase.tasks.filter(t => t.status !== "completed" && t.status !== "abandoned");
}
this.setTodoPhases(phases.filter(p => p.tasks.length > 0));
}
#cloneTodoPhases(phases: TodoPhase[]): TodoPhase[] {
return phases.map(phase => ({
name: phase.name,
tasks: phase.tasks.map(task => {
const out: TodoItem = { content: task.content, status: task.status };
if (task.notes && task.notes.length > 0) out.notes = [...task.notes];
return out;
}),
}));
}
// Auto-clear of completed/abandoned tasks was removed: the timer-driven
// splice mutated canonical `#todoPhases` between tool calls, so the model
// observed phase totals shrinking ("5 → 4") after marking tasks done. The
// `tasks.todoClearDelay` setting is now inert; completed tasks survive
// until the next explicit `todo_write` call removes them via `rm`/`drop`.
/**
* Abort current operation and wait for agent to become idle.
*/
async abort(options?: { goalReason?: "interrupted" | "internal" }): Promise<void> {
this.abortRetry();
this.#promptGeneration++;
this.#scheduledHiddenNextTurnGeneration = undefined;
this.abortCompaction();
this.abortHandoff();
this.abortBash();
this.abortEval();
const postPromptDrain = this.#cancelPostPromptTasks();
this.agent.abort();
await postPromptDrain;
await this.agent.waitForIdle();
await this.#goalRuntime.onTaskAborted({ reason: options?.goalReason ?? "interrupted" });
// Clear prompt-in-flight state: waitForIdle resolves when the agent loop's finally
// block runs, but nested prompt setup/finalizers may still be unwinding. Without this,
// a subsequent prompt() can incorrectly observe the session as busy after an abort.
this.#resetInFlight();
// Safety net: if the agent loop aborted without producing an assistant
// message (e.g. failed before the first stream), the in-flight yield was
// never resolved or rejected by the normal message_end path. Reject it now
// so any requeue callback still fires and the queue stays consistent.
if (this.#toolChoiceQueue.hasInFlight) {
this.#toolChoiceQueue.reject("aborted");
}
}
/**
* Start a new session, optionally with initial messages and parent tracking.
* Clears all messages and starts a new session.
* Listeners are preserved and will continue receiving events.
* @param options - Optional initial messages and parent session path
* @returns true if completed, false if cancelled by hook
*/
async newSession(options?: NewSessionOptions): Promise<boolean> {
const previousSessionFile = this.sessionFile;
const nextDiscoverySessionToolNames = this.#mcpDiscoveryEnabled
? [
...this.#getActiveNonMCPToolNames(),
...this.#filterSelectableMCPToolNames(this.#defaultSelectedMCPToolNames),
]
: undefined;
// Emit session_before_switch event with reason "new" (can be cancelled)
if (this.#extensionRunner?.hasHandlers("session_before_switch")) {
const result = (await this.#extensionRunner.emit({
type: "session_before_switch",
reason: "new",
})) as SessionBeforeSwitchResult | undefined;
if (result?.cancel) {
return false;
}
}
this.#disconnectFromAgent();
await this.abort();
this.#cancelOwnAsyncJobs();
this.#closeAllProviderSessions("new session");
this.agent.reset();
if (options?.drop && previousSessionFile) {
try {
await this.sessionManager.dropSession(previousSessionFile);
} catch (err) {
logger.error("Failed to delete session during /drop", { err });
}
} else {
await this.sessionManager.flush();
}
await this.sessionManager.newSession(options);
this.setTodoPhases([]);
this.#syncAgentSessionId();
this.#rekeyHindsightMemoryForCurrentSessionId();
this.#resetHindsightConversationTrackingIfHindsight();
this.#steeringMessages = [];
this.#followUpMessages = [];
this.#pendingNextTurnMessages = [];
this.#scheduledHiddenNextTurnGeneration = undefined;
this.sessionManager.appendThinkingLevelChange(this.thinkingLevel);
this.sessionManager.appendServiceTierChange(this.serviceTier ?? null);
if (nextDiscoverySessionToolNames) {
await this.#applyActiveToolsByName(nextDiscoverySessionToolNames, { persistMCPSelection: false });
if (this.getSelectedMCPToolNames().length > 0) {
this.sessionManager.appendMCPToolSelection(this.getSelectedMCPToolNames());
}
}
this.#rememberSessionDefaultSelectedMCPToolNames(
this.sessionFile,
this.#getConfiguredDefaultSelectedMCPToolNames(),
);
this.#todoReminderCount = 0;
this.#planReferenceSent = false;
this.#planReferencePath = "local://PLAN.md";
this.#reconnectToAgent();
// Emit session_switch event with reason "new" to hooks
if (this.#extensionRunner) {
await this.#extensionRunner.emit({
type: "session_switch",
reason: "new",
previousSessionFile,
});
}
return true;
}
/**
* Set a display name for the current session.
*/
setSessionName(name: string, source: "auto" | "user" = "auto"): Promise<boolean> {
return this.sessionManager.setSessionName(name, source);
}
/**
* Fork the current session, creating a new session file with the exact same state.
* Copies all entries and artifacts to the new session.
* Unlike newSession(), this preserves all messages in the agent state.
* @returns true if completed, false if cancelled by hook or not persisting
*/
async fork(): Promise<boolean> {
const previousSessionFile = this.sessionFile;
// Emit session_before_switch event with reason "fork" (can be cancelled)
if (this.#extensionRunner?.hasHandlers("session_before_switch")) {
const result = (await this.#extensionRunner.emit({
type: "session_before_switch",
reason: "fork",
})) as SessionBeforeSwitchResult | undefined;
if (result?.cancel) {
return false;
}
}
// Flush current session to ensure all entries are written
await this.sessionManager.flush();
// Fork the session (creates new session file with same entries)
const forkResult = await this.sessionManager.fork();
if (!forkResult) {
return false;
}
// Copy artifacts directory if it exists
const oldArtifactDir = forkResult.oldSessionFile.slice(0, -6);
const newArtifactDir = forkResult.newSessionFile.slice(0, -6);
try {
const oldDirStat = await fs.promises.stat(oldArtifactDir);
if (oldDirStat.isDirectory()) {
await fs.promises.cp(oldArtifactDir, newArtifactDir, { recursive: true });
}
} catch (err) {
if (!isEnoent(err)) {
logger.warn("Failed to copy artifacts during fork", {
oldArtifactDir,
newArtifactDir,
error: err instanceof Error ? err.message : String(err),
});
}
}
// Update agent session ID
this.#syncAgentSessionId();
this.#rekeyHindsightMemoryForCurrentSessionId();
// Emit session_switch event with reason "fork" to hooks
if (this.#extensionRunner) {
await this.#extensionRunner.emit({
type: "session_switch",
reason: "fork",
previousSessionFile,
});
}
return true;
}
// =========================================================================
// Model Management
// =========================================================================
/**
* Set model directly.
* Validates API key, saves to session and settings.
* @throws Error if no API key available for the model
*/
async setModel(
model: Model,
role: string = "default",
options?: { selector?: string; thinkingLevel?: ThinkingLevel },
): Promise<void> {
const previousEditMode = this.#resolveActiveEditMode();
const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
if (!apiKey) {
throw new Error(`No API key for ${model.provider}/${model.id}`);
}
this.#clearActiveRetryFallback();
this.#setModelWithProviderSessionReset(model);
this.sessionManager.appendModelChange(`${model.provider}/${model.id}`, role);
this.settings.setModelRole(
role,
this.#formatRoleModelValue(role, model, options?.selector, options?.thinkingLevel),
);
this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
// Re-apply thinking for the newly selected model. Prefer the model's
// configured defaultLevel; otherwise preserve the current level.
this.setThinkingLevel(model.thinking?.defaultLevel ?? this.thinkingLevel);
await this.#syncEditToolModeAfterModelChange(previousEditMode);
}
/**
* Set model temporarily (for this session only).
* Validates API key, saves to session log but NOT to settings.
* @throws Error if no API key available for the model
*/
async setModelTemporary(model: Model, thinkingLevel?: ThinkingLevel): Promise<void> {
const previousEditMode = this.#resolveActiveEditMode();
const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
if (!apiKey) {
throw new Error(`No API key for ${model.provider}/${model.id}`);
}
this.#clearActiveRetryFallback();
this.#setModelWithProviderSessionReset(model);
this.sessionManager.appendModelChange(`${model.provider}/${model.id}`, "temporary");
this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
// Apply explicit thinking level if given; otherwise prefer the model's
// configured defaultLevel; otherwise re-clamp the current level.
this.setThinkingLevel(thinkingLevel ?? model.thinking?.defaultLevel ?? this.thinkingLevel);
await this.#syncEditToolModeAfterModelChange(previousEditMode);
}
/**
* 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" | "backward" = "forward"): Promise<ModelCycleResult | undefined> {
if (this.#scopedModels.length > 0) {
return this.#cycleScopedModel(direction);
}
return this.#cycleAvailableModel(direction);
}
/**
* Cycle through configured role models in a fixed order.
* Skips missing roles.
* @param roleOrder - Order of roles to cycle through (e.g., ["slow", "default", "smol"])
* @param options - Optional settings: `temporary` to not persist to settings
*/
async cycleRoleModels(
roleOrder: readonly string[],
options?: { temporary?: boolean },
): Promise<RoleModelCycleResult | undefined> {
const availableModels = this.#modelRegistry.getAvailable();
if (availableModels.length === 0) return undefined;
const currentModel = this.model;
if (!currentModel) return undefined;
const matchPreferences = { usageOrder: this.settings.getStorage()?.getModelUsageOrder() };
const roleModels: Array<{
role: string;
model: Model;
thinkingLevel?: ThinkingLevel;
explicitThinkingLevel: boolean;
}> = [];
for (const role of roleOrder) {
const roleModelStr =
role === "default"
? (this.settings.getModelRole("default") ?? `${currentModel.provider}/${currentModel.id}`)
: this.settings.getModelRole(role);
if (!roleModelStr) continue;
const resolved = resolveModelRoleValue(roleModelStr, availableModels, {
settings: this.settings,
matchPreferences,
modelRegistry: this.#modelRegistry,
});
if (!resolved.model) continue;
roleModels.push({
role,
model: resolved.model,
thinkingLevel: resolved.thinkingLevel,
explicitThinkingLevel: resolved.explicitThinkingLevel,
});
}
if (roleModels.length <= 1) return undefined;
const lastRole = this.sessionManager.getLastModelChangeRole();
let currentIndex = lastRole ? roleModels.findIndex(entry => entry.role === lastRole) : -1;
if (currentIndex === -1) {
currentIndex = roleModels.findIndex(entry => modelsAreEqual(entry.model, currentModel));
}
if (currentIndex === -1) currentIndex = 0;
const nextIndex = (currentIndex + 1) % roleModels.length;
const next = roleModels[nextIndex];
if (options?.temporary) {
await this.setModelTemporary(next.model, next.explicitThinkingLevel ? next.thinkingLevel : undefined);
} else {
await this.setModel(next.model, next.role);
if (next.explicitThinkingLevel && next.thinkingLevel !== undefined) {
this.setThinkingLevel(next.thinkingLevel);
}
}
return { model: next.model, thinkingLevel: this.thinkingLevel, role: next.role };
}
async #getScopedModelsWithApiKey(): Promise<Array<{ model: Model; thinkingLevel?: ThinkingLevel }>> {
const apiKeysByProvider = new Map<string, string | undefined>();
const result: Array<{ model: Model; thinkingLevel?: ThinkingLevel }> = [];
for (const scoped of this.#scopedModels) {
const provider = scoped.model.provider;
let apiKey: string | undefined;
if (apiKeysByProvider.has(provider)) {
apiKey = apiKeysByProvider.get(provider);
} else {
apiKey = await this.#modelRegistry.getApiKeyForProvider(provider, this.sessionId);
apiKeysByProvider.set(provider, apiKey);
}
if (apiKey) {
result.push(scoped);
}
}
return result;
}
async #cycleScopedModel(direction: "forward" | "backward"): Promise<ModelCycleResult | undefined> {
const previousEditMode = this.#resolveActiveEditMode();
const scopedModels = await this.#getScopedModelsWithApiKey();
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];
// Apply model
this.#clearActiveRetryFallback();
this.#setModelWithProviderSessionReset(next.model);
this.sessionManager.appendModelChange(`${next.model.provider}/${next.model.id}`);
this.settings.setModelRole("default", this.#formatRoleModelValue("default", next.model));
this.settings.getStorage()?.recordModelUsage(`${next.model.provider}/${next.model.id}`);
// Apply the scoped model's configured thinking level
this.setThinkingLevel(next.thinkingLevel);
await this.#syncEditToolModeAfterModelChange(previousEditMode);
return { model: next.model, thinkingLevel: this.thinkingLevel, isScoped: true };
}
async #cycleAvailableModel(direction: "forward" | "backward"): Promise<ModelCycleResult | undefined> {
const previousEditMode = this.#resolveActiveEditMode();
const availableModels = 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 apiKey = await this.#modelRegistry.getApiKey(nextModel, this.sessionId);
if (!apiKey) {
throw new Error(`No API key for ${nextModel.provider}/${nextModel.id}`);
}
this.#clearActiveRetryFallback();
this.#setModelWithProviderSessionReset(nextModel);
this.sessionManager.appendModelChange(`${nextModel.provider}/${nextModel.id}`);
this.settings.setModelRole("default", this.#formatRoleModelValue("default", nextModel));
this.settings.getStorage()?.recordModelUsage(`${nextModel.provider}/${nextModel.id}`);
// Re-apply the current thinking level for the newly selected model
this.setThinkingLevel(this.thinkingLevel);
await this.#syncEditToolModeAfterModelChange(previousEditMode);
return { model: nextModel, thinkingLevel: this.thinkingLevel, isScoped: false };
}
/**
* Get all available models with valid API keys.
*/
getAvailableModels(): Model[] {
return this.#modelRegistry.getAvailable();
}
// =========================================================================
// Thinking Level Management
// =========================================================================
/**
* Set thinking level.
* Saves the effective metadata-clamped level to session and settings only if it changes.
*/
setThinkingLevel(level: ThinkingLevel | undefined, persist: boolean = false): void {
const effectiveLevel = resolveThinkingLevelForModel(this.model, level);
const isChanging = effectiveLevel !== this.#thinkingLevel;
this.#thinkingLevel = effectiveLevel;
this.agent.setThinkingLevel(toReasoningEffort(effectiveLevel));
if (isChanging) {
this.sessionManager.appendThinkingLevelChange(effectiveLevel);
if (persist && effectiveLevel !== undefined && effectiveLevel !== ThinkingLevel.Off) {
this.settings.set("defaultThinkingLevel", effectiveLevel);
}
this.#emit({ type: "thinking_level_changed", thinkingLevel: effectiveLevel });
}
}
/**
* Cycle to next thinking level.
* @returns New level, or undefined if model doesn't support thinking
*/
cycleThinkingLevel(): ThinkingLevel | undefined {
if (!this.model?.reasoning) return undefined;
const levels = [ThinkingLevel.Off, ...this.getAvailableThinkingLevels()];
const currentLevel = this.thinkingLevel === ThinkingLevel.Inherit ? ThinkingLevel.Off : this.thinkingLevel;
const currentIndex = currentLevel ? levels.indexOf(currentLevel) : -1;
const nextIndex = (currentIndex + 1) % levels.length;
const nextLevel = levels[nextIndex];
if (!nextLevel) return undefined;
this.setThinkingLevel(nextLevel);
return nextLevel;
}
/**
* True when *any* fast-mode-granting service tier is configured, regardless
* of whether the active model's provider actually realizes it. Used by the
* toggle (`/fast on|off`) so re-toggling a scoped tier (`openai-only`,
* `claude-only`) doesn't silently broaden it to unscoped `priority`.
*
* For "is fast mode actually applied to the next request?" use
* {@link isFastModeActive} instead — that one respects the model's provider.
*/
isFastModeEnabled(): boolean {
return (
this.serviceTier === "priority" || this.serviceTier === "claude-only" || this.serviceTier === "openai-only"
);
}
/**
* True when the configured `serviceTier` resolves to `"priority"` for the
* *currently selected model's provider*. Returns false for scoped tiers
* that don't match (e.g. `"openai-only"` on an anthropic model) and when
* no model is selected.
*/
isFastModeActive(): boolean {
return resolveServiceTier(this.serviceTier, this.model?.provider) === "priority";
}
setServiceTier(serviceTier: ServiceTier | undefined): void {
if (this.serviceTier === serviceTier) return;
// Re-arming priority on Anthropic? Clear the per-session auto-fallback
// sticky disable so the next request actually carries `speed: "fast"`
// again. Without this, `/fast on` (or user switching to a tier that
// grants anthropic priority) after an auto-disable is a silent no-op
// and the warning notice fires every turn.
if (serviceTier === "priority" || serviceTier === "claude-only") {
clearAnthropicFastModeFallback(this.#providerSessionState);
}
this.agent.serviceTier = serviceTier;
this.sessionManager.appendServiceTierChange(serviceTier ?? null);
}
setFastMode(enabled: boolean): void {
if (enabled && this.isFastModeEnabled()) {
// Already on under any scope — keep the user's scoped value.
return;
}
this.setServiceTier(enabled ? "priority" : undefined);
}
toggleFastMode(): boolean {
const enabled = !this.isFastModeEnabled();
this.setFastMode(enabled);
return enabled;
}
/**
* Get available thinking levels for current model.
*/
getAvailableThinkingLevels(): ReadonlyArray<Effort> {
if (!this.model) return [];
return getSupportedEfforts(this.model);
}
// =========================================================================
// Message Queue Mode Management
// =========================================================================
/**
* Set steering mode.
* Saves to settings.
*/
setSteeringMode(mode: "all" | "one-at-a-time"): void {
this.agent.setSteeringMode(mode);
this.settings.set("steeringMode", mode);
}
/**
* Set follow-up mode.
* Saves to settings.
*/
setFollowUpMode(mode: "all" | "one-at-a-time"): void {
this.agent.setFollowUpMode(mode);
this.settings.set("followUpMode", mode);
}
/**
* Set interrupt mode.
* Saves to settings.
*/
setInterruptMode(mode: "immediate" | "wait"): void {
this.agent.setInterruptMode(mode);
this.settings.set("interruptMode", mode);
}
// =========================================================================
// Compaction
// =========================================================================
async #pruneToolOutputs(): Promise<{ prunedCount: number; tokensSaved: number } | undefined> {
const branchEntries = this.sessionManager.getBranch();
const result = pruneToolOutputs(branchEntries, DEFAULT_PRUNE_CONFIG);
if (result.prunedCount === 0) {
return undefined;
}
await this.sessionManager.rewriteEntries();
const sessionContext = this.buildDisplaySessionContext();
this.agent.replaceMessages(sessionContext.messages);
this.#syncTodoPhasesFromBranch();
this.#closeCodexProviderSessionsForHistoryRewrite();
return result;
}
/**
* Strip image content blocks from every message on the current branch and
* persist the rewrite. Walks `SessionManager.getBranch()` in place — both
* `SessionMessageEntry.message` and `CustomMessageEntry.content` arrays
* are mutated, then `rewriteEntries` durably commits the new shape. The
* agent's runtime view is rebuilt from the freshly-mutated entries so any
* provider sessions caching message identity (Codex Responses) are torn
* down to force a clean replay on the next turn.
*
* No-op when the branch carries no images; returns `{ removed: 0 }` and
* skips the disk rewrite.
*/
async dropImages(): Promise<{ removed: number }> {
const branchEntries = this.sessionManager.getBranch();
let removed = 0;
for (const entry of branchEntries) {
if (entry.type === "message") {
removed += stripImagesFromMessage(entry.message);
continue;
}
if (entry.type === "custom_message" && typeof entry.content !== "string") {
const kept: typeof entry.content = [];
let dropped = 0;
for (const part of entry.content) {
if (part.type === "image") {
dropped++;
} else {
kept.push(part);
}
}
if (dropped > 0) {
if (kept.length === 0) {
kept.push({ type: "text", text: "[image removed]" });
}
entry.content = kept;
removed += dropped;
}
}
}
if (removed === 0) {
return { removed: 0 };
}
await this.sessionManager.rewriteEntries();
const sessionContext = this.buildDisplaySessionContext();
this.agent.replaceMessages(sessionContext.messages);
this.#closeCodexProviderSessionsForHistoryRewrite();
return { removed };
}
/**
* Manually compact the session context.
* Aborts current agent operation first.
* @param customInstructions Optional instructions for the compaction summary
* @param options Optional callbacks for completion/error handling
*/
async compact(customInstructions?: string, options?: CompactOptions): Promise<CompactionResult> {
if (this.#compactionAbortController) {
throw new Error("Compaction already in progress");
}
this.#disconnectFromAgent();
await this.abort();
const compactionAbortController = new AbortController();
this.#compactionAbortController = compactionAbortController;
try {
if (!this.model) {
throw new Error("No model selected");
}
const compactionSettings = this.settings.getGroup("compaction");
const pathEntries = this.sessionManager.getBranch();
const preparation = prepareCompaction(pathEntries, compactionSettings);
if (!preparation) {
// Check why we can't compact
const lastEntry = pathEntries[pathEntries.length - 1];
if (lastEntry?.type === "compaction") {
throw new Error("Already compacted");
}
throw new Error("Nothing to compact (session too small)");
}
let hookCompaction: CompactionResult | undefined;
let fromExtension = false;
let preserveData: Record<string, unknown> | undefined;
if (this.#extensionRunner?.hasHandlers("session_before_compact")) {
const result = (await this.#extensionRunner.emit({
type: "session_before_compact",
preparation,
branchEntries: pathEntries,
customInstructions,
signal: compactionAbortController.signal,
})) as SessionBeforeCompactResult | undefined;
if (result?.cancel) {
throw new CompactionCancelledError();
}
if (result?.compaction) {
hookCompaction = result.compaction;
fromExtension = true;
}
}
const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction);
let summary: string;
let shortSummary: string | undefined;
let firstKeptEntryId: string;
let tokensBefore: number;
let details: unknown;
if (compactionPrep.kind === "fromHook") {
summary = compactionPrep.summary;
shortSummary = compactionPrep.shortSummary;
firstKeptEntryId = compactionPrep.firstKeptEntryId;
tokensBefore = compactionPrep.tokensBefore;
details = compactionPrep.details;
preserveData = compactionPrep.preserveData;
} else {
// Generate compaction result. Only convert known abort-shaped
// rejections (AbortError raised while the abort signal is set,
// or an already-typed sentinel) into `CompactionCancelledError`
// so downstream callers can discriminate cancel from generic
// failure via `instanceof` without inspecting message strings.
// Real compaction bugs (network, server, parsing, etc.) keep
// their original shape — they must not be silently relabeled
// as cancellations even if the signal happens to be aborted
// for an unrelated reason. Assignments live inside the try
// block because every catch path throws — the post-try reads
// of the result-derived locals are reachable only on success.
try {
const result = await this.#compactWithFallbackModel(
preparation,
customInstructions,
compactionAbortController.signal,
{
promptOverride: compactionPrep.hookPrompt,
extraContext: compactionPrep.hookContext,
remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
convertToLlm,
},
);
summary = result.summary;
shortSummary = result.shortSummary;
firstKeptEntryId = result.firstKeptEntryId;
tokensBefore = result.tokensBefore;
details = result.details;
preserveData = { ...(compactionPrep.preserveData ?? {}), ...(result.preserveData ?? {}) };
} catch (err) {
if (err instanceof CompactionCancelledError) {
throw err;
}
if (compactionAbortController.signal.aborted && err instanceof Error && err.name === "AbortError") {
throw new CompactionCancelledError();
}
throw err;
}
}
if (compactionAbortController.signal.aborted) {
throw new CompactionCancelledError();
}
this.sessionManager.appendCompaction(
summary,
shortSummary,
firstKeptEntryId,
tokensBefore,
details,
fromExtension,
preserveData,
);
const newEntries = this.sessionManager.getEntries();
const sessionContext = this.buildDisplaySessionContext();
this.agent.replaceMessages(sessionContext.messages);
this.#syncTodoPhasesFromBranch();
this.#closeCodexProviderSessionsForHistoryRewrite();
// Get the saved compaction entry for the hook
const savedCompactionEntry = newEntries.find(e => e.type === "compaction" && e.summary === summary) as
| CompactionEntry
| undefined;
if (this.#extensionRunner && savedCompactionEntry) {
await this.#extensionRunner.emit({
type: "session_compact",
compactionEntry: savedCompactionEntry,
fromExtension,
});
}
const compactionResult: CompactionResult = {
summary,
shortSummary,
firstKeptEntryId,
tokensBefore,
details,
preserveData,
};
options?.onComplete?.(compactionResult);
return compactionResult;
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
options?.onError?.(err);
throw error;
} finally {
if (this.#compactionAbortController === compactionAbortController) {
this.#compactionAbortController = undefined;
}
this.#reconnectToAgent();
}
}
/**
* Ask the active memory backend for an extra-context block to splice into
* the compaction summary prompt. Both the manual and auto compaction paths
* funnel through this helper so the behaviour stays identical.
*
* Failures are swallowed: a memory backend going sideways MUST NOT block
* compaction (which is itself the recovery path for context overflow).
*/
async #collectMemoryBackendContext(preparation: {
messagesToSummarize: AgentMessage[];
turnPrefixMessages: AgentMessage[];
}): Promise<string | undefined> {
const backend = resolveMemoryBackend(this.settings);
if (!backend.preCompactionContext) return undefined;
const messages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages);
try {
return await backend.preCompactionContext(messages, this.settings, this);
} catch (err) {
logger.debug("Memory backend preCompactionContext failed", {
backend: backend.id,
error: String(err),
});
return undefined;
}
}
/**
* Cancel in-progress context maintenance (manual compaction, auto-compaction, or auto-handoff).
*/
abortCompaction(): void {
this.#compactionAbortController?.abort();
this.#autoCompactionAbortController?.abort();
this.#handoffAbortController?.abort();
}
/** Trigger idle compaction through the auto-compaction flow (with UI events). */
async runIdleCompaction(): Promise<void> {
if (this.isStreaming || this.isCompacting) return;
await this.#runAutoCompaction("idle", false, true);
}
/**
* Cancel in-progress branch summarization.
*/
abortBranchSummary(): void {
this.#branchSummaryAbortController?.abort();
}
/**
* Cancel in-progress handoff generation.
*/
abortHandoff(): void {
this.#handoffAbortController?.abort();
}
/**
* Check if handoff generation is in progress.
*/
get isGeneratingHandoff(): boolean {
return this.#handoffAbortController !== undefined;
}
/**
* Generate a handoff document with a oneshot LLM call, then start a new session with it.
*
* @param customInstructions Optional focus for the handoff document
* @param options Handoff execution options
* @returns The handoff document text, or undefined if cancelled/failed
*/
async handoff(customInstructions?: string, options?: SessionHandoffOptions): Promise<HandoffResult | undefined> {
const entries = this.sessionManager.getBranch();
const messageCount = entries.filter(e => e.type === "message").length;
if (messageCount < 2) {
throw new Error("Nothing to hand off (no messages yet)");
}
this.#skipPostTurnMaintenanceAssistantTimestamp = undefined;
this.#handoffAbortController = new AbortController();
const handoffAbortController = this.#handoffAbortController;
const handoffSignal = handoffAbortController.signal;
const sourceSignal = options?.signal;
const onSourceAbort = () => {
if (!handoffSignal.aborted) {
handoffAbortController.abort();
}
};
if (sourceSignal) {
sourceSignal.addEventListener("abort", onSourceAbort, { once: true });
if (sourceSignal.aborted) {
onSourceAbort();
}
}
try {
if (handoffSignal.aborted) {
throw new Error("Handoff cancelled");
}
const model = this.model;
if (!model) {
throw new Error("No model selected for handoff");
}
const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
if (!apiKey) {
throw new Error(`No API key for ${model.provider}`);
}
const handoffText = await generateHandoff(
this.agent.state.messages,
model,
apiKey,
{
systemPrompt: this.#baseSystemPrompt,
tools: this.agent.state.tools,
customInstructions,
convertToLlm,
initiatorOverride: "agent",
metadata: this.agent.metadataForProvider(model.provider),
telemetry: resolveTelemetry(this.agent.telemetry, this.sessionId),
// Honor the user's /model thinking selection on the handoff
// path. Clamped per-model inside generateHandoff via
// resolveCompactionEffort so unsupported-effort models don't
// trip requireSupportedEffort.
thinkingLevel: this.thinkingLevel,
},
handoffSignal,
);
if (handoffSignal.aborted) {
throw new Error("Handoff cancelled");
}
if (!handoffText) {
return undefined;
}
// Start a new session
const previousSessionFile = this.sessionFile;
await this.sessionManager.flush();
this.#cancelOwnAsyncJobs();
await this.sessionManager.newSession(previousSessionFile ? { parentSession: previousSessionFile } : undefined);
this.agent.reset();
this.#syncAgentSessionId();
this.#rekeyHindsightMemoryForCurrentSessionId();
this.#resetHindsightConversationTrackingIfHindsight();
this.#steeringMessages = [];
this.#followUpMessages = [];
this.#pendingNextTurnMessages = [];
this.#scheduledHiddenNextTurnGeneration = undefined;
this.#todoReminderCount = 0;
// Inject the handoff document as a custom message
const handoffContent = createHandoffContext(handoffText);
this.sessionManager.appendCustomMessageEntry("handoff", handoffContent, true, undefined, "agent");
await this.sessionManager.ensureOnDisk();
let savedPath: string | undefined;
if (options?.autoTriggered && this.settings.get("compaction.handoffSaveToDisk")) {
const artifactsDir = this.sessionManager.getArtifactsDir();
if (artifactsDir) {
const handoffFilePath = path.join(artifactsDir, createHandoffFileName());
try {
await Bun.write(handoffFilePath, `${handoffText}\n`);
savedPath = handoffFilePath;
} catch (error) {
logger.warn("Failed to save handoff document to disk", {
path: handoffFilePath,
error: error instanceof Error ? error.message : String(error),
});
}
} else {
logger.debug("Skipping handoff document save because session is not persisted");
}
}
// Rebuild agent messages from session
const sessionContext = this.buildDisplaySessionContext();
this.agent.replaceMessages(sessionContext.messages);
this.#syncTodoPhasesFromBranch();
return { document: handoffText, savedPath };
} catch (error) {
if (handoffSignal.aborted || (error instanceof Error && error.name === "AbortError")) {
throw new Error("Handoff cancelled");
}
throw error;
} finally {
sourceSignal?.removeEventListener("abort", onSourceAbort);
this.#handoffAbortController = undefined;
}
}
/**
* Check if context maintenance or promotion is needed and run it.
* Called after agent_end and before prompt submission.
*
* Four cases (in order):
* 1. Input overflow + promotion: promote to larger model, retry without maintenance.
* 2. Input overflow + no promotion target: run context maintenance, auto-retry on same model.
* 3. Output incomplete (stopReason === "length", e.g. `response.incomplete`): the
* model burned its output budget without producing an actionable deliverable
* (reasoning-only or truncated). Drop the dead turn, try promotion, otherwise
* run compaction/handoff and retry.
* 4. Threshold: context over threshold, run context maintenance (no auto-retry).
*
* @param assistantMessage The assistant message to check
* @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true
* @param allowDefer If true, threshold-driven handoff strategy may schedule itself as a
* deferred post-prompt task instead of running inline. Callers running inside the
* `agent_end` handler set this to true so `session.prompt()` resolves cleanly; callers
* on the pre-prompt path (where the next agent turn is about to start) set it to false
* to avoid racing the deferred handoff against the new turn.
* @returns true when a deferred handoff was scheduled. Callers MUST then skip any
* subsequent `#scheduleAgentContinue` / reminder appends for this turn — the
* handoff will replace session state and a concurrent `agent.continue()` would
* stream into the soon-to-be-discarded session.
*/
async #checkCompaction(
assistantMessage: AssistantMessage,
skipAbortedCheck = true,
allowDefer = true,
): Promise<boolean> {
// Skip if message was aborted (user cancelled) - unless skipAbortedCheck is false
if (skipAbortedCheck && assistantMessage.stopReason === "aborted") return false;
const contextWindow = this.model?.contextWindow ?? 0;
const generation = this.#promptGeneration;
// Skip overflow check if the message came from a different model.
// This handles the case where user switched from a smaller-context model (e.g. opus)
// to a larger-context model (e.g. codex) - the overflow error from the old model
// shouldn't trigger compaction for the new model.
const sameModel =
this.model && assistantMessage.provider === this.model.provider && assistantMessage.model === this.model.id;
// This handles the case where an error was kept after compaction (in the "kept" region).
// The error shouldn't trigger another compaction since we already compacted.
// Example: opus fails -> switch to codex -> compact -> switch back to opus -> opus error
// is still in context but shouldn't trigger compaction again.
const compactionEntry = getLatestCompactionEntry(this.sessionManager.getBranch());
const errorIsFromBeforeCompaction =
compactionEntry !== null && assistantMessage.timestamp < new Date(compactionEntry.timestamp).getTime();
if (sameModel && !errorIsFromBeforeCompaction && isContextOverflow(assistantMessage, contextWindow)) {
// Remove the error message from agent state (it IS saved to session for history,
// but we don't want it in context for the retry)
const messages = this.agent.state.messages;
if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
this.agent.replaceMessages(messages.slice(0, -1));
}
// Try context promotion first - switch to a larger model and retry without compacting
const promoted = await this.#tryContextPromotion(assistantMessage);
if (promoted) {
// Retry on the promoted (larger) model without compacting
this.#scheduleAgentContinue({ delayMs: 100, generation });
return false;
}
// No promotion target available fall through to compaction
const compactionSettings = this.settings.getGroup("compaction");
if (compactionSettings.enabled && compactionSettings.strategy !== "off") {
await this.#runAutoCompaction("overflow", true, false, allowDefer);
}
return false;
}
// Case 3: Output-side incomplete — `response.incomplete` from OpenAI Responses
// (and Codex) maps to stopReason === "length". The model burned its
// `max_output_tokens` budget on reasoning/text and emitted no actionable
// deliverable. Same recovery class as overflow: promotion if available,
// otherwise compaction/handoff. Unlike overflow, the *input* is fine, so we
// allow the handoff strategy to actually run.
if (sameModel && !errorIsFromBeforeCompaction && assistantMessage.stopReason === "length") {
const messages = this.agent.state.messages;
if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
this.agent.replaceMessages(messages.slice(0, -1));
}
const promoted = await this.#tryContextPromotion(assistantMessage);
if (promoted) {
logger.debug("Context promotion triggered by response.incomplete (length stop)", {
from: `${assistantMessage.provider}/${assistantMessage.model}`,
});
this.#scheduleAgentContinue({ delayMs: 100, generation });
return false;
}
const incompleteCompactionSettings = this.settings.getGroup("compaction");
if (incompleteCompactionSettings.enabled && incompleteCompactionSettings.strategy !== "off") {
logger.debug("Compaction triggered by response.incomplete (length stop, no promotion target)", {
model: `${assistantMessage.provider}/${assistantMessage.model}`,
strategy: incompleteCompactionSettings.strategy,
});
await this.#runAutoCompaction("incomplete", true, false, allowDefer);
} else {
// Neither promotion nor compaction is available — surface the dead-end so
// the user understands why the turn yielded with nothing.
logger.warn("response.incomplete with no recovery path (promotion + compaction both unavailable)", {
model: `${assistantMessage.provider}/${assistantMessage.model}`,
});
}
return false;
}
const compactionSettings = this.settings.getGroup("compaction");
if (!compactionSettings.enabled || compactionSettings.strategy === "off") return false;
// Case 4: Threshold - turn succeeded but context is getting large
// Skip if this was an error (non-overflow errors don't have usage data)
if (assistantMessage.stopReason === "error") return false;
const pruneResult = await this.#pruneToolOutputs();
let contextTokens = calculateContextTokens(assistantMessage.usage);
if (pruneResult) {
contextTokens = Math.max(0, contextTokens - pruneResult.tokensSaved);
}
if (shouldCompact(contextTokens, contextWindow, compactionSettings)) {
// Try promotion first — if a larger model is available, switch instead of compacting
const promoted = await this.#tryContextPromotion(assistantMessage);
if (!promoted) {
return await this.#runAutoCompaction("threshold", false, false, allowDefer);
}
}
return false;
}
#assistantEndedWithSuccessfulYield(assistantMessage: AssistantMessage): boolean {
const toolCallId = this.#lastSuccessfulYieldToolCallId;
if (!toolCallId) return false;
const lastToolCall = assistantMessage.content
.slice()
.reverse()
.find((content): content is ToolCall => content.type === "toolCall");
return lastToolCall?.name === "yield" && lastToolCall.id === toolCallId;
}
#enforceRewindBeforeYield(): boolean {
if (!this.#checkpointState || this.#pendingRewindReport) {
return false;
}
const reminder = [
"<system-warning>",
"You are in an active checkpoint. You MUST call rewind with your investigation findings before yielding. Do NOT yield without completing the checkpoint.",
"</system-warning>",
].join("\n");
this.agent.appendMessage({
role: "developer",
content: [{ type: "text", text: reminder }],
attribution: "agent",
timestamp: Date.now(),
});
this.#scheduleAgentContinue({ generation: this.#promptGeneration });
return true;
}
async #applyRewind(report: string): Promise<void> {
const checkpointState = this.#checkpointState;
if (!checkpointState) {
return;
}
const safeCount = Math.max(0, Math.min(checkpointState.checkpointMessageCount, this.agent.state.messages.length));
this.agent.replaceMessages(this.agent.state.messages.slice(0, safeCount));
try {
this.sessionManager.branchWithSummary(checkpointState.checkpointEntryId, report, {
startedAt: checkpointState.startedAt,
});
} catch (error) {
logger.warn("Rewind branch checkpoint missing, falling back to root", {
error: error instanceof Error ? error.message : String(error),
});
this.sessionManager.branchWithSummary(null, report, { startedAt: checkpointState.startedAt });
}
const details = { startedAt: checkpointState.startedAt, rewoundAt: new Date().toISOString() };
this.agent.appendMessage({
role: "custom",
customType: "rewind-report",
content: report,
display: false,
details,
attribution: "agent",
timestamp: Date.now(),
});
this.sessionManager.appendCustomMessageEntry("rewind-report", report, false, details, "agent");
this.#checkpointState = undefined;
this.#pendingRewindReport = undefined;
}
async #enforcePlanModeToolDecision(): Promise<void> {
if (!this.#planModeState?.enabled) {
return;
}
const assistantMessage = this.#findLastAssistantMessage();
if (!assistantMessage) {
return;
}
if (assistantMessage.stopReason === "error" || assistantMessage.stopReason === "aborted") {
return;
}
const calledRequiredTool = assistantMessage.content.some(
content => content.type === "toolCall" && (content.name === "ask" || content.name === "resolve"),
);
if (calledRequiredTool) {
return;
}
const hasRequiredTools = this.#toolRegistry.has("ask") && this.#toolRegistry.has("resolve");
if (!hasRequiredTools) {
logger.warn("Plan mode enforcement skipped because ask/resolve tools are unavailable", {
activeToolNames: this.agent.state.tools.map(tool => tool.name),
});
return;
}
const reminder = prompt.render(planModeToolDecisionReminderPrompt, {
askToolName: "ask",
});
await this.prompt(reminder, {
synthetic: true,
expandPromptTemplates: false,
toolChoice: "required",
});
}
#createEagerTodoPrelude(promptText: string): { message: AgentMessage; toolChoice: ToolChoice } | undefined {
const eagerTodosEnabled = this.settings.get("todo.eager");
const todosEnabled = this.settings.get("todo.enabled");
if (!eagerTodosEnabled || !todosEnabled) {
return undefined;
}
if (this.#planModeState?.enabled) {
return undefined;
}
if (this.getTodoPhases().length > 0) {
return undefined;
}
// Only inject on the first user message of the conversation. Subsequent user
// turns must not receive the eager todo reminder — they often correct, clarify,
// or redirect the prior task, and forcing a brand-new todo list there is wrong.
const hasPriorUserMessage = this.agent.state.messages.some(m => m.role === "user");
if (hasPriorUserMessage) {
return undefined;
}
const trimmedPromptText = promptText.trimEnd();
if (trimmedPromptText.endsWith("?") || trimmedPromptText.endsWith("!")) {
return undefined;
}
if (!this.#toolRegistry.has("todo_write")) {
logger.warn("Eager todo enforcement skipped because todo_write is unavailable", {
activeToolNames: this.agent.state.tools.map(tool => tool.name),
});
return undefined;
}
const todoWriteToolChoice = buildNamedToolChoice("todo_write", this.model);
if (!todoWriteToolChoice) {
logger.warn("Eager todo enforcement skipped because the current model does not support forcing todo_write", {
modelApi: this.model?.api,
modelId: this.model?.id,
});
return undefined;
}
const eagerTodoReminder = prompt.render(eagerTodoPrompt);
return {
message: {
role: "custom",
customType: "eager-todo-prelude",
content: eagerTodoReminder,
display: false,
attribution: "agent",
timestamp: Date.now(),
},
toolChoice: todoWriteToolChoice,
};
}
/**
* Check if agent stopped with incomplete todos and prompt to continue.
*/
async #checkTodoCompletion(): Promise<void> {
// Skip todo reminders when the most recent turn was driven by an explicit user force —
// the user wanted exactly that tool, not a follow-up nag about incomplete todos.
const lastServedLabel = this.#toolChoiceQueue.consumeLastServedLabel();
if (lastServedLabel === "user-force") {
return;
}
const remindersEnabled = this.settings.get("todo.reminders");
const todosEnabled = this.settings.get("todo.enabled");
if (!remindersEnabled || !todosEnabled) {
this.#todoReminderCount = 0;
return;
}
const remindersMax = this.settings.get("todo.reminders.max");
if (this.#todoReminderCount >= remindersMax) {
logger.debug("Todo completion: max reminders reached", { count: this.#todoReminderCount });
return;
}
const phases = this.getTodoPhases();
if (phases.length === 0) {
this.#todoReminderCount = 0;
return;
}
const incompleteByPhase = phases
.map(phase => ({
name: phase.name,
tasks: phase.tasks
.filter(
(task): task is TodoItem & { status: "pending" | "in_progress" } =>
task.status === "pending" || task.status === "in_progress",
)
.map(task => ({ content: task.content, status: task.status })),
}))
.filter(phase => phase.tasks.length > 0);
const incomplete = incompleteByPhase.flatMap(phase => phase.tasks);
if (incomplete.length === 0) {
this.#todoReminderCount = 0;
return;
}
// Build reminder message
this.#todoReminderCount++;
const todoList = incompleteByPhase
.map(phase => `- ${phase.name}\n${phase.tasks.map(task => ` - ${task.content}`).join("\n")}`)
.join("\n");
const reminder =
`<system-reminder>\n` +
`You stopped with ${incomplete.length} incomplete todo item(s):\n${todoList}\n\n` +
`Please continue working on these tasks or mark them complete if finished.\n` +
`(Reminder ${this.#todoReminderCount}/${remindersMax})\n` +
`</system-reminder>`;
logger.debug("Todo completion: sending reminder", {
incomplete: incomplete.length,
attempt: this.#todoReminderCount,
});
// Emit event for UI to render notification
await this.#emitSessionEvent({
type: "todo_reminder",
todos: incomplete,
attempt: this.#todoReminderCount,
maxAttempts: remindersMax,
});
// Inject reminder and continue the conversation
this.agent.appendMessage({
role: "developer",
content: [{ type: "text", text: reminder }],
attribution: "agent",
timestamp: Date.now(),
});
this.#scheduleAgentContinue({ generation: this.#promptGeneration });
}
/**
* Attempt context promotion to a larger model.
* Returns true if promotion succeeded (caller should retry without compacting).
*/
async #tryContextPromotion(assistantMessage: AssistantMessage): Promise<boolean> {
const promotionSettings = this.settings.getGroup("contextPromotion");
if (!promotionSettings.enabled) return false;
const currentModel = this.model;
if (!currentModel) return false;
if (assistantMessage.provider !== currentModel.provider || assistantMessage.model !== currentModel.id)
return false;
const contextWindow = currentModel.contextWindow ?? 0;
if (contextWindow <= 0) return false;
const targetModel = await this.#resolveContextPromotionTarget(currentModel, contextWindow);
if (!targetModel) return false;
try {
await this.setModelTemporary(targetModel);
logger.debug("Context promotion switched model on overflow", {
from: `${currentModel.provider}/${currentModel.id}`,
to: `${targetModel.provider}/${targetModel.id}`,
});
return true;
} catch (error) {
logger.warn("Context promotion failed", {
from: `${currentModel.provider}/${currentModel.id}`,
to: `${targetModel.provider}/${targetModel.id}`,
error: String(error),
});
return false;
}
}
async #resolveContextPromotionTarget(currentModel: Model, contextWindow: number): Promise<Model | undefined> {
const availableModels = this.#modelRegistry.getAvailable();
if (availableModels.length === 0) return undefined;
const candidate = this.#resolveContextPromotionConfiguredTarget(currentModel, availableModels);
if (!candidate) return undefined;
if (modelsAreEqual(candidate, currentModel)) return undefined;
if (candidate.contextWindow <= contextWindow) return undefined;
const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId);
if (!apiKey) return undefined;
return candidate;
}
#setModelWithProviderSessionReset(model: Model): void {
const currentModel = this.model;
if (currentModel) {
this.#closeProviderSessionsForModelSwitch(currentModel, model);
}
this.agent.setModel(model);
// Re-evaluate append-only context mode — provider or setting may have changed
this.#syncAppendOnlyContext(model);
}
#closeCodexProviderSessionsForHistoryRewrite(): void {
const currentModel = this.model;
if (!currentModel || currentModel.api !== "openai-codex-responses") return;
this.#closeProviderSessionsForModelSwitch(currentModel, currentModel);
}
/**
* Re-evaluate append-only context mode, creating or destroying the
* manager as needed. Called on model switch AND setting change.
*/
#syncAppendOnlyContext(model: Model | null | undefined): void {
const setting = this.settings.get("provider.appendOnlyContext") ?? "auto";
const providerId = model?.provider;
const enable = setting === "on" || (setting === "auto" && providerId === "deepseek");
const prev = this.#lastAppendOnlyResolution;
if (prev && prev.enable === enable && prev.providerId === providerId) return;
this.#lastAppendOnlyResolution = { enable, providerId };
if (enable && !this.agent.appendOnlyContext) {
this.agent.setAppendOnlyContext(new AppendOnlyContextManager());
} else if (enable && this.agent.appendOnlyContext) {
// Already active — invalidate prefix + log so the next turn
// rebuilds for the current model's normalization.
this.agent.appendOnlyContext.invalidateForModelChange();
} else if (!enable && this.agent.appendOnlyContext) {
this.agent.setAppendOnlyContext(undefined);
}
}
#closeProviderSessionsForModelSwitch(currentModel: Model, nextModel: Model): void {
const providerKeys = new Set<string>();
if (currentModel.api === "openai-codex-responses" || nextModel.api === "openai-codex-responses") {
providerKeys.add("openai-codex-responses");
}
if (currentModel.api === "openai-responses") {
providerKeys.add(`openai-responses:${currentModel.provider}`);
}
if (nextModel.api === "openai-responses") {
providerKeys.add(`openai-responses:${nextModel.provider}`);
}
for (const providerKey of providerKeys) {
const state = this.#providerSessionState.get(providerKey);
if (!state) continue;
try {
state.close();
} catch (error) {
logger.warn("Failed to close provider session state during model switch", {
providerKey,
error: String(error),
});
}
this.#providerSessionState.delete(providerKey);
}
}
#normalizeProviderReplayValue(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(item => this.#normalizeProviderReplayValue(item));
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, entryValue]) => [key, this.#normalizeProviderReplayValue(entryValue)]),
);
}
return value;
}
#normalizeSessionMessageForProviderReplay(message: AgentMessage): unknown {
switch (message.role) {
case "user":
case "developer":
return {
role: message.role,
content: this.#normalizeProviderReplayValue(message.content),
providerPayload: message.providerPayload,
};
case "assistant": {
const isResponsesFamilyMessage =
message.api === "openai-responses" || message.api === "openai-codex-responses";
return {
role: message.role,
content:
isResponsesFamilyMessage && Array.isArray(message.content)
? message.content.flatMap(block => {
if (block.type === "thinking") {
return [];
}
if (block.type === "toolCall") {
return [
{
type: block.type,
id: block.id,
name: block.name,
arguments: block.arguments,
},
];
}
if (block.type === "text") {
return [{ type: block.type, text: block.text, textSignature: block.textSignature }];
}
return [this.#normalizeProviderReplayValue(block)];
})
: this.#normalizeProviderReplayValue(message.content),
api: message.api,
provider: message.provider,
model: message.model,
stopReason: message.stopReason,
errorMessage: message.errorMessage,
providerPayload: isResponsesFamilyMessage ? undefined : message.providerPayload,
};
}
case "toolResult":
return {
role: message.role,
toolName: message.toolName,
toolCallId: message.toolCallId,
isError: message.isError,
content: this.#normalizeProviderReplayValue(message.content),
};
case "bashExecution":
return {
role: message.role,
command: message.command,
output: message.output,
exitCode: message.exitCode,
cancelled: message.cancelled,
meta: message.meta
? {
truncation: this.#normalizeProviderReplayValue(message.meta.truncation),
limits: this.#normalizeProviderReplayValue(message.meta.limits),
diagnostics: message.meta.diagnostics
? this.#normalizeProviderReplayValue({
summary: message.meta.diagnostics.summary,
messages: message.meta.diagnostics.messages,
})
: undefined,
}
: undefined,
excludeFromContext: message.excludeFromContext,
};
case "pythonExecution":
return {
role: message.role,
code: message.code,
output: message.output,
exitCode: message.exitCode,
cancelled: message.cancelled,
meta: message.meta
? {
truncation: this.#normalizeProviderReplayValue(message.meta.truncation),
limits: this.#normalizeProviderReplayValue(message.meta.limits),
diagnostics: message.meta.diagnostics
? this.#normalizeProviderReplayValue({
summary: message.meta.diagnostics.summary,
messages: message.meta.diagnostics.messages,
})
: undefined,
}
: undefined,
excludeFromContext: message.excludeFromContext,
};
case "custom":
case "hookMessage":
return {
role: message.role,
customType: message.customType,
content: this.#normalizeProviderReplayValue(message.content),
};
case "branchSummary":
return { role: message.role, summary: message.summary };
case "compactionSummary":
return {
role: message.role,
summary: message.summary,
providerPayload: message.providerPayload,
};
case "fileMention":
return {
role: message.role,
files: message.files.map(file => ({
path: file.path,
content: file.content,
image: file.image,
})),
};
default:
return this.#normalizeProviderReplayValue(message);
}
}
#didSessionMessagesChange(previousMessages: AgentMessage[], nextMessages: AgentMessage[]): boolean {
return (
JSON.stringify(previousMessages.map(message => this.#normalizeSessionMessageForProviderReplay(message))) !==
JSON.stringify(nextMessages.map(message => this.#normalizeSessionMessageForProviderReplay(message)))
);
}
#getModelKey(model: Model): string {
return `${model.provider}/${model.id}`;
}
#formatRoleModelValue(
role: string,
model: Model,
selectorOverride?: string,
thinkingLevelOverride?: ThinkingLevel,
): string {
const modelKey = selectorOverride ?? `${model.provider}/${model.id}`;
if (thinkingLevelOverride !== undefined) {
return formatModelSelectorValue(modelKey, thinkingLevelOverride);
}
const existingRoleValue = this.settings.getModelRole(role);
if (!existingRoleValue) return modelKey;
const thinkingLevel = extractExplicitThinkingSelector(existingRoleValue, this.settings);
return formatModelSelectorValue(modelKey, thinkingLevel);
}
#resolveContextPromotionConfiguredTarget(currentModel: Model, availableModels: Model[]): Model | undefined {
const configuredTarget = currentModel.contextPromotionTarget?.trim();
if (!configuredTarget) return undefined;
const parsed = parseModelString(configuredTarget);
if (parsed) {
const explicitModel = availableModels.find(m => m.provider === parsed.provider && m.id === parsed.id);
if (explicitModel) return explicitModel;
}
return availableModels.find(m => m.provider === currentModel.provider && m.id === configuredTarget);
}
#resolveRoleModelFull(
role: string,
availableModels: Model[],
currentModel: Model | undefined,
): ResolvedModelRoleValue {
const roleModelStr =
role === "default"
? (this.settings.getModelRole("default") ??
(currentModel ? `${currentModel.provider}/${currentModel.id}` : undefined))
: this.settings.getModelRole(role);
if (!roleModelStr) {
return { model: undefined, thinkingLevel: undefined, explicitThinkingLevel: false, warning: undefined };
}
return resolveModelRoleValue(roleModelStr, availableModels, {
settings: this.settings,
matchPreferences: { usageOrder: this.settings.getStorage()?.getModelUsageOrder() },
modelRegistry: this.#modelRegistry,
});
}
#getCompactionModelCandidates(availableModels: Model[]): Model[] {
const candidates: Model[] = [];
const seen = new Set<string>();
const addCandidate = (model: Model | undefined): void => {
if (!model) return;
const key = this.#getModelKey(model);
if (seen.has(key)) return;
seen.add(key);
candidates.push(model);
};
const currentModel = this.model;
// Prefer the active session's model: it's what the user is actively using,
// and routing compaction to a different provider (e.g. an OpenAI default
// model while the chat is on Anthropic) changes provider-specific behavior
// like remote compaction endpoints. Role-based candidates only kick in
// as auth fallbacks when the current model has no usable credentials.
addCandidate(currentModel);
for (const role of MODEL_ROLE_IDS) {
addCandidate(this.#resolveRoleModelFull(role, availableModels, currentModel).model);
}
const sortedByContext = [...availableModels].sort((a, b) => b.contextWindow - a.contextWindow);
for (const model of sortedByContext) {
if (!seen.has(this.#getModelKey(model))) {
addCandidate(model);
break;
}
}
return candidates;
}
#isCompactionAuthFailure(error: unknown): boolean {
if (!(error instanceof Error)) return false;
// Real provider 401/403 — surfaced as `.status` by the compaction layer
// (see `createSummarizationError` in packages/agent/src/compaction/compaction.ts).
// Without this branch, an expired/revoked Anthropic key would bypass the
// authenticated-fallback path and dump the raw HTTP body into the UI.
const status = (error as Error & { status?: number }).status;
if (status === 401 || status === 403) return true;
// pi-native gateway synthetic for "no credential configured" (issue #986).
// Carries no HTTP status, so the legacy message regex stays.
return /auth_unavailable|no auth available/i.test(error.message);
}
#buildCompactionAuthError(): Error {
const currentModel = this.model;
if (!currentModel) {
return new Error(
"Compaction requires a model with usable credentials, but no authenticated compaction model is available.",
);
}
return new Error(
`Compaction requires usable credentials for ${currentModel.provider}/${currentModel.id}. ` +
`Configure ${currentModel.provider} credentials or assign an authenticated fallback role such as modelRoles.smol.`,
);
}
async #compactWithFallbackModel(
preparation: CompactionPreparation,
customInstructions: string | undefined,
signal: AbortSignal,
options?: SummaryOptions,
): Promise<CompactionResult> {
const candidates = this.#getCompactionModelCandidates(this.#modelRegistry.getAvailable());
const telemetry = resolveTelemetry(this.agent.telemetry, this.sessionId);
for (const candidate of candidates) {
const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId);
if (!apiKey) continue;
try {
return await compact(preparation, candidate, apiKey, customInstructions, signal, {
...options,
metadata: this.agent.metadataForProvider(candidate.provider),
convertToLlm,
telemetry,
// Honor the user's /model thinking selection (incl. `off`) on
// the manual `/compact` path. Clamped per-model inside compact()
// via resolveCompactionEffort so unsupported-effort models
// (xai-oauth/grok-build) don't trip requireSupportedEffort.
thinkingLevel: this.thinkingLevel,
});
} catch (error) {
if (!this.#isCompactionAuthFailure(error)) {
throw error;
}
}
}
throw this.#buildCompactionAuthError();
}
async #prepareCompactionFromHooks(
preparation: CompactionPreparation,
hookCompaction: CompactionResult | undefined,
): Promise<
| {
kind: "fromHook";
summary: string;
shortSummary: string | undefined;
firstKeptEntryId: string;
tokensBefore: number;
details: unknown;
preserveData: Record<string, unknown> | undefined;
}
| {
kind: "needsLlm";
hookContext: string[] | undefined;
hookPrompt: string | undefined;
preserveData: Record<string, unknown> | undefined;
}
> {
let hookContext: string[] | undefined;
let hookPrompt: string | undefined;
let preserveData: Record<string, unknown> | undefined;
if (!hookCompaction && this.#extensionRunner?.hasHandlers("session.compacting")) {
const compactMessages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages);
const result = (await this.#extensionRunner.emit({
type: "session.compacting",
sessionId: this.sessionId,
messages: compactMessages,
})) as { context?: string[]; prompt?: string; preserveData?: Record<string, unknown> } | undefined;
hookContext = result?.context;
hookPrompt = result?.prompt;
preserveData = result?.preserveData;
}
const memoryBackendContext = await this.#collectMemoryBackendContext(preparation);
if (memoryBackendContext) {
hookContext = hookContext ? [...hookContext, memoryBackendContext] : [memoryBackendContext];
}
if (hookCompaction) {
preserveData ??= hookCompaction.preserveData;
return {
kind: "fromHook",
summary: hookCompaction.summary,
shortSummary: hookCompaction.shortSummary,
firstKeptEntryId: hookCompaction.firstKeptEntryId,
tokensBefore: hookCompaction.tokensBefore,
details: hookCompaction.details,
preserveData,
};
}
return { kind: "needsLlm", hookContext, hookPrompt, preserveData };
}
/**
* Internal: Run auto-compaction with events.
*
* @param allowDefer If true (default), threshold-driven handoff strategy is allowed to
* schedule itself as a deferred post-prompt task and return `true` immediately. The
* caller MUST treat that as "compaction will happen async — do not also schedule
* `agent.continue()` for this turn", otherwise the deferred handoff races a fresh
* streaming turn (the symptom: "Auto-handoff" loader + assistant message still
* streaming). Callers on a path that is about to start a new agent turn (e.g.
* the pre-prompt check in `#promptWithMessage`) pass `false` to force inline
* execution so the handoff completes before the new turn begins.
* @returns true when a deferred handoff was scheduled. Inline runs always return false.
*/
async #runAutoCompaction(
reason: "overflow" | "threshold" | "idle" | "incomplete",
willRetry: boolean,
deferred = false,
allowDefer = true,
): Promise<boolean> {
const compactionSettings = this.settings.getGroup("compaction");
if (compactionSettings.strategy === "off") return false;
if (reason !== "idle" && !compactionSettings.enabled) return false;
const generation = this.#promptGeneration;
// "overflow" and "incomplete" force inline execution because they are recovery
// paths the caller wants resolved before scheduling the next turn. "idle" is
// triggered by the idle loop and does its own scheduling.
if (
!deferred &&
allowDefer &&
reason !== "overflow" &&
reason !== "incomplete" &&
reason !== "idle" &&
compactionSettings.strategy === "handoff"
) {
this.#schedulePostPromptTask(
async signal => {
await Promise.resolve();
if (signal.aborted) return;
await this.#runAutoCompaction(reason, willRetry, true);
},
{ generation },
);
return true;
}
// "overflow" forces context-full because the input itself is broken — a handoff
// LLM call would hit the same overflow. "incomplete" is an output-side problem,
// so a handoff request on the existing context is still viable.
let action: "context-full" | "handoff" =
compactionSettings.strategy === "handoff" && reason !== "overflow" ? "handoff" : "context-full";
await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
// Abort any older auto-compaction before installing this run's controller.
this.#autoCompactionAbortController?.abort();
const autoCompactionAbortController = new AbortController();
this.#autoCompactionAbortController = autoCompactionAbortController;
const autoCompactionSignal = autoCompactionAbortController.signal;
try {
if (compactionSettings.strategy === "handoff" && reason !== "overflow") {
const handoffFocus = AUTO_HANDOFF_THRESHOLD_FOCUS;
const handoffResult = await this.handoff(handoffFocus, {
autoTriggered: true,
signal: this.#autoCompactionAbortController.signal,
});
if (!handoffResult) {
const aborted = autoCompactionSignal.aborted;
if (aborted) {
await this.#emitSessionEvent({
type: "auto_compaction_end",
action,
result: undefined,
aborted: true,
willRetry: false,
});
return false;
}
logger.warn("Auto-handoff returned no document; falling back to context-full maintenance", {
reason,
});
action = "context-full";
}
if (handoffResult) {
await this.#emitSessionEvent({
type: "auto_compaction_end",
action,
result: undefined,
aborted: false,
willRetry: false,
});
if (!autoCompactionSignal.aborted && reason !== "idle" && compactionSettings.autoContinue !== false) {
this.#scheduleAutoContinuePrompt(generation);
}
return false;
}
}
if (!this.model) {
await this.#emitSessionEvent({
type: "auto_compaction_end",
action,
result: undefined,
aborted: false,
willRetry: false,
skipped: true,
});
return false;
}
const availableModels = this.#modelRegistry.getAvailable();
if (availableModels.length === 0) {
await this.#emitSessionEvent({
type: "auto_compaction_end",
action,
result: undefined,
aborted: false,
willRetry: false,
skipped: true,
});
return false;
}
const pathEntries = this.sessionManager.getBranch();
const preparation = prepareCompaction(pathEntries, compactionSettings);
if (!preparation) {
await this.#emitSessionEvent({
type: "auto_compaction_end",
action,
result: undefined,
aborted: false,
willRetry: false,
skipped: true,
});
if (!willRetry && this.agent.hasQueuedMessages()) {
this.#scheduleAgentContinue({
delayMs: 100,
generation,
shouldContinue: () => this.agent.hasQueuedMessages(),
});
}
return false;
}
let hookCompaction: CompactionResult | undefined;
let fromExtension = false;
let preserveData: Record<string, unknown> | undefined;
if (this.#extensionRunner?.hasHandlers("session_before_compact")) {
const hookResult = (await this.#extensionRunner.emit({
type: "session_before_compact",
preparation,
branchEntries: pathEntries,
customInstructions: undefined,
signal: autoCompactionSignal,
})) as SessionBeforeCompactResult | undefined;
if (hookResult?.cancel) {
await this.#emitSessionEvent({
type: "auto_compaction_end",
action,
result: undefined,
aborted: true,
willRetry: false,
});
return false;
}
if (hookResult?.compaction) {
hookCompaction = hookResult.compaction;
fromExtension = true;
}
}
const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction);
let summary: string;
let shortSummary: string | undefined;
let firstKeptEntryId: string;
let tokensBefore: number;
let details: unknown;
if (compactionPrep.kind === "fromHook") {
summary = compactionPrep.summary;
shortSummary = compactionPrep.shortSummary;
firstKeptEntryId = compactionPrep.firstKeptEntryId;
tokensBefore = compactionPrep.tokensBefore;
details = compactionPrep.details;
preserveData = compactionPrep.preserveData;
} else {
const candidates = this.#getCompactionModelCandidates(availableModels);
const retrySettings = this.settings.getGroup("retry");
const telemetry = resolveTelemetry(this.agent.telemetry, this.sessionId);
let compactResult: CompactionResult | undefined;
let lastError: unknown;
for (const candidate of candidates) {
const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId);
if (!apiKey) continue;
let attempt = 0;
while (true) {
try {
compactResult = await compact(preparation, candidate, apiKey, undefined, autoCompactionSignal, {
promptOverride: compactionPrep.hookPrompt,
extraContext: compactionPrep.hookContext,
remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
metadata: this.agent.metadataForProvider(candidate.provider),
initiatorOverride: "agent",
convertToLlm,
telemetry,
// Honor the user's /model thinking selection on the
// auto-compaction path — the most-fired compaction
// site. Clamped per-model inside compact() via
// resolveCompactionEffort.
thinkingLevel: this.thinkingLevel,
});
break;
} catch (error) {
if (autoCompactionSignal.aborted) {
throw error;
}
const message = error instanceof Error ? error.message : String(error);
if (this.#isCompactionAuthFailure(error)) {
lastError = this.#buildCompactionAuthError();
break;
}
const retryAfterMs = this.#parseRetryAfterMsFromError(message);
const shouldRetry =
retrySettings.enabled &&
attempt < retrySettings.maxRetries &&
(retryAfterMs !== undefined ||
this.#isTransientErrorMessage(message) ||
isUsageLimitError(message));
if (!shouldRetry) {
lastError = error;
break;
}
const baseDelayMs = retrySettings.baseDelayMs * 2 ** attempt;
const delayMs = retryAfterMs !== undefined ? Math.max(baseDelayMs, retryAfterMs) : baseDelayMs;
// If retry delay is too long (>30s), try next candidate instead of waiting
const maxAcceptableDelayMs = 30_000;
if (delayMs > maxAcceptableDelayMs) {
const hasMoreCandidates = candidates.indexOf(candidate) < candidates.length - 1;
if (hasMoreCandidates) {
logger.warn("Auto-compaction retry delay too long, trying next model", {
delayMs,
retryAfterMs,
error: message,
model: `${candidate.provider}/${candidate.id}`,
});
lastError = error;
break; // Exit retry loop, continue to next candidate
}
// No more candidates - we have to wait
}
attempt++;
logger.warn("Auto-compaction failed, retrying", {
attempt,
maxRetries: retrySettings.maxRetries,
delayMs,
retryAfterMs,
error: message,
model: `${candidate.provider}/${candidate.id}`,
});
await scheduler.wait(delayMs, { signal: autoCompactionSignal });
}
}
if (compactResult) {
break;
}
}
if (!compactResult) {
if (lastError) {
throw lastError;
}
throw new Error("Compaction failed: no available model");
}
summary = compactResult.summary;
shortSummary = compactResult.shortSummary;
firstKeptEntryId = compactResult.firstKeptEntryId;
tokensBefore = compactResult.tokensBefore;
details = compactResult.details;
preserveData = { ...(compactionPrep.preserveData ?? {}), ...(compactResult.preserveData ?? {}) };
}
if (autoCompactionSignal.aborted) {
await this.#emitSessionEvent({
type: "auto_compaction_end",
action,
result: undefined,
aborted: true,
willRetry: false,
});
return false;
}
this.sessionManager.appendCompaction(
summary,
shortSummary,
firstKeptEntryId,
tokensBefore,
details,
fromExtension,
preserveData,
);
const newEntries = this.sessionManager.getEntries();
const sessionContext = this.buildDisplaySessionContext();
this.agent.replaceMessages(sessionContext.messages);
this.#syncTodoPhasesFromBranch();
this.#closeCodexProviderSessionsForHistoryRewrite();
// Get the saved compaction entry for the hook
const savedCompactionEntry = newEntries.find(e => e.type === "compaction" && e.summary === summary) as
| CompactionEntry
| undefined;
if (this.#extensionRunner && savedCompactionEntry) {
await this.#extensionRunner.emit({
type: "session_compact",
compactionEntry: savedCompactionEntry,
fromExtension,
});
}
const result: CompactionResult = {
summary,
shortSummary,
firstKeptEntryId,
tokensBefore,
details,
preserveData,
};
await this.#emitSessionEvent({ type: "auto_compaction_end", action, result, aborted: false, willRetry });
if (!willRetry && reason !== "idle" && compactionSettings.autoContinue !== false) {
this.#scheduleAutoContinuePrompt(generation);
}
if (willRetry) {
const messages = this.agent.state.messages;
const lastMsg = messages[messages.length - 1];
if (lastMsg?.role === "assistant") {
const lastAssistant = lastMsg as AssistantMessage;
// Drop the prior turn before retry when it carries no actionable deliverable:
// - "error": failure was kept in history but must not re-enter the next turn's prompt.
// - reason === "incomplete" && stopReason === "length": truncated output (typically
// reasoning-only) — re-running it produces the same dead-end.
const shouldDrop =
lastAssistant.stopReason === "error" ||
(reason === "incomplete" && lastAssistant.stopReason === "length");
if (shouldDrop) {
this.agent.replaceMessages(messages.slice(0, -1));
}
}
this.#scheduleAgentContinue({ delayMs: 100, generation });
} else if (this.agent.hasQueuedMessages()) {
// Auto-compaction can complete while follow-up/steering/custom messages are waiting.
// Kick the loop so queued messages are actually delivered.
this.#scheduleAgentContinue({
delayMs: 100,
generation,
shouldContinue: () => this.agent.hasQueuedMessages(),
});
}
} catch (error) {
if (autoCompactionSignal.aborted) {
await this.#emitSessionEvent({
type: "auto_compaction_end",
action,
result: undefined,
aborted: true,
willRetry: false,
});
return false;
}
const errorMessage = error instanceof Error ? error.message : "compaction failed";
await this.#emitSessionEvent({
type: "auto_compaction_end",
action,
result: undefined,
aborted: false,
willRetry: false,
errorMessage:
reason === "overflow"
? `Context overflow recovery failed: ${errorMessage}`
: reason === "incomplete"
? `Incomplete response recovery failed: ${errorMessage}`
: `Auto-compaction failed: ${errorMessage}`,
});
} finally {
if (this.#autoCompactionAbortController === autoCompactionAbortController) {
this.#autoCompactionAbortController = undefined;
}
}
return false;
}
/**
* Toggle auto-compaction setting.
*/
setAutoCompactionEnabled(enabled: boolean): void {
this.settings.set("compaction.enabled", enabled);
if (enabled && this.settings.get("compaction.strategy") === "off") {
this.settings.set("compaction.strategy", "context-full");
}
}
/** Whether auto-compaction is enabled */
get autoCompactionEnabled(): boolean {
return this.settings.get("compaction.enabled") && this.settings.get("compaction.strategy") !== "off";
}
// =========================================================================
// Auto-Retry
// =========================================================================
/**
* Check if an error is retryable (transient errors or usage limits).
* Context overflow errors are NOT retryable (handled by compaction instead).
* Usage-limit errors are retryable because the retry handler performs credential switching.
*/
#isRetryableError(message: AssistantMessage): boolean {
if (message.stopReason !== "error" || !message.errorMessage) return false;
// Context overflow is handled by compaction, not retry
const contextWindow = this.model?.contextWindow ?? 0;
if (isContextOverflow(message, contextWindow)) return false;
const err = message.errorMessage;
return this.#isTransientErrorMessage(err) || isUsageLimitError(err);
}
#isTransientErrorMessage(errorMessage: string): boolean {
return (
this.#isTransientEnvelopeErrorMessage(errorMessage) || this.#isTransientTransportErrorMessage(errorMessage)
);
}
#isTransientEnvelopeErrorMessage(errorMessage: string): boolean {
// Match Anthropic stream-envelope failures that indicate a broken stream before any content starts.
return /anthropic stream envelope error:/i.test(errorMessage) && /before message_start/i.test(errorMessage);
}
#isTransientTransportErrorMessage(errorMessage: string): boolean {
// Match: overloaded_error, provider returned error, rate limit, 429, 500, 502, 503, 504,
// service unavailable, provider-suggested retry, network/connection/socket errors, fetch failed,
// terminated, retry delay exceeded, Bun HTTP/2 stream resets (RST_STREAM / REFUSED_STREAM /
// ENHANCE_YOUR_CALM, surfaced verbatim from src/http/h2_client/dispatch.zig)
return (
isUnexpectedSocketCloseMessage(errorMessage) ||
/overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|retry your request|network.?error|connection.?error|connection.?refused|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response|HTTP2(?:StreamReset|RefusedStream|EnhanceYourCalm)/i.test(
errorMessage,
)
);
}
#getRetryFallbackChains(): RetryFallbackChains {
const configuredChains = this.settings.get("retry.fallbackChains");
if (!configuredChains || typeof configuredChains !== "object") return {};
return configuredChains as RetryFallbackChains;
}
#validateRetryFallbackChains(): void {
const configuredChains = this.settings.get("retry.fallbackChains");
if (configuredChains === undefined) return;
if (!configuredChains || typeof configuredChains !== "object" || Array.isArray(configuredChains)) {
const msg = "retry.fallbackChains must be a mapping of role names to selector arrays.";
logger.warn(msg);
this.configWarnings.push(msg);
return;
}
for (const [role, chain] of Object.entries(configuredChains)) {
if (!Array.isArray(chain)) {
const msg = `Fallback chain for role '${role}' must be an array of selector strings.`;
logger.warn(msg);
this.configWarnings.push(msg);
continue;
}
for (const selectorStr of chain) {
if (typeof selectorStr !== "string") {
const msg = `Fallback chain for role '${role}' contains a non-string selector.`;
logger.warn(msg);
this.configWarnings.push(msg);
continue;
}
const parsed = parseRetryFallbackSelector(selectorStr);
if (!parsed) {
const msg = `Invalid fallback selector format in role '${role}': ${selectorStr}`;
logger.warn(msg);
this.configWarnings.push(msg);
continue;
}
const exists = this.#modelRegistry.find(parsed.provider, parsed.id);
if (!exists) {
const msg = `Fallback chain for role '${role}' references unknown model: ${selectorStr}`;
logger.warn(msg);
this.configWarnings.push(msg);
}
}
}
}
#getRetryFallbackRevertPolicy(): RetryFallbackRevertPolicy {
return this.settings.get("retry.fallbackRevertPolicy") === "never" ? "never" : "cooldown-expiry";
}
#getRetryFallbackPrimarySelector(role: string): RetryFallbackSelector | undefined {
const configuredSelector = this.settings.getModelRole(role);
return configuredSelector ? parseRetryFallbackSelector(configuredSelector) : undefined;
}
#clearActiveRetryFallback(): void {
this.#activeRetryFallback = undefined;
}
#isRetryFallbackSelectorSuppressed(selector: RetryFallbackSelector): boolean {
return this.#modelRegistry.isSelectorSuppressed(selector.raw);
}
#noteRetryFallbackCooldown(currentSelector: string, retryAfterMs: number | undefined, errorMessage: string): void {
let cooldownMs = retryAfterMs;
if (!cooldownMs || cooldownMs <= 0) {
const reason = parseRateLimitReason(errorMessage);
cooldownMs = reason === "UNKNOWN" ? 5 * 60 * 1000 : calculateRateLimitBackoffMs(reason);
}
this.#modelRegistry.suppressSelector(currentSelector, Date.now() + cooldownMs);
}
#resolveRetryFallbackRole(currentSelector: string): string | undefined {
const parsedCurrent = parseRetryFallbackSelector(currentSelector);
if (!parsedCurrent) return undefined;
const currentBaseSelector = formatRetryFallbackBaseSelector(parsedCurrent);
for (const role of Object.keys(this.#getRetryFallbackChains())) {
const primarySelector = this.#getRetryFallbackPrimarySelector(role);
if (!primarySelector) continue;
if (primarySelector.raw === currentSelector) return role;
if (formatRetryFallbackBaseSelector(primarySelector) === currentBaseSelector) return role;
}
return undefined;
}
#getRetryFallbackEffectiveChain(role: string): RetryFallbackSelector[] {
const primarySelector = this.#getRetryFallbackPrimarySelector(role);
if (!primarySelector) return [];
const chain = [primarySelector];
const seen = new Set<string>([primarySelector.raw]);
for (const selector of this.#getRetryFallbackChains()[role] ?? []) {
const parsed = parseRetryFallbackSelector(selector);
if (!parsed || seen.has(parsed.raw)) continue;
seen.add(parsed.raw);
chain.push(parsed);
}
return chain;
}
#findRetryFallbackCandidates(role: string, currentSelector: string): RetryFallbackSelector[] {
const chain = this.#getRetryFallbackEffectiveChain(role);
if (chain.length <= 1) return [];
const parsedCurrent = parseRetryFallbackSelector(currentSelector);
const currentBaseSelector = parsedCurrent ? formatRetryFallbackBaseSelector(parsedCurrent) : undefined;
const exactIndex = chain.findIndex(selector => selector.raw === currentSelector);
if (exactIndex >= 0) return chain.slice(exactIndex + 1);
const baseIndex = currentBaseSelector
? chain.findIndex(selector => formatRetryFallbackBaseSelector(selector) === currentBaseSelector)
: -1;
if (baseIndex >= 0) return chain.slice(baseIndex + 1);
return chain.slice(1);
}
async #applyRetryFallbackCandidate(
role: string,
selector: RetryFallbackSelector,
currentSelector: string,
): Promise<void> {
const candidate = this.#modelRegistry.find(selector.provider, selector.id);
if (!candidate) {
throw new Error(`Retry fallback model not found: ${selector.raw}`);
}
const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId);
if (!apiKey) {
throw new Error(`No API key for retry fallback ${selector.raw}`);
}
const currentThinkingLevel = this.thinkingLevel;
const nextThinkingLevel = selector.thinkingLevel ?? currentThinkingLevel;
this.#setModelWithProviderSessionReset(candidate);
this.sessionManager.appendModelChange(`${candidate.provider}/${candidate.id}`, "temporary");
this.settings.getStorage()?.recordModelUsage(`${candidate.provider}/${candidate.id}`);
this.setThinkingLevel(nextThinkingLevel);
if (!this.#activeRetryFallback) {
this.#activeRetryFallback = {
role,
originalSelector: currentSelector,
originalThinkingLevel: currentThinkingLevel,
lastAppliedFallbackThinkingLevel: nextThinkingLevel,
};
} else {
this.#activeRetryFallback.lastAppliedFallbackThinkingLevel = nextThinkingLevel;
}
await this.#emitSessionEvent({
type: "retry_fallback_applied",
from: currentSelector,
to: selector.raw,
role,
});
}
async #tryRetryModelFallback(currentSelector: string): Promise<boolean> {
const role = this.#activeRetryFallback?.role ?? this.#resolveRetryFallbackRole(currentSelector);
if (!role) return false;
for (const selector of this.#findRetryFallbackCandidates(role, currentSelector)) {
if (this.#isRetryFallbackSelectorSuppressed(selector)) continue;
const candidate = this.#modelRegistry.find(selector.provider, selector.id);
if (!candidate) continue;
const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId);
if (!apiKey) continue;
await this.#applyRetryFallbackCandidate(role, selector, currentSelector);
return true;
}
return false;
}
async #maybeRestoreRetryFallbackPrimary(): Promise<void> {
if (!this.#activeRetryFallback) return;
if (this.#getRetryFallbackRevertPolicy() !== "cooldown-expiry") return;
const {
originalSelector: originalSelectorRaw,
originalThinkingLevel,
lastAppliedFallbackThinkingLevel,
} = this.#activeRetryFallback;
const originalSelector = parseRetryFallbackSelector(originalSelectorRaw);
if (!originalSelector) {
this.#clearActiveRetryFallback();
return;
}
const currentModel = this.model;
if (!currentModel) return;
const currentSelector = formatRetryFallbackSelector(currentModel, this.thinkingLevel);
if (currentSelector === originalSelector.raw) {
if (!this.#isRetryFallbackSelectorSuppressed(originalSelector)) {
this.#clearActiveRetryFallback();
}
return;
}
if (this.#isRetryFallbackSelectorSuppressed(originalSelector)) return;
const primaryModel = this.#modelRegistry.find(originalSelector.provider, originalSelector.id);
if (!primaryModel) return;
const apiKey = await this.#modelRegistry.getApiKey(primaryModel, this.sessionId);
if (!apiKey) return;
const currentThinkingLevel = this.thinkingLevel;
const thinkingToApply =
currentThinkingLevel === lastAppliedFallbackThinkingLevel ? originalThinkingLevel : currentThinkingLevel;
this.#setModelWithProviderSessionReset(primaryModel);
this.sessionManager.appendModelChange(`${primaryModel.provider}/${primaryModel.id}`, "temporary");
this.settings.getStorage()?.recordModelUsage(`${primaryModel.provider}/${primaryModel.id}`);
this.setThinkingLevel(thinkingToApply);
this.#clearActiveRetryFallback();
}
#parseRetryAfterMsFromError(errorMessage: string): number | undefined {
const now = Date.now();
const retryAfterMsMatch = /retry-after-ms\s*[:=]\s*(\d+)/i.exec(errorMessage);
if (retryAfterMsMatch) {
return Math.max(0, Number(retryAfterMsMatch[1]));
}
const retryAfterMatch = /retry-after\s*[:=]\s*([^\s,;]+)/i.exec(errorMessage);
if (retryAfterMatch) {
const value = retryAfterMatch[1];
const seconds = Number(value);
if (!Number.isNaN(seconds)) {
return Math.max(0, seconds * 1000);
}
const dateMs = Date.parse(value);
if (!Number.isNaN(dateMs)) {
return Math.max(0, dateMs - now);
}
}
const retryHintMs = extractRetryHint(undefined, errorMessage);
if (retryHintMs !== undefined) {
return retryHintMs;
}
const resetMsMatch = /x-ratelimit-reset-ms\s*[:=]\s*(\d+)/i.exec(errorMessage);
if (resetMsMatch) {
const resetMs = Number(resetMsMatch[1]);
if (!Number.isNaN(resetMs)) {
if (resetMs > 1_000_000_000_000) {
return Math.max(0, resetMs - now);
}
return Math.max(0, resetMs);
}
}
const resetMatch = /x-ratelimit-reset\s*[:=]\s*(\d+)/i.exec(errorMessage);
if (resetMatch) {
const resetSeconds = Number(resetMatch[1]);
if (!Number.isNaN(resetSeconds)) {
if (resetSeconds > 1_000_000_000) {
return Math.max(0, resetSeconds * 1000 - now);
}
return Math.max(0, resetSeconds * 1000);
}
}
// Smart Fallback if no exact headers found
return undefined;
}
/**
* Handle retryable errors with exponential backoff.
* @returns true if retry was initiated, false if max retries exceeded or disabled
*/
async #handleRetryableError(message: AssistantMessage): Promise<boolean> {
const retrySettings = this.settings.getGroup("retry");
if (!retrySettings.enabled) return false;
const generation = this.#promptGeneration;
this.#retryAttempt++;
// Create retry promise on first attempt so waitForRetry() can await it
// Ensure only one promise exists (avoid orphaned promises from concurrent calls)
if (!this.#retryPromise) {
const { promise, resolve } = Promise.withResolvers<void>();
this.#retryPromise = promise;
this.#retryResolve = resolve;
}
if (this.#retryAttempt > retrySettings.maxRetries) {
// Max retries exceeded, emit final failure and reset
await this.#emitSessionEvent({
type: "auto_retry_end",
success: false,
attempt: this.#retryAttempt - 1,
finalError: message.errorMessage,
});
this.#retryAttempt = 0;
this.#resolveRetry(); // Resolve so waitForRetry() completes
return false;
}
const errorMessage = message.errorMessage || "Unknown error";
const parsedRetryAfterMs = this.#parseRetryAfterMsFromError(errorMessage);
let delayMs = retrySettings.baseDelayMs * 2 ** (this.#retryAttempt - 1);
let switchedCredential = false;
let switchedModel = false;
if (this.model && isUsageLimitError(errorMessage)) {
const retryAfterMs = parsedRetryAfterMs ?? calculateRateLimitBackoffMs(parseRateLimitReason(errorMessage));
const switched = await this.#modelRegistry.authStorage.markUsageLimitReached(
this.model.provider,
this.sessionId,
{
retryAfterMs,
baseUrl: this.model.baseUrl,
},
);
if (switched) {
switchedCredential = true;
delayMs = 0;
} else if (retryAfterMs > delayMs) {
// No more accounts to switch to — wait out the backoff
delayMs = retryAfterMs;
}
}
const currentSelector = this.model ? formatRetryFallbackSelector(this.model, this.thinkingLevel) : undefined;
if (!switchedCredential && currentSelector) {
this.#noteRetryFallbackCooldown(currentSelector, parsedRetryAfterMs, errorMessage);
switchedModel = await this.#tryRetryModelFallback(currentSelector);
if (switchedModel) {
delayMs = 0;
} else if (parsedRetryAfterMs && parsedRetryAfterMs > delayMs) {
delayMs = parsedRetryAfterMs;
}
}
// Fail-fast cap: if the provider asks us to wait longer than
// retry.maxDelayMs and we have no fallback credential or model to
// switch to, surface the error instead of sleeping. Defends against
// 3-hour Anthropic rate-limit windows that would otherwise leave a
// subagent (or interactive session) silently hung. The original
// assistant error message is preserved in agent state so the caller
// can act on it.
const maxDelayMs = retrySettings.maxDelayMs;
if (maxDelayMs > 0 && delayMs > maxDelayMs && !switchedCredential && !switchedModel) {
const attempt = this.#retryAttempt;
this.#retryAttempt = 0;
await this.#emitSessionEvent({
type: "auto_retry_end",
success: false,
attempt,
finalError: `Provider requested ${delayMs}ms wait, exceeds retry.maxDelayMs (${maxDelayMs}ms). Original error: ${errorMessage}`,
});
this.#resolveRetry();
return false;
}
await this.#emitSessionEvent({
type: "auto_retry_start",
attempt: this.#retryAttempt,
maxAttempts: retrySettings.maxRetries,
delayMs,
errorMessage,
});
// Remove error message from agent state (keep in session for history)
const messages = this.agent.state.messages;
if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
this.agent.replaceMessages(messages.slice(0, -1));
}
// Wait with exponential backoff (abortable).
const retryAbortController = new AbortController();
this.#retryAbortController?.abort();
this.#retryAbortController = retryAbortController;
try {
await scheduler.wait(delayMs, { signal: retryAbortController.signal });
} catch {
if (this.#retryAbortController !== retryAbortController) {
return false;
}
// Aborted during sleep - emit end event so UI can clean up
const attempt = this.#retryAttempt;
this.#retryAttempt = 0;
this.#retryAbortController = undefined;
await this.#emitSessionEvent({
type: "auto_retry_end",
success: false,
attempt,
finalError: "Retry cancelled",
});
this.#resolveRetry();
return false;
}
if (this.#retryAbortController === retryAbortController) {
this.#retryAbortController = undefined;
}
// Retry via continue() outside the agent_end event callback chain.
this.#scheduleAgentContinue({ delayMs: 1, generation });
return true;
}
/**
* Cancel in-progress retry.
*/
abortRetry(): void {
this.#retryAbortController?.abort();
// Note: _retryAttempt is reset in the catch block of _autoRetry
this.#resolveRetry();
}
async #promptAgentWithIdleRetry(messages: AgentMessage[], options?: { toolChoice?: ToolChoice }): Promise<void> {
const deadline = Date.now() + 30_000;
for (;;) {
try {
await this.agent.prompt(messages, options);
return;
} catch (err) {
if (!(err instanceof AgentBusyError)) {
throw err;
}
if (Date.now() >= deadline) {
throw new Error("Timed out waiting for prior agent run to finish before prompting.");
}
await this.agent.waitForIdle();
}
}
}
/** Whether auto-retry is currently in progress */
get isRetrying(): boolean {
return this.#retryPromise !== undefined;
}
/** Whether auto-retry is enabled */
get autoRetryEnabled(): boolean {
return this.settings.get("retry.enabled") ?? true;
}
/**
* Toggle auto-retry setting.
*/
setAutoRetryEnabled(enabled: boolean): void {
this.settings.set("retry.enabled", enabled);
}
/**
* Manually retry the last failed assistant turn.
* Removes the error message from agent state and re-attempts with a fresh retry budget.
* @returns true if retry was initiated, false if no failed turn to retry or agent is busy
*/
async retry(): Promise<boolean> {
if (this.isStreaming || this.isCompacting || this.isRetrying) return false;
const messages = this.agent.state.messages;
const lastMsg = messages[messages.length - 1];
if (lastMsg?.role !== "assistant") return false;
const assistantMsg = lastMsg as AssistantMessage;
if (assistantMsg.stopReason !== "error" && assistantMsg.stopReason !== "aborted") return false;
// Remove the failed/aborted assistant message (same as auto-retry does before re-attempting)
this.agent.replaceMessages(messages.slice(0, -1));
// Reset retry budget for a fresh attempt
this.#retryAttempt = 0;
// Re-attempt the turn
this.#scheduleAgentContinue({ delayMs: 1 });
return true;
}
// =========================================================================
// Bash Execution
// =========================================================================
async #saveBashOriginalArtifact(originalText: string): Promise<string | undefined> {
try {
return await this.sessionManager.saveArtifact(originalText, "bash-original");
} catch {
return undefined;
}
}
/**
* Execute a bash command.
* Adds result to agent context and session.
* @param command The bash command to execute
* @param onChunk Optional streaming callback for output
* @param options.excludeFromContext If true, command output won't be sent to LLM (!! prefix)
*/
async executeBash(
command: string,
onChunk?: (chunk: string) => void,
options?: { excludeFromContext?: boolean },
): Promise<BashResult> {
const excludeFromContext = options?.excludeFromContext === true;
const cwd = this.sessionManager.getCwd();
if (this.#extensionRunner?.hasHandlers("user_bash")) {
const hookResult = await this.#extensionRunner.emitUserBash({
type: "user_bash",
command,
excludeFromContext,
cwd,
});
if (hookResult?.result) {
this.recordBashResult(command, hookResult.result, options);
return hookResult.result;
}
}
const abortController = new AbortController();
this.#bashAbortControllers.add(abortController);
try {
const result = await executeBashCommand(command, {
onChunk,
signal: abortController.signal,
sessionKey: this.sessionId,
timeout: clampTimeout("bash") * 1000,
onMinimizedSave: originalText => this.#saveBashOriginalArtifact(originalText),
});
this.recordBashResult(command, result, options);
return result;
} finally {
this.#bashAbortControllers.delete(abortController);
}
}
/**
* Record a bash execution result in session history.
* Used by executeBash and by extensions that handle bash execution themselves.
*/
recordBashResult(command: string, result: BashResult, options?: { excludeFromContext?: boolean }): void {
const meta = outputMeta().truncationFromSummary(result, { direction: "tail" }).get();
const bashMessage: BashExecutionMessage = {
role: "bashExecution",
command,
output: result.output,
exitCode: result.exitCode,
cancelled: result.cancelled,
truncated: result.truncated,
meta,
timestamp: Date.now(),
excludeFromContext: options?.excludeFromContext,
};
// If agent is streaming, defer adding to avoid breaking tool_use/tool_result ordering
if (this.isStreaming) {
// Queue for later - will be flushed on agent_end
this.#pendingBashMessages.push(bashMessage);
} else {
// Add to agent state immediately
this.agent.appendMessage(bashMessage);
// Save to session
this.sessionManager.appendMessage(bashMessage);
}
}
/**
* Cancel running bash command.
*/
abortBash(): void {
for (const abortController of this.#bashAbortControllers) {
abortController.abort();
}
}
/** Whether a bash command is currently running */
get isBashRunning(): boolean {
return this.#bashAbortControllers.size > 0;
}
/** Whether there are pending bash messages waiting to be flushed */
get hasPendingBashMessages(): boolean {
return this.#pendingBashMessages.length > 0;
}
/**
* Flush pending bash messages to agent state and session.
* Called after agent turn completes to maintain proper message ordering.
*/
#flushPendingBashMessages(): void {
if (this.#pendingBashMessages.length === 0) return;
for (const bashMessage of this.#pendingBashMessages) {
// Add to agent state
this.agent.appendMessage(bashMessage);
// Save to session
this.sessionManager.appendMessage(bashMessage);
}
this.#pendingBashMessages = [];
}
// =========================================================================
// User-Initiated Python Execution
// =========================================================================
/**
* Execute Python code in the shared kernel.
* Uses the same kernel session as eval's Python backend, allowing collaborative editing.
* @param code The Python code to execute
* @param onChunk Optional streaming callback for output
* @param options.excludeFromContext If true, execution won't be sent to LLM ($$ prefix)
*/
async executePython(
code: string,
onChunk?: (chunk: string) => void,
options?: { excludeFromContext?: boolean },
): Promise<PythonResult> {
const excludeFromContext = options?.excludeFromContext === true;
const cwd = this.sessionManager.getCwd();
this.assertEvalExecutionAllowed();
const abortController = new AbortController();
const execution = (async (): Promise<PythonResult> => {
if (this.#extensionRunner?.hasHandlers("user_python")) {
const hookResult = await this.#extensionRunner.emitUserPython({
type: "user_python",
code,
excludeFromContext,
cwd,
});
this.assertEvalExecutionAllowed();
if (hookResult?.result) {
this.recordPythonResult(code, hookResult.result, options);
return hookResult.result;
}
}
// Use the same session ID as eval's Python backend for kernel sharing.
const sessionId =
this.getEvalSessionId() ??
defaultEvalSessionId({
cwd,
getSessionFile: () => this.sessionManager.getSessionFile() ?? null,
});
const result = await executePythonCommand(code, {
cwd,
sessionId: namespacePythonSessionId(sessionId),
kernelOwnerId: this.#evalKernelOwnerId,
kernelMode: this.settings.get("python.kernelMode"),
onChunk,
signal: abortController.signal,
});
this.recordPythonResult(code, result, options);
return result;
})();
return await this.trackEvalExecution(execution, abortController);
}
assertEvalExecutionAllowed(): void {
if (this.#evalExecutionDisposing) {
throw new Error("Python execution is unavailable while session disposal is in progress");
}
}
/**
* Track Python work started outside AgentSession.executePython so dispose can await and abort it too.
*/
trackEvalExecution<T>(execution: Promise<T>, abortController: AbortController): Promise<T> {
this.#evalAbortControllers.add(abortController);
this.#activeEvalExecutions.add(execution);
void execution.then(
() => {
this.#evalAbortControllers.delete(abortController);
this.#activeEvalExecutions.delete(execution);
},
() => {
this.#evalAbortControllers.delete(abortController);
this.#activeEvalExecutions.delete(execution);
},
);
return execution;
}
/**
* Record a Python execution result in session history.
*/
recordPythonResult(code: string, result: PythonResult, options?: { excludeFromContext?: boolean }): void {
const meta = outputMeta().truncationFromSummary(result, { direction: "tail" }).get();
const pythonMessage: PythonExecutionMessage = {
role: "pythonExecution",
code,
output: result.output,
exitCode: result.exitCode,
cancelled: result.cancelled,
truncated: result.truncated,
meta,
timestamp: Date.now(),
excludeFromContext: options?.excludeFromContext,
};
// If agent is streaming, defer adding to avoid breaking tool_use/tool_result ordering
if (this.isStreaming) {
this.#pendingPythonMessages.push(pythonMessage);
} else {
this.agent.appendMessage(pythonMessage);
this.sessionManager.appendMessage(pythonMessage);
}
}
/**
* Cancel running Python execution.
*/
abortEval(): void {
for (const abortController of this.#evalAbortControllers) {
abortController.abort();
}
}
async #waitForEvalExecutionsToSettle(timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (this.#activeEvalExecutions.size > 0) {
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) {
return false;
}
const settled = await Promise.race([
Promise.allSettled(Array.from(this.#activeEvalExecutions)).then(() => true),
Bun.sleep(remainingMs).then(() => false),
]);
if (!settled && this.#activeEvalExecutions.size > 0) {
return false;
}
}
return true;
}
async #prepareEvalExecutionsForDispose(): Promise<boolean> {
if (!(await this.#waitForEvalExecutionsToSettle(3_000))) {
logger.warn("Aborting active Python execution during dispose before retained kernel cleanup");
this.abortEval();
if (!(await this.#waitForEvalExecutionsToSettle(1_000))) {
logger.warn(
"Python execution is still active after dispose aborted all active runs; retained kernel ownership will still be detached",
);
return false;
}
}
return true;
}
/** Whether a Python execution is currently running */
get isEvalRunning(): boolean {
return this.#evalAbortControllers.size > 0;
}
/** Whether there are pending Python messages waiting to be flushed */
get hasPendingPythonMessages(): boolean {
return this.#pendingPythonMessages.length > 0;
}
/**
* Flush pending Python messages to agent state and session.
*/
#flushPendingPythonMessages(): void {
if (this.#pendingPythonMessages.length === 0) return;
for (const pythonMessage of this.#pendingPythonMessages) {
this.agent.appendMessage(pythonMessage);
this.sessionManager.appendMessage(pythonMessage);
}
this.#pendingPythonMessages = [];
}
// =========================================================================
// Background-Channel IRC Exchanges
// =========================================================================
/**
* Generate an ephemeral reply to a background message (e.g. an IRC ping from
* another agent) using this session's current model + system prompt + history.
*
* The incoming message is queued for injection into the recipient's persisted
* history immediately so timeouts/abort still preserve delivery. The reply is
* computed via a side-channel `streamSimple` call (analogous to `/btw`) so it
* never blocks on the recipient's in-flight tool calls. When a reply is
* generated, it is queued separately. Injection happens immediately when the
* session is idle, otherwise it is deferred until streaming ends.
*/
async respondAsBackground(args: {
from: string;
message: string;
awaitReply?: boolean;
signal?: AbortSignal;
}): Promise<{ replyText: string | null }> {
const awaitReply = args.awaitReply !== false;
const incomingTimestamp = Date.now();
const incomingRecord: CustomMessage = {
role: "custom",
customType: "irc:incoming",
content: `[IRC \`${args.from}\` → you]\n\n${args.message}`,
display: true,
details: { from: args.from, message: args.message },
attribution: "agent",
timestamp: incomingTimestamp,
};
void this.#emitSessionEvent({ type: "irc_message", message: incomingRecord });
this.#forwardIrcRelayToMain({
from: args.from,
to: this.#agentId ?? "?",
body: args.message,
kind: "message",
timestamp: incomingTimestamp,
});
this.#queueBackgroundExchangeInjection([incomingRecord]);
if (!awaitReply) {
return { replyText: null };
}
const incomingPrompt = prompt.render(ircIncomingTemplate, {
from: args.from,
message: args.message,
});
const { replyText } = await this.runEphemeralTurn({
promptText: incomingPrompt,
signal: args.signal,
});
const replyRecord: CustomMessage = {
role: "custom",
customType: "irc:autoreply",
content: `[IRC you → \`${args.from}\` (auto)]\n\n${replyText}`,
display: true,
details: { to: args.from, reply: replyText },
attribution: "agent",
timestamp: Date.now(),
};
void this.#emitSessionEvent({ type: "irc_message", message: replyRecord });
this.#forwardIrcRelayToMain({
from: this.#agentId ?? "?",
to: args.from,
body: replyText,
kind: "reply",
timestamp: replyRecord.timestamp,
});
this.#queueBackgroundExchangeInjection([replyRecord]);
return { replyText };
}
/**
* Forward an IRC exchange observation to the main agent's session UI so the
* user can see every IRC conversation in the main transcript, even when the
* main agent is not a direct participant. The relay record is display-only:
* it is NOT injected into the main agent's persisted history.
*/
#forwardIrcRelayToMain(args: {
from: string;
to: string;
body: string;
kind: "message" | "reply";
timestamp: number;
}): void {
const registry = this.#agentRegistry;
if (!registry) return;
// If this session is the main agent, the local emit already reached the main UI.
if (this.#agentId === MAIN_AGENT_ID) return;
const mainRef = registry.get(MAIN_AGENT_ID);
const mainSession = mainRef?.session;
if (!mainSession || mainSession === this) return;
const arrow = args.kind === "reply" ? "→ (auto)" : "→";
const relayRecord: CustomMessage = {
role: "custom",
customType: "irc:relay",
content: `[IRC \`${args.from}\` ${arrow} \`${args.to}\`]\n\n${args.body}`,
display: true,
details: { from: args.from, to: args.to, body: args.body, kind: args.kind },
attribution: "agent",
timestamp: args.timestamp,
};
mainSession.emitIrcRelayObservation(relayRecord);
}
/**
* Emit an IRC relay observation event on this session for UI rendering only.
* Does not persist the record to history. Public so other sessions can forward.
*/
emitIrcRelayObservation(record: CustomMessage): void {
void this.#emitSessionEvent({ type: "irc_message", message: record });
}
/**
* Run a single ephemeral side-channel turn against this session's current
* model + system prompt + history. No tools are used; the side request
* does not block on, or interfere with, any in-flight main turn. The
* session's history and persisted state are NOT modified by this call.
*
* Used by `respondAsBackground` (IRC) and `BtwController` (`/btw`) to share
* the snapshot + stream pipeline. The snapshot includes any in-flight
* streaming assistant text so the model sees the half-finished response
* rather than missing context.
*/
async runEphemeralTurn(args: {
promptText: string;
onTextDelta?: (delta: string) => void;
signal?: AbortSignal;
}): Promise<{ replyText: string; assistantMessage: AssistantMessage }> {
const model = this.model;
if (!model) {
throw new Error("No active model on session");
}
const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
if (!apiKey) {
throw new Error(`No API key for ${model.provider}/${model.id}`);
}
const snapshot = this.#buildEphemeralSnapshot(args.promptText);
const llmMessages = await this.convertMessagesToLlm(snapshot, args.signal);
const context: Context = {
systemPrompt: this.systemPrompt,
messages: llmMessages,
// Empty tools array: with toolChoice="none" some encoders still serialize the
// recipient's tool catalog and the model leaks raw call markup
// (<function_calls>, DSML envelopes) into IRC replies. Stripping tools here
// removes the surface entirely.
tools: [],
};
const cacheSessionId = this.sessionId;
const options = this.prepareSimpleStreamOptions(
{
apiKey,
// Side-channel turns must not share OpenAI/Codex append-only
// conversation state with the main agent turn: IRC and /btw can run
// while the main turn is mid-tool-call. Keep the prompt-cache key
// stable, but give provider routing a unique request lineage.
sessionId: `${cacheSessionId}:side:${Snowflake.next()}`,
promptCacheKey: cacheSessionId,
preferWebsockets: false,
reasoning: toReasoningEffort(this.thinkingLevel),
hideThinkingSummary: this.agent.hideThinkingSummary,
serviceTier: this.serviceTier,
signal: args.signal,
toolChoice: "none",
},
model.provider,
);
let replyText = "";
let assistantMessage: AssistantMessage | undefined;
const stream = streamSimple(model, context, options);
for await (const event of stream) {
if (event.type === "text_delta") {
replyText += event.delta;
if (args.onTextDelta) args.onTextDelta(event.delta);
continue;
}
if (event.type === "done") {
assistantMessage = event.message;
break;
}
if (event.type === "error") {
throw new Error(event.error.errorMessage || "Ephemeral turn failed");
}
}
if (!assistantMessage) {
throw new Error("Ephemeral turn ended without a final message");
}
return { replyText: dedupeIrcReply(replyText.trim()), assistantMessage };
}
/**
* Build a message snapshot for an ephemeral side-channel turn. Includes
* the in-flight streaming assistant message (if any) so the model sees
* the partial response in context, then appends the prompt as a virtual
* user message.
*/
#buildEphemeralSnapshot(promptText: string): AgentMessage[] {
const messages = [...this.messages];
const streaming = this.agent.state.streamMessage;
if (streaming && streaming.role === "assistant") {
const preservedBlocks: AssistantMessage["content"] = [];
// Preserve thinking blocks: DeepSeek-class encoders replay them as
// `reasoning_content` and reject the request (HTTP 400) when the field
// goes missing on a turn that previously emitted thinking.
for (const c of streaming.content) {
if (c.type === "thinking") preservedBlocks.push(c);
}
const streamingText = streaming.content
.filter((c): c is TextContent => c.type === "text")
.map(c => c.text)
.join("");
if (streamingText) {
preservedBlocks.push({ type: "text", text: streamingText });
}
if (preservedBlocks.length > 0) {
const normalized: AssistantMessage = {
...streaming,
content: preservedBlocks,
};
const lastMessage = messages.at(-1);
if (lastMessage?.role === "assistant") {
messages[messages.length - 1] = normalized;
} else {
messages.push(normalized);
}
}
}
messages.push({
role: "user",
content: [{ type: "text", text: promptText }],
attribution: "agent",
timestamp: Date.now(),
});
return messages;
}
#queueBackgroundExchangeInjection(messages: CustomMessage[]): void {
this.#pendingBackgroundExchanges.push(messages);
if (!this.isStreaming) {
this.#flushPendingBackgroundExchanges();
return;
}
this.#scheduleBackgroundExchangeFlush();
}
#scheduleBackgroundExchangeFlush(): void {
if (this.#scheduledBackgroundExchangeFlush) return;
this.#scheduledBackgroundExchangeFlush = true;
const attempt = (): void => {
if (this.#pendingBackgroundExchanges.length === 0 || this.#isDisposed) {
this.#pendingBackgroundExchanges = [];
this.#scheduledBackgroundExchangeFlush = false;
return;
}
if (this.isStreaming) {
setTimeout(attempt, 50);
return;
}
this.#scheduledBackgroundExchangeFlush = false;
this.#flushPendingBackgroundExchanges();
};
setTimeout(attempt, 0);
}
#flushPendingBackgroundExchanges(): void {
if (this.#pendingBackgroundExchanges.length === 0) return;
const batches = this.#pendingBackgroundExchanges;
this.#pendingBackgroundExchanges = [];
for (const batch of batches) {
for (const msg of batch) {
// emitExternalEvent on message_end appends to agent state and dispatches
// to all session listeners, which in turn handle TUI rendering and
// sessionManager persistence via #handleAgentEvent.
this.agent.emitExternalEvent({ type: "message_start", message: msg });
this.agent.emitExternalEvent({ type: "message_end", message: msg });
}
}
}
// =========================================================================
// Session Management
// =========================================================================
/**
* Reload the current session from disk.
*
* Intended for extension commands and headless modes to re-read the current session
* file and re-emit session_switch hooks.
*/
async reload(): Promise<void> {
const sessionFile = this.sessionFile;
if (!sessionFile) return;
await this.switchSession(sessionFile);
}
/**
* Switch to a different session file.
* Aborts current operation, loads messages, restores model/thinking.
* Listeners are preserved and will continue receiving events.
* @returns true if switch completed, false if cancelled by hook
*/
async switchSession(sessionPath: string): Promise<boolean> {
const previousSessionFile = this.sessionManager.getSessionFile();
const switchingToDifferentSession = previousSessionFile
? path.resolve(previousSessionFile) !== path.resolve(sessionPath)
: true;
// Emit session_before_switch event (can be cancelled)
if (this.#extensionRunner?.hasHandlers("session_before_switch")) {
const result = (await this.#extensionRunner.emit({
type: "session_before_switch",
reason: "resume",
targetSessionFile: sessionPath,
})) as SessionBeforeSwitchResult | undefined;
if (result?.cancel) {
return false;
}
}
this.#disconnectFromAgent();
await this.abort();
// Flush pending writes before switching so restore snapshots reflect committed state.
await this.sessionManager.flush();
const previousSessionState = this.sessionManager.captureState();
const previousSessionContext = this.buildDisplaySessionContext();
// switchSession replaces these arrays wholesale during load/rollback, so retaining
// the existing message objects is sufficient and avoids structured-clone failures for
// extension/custom metadata that is valid to persist but not cloneable.
const previousAgentMessages = [...this.agent.state.messages];
const previousSteeringMessages = [...this.#steeringMessages];
const previousFollowUpMessages = [...this.#followUpMessages];
const previousPendingNextTurnMessages = [...this.#pendingNextTurnMessages];
const previousScheduledHiddenNextTurnGeneration = this.#scheduledHiddenNextTurnGeneration;
const previousModel = this.model;
const previousThinkingLevel = this.#thinkingLevel;
const previousServiceTier = this.agent.serviceTier;
const previousSelectedMCPToolNames = new Set(this.#selectedMCPToolNames);
const previousTools = [...this.agent.state.tools];
const previousBaseSystemPrompt = this.#baseSystemPrompt;
const previousSystemPrompt = this.agent.state.systemPrompt;
const previousFallbackSelectedMCPToolNames = previousSessionFile
? this.#getSessionDefaultSelectedMCPToolNames(previousSessionFile)
: undefined;
this.#steeringMessages = [];
this.#followUpMessages = [];
this.#pendingNextTurnMessages = [];
this.#scheduledHiddenNextTurnGeneration = undefined;
try {
await this.sessionManager.setSessionFile(sessionPath);
this.#syncAgentSessionId();
this.#rekeyHindsightMemoryForCurrentSessionId();
const sessionContext = this.buildDisplaySessionContext();
const didReloadConversationChange =
!switchingToDifferentSession &&
this.#didSessionMessagesChange(previousSessionContext.messages, sessionContext.messages);
const fallbackSelectedMCPToolNames = this.#getSessionDefaultSelectedMCPToolNames(sessionPath);
await this.#restoreMCPSelectionsForSessionContext(sessionContext, { fallbackSelectedMCPToolNames });
// Emit session_switch event to hooks
if (this.#extensionRunner) {
await this.#extensionRunner.emit({
type: "session_switch",
reason: "resume",
previousSessionFile,
});
}
this.agent.replaceMessages(sessionContext.messages);
this.#syncTodoPhasesFromBranch();
if (switchingToDifferentSession) {
this.#closeAllProviderSessions("session switch");
} else if (didReloadConversationChange) {
this.#closeAllProviderSessions("session reload");
}
// Restore model if saved
const defaultModelStr = sessionContext.models.default;
if (defaultModelStr) {
const slashIdx = defaultModelStr.indexOf("/");
if (slashIdx > 0) {
const provider = defaultModelStr.slice(0, slashIdx);
const modelId = defaultModelStr.slice(slashIdx + 1);
const availableModels = this.#modelRegistry.getAvailable();
const match = availableModels.find(m => m.provider === provider && m.id === modelId);
if (match) {
const currentModel = this.model;
const shouldResetProviderState =
switchingToDifferentSession ||
(currentModel !== undefined &&
(currentModel.provider !== match.provider ||
currentModel.id !== match.id ||
currentModel.api !== match.api));
if (shouldResetProviderState) {
this.#setModelWithProviderSessionReset(match);
} else {
this.agent.setModel(match);
}
}
}
}
const hasThinkingEntry = this.sessionManager.getBranch().some(entry => entry.type === "thinking_level_change");
const hasServiceTierEntry = this.sessionManager
.getBranch()
.some(entry => entry.type === "service_tier_change");
const defaultThinkingLevel = this.settings.get("defaultThinkingLevel");
const configuredServiceTier = this.settings.get("serviceTier");
const nextThinkingLevel = resolveThinkingLevelForModel(
this.model,
hasThinkingEntry ? (sessionContext.thinkingLevel as ThinkingLevel | undefined) : defaultThinkingLevel,
);
this.#thinkingLevel = nextThinkingLevel;
this.agent.setThinkingLevel(toReasoningEffort(nextThinkingLevel));
this.agent.serviceTier = hasServiceTierEntry
? sessionContext.serviceTier
: configuredServiceTier === "none"
? undefined
: configuredServiceTier;
if (switchingToDifferentSession) {
this.#resetHindsightConversationTrackingIfHindsight();
}
this.#reconnectToAgent();
return true;
} catch (error) {
this.sessionManager.restoreState(previousSessionState);
this.#syncAgentSessionId(previousSessionState.sessionId);
this.#rekeyHindsightMemoryForCurrentSessionId();
let restoreMcpError: unknown;
try {
await this.#restoreMCPSelectionsForSessionContext(previousSessionContext, {
fallbackSelectedMCPToolNames: previousFallbackSelectedMCPToolNames,
});
} catch (mcpError) {
restoreMcpError = mcpError;
logger.warn("Failed to restore MCP selections after switch error", {
previousSessionFile,
targetSessionFile: sessionPath,
error: String(mcpError),
});
this.#selectedMCPToolNames = new Set(previousSelectedMCPToolNames);
this.agent.setTools(previousTools);
this.#baseSystemPrompt = previousBaseSystemPrompt;
this.agent.setSystemPrompt(previousSystemPrompt);
}
this.#baseSystemPrompt = previousBaseSystemPrompt;
this.agent.setSystemPrompt(previousSystemPrompt);
this.agent.replaceMessages(previousAgentMessages);
this.#steeringMessages = previousSteeringMessages;
this.#followUpMessages = previousFollowUpMessages;
this.#pendingNextTurnMessages = previousPendingNextTurnMessages;
this.#scheduledHiddenNextTurnGeneration = previousScheduledHiddenNextTurnGeneration;
if (previousModel) {
this.agent.setModel(previousModel);
}
this.#thinkingLevel = previousThinkingLevel;
this.agent.setThinkingLevel(toReasoningEffort(previousThinkingLevel));
this.agent.serviceTier = previousServiceTier;
this.#syncTodoPhasesFromBranch();
this.#reconnectToAgent();
if (restoreMcpError) {
throw restoreMcpError;
}
throw error;
}
}
/**
* Create a branch from a specific entry.
* Emits before_branch/branch session events to hooks.
*
* @param entryId ID of the entry to branch from
* @returns Object with:
* - selectedText: The text of the selected user message (for editor pre-fill)
* - cancelled: True if a hook cancelled the branch
*/
async branch(entryId: string): Promise<{
selectedText: string;
cancelled: boolean;
}> {
const previousSessionFile = this.sessionFile;
const selectedEntry = this.sessionManager.getEntry(entryId);
if (!selectedEntry || selectedEntry.type !== "message" || selectedEntry.message.role !== "user") {
throw new Error("Invalid entry ID for branching");
}
const selectedText = this.#extractUserMessageText(selectedEntry.message.content);
let skipConversationRestore = false;
// Emit session_before_branch event (can be cancelled)
if (this.#extensionRunner?.hasHandlers("session_before_branch")) {
const result = (await this.#extensionRunner.emit({
type: "session_before_branch",
entryId,
})) as SessionBeforeBranchResult | undefined;
if (result?.cancel) {
return { selectedText, cancelled: true };
}
skipConversationRestore = result?.skipConversationRestore ?? false;
}
// Clear pending messages (bound to old session state)
this.#pendingNextTurnMessages = [];
this.#scheduledHiddenNextTurnGeneration = undefined;
// Flush pending writes before branching
await this.sessionManager.flush();
this.#cancelOwnAsyncJobs();
if (!selectedEntry.parentId) {
await this.sessionManager.newSession({ parentSession: previousSessionFile });
} else {
this.sessionManager.createBranchedSession(selectedEntry.parentId);
}
this.#syncTodoPhasesFromBranch();
this.#syncAgentSessionId();
this.#rekeyHindsightMemoryForCurrentSessionId();
this.#resetHindsightConversationTrackingIfHindsight();
// Reload messages from entries (works for both file and in-memory mode)
const sessionContext = this.buildDisplaySessionContext();
await this.#restoreMCPSelectionsForSessionContext(sessionContext);
// Emit session_branch event to hooks (after branch completes)
if (this.#extensionRunner) {
await this.#extensionRunner.emit({
type: "session_branch",
previousSessionFile,
});
}
if (!skipConversationRestore) {
this.agent.replaceMessages(sessionContext.messages);
this.#closeCodexProviderSessionsForHistoryRewrite();
}
return { selectedText, cancelled: false };
}
// =========================================================================
// Tree Navigation
// =========================================================================
/**
* Navigate to a different node in the session tree.
* Unlike branch() which creates a new session file, this stays in the same file.
*
* @param targetId The entry ID to navigate to
* @param options.summarize Whether user wants to summarize abandoned branch
* @param options.customInstructions Custom instructions for summarizer
* @returns Result with editorText (if user message) and cancelled status
*/
async navigateTree(
targetId: string,
options: { summarize?: boolean; customInstructions?: string } = {},
): Promise<{
editorText?: string;
cancelled: boolean;
aborted?: boolean;
summaryEntry?: BranchSummaryEntry;
/** Raw session context built during navigation — pass to renderInitialMessages to skip a second O(N) walk. */
sessionContext?: SessionContext;
}> {
const oldLeafId = this.sessionManager.getLeafId();
// No-op if already at target
if (targetId === oldLeafId) {
return { cancelled: false };
}
// Model required for summarization
if (options.summarize && !this.model) {
throw new Error("No model available for summarization");
}
const targetEntry = this.sessionManager.getEntry(targetId);
if (!targetEntry) {
throw new Error(`Entry ${targetId} not found`);
}
// Collect entries to summarize (from old leaf to common ancestor)
const { entries: entriesToSummarize, commonAncestorId } = collectEntriesForBranchSummary(
this.sessionManager,
oldLeafId,
targetId,
);
// Prepare event data
const preparation: TreePreparation = {
targetId,
oldLeafId,
commonAncestorId,
entriesToSummarize,
userWantsSummary: options.summarize ?? false,
};
// Set up abort controller for summarization
this.#branchSummaryAbortController = new AbortController();
let hookSummary: { summary: string; details?: unknown } | undefined;
let fromExtension = false;
// Emit session_before_tree event
if (this.#extensionRunner?.hasHandlers("session_before_tree")) {
const result = (await this.#extensionRunner.emit({
type: "session_before_tree",
preparation,
signal: this.#branchSummaryAbortController.signal,
})) as SessionBeforeTreeResult | undefined;
if (result?.cancel) {
return { cancelled: true };
}
if (result?.summary && options.summarize) {
hookSummary = result.summary;
fromExtension = true;
}
}
// Run default summarizer if needed
let summaryText: string | undefined;
let summaryDetails: unknown;
if (options.summarize && entriesToSummarize.length > 0 && !hookSummary) {
const model = this.model!;
const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
if (!apiKey) {
throw new Error(`No API key for ${model.provider}`);
}
const branchSummarySettings = this.settings.getGroup("branchSummary");
const result = await generateBranchSummary(entriesToSummarize, {
model,
apiKey,
signal: this.#branchSummaryAbortController.signal,
customInstructions: options.customInstructions,
reserveTokens: branchSummarySettings.reserveTokens,
metadata: this.agent.metadataForProvider(model.provider),
convertToLlm,
telemetry: resolveTelemetry(this.agent.telemetry, this.sessionId),
});
this.#branchSummaryAbortController = undefined;
if (result.aborted) {
return { cancelled: true, aborted: true };
}
if (result.error) {
throw new Error(result.error);
}
summaryText = result.summary;
summaryDetails = {
readFiles: result.readFiles || [],
modifiedFiles: result.modifiedFiles || [],
};
} else if (hookSummary) {
summaryText = hookSummary.summary;
summaryDetails = hookSummary.details;
}
// Determine the new leaf position based on target type
let newLeafId: string | null;
let editorText: string | undefined;
if (targetEntry.type === "message" && targetEntry.message.role === "user") {
// User message: leaf = parent (null if root), text goes to editor
newLeafId = targetEntry.parentId;
editorText = this.#extractUserMessageText(targetEntry.message.content);
} else if (targetEntry.type === "custom_message") {
// Custom message: leaf = parent (null if root), text goes to editor
newLeafId = targetEntry.parentId;
editorText =
typeof targetEntry.content === "string"
? targetEntry.content
: targetEntry.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map(c => c.text)
.join("");
} else {
// Non-user message: leaf = selected node
newLeafId = targetId;
}
// Switch leaf (with or without summary)
// Summary is attached at the navigation target position (newLeafId), not the old branch
let summaryEntry: BranchSummaryEntry | undefined;
if (summaryText) {
// Create summary at target position (can be null for root)
const summaryId = this.sessionManager.branchWithSummary(newLeafId, summaryText, summaryDetails, fromExtension);
summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry;
} else if (newLeafId === null) {
// No summary, navigating to root - reset leaf
this.sessionManager.resetLeaf();
} else {
// No summary, navigating to non-root
this.sessionManager.branch(newLeafId);
}
// Update agent state — build display context to populate agent messages.
const stateContext = this.sessionManager.buildSessionContext();
const displayContext = deobfuscateSessionContext(stateContext, this.#obfuscator);
await this.#restoreMCPSelectionsForSessionContext(displayContext);
this.agent.replaceMessages(displayContext.messages);
this.#syncTodoPhasesFromBranch();
this.#closeCodexProviderSessionsForHistoryRewrite();
this.#branchSummaryAbortController = undefined;
// Emit session_tree event; only handlers can mutate session entries, so skip
// the emit and the context rebuild when no handlers are registered (mirrors
// the session_before_tree guard above).
if (this.#extensionRunner?.hasHandlers("session_tree")) {
await this.#extensionRunner.emit({
type: "session_tree",
newLeafId: this.sessionManager.getLeafId(),
oldLeafId,
summaryEntry,
fromExtension: summaryText ? fromExtension : undefined,
});
const rawContext = this.sessionManager.buildSessionContext();
return { editorText, cancelled: false, summaryEntry, sessionContext: rawContext };
}
return { editorText, cancelled: false, summaryEntry, sessionContext: stateContext };
}
/**
* Get all user messages from session for branch selector.
*/
getUserMessagesForBranching(): Array<{ entryId: string; text: string }> {
const entries = this.sessionManager.getEntries();
const result: Array<{ entryId: string; text: string }> = [];
for (const entry of entries) {
if (entry.type !== "message") continue;
if (entry.message.role !== "user") continue;
const text = this.#extractUserMessageText(entry.message.content);
if (text) {
result.push({ entryId: entry.id, text });
}
}
return result;
}
#extractUserMessageText(content: string | Array<{ type: string; text?: string }>): string {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map(c => c.text)
.join("");
}
return "";
}
/**
* Get session statistics.
*/
getSessionStats(): SessionStats {
const state = this.state;
const userMessages = state.messages.filter(m => m.role === "user").length;
const assistantMessages = state.messages.filter(m => m.role === "assistant").length;
const toolResults = state.messages.filter(m => m.role === "toolResult").length;
let toolCalls = 0;
let totalInput = 0;
let totalOutput = 0;
let totalCacheRead = 0;
let totalCacheWrite = 0;
let totalCost = 0;
let totalPremiumRequests = 0;
const getTaskToolUsage = (details: unknown): Usage | undefined => {
if (!details || typeof details !== "object") return undefined;
const record = details as Record<string, unknown>;
const usage = record.usage;
if (!usage || typeof usage !== "object") return undefined;
return usage as Usage;
};
for (const message of state.messages) {
if (message.role === "assistant") {
const assistantMsg = message as AssistantMessage;
toolCalls += assistantMsg.content.filter(c => c.type === "toolCall").length;
totalInput += assistantMsg.usage.input;
totalOutput += assistantMsg.usage.output;
totalCacheRead += assistantMsg.usage.cacheRead;
totalCacheWrite += assistantMsg.usage.cacheWrite;
totalPremiumRequests += assistantMsg.usage.premiumRequests ?? 0;
totalCost += assistantMsg.usage.cost.total;
}
if (message.role === "toolResult" && message.toolName === "task") {
const usage = getTaskToolUsage(message.details);
if (usage) {
totalInput += usage.input;
totalOutput += usage.output;
totalCacheRead += usage.cacheRead;
totalCacheWrite += usage.cacheWrite;
totalPremiumRequests += usage.premiumRequests ?? 0;
totalCost += usage.cost.total;
}
}
}
return {
sessionFile: this.sessionFile,
sessionId: this.sessionId,
userMessages,
assistantMessages,
toolCalls,
toolResults,
totalMessages: state.messages.length,
tokens: {
input: totalInput,
output: totalOutput,
cacheRead: totalCacheRead,
cacheWrite: totalCacheWrite,
total: totalInput + totalOutput + totalCacheRead + totalCacheWrite,
},
cost: totalCost,
premiumRequests: totalPremiumRequests,
};
}
/**
* Get current context usage statistics.
* Uses the last assistant message's usage data when available,
* otherwise estimates tokens for all messages.
*/
getContextUsage(): ContextUsage | undefined {
const model = this.model;
if (!model) return undefined;
const contextWindow = model.contextWindow ?? 0;
if (contextWindow <= 0) return undefined;
// After compaction, the last assistant usage reflects pre-compaction context size.
// We can only trust usage from an assistant that responded after the latest compaction.
// If no such assistant exists, context token count is unknown until the next LLM response.
const branchEntries = this.sessionManager.getBranch();
const latestCompaction = getLatestCompactionEntry(branchEntries);
if (latestCompaction) {
// Check if there's a valid assistant usage after the compaction boundary
const compactionIndex = branchEntries.lastIndexOf(latestCompaction);
let hasPostCompactionUsage = false;
for (let i = branchEntries.length - 1; i > compactionIndex; i--) {
const entry = branchEntries[i];
if (entry.type === "message" && entry.message.role === "assistant") {
const assistant = entry.message;
if (assistant.stopReason !== "aborted" && assistant.stopReason !== "error") {
const contextTokens = calculateContextTokens(assistant.usage);
if (contextTokens > 0) {
hasPostCompactionUsage = true;
}
break;
}
}
}
if (!hasPostCompactionUsage) {
return { tokens: null, contextWindow, percent: null };
}
}
const estimate = this.#estimateContextTokens();
const percent = (estimate.tokens / contextWindow) * 100;
return {
tokens: estimate.tokens,
contextWindow,
percent,
};
}
async fetchUsageReports(signal?: AbortSignal): Promise<UsageReport[] | null> {
const authStorage = this.#modelRegistry.authStorage;
if (!authStorage.fetchUsageReports) return null;
return authStorage.fetchUsageReports({
baseUrlResolver: provider => this.#modelRegistry.getProviderBaseUrl?.(provider),
signal,
});
}
/**
* Estimate context tokens from messages, using the last assistant usage when available.
*/
#estimateContextTokens(): {
tokens: number;
} {
const messages = this.messages;
// Find last assistant message with usage
let lastUsageIndex: number | null = null;
let lastUsage: Usage | undefined;
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.role === "assistant") {
const assistantMsg = msg as AssistantMessage;
if (assistantMsg.usage) {
lastUsage = assistantMsg.usage;
lastUsageIndex = i;
break;
}
}
}
if (!lastUsage || lastUsageIndex === null) {
// No usage data - estimate all messages
let estimated = 0;
for (const message of messages) {
estimated += estimateTokens(message);
}
return {
tokens: estimated,
};
}
const usageTokens = calculatePromptTokens(lastUsage);
let trailingTokens = 0;
for (let i = lastUsageIndex + 1; i < messages.length; i++) {
trailingTokens += estimateTokens(messages[i]);
}
return {
tokens: usageTokens + trailingTokens,
};
}
/**
* Export session to HTML.
* @param outputPath Optional output path (defaults to session directory)
* @returns Path to exported file
*/
async exportToHtml(outputPath?: string): Promise<string> {
const themeName = getCurrentThemeName();
return exportSessionToHtml(this.sessionManager, this.state, { outputPath, themeName });
}
// =========================================================================
// Utilities
// =========================================================================
/**
* Get text content of last assistant message.
* Useful for /copy command.
* @returns Text content, or undefined if no assistant message exists
*/
getLastAssistantText(): string | undefined {
const lastAssistant = this.#getLastCopyCandidateAssistantMessage();
if (!lastAssistant) return undefined;
let text = "";
for (const content of lastAssistant.content) {
if (content.type === "text") {
text += content.text;
}
}
return text.trim() || undefined;
}
hasCopyCandidateAssistantMessage(): boolean {
return this.#getLastCopyCandidateAssistantMessage() !== undefined;
}
#getLastCopyCandidateAssistantMessage(): AssistantMessage | undefined {
for (let i = this.messages.length - 1; i >= 0; i--) {
const message = this.messages[i];
if (message.role !== "assistant") continue;
const assistantMessage = message as AssistantMessage;
// Skip aborted messages with no content
if (assistantMessage.stopReason === "aborted" && assistantMessage.content.length === 0) continue;
return assistantMessage;
}
return undefined;
}
/**
* Get text content of the most recent visible handoff message.
* Fresh handoff sessions store the handoff context as a custom message, not
* an assistant message, so callers that copy the "last" message can use this
* as a fallback before the new session has an assistant response.
*/
getLastVisibleHandoffText(): string | undefined {
for (let i = this.messages.length - 1; i >= 0; i--) {
const message = this.messages[i];
if (message.role !== "custom") continue;
const customMessage = message as CustomMessage;
if (customMessage.customType !== "handoff" || !customMessage.display) continue;
if (typeof customMessage.content === "string") {
return customMessage.content.trim() || undefined;
}
let text = "";
for (const content of customMessage.content) {
if (content.type === "text") {
text += content.text;
}
}
return text.trim() || undefined;
}
return undefined;
}
/**
* Format the entire session as plain text for clipboard export.
* Includes user messages, assistant text, thinking blocks, tool calls, and tool results.
*/
formatSessionAsText(): string {
return formatSessionDumpText({
messages: this.messages,
systemPrompt: this.agent.state.systemPrompt,
model: this.agent.state.model,
thinkingLevel: this.#thinkingLevel,
tools: this.agent.state.tools,
});
}
/**
* Format the conversation as compact context for subagents.
* Includes only user messages and assistant text responses.
* Excludes: system prompt, tool definitions, tool calls/results, thinking blocks.
*/
formatCompactContext(): string {
const lines: string[] = [];
lines.push("# Conversation Context");
lines.push("");
lines.push(
"This is a summary of the parent conversation. Read this if you need additional context about what was discussed or decided.",
);
lines.push("");
for (const msg of this.messages) {
if (msg.role === "user" || msg.role === "developer") {
lines.push(msg.role === "developer" ? "## Developer" : "## User");
lines.push("");
if (typeof msg.content === "string") {
lines.push(msg.content);
} else {
for (const c of msg.content) {
if (c.type === "text") {
lines.push(c.text);
} else if (c.type === "image") {
lines.push("[Image attached]");
}
}
}
lines.push("");
} else if (msg.role === "assistant") {
const assistantMsg = msg as AssistantMessage;
// Only include text content, skip tool calls and thinking
const textParts: string[] = [];
for (const c of assistantMsg.content) {
if (c.type === "text" && c.text.trim()) {
textParts.push(c.text);
}
}
if (textParts.length > 0) {
lines.push("## Assistant");
lines.push("");
lines.push(textParts.join("\n\n"));
lines.push("");
}
} else if (msg.role === "fileMention") {
const fileMsg = msg as FileMentionMessage;
const paths = fileMsg.files.map(f => f.path).join(", ");
lines.push(`[Files referenced: ${paths}]`);
lines.push("");
} else if (msg.role === "compactionSummary") {
const compactMsg = msg as CompactionSummaryMessage;
lines.push("## Earlier Context (Summarized)");
lines.push("");
lines.push(compactMsg.summary);
lines.push("");
}
// Skip: toolResult, bashExecution, pythonExecution, branchSummary, custom, hookMessage
}
return lines.join("\n").trim();
}
// =========================================================================
// Extension System
// =========================================================================
/**
* Check if extensions have handlers for a specific event type.
*/
hasExtensionHandlers(eventType: string): boolean {
return this.#extensionRunner?.hasHandlers(eventType) ?? false;
}
/**
* Get the extension runner (for setting UI context and error handlers).
*/
get extensionRunner(): ExtensionRunner | undefined {
return this.#extensionRunner;
}
}