feat: support hooks in plugins (#1127)

* feat(agent-core): support per-hook cwd and env in HookEngine

* feat(agent-core): support hooks in plugin manifest and aggregate via PluginManager

* feat(agent-core): merge plugin hooks into session hook engine

* chore: add changeset for plugin hooks
This commit is contained in:
qer 2026-06-26 17:02:14 +08:00 committed by GitHub
parent 28d358b526
commit 184acf5db5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 220 additions and 11 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---
Plugins can now declare hooks in their manifest to run scripts on lifecycle events.

View file

@ -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,

View file

@ -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<ParsedManifestR
skills,
sessionStart: readSessionStart(raw['sessionStart'], diagnostics),
mcpServers: await readMcpServers(pluginRoot, raw['mcpServers'], diagnostics),
hooks: readHooks(raw['hooks'], diagnostics),
interface: readInterface(raw['interface']),
skillInstructions,
};
@ -284,6 +289,30 @@ async function readMcpServers(
return Object.keys(out).length === 0 ? undefined : out;
}
function readHooks(
raw: unknown,
diagnostics: PluginDiagnostic[],
): readonly HookDefConfig[] | undefined {
if (raw === undefined) return undefined;
if (!Array.isArray(raw)) {
diagnostics.push({ severity: 'warn', message: '"hooks" must be an array' });
return undefined;
}
const out: HookDefConfig[] = [];
raw.forEach((entry, i) => {
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;

View file

@ -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<Record<string, McpServerConfig>>;
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;

View file

@ -281,7 +281,7 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
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<CoreAPI> {
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,

View file

@ -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<string>();
const seen = new Set<string>();
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);
}

View file

@ -7,6 +7,7 @@ import type { HookResult } from './types';
export interface RunHookOptions {
readonly timeout: number;
readonly cwd?: string;
readonly env?: Readonly<Record<string, string>>;
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) });

View file

@ -26,6 +26,8 @@ export interface HookDef {
readonly matcher?: string;
readonly command: string;
readonly timeout?: number;
readonly cwd?: string;
readonly env?: Readonly<Record<string, string>>;
}
export interface HookResult {

View file

@ -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<Record<string, string>>;
};
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);
});
});

View file

@ -23,6 +23,7 @@ async function makePlugin(
version?: string;
sessionStartSkill?: string;
mcpServers?: Record<string, unknown>;
hooks?: readonly unknown[];
} = {},
): Promise<string> {
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 {

View file

@ -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' }));
});
});