mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 11:02:17 +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