mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
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:
parent
8ed81bcfa8
commit
a70ff8de22
10 changed files with 1983 additions and 52 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
597
src/services/hooks/LifecycleHooks.ts
Normal file
597
src/services/hooks/LifecycleHooks.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
538
src/services/hooks/__tests__/LifecycleHooks.spec.ts
Normal file
538
src/services/hooks/__tests__/LifecycleHooks.spec.ts
Normal 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),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -103,3 +103,6 @@ export {
|
|||
type PermissionRequestResult,
|
||||
type HookStatusCallback,
|
||||
} from "./ToolExecutionHooks"
|
||||
|
||||
// Lifecycle Integration
|
||||
export { LifecycleHooks, type LifecycleHookResult, type LifecycleHookOptions } from "./LifecycleHooks"
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue