Skip to content
Open
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
8 changes: 6 additions & 2 deletions docs/en/customization/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,14 @@ Each time a hook triggers, the CLI passes the following base information to the
{
"hook_event_name": "PreToolUse",
"session_id": "session_abc",
"cwd": "/path/to/project"
"cwd": "/path/to/project",
"session_dir": "/path/to/kimi-code/home/sessions/..."
}
```

- `hook_event_name`, `session_id`, and `cwd` are always present.
- `session_dir` is the session's persistent directory under `KIMI_CODE_HOME`; it is present for all events and can be used by integrations that need to read session artifacts (for example, the active agent's wire log at `<session_dir>/agents/main/wire.jsonl`).
Comment thread
AzazelSensei marked this conversation as resolved.

Specific events will also include additional fields (such as tool name and command content); see the event reference below. All field names use snake_case.

## Return Values
Expand Down Expand Up @@ -105,7 +109,7 @@ Only **blockable events** (`PreToolUse`, `Stop`, `UserPromptSubmit`) have return
| `PostToolUseFailure` | Tool name | — | Triggered after a tool fails or is blocked (observation only) |
| `PermissionRequest` | Tool name | — | Triggered just before waiting for user approval (observation only) |
| `PermissionResult` | Tool name | — | Triggered after approval completes (observation only) |
| `SessionStart` | `startup` or `resume` | — | Triggered after a new session starts or a previous session resumes |
| `SessionStart` | `startup` or `resume` | — | Triggered after a new session starts or a previous session resumes; non-empty stdout or `message` is appended to the main agent context (since v0.14.0). It cannot block startup |
| `SessionEnd` | `exit` | — | Triggered after a session closes |
| `SubagentStart` | Sub-agent name | — | Triggered before a sub-agent starts running |
| `SubagentStop` | Sub-agent name | — | Triggered after a sub-agent completes successfully (observation only) |
Expand Down
8 changes: 6 additions & 2 deletions docs/zh/customization/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,14 @@ Hook 命令的工作目录是当前会话的项目目录。非 Windows 平台上
{
"hook_event_name": "PreToolUse",
"session_id": "session_abc",
"cwd": "/path/to/project"
"cwd": "/path/to/project",
"session_dir": "/path/to/kimi-code/home/sessions/..."
}
```

- `hook_event_name`、`session_id` 和 `cwd` 始终存在。
- `session_dir` 是会话在 `KIMI_CODE_HOME` 下的持久化目录;所有事件都会带上它,需要读取会话产物的集成可以用它(例如主 agent 的 wire 日志 `<session_dir>/agents/main/wire.jsonl`)。

具体事件还会附带额外字段(如工具名称、命令内容),见下方事件一览。所有字段名使用下划线命名(snake_case)。

