diff --git a/src/cli/components/HooksManager.tsx b/src/cli/components/HooksManager.tsx index 9b16b27..a1d0d5f 100644 --- a/src/cli/components/HooksManager.tsx +++ b/src/cli/components/HooksManager.tsx @@ -56,6 +56,7 @@ type Screen = const HOOK_EVENTS: { event: HookEvent; description: string }[] = [ { event: "PreToolUse", description: "Before tool execution" }, { event: "PostToolUse", description: "After tool execution" }, + { event: "PostToolUseFailure", description: "After tool execution fails" }, { event: "PermissionRequest", description: "When permission is requested" }, { event: "UserPromptSubmit", description: "When user submits a prompt" }, { event: "Notification", description: "When notifications are sent" }, diff --git a/src/hooks/executor.ts b/src/hooks/executor.ts index c5c9ae8..b781bb3 100644 --- a/src/hooks/executor.ts +++ b/src/hooks/executor.ts @@ -138,13 +138,23 @@ function executeWithLauncher( if (!resolved) { resolved = true; // Log hook completion with command for context - const exitLabel = + // Show exit code with color: green for 0, red for 2, yellow for errors + const exitCode = result.exitCode === HookExitCode.ALLOW - ? "\x1b[32m✓ allowed\x1b[0m" + ? 0 : result.exitCode === HookExitCode.BLOCK - ? "\x1b[31m✗ blocked\x1b[0m" - : "\x1b[33m⚠ error\x1b[0m"; - console.log(`\x1b[90m[hook] ${command}\x1b[0m`); + ? 2 + : 1; + const exitColor = + result.exitCode === HookExitCode.ALLOW + ? "\x1b[32m" + : result.exitCode === HookExitCode.BLOCK + ? "\x1b[31m" + : "\x1b[33m"; + const exitLabel = result.timedOut + ? `${exitColor}timeout\x1b[0m` + : `${exitColor}exit ${exitCode}\x1b[0m`; + console.log(`\x1b[90m[hook:${input.event_type}] ${command}\x1b[0m`); console.log( `\x1b[90m \u23BF ${exitLabel} (${result.durationMs}ms)\x1b[0m`, ); diff --git a/src/hooks/index.ts b/src/hooks/index.ts index f6b9b71..39a8977 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -9,6 +9,7 @@ import type { HookExecutionResult, NotificationHookInput, PermissionRequestHookInput, + PostToolUseFailureHookInput, PostToolUseHookInput, PreCompactHookInput, PreToolUseHookInput, @@ -100,6 +101,57 @@ export async function runPostToolUseHooks( return executeHooksParallel(hooks, input, workingDirectory); } +/** + * Run PostToolUseFailure hooks after a tool has failed + * These run in parallel and cannot block (tool already failed) + * Stderr from hooks with exit code 2 is fed back to the agent + */ +export async function runPostToolUseFailureHooks( + toolName: string, + toolInput: Record, + errorMessage: string, + errorType?: string, + toolCallId?: string, + workingDirectory: string = process.cwd(), + agentId?: string, + precedingReasoning?: string, + precedingAssistantMessage?: string, +): Promise { + const hooks = await getHooksForEvent( + "PostToolUseFailure", + toolName, + workingDirectory, + ); + if (hooks.length === 0) { + return { blocked: false, errored: false, feedback: [], results: [] }; + } + + const input: PostToolUseFailureHookInput = { + event_type: "PostToolUseFailure", + working_directory: workingDirectory, + tool_name: toolName, + tool_input: toolInput, + tool_call_id: toolCallId, + error_message: errorMessage, + error_type: errorType, + agent_id: agentId, + preceding_reasoning: precedingReasoning, + preceding_assistant_message: precedingAssistantMessage, + }; + + // Run in parallel since PostToolUseFailure cannot block + // Use standard executeHooksParallel - feedback collected on exit 2 + const result = await executeHooksParallel(hooks, input, workingDirectory); + + // PostToolUseFailure never actually blocks (tool already failed) + return { + blocked: false, + errored: result.errored, + feedback: result.feedback, + results: result.results, + }; +} + /** * Run PermissionRequest hooks when a permission dialog would be shown * Can auto-allow (exit 0) or auto-deny (exit 2) the permission diff --git a/src/hooks/types.ts b/src/hooks/types.ts index 54247b8..899f54f 100644 --- a/src/hooks/types.ts +++ b/src/hooks/types.ts @@ -7,6 +7,7 @@ export type ToolHookEvent = | "PreToolUse" // Runs before tool calls (can block them) | "PostToolUse" // Runs after tool calls complete (cannot block) + | "PostToolUseFailure" // Runs after tool calls fail (cannot block, feeds stderr back to agent) | "PermissionRequest"; // Runs when a permission dialog is shown (can allow or deny) /** @@ -83,6 +84,7 @@ export type HooksConfig = { export const TOOL_EVENTS: Set = new Set([ "PreToolUse", "PostToolUse", + "PostToolUseFailure", "PermissionRequest", ]); @@ -192,6 +194,30 @@ export interface PostToolUseHookInput extends HookInputBase { preceding_assistant_message?: string; } +/** + * Input for PostToolUseFailure hooks + * Triggered after a tool call fails. Non-blocking, but stderr is fed back to the agent. + */ +export interface PostToolUseFailureHookInput extends HookInputBase { + event_type: "PostToolUseFailure"; + /** Name of the tool that failed */ + tool_name: string; + /** Tool input arguments */ + tool_input: Record; + /** Tool call ID */ + tool_call_id?: string; + /** Error message from the tool failure */ + error_message: string; + /** Error type/name (e.g., "AbortError", "TypeError") */ + error_type?: string; + /** Agent ID (for server-side tools like memory) */ + agent_id?: string; + /** Reasoning/thinking content that preceded this tool call */ + preceding_reasoning?: string; + /** Assistant message content that preceded this tool call */ + preceding_assistant_message?: string; +} + /** * Input for PermissionRequest hooks */ @@ -338,6 +364,7 @@ export interface SessionEndHookInput extends HookInputBase { export type HookInput = | PreToolUseHookInput | PostToolUseHookInput + | PostToolUseFailureHookInput | PermissionRequestHookInput | UserPromptSubmitHookInput | NotificationHookInput diff --git a/src/tests/hooks/executor.test.ts b/src/tests/hooks/executor.test.ts index 22819cc..9ccfa72 100644 --- a/src/tests/hooks/executor.test.ts +++ b/src/tests/hooks/executor.test.ts @@ -10,6 +10,7 @@ import { import { type HookCommand, HookExitCode, + type PostToolUseFailureHookInput, type PostToolUseHookInput, type PreToolUseHookInput, type SessionStartHookInput, @@ -470,6 +471,53 @@ describe.skipIf(isWindows)("Hooks Executor", () => { expect(parsed.is_new_session).toBe(true); expect(parsed.agent_name).toBe("Test Agent"); }); + + test("handles PostToolUseFailure input with error details", async () => { + const hook: HookCommand = { type: "command", command: "cat" }; + + const input: PostToolUseFailureHookInput = { + event_type: "PostToolUseFailure", + working_directory: tempDir, + tool_name: "Bash", + tool_input: { command: "echho hello" }, + tool_call_id: "call-123", + error_message: "zsh:1: command not found: echho", + error_type: "tool_error", + agent_id: "agent-456", + }; + + const result = await executeHookCommand(hook, input, tempDir); + + expect(result.exitCode).toBe(HookExitCode.ALLOW); + const parsed = JSON.parse(result.stdout); + expect(parsed.event_type).toBe("PostToolUseFailure"); + expect(parsed.tool_name).toBe("Bash"); + expect(parsed.error_message).toBe("zsh:1: command not found: echho"); + expect(parsed.error_type).toBe("tool_error"); + expect(parsed.tool_input.command).toBe("echho hello"); + }); + + test("PostToolUseFailure hook can provide feedback via stderr with exit 0", async () => { + const hook: HookCommand = { + type: "command", + command: "echo 'Suggestion: check spelling of command' >&2 && exit 0", + }; + + const input: PostToolUseFailureHookInput = { + event_type: "PostToolUseFailure", + working_directory: tempDir, + tool_name: "Bash", + tool_input: { command: "echho hello" }, + error_message: "command not found: echho", + error_type: "tool_error", + }; + + const result = await executeHookCommand(hook, input, tempDir); + + // Exit 0 = success, stderr should still be captured + expect(result.exitCode).toBe(HookExitCode.ALLOW); + expect(result.stderr).toBe("Suggestion: check spelling of command"); + }); }); describe("Edge cases", () => { diff --git a/src/tests/hooks/integration.test.ts b/src/tests/hooks/integration.test.ts index f53f33b..36cf734 100644 --- a/src/tests/hooks/integration.test.ts +++ b/src/tests/hooks/integration.test.ts @@ -9,6 +9,7 @@ import { hasHooks, runNotificationHooks, runPermissionRequestHooks, + runPostToolUseFailureHooks, runPostToolUseHooks, runPreCompactHooks, runPreToolUseHooks, @@ -275,6 +276,133 @@ describe.skipIf(isWindows)("Hooks Integration Tests", () => { }); }); + // ============================================================================ + // PostToolUseFailure Hooks + // ============================================================================ + + describe("PostToolUseFailure hooks", () => { + test("runs after tool failure", async () => { + createHooksConfig({ + PostToolUseFailure: [ + { + matcher: "Bash", + hooks: [ + { + type: "command", + command: "echo 'hook ran'", + }, + ], + }, + ], + }); + + const result = await runPostToolUseFailureHooks( + "Bash", + { command: "echho hello" }, + "command not found: echho", + "tool_error", + "tool-789", + tempDir, + ); + + // PostToolUseFailure never blocks + expect(result.blocked).toBe(false); + expect(result.results).toHaveLength(1); + expect(result.results[0]?.stdout).toBe("hook ran"); + }); + + test("collects stderr feedback on exit 2", async () => { + createHooksConfig({ + PostToolUseFailure: [ + { + matcher: "*", + hooks: [ + { + type: "command", + command: "echo 'Try checking spelling' >&2 && exit 2", + }, + ], + }, + ], + }); + + const result = await runPostToolUseFailureHooks( + "Bash", + { command: "bad-cmd" }, + "command not found", + "tool_error", + undefined, + tempDir, + ); + + // Stderr collected as feedback on exit 2 + expect(result.feedback).toHaveLength(1); + expect(result.feedback[0]).toContain("Try checking spelling"); + }); + + test("receives error details in input", async () => { + createHooksConfig({ + PostToolUseFailure: [ + { + matcher: "*", + hooks: [{ type: "command", command: "cat" }], + }, + ], + }); + + const result = await runPostToolUseFailureHooks( + "Bash", + { command: "nonexistent-cmd" }, + "zsh:1: command not found: nonexistent-cmd", + "tool_error", + "call-123", + tempDir, + "agent-456", + ); + + const parsed = JSON.parse(result.results[0]?.stdout || "{}"); + expect(parsed.event_type).toBe("PostToolUseFailure"); + expect(parsed.tool_name).toBe("Bash"); + expect(parsed.error_message).toBe( + "zsh:1: command not found: nonexistent-cmd", + ); + expect(parsed.error_type).toBe("tool_error"); + expect(parsed.tool_call_id).toBe("call-123"); + expect(parsed.agent_id).toBe("agent-456"); + }); + + test("never blocks even with exit 2", async () => { + createHooksConfig({ + PostToolUseFailure: [ + { + matcher: "*", + hooks: [ + { + type: "command", + command: "echo 'feedback with exit 2' >&2 && exit 2", + }, + ], + }, + ], + }); + + const result = await runPostToolUseFailureHooks( + "Bash", + { command: "bad" }, + "error", + undefined, + undefined, + tempDir, + ); + + // PostToolUseFailure should never block - tool already failed + expect(result.blocked).toBe(false); + // Stderr collected as feedback on exit 2 + expect(result.feedback).toHaveLength(1); + expect(result.feedback[0]).toContain("feedback with exit 2"); + }); + }); + // ============================================================================ // PermissionRequest Hooks // ============================================================================ diff --git a/src/tools/manager.ts b/src/tools/manager.ts index a9440f4..d47b532 100644 --- a/src/tools/manager.ts +++ b/src/tools/manager.ts @@ -2,7 +2,11 @@ import { getDisplayableToolReturn } from "../agent/approval-execution"; import { getModelInfo } from "../agent/model"; import { getAllSubagentConfigs } from "../agent/subagents"; import { INTERRUPTED_BY_USER } from "../constants"; -import { runPostToolUseHooks, runPreToolUseHooks } from "../hooks"; +import { + runPostToolUseFailureHooks, + runPostToolUseHooks, + runPreToolUseHooks, +} from "../hooks"; import { telemetry } from "../telemetry"; import { TOOL_DEFINITIONS, type ToolName } from "./toolDefinitions"; @@ -972,6 +976,51 @@ export async function executeTool( // Silently ignore hook errors - don't affect tool execution }); + // Run PostToolUseFailure hooks when tool returns error status (async, feeds stderr back to agent) + if (toolStatus === "error") { + const errorOutput = + typeof flattenedResponse === "string" + ? flattenedResponse + : JSON.stringify(flattenedResponse); + try { + const failureHookResult = await runPostToolUseFailureHooks( + internalName, + args as Record, + errorOutput, + "tool_error", // error type for returned errors + options?.toolCallId, + undefined, // workingDirectory + undefined, // agentId + undefined, // precedingReasoning - not available in tool manager context + undefined, // precedingAssistantMessage - not available in tool manager context + ); + // Feed stderr (feedback) back to the agent + if (failureHookResult.feedback.length > 0) { + const feedbackMessage = `\n\n[PostToolUseFailure hook feedback]:\n${failureHookResult.feedback.join("\n")}`; + let finalToolReturn: ToolReturnContent; + if (typeof flattenedResponse === "string") { + finalToolReturn = flattenedResponse + feedbackMessage; + } else if (Array.isArray(flattenedResponse)) { + // Append feedback as a new text content block + finalToolReturn = [ + ...flattenedResponse, + { type: "text" as const, text: feedbackMessage }, + ]; + } else { + finalToolReturn = flattenedResponse; + } + return { + toolReturn: finalToolReturn, + status: toolStatus, + ...(stdout && { stdout }), + ...(stderr && { stderr }), + }; + } + } catch { + // Silently ignore hook execution errors + } + } + // Return the full response (truncation happens in UI layer only) return { toolReturn: flattenedResponse, @@ -1022,10 +1071,32 @@ export async function executeTool( // Silently ignore hook errors }); + // Run PostToolUseFailure hooks (async, non-blocking, feeds stderr back to agent) + let finalErrorMessage = errorMessage; + try { + const failureHookResult = await runPostToolUseFailureHooks( + internalName, + args as Record, + errorMessage, + errorType, + options?.toolCallId, + undefined, // workingDirectory + undefined, // agentId + undefined, // precedingReasoning - not available in tool manager context + undefined, // precedingAssistantMessage - not available in tool manager context + ); + // Feed stderr (feedback) back to the agent + if (failureHookResult.feedback.length > 0) { + finalErrorMessage = `${errorMessage}\n\n[PostToolUseFailure hook feedback]:\n${failureHookResult.feedback.join("\n")}`; + } + } catch { + // Silently ignore hook execution errors + } + // Don't console.error here - it pollutes the TUI // The error message is already returned in toolReturn return { - toolReturn: errorMessage, + toolReturn: finalErrorMessage, status: "error", }; }