diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index adf11048e8..494900862e 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -542,7 +542,7 @@ export class Task extends EventEmitter implements TaskLike { // Initialize tool execution hooks this.toolExecutionHooks = createToolExecutionHooks( - provider.getHookManager() ?? null, + () => provider.getHookManager?.() ?? null, (status) => provider.postHookStatusToWebview(status), (payload) => provider.postHookExecutionOutputStatusToWebview(payload), async (type, text) => { @@ -562,6 +562,8 @@ export class Task extends EventEmitter implements TaskLike { // Default to true if hooksEnabled is undefined (backwards compatibility) return provider.contextProxy.getValue("hooksEnabled") ?? true }, + // Getter for HookManager initialization promise + () => provider.getHookManagerInitPromise?.(), ) this.diffEnabled = enableDiff @@ -1263,6 +1265,64 @@ export class Task extends EventEmitter implements TaskLike { return undefined } + /** + * Lifecycle hooks write `hook_execution` rows and then update them later. + * + * The LifecycleHooks service expects an id-based update callback, but Task message + * rows are keyed by timestamp (`ts`). We use the message ts (as a string) as the + * stable id for hook_execution rows. + */ + public async sayLifecycleHookRow(type: ClineSay, message?: string): Promise { + // Only hook_execution rows need stable ids for updates. + if (type !== "hook_execution") { + await this.say(type, message, undefined, undefined, undefined, undefined, { isNonInteractive: true }) + return + } + + const ts = Date.now() + await this.addToClineMessages({ + ts, + type: "say", + say: type, + text: message, + }) + return String(ts) + } + + public async updateLifecycleHookRow(type: ClineSay, id: string, message?: string): Promise { + if (type !== "hook_execution") return + + const ts = Number(id) + if (!Number.isFinite(ts)) return + + const row = this.findMessageByTimestamp(ts) + if (!row || row.type !== "say" || row.say !== type) { + return + } + + row.text = message + await this.saveClineMessages() + await this.updateClineMessage(row) + } + + /** + * Get all callbacks needed for lifecycle hook execution. + * This includes sayCallback, updateSayCallback, and outputStatusCallback + * which streams terminal output to the chat UI. + */ + private getLifecycleHookCallbacks() { + const provider = this.providerRef.deref() + if (!provider) { + throw new Error("Provider reference lost") + } + + return { + sayCallback: this.sayLifecycleHookRow.bind(this), + updateSayCallback: this.updateLifecycleHookRow.bind(this), + outputStatusCallback: (payload: any) => provider.postHookExecutionOutputStatusToWebview(payload), + } + } + // Note that `partial` has three valid states true (partial message), // false (completion of partial message), undefined (individual complete // message). @@ -1592,7 +1652,22 @@ export class Task extends EventEmitter implements TaskLike { providerProfile?: string, ): Promise { try { - text = (text ?? "").trim() + const rawText = text ?? "" + + // Execute UserPromptSubmit hooks (blocking) + const providerForHooks = this.providerRef.deref() as any + const lifecycleHooks = providerForHooks?.getLifecycleHooks?.() + if (lifecycleHooks) { + const result = await lifecycleHooks.executeUserPromptSubmit(rawText, this.getLifecycleHookCallbacks()) + if (result.blocked) { + console.log("[Task] UserPromptSubmit hook blocked the prompt") + const reason = (result as any).blockMessage || (result as any).error + await this.say("error", reason ? `Prompt blocked: ${String(reason)}` : "Prompt blocked by hook.") + return + } + } + + text = rawText.trim() images = images ?? [] if (text.length === 0 && images.length === 0) { @@ -1636,7 +1711,21 @@ export class Task extends EventEmitter implements TaskLike { } } - public async condenseContext(): Promise { + public async condenseContext(isManual: boolean = false): Promise { + // Execute PreCompact hooks (non-blocking) + try { + const providerForHooks = this.providerRef.deref() as any + const lifecycleHooks = providerForHooks?.getLifecycleHooks?.() + if (lifecycleHooks) { + const trigger = isManual ? "manual" : "auto" + await lifecycleHooks.executePreCompact(trigger, this.getLifecycleHookCallbacks()) + // Note: PreCompact is not a blocking event, so we continue regardless + } + } catch (error) { + // Fail-open: do not prevent compaction if hooks fail + console.error("[Task] PreCompact hook execution failed:", error) + } + // CRITICAL: Flush any pending tool results before condensing // to ensure tool_use/tool_result pairs are complete in history await this.flushPendingToolResultsToHistory() @@ -1685,7 +1774,7 @@ export class Task extends EventEmitter implements TaskLike { systemPrompt, // Default summarization prompt (fallback) this.taskId, prevContextTokens, - false, // manual trigger + !isManual, // automatic trigger customCondensingPrompt, // User's custom prompt condensingApiHandler, // Specific handler for condensing useNativeTools, // Pass native tools flag for proper message handling @@ -2221,11 +2310,71 @@ export class Task extends EventEmitter implements TaskLike { this.debouncedEmitTokenUsage.flush() } - public async abortTask(isAbandoned = false) { + public async abortTask( + isAbandonedOrOptions: + | boolean + | { + /** Reason classifier for Stop hook execution. */ + reason?: "user_request" | "error" | "timeout" | "force" + /** Whether this abort is an abandon/teardown operation. */ + isAbandoned?: boolean + /** Force-abort mode: bypass Stop hooks entirely (safety valve for cleanup). */ + isForceAbort?: boolean + } = false, + ) { + const options = + typeof isAbandonedOrOptions === "object" && isAbandonedOrOptions !== null + ? isAbandonedOrOptions + : { + isAbandoned: isAbandonedOrOptions, + // Backwards compatibility: callers previously used abortTask(true) for teardown. + // Treat that as a force-abort so cleanup cannot be blocked by hooks. + isForceAbort: isAbandonedOrOptions === true, + } + + const stopReason: "user_request" | "error" | "timeout" | "force" = + options.reason ?? + (options.isForceAbort + ? "force" + : this.abortReason === "streaming_failed" + ? "error" + : this.abortReason === "user_cancelled" + ? "user_request" + : "user_request") + + // Execute Stop hooks (blocking) + // Fail-open: if hooks fail, continue abort for safety. + // Force-abort mode bypasses Stop hooks entirely (cleanup must never be blocked). + if (!options.isForceAbort) { + try { + const provider = this.providerRef.deref() + const lifecycleHooks = provider?.getLifecycleHooks?.() + if (lifecycleHooks) { + const result = await lifecycleHooks.executeStop( + { + reason: stopReason, + isAbandoned: options.isAbandoned, + isForceAbort: false, + }, + this.getLifecycleHookCallbacks(), + ) + if (result.blocked) { + // Hook blocked the stop - do not abort + console.log("[Task] Stop hook blocked the abort") + const reason = (result as any).blockMessage || (result as any).error + await this.say("error", reason ? `Stop blocked: ${String(reason)}` : "Stop blocked by hook.") + return + } + } + } catch (error) { + console.error("[Task] Stop hook execution failed, proceeding with abort", error) + } + } + // Aborting task // Will stop any autonomously running promises. - if (isAbandoned) { + if (options.isAbandoned) { this.abandoned = true } @@ -2392,7 +2541,61 @@ export class Task extends EventEmitter implements TaskLike { * - Ensures next API call includes full context * - Immediately continues task loop without user interaction */ - public async resumeAfterDelegation(): Promise { + public async resumeAfterDelegation(subtaskInfo?: { taskId: string; result?: string }): Promise { + // Execute SubagentStop hooks (blocking) + // If blocked, do NOT proceed with the resume routine. Surface the reason and require manual resume. + try { + const provider = this.providerRef.deref() + const lifecycleHooks = provider?.getLifecycleHooks?.() + if (lifecycleHooks) { + const mode = await this.getTaskMode().catch(() => "unknown") + const result = await lifecycleHooks.executeSubagentStop( + { + parentTaskId: this.taskId, + childTaskId: subtaskInfo?.taskId, + mode, + result: subtaskInfo?.result, + }, + this.getLifecycleHookCallbacks(), + ) + if (result.blocked) { + const childId = subtaskInfo?.taskId ?? "unknown" + const reason = result.blockMessage || result.error + const reasonText = reason ? String(reason) : "(no reason provided)" + + console.log( + `[Task#resumeAfterDelegation] SubagentStop hook blocked automatic resume for subtask ${childId}: ${reasonText}`, + ) + + await this.say( + "error", + `SubagentStop blocked automatic resume from subtask ${childId}: ${reasonText}`, + ) + + // Escape hatch: allow user to manually resume anyway. + const { response } = await this.ask( + "resume_task", + `SubagentStop hook blocked automatic resume from subtask ${childId}. +Reason: ${reasonText} + +Click Resume to continue anyway (bypasses SubagentStop block), or Cancel to keep the task paused.`, + false, + ) + + if (response !== "yesButtonClicked") { + return + } + } + } + } catch (error) { + // Even though SubagentStop is blocking, we should not strand the task in a non-resumed state. + console.error( + `[Task#resumeAfterDelegation] Failed to execute SubagentStop hooks for subtask ${ + subtaskInfo?.taskId ?? "unknown" + }: ${error instanceof Error ? error.message : String(error)}`, + ) + } + // Clear any ask states that might have been set during history load this.idleAsk = undefined this.resumableAsk = undefined @@ -3824,6 +4027,16 @@ export class Task extends EventEmitter implements TaskLike { // Send condenseTaskContextStarted to show in-progress indicator await this.providerRef.deref()?.postMessageToWebview({ type: "condenseTaskContextStarted", text: this.taskId }) + // Execute PreCompact hooks for forced/automatic compaction (non-blocking) + try { + const providerForHooks = this.providerRef.deref() as any + const lifecycleHooks = providerForHooks?.getLifecycleHooks?.() + await lifecycleHooks?.executePreCompact("auto", this.getLifecycleHookCallbacks()) + } catch (error) { + // Fail-open: do not prevent compaction if hooks fail + console.error("[Task] PreCompact hook execution failed:", error) + } + // Force aggressive truncation by keeping only 75% of the conversation history const truncateResult = await manageContext({ messages: this.apiConversationHistory, @@ -4022,6 +4235,18 @@ export class Task extends EventEmitter implements TaskLike { ?.postMessageToWebview({ type: "condenseTaskContextStarted", text: this.taskId }) } + // Execute PreCompact hooks for automatic compaction (non-blocking) + if (contextManagementWillRun) { + try { + const providerForHooks = this.providerRef.deref() as any + const lifecycleHooks = providerForHooks?.getLifecycleHooks?.() + await lifecycleHooks?.executePreCompact("auto", this.getLifecycleHookCallbacks()) + } catch (error) { + // Fail-open: do not prevent compaction if hooks fail + console.error("[Task] PreCompact hook execution failed:", error) + } + } + const truncateResult = await manageContext({ messages: this.apiConversationHistory, totalTokens: contextTokens, diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 8a82524b15..5474233a3a 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -1551,6 +1551,25 @@ describe("Cline", () => { }) }) + it("shows hook block reason when UserPromptSubmit blocks", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "initial task", + startTask: false, + }) + + const saySpy = vi.spyOn(task, "say").mockResolvedValue(undefined as any) + mockProvider.getLifecycleHooks = vi.fn().mockReturnValue({ + executeUserPromptSubmit: vi.fn().mockResolvedValue({ blocked: true, blockMessage: "policy" }), + }) + + await task.submitUserMessage("test message") + + expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() + expect(saySpy).toHaveBeenCalledWith("error", expect.stringContaining("Prompt blocked: policy")) + }) + it("should handle empty messages gracefully", async () => { const task = new Task({ provider: mockProvider, @@ -1638,6 +1657,81 @@ describe("Cline", () => { // Restore console.error consoleErrorSpy.mockRestore() }) + + it("blocks auto-resume when SubagentStop hook blocks (escape hatch uses resume_task ask)", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "initial task", + startTask: false, + }) + + // Stub hooks to block + mockProvider.getLifecycleHooks = vi.fn().mockReturnValue({ + executeSubagentStop: vi.fn().mockResolvedValue({ blocked: true, blockMessage: "policy" }), + }) + + // Spy say/ask; ask returns Cancel => remain paused (no resume routine) + const saySpy = vi.spyOn(task, "say").mockResolvedValue(undefined as any) + const askSpy = vi.spyOn(task, "ask").mockResolvedValue({ response: "noButtonClicked" } as any) + const emitSpy = vi.spyOn(task, "emit") + + await task.resumeAfterDelegation({ taskId: "child-1", result: "done" }) + + expect(saySpy).toHaveBeenCalledWith( + "error", + expect.stringContaining("SubagentStop blocked automatic resume"), + ) + expect(askSpy).toHaveBeenCalledWith( + "resume_task", + expect.stringContaining("blocked automatic resume"), + false, + ) + + // Critical: should NOT activate the task automatically + expect(emitSpy).not.toHaveBeenCalledWith("taskActive", task.taskId) + }) + + it("allows resume when SubagentStop hook blocks but user clicks Resume anyway", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "initial task", + startTask: false, + }) + + mockProvider.getLifecycleHooks = vi.fn().mockReturnValue({ + executeSubagentStop: vi.fn().mockResolvedValue({ blocked: true, blockMessage: "policy" }), + }) + + vi.spyOn(task, "say").mockResolvedValue(undefined as any) + vi.spyOn(task, "ask").mockResolvedValue({ response: "yesButtonClicked" } as any) + // Avoid running full task loop in unit test + vi.spyOn(task as any, "initiateTaskLoop").mockResolvedValue(undefined) + + const emitSpy = vi.spyOn(task, "emit") + await task.resumeAfterDelegation({ taskId: "child-1", result: "done" }) + expect(emitSpy).toHaveBeenCalledWith("taskActive", task.taskId) + }) + + it("auto-resumes when SubagentStop hook does not block", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "initial task", + startTask: false, + }) + + mockProvider.getLifecycleHooks = vi.fn().mockReturnValue({ + executeSubagentStop: vi.fn().mockResolvedValue({ blocked: false }), + }) + + vi.spyOn(task as any, "initiateTaskLoop").mockResolvedValue(undefined) + const emitSpy = vi.spyOn(task, "emit") + + await task.resumeAfterDelegation({ taskId: "child-1", result: "done" }) + expect(emitSpy).toHaveBeenCalledWith("taskActive", task.taskId) + }) }) }) @@ -1666,6 +1760,68 @@ describe("Cline", () => { expect(emitSpy).toHaveBeenCalledWith("taskAborted") }) + it("blocks abort and prevents state change when Stop hook blocks", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const saySpy = vi.spyOn(task, "say").mockResolvedValue(undefined as any) + const disposeSpy = vi.spyOn(task, "dispose").mockImplementation(() => {}) + + mockProvider.getLifecycleHooks = vi.fn().mockReturnValue({ + executeStop: vi.fn().mockResolvedValue({ blocked: true, blockMessage: "policy" }), + }) + + await task.abortTask({ reason: "user_request" }) + + expect(task.abort).toBe(false) + expect(disposeSpy).not.toHaveBeenCalled() + expect(saySpy).toHaveBeenCalledWith("error", expect.stringContaining("Stop blocked: policy")) + }) + + it("bypasses Stop hooks in force abort mode", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const disposeSpy = vi.spyOn(task, "dispose").mockImplementation(() => {}) + const executeStop = vi.fn().mockResolvedValue({ blocked: false }) + mockProvider.getLifecycleHooks = vi.fn().mockReturnValue({ executeStop }) + + // Legacy path: abortTask(true) is used for internal cleanup. + await task.abortTask(true) + + expect(task.abort).toBe(true) + expect(disposeSpy).toHaveBeenCalled() + expect(executeStop).not.toHaveBeenCalled() + }) + + it("passes stop reason in hook context", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + vi.spyOn(task, "dispose").mockImplementation(() => {}) + + const executeStop = vi.fn().mockResolvedValue({ blocked: false }) + mockProvider.getLifecycleHooks = vi.fn().mockReturnValue({ executeStop }) + + await task.abortTask({ reason: "timeout" }) + + expect(executeStop).toHaveBeenCalledTimes(1) + const [options] = executeStop.mock.calls[0] + expect(options).toMatchObject({ reason: "timeout" }) + }) + it("should be equivalent to clicking Cancel button functionality", async () => { const task = new Task({ provider: mockProvider, @@ -1929,7 +2085,7 @@ describe("Queued message processing after condense", () => { // Use fake timers to capture setTimeout(0) in processQueuedMessages vi.useFakeTimers() - await task.condenseContext() + await task.condenseContext(false) // Flush the microtask that submits the queued message vi.runAllTimers() @@ -1967,7 +2123,7 @@ describe("Queued message processing after condense", () => { // Condense in task A should only drain A's queue vi.useFakeTimers() - await taskA.condenseContext() + await taskA.condenseContext(false) vi.runAllTimers() vi.useRealTimers() @@ -1977,7 +2133,7 @@ describe("Queued message processing after condense", () => { // Now condense in task B should drain B's queue vi.useFakeTimers() - await taskB.condenseContext() + await taskB.condenseContext(false) vi.runAllTimers() vi.useRealTimers() diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 9e7aeed2bf..ad6bd9578a 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -75,7 +75,7 @@ import { CodeIndexManager } from "../../services/code-index/manager" import type { IndexProgressUpdate } from "../../services/code-index/interfaces/manager" import { MdmService } from "../../services/mdm/MdmService" import { SkillsManager } from "../../services/skills/SkillsManager" -import { HookManager, createHookManager, type IHookManager } from "../../services/hooks" +import { HookManager, createHookManager, LifecycleHooks, type IHookManager } from "../../services/hooks" import { fileExistsAtPath } from "../../utils/fs" import { setTtsEnabled, setTtsSpeed } from "../../utils/tts" @@ -144,6 +144,8 @@ export class ClineProvider protected mcpHub?: McpHub // Change from private to protected protected skillsManager?: SkillsManager protected hookManager?: IHookManager + private hookManagerInitPromise?: Promise + private lifecycleHooks: LifecycleHooks | undefined private hookFileWatchers: vscode.FileSystemWatcher[] = [] private hookReloadTimeout?: NodeJS.Timeout private static readonly HOOK_RELOAD_DEBOUNCE_MS = 500 @@ -214,7 +216,8 @@ export class ClineProvider }) // Initialize Hook Manager for lifecycle hooks - this.initializeHookManager().catch((error) => { + // Store the promise so Tasks can wait for initialization if needed + this.hookManagerInitPromise = this.initializeHookManager().catch((error) => { this.log(`Failed to initialize Hook Manager: ${error}`) }) @@ -316,6 +319,65 @@ export class ClineProvider } } + /** + * Fire-and-forget lifecycle hook trigger for Notification events. + * + * Non-blocking by design: errors are logged and do not affect the main flow. + * Hooks are additive and fail-open. + */ + public triggerNotificationHook( + matcher: "permission_prompt" | "idle_prompt" | "auth_success" | "elicitation_dialog", + severity: "info" | "warn" | "error", + message: string, + source: string, + ): void + /** + * Backwards-compatible overload (legacy POC signature). + */ + public triggerNotificationHook( + matcher: "permission_prompt" | "idle_prompt" | "auth_success" | "elicitation_dialog", + data?: { message?: string; title?: string }, + ): void + public triggerNotificationHook( + matcher: "permission_prompt" | "idle_prompt" | "auth_success" | "elicitation_dialog", + severityOrData?: "info" | "warn" | "error" | { message?: string; title?: string }, + message?: string, + source?: string, + ): void { + if (!this.lifecycleHooks) return + + // When lifecycle hooks fire from provider-level events, we still want them + // rendered in the active task's chat history (same as tool hooks). + const currentTask = this.getCurrentTask() + const sayCallback = + currentTask && typeof (currentTask as any)?.sayLifecycleHookRow === "function" + ? (currentTask as any).sayLifecycleHookRow.bind(currentTask) + : undefined + const updateSayCallback = + currentTask && typeof (currentTask as any)?.updateLifecycleHookRow === "function" + ? (currentTask as any).updateLifecycleHookRow.bind(currentTask) + : undefined + + const isNewSignature = typeof severityOrData === "string" + const notificationData = isNewSignature + ? { + severity: severityOrData, + message: message ?? "", + source: source ?? "", + } + : severityOrData + + void this.lifecycleHooks + .executeNotification(matcher, notificationData, { + sayCallback, + updateSayCallback, + outputStatusCallback: (payload) => this.postHookExecutionOutputStatusToWebview(payload), + }) + .catch((err) => { + console.error("[ClineProvider] Notification hook error:", err) + }) + } + /** * Override EventEmitter's on method to match TaskProviderLike interface */ @@ -468,6 +530,22 @@ export class ClineProvider return } + const currentTask = this.getCurrentTask() + + // Execute SessionEnd hooks (non-blocking, fail-open) + // Wire say/update callbacks so hook execution rows appear in chat. + if (this.lifecycleHooks) { + void this.lifecycleHooks + .executeSessionEnd({ + sayCallback: currentTask?.sayLifecycleHookRow.bind(currentTask), + updateSayCallback: currentTask?.updateLifecycleHookRow.bind(currentTask), + outputStatusCallback: (payload) => this.postHookExecutionOutputStatusToWebview(payload), + }) + .catch((err) => { + console.error("[ClineProvider] SessionEnd hook error:", err) + }) + } + // Pop the top Cline instance from the stack. let task = this.clineStack.pop() @@ -1047,6 +1125,19 @@ export class ClineProvider this.log( `[createTaskWithHistoryItem] ${task.parentTask ? "child" : "parent"} task ${task.taskId}.${task.instanceId} instantiated`, ) + + // Execute SessionStart hooks when restoring from history (fail-open) + if (this.lifecycleHooks) { + try { + await this.lifecycleHooks.executeSessionStart("resume", { + sayCallback: task.sayLifecycleHookRow.bind(task), + updateSayCallback: task.updateLifecycleHookRow.bind(task), + outputStatusCallback: (payload) => this.postHookExecutionOutputStatusToWebview(payload), + }) + } catch (err) { + console.error("[ClineProvider] SessionStart hook error:", err) + } + } } // Check if there's a pending edit after checkpoint restoration @@ -1652,6 +1743,12 @@ export class ClineProvider } await this.upsertProviderProfile(currentApiConfigName, newConfiguration) + + // Notification hook (proof-of-concept): successful OpenRouter auth/key exchange. + this.triggerNotificationHook("auth_success", { + title: "Authentication successful", + message: "OpenRouter API key saved", + }) } // Requesty @@ -1758,7 +1855,7 @@ export class ClineProvider if (!task) { throw new Error(`Task with id ${taskId} not found in stack`) } - await task.condenseContext() + await task.condenseContext(true) // manual trigger await this.postMessageToWebview({ type: "condenseTaskContextResponse", text: taskId }) } @@ -2688,6 +2785,25 @@ export class ClineProvider await this.hookManager.loadHooksConfig() this.log("[HookManager] Hooks loaded successfully") + // Initialize LifecycleHooks adapter once HookManager is available + this.lifecycleHooks = new LifecycleHooks( + () => this.hookManager ?? null, + () => this.contextProxy.getValues().hooksEnabled ?? true, + { + // Stream terminal-style hook output to the settings activity log. + outputStatusCallback: (status) => this.postHookExecutionOutputStatusToWebview(status), + // NOTE: sayCallback/updateSayCallback require task-level chat context. + // Provider-level LifecycleHooks runs (SessionStart/End/Notification) currently omit them. + }, + ) + + // Ensure lifecycle hooks use the active task/session context when available. + this.lifecycleHooks.setSessionContextGetter(() => ({ + cwd: this.cwd, + taskId: this.getCurrentTask()?.taskId ?? "unknown", + mode: this.contextProxy.getValues().mode, + })) + // Set up file watchers for hook configuration files this.setupHookFileWatchers(cwd, state?.mode) @@ -2699,6 +2815,7 @@ export class ClineProvider ) // Don't throw - hooks are optional this.hookManager = undefined + this.lifecycleHooks = undefined } } @@ -2801,10 +2918,15 @@ export class ClineProvider // Clear the hook manager reference this.hookManager = undefined + this.lifecycleHooks = undefined this.log("[HookManager] Hook manager disposed") } + public getLifecycleHooks(): LifecycleHooks | undefined { + return this.lifecycleHooks + } + /** * Get the Hook Manager instance. */ @@ -2812,6 +2934,14 @@ export class ClineProvider return this.hookManager } + /** + * Get the HookManager initialization promise. + * Tasks can await this to ensure hooks are ready before execution. + */ + public getHookManagerInitPromise(): Promise | undefined { + return this.hookManagerInitPromise + } + /** * Reload the Hook Manager configuration. * Call this when hooks configuration files may have changed. @@ -3110,8 +3240,10 @@ export class ClineProvider remoteControlEnabled, } = await this.getState() + const isTopLevelTask = !parentTask + // Single-open-task invariant: always enforce for user-initiated top-level tasks - if (!parentTask) { + if (isTopLevelTask) { try { await this.removeClineFromStack() } catch { @@ -3145,6 +3277,19 @@ export class ClineProvider await this.addClineToStack(task) + // Execute SessionStart hooks for new top-level tasks (fail-open) + if (isTopLevelTask && this.lifecycleHooks) { + try { + await this.lifecycleHooks.executeSessionStart("startup", { + sayCallback: task.sayLifecycleHookRow.bind(task), + updateSayCallback: task.updateLifecycleHookRow.bind(task), + outputStatusCallback: (payload) => this.postHookExecutionOutputStatusToWebview(payload), + }) + } catch (err) { + console.error("[ClineProvider] SessionStart hook error:", err) + } + } + this.log( `[createTask] ${task.parentTask ? "child" : "parent"} task ${task.taskId}.${task.instanceId} instantiated`, ) @@ -3229,6 +3374,19 @@ export class ClineProvider if (this.clineStack.length > 0) { const task = this.clineStack[this.clineStack.length - 1] console.log(`[clearTask] clearing task ${task.taskId}.${task.instanceId}`) + + // Execute SessionStart hooks for clear/reset (fail-open) + if (this.lifecycleHooks) { + try { + await this.lifecycleHooks.executeSessionStart("clear", { + sayCallback: task.sayLifecycleHookRow.bind(task), + updateSayCallback: task.updateLifecycleHookRow.bind(task), + outputStatusCallback: (payload) => this.postHookExecutionOutputStatusToWebview(payload), + }) + } catch (err) { + console.error("[ClineProvider] SessionStart hook error:", err) + } + } await this.removeClineFromStack() } } @@ -3458,6 +3616,53 @@ export class ClineProvider initialStatus: "active", }) + // 4b) Execute SubagentStart hooks (non-blocking) + // This is the common integration point for ALL delegation paths (including `new_task`). + // Fail-open by design: delegation must proceed even if hooks fail. + try { + const lifecycleHooks = this.getLifecycleHooks() + if (lifecycleHooks) { + const sayCallback = + typeof (child as any)?.sayLifecycleHookRow === "function" + ? (child as any).sayLifecycleHookRow.bind(child) + : undefined + const updateSayCallback = + typeof (child as any)?.updateLifecycleHookRow === "function" + ? (child as any).updateLifecycleHookRow.bind(child) + : undefined + + void lifecycleHooks + .executeSubagentStart( + { + parentTaskId, + childTaskId: child.taskId, + mode, + }, + sayCallback || updateSayCallback + ? { + sayCallback, + updateSayCallback, + outputStatusCallback: (payload) => + this.postHookExecutionOutputStatusToWebview(payload), + } + : undefined, + ) + .catch((error) => { + this.log( + `[delegateParentAndOpenChild] SubagentStart hook execution failed for ${parentTaskId} -> ${child.taskId}: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + }) + } + } catch (error) { + this.log( + `[delegateParentAndOpenChild] SubagentStart hook execution setup failed for ${parentTaskId} -> ${child.taskId}: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + // 5) Persist parent delegation metadata try { const { historyItem } = await this.getTaskWithId(parentTaskId) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index a7f2fd5adb..d1fa53dcdc 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -2394,6 +2394,12 @@ export const webviewMessageHandler = async ( claudeCodeOAuthManager .waitForCallback() .then(async () => { + provider.triggerNotificationHook( + "auth_success", + "info", + "Successfully signed in to Claude Code", + "webviewMessageHandler:claudeCodeSignIn", + ) vscode.window.showInformationMessage("Successfully signed in to Claude Code") await provider.postStateToWebview() }) @@ -2413,6 +2419,12 @@ export const webviewMessageHandler = async ( try { const { claudeCodeOAuthManager } = await import("../../integrations/claude-code/oauth") await claudeCodeOAuthManager.clearCredentials() + provider.triggerNotificationHook( + "auth_success", + "info", + "Signed out from Claude Code", + "webviewMessageHandler:claudeCodeSignOut", + ) vscode.window.showInformationMessage("Signed out from Claude Code") await provider.postStateToWebview() } catch (error) { @@ -2433,6 +2445,12 @@ export const webviewMessageHandler = async ( openAiCodexOAuthManager .waitForCallback() .then(async () => { + provider.triggerNotificationHook( + "auth_success", + "info", + "Successfully signed in to OpenAI Codex", + "webviewMessageHandler:openAiCodexSignIn", + ) vscode.window.showInformationMessage("Successfully signed in to OpenAI Codex") await provider.postStateToWebview() }) @@ -2452,6 +2470,12 @@ export const webviewMessageHandler = async ( try { const { openAiCodexOAuthManager } = await import("../../integrations/openai-codex/oauth") await openAiCodexOAuthManager.clearCredentials() + provider.triggerNotificationHook( + "auth_success", + "info", + "Signed out from OpenAI Codex", + "webviewMessageHandler:openAiCodexSignOut", + ) vscode.window.showInformationMessage("Signed out from OpenAI Codex") await provider.postStateToWebview() } catch (error) { diff --git a/src/services/hooks/LifecycleHooks.ts b/src/services/hooks/LifecycleHooks.ts new file mode 100644 index 0000000000..c1d9d45ce9 --- /dev/null +++ b/src/services/hooks/LifecycleHooks.ts @@ -0,0 +1,597 @@ +/** + * Lifecycle Hooks Service + * + * Provides integration between non-tool lifecycle events and the hooks system. + * Mirrors ToolExecutionHooks but for session/task lifecycle events. + */ + +import type { HookContext, HookProjectContext, HookSessionContext, HookToolContext, IHookManager } from "./types" +import type { HookEventType } from "./types" +import { isBlockingEvent } from "./types" + +import type { + ClineSay as SayType, + HookExecutionOutputStatusPayload as HookExecutionOutputStatus, +} from "@roo-code/types" + +/** + * Result of a lifecycle hook execution. + */ +export interface LifecycleHookResult { + blocked: boolean + executionId?: string + /** + * Human-readable reason for a block (typically hook stderr). Present only when `blocked: true`. + */ + blockMessage?: string + error?: string +} + +/** + * Options for executing lifecycle hooks. + */ +export interface LifecycleHookOptions { + /** Matcher value for events that support matchers */ + matcher?: string + /** Additional context data specific to the event */ + eventData?: Record +} + +export interface LifecycleHooksCallbacks { + sayCallback?: (type: SayType, message?: string) => Promise + updateSayCallback?: (type: SayType, id: string, message?: string) => Promise + outputStatusCallback?: (status: HookExecutionOutputStatus) => void + onHookStatus?: LifecycleHookStatusCallback +} + +export type LifecycleHookStatusCallback = (status: { + event: string + hookId: string + state: "started" | "completed" | "failed" | "blocked" +}) => void + +type UserPromptSubmitOptions = { + images?: string[] | { count: number; paths?: string[] } + source?: "chat_input" | "edit_message" | "queued_message" +} + +type StopReason = + | "user_request" + | "error" + | "timeout" + | "force" + // Backwards compatibility: legacy PRD/early-implementation reasons + | "user_cancelled" + | "provider_cleanup" + | "rehydrate" + | "other" + +type StopOptions = { + /** High-level reason why stop/abort was requested. */ + reason?: StopReason + /** Whether the task is being abandoned/force-cleaned up. */ + isAbandoned?: boolean + /** + * Force-abort mode: bypass Stop hooks entirely. + * This is a safety valve to ensure internal cleanup cannot be blocked. + */ + isForceAbort?: boolean +} + +type SubagentStartOptions = { + parentTaskId?: string + childTaskId?: string + mode?: string +} + +type SubagentStopOptions = { + parentTaskId?: string + childTaskId?: string + mode?: string + result?: unknown +} + +type SessionEndOptions = { + endReason?: string +} + +type NotificationOptions = { + message?: string + title?: string + severity?: "info" | "warn" | "error" + source?: string +} + +function isLegacySubtaskStartInfo(arg: unknown): arg is { taskId: string; mode?: string } { + return !!arg && typeof arg === "object" && "taskId" in arg +} + +function isLegacySubtaskStopInfo(arg: unknown): arg is { taskId: string; result?: string } { + return !!arg && typeof arg === "object" && "taskId" in arg +} + +function isCallbacks(arg: unknown): arg is LifecycleHooksCallbacks { + if (!arg || typeof arg !== "object") return false + const obj = arg as Record + return "sayCallback" in obj || "updateSayCallback" in obj || "outputStatusCallback" in obj || "onHookStatus" in obj +} + +/** + * LifecycleHooks provides the integration layer for non-tool lifecycle events. + */ +export class LifecycleHooks { + private hookManagerGetter: () => IHookManager | null + private hooksEnabledGetter?: () => boolean + private onHookStatus?: LifecycleHookStatusCallback + + private sayCallback?: (type: SayType, message?: string) => Promise + private updateSayCallback?: (type: SayType, id: string, message?: string) => Promise + private outputStatusCallback?: (status: HookExecutionOutputStatus) => void + + // Default getter to keep the adapter functional even before call sites are updated. + // Call sites can provide a better implementation in follow-up tasks. + private getSessionContext: () => { cwd: string; taskId: string; mode?: string } = () => ({ + cwd: process.cwd(), + taskId: "unknown", + mode: "unknown", + }) + + constructor( + hookManagerGetter: () => IHookManager | null, + isEnabled: () => boolean, + callbacks?: LifecycleHooksCallbacks, + ) + constructor( + hookManager: IHookManager, + getSessionContext: () => { cwd: string; taskId: string; mode?: string }, + onHookStatus?: (status: { + event: string + hookId: string + state: "started" | "completed" | "failed" | "blocked" + }) => void, + ) + constructor( + hookManagerGetterOrInstance: (() => IHookManager | null) | IHookManager, + isEnabledOrGetSessionContext: (() => boolean) | (() => { cwd: string; taskId: string; mode?: string }), + callbacksOrOnHookStatus?: + | LifecycleHooksCallbacks + | ((status: { + event: string + hookId: string + state: "started" | "completed" | "failed" | "blocked" + }) => void), + ) { + // Backwards-compatible constructor: (hookManager, getSessionContext, onHookStatus?) + if (typeof hookManagerGetterOrInstance !== "function") { + this.hookManagerGetter = () => hookManagerGetterOrInstance + // Default to enabled for backwards compatibility. + this.hooksEnabledGetter = undefined + this.getSessionContext = isEnabledOrGetSessionContext as () => { + cwd: string + taskId: string + mode?: string + } + this.onHookStatus = callbacksOrOnHookStatus as LifecycleHookStatusCallback | undefined + return + } + + // New constructor: (hookManagerGetter, isEnabled, callbacks?) + this.hookManagerGetter = hookManagerGetterOrInstance + this.hooksEnabledGetter = isEnabledOrGetSessionContext as () => boolean + const callbacks = callbacksOrOnHookStatus as LifecycleHooksCallbacks | undefined + this.sayCallback = callbacks?.sayCallback + this.updateSayCallback = callbacks?.updateSayCallback + this.outputStatusCallback = callbacks?.outputStatusCallback + this.onHookStatus = callbacks?.onHookStatus + } + + /** + * Optional: allow call sites to provide a session context getter without changing the constructor signature. + */ + setSessionContextGetter(getter: (() => { cwd: string; taskId: string; mode?: string }) | undefined): void { + if (!getter) return + this.getSessionContext = getter + } + + /** + * Update the hook status callback. + */ + setOnHookStatus(callback: LifecycleHookStatusCallback | undefined): void { + this.onHookStatus = callback + } + + /** + * Update the output status callback used for streaming terminal output. + */ + setOutputStatusCallback(callback: ((status: HookExecutionOutputStatus) => void) | undefined): void { + this.outputStatusCallback = callback + } + + /** + * Update the hooks enabled getter. + */ + setHooksEnabledGetter(getter: (() => boolean) | undefined): void { + this.hooksEnabledGetter = getter + } + + /** + * Check if hooks are globally enabled. + * Returns true if no getter is set (backwards compatibility) or if the getter returns true. + */ + private isHooksEnabled(): boolean { + if (!this.hooksEnabledGetter) { + return true + } + return this.hooksEnabledGetter() + } + + private getHookManager(): IHookManager | null { + return this.hookManagerGetter() + } + + /** + * Execute hooks for UserPromptSubmit event (BLOCKING) + * Called before a user prompt is processed. + */ + async executeUserPromptSubmit(prompt: string, callbacks?: LifecycleHooksCallbacks): Promise + async executeUserPromptSubmit( + prompt: string, + options?: UserPromptSubmitOptions, + callbacks?: LifecycleHooksCallbacks, + ): Promise + async executeUserPromptSubmit( + prompt: string, + optionsOrCallbacks?: UserPromptSubmitOptions | LifecycleHooksCallbacks, + callbacks?: LifecycleHooksCallbacks, + ): Promise { + const options = isCallbacks(optionsOrCallbacks) ? undefined : optionsOrCallbacks + const cb = isCallbacks(optionsOrCallbacks) ? optionsOrCallbacks : callbacks + + const images = options?.images + const imagePayload = Array.isArray(images) ? { count: images.length, paths: images } : images + + const promptPayload: Record = { text: prompt } + if (imagePayload) promptPayload.images = imagePayload + if (options?.source) promptPayload.source = options.source + + return this.executeLifecycleHooks( + "UserPromptSubmit", + undefined, + { + prompt: promptPayload, + }, + cb, + ) + } + + /** + * Execute hooks for PreCompact event + * Called before context compaction. + */ + async executePreCompact( + trigger: "manual" | "auto", + callbacks?: LifecycleHooksCallbacks, + ): Promise { + return this.executeLifecycleHooks("PreCompact", trigger, undefined, callbacks) + } + + /** + * Execute hooks for Stop event (BLOCKING) + * Called when task abort is requested. + */ + async executeStop(callbacks?: LifecycleHooksCallbacks): Promise + async executeStop(options?: StopOptions, callbacks?: LifecycleHooksCallbacks): Promise + async executeStop( + optionsOrCallbacks?: StopOptions | LifecycleHooksCallbacks, + callbacks?: LifecycleHooksCallbacks, + ): Promise { + const options = isCallbacks(optionsOrCallbacks) ? undefined : optionsOrCallbacks + const cb = isCallbacks(optionsOrCallbacks) ? optionsOrCallbacks : callbacks + + // Force-abort bypass: never execute Stop hooks. + // This ensures internal cleanup cannot be blocked by external hook scripts. + if (options?.isForceAbort) { + return { blocked: false } + } + + const stopPayload: Record | undefined = options + ? { + ...(options.reason ? { reason: options.reason } : {}), + ...(options.isAbandoned !== undefined ? { isAbandoned: options.isAbandoned } : {}), + } + : undefined + + return this.executeLifecycleHooks( + "Stop", + undefined, + options + ? { + ...(stopPayload ? { stop: stopPayload } : {}), + // legacy field (pre-PRD) + ...(options.reason ? { reason: options.reason } : {}), + } + : undefined, + cb, + ) + } + + /** + * Execute hooks for SubagentStart event + * Called when a subtask is spawned. + */ + async executeSubagentStart( + subtaskInfo?: { taskId: string; mode?: string } | SubagentStartOptions, + callbacks?: LifecycleHooksCallbacks, + ): Promise { + const legacy = isLegacySubtaskStartInfo(subtaskInfo) ? subtaskInfo : undefined + const options: SubagentStartOptions | undefined = legacy ? undefined : (subtaskInfo as SubagentStartOptions) + + const childTaskId = legacy?.taskId ?? options?.childTaskId + const mode = legacy?.mode ?? options?.mode + const parentTaskId = options?.parentTaskId + + return this.executeLifecycleHooks( + "SubagentStart", + undefined, + { + // Legacy field used by existing tests/call sites. + subtask: legacy, + subagent: { + parentTaskId, + childTaskId, + mode, + }, + }, + callbacks, + ) + } + + /** + * Execute hooks for SubagentStop event (BLOCKING) + * Called when returning from a subtask. + */ + async executeSubagentStop( + subtaskInfo?: { taskId: string; result?: string } | SubagentStopOptions, + callbacks?: LifecycleHooksCallbacks, + ): Promise { + const legacy = isLegacySubtaskStopInfo(subtaskInfo) ? subtaskInfo : undefined + const options: SubagentStopOptions | undefined = legacy ? undefined : (subtaskInfo as SubagentStopOptions) + + const childTaskId = legacy?.taskId ?? options?.childTaskId + const result = legacy?.result ?? options?.result + const mode = options?.mode + const parentTaskId = options?.parentTaskId + + return this.executeLifecycleHooks( + "SubagentStop", + undefined, + { + // Legacy field used by existing tests/call sites. + subtask: legacy, + subagent: { + parentTaskId, + childTaskId, + mode, + result, + }, + }, + callbacks, + ) + } + + /** + * Execute hooks for SessionStart event. + */ + async executeSessionStart( + trigger: "startup" | "resume" | "clear" | "compact", + callbacks?: LifecycleHooksCallbacks, + ): Promise { + return this.executeLifecycleHooks("SessionStart", trigger, undefined, callbacks, { source: trigger }) + } + + /** + * Execute hooks for SessionEnd event. + */ + async executeSessionEnd(callbacks?: LifecycleHooksCallbacks): Promise + async executeSessionEnd( + options?: SessionEndOptions, + callbacks?: LifecycleHooksCallbacks, + ): Promise + async executeSessionEnd( + optionsOrCallbacks?: SessionEndOptions | LifecycleHooksCallbacks, + callbacks?: LifecycleHooksCallbacks, + ): Promise { + const options = isCallbacks(optionsOrCallbacks) ? undefined : optionsOrCallbacks + const cb = isCallbacks(optionsOrCallbacks) ? optionsOrCallbacks : callbacks + return this.executeLifecycleHooks("SessionEnd", undefined, undefined, cb, { endReason: options?.endReason }) + } + + /** + * Execute hooks for Notification event. + */ + async executeNotification( + type: "permission_prompt" | "idle_prompt" | "auth_success" | "elicitation_dialog", + notificationData?: NotificationOptions, + callbacks?: LifecycleHooksCallbacks, + ): Promise { + const message = notificationData?.message ?? notificationData?.title ?? "" + const notificationPayload: Record = { + type, + message, + } + if (notificationData?.severity) notificationPayload.severity = notificationData.severity + if (notificationData?.source) notificationPayload.source = notificationData.source + + return this.executeLifecycleHooks( + "Notification", + type, + { + notification: notificationPayload, + }, + callbacks, + ) + } + + /** + * Internal method to execute lifecycle hooks. + */ + private async executeLifecycleHooks( + event: HookEventType, + matcher?: string, + additionalContext?: Record, + callbacks?: LifecycleHooksCallbacks, + sessionOverrides?: Partial, + ): Promise { + const hookManager = this.getHookManager() + if (!this.isHooksEnabled() || !hookManager) { + return { blocked: false } + } + + const sessionContext = this.getSessionContext() + + const session: HookSessionContext = { + taskId: sessionContext.taskId, + // Lifecycle hooks may be triggered outside a specific provider instance. + // Use taskId as a stable fallback to satisfy HookContext requirements. + sessionId: sessionContext.taskId, + mode: sessionContext.mode ?? "unknown", + ...sessionOverrides, + } + + const project: HookProjectContext = { + directory: sessionContext.cwd, + name: sessionContext.cwd.split(/[/\\]/).filter(Boolean).pop() ?? sessionContext.cwd, + } + + const hookContext: HookContext = { + event, + timestamp: new Date().toISOString(), + session, + project, + ...additionalContext, + } + + // Standard matcher field for lifecycle events with matchers. + if (matcher) { + hookContext.matcher = matcher + } + + // For lifecycle events that support matchers, we reuse tool matcher filtering by + // setting a synthetic tool context name. + if (matcher) { + const tool: HookToolContext = { + name: matcher, + input: {}, + } + hookContext.tool = tool + } + + const executionId = `lifecycle-${sessionContext.taskId}-${event}-${Date.now()}` + + try { + const result = await hookManager.executeHooks(event, { + context: hookContext, + executionId, + outputStatusCallback: callbacks?.outputStatusCallback ?? this.outputStatusCallback, + hookExecutionCallback: this.buildHookExecutionCallback(callbacks), + }) + + // Enforce contract: only blocking events can report blocked. + const blocked = isBlockingEvent(event) ? result.blocked || false : false + const blockMessage = blocked ? result.blockMessage : undefined + + return { + blocked, + executionId, + blockMessage, + } + } catch (error) { + console.error(`[LifecycleHooks] Error executing ${event} hooks:`, error) + return { + blocked: false, + executionId, + error: error instanceof Error ? error.message : String(error), + } + } + } + + // Persisted hook_execution rows need to be stable across all hook runs for a task. + // Keep this mapping on the instance so multiple executeHooks() calls don't lose update ability. + private hookExecutionRowIdByExecutionId = new Map() + private hookExecutionCallbackInstance?: NonNullable + + private buildHookExecutionCallback( + callbacksOverride?: LifecycleHooksCallbacks, + ): NonNullable { + // If call sites pass callbacks per execution, avoid caching so we don't accidentally + // mix callbacks across tasks. + if (!callbacksOverride && this.hookExecutionCallbackInstance) { + return this.hookExecutionCallbackInstance + } + + const sayCallback = callbacksOverride?.sayCallback ?? this.sayCallback + const updateSayCallback = callbacksOverride?.updateSayCallback ?? this.updateSayCallback + const onHookStatus = callbacksOverride?.onHookStatus ?? this.onHookStatus + const hookExecutionRowIdByExecutionId = callbacksOverride + ? new Map() + : this.hookExecutionRowIdByExecutionId + + const callback: NonNullable = async (evt) => { + onHookStatus?.({ + event: String(evt.event), + hookId: evt.hookId, + state: evt.phase, + }) + + if (!sayCallback) return + + if (evt.phase === "started") { + const payload = { + executionId: evt.executionId, + hookId: evt.hookId, + event: evt.event, + toolName: evt.toolName, + command: evt.command, + } + + const rowId = await sayCallback("hook_execution", JSON.stringify(payload)) + hookExecutionRowIdByExecutionId.set( + evt.executionId, + typeof rowId === "string" ? rowId : evt.executionId, + ) + return + } + + // Terminal states: update persisted row with a compressed output summary. + const rowId = hookExecutionRowIdByExecutionId.get(evt.executionId) + if (!rowId || !updateSayCallback) { + return + } + + const payload = { + executionId: evt.executionId, + hookId: evt.hookId, + event: evt.event, + toolName: evt.toolName, + command: evt.command, + result: { + phase: evt.phase, + exitCode: evt.exitCode, + durationMs: evt.durationMs, + blockMessage: evt.blockMessage, + error: evt.error, + modified: evt.modified, + outputSummary: evt.outputSummary, + }, + } + + await updateSayCallback("hook_execution", rowId, JSON.stringify(payload)) + } + + if (!callbacksOverride) { + this.hookExecutionCallbackInstance = callback + } + + return callback + } +} diff --git a/src/services/hooks/ToolExecutionHooks.ts b/src/services/hooks/ToolExecutionHooks.ts index 0fefef03d1..6b800734d9 100644 --- a/src/services/hooks/ToolExecutionHooks.ts +++ b/src/services/hooks/ToolExecutionHooks.ts @@ -94,7 +94,8 @@ export type HooksEnabledGetter = () => boolean * Orchestrates hook execution for tool lifecycle events. */ export class ToolExecutionHooks { - private hookManager: IHookManager | null + private hookManagerGetter: () => IHookManager | null + private hookManagerInitPromiseGetter?: () => Promise | undefined private statusCallback?: HookStatusCallback private outputStatusCallback?: HookOutputStatusCallback private sayCallback?: SayCallback @@ -102,14 +103,16 @@ export class ToolExecutionHooks { private hooksEnabledGetter?: HooksEnabledGetter constructor( - hookManager: IHookManager | null, + hookManagerGetter: () => IHookManager | null, statusCallback?: HookStatusCallback, outputStatusCallback?: HookOutputStatusCallback, sayCallback?: SayCallback, updateSayCallback?: UpdateSayCallback, hooksEnabledGetter?: HooksEnabledGetter, + hookManagerInitPromiseGetter?: () => Promise | undefined, ) { - this.hookManager = hookManager + this.hookManagerGetter = hookManagerGetter + this.hookManagerInitPromiseGetter = hookManagerInitPromiseGetter this.statusCallback = statusCallback this.outputStatusCallback = outputStatusCallback this.sayCallback = sayCallback @@ -118,10 +121,50 @@ export class ToolExecutionHooks { } /** - * Update the hook manager instance. + * Resolve the hook manager lazily. + * + * This is intentionally a getter rather than a stored instance because the + * hook manager initializes asynchronously and may be unavailable at + * ToolExecutionHooks construction time. */ - setHookManager(hookManager: IHookManager | null): void { - this.hookManager = hookManager + private getHookManager(): IHookManager | null { + return this.hookManagerGetter() + } + + /** + * Wait for HookManager initialization to complete. + * Returns the HookManager if available after waiting, or null if timeout/unavailable. + * + * @param timeoutMs - Maximum time to wait for initialization (default: 5000ms) + */ + private async waitForHookManager(timeoutMs: number = 5000): Promise { + // Check if already available + const manager = this.getHookManager() + if (manager) { + return manager + } + + // Get the initialization promise + const initPromise = this.hookManagerInitPromiseGetter?.() + if (!initPromise) { + // No initialization in progress + return null + } + + try { + // Wait for initialization with timeout + await Promise.race([ + initPromise, + new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeoutMs)), + ]) + + // Return the manager after initialization completes + return this.getHookManager() + } catch (error) { + // Timeout or initialization failed + console.warn(`[ToolExecutionHooks] Failed to wait for HookManager:`, error) + return null + } } /** @@ -173,9 +216,23 @@ export class ToolExecutionHooks { * @returns Result indicating whether to proceed, and optionally modified input */ async executePreToolUse(context: ToolExecutionContext): Promise { + // Wait for HookManager initialization (with timeout) + const hookManager = await this.waitForHookManager() + const isEnabled = this.isHooksEnabled() + + // DIAGNOSTIC: Log hook manager state + console.log(`[ToolExecutionHooks] PreToolUse for "${context.toolName}":`, { + isEnabled, + hasHookManager: !!hookManager, + hasSnapshot: hookManager?.getConfigSnapshot() !== null, + }) + // Check global hooks enabled state first - if (!this.isHooksEnabled() || !this.hookManager) { + if (!isEnabled || !hookManager) { // No hooks configured - proceed normally + console.log( + `[ToolExecutionHooks] PreToolUse skipped - isEnabled: ${isEnabled}, hasHookManager: ${!!hookManager}`, + ) return { proceed: true, hookResult: { @@ -196,7 +253,7 @@ export class ToolExecutionHooks { }) try { - const result = await this.hookManager.executeHooks("PreToolUse", { + const result = await hookManager.executeHooks("PreToolUse", { context: hookContext, executionId: `${context.session.taskId}:${Date.now()}`, outputStatusCallback: this.outputStatusCallback, @@ -271,8 +328,22 @@ export class ToolExecutionHooks { output: unknown, duration: number, ): Promise { + // Wait for HookManager initialization (with timeout) + const hookManager = await this.waitForHookManager() + const isEnabled = this.isHooksEnabled() + + // DIAGNOSTIC: Log hook manager state + console.log(`[ToolExecutionHooks] PostToolUse for "${context.toolName}":`, { + isEnabled, + hasHookManager: !!hookManager, + hasSnapshot: hookManager?.getConfigSnapshot() !== null, + }) + // Check global hooks enabled state first - if (!this.isHooksEnabled() || !this.hookManager) { + if (!isEnabled || !hookManager) { + console.log( + `[ToolExecutionHooks] PostToolUse skipped - isEnabled: ${isEnabled}, hasHookManager: ${!!hookManager}`, + ) return { results: [], blocked: false, @@ -292,7 +363,7 @@ export class ToolExecutionHooks { }) try { - const result = await this.hookManager.executeHooks("PostToolUse", { + const result = await hookManager.executeHooks("PostToolUse", { context: hookContext, executionId: `${context.session.taskId}:${Date.now()}`, outputStatusCallback: this.outputStatusCallback, @@ -336,8 +407,11 @@ export class ToolExecutionHooks { error: string, errorMessage: string, ): Promise { + // Wait for HookManager initialization (with timeout) + const hookManager = await this.waitForHookManager() + // Check global hooks enabled state first - if (!this.isHooksEnabled() || !this.hookManager) { + if (!this.isHooksEnabled() || !hookManager) { return { results: [], blocked: false, @@ -357,7 +431,7 @@ export class ToolExecutionHooks { }) try { - const result = await this.hookManager.executeHooks("PostToolUseFailure", { + const result = await hookManager.executeHooks("PostToolUseFailure", { context: hookContext, executionId: `${context.session.taskId}:${Date.now()}`, outputStatusCallback: this.outputStatusCallback, @@ -402,8 +476,11 @@ export class ToolExecutionHooks { * @returns Result indicating whether to proceed with showing the prompt */ async executePermissionRequest(context: ToolExecutionContext): Promise { + // Wait for HookManager initialization (with timeout) + const hookManager = await this.waitForHookManager() + // Check global hooks enabled state first - if (!this.isHooksEnabled() || !this.hookManager) { + if (!this.isHooksEnabled() || !hookManager) { return { proceed: true, hookResult: { @@ -423,7 +500,7 @@ export class ToolExecutionHooks { }) try { - const result = await this.hookManager.executeHooks("PermissionRequest", { + const result = await hookManager.executeHooks("PermissionRequest", { context: hookContext, executionId: `${context.session.taskId}:${Date.now()}`, outputStatusCallback: this.outputStatusCallback, @@ -487,7 +564,8 @@ export class ToolExecutionHooks { * Check if hooks are configured and available. */ hasHooks(): boolean { - return this.isHooksEnabled() && this.hookManager !== null && this.hookManager.getConfigSnapshot() !== null + const hookManager = this.getHookManager() + return this.isHooksEnabled() && hookManager !== null && hookManager.getConfigSnapshot() !== null } /** @@ -614,19 +692,21 @@ export class ToolExecutionHooks { * Create a ToolExecutionHooks instance. */ export function createToolExecutionHooks( - hookManager: IHookManager | null, + hookManagerGetter: () => IHookManager | null, statusCallback?: HookStatusCallback, outputStatusCallback?: HookOutputStatusCallback, sayCallback?: SayCallback, updateSayCallback?: UpdateSayCallback, hooksEnabledGetter?: HooksEnabledGetter, + hookManagerInitPromiseGetter?: () => Promise | undefined, ): ToolExecutionHooks { return new ToolExecutionHooks( - hookManager, + hookManagerGetter, statusCallback, outputStatusCallback, sayCallback, updateSayCallback, hooksEnabledGetter, + hookManagerInitPromiseGetter, ) } diff --git a/src/services/hooks/__tests__/LifecycleHooks.spec.ts b/src/services/hooks/__tests__/LifecycleHooks.spec.ts new file mode 100644 index 0000000000..7dd693dc10 --- /dev/null +++ b/src/services/hooks/__tests__/LifecycleHooks.spec.ts @@ -0,0 +1,538 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { LifecycleHooks } from "../LifecycleHooks" +import type { HookContext, HooksConfigSnapshot, HooksExecutionResult, IHookManager } from "../types" + +describe("LifecycleHooks", () => { + let mockHookManager: IHookManager + let mockGetSessionContext: () => { cwd: string; taskId: string; mode?: string } + let mockOnHookStatus: ReturnType + let lifecycleHooks: LifecycleHooks + + const createMockHookManager = (): IHookManager => + ({ + loadHooksConfig: vi.fn(), + reloadHooksConfig: vi.fn(), + getConfigSnapshot: vi.fn().mockReturnValue({} as HooksConfigSnapshot), + executeHooks: vi.fn().mockResolvedValue({ + results: [], + blocked: false, + totalDuration: 0, + } as HooksExecutionResult), + getEnabledHooks: vi.fn().mockReturnValue([]), + setHookEnabled: vi.fn(), + updateHook: vi.fn(), + getHookExecutionHistory: vi.fn().mockReturnValue([]), + }) as unknown as IHookManager + + beforeEach(() => { + vi.restoreAllMocks() + mockHookManager = createMockHookManager() + mockGetSessionContext = vi.fn().mockReturnValue({ + cwd: "/test/path", + taskId: "test-task-123", + mode: "code", + }) + mockOnHookStatus = vi.fn() + + lifecycleHooks = new LifecycleHooks(mockHookManager, mockGetSessionContext, mockOnHookStatus) + }) + + describe("constructor", () => { + it("creates instance with required dependencies", () => { + expect(lifecycleHooks).toBeInstanceOf(LifecycleHooks) + }) + }) + + const getLastExecuteHooksCall = (): { + event: string + options: { context: HookContext; executionId?: string; hookExecutionCallback?: unknown } + } => { + const calls = vi.mocked(mockHookManager.executeHooks).mock.calls + expect(calls.length).toBeGreaterThan(0) + const [event, options] = calls[calls.length - 1] + return { event, options: options as any } + } + + describe("executeUserPromptSubmit (blocking)", () => { + it("calls hookManager.executeHooks with correct event and context (includes prompt)", async () => { + await lifecycleHooks.executeUserPromptSubmit("test prompt") + + const { event, options } = getLastExecuteHooksCall() + expect(event).toBe("UserPromptSubmit") + expect(options.context).toEqual( + expect.objectContaining({ + event: "UserPromptSubmit", + session: expect.objectContaining({ + taskId: "test-task-123", + sessionId: "test-task-123", + mode: "code", + }), + project: expect.objectContaining({ + directory: "/test/path", + name: "path", + }), + prompt: { text: "test prompt" }, + }), + ) + }) + + it("includes images and source in prompt context when provided", async () => { + await lifecycleHooks.executeUserPromptSubmit("prompt", { + images: ["/a.png", "/b.png"], + source: "chat_input", + }) + + const { options } = getLastExecuteHooksCall() + expect(options.context.prompt).toEqual({ + text: "prompt", + images: { count: 2, paths: ["/a.png", "/b.png"] }, + source: "chat_input", + }) + }) + + it("returns blocked: true when hook manager indicates blocked", async () => { + vi.mocked(mockHookManager.executeHooks).mockResolvedValueOnce({ + results: [], + blocked: true, + blockMessage: "nope", + totalDuration: 1, + } as HooksExecutionResult) + + const result = await lifecycleHooks.executeUserPromptSubmit("test") + expect(result.blocked).toBe(true) + expect(result.blockMessage).toBe("nope") + }) + + it("returns blocked: false when hooks complete normally", async () => { + vi.mocked(mockHookManager.executeHooks).mockResolvedValueOnce({ + results: [], + blocked: false, + totalDuration: 1, + } as HooksExecutionResult) + + const result = await lifecycleHooks.executeUserPromptSubmit("test") + expect(result.blocked).toBe(false) + }) + }) + + describe("hooksEnabled gating", () => { + it("does not execute hooks when disabled", async () => { + lifecycleHooks.setHooksEnabledGetter(() => false) + + const result = await lifecycleHooks.executeStop() + + expect(result.blocked).toBe(false) + expect(mockHookManager.executeHooks).not.toHaveBeenCalled() + }) + }) + + describe("executePreCompact", () => { + it("calls hookManager.executeHooks with 'PreCompact' event and matcher (manual)", async () => { + await lifecycleHooks.executePreCompact("manual") + + const { event, options } = getLastExecuteHooksCall() + expect(event).toBe("PreCompact") + expect(options.context.tool).toEqual({ name: "manual", input: {} }) + }) + + it("passes matcher correctly (auto)", async () => { + await lifecycleHooks.executePreCompact("auto") + const { options } = getLastExecuteHooksCall() + expect(options.context.tool).toEqual({ name: "auto", input: {} }) + }) + + it("always returns blocked: false even if hook manager reports blocked", async () => { + vi.mocked(mockHookManager.executeHooks).mockResolvedValueOnce({ + results: [], + blocked: true, + totalDuration: 1, + } as HooksExecutionResult) + + const result = await lifecycleHooks.executePreCompact("manual") + expect(result.blocked).toBe(false) + }) + }) + + describe("executeStop (blocking)", () => { + it("calls hookManager.executeHooks with 'Stop' event", async () => { + await lifecycleHooks.executeStop() + const { event } = getLastExecuteHooksCall() + expect(event).toBe("Stop") + }) + + it("includes stop payload in context when options are provided", async () => { + await lifecycleHooks.executeStop({ reason: "timeout", isAbandoned: true }) + const { options } = getLastExecuteHooksCall() + expect(options.context).toEqual( + expect.objectContaining({ + stop: { reason: "timeout", isAbandoned: true }, + // legacy field preserved + reason: "timeout", + }), + ) + }) + + it("returns blockMessage when blocked", async () => { + vi.mocked(mockHookManager.executeHooks).mockResolvedValueOnce({ + results: [], + blocked: true, + blockMessage: "policy", + totalDuration: 1, + } as HooksExecutionResult) + + const result = await lifecycleHooks.executeStop() + expect(result.blocked).toBe(true) + expect(result.blockMessage).toBe("policy") + }) + + it("returns blocked: true when hook manager indicates blocked", async () => { + vi.mocked(mockHookManager.executeHooks).mockResolvedValueOnce({ + results: [], + blocked: true, + totalDuration: 1, + } as HooksExecutionResult) + + const result = await lifecycleHooks.executeStop() + expect(result.blocked).toBe(true) + }) + + it("returns blocked: false when hooks complete normally", async () => { + vi.mocked(mockHookManager.executeHooks).mockResolvedValueOnce({ + results: [], + blocked: false, + totalDuration: 1, + } as HooksExecutionResult) + + const result = await lifecycleHooks.executeStop() + expect(result.blocked).toBe(false) + }) + }) + + describe("executeSubagentStart (non-blocking)", () => { + it("calls hookManager.executeHooks with 'SubagentStart' event", async () => { + await lifecycleHooks.executeSubagentStart() + const { event } = getLastExecuteHooksCall() + expect(event).toBe("SubagentStart") + }) + + it("passes subtask info in context when provided", async () => { + await lifecycleHooks.executeSubagentStart({ taskId: "subtask-1", mode: "architect" }) + const { options } = getLastExecuteHooksCall() + expect(options.context).toEqual( + expect.objectContaining({ + subtask: { taskId: "subtask-1", mode: "architect" }, + }), + ) + }) + + it("includes parent/child context in subagent payload when provided", async () => { + await lifecycleHooks.executeSubagentStart({ + parentTaskId: "parent-1", + childTaskId: "child-1", + mode: "code", + }) + const { options } = getLastExecuteHooksCall() + expect(options.context.subagent).toEqual({ + parentTaskId: "parent-1", + childTaskId: "child-1", + mode: "code", + }) + }) + + it("always returns blocked: false even if hook manager reports blocked", async () => { + vi.mocked(mockHookManager.executeHooks).mockResolvedValueOnce({ + results: [], + blocked: true, + totalDuration: 1, + } as HooksExecutionResult) + + const result = await lifecycleHooks.executeSubagentStart({ taskId: "subtask-1" }) + expect(result.blocked).toBe(false) + }) + }) + + describe("executeSubagentStop (blocking)", () => { + it("calls hookManager.executeHooks with 'SubagentStop' event", async () => { + await lifecycleHooks.executeSubagentStop() + const { event } = getLastExecuteHooksCall() + expect(event).toBe("SubagentStop") + }) + + it("returns blocked: true/false based on hook result", async () => { + vi.mocked(mockHookManager.executeHooks).mockResolvedValueOnce({ + results: [], + blocked: true, + totalDuration: 1, + } as HooksExecutionResult) + + const blockedResult = await lifecycleHooks.executeSubagentStop({ taskId: "subtask-1", result: "ok" }) + expect(blockedResult.blocked).toBe(true) + + vi.mocked(mockHookManager.executeHooks).mockResolvedValueOnce({ + results: [], + blocked: false, + totalDuration: 1, + } as HooksExecutionResult) + + const allowedResult = await lifecycleHooks.executeSubagentStop({ taskId: "subtask-1", result: "ok" }) + expect(allowedResult.blocked).toBe(false) + }) + + it("returns blockMessage when blocked", async () => { + vi.mocked(mockHookManager.executeHooks).mockResolvedValueOnce({ + results: [], + blocked: true, + blockMessage: "policy", + totalDuration: 1, + } as HooksExecutionResult) + + const result = await lifecycleHooks.executeSubagentStop({ taskId: "subtask-1", result: "ok" }) + expect(result.blocked).toBe(true) + expect(result.blockMessage).toBe("policy") + }) + + it("includes parent/child context and result in subagent payload when provided", async () => { + await lifecycleHooks.executeSubagentStop({ + parentTaskId: "parent-1", + childTaskId: "child-1", + mode: "debug", + result: { ok: true }, + }) + const { options } = getLastExecuteHooksCall() + expect(options.context.subagent).toEqual({ + parentTaskId: "parent-1", + childTaskId: "child-1", + mode: "debug", + result: { ok: true }, + }) + }) + }) + + describe("executeSessionStart", () => { + it("calls with correct event and passes trigger matcher (startup/resume/clear/compact)", async () => { + const triggers = ["startup", "resume", "clear", "compact"] as const + + for (const trigger of triggers) { + vi.mocked(mockHookManager.executeHooks).mockClear() + await lifecycleHooks.executeSessionStart(trigger) + const { event, options } = getLastExecuteHooksCall() + expect(event).toBe("SessionStart") + expect(options.context.tool).toEqual({ name: trigger, input: {} }) + } + }) + + it("always returns blocked: false (non-blocking)", async () => { + vi.mocked(mockHookManager.executeHooks).mockResolvedValueOnce({ + results: [], + blocked: true, + totalDuration: 1, + } as HooksExecutionResult) + + const result = await lifecycleHooks.executeSessionStart("startup") + expect(result.blocked).toBe(false) + }) + + it("sets session.source to the trigger", async () => { + await lifecycleHooks.executeSessionStart("resume") + const { options } = getLastExecuteHooksCall() + expect(options.context.session).toEqual( + expect.objectContaining({ + source: "resume", + }), + ) + }) + + it("uses sayCallback/updateSayCallback to create and update a hook_execution row", async () => { + await lifecycleHooks.executeSessionStart("startup") + const { options } = getLastExecuteHooksCall() + const hookExecutionCallback = options.hookExecutionCallback as any + expect(hookExecutionCallback).toEqual(expect.any(Function)) + + const sayCallback = vi.fn().mockResolvedValue("row-1") + const updateSayCallback = vi.fn().mockResolvedValue(undefined) + + await hookExecutionCallback({ + phase: "started", + executionId: "exec-1", + hookId: "hook-1", + event: "SessionStart", + command: "echo hi", + cwd: "/test/path", + }) + + // no sayCallback set on the instance -> nothing is persisted + expect(sayCallback).not.toHaveBeenCalled() + expect(updateSayCallback).not.toHaveBeenCalled() + + // Now execute with callbacks override and verify persisted row behavior. + await lifecycleHooks.executeSessionStart("startup", { sayCallback, updateSayCallback }) + const { options: options2 } = getLastExecuteHooksCall() + const cb2 = options2.hookExecutionCallback as any + + await cb2({ + phase: "started", + executionId: "exec-2", + hookId: "hook-2", + event: "SessionStart", + command: "echo hello", + cwd: "/test/path", + }) + + expect(sayCallback).toHaveBeenCalledWith( + "hook_execution", + expect.stringContaining('"executionId":"exec-2"'), + ) + + await cb2({ + phase: "completed", + executionId: "exec-2", + hookId: "hook-2", + event: "SessionStart", + command: "echo hello", + cwd: "/test/path", + exitCode: 0, + durationMs: 5, + outputSummary: "hello", + }) + + expect(updateSayCallback).toHaveBeenCalledWith( + "hook_execution", + "row-1", + expect.stringContaining('"phase":"completed"'), + ) + }) + + it("reuses cached hookExecutionCallback instance when no callbacks override is provided", async () => { + await lifecycleHooks.executeSessionStart("startup") + const { options: first } = getLastExecuteHooksCall() + const cb1 = first.hookExecutionCallback + + await lifecycleHooks.executeSessionStart("startup") + const { options: second } = getLastExecuteHooksCall() + const cb2 = second.hookExecutionCallback + + expect(cb1).toBe(cb2) + }) + + it("falls back to using executionId as row id when sayCallback returns void", async () => { + const sayCallback = vi.fn().mockResolvedValue(undefined) + const updateSayCallback = vi.fn().mockResolvedValue(undefined) + + await lifecycleHooks.executeSessionStart("startup", { sayCallback, updateSayCallback }) + const { options } = getLastExecuteHooksCall() + const cb = options.hookExecutionCallback as any + + await cb({ + phase: "started", + executionId: "exec-void", + hookId: "hook-1", + event: "SessionStart", + command: "echo hi", + cwd: "/test/path", + }) + + await cb({ + phase: "completed", + executionId: "exec-void", + hookId: "hook-1", + event: "SessionStart", + command: "echo hi", + cwd: "/test/path", + exitCode: 0, + durationMs: 1, + outputSummary: "ok", + }) + + expect(updateSayCallback).toHaveBeenCalledWith("hook_execution", "exec-void", expect.any(String)) + }) + }) + + describe("executeSessionEnd", () => { + it("calls hookManager.executeHooks with 'SessionEnd' event", async () => { + await lifecycleHooks.executeSessionEnd() + const { event } = getLastExecuteHooksCall() + expect(event).toBe("SessionEnd") + }) + + it("always returns blocked: false", async () => { + vi.mocked(mockHookManager.executeHooks).mockResolvedValueOnce({ + results: [], + blocked: true, + totalDuration: 1, + } as HooksExecutionResult) + + const result = await lifecycleHooks.executeSessionEnd() + expect(result.blocked).toBe(false) + }) + + it("includes endReason in session context when provided", async () => { + await lifecycleHooks.executeSessionEnd({ endReason: "stack_removed" }) + const { options } = getLastExecuteHooksCall() + expect(options.context.session).toEqual( + expect.objectContaining({ + endReason: "stack_removed", + }), + ) + }) + }) + + describe("executeNotification", () => { + it("calls with correct event, matcher, and passes notification data in context", async () => { + await lifecycleHooks.executeNotification("permission_prompt", { message: "hello" }) + const { event, options } = getLastExecuteHooksCall() + expect(event).toBe("Notification") + expect(options.context.tool).toEqual({ name: "permission_prompt", input: {} }) + expect(options.context).toEqual( + expect.objectContaining({ + notification: { type: "permission_prompt", message: "hello" }, + }), + ) + }) + + it("uses title as fallback message when message is not provided", async () => { + await lifecycleHooks.executeNotification("idle_prompt", { title: "Title Fallback" }) + const { options } = getLastExecuteHooksCall() + expect(options.context.notification).toEqual({ type: "idle_prompt", message: "Title Fallback" }) + }) + }) + + describe("hookExecutionCallback -> onHookStatus", () => { + it("maps hookExecutionCallback events to onHookStatus", async () => { + await lifecycleHooks.executeStop() + const { options } = getLastExecuteHooksCall() + expect(options.hookExecutionCallback).toEqual(expect.any(Function)) + + await (options.hookExecutionCallback as any)({ + phase: "started", + executionId: "exec-1", + hookId: "hook-1", + event: "Stop", + command: "echo hi", + cwd: "/test/path", + }) + + expect(mockOnHookStatus).toHaveBeenCalledWith({ + event: "Stop", + hookId: "hook-1", + state: "started", + }) + }) + }) + + describe("error handling", () => { + it("returns blocked: false when hook execution throws and logs error", async () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + vi.mocked(mockHookManager.executeHooks).mockRejectedValueOnce(new Error("boom")) + + const result = await lifecycleHooks.executeStop() + + expect(result.blocked).toBe(false) + expect(result.error).toBe("boom") + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[LifecycleHooks] Error executing Stop hooks:", + expect.any(Error), + ) + }) + }) +}) diff --git a/src/services/hooks/__tests__/ToolExecutionHooks.spec.ts b/src/services/hooks/__tests__/ToolExecutionHooks.spec.ts index abd5469d2a..6257fa9d32 100644 --- a/src/services/hooks/__tests__/ToolExecutionHooks.spec.ts +++ b/src/services/hooks/__tests__/ToolExecutionHooks.spec.ts @@ -5,7 +5,7 @@ * - Master toggle (hooksEnabled) enforcement * - Pre/Post tool use hook execution * - Permission request hooks - * - Backwards compatibility when no getter is provided + * - Backwards compatibility when optional hooksEnabledGetter is omitted */ import { @@ -48,7 +48,7 @@ describe("ToolExecutionHooks", () => { const hooksEnabledGetter: HooksEnabledGetter = () => false const hooks = new ToolExecutionHooks( - mockManager, + () => mockManager, undefined, undefined, undefined, @@ -69,7 +69,7 @@ describe("ToolExecutionHooks", () => { const hooksEnabledGetter: HooksEnabledGetter = () => true const hooks = new ToolExecutionHooks( - mockManager, + () => mockManager, undefined, undefined, undefined, @@ -86,7 +86,14 @@ describe("ToolExecutionHooks", () => { const mockManager = createMockHookManager() // No hooksEnabledGetter provided - const hooks = new ToolExecutionHooks(mockManager, undefined, undefined, undefined, undefined, undefined) + const hooks = new ToolExecutionHooks( + () => mockManager, + undefined, + undefined, + undefined, + undefined, + undefined, + ) await hooks.executePreToolUse(createMockContext()) @@ -98,7 +105,7 @@ describe("ToolExecutionHooks", () => { const hooksEnabledGetter: HooksEnabledGetter = () => false const hooks = new ToolExecutionHooks( - mockManager, + () => mockManager, undefined, undefined, undefined, @@ -118,7 +125,7 @@ describe("ToolExecutionHooks", () => { const hooksEnabledGetter: HooksEnabledGetter = () => false const hooks = new ToolExecutionHooks( - mockManager, + () => mockManager, undefined, undefined, undefined, @@ -138,7 +145,7 @@ describe("ToolExecutionHooks", () => { const hooksEnabledGetter: HooksEnabledGetter = () => false const hooks = new ToolExecutionHooks( - mockManager, + () => mockManager, undefined, undefined, undefined, @@ -159,7 +166,7 @@ describe("ToolExecutionHooks", () => { const hooksEnabledGetter: HooksEnabledGetter = () => false const hooks = new ToolExecutionHooks( - mockManager, + () => mockManager, undefined, undefined, undefined, @@ -175,7 +182,7 @@ describe("ToolExecutionHooks", () => { const hooksEnabledGetter: HooksEnabledGetter = () => true const hooks = new ToolExecutionHooks( - mockManager, + () => mockManager, undefined, undefined, undefined, @@ -193,7 +200,7 @@ describe("ToolExecutionHooks", () => { const hooksEnabledGetter: HooksEnabledGetter = () => false const hooks = createToolExecutionHooks( - mockManager, + () => mockManager, undefined, undefined, undefined, @@ -209,7 +216,7 @@ describe("ToolExecutionHooks", () => { it("should work without hooksEnabledGetter for backwards compatibility", async () => { const mockManager = createMockHookManager() - const hooks = createToolExecutionHooks(mockManager, undefined, undefined, undefined) + const hooks = createToolExecutionHooks(() => mockManager, undefined, undefined, undefined) await hooks.executePreToolUse(createMockContext()) expect(mockManager.executeHooks).toHaveBeenCalled() @@ -220,7 +227,14 @@ describe("ToolExecutionHooks", () => { it("should allow updating the getter after construction", async () => { const mockManager = createMockHookManager() - const hooks = new ToolExecutionHooks(mockManager, undefined, undefined, undefined, undefined, () => true) + const hooks = new ToolExecutionHooks( + () => mockManager, + undefined, + undefined, + undefined, + undefined, + () => true, + ) // First call with enabled await hooks.executePreToolUse(createMockContext()) @@ -237,7 +251,14 @@ describe("ToolExecutionHooks", () => { describe("No hook manager", () => { it("should return default results when hookManager is null", async () => { - const hooks = new ToolExecutionHooks(null, undefined, undefined, undefined, undefined, () => true) + const hooks = new ToolExecutionHooks( + () => null, + undefined, + undefined, + undefined, + undefined, + () => true, + ) const result = await hooks.executePreToolUse(createMockContext()) @@ -246,10 +267,45 @@ describe("ToolExecutionHooks", () => { }) it("hasHooks should return false when hookManager is null", () => { - const hooks = new ToolExecutionHooks(null, undefined, undefined, undefined, undefined, () => true) + const hooks = new ToolExecutionHooks( + () => null, + undefined, + undefined, + undefined, + undefined, + () => true, + ) expect(hooks.hasHooks()).toBe(false) }) + + it("should resolve hook manager lazily (null first, then available)", async () => { + let currentManager: IHookManager | null = null + const hookManagerGetter = vi.fn(() => currentManager) + + const hooks = new ToolExecutionHooks( + hookManagerGetter, + undefined, + undefined, + undefined, + undefined, + () => true, + ) + + // First call: manager unavailable, should no-op. + const firstResult = await hooks.executePreToolUse(createMockContext()) + expect(firstResult.proceed).toBe(true) + expect(firstResult.hookResult.results).toEqual([]) + expect(firstResult.hookResult.totalDuration).toBe(0) + expect(hookManagerGetter).toHaveBeenCalledTimes(1) + + // Later: manager becomes available. + currentManager = createMockHookManager() + await hooks.executePreToolUse(createMockContext()) + + expect(hookManagerGetter).toHaveBeenCalledTimes(2) + expect(currentManager.executeHooks).toHaveBeenCalledWith("PreToolUse", expect.any(Object)) + }) }) describe("Status callback", () => { @@ -259,7 +315,7 @@ describe("ToolExecutionHooks", () => { const hooksEnabledGetter: HooksEnabledGetter = () => false const hooks = new ToolExecutionHooks( - mockManager, + () => mockManager, statusCallback, undefined, undefined, @@ -278,7 +334,7 @@ describe("ToolExecutionHooks", () => { const hooksEnabledGetter: HooksEnabledGetter = () => true const hooks = new ToolExecutionHooks( - mockManager, + () => mockManager, statusCallback, undefined, undefined, @@ -300,7 +356,7 @@ describe("ToolExecutionHooks", () => { const hooksEnabledGetter: HooksEnabledGetter = () => true const hooks = new ToolExecutionHooks( - mockManager, + () => mockManager, undefined, undefined, sayCallback, @@ -385,7 +441,7 @@ describe("ToolExecutionHooks", () => { const hooksEnabledGetter: HooksEnabledGetter = () => true const hooks = new ToolExecutionHooks( - mockManager, + () => mockManager, undefined, undefined, sayCallback, @@ -443,7 +499,7 @@ describe("ToolExecutionHooks", () => { const hooksEnabledGetter: HooksEnabledGetter = () => true const hooks = new ToolExecutionHooks( - mockManager, + () => mockManager, undefined, undefined, sayCallback, diff --git a/src/services/hooks/index.ts b/src/services/hooks/index.ts index 538dc7e198..9762b1b5a5 100644 --- a/src/services/hooks/index.ts +++ b/src/services/hooks/index.ts @@ -103,3 +103,6 @@ export { type PermissionRequestResult, type HookStatusCallback, } from "./ToolExecutionHooks" + +// Lifecycle Integration +export { LifecycleHooks, type LifecycleHookResult, type LifecycleHookOptions } from "./LifecycleHooks" diff --git a/src/services/hooks/types.ts b/src/services/hooks/types.ts index 55c577cbdd..66e3d99b0c 100644 --- a/src/services/hooks/types.ts +++ b/src/services/hooks/types.ts @@ -320,6 +320,12 @@ export interface HookSessionContext { taskId: string sessionId: string mode: string + + /** Session start classification (SessionStart) */ + source?: "startup" | "resume" | "clear" | "compact" + + /** Session end classification (SessionEnd) */ + endReason?: string } /** @@ -351,7 +357,16 @@ export interface HookToolContext { */ export interface HookPromptContext { text: string - images?: string[] + /** + * Image metadata for the prompt. + * + * Backwards compatible: + * - historically this was `string[]` (paths) + * - PRD expects count + (optional) sanitized paths + */ + images?: string[] | { count: number; paths?: string[] } + /** If distinguishable: chat_input | edit_message | queued_message */ + source?: "chat_input" | "edit_message" | "queued_message" } /** @@ -360,6 +375,24 @@ export interface HookPromptContext { export interface HookNotificationContext { message: string type: string + severity?: "info" | "warn" | "error" + /** File/component identifier */ + source?: string +} + +/** Stop information for Stop event. */ +export interface HookStopContext { + reason?: "user_cancelled" | "provider_cleanup" | "rehydrate" | "other" + isAbandoned?: boolean +} + +/** Subagent information for SubagentStart/SubagentStop events. */ +export interface HookSubagentContext { + parentTaskId?: string + childTaskId?: string + mode?: string + /** Result payload (best-effort, depends on call site). */ + result?: unknown } /** @@ -379,6 +412,14 @@ export interface HookContext { session: HookSessionContext project: HookProjectContext + /** + * Matcher string for events that support matchers. + * + * Note: tool events still use `tool.name` for matching. + * Lifecycle events MAY set this in addition to any legacy matching mechanism. + */ + matcher?: string + /** Tool context - present for tool-related events */ tool?: HookToolContext @@ -388,7 +429,13 @@ export interface HookContext { /** Notification context - present for Notification event */ notification?: HookNotificationContext - /** Stop reason - present for Stop event */ + /** Stop context - present for Stop event */ + stop?: HookStopContext + + /** Subagent context - present for SubagentStart/SubagentStop */ + subagent?: HookSubagentContext + + /** Stop reason - present for Stop event (legacy field) */ reason?: string /** Summary - present for Stop event */