Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/plugin-hooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Plugins can now declare hooks in their manifest to run scripts on lifecycle events.
17 changes: 17 additions & 0 deletions packages/agent-core/src/plugin/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -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,
Expand Down
33 changes: 31 additions & 2 deletions packages/agent-core/src/plugin/manifest.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -19,7 +24,6 @@ const KIMI_PLUGIN_DIR_PATH = '.kimi-plugin/plugin.json';
const UNSUPPORTED_RUNTIME_FIELDS = [
'tools',
'commands',
'hooks',
'apps',
'inject',
'configFile',
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion packages/agent-core/src/plugin/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { McpServerConfig } from '../config/schema';
import type { HookDefConfig, McpServerConfig } from '../config/schema';

export type PluginDiagnosticSeverity = 'error' | 'warn' | 'info';

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions packages/agent-core/src/rpc/core-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 6 additions & 4 deletions packages/agent-core/src/session/hooks/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
),
Expand All @@ -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);
}

Expand Down
2 changes: 2 additions & 0 deletions packages/agent-core/src/session/hooks/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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) });
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-core/src/session/hooks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
47 changes: 47 additions & 0 deletions packages/agent-core/test/hooks/engine.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -10,6 +13,8 @@ type HookDef = {
matcher?: string;
command: string;
timeout?: number;
cwd?: string;
env?: Readonly<Record<string, string>>;
};

interface HookResult {
Expand Down Expand Up @@ -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);
});
});
50 changes: 50 additions & 0 deletions packages/agent-core/test/plugin/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}-`));
Expand All @@ -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),
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading