mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 08:03:39 +00:00
737 lines
23 KiB
TypeScript
737 lines
23 KiB
TypeScript
/**
|
|
* 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<KeyAction> = [
|
|
"interrupt",
|
|
"clear",
|
|
"exit",
|
|
"suspend",
|
|
"cycleThinkingLevel",
|
|
"cycleModelForward",
|
|
"cycleModelBackward",
|
|
"selectModel",
|
|
"expandTools",
|
|
"toggleThinking",
|
|
"externalEditor",
|
|
"followUp",
|
|
"submit",
|
|
"selectConfirm",
|
|
"selectCancel",
|
|
"copy",
|
|
"deleteToLineEnd",
|
|
];
|
|
|
|
type BuiltInKeyBindings = Partial<Record<KeyId, { action: KeyAction; restrictOverride: boolean }>>;
|
|
|
|
const buildBuiltinKeybindings = (effectiveKeybindings: Required<KeybindingsConfig>): 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<BeforeAgentStartEventResult["message"]>[];
|
|
systemPrompt?: string;
|
|
}
|
|
|
|
export type ExtensionErrorListener = (error: ExtensionError) => void;
|
|
|
|
export type NewSessionHandler = (options?: {
|
|
parentSession?: string;
|
|
setup?: (sessionManager: SessionManager) => Promise<void>;
|
|
}) => 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<boolean> {
|
|
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<ExtensionErrorListener> = new Set();
|
|
private getModel: () => Model<any> | undefined = () => undefined;
|
|
private isIdleFn: () => boolean = () => true;
|
|
private waitForIdleFn: () => Promise<void> = 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<string, ExtensionFlag> {
|
|
const allFlags = new Map<string, ExtensionFlag>();
|
|
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<string, boolean | string> {
|
|
return new Map(this.runtime.flagValues);
|
|
}
|
|
|
|
getShortcuts(effectiveKeybindings: Required<KeybindingsConfig>): Map<KeyId, ExtensionShortcut> {
|
|
this.shortcutDiagnostics = [];
|
|
const builtinKeybindings = buildBuiltinKeybindings(effectiveKeybindings);
|
|
const extensionShortcuts = new Map<KeyId, ExtensionShortcut>();
|
|
|
|
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<string>): 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<SessionBeforeCompactResult | SessionBeforeTreeResult | ToolResultEventResult | undefined> {
|
|
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<ToolCallEventResult | undefined> {
|
|
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<UserBashEventResult | undefined> {
|
|
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<AgentMessage[]> {
|
|
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<BeforeAgentStartCombinedResult | undefined> {
|
|
const ctx = this.createContext();
|
|
const messages: NonNullable<BeforeAgentStartEventResult["message"]>[] = [];
|
|
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<InputEventResult> {
|
|
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" };
|
|
}
|
|
}
|