mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-21 17:00:45 +00:00
Add unified extensions system (not wired up yet)
New src/core/extensions/ directory with: - types.ts: merged types from hooks and custom-tools - loader.ts: single loader for extensions - runner.ts: ExtensionRunner for event emission - wrapper.ts: tool wrapping utilities - index.ts: exports Key changes from old system: - Single ExtensionAPI with registerTool() for LLM-callable tools - Tools use ExtensionContext (has UI access) - No onSession callback on tools (use pi.on events instead) refs #454
This commit is contained in:
parent
be330fdd9c
commit
2846c7d190
5 changed files with 1830 additions and 0 deletions
100
packages/coding-agent/src/core/extensions/index.ts
Normal file
100
packages/coding-agent/src/core/extensions/index.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
/**
|
||||||
|
* Extension system - unified hooks and custom tools.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { discoverAndLoadExtensions, loadExtensions } from "./loader.js";
|
||||||
|
export type { BranchHandler, ExtensionErrorListener, NavigateTreeHandler, NewSessionHandler } from "./runner.js";
|
||||||
|
export { ExtensionRunner } from "./runner.js";
|
||||||
|
export type {
|
||||||
|
AgentEndEvent,
|
||||||
|
AgentStartEvent,
|
||||||
|
// Re-exports
|
||||||
|
AgentToolResult,
|
||||||
|
AgentToolUpdateCallback,
|
||||||
|
AppendEntryHandler,
|
||||||
|
BashToolResultEvent,
|
||||||
|
BeforeAgentStartEvent,
|
||||||
|
BeforeAgentStartEventResult,
|
||||||
|
// Events - Agent
|
||||||
|
ContextEvent,
|
||||||
|
// Event Results
|
||||||
|
ContextEventResult,
|
||||||
|
CustomToolResultEvent,
|
||||||
|
EditToolResultEvent,
|
||||||
|
ExecOptions,
|
||||||
|
ExecResult,
|
||||||
|
// API
|
||||||
|
ExtensionAPI,
|
||||||
|
ExtensionCommandContext,
|
||||||
|
// Context
|
||||||
|
ExtensionContext,
|
||||||
|
// Errors
|
||||||
|
ExtensionError,
|
||||||
|
ExtensionEvent,
|
||||||
|
ExtensionFactory,
|
||||||
|
ExtensionFlag,
|
||||||
|
ExtensionHandler,
|
||||||
|
ExtensionShortcut,
|
||||||
|
ExtensionUIContext,
|
||||||
|
FindToolResultEvent,
|
||||||
|
GetActiveToolsHandler,
|
||||||
|
GetAllToolsHandler,
|
||||||
|
GrepToolResultEvent,
|
||||||
|
LoadExtensionsResult,
|
||||||
|
// Loaded Extension
|
||||||
|
LoadedExtension,
|
||||||
|
LsToolResultEvent,
|
||||||
|
// Message Rendering
|
||||||
|
MessageRenderer,
|
||||||
|
MessageRenderOptions,
|
||||||
|
ReadToolResultEvent,
|
||||||
|
// Commands
|
||||||
|
RegisteredCommand,
|
||||||
|
RegisteredTool,
|
||||||
|
SendMessageHandler,
|
||||||
|
SessionBeforeBranchEvent,
|
||||||
|
SessionBeforeBranchResult,
|
||||||
|
SessionBeforeCompactEvent,
|
||||||
|
SessionBeforeCompactResult,
|
||||||
|
SessionBeforeSwitchEvent,
|
||||||
|
SessionBeforeSwitchResult,
|
||||||
|
SessionBeforeTreeEvent,
|
||||||
|
SessionBeforeTreeResult,
|
||||||
|
SessionBranchEvent,
|
||||||
|
SessionCompactEvent,
|
||||||
|
SessionEvent,
|
||||||
|
SessionShutdownEvent,
|
||||||
|
// Events - Session
|
||||||
|
SessionStartEvent,
|
||||||
|
SessionSwitchEvent,
|
||||||
|
SessionTreeEvent,
|
||||||
|
SetActiveToolsHandler,
|
||||||
|
// Events - Tool
|
||||||
|
ToolCallEvent,
|
||||||
|
ToolCallEventResult,
|
||||||
|
// Tools
|
||||||
|
ToolDefinition,
|
||||||
|
ToolRenderResultOptions,
|
||||||
|
ToolResultEvent,
|
||||||
|
ToolResultEventResult,
|
||||||
|
TreePreparation,
|
||||||
|
TurnEndEvent,
|
||||||
|
TurnStartEvent,
|
||||||
|
WriteToolResultEvent,
|
||||||
|
} from "./types.js";
|
||||||
|
// Type guards
|
||||||
|
export {
|
||||||
|
isBashToolResult,
|
||||||
|
isEditToolResult,
|
||||||
|
isFindToolResult,
|
||||||
|
isGrepToolResult,
|
||||||
|
isLsToolResult,
|
||||||
|
isReadToolResult,
|
||||||
|
isWriteToolResult,
|
||||||
|
} from "./types.js";
|
||||||
|
export {
|
||||||
|
wrapRegisteredTool,
|
||||||
|
wrapRegisteredTools,
|
||||||
|
wrapToolsWithExtensions,
|
||||||
|
wrapToolWithExtensions,
|
||||||
|
} from "./wrapper.js";
|
||||||
459
packages/coding-agent/src/core/extensions/loader.ts
Normal file
459
packages/coding-agent/src/core/extensions/loader.ts
Normal file
|
|
@ -0,0 +1,459 @@
|
||||||
|
/**
|
||||||
|
* Extension loader - loads TypeScript extension modules using jiti.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import type { KeyId } from "@mariozechner/pi-tui";
|
||||||
|
import { createJiti } from "jiti";
|
||||||
|
import { getAgentDir, isBunBinary } from "../../config.js";
|
||||||
|
import { theme } from "../../modes/interactive/theme/theme.js";
|
||||||
|
import { createEventBus, type EventBus } from "../event-bus.js";
|
||||||
|
import type { ExecOptions } from "../exec.js";
|
||||||
|
import { execCommand } from "../exec.js";
|
||||||
|
import type {
|
||||||
|
AppendEntryHandler,
|
||||||
|
ExtensionAPI,
|
||||||
|
ExtensionFactory,
|
||||||
|
ExtensionFlag,
|
||||||
|
ExtensionShortcut,
|
||||||
|
ExtensionUIContext,
|
||||||
|
GetActiveToolsHandler,
|
||||||
|
GetAllToolsHandler,
|
||||||
|
LoadExtensionsResult,
|
||||||
|
LoadedExtension,
|
||||||
|
MessageRenderer,
|
||||||
|
RegisteredCommand,
|
||||||
|
RegisteredTool,
|
||||||
|
SendMessageHandler,
|
||||||
|
SetActiveToolsHandler,
|
||||||
|
ToolDefinition,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
|
let _aliases: Record<string, string> | null = null;
|
||||||
|
function getAliases(): Record<string, string> {
|
||||||
|
if (_aliases) return _aliases;
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const packageIndex = path.resolve(__dirname, "../..", "index.js");
|
||||||
|
|
||||||
|
const typeboxEntry = require.resolve("@sinclair/typebox");
|
||||||
|
const typeboxRoot = typeboxEntry.replace(/\/build\/cjs\/index\.js$/, "");
|
||||||
|
|
||||||
|
_aliases = {
|
||||||
|
"@mariozechner/pi-coding-agent": packageIndex,
|
||||||
|
"@mariozechner/pi-coding-agent/extensions": path.resolve(__dirname, "index.js"),
|
||||||
|
"@mariozechner/pi-tui": require.resolve("@mariozechner/pi-tui"),
|
||||||
|
"@mariozechner/pi-ai": require.resolve("@mariozechner/pi-ai"),
|
||||||
|
"@sinclair/typebox": typeboxRoot,
|
||||||
|
};
|
||||||
|
return _aliases;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
||||||
|
|
||||||
|
function normalizeUnicodeSpaces(str: string): string {
|
||||||
|
return str.replace(UNICODE_SPACES, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandPath(p: string): string {
|
||||||
|
const normalized = normalizeUnicodeSpaces(p);
|
||||||
|
if (normalized.startsWith("~/")) {
|
||||||
|
return path.join(os.homedir(), normalized.slice(2));
|
||||||
|
}
|
||||||
|
if (normalized.startsWith("~")) {
|
||||||
|
return path.join(os.homedir(), normalized.slice(1));
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePath(extPath: string, cwd: string): string {
|
||||||
|
const expanded = expandPath(extPath);
|
||||||
|
if (path.isAbsolute(expanded)) {
|
||||||
|
return expanded;
|
||||||
|
}
|
||||||
|
return path.resolve(cwd, expanded);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNoOpUIContext(): ExtensionUIContext {
|
||||||
|
return {
|
||||||
|
select: async () => undefined,
|
||||||
|
confirm: async () => false,
|
||||||
|
input: async () => undefined,
|
||||||
|
notify: () => {},
|
||||||
|
setStatus: () => {},
|
||||||
|
setWidget: () => {},
|
||||||
|
setTitle: () => {},
|
||||||
|
custom: async () => undefined as never,
|
||||||
|
setEditorText: () => {},
|
||||||
|
getEditorText: () => "",
|
||||||
|
editor: async () => undefined,
|
||||||
|
get theme() {
|
||||||
|
return theme;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type HandlerFn = (...args: unknown[]) => Promise<unknown>;
|
||||||
|
|
||||||
|
function createExtensionAPI(
|
||||||
|
handlers: Map<string, HandlerFn[]>,
|
||||||
|
tools: Map<string, RegisteredTool>,
|
||||||
|
cwd: string,
|
||||||
|
extensionPath: string,
|
||||||
|
eventBus: EventBus,
|
||||||
|
_sharedUI: { ui: ExtensionUIContext; hasUI: boolean },
|
||||||
|
): {
|
||||||
|
api: ExtensionAPI;
|
||||||
|
messageRenderers: Map<string, MessageRenderer>;
|
||||||
|
commands: Map<string, RegisteredCommand>;
|
||||||
|
flags: Map<string, ExtensionFlag>;
|
||||||
|
flagValues: Map<string, boolean | string>;
|
||||||
|
shortcuts: Map<KeyId, ExtensionShortcut>;
|
||||||
|
setSendMessageHandler: (handler: SendMessageHandler) => void;
|
||||||
|
setAppendEntryHandler: (handler: AppendEntryHandler) => void;
|
||||||
|
setGetActiveToolsHandler: (handler: GetActiveToolsHandler) => void;
|
||||||
|
setGetAllToolsHandler: (handler: GetAllToolsHandler) => void;
|
||||||
|
setSetActiveToolsHandler: (handler: SetActiveToolsHandler) => void;
|
||||||
|
setFlagValue: (name: string, value: boolean | string) => void;
|
||||||
|
} {
|
||||||
|
let sendMessageHandler: SendMessageHandler = () => {};
|
||||||
|
let appendEntryHandler: AppendEntryHandler = () => {};
|
||||||
|
let getActiveToolsHandler: GetActiveToolsHandler = () => [];
|
||||||
|
let getAllToolsHandler: GetAllToolsHandler = () => [];
|
||||||
|
let setActiveToolsHandler: SetActiveToolsHandler = () => {};
|
||||||
|
|
||||||
|
const messageRenderers = new Map<string, MessageRenderer>();
|
||||||
|
const commands = new Map<string, RegisteredCommand>();
|
||||||
|
const flags = new Map<string, ExtensionFlag>();
|
||||||
|
const flagValues = new Map<string, boolean | string>();
|
||||||
|
const shortcuts = new Map<KeyId, ExtensionShortcut>();
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
on(event: string, handler: HandlerFn): void {
|
||||||
|
const list = handlers.get(event) ?? [];
|
||||||
|
list.push(handler);
|
||||||
|
handlers.set(event, list);
|
||||||
|
},
|
||||||
|
|
||||||
|
registerTool(tool: ToolDefinition): void {
|
||||||
|
tools.set(tool.name, {
|
||||||
|
definition: tool,
|
||||||
|
extensionPath,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void {
|
||||||
|
commands.set(name, { name, ...options });
|
||||||
|
},
|
||||||
|
|
||||||
|
registerShortcut(
|
||||||
|
shortcut: KeyId,
|
||||||
|
options: {
|
||||||
|
description?: string;
|
||||||
|
handler: (ctx: import("./types.js").ExtensionContext) => Promise<void> | void;
|
||||||
|
},
|
||||||
|
): void {
|
||||||
|
shortcuts.set(shortcut, { shortcut, extensionPath, ...options });
|
||||||
|
},
|
||||||
|
|
||||||
|
registerFlag(
|
||||||
|
name: string,
|
||||||
|
options: { description?: string; type: "boolean" | "string"; default?: boolean | string },
|
||||||
|
): void {
|
||||||
|
flags.set(name, { name, extensionPath, ...options });
|
||||||
|
if (options.default !== undefined) {
|
||||||
|
flagValues.set(name, options.default);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getFlag(name: string): boolean | string | undefined {
|
||||||
|
return flagValues.get(name);
|
||||||
|
},
|
||||||
|
|
||||||
|
registerMessageRenderer<T>(customType: string, renderer: MessageRenderer<T>): void {
|
||||||
|
messageRenderers.set(customType, renderer as MessageRenderer);
|
||||||
|
},
|
||||||
|
|
||||||
|
sendMessage(message, options): void {
|
||||||
|
sendMessageHandler(message, options);
|
||||||
|
},
|
||||||
|
|
||||||
|
appendEntry(customType: string, data?: unknown): void {
|
||||||
|
appendEntryHandler(customType, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
exec(command: string, args: string[], options?: ExecOptions) {
|
||||||
|
return execCommand(command, args, options?.cwd ?? cwd, options);
|
||||||
|
},
|
||||||
|
|
||||||
|
getActiveTools(): string[] {
|
||||||
|
return getActiveToolsHandler();
|
||||||
|
},
|
||||||
|
|
||||||
|
getAllTools(): string[] {
|
||||||
|
return getAllToolsHandler();
|
||||||
|
},
|
||||||
|
|
||||||
|
setActiveTools(toolNames: string[]): void {
|
||||||
|
setActiveToolsHandler(toolNames);
|
||||||
|
},
|
||||||
|
|
||||||
|
events: eventBus,
|
||||||
|
} as ExtensionAPI;
|
||||||
|
|
||||||
|
return {
|
||||||
|
api,
|
||||||
|
messageRenderers,
|
||||||
|
commands,
|
||||||
|
flags,
|
||||||
|
flagValues,
|
||||||
|
shortcuts,
|
||||||
|
setSendMessageHandler: (handler: SendMessageHandler) => {
|
||||||
|
sendMessageHandler = handler;
|
||||||
|
},
|
||||||
|
setAppendEntryHandler: (handler: AppendEntryHandler) => {
|
||||||
|
appendEntryHandler = handler;
|
||||||
|
},
|
||||||
|
setGetActiveToolsHandler: (handler: GetActiveToolsHandler) => {
|
||||||
|
getActiveToolsHandler = handler;
|
||||||
|
},
|
||||||
|
setGetAllToolsHandler: (handler: GetAllToolsHandler) => {
|
||||||
|
getAllToolsHandler = handler;
|
||||||
|
},
|
||||||
|
setSetActiveToolsHandler: (handler: SetActiveToolsHandler) => {
|
||||||
|
setActiveToolsHandler = handler;
|
||||||
|
},
|
||||||
|
setFlagValue: (name: string, value: boolean | string) => {
|
||||||
|
flagValues.set(name, value);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadExtensionWithBun(
|
||||||
|
resolvedPath: string,
|
||||||
|
cwd: string,
|
||||||
|
extensionPath: string,
|
||||||
|
eventBus: EventBus,
|
||||||
|
sharedUI: { ui: ExtensionUIContext; hasUI: boolean },
|
||||||
|
): Promise<{ extension: LoadedExtension | null; error: string | null }> {
|
||||||
|
try {
|
||||||
|
const module = await import(resolvedPath);
|
||||||
|
const factory = (module.default ?? module) as ExtensionFactory;
|
||||||
|
|
||||||
|
if (typeof factory !== "function") {
|
||||||
|
return { extension: null, error: "Extension must export a default function" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlers = new Map<string, HandlerFn[]>();
|
||||||
|
const tools = new Map<string, RegisteredTool>();
|
||||||
|
const {
|
||||||
|
api,
|
||||||
|
messageRenderers,
|
||||||
|
commands,
|
||||||
|
flags,
|
||||||
|
flagValues,
|
||||||
|
shortcuts,
|
||||||
|
setSendMessageHandler,
|
||||||
|
setAppendEntryHandler,
|
||||||
|
setGetActiveToolsHandler,
|
||||||
|
setGetAllToolsHandler,
|
||||||
|
setSetActiveToolsHandler,
|
||||||
|
setFlagValue,
|
||||||
|
} = createExtensionAPI(handlers, tools, cwd, extensionPath, eventBus, sharedUI);
|
||||||
|
|
||||||
|
factory(api);
|
||||||
|
|
||||||
|
return {
|
||||||
|
extension: {
|
||||||
|
path: extensionPath,
|
||||||
|
resolvedPath,
|
||||||
|
handlers,
|
||||||
|
tools,
|
||||||
|
messageRenderers,
|
||||||
|
commands,
|
||||||
|
flags,
|
||||||
|
flagValues,
|
||||||
|
shortcuts,
|
||||||
|
setSendMessageHandler,
|
||||||
|
setAppendEntryHandler,
|
||||||
|
setGetActiveToolsHandler,
|
||||||
|
setGetAllToolsHandler,
|
||||||
|
setSetActiveToolsHandler,
|
||||||
|
setFlagValue,
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
|
||||||
|
if (message.includes("Cannot find module") && message.includes("@mariozechner/")) {
|
||||||
|
return {
|
||||||
|
extension: null,
|
||||||
|
error:
|
||||||
|
`${message}\n` +
|
||||||
|
"Note: Extensions importing from @mariozechner/* packages are not supported in the standalone binary.\n" +
|
||||||
|
"Please install pi via npm: npm install -g @mariozechner/pi-coding-agent",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { extension: null, error: `Failed to load extension: ${message}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadExtension(
|
||||||
|
extensionPath: string,
|
||||||
|
cwd: string,
|
||||||
|
eventBus: EventBus,
|
||||||
|
sharedUI: { ui: ExtensionUIContext; hasUI: boolean },
|
||||||
|
): Promise<{ extension: LoadedExtension | null; error: string | null }> {
|
||||||
|
const resolvedPath = resolvePath(extensionPath, cwd);
|
||||||
|
|
||||||
|
if (isBunBinary) {
|
||||||
|
return loadExtensionWithBun(resolvedPath, cwd, extensionPath, eventBus, sharedUI);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const jiti = createJiti(import.meta.url, {
|
||||||
|
alias: getAliases(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const module = await jiti.import(resolvedPath, { default: true });
|
||||||
|
const factory = module as ExtensionFactory;
|
||||||
|
|
||||||
|
if (typeof factory !== "function") {
|
||||||
|
return { extension: null, error: "Extension must export a default function" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlers = new Map<string, HandlerFn[]>();
|
||||||
|
const tools = new Map<string, RegisteredTool>();
|
||||||
|
const {
|
||||||
|
api,
|
||||||
|
messageRenderers,
|
||||||
|
commands,
|
||||||
|
flags,
|
||||||
|
flagValues,
|
||||||
|
shortcuts,
|
||||||
|
setSendMessageHandler,
|
||||||
|
setAppendEntryHandler,
|
||||||
|
setGetActiveToolsHandler,
|
||||||
|
setGetAllToolsHandler,
|
||||||
|
setSetActiveToolsHandler,
|
||||||
|
setFlagValue,
|
||||||
|
} = createExtensionAPI(handlers, tools, cwd, extensionPath, eventBus, sharedUI);
|
||||||
|
|
||||||
|
factory(api);
|
||||||
|
|
||||||
|
return {
|
||||||
|
extension: {
|
||||||
|
path: extensionPath,
|
||||||
|
resolvedPath,
|
||||||
|
handlers,
|
||||||
|
tools,
|
||||||
|
messageRenderers,
|
||||||
|
commands,
|
||||||
|
flags,
|
||||||
|
flagValues,
|
||||||
|
shortcuts,
|
||||||
|
setSendMessageHandler,
|
||||||
|
setAppendEntryHandler,
|
||||||
|
setGetActiveToolsHandler,
|
||||||
|
setGetAllToolsHandler,
|
||||||
|
setSetActiveToolsHandler,
|
||||||
|
setFlagValue,
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return { extension: null, error: `Failed to load extension: ${message}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load extensions from paths.
|
||||||
|
*/
|
||||||
|
export async function loadExtensions(paths: string[], cwd: string, eventBus?: EventBus): Promise<LoadExtensionsResult> {
|
||||||
|
const extensions: LoadedExtension[] = [];
|
||||||
|
const errors: Array<{ path: string; error: string }> = [];
|
||||||
|
const resolvedEventBus = eventBus ?? createEventBus();
|
||||||
|
const sharedUI = { ui: createNoOpUIContext(), hasUI: false };
|
||||||
|
|
||||||
|
for (const extPath of paths) {
|
||||||
|
const { extension, error } = await loadExtension(extPath, cwd, resolvedEventBus, sharedUI);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
errors.push({ path: extPath, error });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extension) {
|
||||||
|
extensions.push(extension);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
extensions,
|
||||||
|
errors,
|
||||||
|
setUIContext(uiContext, hasUI) {
|
||||||
|
sharedUI.ui = uiContext;
|
||||||
|
sharedUI.hasUI = hasUI;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function discoverExtensionsInDir(dir: string): string[] {
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
return entries
|
||||||
|
.filter((e) => (e.isFile() || e.isSymbolicLink()) && e.name.endsWith(".ts"))
|
||||||
|
.map((e) => path.join(dir, e.name));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover and load extensions from standard locations.
|
||||||
|
*/
|
||||||
|
export async function discoverAndLoadExtensions(
|
||||||
|
configuredPaths: string[],
|
||||||
|
cwd: string,
|
||||||
|
agentDir: string = getAgentDir(),
|
||||||
|
eventBus?: EventBus,
|
||||||
|
): Promise<LoadExtensionsResult> {
|
||||||
|
const allPaths: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
const addPaths = (paths: string[]) => {
|
||||||
|
for (const p of paths) {
|
||||||
|
const resolved = path.resolve(p);
|
||||||
|
if (!seen.has(resolved)) {
|
||||||
|
seen.add(resolved);
|
||||||
|
allPaths.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Global extensions: agentDir/extensions/
|
||||||
|
const globalExtDir = path.join(agentDir, "extensions");
|
||||||
|
addPaths(discoverExtensionsInDir(globalExtDir));
|
||||||
|
|
||||||
|
// 2. Project-local extensions: cwd/.pi/extensions/
|
||||||
|
const localExtDir = path.join(cwd, ".pi", "extensions");
|
||||||
|
addPaths(discoverExtensionsInDir(localExtDir));
|
||||||
|
|
||||||
|
// 3. Explicitly configured paths
|
||||||
|
addPaths(configuredPaths.map((p) => resolvePath(p, cwd)));
|
||||||
|
|
||||||
|
return loadExtensions(allPaths, cwd, eventBus);
|
||||||
|
}
|
||||||
464
packages/coding-agent/src/core/extensions/runner.ts
Normal file
464
packages/coding-agent/src/core/extensions/runner.ts
Normal file
|
|
@ -0,0 +1,464 @@
|
||||||
|
/**
|
||||||
|
* 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 { theme } from "../../modes/interactive/theme/theme.js";
|
||||||
|
import type { ModelRegistry } from "../model-registry.js";
|
||||||
|
import type { SessionManager } from "../session-manager.js";
|
||||||
|
import type {
|
||||||
|
AppendEntryHandler,
|
||||||
|
BeforeAgentStartEvent,
|
||||||
|
BeforeAgentStartEventResult,
|
||||||
|
ContextEvent,
|
||||||
|
ContextEventResult,
|
||||||
|
ExtensionCommandContext,
|
||||||
|
ExtensionContext,
|
||||||
|
ExtensionError,
|
||||||
|
ExtensionEvent,
|
||||||
|
ExtensionFlag,
|
||||||
|
ExtensionShortcut,
|
||||||
|
ExtensionUIContext,
|
||||||
|
GetActiveToolsHandler,
|
||||||
|
GetAllToolsHandler,
|
||||||
|
LoadedExtension,
|
||||||
|
MessageRenderer,
|
||||||
|
RegisteredCommand,
|
||||||
|
RegisteredTool,
|
||||||
|
SendMessageHandler,
|
||||||
|
SessionBeforeCompactResult,
|
||||||
|
SessionBeforeTreeResult,
|
||||||
|
SetActiveToolsHandler,
|
||||||
|
ToolCallEvent,
|
||||||
|
ToolCallEventResult,
|
||||||
|
ToolResultEventResult,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
/** Combined result from all before_agent_start handlers */
|
||||||
|
interface BeforeAgentStartCombinedResult {
|
||||||
|
messages?: NonNullable<BeforeAgentStartEventResult["message"]>[];
|
||||||
|
systemPromptAppend?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExtensionErrorListener = (error: ExtensionError) => void;
|
||||||
|
|
||||||
|
export type NewSessionHandler = (options?: {
|
||||||
|
parentSession?: string;
|
||||||
|
setup?: (sessionManager: SessionManager) => Promise<void>;
|
||||||
|
}) => Promise<{ cancelled: boolean }>;
|
||||||
|
|
||||||
|
export type BranchHandler = (entryId: string) => Promise<{ cancelled: boolean }>;
|
||||||
|
|
||||||
|
export type NavigateTreeHandler = (
|
||||||
|
targetId: string,
|
||||||
|
options?: { summarize?: boolean },
|
||||||
|
) => Promise<{ cancelled: boolean }>;
|
||||||
|
|
||||||
|
const noOpUIContext: ExtensionUIContext = {
|
||||||
|
select: async () => undefined,
|
||||||
|
confirm: async () => false,
|
||||||
|
input: async () => undefined,
|
||||||
|
notify: () => {},
|
||||||
|
setStatus: () => {},
|
||||||
|
setWidget: () => {},
|
||||||
|
setTitle: () => {},
|
||||||
|
custom: async () => undefined as never,
|
||||||
|
setEditorText: () => {},
|
||||||
|
getEditorText: () => "",
|
||||||
|
editor: async () => undefined,
|
||||||
|
get theme() {
|
||||||
|
return theme;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ExtensionRunner {
|
||||||
|
private extensions: LoadedExtension[];
|
||||||
|
private uiContext: ExtensionUIContext;
|
||||||
|
private hasUI: boolean;
|
||||||
|
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 newSessionHandler: NewSessionHandler = async () => ({ cancelled: false });
|
||||||
|
private branchHandler: BranchHandler = async () => ({ cancelled: false });
|
||||||
|
private navigateTreeHandler: NavigateTreeHandler = async () => ({ cancelled: false });
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
extensions: LoadedExtension[],
|
||||||
|
cwd: string,
|
||||||
|
sessionManager: SessionManager,
|
||||||
|
modelRegistry: ModelRegistry,
|
||||||
|
) {
|
||||||
|
this.extensions = extensions;
|
||||||
|
this.uiContext = noOpUIContext;
|
||||||
|
this.hasUI = false;
|
||||||
|
this.cwd = cwd;
|
||||||
|
this.sessionManager = sessionManager;
|
||||||
|
this.modelRegistry = modelRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
|
initialize(options: {
|
||||||
|
getModel: () => Model<any> | undefined;
|
||||||
|
sendMessageHandler: SendMessageHandler;
|
||||||
|
appendEntryHandler: AppendEntryHandler;
|
||||||
|
getActiveToolsHandler: GetActiveToolsHandler;
|
||||||
|
getAllToolsHandler: GetAllToolsHandler;
|
||||||
|
setActiveToolsHandler: SetActiveToolsHandler;
|
||||||
|
newSessionHandler?: NewSessionHandler;
|
||||||
|
branchHandler?: BranchHandler;
|
||||||
|
navigateTreeHandler?: NavigateTreeHandler;
|
||||||
|
isIdle?: () => boolean;
|
||||||
|
waitForIdle?: () => Promise<void>;
|
||||||
|
abort?: () => void;
|
||||||
|
hasPendingMessages?: () => boolean;
|
||||||
|
uiContext?: ExtensionUIContext;
|
||||||
|
hasUI?: boolean;
|
||||||
|
}): void {
|
||||||
|
this.getModel = options.getModel;
|
||||||
|
this.isIdleFn = options.isIdle ?? (() => true);
|
||||||
|
this.waitForIdleFn = options.waitForIdle ?? (async () => {});
|
||||||
|
this.abortFn = options.abort ?? (() => {});
|
||||||
|
this.hasPendingMessagesFn = options.hasPendingMessages ?? (() => false);
|
||||||
|
|
||||||
|
if (options.newSessionHandler) {
|
||||||
|
this.newSessionHandler = options.newSessionHandler;
|
||||||
|
}
|
||||||
|
if (options.branchHandler) {
|
||||||
|
this.branchHandler = options.branchHandler;
|
||||||
|
}
|
||||||
|
if (options.navigateTreeHandler) {
|
||||||
|
this.navigateTreeHandler = options.navigateTreeHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ext of this.extensions) {
|
||||||
|
ext.setSendMessageHandler(options.sendMessageHandler);
|
||||||
|
ext.setAppendEntryHandler(options.appendEntryHandler);
|
||||||
|
ext.setGetActiveToolsHandler(options.getActiveToolsHandler);
|
||||||
|
ext.setGetAllToolsHandler(options.getAllToolsHandler);
|
||||||
|
ext.setSetActiveToolsHandler(options.setActiveToolsHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.uiContext = options.uiContext ?? noOpUIContext;
|
||||||
|
this.hasUI = options.hasUI ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getUIContext(): ExtensionUIContext | null {
|
||||||
|
return this.uiContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
getHasUI(): boolean {
|
||||||
|
return this.hasUI;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
for (const ext of this.extensions) {
|
||||||
|
if (ext.flags.has(name)) {
|
||||||
|
ext.setFlagValue(name, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly RESERVED_SHORTCUTS = new Set([
|
||||||
|
"ctrl+c",
|
||||||
|
"ctrl+d",
|
||||||
|
"ctrl+z",
|
||||||
|
"ctrl+k",
|
||||||
|
"ctrl+p",
|
||||||
|
"ctrl+l",
|
||||||
|
"ctrl+o",
|
||||||
|
"ctrl+t",
|
||||||
|
"ctrl+g",
|
||||||
|
"shift+tab",
|
||||||
|
"shift+ctrl+p",
|
||||||
|
"alt+enter",
|
||||||
|
"escape",
|
||||||
|
"enter",
|
||||||
|
]);
|
||||||
|
|
||||||
|
getShortcuts(): Map<KeyId, ExtensionShortcut> {
|
||||||
|
const allShortcuts = new Map<KeyId, ExtensionShortcut>();
|
||||||
|
for (const ext of this.extensions) {
|
||||||
|
for (const [key, shortcut] of ext.shortcuts) {
|
||||||
|
const normalizedKey = key.toLowerCase() as KeyId;
|
||||||
|
|
||||||
|
if (ExtensionRunner.RESERVED_SHORTCUTS.has(normalizedKey)) {
|
||||||
|
console.warn(
|
||||||
|
`Extension shortcut '${key}' from ${shortcut.extensionPath} conflicts with built-in shortcut. Skipping.`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = allShortcuts.get(normalizedKey);
|
||||||
|
if (existing) {
|
||||||
|
console.warn(
|
||||||
|
`Extension shortcut conflict: '${key}' registered by both ${existing.extensionPath} and ${shortcut.extensionPath}. Using ${shortcut.extensionPath}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
allShortcuts.set(normalizedKey, shortcut);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allShortcuts;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(): RegisteredCommand[] {
|
||||||
|
const commands: RegisteredCommand[] = [];
|
||||||
|
for (const ext of this.extensions) {
|
||||||
|
for (const command of ext.commands.values()) {
|
||||||
|
commands.push(command);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return commands;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCommand(name: string): RegisteredCommand | undefined {
|
||||||
|
for (const ext of this.extensions) {
|
||||||
|
const command = ext.commands.get(name);
|
||||||
|
if (command) {
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createContext(): ExtensionContext {
|
||||||
|
return {
|
||||||
|
ui: this.uiContext,
|
||||||
|
hasUI: this.hasUI,
|
||||||
|
cwd: this.cwd,
|
||||||
|
sessionManager: this.sessionManager,
|
||||||
|
modelRegistry: this.modelRegistry,
|
||||||
|
model: this.getModel(),
|
||||||
|
isIdle: () => this.isIdleFn(),
|
||||||
|
abort: () => this.abortFn(),
|
||||||
|
hasPendingMessages: () => this.hasPendingMessagesFn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
createCommandContext(): ExtensionCommandContext {
|
||||||
|
return {
|
||||||
|
...this.createContext(),
|
||||||
|
waitForIdle: () => this.waitForIdleFn(),
|
||||||
|
newSession: (options) => this.newSessionHandler(options),
|
||||||
|
branch: (entryId) => this.branchHandler(entryId),
|
||||||
|
navigateTree: (targetId, options) => this.navigateTreeHandler(targetId, options),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private isSessionBeforeEvent(
|
||||||
|
type: string,
|
||||||
|
): type is "session_before_switch" | "session_before_branch" | "session_before_compact" | "session_before_tree" {
|
||||||
|
return (
|
||||||
|
type === "session_before_switch" ||
|
||||||
|
type === "session_before_branch" ||
|
||||||
|
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 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[],
|
||||||
|
): Promise<BeforeAgentStartCombinedResult | undefined> {
|
||||||
|
const ctx = this.createContext();
|
||||||
|
const messages: NonNullable<BeforeAgentStartEventResult["message"]>[] = [];
|
||||||
|
const systemPromptAppends: string[] = [];
|
||||||
|
|
||||||
|
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 };
|
||||||
|
const handlerResult = await handler(event, ctx);
|
||||||
|
|
||||||
|
if (handlerResult) {
|
||||||
|
const result = handlerResult as BeforeAgentStartEventResult;
|
||||||
|
if (result.message) {
|
||||||
|
messages.push(result.message);
|
||||||
|
}
|
||||||
|
if (result.systemPromptAppend) {
|
||||||
|
systemPromptAppends.push(result.systemPromptAppend);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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 || systemPromptAppends.length > 0) {
|
||||||
|
return {
|
||||||
|
messages: messages.length > 0 ? messages : undefined,
|
||||||
|
systemPromptAppend: systemPromptAppends.length > 0 ? systemPromptAppends.join("\n\n") : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
688
packages/coding-agent/src/core/extensions/types.ts
Normal file
688
packages/coding-agent/src/core/extensions/types.ts
Normal file
|
|
@ -0,0 +1,688 @@
|
||||||
|
/**
|
||||||
|
* Extension system types.
|
||||||
|
*
|
||||||
|
* Extensions are TypeScript modules that can:
|
||||||
|
* - Subscribe to agent lifecycle events
|
||||||
|
* - Register LLM-callable tools
|
||||||
|
* - Register slash commands, keyboard shortcuts, and CLI flags
|
||||||
|
* - Interact with the user via UI primitives
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AgentMessage, AgentToolResult, AgentToolUpdateCallback } from "@mariozechner/pi-agent-core";
|
||||||
|
import type { ImageContent, Model, TextContent, ToolResultMessage } from "@mariozechner/pi-ai";
|
||||||
|
import type { Component, KeyId, TUI } from "@mariozechner/pi-tui";
|
||||||
|
import type { Static, TSchema } from "@sinclair/typebox";
|
||||||
|
import type { Theme } from "../../modes/interactive/theme/theme.js";
|
||||||
|
import type { CompactionPreparation, CompactionResult } from "../compaction/index.js";
|
||||||
|
import type { EventBus } from "../event-bus.js";
|
||||||
|
import type { ExecOptions, ExecResult } from "../exec.js";
|
||||||
|
import type { HookMessage } from "../messages.js";
|
||||||
|
import type { ModelRegistry } from "../model-registry.js";
|
||||||
|
import type {
|
||||||
|
BranchSummaryEntry,
|
||||||
|
CompactionEntry,
|
||||||
|
ReadonlySessionManager,
|
||||||
|
SessionEntry,
|
||||||
|
SessionManager,
|
||||||
|
} from "../session-manager.js";
|
||||||
|
import type { EditToolDetails } from "../tools/edit.js";
|
||||||
|
import type {
|
||||||
|
BashToolDetails,
|
||||||
|
FindToolDetails,
|
||||||
|
GrepToolDetails,
|
||||||
|
LsToolDetails,
|
||||||
|
ReadToolDetails,
|
||||||
|
} from "../tools/index.js";
|
||||||
|
|
||||||
|
export type { ExecOptions, ExecResult } from "../exec.js";
|
||||||
|
export type { AgentToolResult, AgentToolUpdateCallback };
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// UI Context
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI context for extensions to request interactive UI.
|
||||||
|
* Each mode (interactive, RPC, print) provides its own implementation.
|
||||||
|
*/
|
||||||
|
export interface ExtensionUIContext {
|
||||||
|
/** Show a selector and return the user's choice. */
|
||||||
|
select(title: string, options: string[]): Promise<string | undefined>;
|
||||||
|
|
||||||
|
/** Show a confirmation dialog. */
|
||||||
|
confirm(title: string, message: string): Promise<boolean>;
|
||||||
|
|
||||||
|
/** Show a text input dialog. */
|
||||||
|
input(title: string, placeholder?: string): Promise<string | undefined>;
|
||||||
|
|
||||||
|
/** Show a notification to the user. */
|
||||||
|
notify(message: string, type?: "info" | "warning" | "error"): void;
|
||||||
|
|
||||||
|
/** Set status text in the footer/status bar. Pass undefined to clear. */
|
||||||
|
setStatus(key: string, text: string | undefined): void;
|
||||||
|
|
||||||
|
/** Set a widget to display above the editor. Accepts string array or component factory. */
|
||||||
|
setWidget(key: string, content: string[] | undefined): void;
|
||||||
|
setWidget(key: string, content: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined): void;
|
||||||
|
|
||||||
|
/** Set the terminal window/tab title. */
|
||||||
|
setTitle(title: string): void;
|
||||||
|
|
||||||
|
/** Show a custom component with keyboard focus. */
|
||||||
|
custom<T>(
|
||||||
|
factory: (
|
||||||
|
tui: TUI,
|
||||||
|
theme: Theme,
|
||||||
|
done: (result: T) => void,
|
||||||
|
) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
|
||||||
|
): Promise<T>;
|
||||||
|
|
||||||
|
/** Set the text in the core input editor. */
|
||||||
|
setEditorText(text: string): void;
|
||||||
|
|
||||||
|
/** Get the current text from the core input editor. */
|
||||||
|
getEditorText(): string;
|
||||||
|
|
||||||
|
/** Show a multi-line editor for text editing. */
|
||||||
|
editor(title: string, prefill?: string): Promise<string | undefined>;
|
||||||
|
|
||||||
|
/** Get the current theme for styling. */
|
||||||
|
readonly theme: Theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Extension Context
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context passed to extension event handlers.
|
||||||
|
*/
|
||||||
|
export interface ExtensionContext {
|
||||||
|
/** UI methods for user interaction */
|
||||||
|
ui: ExtensionUIContext;
|
||||||
|
/** Whether UI is available (false in print/RPC mode) */
|
||||||
|
hasUI: boolean;
|
||||||
|
/** Current working directory */
|
||||||
|
cwd: string;
|
||||||
|
/** Session manager (read-only) */
|
||||||
|
sessionManager: ReadonlySessionManager;
|
||||||
|
/** Model registry for API key resolution */
|
||||||
|
modelRegistry: ModelRegistry;
|
||||||
|
/** Current model (may be undefined) */
|
||||||
|
model: Model<any> | undefined;
|
||||||
|
/** Whether the agent is idle (not streaming) */
|
||||||
|
isIdle(): boolean;
|
||||||
|
/** Abort the current agent operation */
|
||||||
|
abort(): void;
|
||||||
|
/** Whether there are queued messages waiting */
|
||||||
|
hasPendingMessages(): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extended context for slash command handlers.
|
||||||
|
* Includes session control methods only safe in user-initiated commands.
|
||||||
|
*/
|
||||||
|
export interface ExtensionCommandContext extends ExtensionContext {
|
||||||
|
/** Wait for the agent to finish streaming */
|
||||||
|
waitForIdle(): Promise<void>;
|
||||||
|
|
||||||
|
/** Start a new session, optionally with initialization. */
|
||||||
|
newSession(options?: {
|
||||||
|
parentSession?: string;
|
||||||
|
setup?: (sessionManager: SessionManager) => Promise<void>;
|
||||||
|
}): Promise<{ cancelled: boolean }>;
|
||||||
|
|
||||||
|
/** Branch from a specific entry, creating a new session file. */
|
||||||
|
branch(entryId: string): Promise<{ cancelled: boolean }>;
|
||||||
|
|
||||||
|
/** Navigate to a different point in the session tree. */
|
||||||
|
navigateTree(targetId: string, options?: { summarize?: boolean }): Promise<{ cancelled: boolean }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tool Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** Rendering options for tool results */
|
||||||
|
export interface ToolRenderResultOptions {
|
||||||
|
/** Whether the result view is expanded */
|
||||||
|
expanded: boolean;
|
||||||
|
/** Whether this is a partial/streaming result */
|
||||||
|
isPartial: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool definition for registerTool().
|
||||||
|
*/
|
||||||
|
export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = unknown> {
|
||||||
|
/** Tool name (used in LLM tool calls) */
|
||||||
|
name: string;
|
||||||
|
/** Human-readable label for UI */
|
||||||
|
label: string;
|
||||||
|
/** Description for LLM */
|
||||||
|
description: string;
|
||||||
|
/** Parameter schema (TypeBox) */
|
||||||
|
parameters: TParams;
|
||||||
|
|
||||||
|
/** Execute the tool. */
|
||||||
|
execute(
|
||||||
|
toolCallId: string,
|
||||||
|
params: Static<TParams>,
|
||||||
|
onUpdate: AgentToolUpdateCallback<TDetails> | undefined,
|
||||||
|
ctx: ExtensionContext,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<AgentToolResult<TDetails>>;
|
||||||
|
|
||||||
|
/** Custom rendering for tool call display */
|
||||||
|
renderCall?: (args: Static<TParams>, theme: Theme) => Component;
|
||||||
|
|
||||||
|
/** Custom rendering for tool result display */
|
||||||
|
renderResult?: (result: AgentToolResult<TDetails>, options: ToolRenderResultOptions, theme: Theme) => Component;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Session Events
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** Fired on initial session load */
|
||||||
|
export interface SessionStartEvent {
|
||||||
|
type: "session_start";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fired before switching to another session (can be cancelled) */
|
||||||
|
export interface SessionBeforeSwitchEvent {
|
||||||
|
type: "session_before_switch";
|
||||||
|
reason: "new" | "resume";
|
||||||
|
targetSessionFile?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fired after switching to another session */
|
||||||
|
export interface SessionSwitchEvent {
|
||||||
|
type: "session_switch";
|
||||||
|
reason: "new" | "resume";
|
||||||
|
previousSessionFile: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fired before branching a session (can be cancelled) */
|
||||||
|
export interface SessionBeforeBranchEvent {
|
||||||
|
type: "session_before_branch";
|
||||||
|
entryId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fired after branching a session */
|
||||||
|
export interface SessionBranchEvent {
|
||||||
|
type: "session_branch";
|
||||||
|
previousSessionFile: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fired before context compaction (can be cancelled or customized) */
|
||||||
|
export interface SessionBeforeCompactEvent {
|
||||||
|
type: "session_before_compact";
|
||||||
|
preparation: CompactionPreparation;
|
||||||
|
branchEntries: SessionEntry[];
|
||||||
|
customInstructions?: string;
|
||||||
|
signal: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fired after context compaction */
|
||||||
|
export interface SessionCompactEvent {
|
||||||
|
type: "session_compact";
|
||||||
|
compactionEntry: CompactionEntry;
|
||||||
|
fromHook: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fired on process exit */
|
||||||
|
export interface SessionShutdownEvent {
|
||||||
|
type: "session_shutdown";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Preparation data for tree navigation */
|
||||||
|
export interface TreePreparation {
|
||||||
|
targetId: string;
|
||||||
|
oldLeafId: string | null;
|
||||||
|
commonAncestorId: string | null;
|
||||||
|
entriesToSummarize: SessionEntry[];
|
||||||
|
userWantsSummary: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fired before navigating in the session tree (can be cancelled) */
|
||||||
|
export interface SessionBeforeTreeEvent {
|
||||||
|
type: "session_before_tree";
|
||||||
|
preparation: TreePreparation;
|
||||||
|
signal: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fired after navigating in the session tree */
|
||||||
|
export interface SessionTreeEvent {
|
||||||
|
type: "session_tree";
|
||||||
|
newLeafId: string | null;
|
||||||
|
oldLeafId: string | null;
|
||||||
|
summaryEntry?: BranchSummaryEntry;
|
||||||
|
fromHook?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionEvent =
|
||||||
|
| SessionStartEvent
|
||||||
|
| SessionBeforeSwitchEvent
|
||||||
|
| SessionSwitchEvent
|
||||||
|
| SessionBeforeBranchEvent
|
||||||
|
| SessionBranchEvent
|
||||||
|
| SessionBeforeCompactEvent
|
||||||
|
| SessionCompactEvent
|
||||||
|
| SessionShutdownEvent
|
||||||
|
| SessionBeforeTreeEvent
|
||||||
|
| SessionTreeEvent;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Agent Events
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** Fired before each LLM call. Can modify messages. */
|
||||||
|
export interface ContextEvent {
|
||||||
|
type: "context";
|
||||||
|
messages: AgentMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fired after user submits prompt but before agent loop. */
|
||||||
|
export interface BeforeAgentStartEvent {
|
||||||
|
type: "before_agent_start";
|
||||||
|
prompt: string;
|
||||||
|
images?: ImageContent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fired when an agent loop starts */
|
||||||
|
export interface AgentStartEvent {
|
||||||
|
type: "agent_start";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fired when an agent loop ends */
|
||||||
|
export interface AgentEndEvent {
|
||||||
|
type: "agent_end";
|
||||||
|
messages: AgentMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fired at the start of each turn */
|
||||||
|
export interface TurnStartEvent {
|
||||||
|
type: "turn_start";
|
||||||
|
turnIndex: number;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fired at the end of each turn */
|
||||||
|
export interface TurnEndEvent {
|
||||||
|
type: "turn_end";
|
||||||
|
turnIndex: number;
|
||||||
|
message: AgentMessage;
|
||||||
|
toolResults: ToolResultMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tool Events
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** Fired before a tool executes. Can block. */
|
||||||
|
export interface ToolCallEvent {
|
||||||
|
type: "tool_call";
|
||||||
|
toolName: string;
|
||||||
|
toolCallId: string;
|
||||||
|
input: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToolResultEventBase {
|
||||||
|
type: "tool_result";
|
||||||
|
toolCallId: string;
|
||||||
|
input: Record<string, unknown>;
|
||||||
|
content: (TextContent | ImageContent)[];
|
||||||
|
isError: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BashToolResultEvent extends ToolResultEventBase {
|
||||||
|
toolName: "bash";
|
||||||
|
details: BashToolDetails | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReadToolResultEvent extends ToolResultEventBase {
|
||||||
|
toolName: "read";
|
||||||
|
details: ReadToolDetails | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditToolResultEvent extends ToolResultEventBase {
|
||||||
|
toolName: "edit";
|
||||||
|
details: EditToolDetails | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WriteToolResultEvent extends ToolResultEventBase {
|
||||||
|
toolName: "write";
|
||||||
|
details: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GrepToolResultEvent extends ToolResultEventBase {
|
||||||
|
toolName: "grep";
|
||||||
|
details: GrepToolDetails | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FindToolResultEvent extends ToolResultEventBase {
|
||||||
|
toolName: "find";
|
||||||
|
details: FindToolDetails | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LsToolResultEvent extends ToolResultEventBase {
|
||||||
|
toolName: "ls";
|
||||||
|
details: LsToolDetails | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomToolResultEvent extends ToolResultEventBase {
|
||||||
|
toolName: string;
|
||||||
|
details: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fired after a tool executes. Can modify result. */
|
||||||
|
export type ToolResultEvent =
|
||||||
|
| BashToolResultEvent
|
||||||
|
| ReadToolResultEvent
|
||||||
|
| EditToolResultEvent
|
||||||
|
| WriteToolResultEvent
|
||||||
|
| GrepToolResultEvent
|
||||||
|
| FindToolResultEvent
|
||||||
|
| LsToolResultEvent
|
||||||
|
| CustomToolResultEvent;
|
||||||
|
|
||||||
|
// Type guards
|
||||||
|
export function isBashToolResult(e: ToolResultEvent): e is BashToolResultEvent {
|
||||||
|
return e.toolName === "bash";
|
||||||
|
}
|
||||||
|
export function isReadToolResult(e: ToolResultEvent): e is ReadToolResultEvent {
|
||||||
|
return e.toolName === "read";
|
||||||
|
}
|
||||||
|
export function isEditToolResult(e: ToolResultEvent): e is EditToolResultEvent {
|
||||||
|
return e.toolName === "edit";
|
||||||
|
}
|
||||||
|
export function isWriteToolResult(e: ToolResultEvent): e is WriteToolResultEvent {
|
||||||
|
return e.toolName === "write";
|
||||||
|
}
|
||||||
|
export function isGrepToolResult(e: ToolResultEvent): e is GrepToolResultEvent {
|
||||||
|
return e.toolName === "grep";
|
||||||
|
}
|
||||||
|
export function isFindToolResult(e: ToolResultEvent): e is FindToolResultEvent {
|
||||||
|
return e.toolName === "find";
|
||||||
|
}
|
||||||
|
export function isLsToolResult(e: ToolResultEvent): e is LsToolResultEvent {
|
||||||
|
return e.toolName === "ls";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Union of all event types */
|
||||||
|
export type ExtensionEvent =
|
||||||
|
| SessionEvent
|
||||||
|
| ContextEvent
|
||||||
|
| BeforeAgentStartEvent
|
||||||
|
| AgentStartEvent
|
||||||
|
| AgentEndEvent
|
||||||
|
| TurnStartEvent
|
||||||
|
| TurnEndEvent
|
||||||
|
| ToolCallEvent
|
||||||
|
| ToolResultEvent;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Event Results
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface ContextEventResult {
|
||||||
|
messages?: AgentMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolCallEventResult {
|
||||||
|
block?: boolean;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolResultEventResult {
|
||||||
|
content?: (TextContent | ImageContent)[];
|
||||||
|
details?: unknown;
|
||||||
|
isError?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BeforeAgentStartEventResult {
|
||||||
|
message?: Pick<HookMessage, "customType" | "content" | "display" | "details">;
|
||||||
|
systemPromptAppend?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionBeforeSwitchResult {
|
||||||
|
cancel?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionBeforeBranchResult {
|
||||||
|
cancel?: boolean;
|
||||||
|
skipConversationRestore?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionBeforeCompactResult {
|
||||||
|
cancel?: boolean;
|
||||||
|
compaction?: CompactionResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionBeforeTreeResult {
|
||||||
|
cancel?: boolean;
|
||||||
|
summary?: {
|
||||||
|
summary: string;
|
||||||
|
details?: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Message Rendering
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface MessageRenderOptions {
|
||||||
|
expanded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MessageRenderer<T = unknown> = (
|
||||||
|
message: HookMessage<T>,
|
||||||
|
options: MessageRenderOptions,
|
||||||
|
theme: Theme,
|
||||||
|
) => Component | undefined;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Command Registration
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface RegisteredCommand {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
handler: (args: string, ctx: ExtensionCommandContext) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Extension API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** Handler function type for events */
|
||||||
|
// biome-ignore lint/suspicious/noConfusingVoidType: void allows bare return statements
|
||||||
|
export type ExtensionHandler<E, R = undefined> = (event: E, ctx: ExtensionContext) => Promise<R | void> | R | void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ExtensionAPI passed to extension factory functions.
|
||||||
|
*/
|
||||||
|
export interface ExtensionAPI {
|
||||||
|
// =========================================================================
|
||||||
|
// Event Subscription
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
on(event: "session_start", handler: ExtensionHandler<SessionStartEvent>): void;
|
||||||
|
on(
|
||||||
|
event: "session_before_switch",
|
||||||
|
handler: ExtensionHandler<SessionBeforeSwitchEvent, SessionBeforeSwitchResult>,
|
||||||
|
): void;
|
||||||
|
on(event: "session_switch", handler: ExtensionHandler<SessionSwitchEvent>): void;
|
||||||
|
on(
|
||||||
|
event: "session_before_branch",
|
||||||
|
handler: ExtensionHandler<SessionBeforeBranchEvent, SessionBeforeBranchResult>,
|
||||||
|
): void;
|
||||||
|
on(event: "session_branch", handler: ExtensionHandler<SessionBranchEvent>): void;
|
||||||
|
on(
|
||||||
|
event: "session_before_compact",
|
||||||
|
handler: ExtensionHandler<SessionBeforeCompactEvent, SessionBeforeCompactResult>,
|
||||||
|
): void;
|
||||||
|
on(event: "session_compact", handler: ExtensionHandler<SessionCompactEvent>): void;
|
||||||
|
on(event: "session_shutdown", handler: ExtensionHandler<SessionShutdownEvent>): void;
|
||||||
|
on(event: "session_before_tree", handler: ExtensionHandler<SessionBeforeTreeEvent, SessionBeforeTreeResult>): void;
|
||||||
|
on(event: "session_tree", handler: ExtensionHandler<SessionTreeEvent>): void;
|
||||||
|
on(event: "context", handler: ExtensionHandler<ContextEvent, ContextEventResult>): void;
|
||||||
|
on(event: "before_agent_start", handler: ExtensionHandler<BeforeAgentStartEvent, BeforeAgentStartEventResult>): void;
|
||||||
|
on(event: "agent_start", handler: ExtensionHandler<AgentStartEvent>): void;
|
||||||
|
on(event: "agent_end", handler: ExtensionHandler<AgentEndEvent>): void;
|
||||||
|
on(event: "turn_start", handler: ExtensionHandler<TurnStartEvent>): void;
|
||||||
|
on(event: "turn_end", handler: ExtensionHandler<TurnEndEvent>): void;
|
||||||
|
on(event: "tool_call", handler: ExtensionHandler<ToolCallEvent, ToolCallEventResult>): void;
|
||||||
|
on(event: "tool_result", handler: ExtensionHandler<ToolResultEvent, ToolResultEventResult>): void;
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Tool Registration
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/** Register a tool that the LLM can call. */
|
||||||
|
registerTool<TParams extends TSchema = TSchema, TDetails = unknown>(tool: ToolDefinition<TParams, TDetails>): void;
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Command, Shortcut, Flag Registration
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/** Register a custom slash command. */
|
||||||
|
registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void;
|
||||||
|
|
||||||
|
/** Register a keyboard shortcut. */
|
||||||
|
registerShortcut(
|
||||||
|
shortcut: KeyId,
|
||||||
|
options: {
|
||||||
|
description?: string;
|
||||||
|
handler: (ctx: ExtensionContext) => Promise<void> | void;
|
||||||
|
},
|
||||||
|
): void;
|
||||||
|
|
||||||
|
/** Register a CLI flag. */
|
||||||
|
registerFlag(
|
||||||
|
name: string,
|
||||||
|
options: {
|
||||||
|
description?: string;
|
||||||
|
type: "boolean" | "string";
|
||||||
|
default?: boolean | string;
|
||||||
|
},
|
||||||
|
): void;
|
||||||
|
|
||||||
|
/** Get the value of a registered CLI flag. */
|
||||||
|
getFlag(name: string): boolean | string | undefined;
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Message Rendering
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/** Register a custom renderer for HookMessageEntry. */
|
||||||
|
registerMessageRenderer<T = unknown>(customType: string, renderer: MessageRenderer<T>): void;
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Actions
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/** Send a custom message to the session. */
|
||||||
|
sendMessage<T = unknown>(
|
||||||
|
message: Pick<HookMessage<T>, "customType" | "content" | "display" | "details">,
|
||||||
|
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
|
||||||
|
): void;
|
||||||
|
|
||||||
|
/** Append a custom entry to the session for state persistence (not sent to LLM). */
|
||||||
|
appendEntry<T = unknown>(customType: string, data?: T): void;
|
||||||
|
|
||||||
|
/** Execute a shell command. */
|
||||||
|
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
|
||||||
|
|
||||||
|
/** Get the list of currently active tool names. */
|
||||||
|
getActiveTools(): string[];
|
||||||
|
|
||||||
|
/** Get all configured tools (built-in + extension tools). */
|
||||||
|
getAllTools(): string[];
|
||||||
|
|
||||||
|
/** Set the active tools by name. */
|
||||||
|
setActiveTools(toolNames: string[]): void;
|
||||||
|
|
||||||
|
/** Shared event bus for extension communication. */
|
||||||
|
events: EventBus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extension factory function type. */
|
||||||
|
export type ExtensionFactory = (pi: ExtensionAPI) => void;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Loaded Extension Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface RegisteredTool {
|
||||||
|
definition: ToolDefinition;
|
||||||
|
extensionPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtensionFlag {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
type: "boolean" | "string";
|
||||||
|
default?: boolean | string;
|
||||||
|
extensionPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtensionShortcut {
|
||||||
|
shortcut: KeyId;
|
||||||
|
description?: string;
|
||||||
|
handler: (ctx: ExtensionContext) => Promise<void> | void;
|
||||||
|
extensionPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type HandlerFn = (...args: unknown[]) => Promise<unknown>;
|
||||||
|
|
||||||
|
export type SendMessageHandler = <T = unknown>(
|
||||||
|
message: Pick<HookMessage<T>, "customType" | "content" | "display" | "details">,
|
||||||
|
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
export type AppendEntryHandler = <T = unknown>(customType: string, data?: T) => void;
|
||||||
|
|
||||||
|
export type GetActiveToolsHandler = () => string[];
|
||||||
|
|
||||||
|
export type GetAllToolsHandler = () => string[];
|
||||||
|
|
||||||
|
export type SetActiveToolsHandler = (toolNames: string[]) => void;
|
||||||
|
|
||||||
|
/** Loaded extension with all registered items. */
|
||||||
|
export interface LoadedExtension {
|
||||||
|
path: string;
|
||||||
|
resolvedPath: string;
|
||||||
|
handlers: Map<string, HandlerFn[]>;
|
||||||
|
tools: Map<string, RegisteredTool>;
|
||||||
|
messageRenderers: Map<string, MessageRenderer>;
|
||||||
|
commands: Map<string, RegisteredCommand>;
|
||||||
|
flags: Map<string, ExtensionFlag>;
|
||||||
|
flagValues: Map<string, boolean | string>;
|
||||||
|
shortcuts: Map<KeyId, ExtensionShortcut>;
|
||||||
|
setSendMessageHandler: (handler: SendMessageHandler) => void;
|
||||||
|
setAppendEntryHandler: (handler: AppendEntryHandler) => void;
|
||||||
|
setGetActiveToolsHandler: (handler: GetActiveToolsHandler) => void;
|
||||||
|
setGetAllToolsHandler: (handler: GetAllToolsHandler) => void;
|
||||||
|
setSetActiveToolsHandler: (handler: SetActiveToolsHandler) => void;
|
||||||
|
setFlagValue: (name: string, value: boolean | string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of loading extensions. */
|
||||||
|
export interface LoadExtensionsResult {
|
||||||
|
extensions: LoadedExtension[];
|
||||||
|
errors: Array<{ path: string; error: string }>;
|
||||||
|
setUIContext(uiContext: ExtensionUIContext, hasUI: boolean): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Extension Error
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface ExtensionError {
|
||||||
|
extensionPath: string;
|
||||||
|
event: string;
|
||||||
|
error: string;
|
||||||
|
stack?: string;
|
||||||
|
}
|
||||||
119
packages/coding-agent/src/core/extensions/wrapper.ts
Normal file
119
packages/coding-agent/src/core/extensions/wrapper.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
/**
|
||||||
|
* Tool wrappers for extensions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AgentTool, AgentToolUpdateCallback } from "@mariozechner/pi-agent-core";
|
||||||
|
import type { ExtensionRunner } from "./runner.js";
|
||||||
|
import type { ExtensionContext, RegisteredTool, ToolCallEventResult, ToolResultEventResult } from "./types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a RegisteredTool into an AgentTool.
|
||||||
|
*/
|
||||||
|
export function wrapRegisteredTool(registeredTool: RegisteredTool, getContext: () => ExtensionContext): AgentTool {
|
||||||
|
const { definition } = registeredTool;
|
||||||
|
return {
|
||||||
|
name: definition.name,
|
||||||
|
label: definition.label,
|
||||||
|
description: definition.description,
|
||||||
|
parameters: definition.parameters,
|
||||||
|
execute: (toolCallId, params, signal, onUpdate) =>
|
||||||
|
definition.execute(toolCallId, params, onUpdate, getContext(), signal),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap all registered tools into AgentTools.
|
||||||
|
*/
|
||||||
|
export function wrapRegisteredTools(
|
||||||
|
registeredTools: RegisteredTool[],
|
||||||
|
getContext: () => ExtensionContext,
|
||||||
|
): AgentTool[] {
|
||||||
|
return registeredTools.map((rt) => wrapRegisteredTool(rt, getContext));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a tool with extension callbacks for interception.
|
||||||
|
* - Emits tool_call event before execution (can block)
|
||||||
|
* - Emits tool_result event after execution (can modify result)
|
||||||
|
*/
|
||||||
|
export function wrapToolWithExtensions<T>(tool: AgentTool<any, T>, runner: ExtensionRunner): AgentTool<any, T> {
|
||||||
|
return {
|
||||||
|
...tool,
|
||||||
|
execute: async (
|
||||||
|
toolCallId: string,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
onUpdate?: AgentToolUpdateCallback<T>,
|
||||||
|
) => {
|
||||||
|
// Emit tool_call event - extensions can block execution
|
||||||
|
if (runner.hasHandlers("tool_call")) {
|
||||||
|
try {
|
||||||
|
const callResult = (await runner.emitToolCall({
|
||||||
|
type: "tool_call",
|
||||||
|
toolName: tool.name,
|
||||||
|
toolCallId,
|
||||||
|
input: params,
|
||||||
|
})) as ToolCallEventResult | undefined;
|
||||||
|
|
||||||
|
if (callResult?.block) {
|
||||||
|
const reason = callResult.reason || "Tool execution was blocked by an extension";
|
||||||
|
throw new Error(reason);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
throw new Error(`Extension failed, blocking execution: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the actual tool
|
||||||
|
try {
|
||||||
|
const result = await tool.execute(toolCallId, params, signal, onUpdate);
|
||||||
|
|
||||||
|
// Emit tool_result event - extensions can modify the result
|
||||||
|
if (runner.hasHandlers("tool_result")) {
|
||||||
|
const resultResult = (await runner.emit({
|
||||||
|
type: "tool_result",
|
||||||
|
toolName: tool.name,
|
||||||
|
toolCallId,
|
||||||
|
input: params,
|
||||||
|
content: result.content,
|
||||||
|
details: result.details,
|
||||||
|
isError: false,
|
||||||
|
})) as ToolResultEventResult | undefined;
|
||||||
|
|
||||||
|
if (resultResult) {
|
||||||
|
return {
|
||||||
|
content: resultResult.content ?? result.content,
|
||||||
|
details: (resultResult.details ?? result.details) as T,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
// Emit tool_result event for errors
|
||||||
|
if (runner.hasHandlers("tool_result")) {
|
||||||
|
await runner.emit({
|
||||||
|
type: "tool_result",
|
||||||
|
toolName: tool.name,
|
||||||
|
toolCallId,
|
||||||
|
input: params,
|
||||||
|
content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }],
|
||||||
|
details: undefined,
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap all tools with extension callbacks.
|
||||||
|
*/
|
||||||
|
export function wrapToolsWithExtensions<T>(tools: AgentTool<any, T>[], runner: ExtensionRunner): AgentTool<any, T>[] {
|
||||||
|
return tools.map((tool) => wrapToolWithExtensions(tool, runner));
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue