/ schemas / hooks.ts
hooks.ts
  1  /**
  2   * Hook Zod schemas extracted to break import cycles.
  3   *
  4   * This file contains hook-related schema definitions that were originally
  5   * in src/utils/settings/types.ts. By extracting them here, we break the
  6   * circular dependency between settings/types.ts and plugins/schemas.ts.
  7   *
  8   * Both files now import from this shared location instead of each other.
  9   */
 10  
 11  import { HOOK_EVENTS, type HookEvent } from 'src/entrypoints/agentSdkTypes.js'
 12  import { z } from 'zod/v4'
 13  import { lazySchema } from '../utils/lazySchema.js'
 14  import { SHELL_TYPES } from '../utils/shell/shellProvider.js'
 15  
 16  // Shared schema for the `if` condition field.
 17  // Uses permission rule syntax (e.g., "Bash(git *)", "Read(*.ts)") to filter hooks
 18  // before spawning. Evaluated against the hook input's tool_name and tool_input.
 19  const IfConditionSchema = lazySchema(() =>
 20    z
 21      .string()
 22      .optional()
 23      .describe(
 24        'Permission rule syntax to filter when this hook runs (e.g., "Bash(git *)"). ' +
 25          'Only runs if the tool call matches the pattern. Avoids spawning hooks for non-matching commands.',
 26      ),
 27  )
 28  
 29  // Internal factory for individual hook schemas (shared between exported
 30  // discriminated union members and the HookCommandSchema factory)
 31  function buildHookSchemas() {
 32    const BashCommandHookSchema = z.object({
 33      type: z.literal('command').describe('Shell command hook type'),
 34      command: z.string().describe('Shell command to execute'),
 35      if: IfConditionSchema(),
 36      shell: z
 37        .enum(SHELL_TYPES)
 38        .optional()
 39        .describe(
 40          "Shell interpreter. 'bash' uses your $SHELL (bash/zsh/sh); 'powershell' uses pwsh. Defaults to bash.",
 41        ),
 42      timeout: z
 43        .number()
 44        .positive()
 45        .optional()
 46        .describe('Timeout in seconds for this specific command'),
 47      statusMessage: z
 48        .string()
 49        .optional()
 50        .describe('Custom status message to display in spinner while hook runs'),
 51      once: z
 52        .boolean()
 53        .optional()
 54        .describe('If true, hook runs once and is removed after execution'),
 55      async: z
 56        .boolean()
 57        .optional()
 58        .describe('If true, hook runs in background without blocking'),
 59      asyncRewake: z
 60        .boolean()
 61        .optional()
 62        .describe(
 63          'If true, hook runs in background and wakes the model on exit code 2 (blocking error). Implies async.',
 64        ),
 65    })
 66  
 67    const PromptHookSchema = z.object({
 68      type: z.literal('prompt').describe('LLM prompt hook type'),
 69      prompt: z
 70        .string()
 71        .describe(
 72          'Prompt to evaluate with LLM. Use $ARGUMENTS placeholder for hook input JSON.',
 73        ),
 74      if: IfConditionSchema(),
 75      timeout: z
 76        .number()
 77        .positive()
 78        .optional()
 79        .describe('Timeout in seconds for this specific prompt evaluation'),
 80      // @[MODEL LAUNCH]: Update the example model ID in the .describe() strings below (prompt + agent hooks).
 81      model: z
 82        .string()
 83        .optional()
 84        .describe(
 85          'Model to use for this prompt hook (e.g., "claude-sonnet-4-6"). If not specified, uses the default small fast model.',
 86        ),
 87      statusMessage: z
 88        .string()
 89        .optional()
 90        .describe('Custom status message to display in spinner while hook runs'),
 91      once: z
 92        .boolean()
 93        .optional()
 94        .describe('If true, hook runs once and is removed after execution'),
 95    })
 96  
 97    const HttpHookSchema = z.object({
 98      type: z.literal('http').describe('HTTP hook type'),
 99      url: z.string().url().describe('URL to POST the hook input JSON to'),
100      if: IfConditionSchema(),
101      timeout: z
102        .number()
103        .positive()
104        .optional()
105        .describe('Timeout in seconds for this specific request'),
106      headers: z
107        .record(z.string(), z.string())
108        .optional()
109        .describe(
110          'Additional headers to include in the request. Values may reference environment variables using $VAR_NAME or ${VAR_NAME} syntax (e.g., "Authorization": "Bearer $MY_TOKEN"). Only variables listed in allowedEnvVars will be interpolated.',
111        ),
112      allowedEnvVars: z
113        .array(z.string())
114        .optional()
115        .describe(
116          'Explicit list of environment variable names that may be interpolated in header values. Only variables listed here will be resolved; all other $VAR references are left as empty strings. Required for env var interpolation to work.',
117        ),
118      statusMessage: z
119        .string()
120        .optional()
121        .describe('Custom status message to display in spinner while hook runs'),
122      once: z
123        .boolean()
124        .optional()
125        .describe('If true, hook runs once and is removed after execution'),
126    })
127  
128    const AgentHookSchema = z.object({
129      type: z.literal('agent').describe('Agentic verifier hook type'),
130      // DO NOT add .transform() here. This schema is used by parseSettingsFile,
131      // and updateSettingsForSource round-trips the parsed result through
132      // JSON.stringify — a transformed function value is silently dropped,
133      // deleting the user's prompt from settings.json (gh-24920, CC-79). The
134      // transform (from #10594) wrapped the string in `(_msgs) => prompt`
135      // for a programmatic-construction use case in ExitPlanModeV2Tool that
136      // has since been refactored into VerifyPlanExecutionTool, which no
137      // longer constructs AgentHook objects at all.
138      prompt: z
139        .string()
140        .describe(
141          'Prompt describing what to verify (e.g. "Verify that unit tests ran and passed."). Use $ARGUMENTS placeholder for hook input JSON.',
142        ),
143      if: IfConditionSchema(),
144      timeout: z
145        .number()
146        .positive()
147        .optional()
148        .describe('Timeout in seconds for agent execution (default 60)'),
149      model: z
150        .string()
151        .optional()
152        .describe(
153          'Model to use for this agent hook (e.g., "claude-sonnet-4-6"). If not specified, uses Haiku.',
154        ),
155      statusMessage: z
156        .string()
157        .optional()
158        .describe('Custom status message to display in spinner while hook runs'),
159      once: z
160        .boolean()
161        .optional()
162        .describe('If true, hook runs once and is removed after execution'),
163    })
164  
165    return {
166      BashCommandHookSchema,
167      PromptHookSchema,
168      HttpHookSchema,
169      AgentHookSchema,
170    }
171  }
172  
173  /**
174   * Schema for hook command (excludes function hooks - they can't be persisted)
175   */
176  export const HookCommandSchema = lazySchema(() => {
177    const {
178      BashCommandHookSchema,
179      PromptHookSchema,
180      AgentHookSchema,
181      HttpHookSchema,
182    } = buildHookSchemas()
183    return z.discriminatedUnion('type', [
184      BashCommandHookSchema,
185      PromptHookSchema,
186      AgentHookSchema,
187      HttpHookSchema,
188    ])
189  })
190  
191  /**
192   * Schema for matcher configuration with multiple hooks
193   */
194  export const HookMatcherSchema = lazySchema(() =>
195    z.object({
196      matcher: z
197        .string()
198        .optional()
199        .describe('String pattern to match (e.g. tool names like "Write")'), // String (e.g. Write) to match values related to the hook event, e.g. tool names
200      hooks: z
201        .array(HookCommandSchema())
202        .describe('List of hooks to execute when the matcher matches'),
203    }),
204  )
205  
206  /**
207   * Schema for hooks configuration
208   * The key is the hook event. The value is an array of matcher configurations.
209   * Uses partialRecord since not all hook events need to be defined.
210   */
211  export const HooksSchema = lazySchema(() =>
212    z.partialRecord(z.enum(HOOK_EVENTS), z.array(HookMatcherSchema())),
213  )
214  
215  // Inferred types from schemas
216  export type HookCommand = z.infer<ReturnType<typeof HookCommandSchema>>
217  export type BashCommandHook = Extract<HookCommand, { type: 'command' }>
218  export type PromptHook = Extract<HookCommand, { type: 'prompt' }>
219  export type AgentHook = Extract<HookCommand, { type: 'agent' }>
220  export type HttpHook = Extract<HookCommand, { type: 'http' }>
221  export type HookMatcher = z.infer<ReturnType<typeof HookMatcherSchema>>
222  export type HooksSettings = Partial<Record<HookEvent, HookMatcher[]>>