964 lines
37 KiB
JavaScript
964 lines
37 KiB
JavaScript
/**
|
|
* 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.copy",
|
|
"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;
|
|
}
|
|
export async function emitProjectTrustEvent(extensionsResult, event, ctx) {
|
|
const errors = [];
|
|
for (const ext of extensionsResult.extensions) {
|
|
// A single extension may register multiple handlers for the same event.
|
|
// The first project_trust handler that returns yes/no wins; undecided falls through.
|
|
const handlers = ext.handlers.get("project_trust");
|
|
if (!handlers || handlers.length === 0)
|
|
continue;
|
|
for (const handler of handlers) {
|
|
try {
|
|
const handlerResult = (await handler(event, ctx));
|
|
if (handlerResult.trusted === "undecided") {
|
|
continue;
|
|
}
|
|
return { result: handlerResult, errors };
|
|
}
|
|
catch (error) {
|
|
errors.push({
|
|
extensionPath: ext.path,
|
|
event: event.type,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
stack: error instanceof Error ? error.stack : undefined,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return { errors };
|
|
}
|
|
const noOpUIContext = {
|
|
select: async () => undefined,
|
|
confirm: async () => false,
|
|
input: async () => undefined,
|
|
notify: () => { },
|
|
onTerminalInput: () => () => { },
|
|
setStatus: () => { },
|
|
setWorkingMessage: () => { },
|
|
setWorkingVisible: () => { },
|
|
setWorkingIndicator: () => { },
|
|
setHiddenThinkingLabel: () => { },
|
|
setWidget: () => { },
|
|
setFooter: () => { },
|
|
setHeader: () => { },
|
|
setTitle: () => { },
|
|
custom: async () => undefined,
|
|
pasteToEditor: () => { },
|
|
setEditorText: () => { },
|
|
getEditorText: () => "",
|
|
editor: async () => undefined,
|
|
addAutocompleteProvider: () => { },
|
|
setEditorComponent: () => { },
|
|
getEditorComponent: () => undefined,
|
|
get theme() {
|
|
return theme;
|
|
},
|
|
getAllThemes: () => [],
|
|
getTheme: () => undefined,
|
|
setTheme: (_theme) => ({ success: false, error: "UI not available" }),
|
|
getToolsExpanded: () => false,
|
|
setToolsExpanded: () => { },
|
|
};
|
|
export class ExtensionRunner {
|
|
extensions;
|
|
runtime;
|
|
uiContext;
|
|
mode = "print";
|
|
cwd;
|
|
sessionManager;
|
|
modelRegistry;
|
|
errorListeners = new Set();
|
|
getModel = () => undefined;
|
|
getScopedModels = () => [];
|
|
isIdleFn = () => true;
|
|
isProjectTrustedFn = () => true;
|
|
getSignalFn = () => undefined;
|
|
waitForIdleFn = async () => { };
|
|
abortFn = () => { };
|
|
hasPendingMessagesFn = () => false;
|
|
getContextUsageFn = () => undefined;
|
|
compactFn = () => { };
|
|
getSystemPromptFn = () => "";
|
|
getSystemPromptOptionsFn = () => ({ cwd: this.cwd });
|
|
newSessionHandler = async () => ({ cancelled: false });
|
|
forkHandler = async () => ({ cancelled: false });
|
|
navigateTreeHandler = async () => ({ cancelled: false });
|
|
switchSessionHandler = async () => ({ cancelled: false });
|
|
reloadHandler = async () => { };
|
|
shutdownHandler = () => { };
|
|
shortcutDiagnostics = [];
|
|
commandDiagnostics = [];
|
|
staleMessage;
|
|
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.getScopedModels = contextActions.getScopedModels;
|
|
this.isIdleFn = contextActions.isIdle;
|
|
this.isProjectTrustedFn = contextActions.isProjectTrusted;
|
|
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;
|
|
this.getSystemPromptOptionsFn = contextActions.getSystemPromptOptions ?? (() => ({ cwd: this.cwd }));
|
|
// 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 = [];
|
|
for (const { provider, extensionPath } of this.runtime.pendingNativeProviderRegistrations) {
|
|
try {
|
|
if (providerActions?.registerNativeProvider) {
|
|
providerActions.registerNativeProvider(provider);
|
|
}
|
|
else {
|
|
this.modelRegistry.registerProvider(provider);
|
|
}
|
|
}
|
|
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.pendingNativeProviderRegistrations = [];
|
|
// 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.registerNativeProvider = (provider) => {
|
|
if (providerActions?.registerNativeProvider) {
|
|
providerActions.registerNativeProvider(provider);
|
|
return;
|
|
}
|
|
this.modelRegistry.registerProvider(provider);
|
|
};
|
|
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, mode = "print") {
|
|
this.uiContext = uiContext ?? noOpUIContext;
|
|
this.mode = mode;
|
|
}
|
|
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;
|
|
}
|
|
invalidate(message = "This extension ctx is stale after session replacement or reload. Do not use a captured pi or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload(). For newSession, fork, and switchSession, move post-replacement work into withSession and use the ctx passed to withSession. For reload, do not use the old ctx after await ctx.reload().") {
|
|
if (!this.staleMessage) {
|
|
this.staleMessage = message;
|
|
this.runtime.invalidate(message);
|
|
}
|
|
}
|
|
assertActive() {
|
|
if (this.staleMessage) {
|
|
throw new Error(this.staleMessage);
|
|
}
|
|
}
|
|
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;
|
|
}
|
|
getEntryRenderer(customType) {
|
|
for (const ext of this.extensions) {
|
|
const renderer = ext.entryRenderers?.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,
|
|
};
|
|
});
|
|
}
|
|
getModelRegistry() {
|
|
return this.modelRegistry;
|
|
}
|
|
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();
|
|
}
|
|
getActiveTools() {
|
|
this.assertActive();
|
|
return this.runtime.getActiveTools();
|
|
}
|
|
/**
|
|
* 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 runner = this;
|
|
const getModel = this.getModel;
|
|
const getScopedModels = this.getScopedModels;
|
|
return {
|
|
get ui() {
|
|
runner.assertActive();
|
|
return runner.uiContext;
|
|
},
|
|
get mode() {
|
|
runner.assertActive();
|
|
return runner.mode;
|
|
},
|
|
get hasUI() {
|
|
runner.assertActive();
|
|
return runner.hasUI();
|
|
},
|
|
get cwd() {
|
|
runner.assertActive();
|
|
return runner.cwd;
|
|
},
|
|
get sessionManager() {
|
|
runner.assertActive();
|
|
return runner.sessionManager;
|
|
},
|
|
get modelRegistry() {
|
|
runner.assertActive();
|
|
return runner.modelRegistry;
|
|
},
|
|
get model() {
|
|
runner.assertActive();
|
|
return getModel();
|
|
},
|
|
get scopedModels() {
|
|
runner.assertActive();
|
|
return getScopedModels();
|
|
},
|
|
get thinkingLevel() {
|
|
runner.assertActive();
|
|
return runner.runtime.getThinkingLevel();
|
|
},
|
|
isIdle: () => {
|
|
runner.assertActive();
|
|
return runner.isIdleFn();
|
|
},
|
|
isProjectTrusted: () => {
|
|
runner.assertActive();
|
|
return runner.isProjectTrustedFn();
|
|
},
|
|
get signal() {
|
|
runner.assertActive();
|
|
return runner.getSignalFn();
|
|
},
|
|
abort: () => {
|
|
runner.assertActive();
|
|
runner.abortFn();
|
|
},
|
|
hasPendingMessages: () => {
|
|
runner.assertActive();
|
|
return runner.hasPendingMessagesFn();
|
|
},
|
|
shutdown: () => {
|
|
runner.assertActive();
|
|
runner.shutdownHandler();
|
|
},
|
|
getContextUsage: () => {
|
|
runner.assertActive();
|
|
return runner.getContextUsageFn();
|
|
},
|
|
compact: (options) => {
|
|
runner.assertActive();
|
|
runner.compactFn(options);
|
|
},
|
|
getSystemPrompt: () => {
|
|
runner.assertActive();
|
|
return runner.getSystemPromptFn();
|
|
},
|
|
};
|
|
}
|
|
createCommandContext() {
|
|
// Use property descriptors instead of object spread so the guarded getters from
|
|
// createContext() stay lazy. A spread would eagerly read them once and freeze the
|
|
// old values into the returned object, bypassing stale-instance checks.
|
|
const context = Object.defineProperties({}, Object.getOwnPropertyDescriptors(this.createContext()));
|
|
context.getSystemPromptOptions = () => {
|
|
this.assertActive();
|
|
return this.getSystemPromptOptionsFn();
|
|
};
|
|
context.waitForIdle = () => {
|
|
this.assertActive();
|
|
return this.waitForIdleFn();
|
|
};
|
|
context.newSession = (options) => {
|
|
this.assertActive();
|
|
return this.newSessionHandler(options);
|
|
};
|
|
context.fork = (entryId, options) => {
|
|
this.assertActive();
|
|
return this.forkHandler(entryId, options);
|
|
};
|
|
context.navigateTree = (targetId, options) => {
|
|
this.assertActive();
|
|
return this.navigateTreeHandler(targetId, options);
|
|
};
|
|
context.switchSession = (sessionPath, options) => {
|
|
this.assertActive();
|
|
return this.switchSessionHandler(sessionPath, options);
|
|
};
|
|
context.reload = () => {
|
|
this.assertActive();
|
|
return this.reloadHandler();
|
|
};
|
|
return context;
|
|
}
|
|
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 emitMessageEnd(event) {
|
|
const ctx = this.createContext();
|
|
let currentMessage = event.message;
|
|
let modified = false;
|
|
for (const ext of this.extensions) {
|
|
const handlers = ext.handlers.get("message_end");
|
|
if (!handlers || handlers.length === 0)
|
|
continue;
|
|
for (const handler of handlers) {
|
|
try {
|
|
const currentEvent = { ...event, message: currentMessage };
|
|
const handlerResult = (await handler(currentEvent, ctx));
|
|
if (!handlerResult?.message)
|
|
continue;
|
|
if (handlerResult.message.role !== currentMessage.role) {
|
|
this.emitError({
|
|
extensionPath: ext.path,
|
|
event: "message_end",
|
|
error: "message_end handlers must return a message with the same role",
|
|
});
|
|
continue;
|
|
}
|
|
currentMessage = handlerResult.message;
|
|
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: "message_end",
|
|
error: message,
|
|
stack,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return modified ? currentMessage : undefined;
|
|
}
|
|
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;
|
|
}
|
|
if (handlerResult.usage !== undefined) {
|
|
currentEvent.usage = handlerResult.usage;
|
|
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,
|
|
usage: currentEvent.usage,
|
|
};
|
|
}
|
|
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 emitBeforeProviderHeaders(headers) {
|
|
const ctx = this.createContext();
|
|
for (const ext of this.extensions) {
|
|
const handlers = ext.handlers.get("before_provider_headers");
|
|
if (!handlers || handlers.length === 0)
|
|
continue;
|
|
for (const handler of handlers) {
|
|
try {
|
|
// Handlers mutate `headers` in place; the return value is ignored.
|
|
const event = {
|
|
type: "before_provider_headers",
|
|
headers,
|
|
};
|
|
await handler(event, ctx);
|
|
}
|
|
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_headers",
|
|
error: message,
|
|
stack,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return headers;
|
|
}
|
|
async emitBeforeAgentStart(prompt, images, systemPrompt, systemPromptOptions) {
|
|
let currentSystemPrompt = systemPrompt;
|
|
const ctx = Object.defineProperties({}, Object.getOwnPropertyDescriptors(this.createContext()));
|
|
ctx.getSystemPrompt = () => {
|
|
this.assertActive();
|
|
return currentSystemPrompt;
|
|
};
|
|
const messages = [];
|
|
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, streamingBehavior) {
|
|
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,
|
|
streamingBehavior,
|
|
};
|
|
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
|