feat: support plugin-managed hooks

This commit is contained in:
Peter Steinberger
2026-01-18 05:56:59 +00:00
parent 88b37e80fc
commit e2c10a2b7a
16 changed files with 436 additions and 26 deletions

View File

@@ -11,6 +11,7 @@ Manage agent hooks (event-driven automations for commands like `/new`, `/reset`,
Related:
- Hooks: [Hooks](/hooks)
- Plugin hooks: [Plugins](/plugin#plugin-hooks)
## List All Hooks
@@ -118,6 +119,9 @@ clawdbot hooks enable <name>
Enable a specific hook by adding it to your config (`~/.clawdbot/config.json`).
**Note:** Hooks managed by plugins show `plugin:<id>` in `clawdbot hooks list` and
cant be enabled/disabled here. Enable/disable the plugin instead.
**Arguments:**
- `<name>`: Hook name (e.g., `session-memory`)

View File

@@ -14,6 +14,8 @@ Hooks are small scripts that run when something happens. There are two kinds:
- **Hooks** (this page): run inside the Gateway when agent events fire, like `/new`, `/reset`, `/stop`, or lifecycle events.
- **Webhooks**: external HTTP webhooks that let other systems trigger work in Clawdbot. See [Webhook Hooks](/automation/webhook) or use `clawdbot webhooks` for Gmail helper commands.
Hooks can also be bundled inside plugins; see [Plugins](/plugin#plugin-hooks).
Common uses:
- Save a memory snapshot when you reset a session

View File

@@ -215,6 +215,27 @@ Plugins export either:
- A function: `(api) => { ... }`
- An object: `{ id, name, configSchema, register(api) { ... } }`
## Plugin hooks
Plugins can ship hooks and register them at runtime. This lets a plugin bundle
event-driven automation without a separate hook pack install.
### Example
```
import { registerPluginHooksFromDir } from "clawdbot/plugin-sdk";
export default function register(api) {
registerPluginHooksFromDir(api, "./hooks");
}
```
Notes:
- Hook directories follow the normal hook structure (`HOOK.md` + `handler.ts`).
- Hook eligibility rules still apply (OS/bins/env/config requirements).
- Plugin-managed hooks show up in `clawdbot hooks list` with `plugin:<id>`.
- You cannot enable/disable plugin-managed hooks via `clawdbot hooks`; enable/disable the plugin instead.
## Provider plugins (model auth)
Plugins can register **model provider auth** flows so users can run OAuth or

View File

@@ -10,6 +10,7 @@ const report: HookStatusReport = {
name: "session-memory",
description: "Save session context to memory",
source: "clawdbot-bundled",
pluginId: undefined,
filePath: "/tmp/hooks/session-memory/HOOK.md",
baseDir: "/tmp/hooks/session-memory",
handlerPath: "/tmp/hooks/session-memory/handler.js",
@@ -20,6 +21,7 @@ const report: HookStatusReport = {
always: false,
disabled: false,
eligible: true,
managedByPlugin: false,
requirements: {
bins: [],
anyBins: [],
@@ -51,4 +53,49 @@ describe("hooks cli formatting", () => {
const output = formatHooksCheck(report, {});
expect(output).toContain("Hooks Status");
});
it("labels plugin-managed hooks with plugin id", () => {
const pluginReport: HookStatusReport = {
workspaceDir: "/tmp/workspace",
managedHooksDir: "/tmp/hooks",
hooks: [
{
name: "plugin-hook",
description: "Hook from plugin",
source: "clawdbot-plugin",
pluginId: "voice-call",
filePath: "/tmp/hooks/plugin-hook/HOOK.md",
baseDir: "/tmp/hooks/plugin-hook",
handlerPath: "/tmp/hooks/plugin-hook/handler.js",
hookKey: "plugin-hook",
emoji: "🔗",
homepage: undefined,
events: ["command:new"],
always: false,
disabled: false,
eligible: true,
managedByPlugin: true,
requirements: {
bins: [],
anyBins: [],
env: [],
config: [],
os: [],
},
missing: {
bins: [],
anyBins: [],
env: [],
config: [],
os: [],
},
configChecks: [],
install: [],
},
],
};
const output = formatHooksList(pluginReport, {});
expect(output).toContain("plugin:voice-call");
});
});

View File

@@ -11,6 +11,8 @@ import {
type HookStatusEntry,
type HookStatusReport,
} from "../hooks/hooks-status.js";
import type { HookEntry } from "../hooks/types.js";
import { loadWorkspaceHookEntries } from "../hooks/workspace.js";
import { loadConfig, writeConfigFile } from "../config/io.js";
import {
installHooksFromNpmSpec,
@@ -18,6 +20,7 @@ import {
resolveHookInstallDir,
} from "../hooks/install.js";
import { recordHookInstall } from "../hooks/installs.js";
import { buildPluginStatusReport } from "../plugins/status.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
@@ -42,6 +45,29 @@ export type HooksUpdateOptions = {
dryRun?: boolean;
};
function mergeHookEntries(
pluginEntries: HookEntry[],
workspaceEntries: HookEntry[],
): HookEntry[] {
const merged = new Map<string, HookEntry>();
for (const entry of pluginEntries) {
merged.set(entry.hook.name, entry);
}
for (const entry of workspaceEntries) {
merged.set(entry.hook.name, entry);
}
return Array.from(merged.values());
}
function buildHooksReport(config: ClawdbotConfig): HookStatusReport {
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
const workspaceEntries = loadWorkspaceHookEntries(workspaceDir, { config });
const pluginReport = buildPluginStatusReport({ config, workspaceDir });
const pluginEntries = pluginReport.hooks.map((hook) => hook.entry);
const entries = mergeHookEntries(pluginEntries, workspaceEntries);
return buildWorkspaceHookStatus(workspaceDir, { config, entries });
}
/**
* Format a single hook for display in the list
*/
@@ -58,6 +84,9 @@ function formatHookLine(hook: HookStatusEntry, verbose = false): string {
const desc = chalk.gray(
hook.description.length > 50 ? `${hook.description.slice(0, 47)}...` : hook.description,
);
const sourceLabel = hook.managedByPlugin
? chalk.magenta(`plugin:${hook.pluginId ?? "unknown"}`)
: "";
if (verbose) {
const missing: string[] = [];
@@ -77,10 +106,12 @@ function formatHookLine(hook: HookStatusEntry, verbose = false): string {
missing.push(`os: ${hook.missing.os.join(", ")}`);
}
const missingStr = missing.length > 0 ? chalk.red(` [${missing.join("; ")}]`) : "";
return `${emoji} ${name} ${status}${missingStr}\n ${desc}`;
const sourceSuffix = sourceLabel ? ` ${sourceLabel}` : "";
return `${emoji} ${name} ${status}${missingStr}\n ${desc}${sourceSuffix}`;
}
return `${emoji} ${name} ${status} - ${desc}`;
const sourceSuffix = sourceLabel ? ` ${sourceLabel}` : "";
return `${emoji} ${name} ${status} - ${desc}${sourceSuffix}`;
}
async function readInstalledPackageVersion(dir: string): Promise<string | undefined> {
@@ -110,9 +141,11 @@ export function formatHooksList(report: HookStatusReport, opts: HooksListOptions
eligible: h.eligible,
disabled: h.disabled,
source: h.source,
pluginId: h.pluginId,
events: h.events,
homepage: h.homepage,
missing: h.missing,
managedByPlugin: h.managedByPlugin,
})),
};
return JSON.stringify(jsonReport, null, 2);
@@ -186,7 +219,11 @@ export function formatHookInfo(
// Details
lines.push(chalk.bold("Details:"));
lines.push(` Source: ${hook.source}`);
if (hook.managedByPlugin) {
lines.push(` Source: ${hook.source} (${hook.pluginId ?? "unknown"})`);
} else {
lines.push(` Source: ${hook.source}`);
}
lines.push(` Path: ${chalk.gray(hook.filePath)}`);
lines.push(` Handler: ${chalk.gray(hook.handlerPath)}`);
if (hook.homepage) {
@@ -195,6 +232,9 @@ export function formatHookInfo(
if (hook.events.length > 0) {
lines.push(` Events: ${hook.events.join(", ")}`);
}
if (hook.managedByPlugin) {
lines.push(` Managed by plugin; enable/disable via hooks CLI not available.`);
}
// Requirements
const hasRequirements =
@@ -302,14 +342,19 @@ export function formatHooksCheck(report: HookStatusReport, opts: HooksCheckOptio
export async function enableHook(hookName: string): Promise<void> {
const config = loadConfig();
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
const report = buildWorkspaceHookStatus(workspaceDir, { config });
const report = buildHooksReport(config);
const hook = report.hooks.find((h) => h.name === hookName);
if (!hook) {
throw new Error(`Hook "${hookName}" not found`);
}
if (hook.managedByPlugin) {
throw new Error(
`Hook "${hookName}" is managed by plugin "${hook.pluginId ?? "unknown"}" and cannot be enabled/disabled.`,
);
}
if (!hook.eligible) {
throw new Error(`Hook "${hookName}" is not eligible (missing requirements)`);
}
@@ -336,14 +381,19 @@ export async function enableHook(hookName: string): Promise<void> {
export async function disableHook(hookName: string): Promise<void> {
const config = loadConfig();
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
const report = buildWorkspaceHookStatus(workspaceDir, { config });
const report = buildHooksReport(config);
const hook = report.hooks.find((h) => h.name === hookName);
if (!hook) {
throw new Error(`Hook "${hookName}" not found`);
}
if (hook.managedByPlugin) {
throw new Error(
`Hook "${hookName}" is managed by plugin "${hook.pluginId ?? "unknown"}" and cannot be enabled/disabled.`,
);
}
// Update config
const entries = { ...config.hooks?.internal?.entries };
entries[hookName] = { ...entries[hookName], enabled: false };
@@ -382,8 +432,7 @@ export function registerHooksCli(program: Command): void {
.action(async (opts) => {
try {
const config = loadConfig();
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
const report = buildWorkspaceHookStatus(workspaceDir, { config });
const report = buildHooksReport(config);
console.log(formatHooksList(report, opts));
} catch (err) {
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
@@ -398,8 +447,7 @@ export function registerHooksCli(program: Command): void {
.action(async (name, opts) => {
try {
const config = loadConfig();
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
const report = buildWorkspaceHookStatus(workspaceDir, { config });
const report = buildHooksReport(config);
console.log(formatHookInfo(report, name, opts));
} catch (err) {
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
@@ -414,8 +462,7 @@ export function registerHooksCli(program: Command): void {
.action(async (opts) => {
try {
const config = loadConfig();
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
const report = buildWorkspaceHookStatus(workspaceDir, { config });
const report = buildHooksReport(config);
console.log(formatHooksCheck(report, opts));
} catch (err) {
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
@@ -765,8 +812,7 @@ export function registerHooksCli(program: Command): void {
hooks.action(async () => {
try {
const config = loadConfig();
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
const report = buildWorkspaceHookStatus(workspaceDir, { config });
const report = buildHooksReport(config);
console.log(formatHooksList(report, {}));
} catch (err) {
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));

View File

@@ -48,12 +48,34 @@ describe("onboard-hooks", () => {
name: "session-memory",
description: "Save session context to memory when /new command is issued",
source: "clawdbot-bundled",
pluginId: undefined,
filePath: "/mock/workspace/hooks/session-memory/HOOK.md",
baseDir: "/mock/workspace/hooks/session-memory",
handlerPath: "/mock/workspace/hooks/session-memory/handler.js",
hookKey: "session-memory",
emoji: "💾",
events: ["command:new"],
homepage: undefined,
always: false,
disabled: false,
eligible,
requirements: { config: ["workspace.dir"] },
missing: {},
managedByPlugin: false,
requirements: {
bins: [],
anyBins: [],
env: [],
config: ["workspace.dir"],
os: [],
},
missing: {
bins: [],
anyBins: [],
env: [],
config: eligible ? [] : ["workspace.dir"],
os: [],
},
configChecks: [],
install: [],
},
],
});

View File

@@ -4,6 +4,7 @@ export const createTestRegistry = (overrides: Partial<PluginRegistry> = {}): Plu
const base: PluginRegistry = {
plugins: [],
tools: [],
hooks: [],
channels: [],
providers: [],
gatewayHandlers: {},

View File

@@ -73,11 +73,12 @@ export function shouldIncludeHook(params: {
const { entry, config, eligibility } = params;
const hookKey = resolveHookKey(entry.hook.name, entry);
const hookConfig = resolveHookConfig(config, hookKey);
const pluginManaged = entry.hook.source === "clawdbot-plugin";
const osList = entry.clawdbot?.os ?? [];
const remotePlatforms = eligibility?.remote?.platforms ?? [];
// Check if explicitly disabled
if (hookConfig?.enabled === false) return false;
if (!pluginManaged && hookConfig?.enabled === false) return false;
// Check OS requirement
if (

View File

@@ -23,6 +23,7 @@ export type HookStatusEntry = {
name: string;
description: string;
source: string;
pluginId?: string;
filePath: string;
baseDir: string;
handlerPath: string;
@@ -33,6 +34,7 @@ export type HookStatusEntry = {
always: boolean;
disabled: boolean;
eligible: boolean;
managedByPlugin: boolean;
requirements: {
bins: string[];
anyBins: string[];
@@ -94,7 +96,8 @@ function buildHookStatus(
): HookStatusEntry {
const hookKey = resolveHookKey(entry);
const hookConfig = resolveHookConfig(config, hookKey);
const disabled = hookConfig?.enabled === false;
const managedByPlugin = entry.hook.source === "clawdbot-plugin";
const disabled = managedByPlugin ? false : hookConfig?.enabled === false;
const always = entry.clawdbot?.always === true;
const emoji = entry.clawdbot?.emoji ?? entry.frontmatter.emoji;
const homepageRaw =
@@ -171,6 +174,7 @@ function buildHookStatus(
name: entry.hook.name,
description: entry.hook.description,
source: entry.hook.source,
pluginId: entry.hook.pluginId,
filePath: entry.hook.filePath,
baseDir: entry.hook.baseDir,
handlerPath: entry.hook.handlerPath,
@@ -181,6 +185,7 @@ function buildHookStatus(
always,
disabled,
eligible,
managedByPlugin,
requirements: {
bins: requiredBins,
anyBins: requiredAnyBins,

115
src/hooks/plugin-hooks.ts Normal file
View File

@@ -0,0 +1,115 @@
import path from "node:path";
import { pathToFileURL } from "node:url";
import type { ClawdbotPluginApi } from "../plugins/types.js";
import type { HookEntry } from "./types.js";
import { shouldIncludeHook } from "./config.js";
import { loadHookEntriesFromDir } from "./workspace.js";
import type { InternalHookHandler } from "./internal-hooks.js";
export type PluginHookLoadResult = {
hooks: HookEntry[];
loaded: number;
skipped: number;
errors: string[];
};
function resolveHookDir(api: ClawdbotPluginApi, dir: string): string {
if (path.isAbsolute(dir)) return dir;
return path.resolve(path.dirname(api.source), dir);
}
function normalizePluginHookEntry(api: ClawdbotPluginApi, entry: HookEntry): HookEntry {
return {
...entry,
hook: {
...entry.hook,
source: "clawdbot-plugin",
pluginId: api.id,
},
clawdbot: {
...entry.clawdbot,
hookKey: entry.clawdbot?.hookKey ?? `${api.id}:${entry.hook.name}`,
events: entry.clawdbot?.events ?? [],
},
};
}
async function loadHookHandler(
entry: HookEntry,
api: ClawdbotPluginApi,
): Promise<InternalHookHandler | null> {
try {
const url = pathToFileURL(entry.hook.handlerPath).href;
const cacheBustedUrl = `${url}?t=${Date.now()}`;
const mod = (await import(cacheBustedUrl)) as Record<string, unknown>;
const exportName = entry.clawdbot?.export ?? "default";
const handler = mod[exportName];
if (typeof handler === "function") {
return handler as InternalHookHandler;
}
api.logger.warn?.(`[hooks] ${entry.hook.name} handler is not a function`);
return null;
} catch (err) {
api.logger.warn?.(`[hooks] Failed to load ${entry.hook.name}: ${String(err)}`);
return null;
}
}
export async function registerPluginHooksFromDir(
api: ClawdbotPluginApi,
dir: string,
): Promise<PluginHookLoadResult> {
const resolvedDir = resolveHookDir(api, dir);
const hooks = loadHookEntriesFromDir({
dir: resolvedDir,
source: "clawdbot-plugin",
pluginId: api.id,
});
const result: PluginHookLoadResult = {
hooks,
loaded: 0,
skipped: 0,
errors: [],
};
for (const entry of hooks) {
const normalizedEntry = normalizePluginHookEntry(api, entry);
const events = normalizedEntry.clawdbot?.events ?? [];
if (events.length === 0) {
api.logger.warn?.(`[hooks] ${entry.hook.name} has no events; skipping`);
api.registerHook(events, async () => undefined, {
entry: normalizedEntry,
register: false,
});
result.skipped += 1;
continue;
}
const handler = await loadHookHandler(entry, api);
if (!handler) {
result.errors.push(`[hooks] Failed to load ${entry.hook.name}`);
api.registerHook(events, async () => undefined, {
entry: normalizedEntry,
register: false,
});
result.skipped += 1;
continue;
}
const eligible = shouldIncludeHook({ entry: normalizedEntry, config: api.config });
api.registerHook(events, handler, {
entry: normalizedEntry,
register: eligible,
});
if (eligible) {
result.loaded += 1;
} else {
result.skipped += 1;
}
}
return result;
}

View File

@@ -35,12 +35,15 @@ export type ParsedHookFrontmatter = Record<string, string>;
export type Hook = {
name: string;
description: string;
source: "clawdbot-bundled" | "clawdbot-managed" | "clawdbot-workspace";
source: "clawdbot-bundled" | "clawdbot-managed" | "clawdbot-workspace" | "clawdbot-plugin";
pluginId?: string;
filePath: string; // Path to HOOK.md
baseDir: string; // Directory containing hook
handlerPath: string; // Path to handler module (handler.ts/js)
};
export type HookSource = Hook["source"];
export type HookEntry = {
hook: Hook;
frontmatter: ParsedHookFrontmatter;

View File

@@ -15,6 +15,7 @@ import type {
HookEligibilityContext,
HookEntry,
HookSnapshot,
HookSource,
ParsedHookFrontmatter,
} from "./types.js";
@@ -50,7 +51,8 @@ function resolvePackageHooks(manifest: HookPackageManifest): string[] {
function loadHookFromDir(params: {
hookDir: string;
source: string;
source: HookSource;
pluginId?: string;
nameHint?: string;
}): Hook | null {
const hookMdPath = path.join(params.hookDir, "HOOK.md");
@@ -82,6 +84,7 @@ function loadHookFromDir(params: {
name,
description,
source: params.source as Hook["source"],
pluginId: params.pluginId,
filePath: hookMdPath,
baseDir: params.hookDir,
handlerPath,
@@ -95,8 +98,8 @@ function loadHookFromDir(params: {
/**
* Scan a directory for hooks (subdirectories containing HOOK.md)
*/
function loadHooksFromDir(params: { dir: string; source: string }): Hook[] {
const { dir, source } = params;
function loadHooksFromDir(params: { dir: string; source: HookSource; pluginId?: string }): Hook[] {
const { dir, source, pluginId } = params;
if (!fs.existsSync(dir)) return [];
@@ -119,6 +122,7 @@ function loadHooksFromDir(params: { dir: string; source: string }): Hook[] {
const hook = loadHookFromDir({
hookDir: resolvedHookDir,
source,
pluginId,
nameHint: path.basename(resolvedHookDir),
});
if (hook) hooks.push(hook);
@@ -126,13 +130,50 @@ function loadHooksFromDir(params: { dir: string; source: string }): Hook[] {
continue;
}
const hook = loadHookFromDir({ hookDir, source, nameHint: entry.name });
const hook = loadHookFromDir({
hookDir,
source,
pluginId,
nameHint: entry.name,
});
if (hook) hooks.push(hook);
}
return hooks;
}
export function loadHookEntriesFromDir(params: {
dir: string;
source: HookSource;
pluginId?: string;
}): HookEntry[] {
const hooks = loadHooksFromDir({
dir: params.dir,
source: params.source,
pluginId: params.pluginId,
});
return hooks.map((hook) => {
let frontmatter: ParsedHookFrontmatter = {};
try {
const raw = fs.readFileSync(hook.filePath, "utf-8");
frontmatter = parseFrontmatter(raw);
} catch {
// ignore malformed hooks
}
const entry: HookEntry = {
hook: {
...hook,
source: params.source,
pluginId: params.pluginId,
},
frontmatter,
clawdbot: resolveClawdbotMetadata(frontmatter),
invocation: resolveHookInvocationPolicy(frontmatter),
};
return entry;
});
}
function loadHookEntries(
workspaceDir: string,
opts?: {
@@ -178,7 +219,7 @@ function loadHookEntries(
for (const hook of managedHooks) merged.set(hook.name, hook);
for (const hook of workspaceHooks) merged.set(hook.name, hook);
const hookEntries: HookEntry[] = Array.from(merged.values()).map((hook) => {
return Array.from(merged.values()).map((hook) => {
let frontmatter: ParsedHookFrontmatter = {};
try {
const raw = fs.readFileSync(hook.filePath, "utf-8");
@@ -193,7 +234,6 @@ function loadHookEntries(
invocation: resolveHookInvocationPolicy(frontmatter),
};
});
return hookEntries;
}
export function buildWorkspaceHookSnapshot(

View File

@@ -162,3 +162,5 @@ export { createMemoryGetTool, createMemorySearchTool } from "../agents/tools/mem
export { registerMemoryCli } from "../cli/memory-cli.js";
export { formatDocsLink } from "../terminal/links.js";
export type { HookEntry } from "../hooks/types.js";
export { registerPluginHooksFromDir } from "../hooks/plugin-hooks.js";

View File

@@ -264,6 +264,7 @@ function createPluginRecord(params: {
enabled: params.enabled,
status: params.enabled ? "loaded" : "disabled",
toolNames: [],
hookNames: [],
channelIds: [],
providerIds: [],
gatewayMethods: [],

View File

@@ -5,12 +5,14 @@ import type {
GatewayRequestHandler,
GatewayRequestHandlers,
} from "../gateway/server-methods/types.js";
import { registerInternalHook } from "../hooks/internal-hooks.js";
import { resolveUserPath } from "../utils.js";
import type {
ClawdbotPluginApi,
ClawdbotPluginChannelRegistration,
ClawdbotPluginCliRegistrar,
ClawdbotPluginHttpHandler,
ClawdbotPluginHookOptions,
ProviderPlugin,
ClawdbotPluginService,
ClawdbotPluginToolContext,
@@ -22,6 +24,8 @@ import type {
PluginKind,
} from "./types.js";
import type { PluginRuntime } from "./runtime/types.js";
import type { HookEntry } from "../hooks/types.js";
import path from "node:path";
export type PluginToolRegistration = {
pluginId: string;
@@ -57,6 +61,13 @@ export type PluginProviderRegistration = {
source: string;
};
export type PluginHookRegistration = {
pluginId: string;
entry: HookEntry;
events: string[];
source: string;
};
export type PluginServiceRegistration = {
pluginId: string;
service: ClawdbotPluginService;
@@ -76,6 +87,7 @@ export type PluginRecord = {
status: "loaded" | "disabled" | "error";
error?: string;
toolNames: string[];
hookNames: string[];
channelIds: string[];
providerIds: string[];
gatewayMethods: string[];
@@ -90,6 +102,7 @@ export type PluginRecord = {
export type PluginRegistry = {
plugins: PluginRecord[];
tools: PluginToolRegistration[];
hooks: PluginHookRegistration[];
channels: PluginChannelRegistration[];
providers: PluginProviderRegistration[];
gatewayHandlers: GatewayRequestHandlers;
@@ -109,6 +122,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
const registry: PluginRegistry = {
plugins: [],
tools: [],
hooks: [],
channels: [],
providers: [],
gatewayHandlers: {},
@@ -150,6 +164,76 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
});
};
const registerHook = (
record: PluginRecord,
events: string | string[],
handler: Parameters<typeof registerInternalHook>[1],
opts: ClawdbotPluginHookOptions | undefined,
config: ClawdbotPluginApi["config"],
) => {
const eventList = Array.isArray(events) ? events : [events];
const normalizedEvents = eventList.map((event) => event.trim()).filter(Boolean);
const entry = opts?.entry ?? null;
const name = entry?.hook.name ?? opts?.name?.trim();
if (!name) {
pushDiagnostic({
level: "warn",
pluginId: record.id,
source: record.source,
message: "hook registration missing name",
});
return;
}
const description = entry?.hook.description ?? opts?.description ?? "";
const hookEntry: HookEntry = entry
? {
...entry,
hook: {
...entry.hook,
name,
description,
source: "clawdbot-plugin",
pluginId: record.id,
},
clawdbot: {
...entry.clawdbot,
events: normalizedEvents,
},
}
: {
hook: {
name,
description,
source: "clawdbot-plugin",
pluginId: record.id,
filePath: record.source,
baseDir: path.dirname(record.source),
handlerPath: record.source,
},
frontmatter: {},
clawdbot: { events: normalizedEvents },
invocation: { enabled: true },
};
record.hookNames.push(name);
registry.hooks.push({
pluginId: record.id,
entry: hookEntry,
events: normalizedEvents,
source: record.source,
});
const hookSystemEnabled = config?.hooks?.internal?.enabled === true;
if (!hookSystemEnabled || opts?.register === false) {
return;
}
for (const event of normalizedEvents) {
registerInternalHook(event, handler);
}
};
const registerGatewayMethod = (
record: PluginRecord,
method: string,
@@ -287,6 +371,8 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
runtime: registryParams.runtime,
logger: normalizeLogger(registryParams.logger),
registerTool: (tool, opts) => registerTool(record, tool, opts),
registerHook: (events, handler, opts) =>
registerHook(record, events, handler, opts, params.config),
registerHttpHandler: (handler) => registerHttpHandler(record, handler),
registerChannel: (registration) => registerChannel(record, registration),
registerProvider: (provider) => registerProvider(record, provider),

View File

@@ -6,6 +6,8 @@ import type { AnyAgentTool } from "../agents/tools/common.js";
import type { ChannelDock } from "../channels/dock.js";
import type { ChannelPlugin } from "../channels/plugins/types.js";
import type { ClawdbotConfig } from "../config/config.js";
import type { InternalHookHandler } from "../hooks/internal-hooks.js";
import type { HookEntry } from "../hooks/types.js";
import type { ModelProviderConfig } from "../config/types.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
@@ -71,6 +73,13 @@ export type ClawdbotPluginToolOptions = {
optional?: boolean;
};
export type ClawdbotPluginHookOptions = {
entry?: HookEntry;
name?: string;
description?: string;
register?: boolean;
};
export type ProviderAuthKind = "oauth" | "api_key" | "token" | "device_code" | "custom";
export type ProviderAuthResult = {
@@ -179,6 +188,11 @@ export type ClawdbotPluginApi = {
tool: AnyAgentTool | ClawdbotPluginToolFactory,
opts?: ClawdbotPluginToolOptions,
) => void;
registerHook: (
events: string | string[],
handler: InternalHookHandler,
opts?: ClawdbotPluginHookOptions,
) => void;
registerHttpHandler: (handler: ClawdbotPluginHttpHandler) => void;
registerChannel: (registration: ClawdbotPluginChannelRegistration | ChannelPlugin) => void;
registerGatewayMethod: (method: string, handler: GatewayRequestHandler) => void;