/ types / hooks.ts
hooks.ts
  1  // biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
  2  import { z } from 'zod/v4'
  3  import { lazySchema } from '../utils/lazySchema.js'
  4  import {
  5    type HookEvent,
  6    HOOK_EVENTS,
  7    type HookInput,
  8    type PermissionUpdate,
  9  } from 'src/entrypoints/agentSdkTypes.js'
 10  import type {
 11    HookJSONOutput,
 12    AsyncHookJSONOutput,
 13    SyncHookJSONOutput,
 14  } from 'src/entrypoints/agentSdkTypes.js'
 15  import type { Message } from 'src/types/message.js'
 16  import type { PermissionResult } from 'src/utils/permissions/PermissionResult.js'
 17  import { permissionBehaviorSchema } from 'src/utils/permissions/PermissionRule.js'
 18  import { permissionUpdateSchema } from 'src/utils/permissions/PermissionUpdateSchema.js'
 19  import type { AppState } from '../state/AppState.js'
 20  import type { AttributionState } from '../utils/commitAttribution.js'
 21  
 22  export function isHookEvent(value: string): value is HookEvent {
 23    return HOOK_EVENTS.includes(value as HookEvent)
 24  }
 25  
 26  // Prompt elicitation protocol types. The `prompt` key acts as discriminator
 27  // (mirroring the {async:true} pattern), with the id as its value.
 28  export const promptRequestSchema = lazySchema(() =>
 29    z.object({
 30      prompt: z.string(), // request id
 31      message: z.string(),
 32      options: z.array(
 33        z.object({
 34          key: z.string(),
 35          label: z.string(),
 36          description: z.string().optional(),
 37        }),
 38      ),
 39    }),
 40  )
 41  
 42  export type PromptRequest = z.infer<ReturnType<typeof promptRequestSchema>>
 43  
 44  export type PromptResponse = {
 45    prompt_response: string // request id
 46    selected: string
 47  }
 48  
 49  // Sync hook response schema
 50  export const syncHookResponseSchema = lazySchema(() =>
 51    z.object({
 52      continue: z
 53        .boolean()
 54        .describe('Whether Claude should continue after hook (default: true)')
 55        .optional(),
 56      suppressOutput: z
 57        .boolean()
 58        .describe('Hide stdout from transcript (default: false)')
 59        .optional(),
 60      stopReason: z
 61        .string()
 62        .describe('Message shown when continue is false')
 63        .optional(),
 64      decision: z.enum(['approve', 'block']).optional(),
 65      reason: z.string().describe('Explanation for the decision').optional(),
 66      systemMessage: z
 67        .string()
 68        .describe('Warning message shown to the user')
 69        .optional(),
 70      hookSpecificOutput: z
 71        .union([
 72          z.object({
 73            hookEventName: z.literal('PreToolUse'),
 74            permissionDecision: permissionBehaviorSchema().optional(),
 75            permissionDecisionReason: z.string().optional(),
 76            updatedInput: z.record(z.string(), z.unknown()).optional(),
 77            additionalContext: z.string().optional(),
 78          }),
 79          z.object({
 80            hookEventName: z.literal('UserPromptSubmit'),
 81            additionalContext: z.string().optional(),
 82          }),
 83          z.object({
 84            hookEventName: z.literal('SessionStart'),
 85            additionalContext: z.string().optional(),
 86            initialUserMessage: z.string().optional(),
 87            watchPaths: z
 88              .array(z.string())
 89              .describe('Absolute paths to watch for FileChanged hooks')
 90              .optional(),
 91          }),
 92          z.object({
 93            hookEventName: z.literal('Setup'),
 94            additionalContext: z.string().optional(),
 95          }),
 96          z.object({
 97            hookEventName: z.literal('SubagentStart'),
 98            additionalContext: z.string().optional(),
 99          }),
100          z.object({
101            hookEventName: z.literal('PostToolUse'),
102            additionalContext: z.string().optional(),
103            updatedMCPToolOutput: z
104              .unknown()
105              .describe('Updates the output for MCP tools')
106              .optional(),
107          }),
108          z.object({
109            hookEventName: z.literal('PostToolUseFailure'),
110            additionalContext: z.string().optional(),
111          }),
112          z.object({
113            hookEventName: z.literal('PermissionDenied'),
114            retry: z.boolean().optional(),
115          }),
116          z.object({
117            hookEventName: z.literal('Notification'),
118            additionalContext: z.string().optional(),
119          }),
120          z.object({
121            hookEventName: z.literal('PermissionRequest'),
122            decision: z.union([
123              z.object({
124                behavior: z.literal('allow'),
125                updatedInput: z.record(z.string(), z.unknown()).optional(),
126                updatedPermissions: z.array(permissionUpdateSchema()).optional(),
127              }),
128              z.object({
129                behavior: z.literal('deny'),
130                message: z.string().optional(),
131                interrupt: z.boolean().optional(),
132              }),
133            ]),
134          }),
135          z.object({
136            hookEventName: z.literal('Elicitation'),
137            action: z.enum(['accept', 'decline', 'cancel']).optional(),
138            content: z.record(z.string(), z.unknown()).optional(),
139          }),
140          z.object({
141            hookEventName: z.literal('ElicitationResult'),
142            action: z.enum(['accept', 'decline', 'cancel']).optional(),
143            content: z.record(z.string(), z.unknown()).optional(),
144          }),
145          z.object({
146            hookEventName: z.literal('CwdChanged'),
147            watchPaths: z
148              .array(z.string())
149              .describe('Absolute paths to watch for FileChanged hooks')
150              .optional(),
151          }),
152          z.object({
153            hookEventName: z.literal('FileChanged'),
154            watchPaths: z
155              .array(z.string())
156              .describe('Absolute paths to watch for FileChanged hooks')
157              .optional(),
158          }),
159          z.object({
160            hookEventName: z.literal('WorktreeCreate'),
161            worktreePath: z.string(),
162          }),
163        ])
164        .optional(),
165    }),
166  )
167  
168  // Zod schema for hook JSON output validation
169  export const hookJSONOutputSchema = lazySchema(() => {
170    // Async hook response schema
171    const asyncHookResponseSchema = z.object({
172      async: z.literal(true),
173      asyncTimeout: z.number().optional(),
174    })
175    return z.union([asyncHookResponseSchema, syncHookResponseSchema()])
176  })
177  
178  // Infer the TypeScript type from the schema
179  type SchemaHookJSONOutput = z.infer<ReturnType<typeof hookJSONOutputSchema>>
180  
181  // Type guard function to check if response is sync
182  export function isSyncHookJSONOutput(
183    json: HookJSONOutput,
184  ): json is SyncHookJSONOutput {
185    return !('async' in json && json.async === true)
186  }
187  
188  // Type guard function to check if response is async
189  export function isAsyncHookJSONOutput(
190    json: HookJSONOutput,
191  ): json is AsyncHookJSONOutput {
192    return 'async' in json && json.async === true
193  }
194  
195  // Compile-time assertion that SDK and Zod types match
196  import type { IsEqual } from 'type-fest'
197  type Assert<T extends true> = T
198  type _assertSDKTypesMatch = Assert<
199    IsEqual<SchemaHookJSONOutput, HookJSONOutput>
200  >
201  
202  /** Context passed to callback hooks for state access */
203  export type HookCallbackContext = {
204    getAppState: () => AppState
205    updateAttributionState: (
206      updater: (prev: AttributionState) => AttributionState,
207    ) => void
208  }
209  
210  /** Hook that is a callback. */
211  export type HookCallback = {
212    type: 'callback'
213    callback: (
214      input: HookInput,
215      toolUseID: string | null,
216      abort: AbortSignal | undefined,
217      /** Hook index for SessionStart hooks to compute CLAUDE_ENV_FILE path */
218      hookIndex?: number,
219      /** Optional context for accessing app state */
220      context?: HookCallbackContext,
221    ) => Promise<HookJSONOutput>
222    /** Timeout in seconds for this hook */
223    timeout?: number
224    /** Internal hooks (e.g. session file access analytics) are excluded from tengu_run_hook metrics */
225    internal?: boolean
226  }
227  
228  export type HookCallbackMatcher = {
229    matcher?: string
230    hooks: HookCallback[]
231    pluginName?: string
232  }
233  
234  export type HookProgress = {
235    type: 'hook_progress'
236    hookEvent: HookEvent
237    hookName: string
238    command: string
239    promptText?: string
240    statusMessage?: string
241  }
242  
243  export type HookBlockingError = {
244    blockingError: string
245    command: string
246  }
247  
248  export type PermissionRequestResult =
249    | {
250        behavior: 'allow'
251        updatedInput?: Record<string, unknown>
252        updatedPermissions?: PermissionUpdate[]
253      }
254    | {
255        behavior: 'deny'
256        message?: string
257        interrupt?: boolean
258      }
259  
260  export type HookResult = {
261    message?: Message
262    systemMessage?: Message
263    blockingError?: HookBlockingError
264    outcome: 'success' | 'blocking' | 'non_blocking_error' | 'cancelled'
265    preventContinuation?: boolean
266    stopReason?: string
267    permissionBehavior?: 'ask' | 'deny' | 'allow' | 'passthrough'
268    hookPermissionDecisionReason?: string
269    additionalContext?: string
270    initialUserMessage?: string
271    updatedInput?: Record<string, unknown>
272    updatedMCPToolOutput?: unknown
273    permissionRequestResult?: PermissionRequestResult
274    retry?: boolean
275  }
276  
277  export type AggregatedHookResult = {
278    message?: Message
279    blockingErrors?: HookBlockingError[]
280    preventContinuation?: boolean
281    stopReason?: string
282    hookPermissionDecisionReason?: string
283    permissionBehavior?: PermissionResult['behavior']
284    additionalContexts?: string[]
285    initialUserMessage?: string
286    updatedInput?: Record<string, unknown>
287    updatedMCPToolOutput?: unknown
288    permissionRequestResult?: PermissionRequestResult
289    retry?: boolean
290  }