diff --git a/.changeset/plugin-hooks.md b/.changeset/plugin-hooks.md new file mode 100644 index 0000000000..d07584ac90 --- /dev/null +++ b/.changeset/plugin-hooks.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Plugins can now declare hooks in their manifest to run scripts on lifecycle events. diff --git a/packages/agent-core/src/plugin/manager.ts b/packages/agent-core/src/plugin/manager.ts index 2d3c1a7000..a278f0300c 100644 --- a/packages/agent-core/src/plugin/manager.ts +++ b/packages/agent-core/src/plugin/manager.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import type { McpServerConfig } from '../config/schema'; import { discoverSkills, type SkillRoot } from '../skill'; +import type { HookDef } from '../session/hooks'; import { downloadZip, extractZip } from './archive'; import { resolveGithubSource } from './github-resolver'; import { parseManifest, type ParsedManifestResult } from './manifest'; @@ -239,6 +240,21 @@ export class PluginManager { return out; } + enabledHooks(): readonly HookDef[] { + const out: HookDef[] = []; + for (const record of this.records.values()) { + if (!record.enabled || record.state !== 'ok' || record.manifest === undefined) continue; + for (const hook of record.manifest.hooks ?? []) { + out.push({ + ...hook, + cwd: record.root, + env: { KIMI_CODE_HOME: this.kimiHomeDir, KIMI_PLUGIN_ROOT: record.root }, + }); + } + } + return out; + } + summaries(): readonly PluginSummary[] { return this.list().map((record) => recordToSummary(record)); } @@ -362,6 +378,7 @@ function recordToSummary(record: PluginRecord): PluginSummary { skillCount: record.skillCount, mcpServerCount: Object.keys(record.manifest?.mcpServers ?? {}).length, enabledMcpServerCount: pluginMcpServersInfo(record).filter((server) => server.enabled).length, + hookCount: record.manifest?.hooks?.length ?? 0, hasErrors: record.diagnostics.some((d) => d.severity === 'error'), source: record.source, originalSource: record.originalSource, diff --git a/packages/agent-core/src/plugin/manifest.ts b/packages/agent-core/src/plugin/manifest.ts index 93355f5eab..8b6669c3e8 100644 --- a/packages/agent-core/src/plugin/manifest.ts +++ b/packages/agent-core/src/plugin/manifest.ts @@ -1,7 +1,12 @@ import { realpath, readFile, stat } from 'node:fs/promises'; import path from 'node:path'; -import { McpServerConfigSchema, type McpServerConfig } from '../config/schema'; +import { + HookDefSchema, + McpServerConfigSchema, + type HookDefConfig, + type McpServerConfig, +} from '../config/schema'; import { PLUGIN_NAME_REGEX, type PluginDiagnostic, @@ -19,7 +24,6 @@ const KIMI_PLUGIN_DIR_PATH = '.kimi-plugin/plugin.json'; const UNSUPPORTED_RUNTIME_FIELDS = [ 'tools', 'commands', - 'hooks', 'apps', 'inject', 'configFile', @@ -121,6 +125,7 @@ export async function parseManifest(pluginRoot: string): Promise { + const parsed = HookDefSchema.safeParse(entry); + if (!parsed.success) { + diagnostics.push({ + severity: 'warn', + message: `Invalid hook at index ${i}: ${parsed.error.message}`, + }); + } else { + out.push(parsed.data); + } + }); + return out.length === 0 ? undefined : out; +} + async function normalizePluginMcpServer(input: { readonly pluginRoot: string; readonly name: string; diff --git a/packages/agent-core/src/plugin/types.ts b/packages/agent-core/src/plugin/types.ts index 82ae27bb2d..7a451c45b3 100644 --- a/packages/agent-core/src/plugin/types.ts +++ b/packages/agent-core/src/plugin/types.ts @@ -1,4 +1,4 @@ -import type { McpServerConfig } from '../config/schema'; +import type { HookDefConfig, McpServerConfig } from '../config/schema'; export type PluginDiagnosticSeverity = 'error' | 'warn' | 'info'; @@ -35,6 +35,7 @@ export interface PluginManifest { readonly skills?: readonly string[]; // resolved absolute paths readonly sessionStart?: PluginSessionStart; readonly mcpServers?: Readonly>; + readonly hooks?: readonly HookDefConfig[]; readonly interface?: PluginInterface; readonly skillInstructions?: string; } @@ -105,6 +106,7 @@ export interface PluginSummary { readonly skillCount: number; readonly mcpServerCount: number; readonly enabledMcpServerCount: number; + readonly hookCount: number; readonly hasErrors: boolean; readonly source: PluginSource; readonly originalSource?: string; diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 7335e5338a..6ad2a1cd54 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -281,7 +281,7 @@ export class KimiCore implements PromisableMethods { rpc: proxyWithExtraPayload(await this.sdk, { sessionId: summary.id }), providerManager: this.resolveProviderManager(summary.id), background: config.background, - hooks: config.hooks, + hooks: [...(config.hooks ?? []), ...this.plugins.enabledHooks()], permissionRules: config.permission?.rules, skills: this.resolveSessionSkillConfig(config), mcpConfig, @@ -412,7 +412,7 @@ export class KimiCore implements PromisableMethods { rpc: proxyWithExtraPayload(await this.sdk, { sessionId: summary.id }), providerManager: this.resolveProviderManager(summary.id), background: config.background, - hooks: config.hooks, + hooks: [...(config.hooks ?? []), ...this.plugins.enabledHooks()], permissionRules: config.permission?.rules, skills: this.resolveSessionSkillConfig(config), mcpConfig, diff --git a/packages/agent-core/src/session/hooks/engine.ts b/packages/agent-core/src/session/hooks/engine.ts index 832ecd6204..f71491fbfc 100644 --- a/packages/agent-core/src/session/hooks/engine.ts +++ b/packages/agent-core/src/session/hooks/engine.ts @@ -85,7 +85,8 @@ export class HookEngine { matched.map((hook) => runHook(hook.command, inputData, { timeout: hook.timeout ?? DEFAULT_HOOK_TIMEOUT_SECONDS, - cwd: this.options.cwd === '' ? undefined : this.options.cwd, + cwd: hook.cwd ?? (this.options.cwd === '' ? undefined : this.options.cwd), + env: hook.env, signal: args.signal, }), ), @@ -96,13 +97,14 @@ export class HookEngine { } private matchingHooks(event: string, matcherValue: string): HookDef[] { - const seenCommands = new Set(); + const seen = new Set(); const matched: HookDef[] = []; for (const hook of this.byEvent.get(event) ?? []) { if (!matches(hook.matcher ?? '', matcherValue)) continue; - if (seenCommands.has(hook.command)) continue; - seenCommands.add(hook.command); + const key = (hook.cwd ?? '') + '\0' + hook.command; + if (seen.has(key)) continue; + seen.add(key); matched.push(hook); } diff --git a/packages/agent-core/src/session/hooks/runner.ts b/packages/agent-core/src/session/hooks/runner.ts index ad518f1146..509c48f157 100644 --- a/packages/agent-core/src/session/hooks/runner.ts +++ b/packages/agent-core/src/session/hooks/runner.ts @@ -7,6 +7,7 @@ import type { HookResult } from './types'; export interface RunHookOptions { readonly timeout: number; readonly cwd?: string; + readonly env?: Readonly>; readonly signal?: AbortSignal; } @@ -50,6 +51,7 @@ export async function runHook( cwd: options.cwd, stdio: 'pipe', detached: process.platform !== 'win32', + env: options.env ? { ...process.env, ...options.env } : undefined, }); } catch (error) { return allowResult({ stderr: errorMessage(error) }); diff --git a/packages/agent-core/src/session/hooks/types.ts b/packages/agent-core/src/session/hooks/types.ts index cf05ca7475..786296de27 100644 --- a/packages/agent-core/src/session/hooks/types.ts +++ b/packages/agent-core/src/session/hooks/types.ts @@ -26,6 +26,8 @@ export interface HookDef { readonly matcher?: string; readonly command: string; readonly timeout?: number; + readonly cwd?: string; + readonly env?: Readonly>; } export interface HookResult { diff --git a/packages/agent-core/test/hooks/engine.test.ts b/packages/agent-core/test/hooks/engine.test.ts index 11942a35a1..b489c38516 100644 --- a/packages/agent-core/test/hooks/engine.test.ts +++ b/packages/agent-core/test/hooks/engine.test.ts @@ -1,3 +1,6 @@ +import { realpathSync } from 'node:fs'; +import { tmpdir } from 'node:os'; + import { describe, expect, it, vi } from 'vitest'; import type { ContentPart } from '@moonshot-ai/kosong'; @@ -10,6 +13,8 @@ type HookDef = { matcher?: string; command: string; timeout?: number; + cwd?: string; + env?: Readonly>; }; interface HookResult { @@ -349,4 +354,46 @@ describe('HookEngine', () => { spy?.mockRestore(); } }); + + it('runs a hook with HookDef.cwd as the working directory', async () => { + const { HookEngine } = await importEngine(); + const pluginCwd = tmpdir(); + const engine = new HookEngine( + [ + { + event: 'PreToolUse', + command: 'node -e "process.stdout.write(process.cwd())"', + timeout: 5, + cwd: pluginCwd, + }, + ], + { cwd: process.cwd() }, + ); + const results = await engine.trigger('PreToolUse', { inputData: {} }); + expect(results[0]?.stdout).toBe(realpathSync(pluginCwd)); + }); + + it('passes HookDef.env into the hook process environment', async () => { + const { HookEngine } = await importEngine(); + const engine = new HookEngine([ + { + event: 'PreToolUse', + command: 'node -e "process.stdout.write(process.env.KIMI_PLUGIN_TEST ?? \'missing\')"', + timeout: 5, + env: { KIMI_PLUGIN_TEST: 'plugin-value' }, + }, + ]); + const results = await engine.trigger('PreToolUse', { inputData: {} }); + expect(results[0]?.stdout).toBe('plugin-value'); + }); + + it('does not dedupe hooks that share a command but have different cwd', async () => { + const { HookEngine } = await importEngine(); + const engine = new HookEngine([ + { event: 'Stop', command: 'echo same', timeout: 5, cwd: process.cwd() }, + { event: 'Stop', command: 'echo same', timeout: 5, cwd: tmpdir() }, + ]); + const results = await engine.trigger('Stop', { inputData: {} }); + expect(results).toHaveLength(2); + }); }); diff --git a/packages/agent-core/test/plugin/manager.test.ts b/packages/agent-core/test/plugin/manager.test.ts index 49dd383ce5..4366e4bb86 100644 --- a/packages/agent-core/test/plugin/manager.test.ts +++ b/packages/agent-core/test/plugin/manager.test.ts @@ -23,6 +23,7 @@ async function makePlugin( version?: string; sessionStartSkill?: string; mcpServers?: Record; + hooks?: readonly unknown[]; } = {}, ): Promise { const root = await mkdtemp(path.join(tmpdir(), `plugin-${name}-`)); @@ -49,6 +50,9 @@ async function makePlugin( if (options.mcpServers !== undefined) { manifest['mcpServers'] = options.mcpServers; } + if (options.hooks !== undefined) { + manifest['hooks'] = options.hooks; + } await writeFile( path.join(root, 'kimi.plugin.json'), JSON.stringify(manifest), @@ -853,6 +857,52 @@ describe('PluginManager', () => { expect(updated.github?.ref).toEqual({ kind: 'tag', value: 'v5.1.0' }); expect(manager.list()).toHaveLength(1); }); + + it('enabledHooks() returns hooks from enabled plugins with cwd and env injected', async () => { + const home = await makeKimiHome(); + const root = await makePlugin('demo', { + hooks: [{ event: 'PreToolUse', command: './hooks/guard.sh', timeout: 10 }], + }); + const manager = new PluginManager({ kimiHomeDir: home }); + await manager.load(); + await manager.install(root); + const installedRoot = await managedPluginRoot(home, 'demo'); + expect(manager.enabledHooks()).toEqual([ + { + event: 'PreToolUse', + command: './hooks/guard.sh', + timeout: 10, + cwd: installedRoot, + env: { KIMI_CODE_HOME: home, KIMI_PLUGIN_ROOT: installedRoot }, + }, + ]); + }); + + it('enabledHooks() excludes disabled plugins', async () => { + const home = await makeKimiHome(); + const root = await makePlugin('demo', { + hooks: [{ event: 'PreToolUse', command: './x.sh' }], + }); + const manager = new PluginManager({ kimiHomeDir: home }); + await manager.load(); + await manager.install(root); + await manager.setEnabled('demo', false); + expect(manager.enabledHooks()).toEqual([]); + }); + + it('summaries() include hookCount', async () => { + const home = await makeKimiHome(); + const root = await makePlugin('demo', { + hooks: [ + { event: 'PreToolUse', command: './a.sh' }, + { event: 'Stop', command: './b.sh' }, + ], + }); + const manager = new PluginManager({ kimiHomeDir: home }); + await manager.load(); + await manager.install(root); + expect(manager.summaries()[0]?.hookCount).toBe(2); + }); }); interface MockGithubFetchOptions { diff --git a/packages/agent-core/test/plugin/manifest.test.ts b/packages/agent-core/test/plugin/manifest.test.ts index 4a436c4beb..5962e8bb59 100644 --- a/packages/agent-core/test/plugin/manifest.test.ts +++ b/packages/agent-core/test/plugin/manifest.test.ts @@ -223,7 +223,6 @@ describe('parseManifest', () => { config_file: 'legacy-cfg.json', inject: { foo: 'bar' }, bootstrap: { skill: 'using-demo' }, - hooks: { sessionStart: { skill: 'using-demo' } }, apps: './apps', }), }); @@ -236,7 +235,6 @@ describe('parseManifest', () => { 'config_file', 'inject', 'bootstrap', - 'hooks', 'apps', ]) { expect(result.diagnostics).toContainEqual( @@ -366,4 +364,59 @@ describe('parseManifest', () => { expect(result.manifest?.interface?.displayName).toBe('Demo'); expect(result.manifest?.interface?.shortDescription).toBe('A demo.'); }); + + it('parses a flat hooks array from the manifest', async () => { + const root = await makePlugin({ + 'kimi.plugin.json': JSON.stringify({ + name: 'demo', + hooks: [ + { event: 'PreToolUse', matcher: 'Bash', command: './hooks/guard.sh', timeout: 10 }, + { event: 'UserPromptSubmit', command: 'node ./hooks/log.js' }, + ], + }), + }); + const result = await parseManifest(root); + expect(result.diagnostics).toEqual([]); + expect(result.manifest?.hooks).toEqual([ + { event: 'PreToolUse', matcher: 'Bash', command: './hooks/guard.sh', timeout: 10 }, + { event: 'UserPromptSubmit', command: 'node ./hooks/log.js' }, + ]); + }); + + it('warns and skips a hook entry that is missing required fields', async () => { + const root = await makePlugin({ + 'kimi.plugin.json': JSON.stringify({ + name: 'demo', + hooks: [{ event: 'PreToolUse' }], + }), + }); + const result = await parseManifest(root); + expect(result.manifest?.hooks).toBeUndefined(); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ severity: 'warn', message: expect.stringContaining('index 0') }), + ); + }); + + it('warns when hooks is not an array', async () => { + const root = await makePlugin({ + 'kimi.plugin.json': JSON.stringify({ name: 'demo', hooks: { event: 'Stop', command: 'x' } }), + }); + const result = await parseManifest(root); + expect(result.manifest?.hooks).toBeUndefined(); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ severity: 'warn', message: '"hooks" must be an array' }), + ); + }); + + it('rejects a hook entry that sets cwd/env (strict schema)', async () => { + const root = await makePlugin({ + 'kimi.plugin.json': JSON.stringify({ + name: 'demo', + hooks: [{ event: 'PreToolUse', command: './x.sh', cwd: '/tmp' }], + }), + }); + const result = await parseManifest(root); + expect(result.manifest?.hooks).toBeUndefined(); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ severity: 'warn' })); + }); });