feat: add LifecycleHooks and enhance hook execution

Implement LifecycleHooks and ToolExecutionHooks with full test coverage. Refactor ClineProvider and webviewMessageHandler for new hook lifecycle. Update Task logic for hook integration. All tests pass.
This commit is contained in:
Toray Altas 2026-01-21 09:55:07 -05:00
parent 8ed81bcfa8
commit a70ff8de22
10 changed files with 1983 additions and 52 deletions

View file

@ -542,7 +542,7 @@ export class Task extends EventEmitter<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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<string | void> {
// 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<string | void> {
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<TaskEvents> implements TaskLike {
providerProfile?: string,
): Promise<void> {
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<TaskEvents> implements TaskLike {
}
}
public async condenseContext(): Promise<void> {
public async condenseContext(isManual: boolean = false): Promise<void> {
// 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<TaskEvents> 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<TaskEvents> 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<TaskEvents> implements TaskLike {
* - Ensures next API call includes full context
* - Immediately continues task loop without user interaction
*/
public async resumeAfterDelegation(): Promise<void> {
public async resumeAfterDelegation(subtaskInfo?: { taskId: string; result?: string }): Promise<void> {
// 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<TaskEvents> 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<TaskEvents> 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,

View file

@ -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()

View file

@ -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<void>
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<void> | 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)

View file

@ -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) {

View file

@ -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<string, unknown>
}
export interface LifecycleHooksCallbacks {
sayCallback?: (type: SayType, message?: string) => Promise<string | void>
updateSayCallback?: (type: SayType, id: string, message?: string) => Promise<string | void>
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<string, unknown>
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<string | void>
private updateSayCallback?: (type: SayType, id: string, message?: string) => Promise<string | void>
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<LifecycleHookResult>
async executeUserPromptSubmit(
prompt: string,
options?: UserPromptSubmitOptions,
callbacks?: LifecycleHooksCallbacks,
): Promise<LifecycleHookResult>
async executeUserPromptSubmit(
prompt: string,
optionsOrCallbacks?: UserPromptSubmitOptions | LifecycleHooksCallbacks,
callbacks?: LifecycleHooksCallbacks,
): Promise<LifecycleHookResult> {
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<string, unknown> = { 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<LifecycleHookResult> {
return this.executeLifecycleHooks("PreCompact", trigger, undefined, callbacks)
}
/**
* Execute hooks for Stop event (BLOCKING)
* Called when task abort is requested.
*/
async executeStop(callbacks?: LifecycleHooksCallbacks): Promise<LifecycleHookResult>
async executeStop(options?: StopOptions, callbacks?: LifecycleHooksCallbacks): Promise<LifecycleHookResult>
async executeStop(
optionsOrCallbacks?: StopOptions | LifecycleHooksCallbacks,
callbacks?: LifecycleHooksCallbacks,
): Promise<LifecycleHookResult> {
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<string, unknown> | 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<LifecycleHookResult> {
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<LifecycleHookResult> {
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<LifecycleHookResult> {
return this.executeLifecycleHooks("SessionStart", trigger, undefined, callbacks, { source: trigger })
}
/**
* Execute hooks for SessionEnd event.
*/
async executeSessionEnd(callbacks?: LifecycleHooksCallbacks): Promise<LifecycleHookResult>
async executeSessionEnd(
options?: SessionEndOptions,
callbacks?: LifecycleHooksCallbacks,
): Promise<LifecycleHookResult>
async executeSessionEnd(
optionsOrCallbacks?: SessionEndOptions | LifecycleHooksCallbacks,
callbacks?: LifecycleHooksCallbacks,
): Promise<LifecycleHookResult> {
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<LifecycleHookResult> {
const message = notificationData?.message ?? notificationData?.title ?? ""
const notificationPayload: Record<string, unknown> = {
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<string, unknown>,
callbacks?: LifecycleHooksCallbacks,
sessionOverrides?: Partial<HookSessionContext>,
): Promise<LifecycleHookResult> {
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<string, string>()
private hookExecutionCallbackInstance?: NonNullable<import("./types").ExecuteHooksOptions["hookExecutionCallback"]>
private buildHookExecutionCallback(
callbacksOverride?: LifecycleHooksCallbacks,
): NonNullable<import("./types").ExecuteHooksOptions["hookExecutionCallback"]> {
// 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<string, string>()
: this.hookExecutionRowIdByExecutionId
const callback: NonNullable<import("./types").ExecuteHooksOptions["hookExecutionCallback"]> = 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
}
}

View file

@ -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<void> | 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<void> | 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<IHookManager | null> {
// 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<PreToolUseResult> {
// 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<HooksExecutionResult> {
// 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<HooksExecutionResult> {
// 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<PermissionRequestResult> {
// 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<void> | undefined,
): ToolExecutionHooks {
return new ToolExecutionHooks(
hookManager,
hookManagerGetter,
statusCallback,
outputStatusCallback,
sayCallback,
updateSayCallback,
hooksEnabledGetter,
hookManagerInitPromiseGetter,
)
}

View file

@ -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<typeof vi.fn>
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),
)
})
})
})

View file

@ -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,

View file

@ -103,3 +103,6 @@ export {
type PermissionRequestResult,
type HookStatusCallback,
} from "./ToolExecutionHooks"
// Lifecycle Integration
export { LifecycleHooks, type LifecycleHookResult, type LifecycleHookOptions } from "./LifecycleHooks"

View file

@ -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 */