UNPKG

@mariozechner/pi-coding-agent

Version:

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

705 lines 28.2 kB
/** * Extension runner - executes extensions and manages their lifecycle. */ import { theme } from "../../modes/interactive/theme/theme.js"; // Extension shortcuts compete with canonical keybinding ids from keybindings.json. // Only editor-global shortcuts are reserved here. Picker-specific bindings are not. const RESERVED_KEYBINDINGS_FOR_EXTENSION_CONFLICTS = [ "app.interrupt", "app.clear", "app.exit", "app.suspend", "app.thinking.cycle", "app.model.cycleForward", "app.model.cycleBackward", "app.model.select", "app.tools.expand", "app.thinking.toggle", "app.editor.external", "app.message.followUp", "tui.input.submit", "tui.select.confirm", "tui.select.cancel", "tui.input.copy", "tui.editor.deleteToLineEnd", ]; const buildBuiltinKeybindings = (resolvedKeybindings) => { const builtinKeybindings = {}; for (const [keybinding, keys] of Object.entries(resolvedKeybindings)) { if (keys === undefined) continue; const keyList = Array.isArray(keys) ? keys : [keys]; const restrictOverride = RESERVED_KEYBINDINGS_FOR_EXTENSION_CONFLICTS.includes(keybinding); for (const key of keyList) { const normalizedKey = key.toLowerCase(); // If multiple actions bind the same key, the reserved action wins so extensions // remain blocked by reserved shortcuts regardless of iteration order. const existing = builtinKeybindings[normalizedKey]; if (existing?.restrictOverride && !restrictOverride) continue; builtinKeybindings[normalizedKey] = { keybinding, restrictOverride, }; } } return builtinKeybindings; }; /** * 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, event) { if (extensionRunner.hasHandlers("session_shutdown")) { await extensionRunner.emit(event); return true; } return false; } const noOpUIContext = { select: async () => undefined, confirm: async () => false, input: async () => undefined, notify: () => { }, onTerminalInput: () => () => { }, setStatus: () => { }, setWorkingMessage: () => { }, setWorkingIndicator: () => { }, setHiddenThinkingLabel: () => { }, setWidget: () => { }, setFooter: () => { }, setHeader: () => { }, setTitle: () => { }, custom: async () => undefined, pasteToEditor: () => { }, setEditorText: () => { }, getEditorText: () => "", editor: async () => undefined, setEditorComponent: () => { }, get theme() { return theme; }, getAllThemes: () => [], getTheme: () => undefined, setTheme: (_theme) => ({ success: false, error: "UI not available" }), getToolsExpanded: () => false, setToolsExpanded: () => { }, }; export class ExtensionRunner { extensions; runtime; uiContext; cwd; sessionManager; modelRegistry; errorListeners = new Set(); getModel = () => undefined; isIdleFn = () => true; getSignalFn = () => undefined; waitForIdleFn = async () => { }; abortFn = () => { }; hasPendingMessagesFn = () => false; getContextUsageFn = () => undefined; compactFn = () => { }; getSystemPromptFn = () => ""; newSessionHandler = async () => ({ cancelled: false }); forkHandler = async () => ({ cancelled: false }); navigateTreeHandler = async () => ({ cancelled: false }); switchSessionHandler = async () => ({ cancelled: false }); reloadHandler = async () => { }; shutdownHandler = () => { }; shortcutDiagnostics = []; commandDiagnostics = []; constructor(extensions, runtime, cwd, sessionManager, modelRegistry) { this.extensions = extensions; this.runtime = runtime; this.uiContext = noOpUIContext; this.cwd = cwd; this.sessionManager = sessionManager; this.modelRegistry = modelRegistry; } bindCore(actions, contextActions, providerActions) { // 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.refreshTools = actions.refreshTools; 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.getSignalFn = contextActions.getSignal; this.abortFn = contextActions.abort; this.hasPendingMessagesFn = contextActions.hasPendingMessages; this.shutdownHandler = contextActions.shutdown; this.getContextUsageFn = contextActions.getContextUsage; this.compactFn = contextActions.compact; this.getSystemPromptFn = contextActions.getSystemPrompt; // Flush provider registrations queued during extension loading for (const { name, config, extensionPath } of this.runtime.pendingProviderRegistrations) { try { if (providerActions?.registerProvider) { providerActions.registerProvider(name, config); } else { this.modelRegistry.registerProvider(name, config); } } catch (err) { this.emitError({ extensionPath, event: "register_provider", error: err instanceof Error ? err.message : String(err), stack: err instanceof Error ? err.stack : undefined, }); } } this.runtime.pendingProviderRegistrations = []; // From this point on, provider registration/unregistration takes effect immediately // without requiring a /reload. this.runtime.registerProvider = (name, config) => { if (providerActions?.registerProvider) { providerActions.registerProvider(name, config); return; } this.modelRegistry.registerProvider(name, config); }; this.runtime.unregisterProvider = (name) => { if (providerActions?.unregisterProvider) { providerActions.unregisterProvider(name); return; } this.modelRegistry.unregisterProvider(name); }; } bindCommandContext(actions) { if (actions) { this.waitForIdleFn = actions.waitForIdle; this.newSessionHandler = actions.newSession; this.forkHandler = actions.fork; this.navigateTreeHandler = actions.navigateTree; this.switchSessionHandler = actions.switchSession; this.reloadHandler = actions.reload; return; } this.waitForIdleFn = async () => { }; this.newSessionHandler = async () => ({ cancelled: false }); this.forkHandler = async () => ({ cancelled: false }); this.navigateTreeHandler = async () => ({ cancelled: false }); this.switchSessionHandler = async () => ({ cancelled: false }); this.reloadHandler = async () => { }; } setUIContext(uiContext) { this.uiContext = uiContext ?? noOpUIContext; } getUIContext() { return this.uiContext; } hasUI() { return this.uiContext !== noOpUIContext; } getExtensionPaths() { return this.extensions.map((e) => e.path); } /** Get all registered tools from all extensions (first registration per name wins). */ getAllRegisteredTools() { const toolsByName = new Map(); for (const ext of this.extensions) { for (const tool of ext.tools.values()) { if (!toolsByName.has(tool.definition.name)) { toolsByName.set(tool.definition.name, tool); } } } return Array.from(toolsByName.values()); } /** Get a tool definition by name. Returns undefined if not found. */ getToolDefinition(toolName) { for (const ext of this.extensions) { const tool = ext.tools.get(toolName); if (tool) { return tool.definition; } } return undefined; } getFlags() { const allFlags = new Map(); for (const ext of this.extensions) { for (const [name, flag] of ext.flags) { if (!allFlags.has(name)) { allFlags.set(name, flag); } } } return allFlags; } setFlagValue(name, value) { this.runtime.flagValues.set(name, value); } getFlagValues() { return new Map(this.runtime.flagValues); } getShortcuts(resolvedKeybindings) { this.shortcutDiagnostics = []; const builtinKeybindings = buildBuiltinKeybindings(resolvedKeybindings); const extensionShortcuts = new Map(); const addDiagnostic = (message, extensionPath) => { 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(); 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.keybinding} 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() { return this.shortcutDiagnostics; } onError(listener) { this.errorListeners.add(listener); return () => this.errorListeners.delete(listener); } emitError(error) { for (const listener of this.errorListeners) { listener(error); } } hasHandlers(eventType) { for (const ext of this.extensions) { const handlers = ext.handlers.get(eventType); if (handlers && handlers.length > 0) { return true; } } return false; } getMessageRenderer(customType) { for (const ext of this.extensions) { const renderer = ext.messageRenderers.get(customType); if (renderer) { return renderer; } } return undefined; } resolveRegisteredCommands() { const commands = []; const counts = new Map(); for (const ext of this.extensions) { for (const command of ext.commands.values()) { commands.push(command); counts.set(command.name, (counts.get(command.name) ?? 0) + 1); } } const seen = new Map(); const takenInvocationNames = new Set(); return commands.map((command) => { const occurrence = (seen.get(command.name) ?? 0) + 1; seen.set(command.name, occurrence); let invocationName = (counts.get(command.name) ?? 0) > 1 ? `${command.name}:${occurrence}` : command.name; if (takenInvocationNames.has(invocationName)) { let suffix = occurrence; do { suffix++; invocationName = `${command.name}:${suffix}`; } while (takenInvocationNames.has(invocationName)); } takenInvocationNames.add(invocationName); return { ...command, invocationName, }; }); } getRegisteredCommands() { this.commandDiagnostics = []; return this.resolveRegisteredCommands(); } getCommandDiagnostics() { return this.commandDiagnostics; } getCommand(name) { return this.resolveRegisteredCommands().find((command) => command.invocationName === name); } /** * Request a graceful shutdown. Called by extension tools and event handlers. * The actual shutdown behavior is provided by the mode via bindExtensions(). */ shutdown() { 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() { 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(), signal: this.getSignalFn(), abort: () => this.abortFn(), hasPendingMessages: () => this.hasPendingMessagesFn(), shutdown: () => this.shutdownHandler(), getContextUsage: () => this.getContextUsageFn(), compact: (options) => this.compactFn(options), getSystemPrompt: () => this.getSystemPromptFn(), }; } createCommandContext() { return { ...this.createContext(), waitForIdle: () => this.waitForIdleFn(), newSession: (options) => this.newSessionHandler(options), fork: (entryId, options) => this.forkHandler(entryId, options), navigateTree: (targetId, options) => this.navigateTreeHandler(targetId, options), switchSession: (sessionPath) => this.switchSessionHandler(sessionPath), reload: () => this.reloadHandler(), }; } isSessionBeforeEvent(event) { return (event.type === "session_before_switch" || event.type === "session_before_fork" || event.type === "session_before_compact" || event.type === "session_before_tree"); } async emit(event) { const ctx = this.createContext(); let result; 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) && handlerResult) { result = handlerResult; if (result.cancel) { return result; } } } 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 emitToolResult(event) { const ctx = this.createContext(); const currentEvent = { ...event }; let modified = false; for (const ext of this.extensions) { const handlers = ext.handlers.get("tool_result"); if (!handlers || handlers.length === 0) continue; for (const handler of handlers) { try { const handlerResult = (await handler(currentEvent, ctx)); if (!handlerResult) continue; if (handlerResult.content !== undefined) { currentEvent.content = handlerResult.content; modified = true; } if (handlerResult.details !== undefined) { currentEvent.details = handlerResult.details; modified = true; } if (handlerResult.isError !== undefined) { currentEvent.isError = handlerResult.isError; modified = 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: "tool_result", error: message, stack, }); } } } if (!modified) { return undefined; } return { content: currentEvent.content, details: currentEvent.details, isError: currentEvent.isError, }; } async emitToolCall(event) { const ctx = this.createContext(); let result; 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; if (result.block) { return result; } } } } return result; } async emitUserBash(event) { 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; } } 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) { 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 = { type: "context", messages: currentMessages }; const handlerResult = await handler(event, ctx); if (handlerResult && handlerResult.messages) { currentMessages = handlerResult.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 emitBeforeProviderRequest(payload) { const ctx = this.createContext(); let currentPayload = payload; for (const ext of this.extensions) { const handlers = ext.handlers.get("before_provider_request"); if (!handlers || handlers.length === 0) continue; for (const handler of handlers) { try { const event = { type: "before_provider_request", payload: currentPayload, }; const handlerResult = await handler(event, ctx); if (handlerResult !== undefined) { currentPayload = handlerResult; } } 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_provider_request", error: message, stack, }); } } } return currentPayload; } async emitBeforeAgentStart(prompt, images, systemPrompt, systemPromptOptions) { const ctx = this.createContext(); const messages = []; 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 = { type: "before_agent_start", prompt, images, systemPrompt: currentSystemPrompt, systemPromptOptions, }; const handlerResult = await handler(event, ctx); if (handlerResult) { const result = handlerResult; 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, reason) { const ctx = this.createContext(); const skillPaths = []; const promptPaths = []; const themePaths = []; 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 = { type: "resources_discover", cwd, reason }; const handlerResult = await handler(event, ctx); const result = handlerResult; 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, images, source) { 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 = { type: "input", text: currentText, images: currentImages, source }; const result = (await handler(event, ctx)); 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" }; } } //# sourceMappingURL=runner.js.map