diff --git a/.changeset/bright-cars-race.md b/.changeset/bright-cars-race.md new file mode 100644 index 00000000000..19b5cc94052 --- /dev/null +++ b/.changeset/bright-cars-race.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Add CLI lifecycle hooks for custom automation commands. diff --git a/cli/README.md b/cli/README.md index c02964206eb..ae2bc818d1c 100644 --- a/cli/README.md +++ b/cli/README.md @@ -128,6 +128,31 @@ kilocode --auto "Refactor the auth module" --on-task-completed "Commit all chang - The agent has 90 seconds to complete the follow-up action - Supports markdown, special characters, and multi-line prompts +#### Hooks Configuration + +You can register lifecycle hooks in your CLI config (`~/.kilocode/cli/config.json` or `/.kilocode/cli/config.json`). Project hooks are appended after global hooks. + +```json +{ + "hooks": { + "UserPromptSubmit": [ + { + "matcher": "*", + "hooks": [{ "type": "command", "command": "echo \"$KILO_HOOK\" >> /tmp/kilo.log" }] + } + ], + "PreToolUse": [ + { + "matcher": "Write|Edit", + "hooks": [{ "type": "command", "command": "node ./scripts/validate-tool.js", "timeout": 30000 }] + } + ] + } +} +``` + +Supported hook events: `PreToolUse`, `PostToolUse`, `PermissionRequest`, `Notification`, `UserPromptSubmit`, `Stop`, `PreCompact`, `SessionStart`, `SessionEnd`. + #### Autonomous mode Behavior When running in Autonomous mode (`--auto` flag): diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 1c1989e564e..5fd4d69649c 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -38,6 +38,8 @@ import { getKiloToken } from "./config/persistence.js" import { SessionManager } from "../../src/shared/kilocode/cli-sessions/core/SessionManager.js" import { triggerExitConfirmationAtom } from "./state/atoms/keyboard.js" import { randomUUID } from "crypto" +import { loadHooks, runSessionStartHooks, runSessionEndHooks, type HooksConfig } from "./hooks/index.js" +import { setHooksConfigAtom, setHooksSessionIdAtom, setHooksWorkspaceAtom } from "./state/atoms/hooks.js" /** * Main application class that orchestrates the CLI lifecycle @@ -49,11 +51,34 @@ export class CLI { private options: CLIOptions private isInitialized = false private sessionService: SessionManager | null = null + private hooksConfig: HooksConfig = {} + private sessionId: string | null = null constructor(options: CLIOptions = {}) { this.options = options } + /** + * Get the loaded hooks configuration + */ + getHooksConfig(): HooksConfig { + return this.hooksConfig + } + + /** + * Get the current session ID + */ + getSessionId(): string | null { + return this.sessionId + } + + /** + * Get the workspace path + */ + getWorkspace(): string { + return this.options.workspace || process.cwd() + } + /** * Initialize the application * - Creates ExtensionService @@ -91,6 +116,17 @@ export class CLI { let config = await this.store.set(loadConfigAtom, this.options.mode) logs.debug("CLI configuration loaded", "CLI", { mode: this.options.mode }) + // Load lifecycle hooks from global and project configs + const workspace = this.options.workspace || process.cwd() + this.hooksConfig = await loadHooks(workspace) + logs.debug("Hooks configuration loaded", "CLI", { + events: Object.keys(this.hooksConfig), + }) + + // Set hooks config in store for access from atoms and hooks + this.store.set(setHooksConfigAtom, this.hooksConfig) + this.store.set(setHooksWorkspaceAtom, workspace) + // Apply provider and model overrides from CLI if (this.options.provider || this.options.model) { config = await this.applyProviderModelOverrides(config) @@ -326,6 +362,19 @@ export class CLI { await this.resumeLastConversation() } + // Generate session ID for hooks context + this.sessionId = randomUUID() + this.store.set(setHooksSessionIdAtom, this.sessionId) + + // Run SessionStart hooks + const isResume = Boolean(this.options.continue || this.options.session || this.options.fork) + await runSessionStartHooks(this.hooksConfig, { + workspace: this.options.workspace || process.cwd(), + session_id: this.sessionId, + isResume, + }) + logs.debug("SessionStart hooks executed", "CLI", { isResume }) + this.isInitialized = true logs.info("Kilo Code CLI initialized successfully", "CLI") } catch (error) { @@ -452,6 +501,18 @@ export class CLI { try { logs.info("Disposing Kilo Code CLI...", "CLI") + // Run SessionEnd hooks before cleanup + const exitReason = signal || (this.options.ci ? "ci_exit" : "user_exit") + const sessionEndInput: { workspace: string; reason: string; session_id?: string } = { + workspace: this.options.workspace || process.cwd(), + reason: exitReason, + } + if (this.sessionId) { + sessionEndInput.session_id = this.sessionId + } + await runSessionEndHooks(this.hooksConfig, sessionEndInput) + logs.debug("SessionEnd hooks executed", "CLI", { reason: exitReason }) + await this.sessionService?.doSync(true) // Signal codes take precedence over CI logic diff --git a/cli/src/config/__tests__/persistence.test.ts b/cli/src/config/__tests__/persistence.test.ts index d08da67d1af..8d60121e3bb 100644 --- a/cli/src/config/__tests__/persistence.test.ts +++ b/cli/src/config/__tests__/persistence.test.ts @@ -159,6 +159,7 @@ describe("Config Persistence", () => { ], autoApproval: DEFAULT_CONFIG.autoApproval, customThemes: {}, + hooks: {}, } await saveConfig(testConfig) diff --git a/cli/src/config/defaults.ts b/cli/src/config/defaults.ts index 82cac00f1e8..cac49d85743 100644 --- a/cli/src/config/defaults.ts +++ b/cli/src/config/defaults.ts @@ -1,4 +1,9 @@ -import type { CLIConfig, AutoApprovalConfig } from "./types.js" +import type { CLIConfig, AutoApprovalConfig, HooksConfig } from "./types.js" + +/** + * Default hooks configuration (empty - no hooks registered by default) + */ +export const DEFAULT_HOOKS: HooksConfig = {} /** * Default auto approval configuration @@ -59,6 +64,7 @@ export const DEFAULT_CONFIG = { }, ], autoApproval: DEFAULT_AUTO_APPROVAL, + hooks: DEFAULT_HOOKS, theme: "dark", customThemes: {}, } satisfies CLIConfig diff --git a/cli/src/config/types.ts b/cli/src/config/types.ts index a0ce32b580d..1fd1a903bb8 100644 --- a/cli/src/config/types.ts +++ b/cli/src/config/types.ts @@ -10,9 +10,124 @@ import type { ProviderConfig as CoreProviderConfig, CLIConfig as CoreCLIConfig } // ProviderConfig with index signature for dynamic property access (backward compatibility) export type ProviderConfig = CoreProviderConfig & { [key: string]: unknown } -// CLIConfig with our enhanced ProviderConfig type +// ============================================ +// HOOKS TYPES - Claude Code compatible hooks +// ============================================ + +/** + * Hook event types supported by Kilo CLI + * Matches Claude Code hook events where applicable + */ +export type HookEvent = + | "PreToolUse" // Runs before tool calls (can block them) + | "PostToolUse" // Runs after tool calls complete + | "PermissionRequest" // Runs when a permission dialog is shown (can allow or deny) + | "Notification" // Runs when notifications are sent + | "UserPromptSubmit" // Runs when user submits a prompt, before processing + | "Stop" // Runs when the agent finishes responding + | "PreCompact" // Runs before a compact/condense operation + | "SessionStart" // Runs when a session starts or resumes + | "SessionEnd" // Runs when a session ends + +/** + * Individual hook command definition + * Matches Claude Code hook command structure + */ +export interface HookCommand { + /** Type of hook - currently only "command" is supported */ + type: "command" + /** Shell command to execute. Receives JSON input on stdin. */ + command: string + /** Optional timeout in milliseconds (default: 30000) */ + timeout?: number +} + +/** + * Hook matcher entry - matches specific tools/patterns for an event + * Matches Claude Code matcher structure + */ +export interface HookMatcher { + /** + * Pattern to match against tool names or other event-specific identifiers + * - Empty string or "*" matches all + * - Pipe-separated values match any (e.g., "Edit|Write") + * - Exact string matches that specific tool/identifier + */ + matcher: string + /** Array of hooks to execute when matcher matches */ + hooks: HookCommand[] +} + +/** + * Hooks configuration - maps events to matchers + * Matches Claude Code hooks configuration structure + */ +export interface HooksConfig { + PreToolUse?: HookMatcher[] + PostToolUse?: HookMatcher[] + PermissionRequest?: HookMatcher[] + Notification?: HookMatcher[] + UserPromptSubmit?: HookMatcher[] + Stop?: HookMatcher[] + PreCompact?: HookMatcher[] + SessionStart?: HookMatcher[] + SessionEnd?: HookMatcher[] +} + +/** + * Hook execution result from a hook command + */ +export interface HookResult { + /** Exit code from the hook command */ + exitCode: number + /** Stdout from the hook command */ + stdout: string + /** Stderr from the hook command */ + stderr: string + /** Parsed JSON decision from stdout (if valid JSON) */ + decision?: HookDecision +} + +/** + * Hook decision returned via JSON stdout + * Matches Claude Code hook decision structure + */ +export interface HookDecision { + /** Permission decision - "allow" or "deny" */ + permissionDecision?: "allow" | "deny" + /** Reason for the decision (shown to user/agent) */ + permissionDecisionReason?: string + /** Additional data to pass back */ + [key: string]: unknown +} + +/** + * Input data passed to hooks via stdin + */ +export interface HookInput { + /** The hook event type */ + hook_event: HookEvent + /** Tool name (for tool-related events) */ + tool_name?: string + /** Tool input parameters (for PreToolUse) */ + tool_input?: Record + /** Tool output/result (for PostToolUse) */ + tool_output?: unknown + /** User prompt text (for UserPromptSubmit) */ + prompt?: string + /** Session ID */ + session_id?: string + /** Workspace path */ + workspace?: string + /** Additional event-specific data */ + [key: string]: unknown +} + +// CLIConfig with our enhanced ProviderConfig type and hooks support export interface CLIConfig extends Omit { providers: ProviderConfig[] + /** Lifecycle hooks configuration */ + hooks?: HooksConfig } // Re-export all config types from core-schemas diff --git a/cli/src/hooks/__tests__/config.test.ts b/cli/src/hooks/__tests__/config.test.ts new file mode 100644 index 00000000000..ab9c51b1e9c --- /dev/null +++ b/cli/src/hooks/__tests__/config.test.ts @@ -0,0 +1,255 @@ +/** + * Tests for hooks configuration loading and merging + */ + +import { describe, it, expect, beforeEach, vi } from "vitest" +import type { HooksConfig } from "../../config/types.js" + +// Mock fs/promises +const mockReadFile = vi.fn() +const mockExistsSync = vi.fn() + +vi.mock("fs/promises", () => ({ + readFile: (...args: unknown[]) => mockReadFile(...args), +})) + +vi.mock("fs", () => ({ + existsSync: (...args: unknown[]) => mockExistsSync(...args), +})) + +vi.mock("os", () => ({ + homedir: () => "/mock/home", +})) + +vi.mock("../../services/logs.js", () => ({ + logs: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})) + +// Import after mocks +const { loadHooks, mergeHooksConfigs, getHooksForEvent, getProjectConfigPath, GLOBAL_CONFIG_FILE } = await import( + "../config.js" +) + +describe("Hooks Configuration", () => { + beforeEach(() => { + vi.clearAllMocks() + mockExistsSync.mockReturnValue(false) + mockReadFile.mockResolvedValue("{}") + }) + + describe("getProjectConfigPath", () => { + it("should return correct path for workspace", () => { + const path = getProjectConfigPath("/my/workspace") + expect(path).toBe("/my/workspace/.kilocode/cli/config.json") + }) + }) + + describe("GLOBAL_CONFIG_FILE", () => { + it("should use home directory", () => { + expect(GLOBAL_CONFIG_FILE).toBe("/mock/home/.kilocode/cli/config.json") + }) + }) + + describe("loadHooks", () => { + it("should return empty hooks when no config files exist", async () => { + mockExistsSync.mockReturnValue(false) + + const hooks = await loadHooks("/workspace") + + expect(hooks).toEqual({}) + }) + + it("should load hooks from global config", async () => { + mockExistsSync.mockImplementation((path: string) => path === GLOBAL_CONFIG_FILE) + mockReadFile.mockResolvedValue( + JSON.stringify({ + hooks: { + PreToolUse: [ + { + matcher: "Bash", + hooks: [{ type: "command", command: "echo test" }], + }, + ], + }, + }), + ) + + const hooks = await loadHooks("/workspace") + + expect(hooks.PreToolUse).toHaveLength(1) + expect(hooks.PreToolUse![0].matcher).toBe("Bash") + }) + + it("should load hooks from project config", async () => { + const projectPath = "/workspace/.kilocode/cli/config.json" + mockExistsSync.mockImplementation((path: string) => path === projectPath) + mockReadFile.mockResolvedValue( + JSON.stringify({ + hooks: { + PostToolUse: [ + { + matcher: "Edit|Write", + hooks: [{ type: "command", command: "prettier --write" }], + }, + ], + }, + }), + ) + + const hooks = await loadHooks("/workspace") + + expect(hooks.PostToolUse).toHaveLength(1) + expect(hooks.PostToolUse![0].matcher).toBe("Edit|Write") + }) + + it("should merge global and project hooks", async () => { + mockExistsSync.mockReturnValue(true) + + // First call is global, second is project + let callCount = 0 + mockReadFile.mockImplementation(() => { + callCount++ + if (callCount === 1) { + // Global config + return JSON.stringify({ + hooks: { + PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "global" }] }], + }, + }) + } else { + // Project config + return JSON.stringify({ + hooks: { + PreToolUse: [{ matcher: "Edit", hooks: [{ type: "command", command: "project" }] }], + }, + }) + } + }) + + const hooks = await loadHooks("/workspace") + + // Both matchers should be present + expect(hooks.PreToolUse).toHaveLength(2) + }) + + it("should handle invalid JSON gracefully", async () => { + mockExistsSync.mockReturnValue(true) + mockReadFile.mockResolvedValue("not valid json") + + const hooks = await loadHooks("/workspace") + + expect(hooks).toEqual({}) + }) + + it("should skip invalid hook entries", async () => { + // Only mock global config file as existing + mockExistsSync.mockImplementation((path: string) => path === GLOBAL_CONFIG_FILE) + mockReadFile.mockResolvedValue( + JSON.stringify({ + hooks: { + PreToolUse: [ + // Valid entry + { matcher: "Bash", hooks: [{ type: "command", command: "echo test" }] }, + // Invalid: missing hooks array + { matcher: "Edit" }, + // Invalid: empty hooks array + { matcher: "Write", hooks: [] }, + // Invalid: wrong type + { matcher: "Read", hooks: [{ type: "invalid", command: "test" }] }, + ], + }, + }), + ) + + const hooks = await loadHooks("/workspace") + + // Only the valid entry should remain + expect(hooks.PreToolUse).toHaveLength(1) + expect(hooks.PreToolUse![0].matcher).toBe("Bash") + }) + }) + + describe("mergeHooksConfigs", () => { + it("should merge two empty configs", () => { + const merged = mergeHooksConfigs({}, {}) + expect(merged).toEqual({}) + }) + + it("should preserve global hooks when project is empty", () => { + const global: HooksConfig = { + PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "global" }] }], + } + + const merged = mergeHooksConfigs(global, {}) + + expect(merged.PreToolUse).toHaveLength(1) + expect(merged.PreToolUse![0].hooks[0].command).toBe("global") + }) + + it("should preserve project hooks when global is empty", () => { + const project: HooksConfig = { + PreToolUse: [{ matcher: "Edit", hooks: [{ type: "command", command: "project" }] }], + } + + const merged = mergeHooksConfigs({}, project) + + expect(merged.PreToolUse).toHaveLength(1) + expect(merged.PreToolUse![0].hooks[0].command).toBe("project") + }) + + it("should concatenate hooks from both configs", () => { + const global: HooksConfig = { + PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "global" }] }], + } + const project: HooksConfig = { + PreToolUse: [{ matcher: "Edit", hooks: [{ type: "command", command: "project" }] }], + } + + const merged = mergeHooksConfigs(global, project) + + expect(merged.PreToolUse).toHaveLength(2) + // Global should come first + expect(merged.PreToolUse![0].hooks[0].command).toBe("global") + // Project should come second + expect(merged.PreToolUse![1].hooks[0].command).toBe("project") + }) + + it("should merge different event types", () => { + const global: HooksConfig = { + PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "pre" }] }], + } + const project: HooksConfig = { + PostToolUse: [{ matcher: "Edit", hooks: [{ type: "command", command: "post" }] }], + } + + const merged = mergeHooksConfigs(global, project) + + expect(merged.PreToolUse).toHaveLength(1) + expect(merged.PostToolUse).toHaveLength(1) + }) + }) + + describe("getHooksForEvent", () => { + it("should return empty array for missing event", () => { + const hooks: HooksConfig = {} + const matchers = getHooksForEvent(hooks, "PreToolUse") + expect(matchers).toEqual([]) + }) + + it("should return matchers for existing event", () => { + const hooks: HooksConfig = { + PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "test" }] }], + } + + const matchers = getHooksForEvent(hooks, "PreToolUse") + + expect(matchers).toHaveLength(1) + expect(matchers[0].matcher).toBe("Bash") + }) + }) +}) diff --git a/cli/src/hooks/__tests__/runner.test.ts b/cli/src/hooks/__tests__/runner.test.ts new file mode 100644 index 00000000000..60e3602c629 --- /dev/null +++ b/cli/src/hooks/__tests__/runner.test.ts @@ -0,0 +1,292 @@ +/** + * Tests for hooks execution runner + */ + +import { describe, it, expect, vi, beforeEach } from "vitest" +import type { HooksConfig } from "../../config/types.js" + +// Mock child_process +const mockSpawn = vi.fn() + +vi.mock("child_process", () => ({ + spawn: (...args: unknown[]) => mockSpawn(...args), +})) + +vi.mock("../../services/logs.js", () => ({ + logs: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})) + +// Import after mocks +const { + matchesPattern, + runHooks, + runPreToolUseHooks, + runPostToolUseHooks, + runUserPromptSubmitHooks, + runStopHooks, + runSessionStartHooks, + runSessionEndHooks, +} = await import("../runner.js") + +describe("Hooks Runner", () => { + describe("matchesPattern", () => { + it("should match empty string to any target", () => { + expect(matchesPattern("", "Bash")).toBe(true) + expect(matchesPattern("", "Edit")).toBe(true) + expect(matchesPattern("", "")).toBe(true) + }) + + it("should match asterisk to any target", () => { + expect(matchesPattern("*", "Bash")).toBe(true) + expect(matchesPattern("*", "Edit")).toBe(true) + expect(matchesPattern("*", "anything")).toBe(true) + }) + + it("should match exact string", () => { + expect(matchesPattern("Bash", "Bash")).toBe(true) + expect(matchesPattern("Bash", "Edit")).toBe(false) + }) + + it("should be case-sensitive", () => { + expect(matchesPattern("bash", "Bash")).toBe(false) + expect(matchesPattern("Bash", "bash")).toBe(false) + }) + + it("should match pipe-separated patterns (OR)", () => { + expect(matchesPattern("Edit|Write", "Edit")).toBe(true) + expect(matchesPattern("Edit|Write", "Write")).toBe(true) + expect(matchesPattern("Edit|Write", "Bash")).toBe(false) + }) + + it("should handle multiple pipe-separated patterns", () => { + expect(matchesPattern("Edit|Write|Bash", "Bash")).toBe(true) + expect(matchesPattern("Edit|Write|Bash", "Read")).toBe(false) + }) + + it("should trim whitespace in pipe patterns", () => { + expect(matchesPattern("Edit | Write", "Edit")).toBe(true) + expect(matchesPattern("Edit | Write", "Write")).toBe(true) + }) + }) + + describe("runHooks", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("should return not blocked when no hooks configured", async () => { + const hooks: HooksConfig = {} + const result = await runHooks(hooks, "PreToolUse", "Bash", {}) + + expect(result.blocked).toBe(false) + expect(result.results).toEqual([]) + }) + + it("should return not blocked when no matchers match", async () => { + const hooks: HooksConfig = { + PreToolUse: [{ matcher: "Edit", hooks: [{ type: "command", command: "echo test" }] }], + } + + const result = await runHooks(hooks, "PreToolUse", "Bash", {}) + + expect(result.blocked).toBe(false) + expect(result.results).toEqual([]) + }) + + it("should execute matching hooks", async () => { + const hooks: HooksConfig = { + PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "echo test" }] }], + } + + // Mock spawn to simulate successful execution + let stdinWrite = "" + mockSpawn.mockImplementation(() => { + const mockChild = { + stdout: { + on: vi.fn((event, callback) => { + if (event === "data") { + callback(Buffer.from("")) + } + }), + }, + stderr: { + on: vi.fn((event, callback) => { + if (event === "data") { + callback(Buffer.from("")) + } + }), + }, + stdin: { + write: vi.fn((data: string) => { + stdinWrite = data + }), + end: vi.fn(), + }, + on: vi.fn((event, callback) => { + if (event === "close") { + // Simulate successful exit + setTimeout(() => callback(0), 10) + } + }), + kill: vi.fn(), + } + return mockChild + }) + + const result = await runHooks(hooks, "PreToolUse", "Bash", { tool_name: "Bash" }) + + expect(result.blocked).toBe(false) + expect(result.results).toHaveLength(1) + expect(result.results[0].exitCode).toBe(0) + + // Verify stdin received JSON input + const input = JSON.parse(stdinWrite) + expect(input.hook_event).toBe("PreToolUse") + expect(input.tool_name).toBe("Bash") + }) + + it("should block when exit code is 2", async () => { + const hooks: HooksConfig = { + PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "exit 2" }] }], + } + + mockSpawn.mockImplementation(() => { + const mockChild = { + stdout: { on: vi.fn() }, + stderr: { + on: vi.fn((event, callback) => { + if (event === "data") { + callback(Buffer.from("Blocked by policy")) + } + }), + }, + stdin: { write: vi.fn(), end: vi.fn() }, + on: vi.fn((event, callback) => { + if (event === "close") { + setTimeout(() => callback(2), 10) + } + }), + kill: vi.fn(), + } + return mockChild + }) + + const result = await runHooks(hooks, "PreToolUse", "Bash", {}) + + expect(result.blocked).toBe(true) + expect(result.blockReason).toContain("Blocked") + }) + + it("should block when JSON decision denies permission", async () => { + const hooks: HooksConfig = { + PermissionRequest: [{ matcher: "", hooks: [{ type: "command", command: "check-permission" }] }], + } + + mockSpawn.mockImplementation(() => { + const mockChild = { + stdout: { + on: vi.fn((event, callback) => { + if (event === "data") { + callback( + Buffer.from( + JSON.stringify({ + permissionDecision: "deny", + permissionDecisionReason: "Not allowed", + }), + ), + ) + } + }), + }, + stderr: { on: vi.fn() }, + stdin: { write: vi.fn(), end: vi.fn() }, + on: vi.fn((event, callback) => { + if (event === "close") { + setTimeout(() => callback(0), 10) + } + }), + kill: vi.fn(), + } + return mockChild + }) + + const result = await runHooks(hooks, "PermissionRequest", "tool", {}) + + expect(result.blocked).toBe(true) + expect(result.blockReason).toBe("Not allowed") + expect(result.decision?.permissionDecision).toBe("deny") + }) + }) + + describe("Helper functions", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("runPreToolUseHooks should pass correct input", async () => { + const hooks: HooksConfig = {} + const result = await runPreToolUseHooks( + hooks, + "Bash", + { command: "ls" }, + { workspace: "/test", session_id: "123" }, + ) + + expect(result.blocked).toBe(false) + }) + + it("runPostToolUseHooks should pass correct input", async () => { + const hooks: HooksConfig = {} + const result = await runPostToolUseHooks( + hooks, + "Bash", + { command: "ls" }, + { output: "file.txt" }, + { workspace: "/test" }, + ) + + expect(result.blocked).toBe(false) + }) + + it("runUserPromptSubmitHooks should pass correct input", async () => { + const hooks: HooksConfig = {} + const result = await runUserPromptSubmitHooks(hooks, "Fix the bug", { workspace: "/test" }) + + expect(result.blocked).toBe(false) + }) + + it("runStopHooks should pass correct input", async () => { + const hooks: HooksConfig = {} + const result = await runStopHooks(hooks, "completion_result", { workspace: "/test" }) + + expect(result.blocked).toBe(false) + }) + + it("runSessionStartHooks should pass correct input", async () => { + const hooks: HooksConfig = {} + const result = await runSessionStartHooks(hooks, { + workspace: "/test", + session_id: "123", + isResume: false, + }) + + expect(result.blocked).toBe(false) + }) + + it("runSessionEndHooks should pass correct input", async () => { + const hooks: HooksConfig = {} + const result = await runSessionEndHooks(hooks, { + workspace: "/test", + session_id: "123", + reason: "user_exit", + }) + + expect(result.blocked).toBe(false) + }) + }) +}) diff --git a/cli/src/hooks/config.ts b/cli/src/hooks/config.ts new file mode 100644 index 00000000000..96c058dde71 --- /dev/null +++ b/cli/src/hooks/config.ts @@ -0,0 +1,221 @@ +/** + * Hooks configuration loader + * Loads and merges hooks from global (~/.kilocode/cli/config.json) + * and project (/.kilocode/cli/config.json) configurations + */ + +import * as fs from "fs/promises" +import * as path from "path" +import { homedir } from "os" +import { existsSync } from "fs" +import type { HooksConfig, HookMatcher, HookEvent } from "../config/types.js" +import { logs } from "../services/logs.js" + +/** + * Global config directory path + */ +export const GLOBAL_CONFIG_DIR = path.join(homedir(), ".kilocode", "cli") +export const GLOBAL_CONFIG_FILE = path.join(GLOBAL_CONFIG_DIR, "config.json") + +/** + * Get project config file path + */ +export function getProjectConfigPath(workspace: string): string { + return path.join(workspace, ".kilocode", "cli", "config.json") +} + +/** + * Load hooks from a config file + * Returns empty object if file doesn't exist or has no hooks + */ +async function loadHooksFromFile(configPath: string): Promise { + if (!existsSync(configPath)) { + logs.debug(`Hooks config file not found: ${configPath}`, "HooksConfig") + return {} + } + + try { + const content = await fs.readFile(configPath, "utf-8") + const config = JSON.parse(content) + + if (!config.hooks || typeof config.hooks !== "object") { + return {} + } + + // Validate and normalize hooks structure + return normalizeHooksConfig(config.hooks) + } catch (error) { + logs.warn(`Failed to load hooks from ${configPath}`, "HooksConfig", { error }) + return {} + } +} + +/** + * Normalize and validate hooks configuration + * Ensures all hook entries have the correct structure + */ +function normalizeHooksConfig(hooks: unknown): HooksConfig { + if (!hooks || typeof hooks !== "object") { + return {} + } + + const validEvents: HookEvent[] = [ + "PreToolUse", + "PostToolUse", + "PermissionRequest", + "Notification", + "UserPromptSubmit", + "Stop", + "PreCompact", + "SessionStart", + "SessionEnd", + ] + + const normalized: HooksConfig = {} + const hooksObj = hooks as Record + + for (const event of validEvents) { + const eventHooks = hooksObj[event] + if (!Array.isArray(eventHooks)) { + continue + } + + const validMatchers: HookMatcher[] = [] + for (const matcher of eventHooks) { + const normalizedMatcher = normalizeHookMatcher(matcher) + if (normalizedMatcher) { + validMatchers.push(normalizedMatcher) + } + } + + if (validMatchers.length > 0) { + normalized[event] = validMatchers + } + } + + return normalized +} + +/** + * Normalize a single hook matcher entry + */ +function normalizeHookMatcher(matcher: unknown): HookMatcher | null { + if (!matcher || typeof matcher !== "object") { + return null + } + + const m = matcher as Record + + // Matcher string is required (can be empty string for "match all") + if (typeof m.matcher !== "string") { + return null + } + + // Hooks array is required + if (!Array.isArray(m.hooks)) { + return null + } + + const validHooks: HookMatcher["hooks"] = [] + for (const hook of m.hooks) { + if (!hook || typeof hook !== "object") { + continue + } + + const h = hook as Record + + // Type must be "command" + if (h.type !== "command") { + continue + } + + // Command must be a non-empty string + if (typeof h.command !== "string" || h.command.trim().length === 0) { + continue + } + + validHooks.push({ + type: "command", + command: h.command, + ...(typeof h.timeout === "number" && h.timeout > 0 ? { timeout: h.timeout } : {}), + }) + } + + if (validHooks.length === 0) { + return null + } + + return { + matcher: m.matcher, + hooks: validHooks, + } +} + +/** + * Merge two hooks configurations + * Project hooks are appended after global hooks for each event + */ +export function mergeHooksConfigs(global: HooksConfig, project: HooksConfig): HooksConfig { + const merged: HooksConfig = {} + + const allEvents: HookEvent[] = [ + "PreToolUse", + "PostToolUse", + "PermissionRequest", + "Notification", + "UserPromptSubmit", + "Stop", + "PreCompact", + "SessionStart", + "SessionEnd", + ] + + for (const event of allEvents) { + const globalMatchers = global[event] || [] + const projectMatchers = project[event] || [] + + if (globalMatchers.length > 0 || projectMatchers.length > 0) { + merged[event] = [...globalMatchers, ...projectMatchers] + } + } + + return merged +} + +/** + * Load hooks from both global and project configurations + * @param workspace - Workspace directory path + * @returns Merged hooks configuration + */ +export async function loadHooks(workspace?: string): Promise { + // Load global hooks + const globalHooks = await loadHooksFromFile(GLOBAL_CONFIG_FILE) + logs.debug(`Loaded global hooks`, "HooksConfig", { + events: Object.keys(globalHooks), + }) + + // Load project hooks if workspace is provided + let projectHooks: HooksConfig = {} + if (workspace) { + const projectConfigPath = getProjectConfigPath(workspace) + projectHooks = await loadHooksFromFile(projectConfigPath) + logs.debug(`Loaded project hooks from ${projectConfigPath}`, "HooksConfig", { + events: Object.keys(projectHooks), + }) + } + + // Merge configurations + const merged = mergeHooksConfigs(globalHooks, projectHooks) + logs.debug(`Merged hooks configuration`, "HooksConfig", { + events: Object.keys(merged), + }) + + return merged +} + +/** + * Get hooks for a specific event + */ +export function getHooksForEvent(hooks: HooksConfig, event: HookEvent): HookMatcher[] { + return hooks[event] || [] +} diff --git a/cli/src/hooks/index.ts b/cli/src/hooks/index.ts new file mode 100644 index 00000000000..f8af06b3767 --- /dev/null +++ b/cli/src/hooks/index.ts @@ -0,0 +1,41 @@ +/** + * Hooks module + * Claude Code compatible lifecycle hooks for Kilo CLI + */ + +// Config loading and merging +export { + loadHooks, + mergeHooksConfigs, + getHooksForEvent, + getProjectConfigPath, + GLOBAL_CONFIG_DIR, + GLOBAL_CONFIG_FILE, +} from "./config.js" + +// Hook execution +export { + runHooks, + runPreToolUseHooks, + runPostToolUseHooks, + runPermissionRequestHooks, + runUserPromptSubmitHooks, + runNotificationHooks, + runStopHooks, + runPreCompactHooks, + runSessionStartHooks, + runSessionEndHooks, + matchesPattern, + type HookEventResult, +} from "./runner.js" + +// Re-export types for convenience +export type { + HookEvent, + HooksConfig, + HookMatcher, + HookCommand, + HookInput, + HookResult, + HookDecision, +} from "../config/types.js" diff --git a/cli/src/hooks/runner.ts b/cli/src/hooks/runner.ts new file mode 100644 index 00000000000..c15c5cc1ae7 --- /dev/null +++ b/cli/src/hooks/runner.ts @@ -0,0 +1,444 @@ +/** + * Hooks execution runner + * Executes hooks with Claude Code compatible matching and decision handling + */ + +import { spawn } from "child_process" +import type { + HooksConfig, + HookMatcher, + HookCommand, + HookEvent, + HookInput, + HookResult, + HookDecision, +} from "../config/types.js" +import { getHooksForEvent } from "./config.js" +import { logs } from "../services/logs.js" + +/** Default timeout for hook execution (30 seconds) */ +const DEFAULT_HOOK_TIMEOUT = 30000 + +/** + * Exit code that blocks the operation (Claude Code compatible) + * Exit code 2 means "block this operation" + */ +const BLOCK_EXIT_CODE = 2 + +/** + * Result of running hooks for an event + */ +export interface HookEventResult { + /** Whether any hook blocked the operation */ + blocked: boolean + /** Reason for blocking (from hook decision or stderr) */ + blockReason?: string + /** All individual hook results */ + results: HookResult[] + /** Aggregated decision from hooks */ + decision?: HookDecision +} + +/** + * Check if a matcher pattern matches the given target + * + * @param pattern - Matcher pattern (empty/"*" matches all, "|" for OR) + * @param target - Target string to match against + * @returns Whether the pattern matches the target + */ +export function matchesPattern(pattern: string, target: string): boolean { + // Empty string or "*" matches everything + if (pattern === "" || pattern === "*") { + return true + } + + // Check for pipe-separated patterns (OR matching) + if (pattern.includes("|")) { + const patterns = pattern.split("|").map((p) => p.trim()) + return patterns.some((p) => matchesPattern(p, target)) + } + + // Exact match (case-sensitive) + return pattern === target +} + +/** + * Find all matching hooks for a given target + */ +function findMatchingHooks(matchers: HookMatcher[], target: string): HookCommand[] { + const matchingHooks: HookCommand[] = [] + + for (const matcher of matchers) { + if (matchesPattern(matcher.matcher, target)) { + matchingHooks.push(...matcher.hooks) + } + } + + return matchingHooks +} + +/** + * Execute a single hook command + * + * @param hook - Hook command to execute + * @param input - Input data to pass via stdin as JSON + * @returns Hook execution result + */ +async function executeHookCommand(hook: HookCommand, input: HookInput): Promise { + const timeout = hook.timeout ?? DEFAULT_HOOK_TIMEOUT + + return new Promise((resolve) => { + const startTime = Date.now() + + try { + // Spawn shell process + const child = spawn(hook.command, [], { + shell: true, + stdio: ["pipe", "pipe", "pipe"], + timeout, + }) + + let stdout = "" + let stderr = "" + let resolved = false + + const resolveOnce = (result: HookResult) => { + if (!resolved) { + resolved = true + resolve(result) + } + } + + // Collect stdout + child.stdout?.on("data", (data: Buffer) => { + stdout += data.toString() + }) + + // Collect stderr + child.stderr?.on("data", (data: Buffer) => { + stderr += data.toString() + }) + + // Handle process completion + child.on("close", (code) => { + const exitCode = code ?? 0 + const duration = Date.now() - startTime + + logs.debug(`Hook completed`, "HooksRunner", { + command: hook.command.substring(0, 50), + exitCode, + duration, + stdoutLength: stdout.length, + stderrLength: stderr.length, + }) + + // Try to parse stdout as JSON decision + let decision: HookDecision | undefined + if (stdout.trim()) { + try { + const parsed = JSON.parse(stdout.trim()) + if (typeof parsed === "object" && parsed !== null) { + decision = parsed as HookDecision + } + } catch { + // Not valid JSON, that's fine + } + } + + resolveOnce({ + exitCode, + stdout, + stderr, + ...(decision ? { decision } : {}), + }) + }) + + // Handle errors + child.on("error", (error) => { + logs.error(`Hook execution error`, "HooksRunner", { + command: hook.command.substring(0, 50), + error: error.message, + }) + + resolveOnce({ + exitCode: 1, + stdout, + stderr: stderr + `\nError: ${error.message}`, + }) + }) + + // Set up timeout handler + const timeoutId = setTimeout(() => { + if (!resolved) { + logs.warn(`Hook timed out`, "HooksRunner", { + command: hook.command.substring(0, 50), + timeout, + }) + + child.kill("SIGTERM") + + resolveOnce({ + exitCode: 124, // Standard timeout exit code + stdout, + stderr: stderr + `\nHook timed out after ${timeout}ms`, + }) + } + }, timeout) + + child.on("close", () => { + clearTimeout(timeoutId) + }) + + // Write input to stdin and close + const inputJson = JSON.stringify(input) + child.stdin?.write(inputJson) + child.stdin?.end() + } catch (error) { + logs.error(`Failed to spawn hook process`, "HooksRunner", { + command: hook.command.substring(0, 50), + error: error instanceof Error ? error.message : String(error), + }) + + resolve({ + exitCode: 1, + stdout: "", + stderr: `Failed to execute hook: ${error instanceof Error ? error.message : String(error)}`, + }) + } + }) +} + +/** + * Run all hooks for an event + * + * @param hooks - Hooks configuration + * @param event - Hook event type + * @param target - Target to match against (e.g., tool name) + * @param input - Input data to pass to hooks + * @returns Combined result of all hook executions + */ +export async function runHooks( + hooks: HooksConfig, + event: HookEvent, + target: string, + input: Omit, +): Promise { + const matchers = getHooksForEvent(hooks, event) + + if (matchers.length === 0) { + return { + blocked: false, + results: [], + } + } + + // Find all hooks that match the target + const matchingHooks = findMatchingHooks(matchers, target) + + if (matchingHooks.length === 0) { + return { + blocked: false, + results: [], + } + } + + logs.debug(`Running ${matchingHooks.length} hooks for ${event}`, "HooksRunner", { + target, + }) + + // Build full input with event type + const fullInput: HookInput = { + ...input, + hook_event: event, + } + + // Execute hooks in order + const results: HookResult[] = [] + let blocked = false + let blockReason: string | undefined + let aggregatedDecision: HookDecision | undefined + + for (const hook of matchingHooks) { + const result = await executeHookCommand(hook, fullInput) + results.push(result) + + // Check for blocking conditions: + // 1. Exit code 2 (Claude Code convention) + // 2. JSON decision with permissionDecision: "deny" + if (result.exitCode === BLOCK_EXIT_CODE) { + blocked = true + blockReason = result.decision?.permissionDecisionReason || result.stderr.trim() || "Blocked by hook" + + logs.info(`Hook blocked operation`, "HooksRunner", { + event, + target, + reason: blockReason, + }) + } + + if (result.decision?.permissionDecision === "deny") { + blocked = true + blockReason = result.decision.permissionDecisionReason || "Denied by hook" + aggregatedDecision = result.decision + + logs.info(`Hook denied permission`, "HooksRunner", { + event, + target, + reason: blockReason, + }) + } + + // If any hook blocks, we can optionally continue or stop + // For now, we continue to collect all results but mark as blocked + } + + return { + blocked, + ...(blockReason ? { blockReason } : {}), + results, + ...(aggregatedDecision ? { decision: aggregatedDecision } : {}), + } +} + +/** + * Run PreToolUse hooks + * Returns whether the tool should be blocked + */ +export async function runPreToolUseHooks( + hooks: HooksConfig, + toolName: string, + toolInput: Record, + context: { workspace?: string; session_id?: string }, +): Promise { + return runHooks(hooks, "PreToolUse", toolName, { + tool_name: toolName, + tool_input: toolInput, + workspace: context.workspace, + session_id: context.session_id, + }) +} + +/** + * Run PostToolUse hooks + */ +export async function runPostToolUseHooks( + hooks: HooksConfig, + toolName: string, + toolInput: Record, + toolOutput: unknown, + context: { workspace?: string; session_id?: string }, +): Promise { + return runHooks(hooks, "PostToolUse", toolName, { + tool_name: toolName, + tool_input: toolInput, + tool_output: toolOutput, + workspace: context.workspace, + session_id: context.session_id, + }) +} + +/** + * Run PermissionRequest hooks + */ +export async function runPermissionRequestHooks( + hooks: HooksConfig, + permissionType: string, + details: Record, + context: { workspace?: string; session_id?: string }, +): Promise { + return runHooks(hooks, "PermissionRequest", permissionType, { + tool_name: permissionType, + tool_input: details, + workspace: context.workspace, + session_id: context.session_id, + }) +} + +/** + * Run UserPromptSubmit hooks + */ +export async function runUserPromptSubmitHooks( + hooks: HooksConfig, + prompt: string, + context: { workspace?: string; session_id?: string }, +): Promise { + return runHooks(hooks, "UserPromptSubmit", "", { + prompt, + workspace: context.workspace, + session_id: context.session_id, + }) +} + +/** + * Run Notification hooks + */ +export async function runNotificationHooks( + hooks: HooksConfig, + notificationType: string, + details: Record, + context: { workspace?: string; session_id?: string }, +): Promise { + return runHooks(hooks, "Notification", notificationType, { + tool_name: notificationType, + tool_input: details, + workspace: context.workspace, + session_id: context.session_id, + }) +} + +/** + * Run Stop hooks (when agent finishes responding) + */ +export async function runStopHooks( + hooks: HooksConfig, + reason: string, + context: { workspace?: string; session_id?: string }, +): Promise { + return runHooks(hooks, "Stop", reason, { + tool_name: reason, + workspace: context.workspace, + session_id: context.session_id, + }) +} + +/** + * Run PreCompact hooks + */ +export async function runPreCompactHooks( + hooks: HooksConfig, + context: { workspace?: string; session_id?: string; taskId?: string }, +): Promise { + return runHooks(hooks, "PreCompact", "", { + workspace: context.workspace, + session_id: context.session_id, + task_id: context.taskId, + }) +} + +/** + * Run SessionStart hooks + */ +export async function runSessionStartHooks( + hooks: HooksConfig, + context: { workspace?: string; session_id?: string; isResume?: boolean }, +): Promise { + return runHooks(hooks, "SessionStart", context.isResume ? "resume" : "start", { + workspace: context.workspace, + session_id: context.session_id, + is_resume: context.isResume, + }) +} + +/** + * Run SessionEnd hooks + */ +export async function runSessionEndHooks( + hooks: HooksConfig, + context: { workspace?: string; session_id?: string; reason?: string }, +): Promise { + return runHooks(hooks, "SessionEnd", context.reason || "exit", { + workspace: context.workspace, + session_id: context.session_id, + reason: context.reason, + }) +} diff --git a/cli/src/state/atoms/effects.ts b/cli/src/state/atoms/effects.ts index 32320652dbf..4acabdefd8e 100644 --- a/cli/src/state/atoms/effects.ts +++ b/cli/src/state/atoms/effects.ts @@ -37,6 +37,7 @@ import { validateModelOnRouterModelsUpdateAtom } from "./modelValidation.js" import { validateModeOnCustomModesUpdateAtom } from "./modeValidation.js" import { logs } from "../../services/logs.js" import { SessionManager } from "../../../../src/shared/kilocode/cli-sessions/core/SessionManager.js" +import { runStopHooksAtom } from "./hooks.js" /** * Message buffer to handle race conditions during initialization @@ -629,6 +630,9 @@ export const messageHandlerEffectAtom = atom(null, (get, set, message: Extension } else { logs.info("Completion result detected in state update", "effects") + // Run Stop hooks when completion_result is detected + void set(runStopHooksAtom, { reason: "completion_result" }) + set(ciCompletionDetectedAtom, true) SessionManager.init()?.doSync(true) diff --git a/cli/src/state/atoms/hooks.ts b/cli/src/state/atoms/hooks.ts new file mode 100644 index 00000000000..a3153cc1349 --- /dev/null +++ b/cli/src/state/atoms/hooks.ts @@ -0,0 +1,208 @@ +/** + * Hooks state atoms + * Provides global access to hooks configuration for running lifecycle hooks + */ + +import { atom } from "jotai" +import type { HooksConfig } from "../../config/types.js" +import { + runPreToolUseHooks, + runPostToolUseHooks, + runPermissionRequestHooks, + runUserPromptSubmitHooks, + runNotificationHooks, + runStopHooks, + runPreCompactHooks, + type HookEventResult, +} from "../../hooks/runner.js" +import { logs } from "../../services/logs.js" + +/** + * Atom to hold the loaded hooks configuration + */ +export const hooksConfigAtom = atom({}) + +/** + * Atom to hold the current session ID for hooks context + */ +export const hooksSessionIdAtom = atom(null) + +/** + * Atom to hold the workspace path for hooks context + */ +export const hooksWorkspaceAtom = atom("") + +/** + * Setter atom to initialize hooks configuration + */ +export const setHooksConfigAtom = atom(null, (_get, set, config: HooksConfig) => { + set(hooksConfigAtom, config) +}) + +/** + * Setter atom to set session ID + */ +export const setHooksSessionIdAtom = atom(null, (_get, set, sessionId: string | null) => { + set(hooksSessionIdAtom, sessionId) +}) + +/** + * Setter atom to set workspace + */ +export const setHooksWorkspaceAtom = atom(null, (_get, set, workspace: string) => { + set(hooksWorkspaceAtom, workspace) +}) + +/** + * Derived atom to get hooks context + */ +export const hooksContextAtom = atom((get) => ({ + hooks: get(hooksConfigAtom), + session_id: get(hooksSessionIdAtom), + workspace: get(hooksWorkspaceAtom), +})) + +/** + * Helper to build context object without undefined properties + * This is needed for exactOptionalPropertyTypes compatibility + */ +function buildContext( + workspace: string | null, + session_id: string | null, + taskId?: string, +): { workspace?: string; session_id?: string; taskId?: string } { + const result: { workspace?: string; session_id?: string; taskId?: string } = {} + if (workspace) result.workspace = workspace + if (session_id) result.session_id = session_id + if (taskId) result.taskId = taskId + return result +} + +// ============================================ +// ACTION ATOMS FOR RUNNING HOOKS +// ============================================ + +/** + * Action atom to run PreToolUse hooks + */ +export const runPreToolUseHooksAtom = atom( + null, + async (get, _set, params: { toolName: string; toolInput: Record }): Promise => { + const { hooks, session_id, workspace } = get(hooksContextAtom) + if (Object.keys(hooks).length === 0) { + return { blocked: false, results: [] } + } + logs.debug("Running PreToolUse hooks", "HooksAtom", { toolName: params.toolName }) + return runPreToolUseHooks(hooks, params.toolName, params.toolInput, buildContext(workspace, session_id)) + }, +) + +/** + * Action atom to run PostToolUse hooks + */ +export const runPostToolUseHooksAtom = atom( + null, + async ( + get, + _set, + params: { toolName: string; toolInput: Record; toolOutput: unknown }, + ): Promise => { + const { hooks, session_id, workspace } = get(hooksContextAtom) + if (Object.keys(hooks).length === 0) { + return { blocked: false, results: [] } + } + logs.debug("Running PostToolUse hooks", "HooksAtom", { toolName: params.toolName }) + return runPostToolUseHooks( + hooks, + params.toolName, + params.toolInput, + params.toolOutput, + buildContext(workspace, session_id), + ) + }, +) + +/** + * Action atom to run PermissionRequest hooks + */ +export const runPermissionRequestHooksAtom = atom( + null, + async ( + get, + _set, + params: { permissionType: string; details: Record }, + ): Promise => { + const { hooks, session_id, workspace } = get(hooksContextAtom) + if (Object.keys(hooks).length === 0) { + return { blocked: false, results: [] } + } + logs.debug("Running PermissionRequest hooks", "HooksAtom", { permissionType: params.permissionType }) + return runPermissionRequestHooks( + hooks, + params.permissionType, + params.details, + buildContext(workspace, session_id), + ) + }, +) + +/** + * Action atom to run UserPromptSubmit hooks + */ +export const runUserPromptSubmitHooksAtom = atom( + null, + async (get, _set, params: { prompt: string }): Promise => { + const { hooks, session_id, workspace } = get(hooksContextAtom) + if (Object.keys(hooks).length === 0) { + return { blocked: false, results: [] } + } + logs.debug("Running UserPromptSubmit hooks", "HooksAtom", { promptLength: params.prompt.length }) + return runUserPromptSubmitHooks(hooks, params.prompt, buildContext(workspace, session_id)) + }, +) + +/** + * Action atom to run Notification hooks + */ +export const runNotificationHooksAtom = atom( + null, + async ( + get, + _set, + params: { notificationType: string; details: Record }, + ): Promise => { + const { hooks, session_id, workspace } = get(hooksContextAtom) + if (Object.keys(hooks).length === 0) { + return { blocked: false, results: [] } + } + logs.debug("Running Notification hooks", "HooksAtom", { notificationType: params.notificationType }) + return runNotificationHooks(hooks, params.notificationType, params.details, buildContext(workspace, session_id)) + }, +) + +/** + * Action atom to run Stop hooks + */ +export const runStopHooksAtom = atom(null, async (get, _set, params: { reason: string }): Promise => { + const { hooks, session_id, workspace } = get(hooksContextAtom) + if (Object.keys(hooks).length === 0) { + return { blocked: false, results: [] } + } + logs.debug("Running Stop hooks", "HooksAtom", { reason: params.reason }) + return runStopHooks(hooks, params.reason, buildContext(workspace, session_id)) +}) + +/** + * Action atom to run PreCompact hooks + */ +export const runPreCompactHooksAtom = atom( + null, + async (get, _set, params: { taskId?: string }): Promise => { + const { hooks, session_id, workspace } = get(hooksContextAtom) + if (Object.keys(hooks).length === 0) { + return { blocked: false, results: [] } + } + logs.debug("Running PreCompact hooks", "HooksAtom", { taskId: params.taskId }) + return runPreCompactHooks(hooks, buildContext(workspace, session_id, params.taskId)) + }, +) diff --git a/cli/src/state/hooks/useCondense.ts b/cli/src/state/hooks/useCondense.ts index c81a20139b5..dfabad2a28c 100644 --- a/cli/src/state/hooks/useCondense.ts +++ b/cli/src/state/hooks/useCondense.ts @@ -9,6 +9,7 @@ import { removePendingCondenseRequestAtom, CONDENSE_REQUEST_TIMEOUT_MS, } from "../atoms/condense.js" +import { runPreCompactHooksAtom } from "../atoms/hooks.js" import { useWebviewMessage } from "./useWebviewMessage.js" import { logs } from "../../services/logs.js" @@ -50,10 +51,20 @@ export interface UseCondenseReturn { export function useCondense(): UseCondenseReturn { const addPendingRequest = useSetAtom(addPendingCondenseRequestAtom) const removePendingRequest = useSetAtom(removePendingCondenseRequestAtom) + const runPreCompactHooks = useSetAtom(runPreCompactHooksAtom) const { sendMessage } = useWebviewMessage() const condenseAndWait = useCallback( async (taskId: string): Promise => { + // Run PreCompact hooks before starting condense + const hookResult = await runPreCompactHooks({ taskId }) + if (hookResult.blocked) { + logs.info("Condense blocked by PreCompact hook", "useCondense", { + reason: hookResult.blockReason, + }) + throw new Error(`Condense blocked: ${hookResult.blockReason || "Blocked by hook"}`) + } + return new Promise((resolve, reject) => { // Set up timeout const timeout = setTimeout(() => { @@ -83,7 +94,7 @@ export function useCondense(): UseCondenseReturn { logs.info(`Condense request sent for task: ${taskId}, waiting for response...`, "useCondense") }) }, - [addPendingRequest, removePendingRequest, sendMessage], + [addPendingRequest, removePendingRequest, sendMessage, runPreCompactHooks], ) return { condenseAndWait } diff --git a/cli/src/state/hooks/useMessageHandler.ts b/cli/src/state/hooks/useMessageHandler.ts index 0f54bd4bd84..56b421ffd36 100644 --- a/cli/src/state/hooks/useMessageHandler.ts +++ b/cli/src/state/hooks/useMessageHandler.ts @@ -12,6 +12,7 @@ import { pastedTextReferencesAtom, clearPastedTextReferencesAtom, } from "../atoms/keyboard.js" +import { runUserPromptSubmitHooksAtom } from "../atoms/hooks.js" import { useWebviewMessage } from "./useWebviewMessage.js" import { useTaskState } from "./useTaskState.js" import type { CliMessage } from "../../types/cli.js" @@ -70,6 +71,7 @@ export function useMessageHandler(options: UseMessageHandlerOptions = {}): UseMe const clearImageReferences = useSetAtom(clearImageReferencesAtom) const pastedTextReferences = useAtomValue(pastedTextReferencesAtom) const clearPastedTextReferences = useSetAtom(clearPastedTextReferencesAtom) + const runUserPromptSubmitHooks = useSetAtom(runUserPromptSubmitHooksAtom) const { sendMessage, sendAskResponse } = useWebviewMessage() const { hasActiveTask } = useTaskState() @@ -83,6 +85,23 @@ export function useMessageHandler(options: UseMessageHandlerOptions = {}): UseMe setIsSending(true) try { + // Run UserPromptSubmit hooks before processing + const hookResult = await runUserPromptSubmitHooks({ prompt: trimmedText }) + if (hookResult.blocked) { + logs.info("User prompt blocked by hook", "useMessageHandler", { + reason: hookResult.blockReason, + }) + const blockedMessage: CliMessage = { + id: `hook-blocked-${Date.now()}`, + type: "error", + content: `Message blocked: ${hookResult.blockReason || "Blocked by hook"}`, + ts: Date.now(), + } + addMessage(blockedMessage) + setIsSending(false) + return + } + // Expand [Pasted text #N +X lines] references with full content const pastedTextRefsObject = Object.fromEntries(pastedTextReferences) const expandedText = expandPastedTextReferences(trimmedText, pastedTextRefsObject) @@ -162,6 +181,7 @@ export function useMessageHandler(options: UseMessageHandlerOptions = {}): UseMe clearImageReferences, pastedTextReferences, clearPastedTextReferences, + runUserPromptSubmitHooks, ], )