/** * Extension runner - executes extensions and manages their lifecycle. */ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { ImageContent, Model } from "@mariozechner/pi-ai"; import type { KeyId } from "@mariozechner/pi-tui"; import { type Theme, theme } from "../../modes/interactive/theme/theme.js"; import type { ResourceDiagnostic } from "../diagnostics.js"; import type { KeyAction, KeybindingsConfig } from "../keybindings.js"; import type { ModelRegistry } from "../model-registry.js"; import type { SessionManager } from "../session-manager.js"; import type { BeforeAgentStartEvent, BeforeAgentStartEventResult, CompactOptions, ContextEvent, ContextEventResult, ContextUsage, Extension, ExtensionActions, ExtensionCommandContext, ExtensionCommandContextActions, ExtensionContext, ExtensionContextActions, ExtensionError, ExtensionEvent, ExtensionFlag, ExtensionRuntime, ExtensionShortcut, ExtensionUIContext, InputEvent, InputEventResult, InputSource, MessageRenderer, RegisteredCommand, RegisteredTool, ResourcesDiscoverEvent, ResourcesDiscoverResult, SessionBeforeCompactResult, SessionBeforeTreeResult, ToolCallEvent, ToolCallEventResult, ToolResultEventResult, UserBashEvent, UserBashEventResult, } from "./types.js"; // Keybindings for these actions cannot be overridden by extensions const RESERVED_ACTIONS_FOR_EXTENSION_CONFLICTS: ReadonlyArray = [ "interrupt", "clear", "exit", "suspend", "cycleThinkingLevel", "cycleModelForward", "cycleModelBackward", "selectModel", "expandTools", "toggleThinking", "externalEditor", "followUp", "submit", "selectConfirm", "selectCancel", "copy", "deleteToLineEnd", ]; type BuiltInKeyBindings = Partial>; const buildBuiltinKeybindings = (effectiveKeybindings: Required): BuiltInKeyBindings => { const builtinKeybindings = {} as BuiltInKeyBindings; for (const [action, keys] of Object.entries(effectiveKeybindings)) { const keyAction = action as KeyAction; const keyList = Array.isArray(keys) ? keys : [keys]; const restrictOverride = RESERVED_ACTIONS_FOR_EXTENSION_CONFLICTS.includes(keyAction); for (const key of keyList) { const normalizedKey = key.toLowerCase() as KeyId; builtinKeybindings[normalizedKey] = { action: keyAction, restrictOverride: restrictOverride, }; } } return builtinKeybindings; }; /** Combined result from all before_agent_start handlers */ interface BeforeAgentStartCombinedResult { messages?: NonNullable[]; systemPrompt?: string; } export type ExtensionErrorListener = (error: ExtensionError) => void; export type NewSessionHandler = (options?: { parentSession?: string; setup?: (sessionManager: SessionManager) => Promise; }) => Promise<{ cancelled: boolean }>; export type ForkHandler = (entryId: string) => Promise<{ cancelled: boolean }>; export type NavigateTreeHandler = ( targetId: string, options?: { summarize?: boolean; customInstructions?: string; replaceInstructions?: boolean; label?: string }, ) => Promise<{ cancelled: boolean }>; export type SwitchSessionHandler = (sessionPath: string) => Promise<{ cancelled: boolean }>; export type ShutdownHandler = () => void; /** * Helper function to emit session_shutdown event to extensions. * Returns true if the event was emitted, false if there were no handlers. */ export async function emitSessionShutdownEvent(extensionRunner: ExtensionRunner | undefined): Promise { if (extensionRunner?.hasHandlers("session_shutdown")) { await extensionRunner.emit({ type: "session_shutdown", }); return true; } return false; } const noOpUIContext: ExtensionUIContext = { select: async () => undefined, confirm: async () => false, input: async () => undefined, notify: () => {}, setStatus: () => {}, setWorkingMessage: () => {}, setWidget: () => {}, setFooter: () => {}, setHeader: () => {}, setTitle: () => {}, custom: async () => undefined as never, setEditorText: () => {}, getEditorText: () => "", editor: async () => undefined, setEditorComponent: () => {}, get theme() { return theme; }, getAllThemes: () => [], getTheme: () => undefined, setTheme: (_theme: string | Theme) => ({ success: false, error: "UI not available" }), getToolsExpanded: () => false, setToolsExpanded: () => {}, }; export class ExtensionRunner { private extensions: Extension[]; private runtime: ExtensionRuntime; private uiContext: ExtensionUIContext; private cwd: string; private sessionManager: SessionManager; private modelRegistry: ModelRegistry; private errorListeners: Set = new Set(); private getModel: () => Model | undefined = () => undefined; private isIdleFn: () => boolean = () => true; private waitForIdleFn: () => Promise = async () => {}; private abortFn: () => void = () => {}; private hasPendingMessagesFn: () => boolean = () => false; private getContextUsageFn: () => ContextUsage | undefined = () => undefined; private compactFn: (options?: CompactOptions) => void = () => {}; private getSystemPromptFn: () => string = () => ""; private newSessionHandler: NewSessionHandler = async () => ({ cancelled: false }); private forkHandler: ForkHandler = async () => ({ cancelled: false }); private navigateTreeHandler: NavigateTreeHandler = async () => ({ cancelled: false }); private switchSessionHandler: SwitchSessionHandler = async () => ({ cancelled: false }); private shutdownHandler: ShutdownHandler = () => {}; private shortcutDiagnostics: ResourceDiagnostic[] = []; private commandDiagnostics: ResourceDiagnostic[] = []; constructor( extensions: Extension[], runtime: ExtensionRuntime, cwd: string, sessionManager: SessionManager, modelRegistry: ModelRegistry, ) { this.extensions = extensions; this.runtime = runtime; this.uiContext = noOpUIContext; this.cwd = cwd; this.sessionManager = sessionManager; this.modelRegistry = modelRegistry; } bindCore(actions: ExtensionActions, contextActions: ExtensionContextActions): void { // Copy actions into the shared runtime (all extension APIs reference this) this.runtime.sendMessage = actions.sendMessage; this.runtime.sendUserMessage = actions.sendUserMessage; this.runtime.appendEntry = actions.appendEntry; this.runtime.setSessionName = actions.setSessionName; this.runtime.getSessionName = actions.getSessionName; this.runtime.setLabel = actions.setLabel; this.runtime.getActiveTools = actions.getActiveTools; this.runtime.getAllTools = actions.getAllTools; this.runtime.setActiveTools = actions.setActiveTools; this.runtime.getCommands = actions.getCommands; this.runtime.setModel = actions.setModel; this.runtime.getThinkingLevel = actions.getThinkingLevel; this.runtime.setThinkingLevel = actions.setThinkingLevel; // Context actions (required) this.getModel = contextActions.getModel; this.isIdleFn = contextActions.isIdle; this.abortFn = contextActions.abort; this.hasPendingMessagesFn = contextActions.hasPendingMessages; this.shutdownHandler = contextActions.shutdown; this.getContextUsageFn = contextActions.getContextUsage; this.compactFn = contextActions.compact; this.getSystemPromptFn = contextActions.getSystemPrompt; // Process provider registrations queued during extension loading for (const { name, config } of this.runtime.pendingProviderRegistrations) { this.modelRegistry.registerProvider(name, config); } this.runtime.pendingProviderRegistrations = []; } bindCommandContext(actions?: ExtensionCommandContextActions): void { if (actions) { this.waitForIdleFn = actions.waitForIdle; this.newSessionHandler = actions.newSession; this.forkHandler = actions.fork; this.navigateTreeHandler = actions.navigateTree; this.switchSessionHandler = actions.switchSession; return; } this.waitForIdleFn = async () => {}; this.newSessionHandler = async () => ({ cancelled: false }); this.forkHandler = async () => ({ cancelled: false }); this.navigateTreeHandler = async () => ({ cancelled: false }); this.switchSessionHandler = async () => ({ cancelled: false }); } setUIContext(uiContext?: ExtensionUIContext): void { this.uiContext = uiContext ?? noOpUIContext; } getUIContext(): ExtensionUIContext { return this.uiContext; } hasUI(): boolean { return this.uiContext !== noOpUIContext; } getExtensionPaths(): string[] { return this.extensions.map((e) => e.path); } /** Get all registered tools from all extensions. */ getAllRegisteredTools(): RegisteredTool[] { const tools: RegisteredTool[] = []; for (const ext of this.extensions) { for (const tool of ext.tools.values()) { tools.push(tool); } } return tools; } /** Get a tool definition by name. Returns undefined if not found. */ getToolDefinition(toolName: string): RegisteredTool["definition"] | undefined { for (const ext of this.extensions) { const tool = ext.tools.get(toolName); if (tool) { return tool.definition; } } return undefined; } getFlags(): Map { const allFlags = new Map(); for (const ext of this.extensions) { for (const [name, flag] of ext.flags) { allFlags.set(name, flag); } } return allFlags; } setFlagValue(name: string, value: boolean | string): void { this.runtime.flagValues.set(name, value); } getFlagValues(): Map { return new Map(this.runtime.flagValues); } getShortcuts(effectiveKeybindings: Required): Map { this.shortcutDiagnostics = []; const builtinKeybindings = buildBuiltinKeybindings(effectiveKeybindings); const extensionShortcuts = new Map(); const addDiagnostic = (message: string, extensionPath: string) => { this.shortcutDiagnostics.push({ type: "warning", message, path: extensionPath }); if (!this.hasUI()) { console.warn(message); } }; for (const ext of this.extensions) { for (const [key, shortcut] of ext.shortcuts) { const normalizedKey = key.toLowerCase() as KeyId; const builtInKeybinding = builtinKeybindings[normalizedKey]; if (builtInKeybinding?.restrictOverride === true) { addDiagnostic( `Extension shortcut '${key}' from ${shortcut.extensionPath} conflicts with built-in shortcut. Skipping.`, shortcut.extensionPath, ); continue; } if (builtInKeybinding?.restrictOverride === false) { addDiagnostic( `Extension shortcut conflict: '${key}' is built-in shortcut for ${builtInKeybinding.action} and ${shortcut.extensionPath}. Using ${shortcut.extensionPath}.`, shortcut.extensionPath, ); } const existingExtensionShortcut = extensionShortcuts.get(normalizedKey); if (existingExtensionShortcut) { addDiagnostic( `Extension shortcut conflict: '${key}' registered by both ${existingExtensionShortcut.extensionPath} and ${shortcut.extensionPath}. Using ${shortcut.extensionPath}.`, shortcut.extensionPath, ); } extensionShortcuts.set(normalizedKey, shortcut); } } return extensionShortcuts; } getShortcutDiagnostics(): ResourceDiagnostic[] { return this.shortcutDiagnostics; } onError(listener: ExtensionErrorListener): () => void { this.errorListeners.add(listener); return () => this.errorListeners.delete(listener); } emitError(error: ExtensionError): void { for (const listener of this.errorListeners) { listener(error); } } hasHandlers(eventType: string): boolean { for (const ext of this.extensions) { const handlers = ext.handlers.get(eventType); if (handlers && handlers.length > 0) { return true; } } return false; } getMessageRenderer(customType: string): MessageRenderer | undefined { for (const ext of this.extensions) { const renderer = ext.messageRenderers.get(customType); if (renderer) { return renderer; } } return undefined; } getRegisteredCommands(reserved?: Set): RegisteredCommand[] { this.commandDiagnostics = []; const commands: RegisteredCommand[] = []; for (const ext of this.extensions) { for (const command of ext.commands.values()) { if (reserved?.has(command.name)) { const message = `Extension command '${command.name}' from ${ext.path} conflicts with built-in commands. Skipping.`; this.commandDiagnostics.push({ type: "warning", message, path: ext.path }); if (!this.hasUI()) { console.warn(message); } continue; } commands.push(command); } } return commands; } getCommandDiagnostics(): ResourceDiagnostic[] { return this.commandDiagnostics; } getRegisteredCommandsWithPaths(): Array<{ command: RegisteredCommand; extensionPath: string }> { const result: Array<{ command: RegisteredCommand; extensionPath: string }> = []; for (const ext of this.extensions) { for (const command of ext.commands.values()) { result.push({ command, extensionPath: ext.path }); } } return result; } getCommand(name: string): RegisteredCommand | undefined { for (const ext of this.extensions) { const command = ext.commands.get(name); if (command) { return command; } } return undefined; } /** * Request a graceful shutdown. Called by extension tools and event handlers. * The actual shutdown behavior is provided by the mode via bindExtensions(). */ shutdown(): void { this.shutdownHandler(); } /** * Create an ExtensionContext for use in event handlers and tool execution. * Context values are resolved at call time, so changes via bindCore/bindUI are reflected. */ createContext(): ExtensionContext { const getModel = this.getModel; return { ui: this.uiContext, hasUI: this.hasUI(), cwd: this.cwd, sessionManager: this.sessionManager, modelRegistry: this.modelRegistry, get model() { return getModel(); }, isIdle: () => this.isIdleFn(), abort: () => this.abortFn(), hasPendingMessages: () => this.hasPendingMessagesFn(), shutdown: () => this.shutdownHandler(), getContextUsage: () => this.getContextUsageFn(), compact: (options) => this.compactFn(options), getSystemPrompt: () => this.getSystemPromptFn(), }; } createCommandContext(): ExtensionCommandContext { return { ...this.createContext(), waitForIdle: () => this.waitForIdleFn(), newSession: (options) => this.newSessionHandler(options), fork: (entryId) => this.forkHandler(entryId), navigateTree: (targetId, options) => this.navigateTreeHandler(targetId, options), switchSession: (sessionPath) => this.switchSessionHandler(sessionPath), }; } private isSessionBeforeEvent( type: string, ): type is "session_before_switch" | "session_before_fork" | "session_before_compact" | "session_before_tree" { return ( type === "session_before_switch" || type === "session_before_fork" || type === "session_before_compact" || type === "session_before_tree" ); } async emit( event: ExtensionEvent, ): Promise { const ctx = this.createContext(); let result: SessionBeforeCompactResult | SessionBeforeTreeResult | ToolResultEventResult | undefined; for (const ext of this.extensions) { const handlers = ext.handlers.get(event.type); if (!handlers || handlers.length === 0) continue; for (const handler of handlers) { try { const handlerResult = await handler(event, ctx); if (this.isSessionBeforeEvent(event.type) && handlerResult) { result = handlerResult as SessionBeforeCompactResult | SessionBeforeTreeResult; if (result.cancel) { return result; } } if (event.type === "tool_result" && handlerResult) { result = handlerResult as ToolResultEventResult; } } catch (err) { const message = err instanceof Error ? err.message : String(err); const stack = err instanceof Error ? err.stack : undefined; this.emitError({ extensionPath: ext.path, event: event.type, error: message, stack, }); } } } return result; } async emitToolCall(event: ToolCallEvent): Promise { const ctx = this.createContext(); let result: ToolCallEventResult | undefined; for (const ext of this.extensions) { const handlers = ext.handlers.get("tool_call"); if (!handlers || handlers.length === 0) continue; for (const handler of handlers) { const handlerResult = await handler(event, ctx); if (handlerResult) { result = handlerResult as ToolCallEventResult; if (result.block) { return result; } } } } return result; } async emitUserBash(event: UserBashEvent): Promise { const ctx = this.createContext(); for (const ext of this.extensions) { const handlers = ext.handlers.get("user_bash"); if (!handlers || handlers.length === 0) continue; for (const handler of handlers) { try { const handlerResult = await handler(event, ctx); if (handlerResult) { return handlerResult as UserBashEventResult; } } catch (err) { const message = err instanceof Error ? err.message : String(err); const stack = err instanceof Error ? err.stack : undefined; this.emitError({ extensionPath: ext.path, event: "user_bash", error: message, stack, }); } } } return undefined; } async emitContext(messages: AgentMessage[]): Promise { const ctx = this.createContext(); let currentMessages = structuredClone(messages); for (const ext of this.extensions) { const handlers = ext.handlers.get("context"); if (!handlers || handlers.length === 0) continue; for (const handler of handlers) { try { const event: ContextEvent = { type: "context", messages: currentMessages }; const handlerResult = await handler(event, ctx); if (handlerResult && (handlerResult as ContextEventResult).messages) { currentMessages = (handlerResult as ContextEventResult).messages!; } } catch (err) { const message = err instanceof Error ? err.message : String(err); const stack = err instanceof Error ? err.stack : undefined; this.emitError({ extensionPath: ext.path, event: "context", error: message, stack, }); } } } return currentMessages; } async emitBeforeAgentStart( prompt: string, images: ImageContent[] | undefined, systemPrompt: string, ): Promise { const ctx = this.createContext(); const messages: NonNullable[] = []; let currentSystemPrompt = systemPrompt; let systemPromptModified = false; for (const ext of this.extensions) { const handlers = ext.handlers.get("before_agent_start"); if (!handlers || handlers.length === 0) continue; for (const handler of handlers) { try { const event: BeforeAgentStartEvent = { type: "before_agent_start", prompt, images, systemPrompt: currentSystemPrompt, }; const handlerResult = await handler(event, ctx); if (handlerResult) { const result = handlerResult as BeforeAgentStartEventResult; if (result.message) { messages.push(result.message); } if (result.systemPrompt !== undefined) { currentSystemPrompt = result.systemPrompt; systemPromptModified = true; } } } catch (err) { const message = err instanceof Error ? err.message : String(err); const stack = err instanceof Error ? err.stack : undefined; this.emitError({ extensionPath: ext.path, event: "before_agent_start", error: message, stack, }); } } } if (messages.length > 0 || systemPromptModified) { return { messages: messages.length > 0 ? messages : undefined, systemPrompt: systemPromptModified ? currentSystemPrompt : undefined, }; } return undefined; } async emitResourcesDiscover( cwd: string, reason: ResourcesDiscoverEvent["reason"], ): Promise<{ skillPaths: Array<{ path: string; extensionPath: string }>; promptPaths: Array<{ path: string; extensionPath: string }>; themePaths: Array<{ path: string; extensionPath: string }>; }> { const ctx = this.createContext(); const skillPaths: Array<{ path: string; extensionPath: string }> = []; const promptPaths: Array<{ path: string; extensionPath: string }> = []; const themePaths: Array<{ path: string; extensionPath: string }> = []; for (const ext of this.extensions) { const handlers = ext.handlers.get("resources_discover"); if (!handlers || handlers.length === 0) continue; for (const handler of handlers) { try { const event: ResourcesDiscoverEvent = { type: "resources_discover", cwd, reason }; const handlerResult = await handler(event, ctx); const result = handlerResult as ResourcesDiscoverResult | undefined; if (result?.skillPaths?.length) { skillPaths.push(...result.skillPaths.map((path) => ({ path, extensionPath: ext.path }))); } if (result?.promptPaths?.length) { promptPaths.push(...result.promptPaths.map((path) => ({ path, extensionPath: ext.path }))); } if (result?.themePaths?.length) { themePaths.push(...result.themePaths.map((path) => ({ path, extensionPath: ext.path }))); } } catch (err) { const message = err instanceof Error ? err.message : String(err); const stack = err instanceof Error ? err.stack : undefined; this.emitError({ extensionPath: ext.path, event: "resources_discover", error: message, stack, }); } } } return { skillPaths, promptPaths, themePaths }; } /** Emit input event. Transforms chain, "handled" short-circuits. */ async emitInput(text: string, images: ImageContent[] | undefined, source: InputSource): Promise { const ctx = this.createContext(); let currentText = text; let currentImages = images; for (const ext of this.extensions) { for (const handler of ext.handlers.get("input") ?? []) { try { const event: InputEvent = { type: "input", text: currentText, images: currentImages, source }; const result = (await handler(event, ctx)) as InputEventResult | undefined; if (result?.action === "handled") return result; if (result?.action === "transform") { currentText = result.text; currentImages = result.images ?? currentImages; } } catch (err) { this.emitError({ extensionPath: ext.path, event: "input", error: err instanceof Error ? err.message : String(err), stack: err instanceof Error ? err.stack : undefined, }); } } } return currentText !== text || currentImages !== images ? { action: "transform", text: currentText, images: currentImages } : { action: "continue" }; } }