## 返回值
Expand Down Expand Up @@ -105,7 +109,7 @@ Hook 命令的工作目录是当前会话的项目目录。非 Windows 平台上
| `PostToolUseFailure` | 工具名 | — | 工具失败或被阻断后触发(观察用) |
| `PermissionRequest` | 工具名 | — | 即将等待用户审批前触发(观察用) |
| `PermissionResult` | 工具名 | — | 审批结束后触发(观察用) |
| `SessionStart` | `startup` 或 `resume` | — | 新会话启动或历史会话恢复后触发 |
| `SessionStart` | `startup` 或 `resume` | — | 新会话启动或历史会话恢复后触发;非空 stdout 或 `message` 会追加到主 Agent 上下文(自 v0.14.0),无法阻断启动 |
| `SessionEnd` | `exit` | — | 会话关闭后触发 |
| `SubagentStart` | 子 Agent 名称 | — | 子 Agent 开始运行前触发 |
| `SubagentStop` | 子 Agent 名称 | — | 子 Agent 成功完成后触发(观察用) |
Expand Down
1 change: 1 addition & 0 deletions packages/agent-core/src/session/hooks/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export class HookEngine {
hookEventName: event,
sessionId: this.options.sessionId ?? '',
cwd: this.options.cwd ?? '',
sessionDir: this.options.sessionDir ?? '',
...args.inputData,
});
const matched = this.matchingHooks(event, matcherValue);
Expand Down
1 change: 1 addition & 0 deletions packages/agent-core/src/session/hooks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export type HookResolvedCallback = (
export interface HookEngineOptions {
readonly cwd?: string;
readonly sessionId?: string;
readonly sessionDir?: string;
readonly onTriggered?: HookTriggeredCallback;
readonly onResolved?: HookResolvedCallback;
}
27 changes: 26 additions & 1 deletion packages/agent-core/src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { proxyWithExtraPayload } from '#/rpc/types';

import { Agent, type AgentOptions, type AgentType } from '../agent';
import { HookEngine, type HookDef } from './hooks';
import { renderHookResult } from './hooks/user-prompt';
import type { PermissionManagerOptions, PermissionRule } from '../agent/permission';
import { parseBooleanEnv, resolveConfigValue, type BackgroundConfig } from '../config';
import { makeErrorPayload } from '../errors';
Expand Down Expand Up @@ -178,6 +179,7 @@ export class Session {
this.hookEngine = new HookEngine(options.hooks, {
cwd: options.kaos.getcwd(),
sessionId: options.id,
sessionDir: options.homedir,
});
this.telemetry = options.telemetry ?? noopTelemetryClient;
this.toolKaos = options.kaos;
Expand Down Expand Up @@ -683,10 +685,33 @@ export class Session {
}

private async triggerSessionStart(source: 'startup' | 'resume'): Promise<void> {
await this.hookEngine.trigger('SessionStart', {
const results = await this.hookEngine.trigger('SessionStart', {
matcherValue: source,
inputData: { source },
});

const messages: string[] = [];
for (const result of results) {
if (result.action !== 'allow') continue;
if (result.timedOut === true || (result.exitCode !== undefined && result.exitCode !== 0)) {
continue;
}
const text = (result.message ?? result.stdout ?? '').trim();
Comment thread
AzazelSensei marked this conversation as resolved.
if (text.length > 0) messages.push(text);
}

if (messages.length === 0) return;

const mainAgent = this.getReadyAgent('main');
if (mainAgent === undefined) return;

const block = messages.map((message) => renderHookResult('SessionStart', message)).join('\n');
mainAgent.context.appendMessage({
role: 'assistant',
content: [{ type: 'text', text: block }],
toolCalls: [],
origin: { kind: 'hook_result', event: 'SessionStart', blocked: false },
});
}

private async triggerSessionEnd(reason: 'exit'): Promise<void> {
Expand Down
103 changes: 103 additions & 0 deletions packages/agent-core/test/session/lifecycle-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,109 @@ describe('Session lifecycle hooks', () => {
},
]);
});

it('includes session_dir in hook payload', async () => {
const { command, logPath, sessionDir, workDir } = await hookFixture();
const session = new Session({
kaos: testKaos.withCwd(workDir),
id: 'session-dir-payload',
homedir: sessionDir,
rpc: createSessionRpc(),
skills: { explicitDirs: [join(workDir, 'missing-skills')] },
hooks: [{ event: 'SessionStart', matcher: 'startup', command, timeout: 5 }],
});

await session.createMain();

expect(await readHookPayloads(logPath)).toMatchObject([
{
hook_event_name: 'SessionStart',
session_id: 'session-dir-payload',
cwd: workDir,
session_dir: sessionDir,
source: 'startup',
},
]);
await session.close();
});

it('injects SessionStart hook stdout into the main agent context', async () => {
const { sessionDir, workDir } = await hookFixture();
const hookScriptPath = join(workDir, 'session-start-stdout.cjs');
await writeFile(
hookScriptPath,
"process.stdout.write('recall-block-from-session-start-hook');",
'utf-8',
);
const session = new Session({
kaos: testKaos.withCwd(workDir),
id: 'session-start-inject',
homedir: sessionDir,
rpc: createSessionRpc(),
skills: { explicitDirs: [join(workDir, 'missing-skills')] },
hooks: [
{
event: 'SessionStart',
matcher: 'startup',
command: `node ${JSON.stringify(hookScriptPath)}`,
timeout: 5,
},
],
});

await session.createMain();

const mainAgent = session.getReadyAgent('main');
const injected = mainAgent?.context.history.find(
(message) =>
message.origin?.kind === 'hook_result' &&
message.origin?.event === 'SessionStart' &&
message.content.some(
(part) => part.type === 'text' && part.text.includes('recall-block-from-session-start-hook'),
),
);
expect(injected).toBeDefined();
await session.close();
});

it('does not inject stdout from a failing SessionStart hook', async () => {
const { sessionDir, workDir } = await hookFixture();
const hookScriptPath = join(workDir, 'session-start-fail.cjs');
await writeFile(
hookScriptPath,
"process.stdout.write('should-not-be-injected'); process.exit(1);",
'utf-8',
);
const session = new Session({
kaos: testKaos.withCwd(workDir),
id: 'session-start-fail',
homedir: sessionDir,
rpc: createSessionRpc(),
skills: { explicitDirs: [join(workDir, 'missing-skills')] },
hooks: [
{
event: 'SessionStart',
matcher: 'startup',
command: `node ${JSON.stringify(hookScriptPath)}`,
timeout: 5,
},
],
});

await session.createMain();

const mainAgent = session.getReadyAgent('main');
const injected = mainAgent?.context.history.find(
(message) =>
message.origin?.kind === 'hook_result' &&
message.origin?.event === 'SessionStart' &&
message.content.some(
(part) => part.type === 'text' && part.text.includes('should-not-be-injected'),
),
);
expect(injected).toBeUndefined();
await session.close();
});
});

async function hookFixture(): Promise<{
Expand Down