/ utils / hooks / execPromptHook.ts
execPromptHook.ts
  1  import { randomUUID } from 'crypto'
  2  import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js'
  3  import { queryModelWithoutStreaming } from '../../services/api/claude.js'
  4  import type { ToolUseContext } from '../../Tool.js'
  5  import type { Message } from '../../types/message.js'
  6  import { createAttachmentMessage } from '../attachments.js'
  7  import { createCombinedAbortSignal } from '../combinedAbortSignal.js'
  8  import { logForDebugging } from '../debug.js'
  9  import { errorMessage } from '../errors.js'
 10  import type { HookResult } from '../hooks.js'
 11  import { safeParseJSON } from '../json.js'
 12  import { createUserMessage, extractTextContent } from '../messages.js'
 13  import { getSmallFastModel } from '../model/model.js'
 14  import type { PromptHook } from '../settings/types.js'
 15  import { asSystemPrompt } from '../systemPromptType.js'
 16  import { addArgumentsToPrompt, hookResponseSchema } from './hookHelpers.js'
 17  
 18  /**
 19   * Execute a prompt-based hook using an LLM
 20   */
 21  export async function execPromptHook(
 22    hook: PromptHook,
 23    hookName: string,
 24    hookEvent: HookEvent,
 25    jsonInput: string,
 26    signal: AbortSignal,
 27    toolUseContext: ToolUseContext,
 28    messages?: Message[],
 29    toolUseID?: string,
 30  ): Promise<HookResult> {
 31    // Use provided toolUseID or generate a new one
 32    const effectiveToolUseID = toolUseID || `hook-${randomUUID()}`
 33    try {
 34      // Replace $ARGUMENTS with the JSON input
 35      const processedPrompt = addArgumentsToPrompt(hook.prompt, jsonInput)
 36      logForDebugging(
 37        `Hooks: Processing prompt hook with prompt: ${processedPrompt}`,
 38      )
 39  
 40      // Create user message directly - no need for processUserInput which would
 41      // trigger UserPromptSubmit hooks and cause infinite recursion
 42      const userMessage = createUserMessage({ content: processedPrompt })
 43  
 44      // Prepend conversation history if provided
 45      const messagesToQuery =
 46        messages && messages.length > 0
 47          ? [...messages, userMessage]
 48          : [userMessage]
 49  
 50      logForDebugging(
 51        `Hooks: Querying model with ${messagesToQuery.length} messages`,
 52      )
 53  
 54      // Query the model with Haiku
 55      const hookTimeoutMs = hook.timeout ? hook.timeout * 1000 : 30000
 56  
 57      // Combined signal: aborts if either the hook signal or timeout triggers
 58      const { signal: combinedSignal, cleanup: cleanupSignal } =
 59        createCombinedAbortSignal(signal, { timeoutMs: hookTimeoutMs })
 60  
 61      try {
 62        const response = await queryModelWithoutStreaming({
 63          messages: messagesToQuery,
 64          systemPrompt: asSystemPrompt([
 65            `You are evaluating a hook in Claude Code.
 66  
 67  Your response must be a JSON object matching one of the following schemas:
 68  1. If the condition is met, return: {"ok": true}
 69  2. If the condition is not met, return: {"ok": false, "reason": "Reason for why it is not met"}`,
 70          ]),
 71          thinkingConfig: { type: 'disabled' as const },
 72          tools: toolUseContext.options.tools,
 73          signal: combinedSignal,
 74          options: {
 75            async getToolPermissionContext() {
 76              const appState = toolUseContext.getAppState()
 77              return appState.toolPermissionContext
 78            },
 79            model: hook.model ?? getSmallFastModel(),
 80            toolChoice: undefined,
 81            isNonInteractiveSession: true,
 82            hasAppendSystemPrompt: false,
 83            agents: [],
 84            querySource: 'hook_prompt',
 85            mcpTools: [],
 86            agentId: toolUseContext.agentId,
 87            outputFormat: {
 88              type: 'json_schema',
 89              schema: {
 90                type: 'object',
 91                properties: {
 92                  ok: { type: 'boolean' },
 93                  reason: { type: 'string' },
 94                },
 95                required: ['ok'],
 96                additionalProperties: false,
 97              },
 98            },
 99          },
100        })
101  
102        cleanupSignal()
103  
104        // Extract text content from response
105        const content = extractTextContent(response.message.content)
106  
107        // Update response length for spinner display
108        toolUseContext.setResponseLength(length => length + content.length)
109  
110        const fullResponse = content.trim()
111        logForDebugging(`Hooks: Model response: ${fullResponse}`)
112  
113        const json = safeParseJSON(fullResponse)
114        if (!json) {
115          logForDebugging(
116            `Hooks: error parsing response as JSON: ${fullResponse}`,
117          )
118          return {
119            hook,
120            outcome: 'non_blocking_error',
121            message: createAttachmentMessage({
122              type: 'hook_non_blocking_error',
123              hookName,
124              toolUseID: effectiveToolUseID,
125              hookEvent,
126              stderr: 'JSON validation failed',
127              stdout: fullResponse,
128              exitCode: 1,
129            }),
130          }
131        }
132  
133        const parsed = hookResponseSchema().safeParse(json)
134        if (!parsed.success) {
135          logForDebugging(
136            `Hooks: model response does not conform to expected schema: ${parsed.error.message}`,
137          )
138          return {
139            hook,
140            outcome: 'non_blocking_error',
141            message: createAttachmentMessage({
142              type: 'hook_non_blocking_error',
143              hookName,
144              toolUseID: effectiveToolUseID,
145              hookEvent,
146              stderr: `Schema validation failed: ${parsed.error.message}`,
147              stdout: fullResponse,
148              exitCode: 1,
149            }),
150          }
151        }
152  
153        // Failed to meet condition
154        if (!parsed.data.ok) {
155          logForDebugging(
156            `Hooks: Prompt hook condition was not met: ${parsed.data.reason}`,
157          )
158          return {
159            hook,
160            outcome: 'blocking',
161            blockingError: {
162              blockingError: `Prompt hook condition was not met: ${parsed.data.reason}`,
163              command: hook.prompt,
164            },
165            preventContinuation: true,
166            stopReason: parsed.data.reason,
167          }
168        }
169  
170        // Condition was met
171        logForDebugging(`Hooks: Prompt hook condition was met`)
172        return {
173          hook,
174          outcome: 'success',
175          message: createAttachmentMessage({
176            type: 'hook_success',
177            hookName,
178            toolUseID: effectiveToolUseID,
179            hookEvent,
180            content: '',
181          }),
182        }
183      } catch (error) {
184        cleanupSignal()
185  
186        if (combinedSignal.aborted) {
187          return {
188            hook,
189            outcome: 'cancelled',
190          }
191        }
192        throw error
193      }
194    } catch (error) {
195      const errorMsg = errorMessage(error)
196      logForDebugging(`Hooks: Prompt hook error: ${errorMsg}`)
197      return {
198        hook,
199        outcome: 'non_blocking_error',
200        message: createAttachmentMessage({
201          type: 'hook_non_blocking_error',
202          hookName,
203          toolUseID: effectiveToolUseID,
204          hookEvent,
205          stderr: `Error executing prompt hook: ${errorMsg}`,
206          stdout: '',
207          exitCode: 1,
208        }),
209      }
210    }
211  }