diff --git a/.changeset/hooks-session-dir-sessionstart.md b/.changeset/hooks-session-dir-sessionstart.md new file mode 100644 index 000000000..9b4430698 --- /dev/null +++ b/.changeset/hooks-session-dir-sessionstart.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kimi-code-sdk": minor +"@moonshot-ai/kimi-code": minor +--- + +Expose `session_dir` in every hook payload and inject non-empty `SessionStart` hook output into the main agent context, so external integrations (such as persistent-memory hooks) can locate session artifacts and add a recall block at session start. Timed-out or non-zero-exit `SessionStart` hook output is ignored. diff --git a/docs/en/customization/hooks.md b/docs/en/customization/hooks.md index 1f966e341..ae105406a 100644 --- a/docs/en/customization/hooks.md +++ b/docs/en/customization/hooks.md @@ -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 `/agents/main/wire.jsonl`). + 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 @@ -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) | diff --git a/docs/zh/customization/hooks.md b/docs/zh/customization/hooks.md index a506923b9..972755945 100644 --- a/docs/zh/customization/hooks.md +++ b/docs/zh/customization/hooks.md @@ -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 日志 `/agents/main/wire.jsonl`)。 + 具体事件还会附带额外字段(如工具名称、命令内容),见下方事件一览。所有字段名使用下划线命名(snake_case)。 ## 返回值 @@ -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 成功完成后触发(观察用) | diff --git a/packages/agent-core/src/session/hooks/engine.ts b/packages/agent-core/src/session/hooks/engine.ts index 832ecd620..07fc9fc8e 100644 --- a/packages/agent-core/src/session/hooks/engine.ts +++ b/packages/agent-core/src/session/hooks/engine.ts @@ -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); diff --git a/packages/agent-core/src/session/hooks/types.ts b/packages/agent-core/src/session/hooks/types.ts index cf05ca747..76c97900b 100644 --- a/packages/agent-core/src/session/hooks/types.ts +++ b/packages/agent-core/src/session/hooks/types.ts @@ -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; } diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index ca8c531a9..cee4f70d5 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -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'; @@ -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; @@ -683,10 +685,33 @@ export class Session { } private async triggerSessionStart(source: 'startup' | 'resume'): Promise { - 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(); + 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 { diff --git a/packages/agent-core/test/session/lifecycle-hooks.test.ts b/packages/agent-core/test/session/lifecycle-hooks.test.ts index c2d5041e3..e4f52fb01 100644 --- a/packages/agent-core/test/session/lifecycle-hooks.test.ts +++ b/packages/agent-core/test/session/lifecycle-hooks.test.ts @@ -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<{