diff --git a/docs/cli/hooks.md b/docs/cli/hooks.md index 737e6189b..04538b3a7 100644 --- a/docs/cli/hooks.md +++ b/docs/cli/hooks.md @@ -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 Enable a specific hook by adding it to your config (`~/.clawdbot/config.json`). +**Note:** Hooks managed by plugins show `plugin:` in `clawdbot hooks list` and +canโ€™t be enabled/disabled here. Enable/disable the plugin instead. + **Arguments:** - ``: Hook name (e.g., `session-memory`) diff --git a/docs/hooks.md b/docs/hooks.md index b1dc11a32..26047b3c0 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -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 diff --git a/docs/plugin.md b/docs/plugin.md index b64c51bb5..dbf711956 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -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:`. +- 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 diff --git a/src/cli/hooks-cli.test.ts b/src/cli/hooks-cli.test.ts index 516671cf0..edbaf797d 100644 --- a/src/cli/hooks-cli.test.ts +++ b/src/cli/hooks-cli.test.ts @@ -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"); + }); }); diff --git a/src/cli/hooks-cli.ts b/src/cli/hooks-cli.ts index 7e933b607..e9731cce6 100644 --- a/src/cli/hooks-cli.ts +++ b/src/cli/hooks-cli.ts @@ -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(); + 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 { @@ -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 { 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 { export async function disableHook(hookName: string): Promise { 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)); diff --git a/src/commands/onboard-hooks.test.ts b/src/commands/onboard-hooks.test.ts index 61f1790e1..22c6b8471 100644 --- a/src/commands/onboard-hooks.test.ts +++ b/src/commands/onboard-hooks.test.ts @@ -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: [], }, ], }); diff --git a/src/gateway/server/__tests__/test-utils.ts b/src/gateway/server/__tests__/test-utils.ts index 5d8fac524..d22ecc63d 100644 --- a/src/gateway/server/__tests__/test-utils.ts +++ b/src/gateway/server/__tests__/test-utils.ts @@ -4,6 +4,7 @@ export const createTestRegistry = (overrides: Partial = {}): Plu const base: PluginRegistry = { plugins: [], tools: [], + hooks: [], channels: [], providers: [], gatewayHandlers: {}, diff --git a/src/hooks/config.ts b/src/hooks/config.ts index 228a4902a..e290e6b5d 100644 --- a/src/hooks/config.ts +++ b/src/hooks/config.ts @@ -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 ( diff --git a/src/hooks/hooks-status.ts b/src/hooks/hooks-status.ts index 4ca73b3e8..fb788bd97 100644 --- a/src/hooks/hooks-status.ts +++ b/src/hooks/hooks-status.ts @@ -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, diff --git a/src/hooks/plugin-hooks.ts b/src/hooks/plugin-hooks.ts new file mode 100644 index 000000000..bce5af5e1 --- /dev/null +++ b/src/hooks/plugin-hooks.ts @@ -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 { + try { + const url = pathToFileURL(entry.hook.handlerPath).href; + const cacheBustedUrl = `${url}?t=${Date.now()}`; + const mod = (await import(cacheBustedUrl)) as Record; + 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 { + 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; +} diff --git a/src/hooks/types.ts b/src/hooks/types.ts index b4035718b..cd4ee8df0 100644 --- a/src/hooks/types.ts +++ b/src/hooks/types.ts @@ -35,12 +35,15 @@ export type ParsedHookFrontmatter = Record; 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; diff --git a/src/hooks/workspace.ts b/src/hooks/workspace.ts index bb5b3b9b5..14596c3d7 100644 --- a/src/hooks/workspace.ts +++ b/src/hooks/workspace.ts @@ -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( diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 8d0f75e47..e3da6a6d3 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -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"; diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 5011c13bf..23a9a1f5d 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -264,6 +264,7 @@ function createPluginRecord(params: { enabled: params.enabled, status: params.enabled ? "loaded" : "disabled", toolNames: [], + hookNames: [], channelIds: [], providerIds: [], gatewayMethods: [], diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 509c94fb1..1aafee404 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -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[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), diff --git a/src/plugins/types.ts b/src/plugins/types.ts index e5135414c..4a95932b8 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -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;