// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
/**
* Hooks are user-defined shell commands that can be executed at various points
* in Claude Code's lifecycle.
*/
import { basename } from 'path'
import { spawn, type ChildProcessWithoutNullStreams } from 'child_process'
import { pathExists } from './file.js'
import { wrapSpawn } from './ShellCommand.js'
import { TaskOutput } from './task/TaskOutput.js'
import { getCwd } from './cwd.js'
import { randomUUID } from 'crypto'
import { formatShellPrefixCommand } from './bash/shellPrefix.js'
import {
getHookEnvFilePath,
invalidateSessionEnvCache,
} from './sessionEnvironment.js'
import { subprocessEnv } from './subprocessEnv.js'
import { getPlatform } from './platform.js'
import { findGitBashPath, windowsPathToPosixPath } from './windowsPaths.js'
import { getCachedPowerShellPath } from './shell/powershellDetection.js'
import { DEFAULT_HOOK_SHELL } from './shell/shellProvider.js'
import { buildPowerShellArgs } from './shell/powershellProvider.js'
import {
loadPluginOptions,
substituteUserConfigVariables,
} from './plugins/pluginOptionsStorage.js'
import { getPluginDataDir } from './plugins/pluginDirectories.js'
import {
getSessionId,
getProjectRoot,
getIsNonInteractiveSession,
getRegisteredHooks,
getStatsStore,
addToTurnHookDuration,
getOriginalCwd,
getMainThreadAgentType,
} from '../bootstrap/state.js'
import { checkHasTrustDialogAccepted } from './config.js'
import {
getHooksConfigFromSnapshot,
shouldAllowManagedHooksOnly,
shouldDisableAllHooksIncludingManaged,
} from './hooks/hooksConfigSnapshot.js'
import {
getTranscriptPathForSession,
getAgentTranscriptPath,
} from './sessionStorage.js'
import type { AgentId } from '../types/ids.js'
import {
getSettings_DEPRECATED,
getSettingsForSource,
} from './settings/settings.js'
import {
logEvent,
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
} from 'src/services/analytics/index.js'
import { logOTelEvent } from './telemetry/events.js'
import { ALLOWED_OFFICIAL_MARKETPLACE_NAMES } from './plugins/schemas.js'
import {
startHookSpan,
endHookSpan,
isBetaTracingEnabled,
} from './telemetry/sessionTracing.js'
import {
hookJSONOutputSchema,
promptRequestSchema,
type HookCallback,
type HookCallbackMatcher,
type PromptRequest,
type PromptResponse,
isAsyncHookJSONOutput,
isSyncHookJSONOutput,
type PermissionRequestResult,
} from '../types/hooks.js'
import type {
HookEvent,
HookInput,
HookJSONOutput,
NotificationHookInput,
PostToolUseHookInput,
PostToolUseFailureHookInput,
PermissionDeniedHookInput,
PreCompactHookInput,
PostCompactHookInput,
PreToolUseHookInput,
SessionStartHookInput,
SessionEndHookInput,
SetupHookInput,
StopHookInput,
StopFailureHookInput,
SubagentStartHookInput,
SubagentStopHookInput,
TeammateIdleHookInput,
TaskCreatedHookInput,
TaskCompletedHookInput,
ConfigChangeHookInput,
CwdChangedHookInput,
FileChangedHookInput,
InstructionsLoadedHookInput,
UserPromptSubmitHookInput,
PermissionRequestHookInput,
ElicitationHookInput,
ElicitationResultHookInput,
PermissionUpdate,
ExitReason,
SyncHookJSONOutput,
AsyncHookJSONOutput,
} from 'src/entrypoints/agentSdkTypes.js'
import type { StatusLineCommandInput } from '../types/statusLine.js'
import type { ElicitResult } from '@modelcontextprotocol/sdk/types.js'
import type { FileSuggestionCommandInput } from '../types/fileSuggestion.js'
import type { HookResultMessage } from 'src/types/message.js'
import chalk from 'chalk'
import type {
HookMatcher,
HookCommand,
PluginHookMatcher,
SkillHookMatcher,
} from './settings/types.js'
import { getHookDisplayText } from './hooks/hooksSettings.js'
import { logForDebugging } from './debug.js'
import { logForDiagnosticsNoPII } from './diagLogs.js'
import { firstLineOf } from './stringUtils.js'
import {
normalizeLegacyToolName,
getLegacyToolNames,
permissionRuleValueFromString,
} from './permissions/permissionRuleParser.js'
import { logError } from './log.js'
import { createCombinedAbortSignal } from './combinedAbortSignal.js'
import type { PermissionResult } from './permissions/PermissionResult.js'
import { registerPendingAsyncHook } from './hooks/AsyncHookRegistry.js'
import { enqueuePendingNotification } from './messageQueueManager.js'
import {
extractTextContent,
getLastAssistantMessage,
wrapInSystemReminder,
} from './messages.js'
import {
emitHookStarted,
emitHookResponse,
startHookProgressInterval,
} from './hooks/hookEvents.js'
import { createAttachmentMessage } from './attachments.js'
import { all } from './generators.js'
import { findToolByName, type Tools, type ToolUseContext } from '../Tool.js'
import { execPromptHook } from './hooks/execPromptHook.js'
import type { Message, AssistantMessage } from '../types/message.js'
import { execAgentHook } from './hooks/execAgentHook.js'
import { execHttpHook } from './hooks/execHttpHook.js'
import type { ShellCommand } from './ShellCommand.js'
import {
getSessionHooks,
getSessionFunctionHooks,
getSessionHookCallback,
clearSessionHooks,
type SessionDerivedHookMatcher,
type FunctionHook,
} from './hooks/sessionHooks.js'
import type { AppState } from '../state/AppState.js'
import { jsonStringify, jsonParse } from './slowOperations.js'
import { isEnvTruthy } from './envUtils.js'
import { errorMessage, getErrnoCode } from './errors.js'
const TOOL_HOOK_EXECUTION_TIMEOUT_MS = 10 * 60 * 1000
/**
* SessionEnd hooks run during shutdown/clear and need a much tighter bound
* than TOOL_HOOK_EXECUTION_TIMEOUT_MS. This value is used by callers as both
* the per-hook default timeout AND the overall AbortSignal cap (hooks run in
* parallel, so one value suffices). Overridable via env var for users whose
* teardown scripts need more time.
*/
const SESSION_END_HOOK_TIMEOUT_MS_DEFAULT = 1500
export function getSessionEndHookTimeoutMs(): number {
const raw = process.env.CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS
const parsed = raw ? parseInt(raw, 10) : NaN
return Number.isFinite(parsed) && parsed > 0
? parsed
: SESSION_END_HOOK_TIMEOUT_MS_DEFAULT
}
function executeInBackground({
processId,
hookId,
shellCommand,
asyncResponse,
hookEvent,
hookName,
command,
asyncRewake,
pluginId,
}: {
processId: string
hookId: string
shellCommand: ShellCommand
asyncResponse: AsyncHookJSONOutput
hookEvent: HookEvent | 'StatusLine' | 'FileSuggestion'
hookName: string
command: string
asyncRewake?: boolean
pluginId?: string
}): boolean {
if (asyncRewake) {
// asyncRewake hooks bypass the registry entirely. On completion, if exit
// code 2 (blocking error), enqueue as a task-notification so it wakes the
// model via useQueueProcessor (idle) or gets injected mid-query via
// queued_command attachments (busy).
//
// NOTE: We deliberately do NOT call shellCommand.background() here, because
// it calls taskOutput.spillToDisk() which breaks in-memory stdout/stderr
// capture (getStderr() returns '' in disk mode). The StreamWrappers stay
// attached and pipe data into the in-memory TaskOutput buffers. The abort
// handler already no-ops on 'interrupt' reason (user submitted a new
// message), so the hook survives new prompts. A hard cancel (Escape) WILL
// kill the hook via the abort handler, which is the desired behavior.
void shellCommand.result.then(async result => {
// result resolves on 'exit', but stdio 'data' events may still be
// pending. Yield to I/O so the StreamWrapper data handlers drain into
// TaskOutput before we read it.
await new Promise(resolve => setImmediate(resolve))
const stdout = await shellCommand.taskOutput.getStdout()
const stderr = shellCommand.taskOutput.getStderr()
shellCommand.cleanup()
emitHookResponse({
hookId,
hookName,
hookEvent,
output: stdout + stderr,
stdout,
stderr,
exitCode: result.code,
outcome: result.code === 0 ? 'success' : 'error',
})
if (result.code === 2) {
enqueuePendingNotification({
value: wrapInSystemReminder(
`Stop hook blocking error from command "${hookName}": ${stderr || stdout}`,
),
mode: 'task-notification',
})
}
})
return true
}
// TaskOutput on the ShellCommand accumulates data — no stream listeners needed
if (!shellCommand.background(processId)) {
return false
}
registerPendingAsyncHook({
processId,
hookId,
asyncResponse,
hookEvent,
hookName,
command,
shellCommand,
pluginId,
})
return true
}
/**
* Checks if a hook should be skipped due to lack of workspace trust.
*
* ALL hooks require workspace trust because they execute arbitrary commands from
* .claude/settings.json. This is a defense-in-depth security measure.
*
* Context: Hooks are captured via captureHooksConfigSnapshot() before the trust
* dialog is shown. While most hooks won't execute until after trust is established
* through normal program flow, enforcing trust for ALL hooks prevents:
* - Future bugs where a hook might accidentally execute before trust
* - Any codepath that might trigger hooks before trust dialog
* - Security issues from hook execution in untrusted workspaces
*
* Historical vulnerabilities that prompted this check:
* - SessionEnd hooks executing when user declines trust dialog
* - SubagentStop hooks executing when subagent completes before trust
*
* @returns true if hook should be skipped, false if it should execute
*/
export function shouldSkipHookDueToTrust(): boolean {
// In non-interactive mode (SDK), trust is implicit - always execute
const isInteractive = !getIsNonInteractiveSession()
if (!isInteractive) {
return false
}
// In interactive mode, ALL hooks require trust
const hasTrust = checkHasTrustDialogAccepted()
return !hasTrust
}
/**
* Creates the base hook input that's common to all hook types
*/
export function createBaseHookInput(
permissionMode?: string,
sessionId?: string,
// Typed narrowly (not ToolUseContext) so callers can pass toolUseContext
// directly via structural typing without this function depending on Tool.ts.
agentInfo?: { agentId?: string; agentType?: string },
): {
session_id: string
transcript_path: string
cwd: string
permission_mode?: string
agent_id?: string
agent_type?: string
} {
const resolvedSessionId = sessionId ?? getSessionId()
// agent_type: subagent's type (from toolUseContext) takes precedence over
// the session's --agent flag. Hooks use agent_id presence to distinguish
// subagent calls from main-thread calls in a --agent session.
const resolvedAgentType = agentInfo?.agentType ?? getMainThreadAgentType()
return {
session_id: resolvedSessionId,
transcript_path: getTranscriptPathForSession(resolvedSessionId),
cwd: getCwd(),
permission_mode: permissionMode,
agent_id: agentInfo?.agentId,
agent_type: resolvedAgentType,
}
}
export interface HookBlockingError {
blockingError: string
command: string
}
/** Re-export ElicitResult from MCP SDK as ElicitationResponse for backward compat. */
export type ElicitationResponse = ElicitResult
export interface HookResult {
message?: HookResultMessage
systemMessage?: string
blockingError?: HookBlockingError
outcome: 'success' | 'blocking' | 'non_blocking_error' | 'cancelled'
preventContinuation?: boolean
stopReason?: string
permissionBehavior?: 'ask' | 'deny' | 'allow' | 'passthrough'
hookPermissionDecisionReason?: string
additionalContext?: string
initialUserMessage?: string
updatedInput?: Record<string, unknown>
updatedMCPToolOutput?: unknown
permissionRequestResult?: PermissionRequestResult
elicitationResponse?: ElicitationResponse
watchPaths?: string[]
elicitationResultResponse?: ElicitationResponse
retry?: boolean
hook: HookCommand | HookCallback | FunctionHook
}
export type AggregatedHookResult = {
message?: HookResultMessage
blockingError?: HookBlockingError
preventContinuation?: boolean
stopReason?: string
hookPermissionDecisionReason?: string
hookSource?: string
permissionBehavior?: PermissionResult['behavior']
additionalContexts?: string[]
initialUserMessage?: string
updatedInput?: Record<string, unknown>
updatedMCPToolOutput?: unknown
permissionRequestResult?: PermissionRequestResult
watchPaths?: string[]
elicitationResponse?: ElicitationResponse
elicitationResultResponse?: ElicitationResponse
retry?: boolean
}
/**
* Parse and validate a JSON string against the hook output Zod schema.
* Returns the validated output or formatted validation errors.
*/
function validateHookJson(
jsonString: string,
): { json: HookJSONOutput } | { validationError: string } {
const parsed = jsonParse(jsonString)
const validation = hookJSONOutputSchema().safeParse(parsed)
if (validation.success) {
logForDebugging('Successfully parsed and validated hook JSON output')
return { json: validation.data }
}
const errors = validation.error.issues
.map(err => ` - ${err.path.join('.')}: ${err.message}`)
.join('\n')
return {
validationError: `Hook JSON output validation failed:\n${errors}\n\nThe hook's output was: ${jsonStringify(parsed, null, 2)}`,
}
}
function parseHookOutput(stdout: string): {
json?: HookJSONOutput
plainText?: string
validationError?: string
} {
const trimmed = stdout.trim()
if (!trimmed.startsWith('{')) {
logForDebugging('Hook output does not start with {, treating as plain text')
return { plainText: stdout }
}
try {
const result = validateHookJson(trimmed)
if ('json' in result) {
return result
}
// For command hooks, include the schema hint in the error message
const errorMessage = `${result.validationError}\n\nExpected schema:\n${jsonStringify(
{
continue: 'boolean (optional)',
suppressOutput: 'boolean (optional)',
stopReason: 'string (optional)',
decision: '"approve" | "block" (optional)',
reason: 'string (optional)',
systemMessage: 'string (optional)',
permissionDecision: '"allow" | "deny" | "ask" (optional)',
hookSpecificOutput: {
'for PreToolUse': {
hookEventName: '"PreToolUse"',
permissionDecision: '"allow" | "deny" | "ask" (optional)',
permissionDecisionReason: 'string (optional)',
updatedInput: 'object (optional) - Modified tool input to use',
},
'for UserPromptSubmit': {
hookEventName: '"UserPromptSubmit"',
additionalContext: 'string (required)',
},
'for PostToolUse': {
hookEventName: '"PostToolUse"',
additionalContext: 'string (optional)',
},
},
},
null,
2,
)}`
logForDebugging(errorMessage)
return { plainText: stdout, validationError: errorMessage }
} catch (e) {
logForDebugging(`Failed to parse hook output as JSON: ${e}`)
return { plainText: stdout }
}
}
function parseHttpHookOutput(body: string): {
json?: HookJSONOutput
validationError?: string
} {
const trimmed = body.trim()
if (trimmed === '') {
const validation = hookJSONOutputSchema().safeParse({})
if (validation.success) {
logForDebugging(
'HTTP hook returned empty body, treating as empty JSON object',
)
return { json: validation.data }
}
}
if (!trimmed.startsWith('{')) {
const validationError = `HTTP hook must return JSON, but got non-JSON response body: ${trimmed.length > 200 ? trimmed.slice(0, 200) + '\u2026' : trimmed}`
logForDebugging(validationError)
return { validationError }
}
try {
const result = validateHookJson(trimmed)
if ('json' in result) {
return result
}
logForDebugging(result.validationError)
return result
} catch (e) {
const validationError = `HTTP hook must return valid JSON, but parsing failed: ${e}`
logForDebugging(validationError)
return { validationError }
}
}
function processHookJSONOutput({
json,
command,
hookName,
toolUseID,
hookEvent,
expectedHookEvent,
stdout,
stderr,
exitCode,
durationMs,
}: {
json: SyncHookJSONOutput
command: string
hookName: string
toolUseID: string
hookEvent: HookEvent
expectedHookEvent?: HookEvent
stdout?: string
stderr?: string
exitCode?: number
durationMs?: number
}): Partial<HookResult> {
const result: Partial<HookResult> = {}
// At this point we know it's a sync response
const syncJson = json
// Handle common elements
if (syncJson.continue === false) {
result.preventContinuation = true
if (syncJson.stopReason) {
result.stopReason = syncJson.stopReason
}
}
if (json.decision) {
switch (json.decision) {
case 'approve':
result.permissionBehavior = 'allow'
break
case 'block':
result.permissionBehavior = 'deny'
result.blockingError = {
blockingError: json.reason || 'Blocked by hook',
command,
}
break
default:
// Handle unknown decision types as errors
throw new Error(
`Unknown hook decision type: ${json.decision}. Valid types are: approve, block`,
)
}
}
// Handle systemMessage field
if (json.systemMessage) {
result.systemMessage = json.systemMessage
}
// Handle PreToolUse specific
if (
json.hookSpecificOutput?.hookEventName === 'PreToolUse' &&
json.hookSpecificOutput.permissionDecision
) {
switch (json.hookSpecificOutput.permissionDecision) {
case 'allow':
result.permissionBehavior = 'allow'
break
case 'deny':
result.permissionBehavior = 'deny'
result.blockingError = {
blockingError: json.reason || 'Blocked by hook',
command,
}
break
case 'ask':
result.permissionBehavior = 'ask'
break
default:
// Handle unknown decision types as errors
throw new Error(
`Unknown hook permissionDecision type: ${json.hookSpecificOutput.permissionDecision}. Valid types are: allow, deny, ask`,
)
}
}
if (result.permissionBehavior !== undefined && json.reason !== undefined) {
result.hookPermissionDecisionReason = json.reason
}
// Handle hookSpecificOutput
if (json.hookSpecificOutput) {
// Validate hook event name matches expected if provided
if (
expectedHookEvent &&
json.hookSpecificOutput.hookEventName !== expectedHookEvent
) {
throw new Error(
`Hook returned incorrect event name: expected '${expectedHookEvent}' but got '${json.hookSpecificOutput.hookEventName}'. Full stdout: ${jsonStringify(json, null, 2)}`,
)
}
switch (json.hookSpecificOutput.hookEventName) {
case 'PreToolUse':
// Override with more specific permission decision if provided
if (json.hookSpecificOutput.permissionDecision) {
switch (json.hookSpecificOutput.permissionDecision) {
case 'allow':
result.permissionBehavior = 'allow'
break
case 'deny':
result.permissionBehavior = 'deny'
result.blockingError = {
blockingError:
json.hookSpecificOutput.permissionDecisionReason ||
json.reason ||
'Blocked by hook',
command,
}
break
case 'ask':
result.permissionBehavior = 'ask'
break
}
}
result.hookPermissionDecisionReason =
json.hookSpecificOutput.permissionDecisionReason
// Extract updatedInput if provided
if (json.hookSpecificOutput.updatedInput) {
result.updatedInput = json.hookSpecificOutput.updatedInput
}
// Extract additionalContext if provided
result.additionalContext = json.hookSpecificOutput.additionalContext
break
case 'UserPromptSubmit':
result.additionalContext = json.hookSpecificOutput.additionalContext
break
case 'SessionStart':
result.additionalContext = json.hookSpecificOutput.additionalContext
result.initialUserMessage = json.hookSpecificOutput.initialUserMessage
if (
'watchPaths' in json.hookSpecificOutput &&
json.hookSpecificOutput.watchPaths
) {
result.watchPaths = json.hookSpecificOutput.watchPaths
}
break
case 'Setup':
result.additionalContext = json.hookSpecificOutput.additionalContext
break
case 'SubagentStart':
result.additionalContext = json.hookSpecificOutput.additionalContext
break
case 'PostToolUse':
result.additionalContext = json.hookSpecificOutput.additionalContext
// Extract updatedMCPToolOutput if provided
if (json.hookSpecificOutput.updatedMCPToolOutput) {
result.updatedMCPToolOutput =
json.hookSpecificOutput.updatedMCPToolOutput
}
break
case 'PostToolUseFailure':
result.additionalContext = json.hookSpecificOutput.additionalContext
break
case 'PermissionDenied':
result.retry = json.hookSpecificOutput.retry
break
case 'PermissionRequest':
// Extract the permission request decision
if (json.hookSpecificOutput.decision) {
result.permissionRequestResult = json.hookSpecificOutput.decision
// Also update permissionBehavior for consistency
result.permissionBehavior =
json.hookSpecificOutput.decision.behavior === 'allow'
? 'allow'
: 'deny'
if (
json.hookSpecificOutput.decision.behavior === 'allow' &&
json.hookSpecificOutput.decision.updatedInput
) {
result.updatedInput = json.hookSpecificOutput.decision.updatedInput
}
}
break
case 'Elicitation':
if (json.hookSpecificOutput.action) {
result.elicitationResponse = {
action: json.hookSpecificOutput.action,
content: json.hookSpecificOutput.content as
| ElicitationResponse['content']
| undefined,
}
if (json.hookSpecificOutput.action === 'decline') {
result.blockingError = {
blockingError: json.reason || 'Elicitation denied by hook',
command,
}
}
}
break
case 'ElicitationResult':
if (json.hookSpecificOutput.action) {
result.elicitationResultResponse = {
action: json.hookSpecificOutput.action,
content: json.hookSpecificOutput.content as
| ElicitationResponse['content']
| undefined,
}
if (json.hookSpecificOutput.action === 'decline') {
result.blockingError = {
blockingError:
json.reason || 'Elicitation result blocked by hook',
command,
}
}
}
break
}
}
return {
...result,
message: result.blockingError
? createAttachmentMessage({
type: 'hook_blocking_error',
hookName,
toolUseID,
hookEvent,
blockingError: result.blockingError,
})
: createAttachmentMessage({
type: 'hook_success',
hookName,
toolUseID,
hookEvent,
// JSON-output hooks inject context via additionalContext →
// hook_additional_context, not this field. Empty content suppresses
// the trivial "X hook success: Success" system-reminder that
// otherwise pollutes every turn (messages.ts:3577 skips on '').
content: '',
stdout,
stderr,
exitCode,
command,
durationMs,
}),
}
}
/**
* Execute a command-based hook using bash or PowerShell.
*
* Shell resolution: hook.shell → 'bash'. PowerShell hooks spawn pwsh
* with -NoProfile -NonInteractive -Command and skip bash-specific prep
* (POSIX path conversion, .sh auto-prepend, CLAUDE_CODE_SHELL_PREFIX).
* See docs/design/ps-shell-selection.md §5.1.
*/
async function execCommandHook(
hook: HookCommand & { type: 'command' },
hookEvent: HookEvent | 'StatusLine' | 'FileSuggestion',
hookName: string,
jsonInput: string,
signal: AbortSignal,
hookId: string,
hookIndex?: number,
pluginRoot?: string,
pluginId?: string,
skillRoot?: string,
forceSyncExecution?: boolean,
requestPrompt?: (request: PromptRequest) => Promise<PromptResponse>,
): Promise<{
stdout: string
stderr: string
output: string
status: number
aborted?: boolean
backgrounded?: boolean
}> {
// Gated to once-per-session events to keep diag_log volume bounded.
// started/completed live inside the try/finally so setup-path throws
// don't orphan a started marker — that'd be indistinguishable from a hang.
const shouldEmitDiag =
hookEvent === 'SessionStart' ||
hookEvent === 'Setup' ||
hookEvent === 'SessionEnd'
const diagStartMs = Date.now()
let diagExitCode: number | undefined
let diagAborted = false
const isWindows = getPlatform() === 'windows'
// --
// Per-hook shell selection (phase 1 of docs/design/ps-shell-selection.md).
// Resolution order: hook.shell → DEFAULT_HOOK_SHELL. The defaultShell
// fallback (settings.defaultShell) is phase 2 — not wired yet.
//
// The bash path is the historical default and stays unchanged. The
// PowerShell path deliberately skips the Windows-specific bash
// accommodations (cygpath conversion, .sh auto-prepend, POSIX-quoted
// SHELL_PREFIX).
const shellType = hook.shell ?? DEFAULT_HOOK_SHELL
const isPowerShell = shellType === 'powershell'
// --
// Windows bash path: hooks run via Git Bash (Cygwin), NOT cmd.exe.
//
// This means every path we put into env vars or substitute into the command
// string MUST be a POSIX path (/c/Users/foo), not a Windows path
// (C:\Users\foo or C:/Users/foo). Git Bash cannot resolve Windows paths.
//
// windowsPathToPosixPath() is pure-JS regex conversion (no cygpath shell-out):
// C:\Users\foo -> /c/Users/foo, UNC preserved, slashes flipped. Memoized
// (LRU-500) so repeated calls are cheap.
//
// PowerShell path: use native paths — skip the conversion entirely.
// PowerShell expects Windows paths on Windows (and native paths on
// Unix where pwsh is also available).
const toHookPath =
isWindows && !isPowerShell
? (p: string) => windowsPathToPosixPath(p)
: (p: string) => p
// Set CLAUDE_PROJECT_DIR to the stable project root (not the worktree path).
// getProjectRoot() is never updated when entering a worktree, so hooks that
// reference $CLAUDE_PROJECT_DIR always resolve relative to the real repo root.
const projectDir = getProjectRoot()
// Substitute ${CLAUDE_PLUGIN_ROOT} and ${user_config.X} in the command string.
// Order matches MCP/LSP (plugin vars FIRST, then user config) so a user-
// entered value containing the literal text ${CLAUDE_PLUGIN_ROOT} is treated
// as opaque — not re-interpreted as a template.
let command = hook.command
let pluginOpts: ReturnType<typeof loadPluginOptions> | undefined
if (pluginRoot) {
// Plugin directory gone (orphan GC race, concurrent session deleted it):
// throw so callers yield a non-blocking error. Running would fail — and
// `python3 <missing>.py` exits 2, the hook protocol's "block" code, which
// bricks UserPromptSubmit/Stop until restart. The pre-check is necessary
// because exit-2-from-missing-script is indistinguishable from an
// intentional block after spawn.
if (!(await pathExists(pluginRoot))) {
throw new Error(
`Plugin directory does not exist: ${pluginRoot}` +
(pluginId ? ` (${pluginId} — run /plugin to reinstall)` : ''),
)
}
// Inline both ROOT and DATA substitution instead of calling
// substitutePluginVariables(). That helper normalizes \ → / on Windows
// unconditionally — correct for bash (toHookPath already produced /c/...
// so it's a no-op) but wrong for PS where toHookPath is identity and we
// want native C:\... backslashes. Inlining also lets us use the function-
// form .replace() so paths containing $ aren't mangled by $-pattern
// interpretation (rare but possible: \\server\c$\plugin).
const rootPath = toHookPath(pluginRoot)
command = command.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, () => rootPath)
if (pluginId) {
const dataPath = toHookPath(getPluginDataDir(pluginId))
command = command.replace(/\$\{CLAUDE_PLUGIN_DATA\}/g, () => dataPath)
}
if (pluginId) {
pluginOpts = loadPluginOptions(pluginId)
// Throws if a referenced key is missing — that means the hook uses a key
// that's either not declared in manifest.userConfig or not yet configured.
// Caught upstream like any other hook exec failure.
command = substituteUserConfigVariables(command, pluginOpts)
}
}
// On Windows (bash only), auto-prepend `bash` for .sh scripts so they
// execute instead of opening in the default file handler. PowerShell
// runs .ps1 files natively — no prepend needed.
if (isWindows && !isPowerShell && command.trim().match(/\.sh(\s|$|")/)) {
if (!command.trim().startsWith('bash ')) {
command = `bash ${command}`
}
}
// CLAUDE_CODE_SHELL_PREFIX wraps the command via POSIX quoting
// (formatShellPrefixCommand uses shell-quote). This makes no sense for
// PowerShell — see design §8.1. For now PS hooks ignore the prefix;
// a CLAUDE_CODE_PS_SHELL_PREFIX (or shell-aware prefix) is a follow-up.
const finalCommand =
!isPowerShell && process.env.CLAUDE_CODE_SHELL_PREFIX
? formatShellPrefixCommand(process.env.CLAUDE_CODE_SHELL_PREFIX, command)
: command
const hookTimeoutMs = hook.timeout
? hook.timeout * 1000
: TOOL_HOOK_EXECUTION_TIMEOUT_MS
// Build env vars — all paths go through toHookPath for Windows POSIX conversion
const envVars: NodeJS.ProcessEnv = {
...subprocessEnv(),
CLAUDE_PROJECT_DIR: toHookPath(projectDir),
}
// Plugin and skill hooks both set CLAUDE_PLUGIN_ROOT (skills use the same
// name for consistency — skills can migrate to plugins without code changes)
if (pluginRoot) {
envVars.CLAUDE_PLUGIN_ROOT = toHookPath(pluginRoot)
if (pluginId) {
envVars.CLAUDE_PLUGIN_DATA = toHookPath(getPluginDataDir(pluginId))
}
}
// Expose plugin options as env vars too, so hooks can read them without
// ${user_config.X} in the command string. Sensitive values included — hooks
// run the user's own code, same trust boundary as reading keychain directly.
if (pluginOpts) {
for (const [key, value] of Object.entries(pluginOpts)) {
// Sanitize non-identifier chars (bash can't ref $FOO-BAR). The schema
// at schemas.ts:611 now constrains keys to /^[A-Za-z_]\w*$/ so this is
// belt-and-suspenders, but cheap insurance if someone bypasses the schema.
const envKey = key.replace(/[^A-Za-z0-9_]/g, '_').toUpperCase()
envVars[`CLAUDE_PLUGIN_OPTION_${envKey}`] = String(value)
}
}
if (skillRoot) {
envVars.CLAUDE_PLUGIN_ROOT = toHookPath(skillRoot)
}
// CLAUDE_ENV_FILE points to a .sh file that the hook writes env var
// definitions into; getSessionEnvironmentScript() concatenates them and
// bashProvider injects the content into bash commands. A PS hook would
// naturally write PS syntax ($env:FOO = 'bar'), which bash can't parse.
// Skip for PS — consistent with how .sh prepend and SHELL_PREFIX are
// already bash-only above.
if (
!isPowerShell &&
(hookEvent === 'SessionStart' ||
hookEvent === 'Setup' ||
hookEvent === 'CwdChanged' ||
hookEvent === 'FileChanged') &&
hookIndex !== undefined
) {
envVars.CLAUDE_ENV_FILE = await getHookEnvFilePath(hookEvent, hookIndex)
}
// When agent worktrees are removed, getCwd() may return a deleted path via
// AsyncLocalStorage. Validate before spawning since spawn() emits async
// 'error' events for missing cwd rather than throwing synchronously.
const hookCwd = getCwd()
const safeCwd = (await pathExists(hookCwd)) ? hookCwd : getOriginalCwd()
if (safeCwd !== hookCwd) {
logForDebugging(
`Hooks: cwd ${hookCwd} not found, falling back to original cwd`,
{ level: 'warn' },
)
}
// --
// Spawn. Two completely separate paths:
//
// Bash: spawn(cmd, [], { shell: <gitBashPath | true> }) — the shell
// option makes Node pass the whole string to the shell for parsing.
//
// PowerShell: spawn(pwshPath, ['-NoProfile', '-NonInteractive',
// '-Command', cmd]) — explicit argv, no shell option. -NoProfile
// skips user profile scripts (faster, deterministic).
// -NonInteractive fails fast instead of prompting.
//
// The Git Bash hard-exit in findGitBashPath() is still in place for
// bash hooks. PowerShell hooks never call it, so a Windows user with
// only pwsh and shell: 'powershell' on every hook could in theory run
// without Git Bash — but init.ts still calls setShellIfWindows() on
// startup, which will exit first. Relaxing that is phase 1 of the
// design's implementation order (separate PR).
let child: ChildProcessWithoutNullStreams
if (shellType === 'powershell') {
const pwshPath = await getCachedPowerShellPath()
if (!pwshPath) {
throw new Error(
`Hook "${hook.command}" has shell: 'powershell' but no PowerShell ` +
`executable (pwsh or powershell) was found on PATH. Install ` +
`PowerShell, or remove "shell": "powershell" to use bash.`,
)
}
child = spawn(pwshPath, buildPowerShellArgs(finalCommand), {
env: envVars,
cwd: safeCwd,
// Prevent visible console window on Windows (no-op on other platforms)
windowsHide: true,
}) as ChildProcessWithoutNullStreams
} else {
// On Windows, use Git Bash explicitly (cmd.exe can't run bash syntax).
// On other platforms, shell: true uses /bin/sh.
const shell = isWindows ? findGitBashPath() : true
child = spawn(finalCommand, [], {
env: envVars,
cwd: safeCwd,
shell,
// Prevent visible console window on Windows (no-op on other platforms)
windowsHide: true,
}) as ChildProcessWithoutNullStreams
}
// Hooks use pipe mode — stdout must be streamed into JS so we can parse
// the first response line to detect async hooks ({"async": true}).
const hookTaskOutput = new TaskOutput(`hook_${child.pid}`, null)
const shellCommand = wrapSpawn(child, signal, hookTimeoutMs, hookTaskOutput)
// Track whether shellCommand ownership was transferred (e.g., to async hook registry)
let shellCommandTransferred = false
// Track whether stdin has already been written (to avoid "write after end" errors)
let stdinWritten = false
if ((hook.async || hook.asyncRewake) && !forceSyncExecution) {
const processId = `async_hook_${child.pid}`
logForDebugging(
`Hooks: Config-based async hook, backgrounding process ${processId}`,
)
// Write stdin before backgrounding so the hook receives its input.
// The trailing newline matches the sync path (L1000). Without it,
// bash `read -r line` returns exit 1 (EOF before delimiter) — the
// variable IS populated but `if read -r line; then ...` skips the
// branch. See gh-30509 / CC-161.
child.stdin.write(jsonInput + '\n', 'utf8')
child.stdin.end()
stdinWritten = true
const backgrounded = executeInBackground({
processId,
hookId,
shellCommand,
asyncResponse: { async: true, asyncTimeout: hookTimeoutMs },
hookEvent,
hookName,
command: hook.command,
asyncRewake: hook.asyncRewake,
pluginId,
})
if (backgrounded) {
return {
stdout: '',
stderr: '',
output: '',
status: 0,
backgrounded: true,
}
}
}
let stdout = ''
let stderr = ''
let output = ''
// Set up output data collection with explicit UTF-8 encoding
child.stdout.setEncoding('utf8')
child.stderr.setEncoding('utf8')
let initialResponseChecked = false
let asyncResolve:
| ((result: {
stdout: string
stderr: string
output: string
status: number
}) => void)
| null = null
const childIsAsyncPromise = new Promise<{
stdout: string
stderr: string
output: string
status: number
aborted?: boolean
}>(resolve => {
asyncResolve = resolve
})
// Track trimmed prompt-request lines we processed so we can strip them
// from final stdout by content match (no index tracking → no index drift)
const processedPromptLines = new Set<string>()
// Serialize async prompt handling so responses are sent in order
let promptChain = Promise.resolve()
// Line buffer for detecting prompt requests in streaming output
let lineBuffer = ''
child.stdout.on('data', data => {
stdout += data
output += data
// When requestPrompt is provided, parse stdout line-by-line for prompt requests
if (requestPrompt) {
lineBuffer += data
const lines = lineBuffer.split('\n')
lineBuffer = lines.pop() ?? '' // last element is an incomplete line
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed) continue
try {
const parsed = jsonParse(trimmed)
const validation = promptRequestSchema().safeParse(parsed)
if (validation.success) {
processedPromptLines.add(trimmed)
logForDebugging(
`Hooks: Detected prompt request from hook: ${trimmed}`,
)
// Chain the async handling to serialize prompt responses
const promptReq = validation.data
const reqPrompt = requestPrompt
promptChain = promptChain.then(async () => {
try {
const response = await reqPrompt(promptReq)
child.stdin.write(jsonStringify(response) + '\n', 'utf8')
} catch (err) {
logForDebugging(`Hooks: Prompt request handling failed: ${err}`)
// User cancelled or prompt failed — close stdin so the hook
// process doesn't hang waiting for input
child.stdin.destroy()
}
})
continue
}
} catch {
// Not JSON, just a normal line
}
}
}
// Check for async response on first line of output. The async protocol is:
// hook emits {"async":true,...} as its FIRST line, then its normal output.
// We must parse ONLY the first line — if the process is fast and writes more
// before this 'data' event fires, parsing the full accumulated stdout fails
// and an async hook blocks for its full duration instead of backgrounding.
if (!initialResponseChecked) {
const firstLine = firstLineOf(stdout).trim()
if (!firstLine.includes('}')) return
initialResponseChecked = true
logForDebugging(`Hooks: Checking first line for async: ${firstLine}`)
try {
const parsed = jsonParse(firstLine)
logForDebugging(
`Hooks: Parsed initial response: ${jsonStringify(parsed)}`,
)
if (isAsyncHookJSONOutput(parsed) && !forceSyncExecution) {
const processId = `async_hook_${child.pid}`
logForDebugging(
`Hooks: Detected async hook, backgrounding process ${processId}`,
)
const backgrounded = executeInBackground({
processId,
hookId,
shellCommand,
asyncResponse: parsed,
hookEvent,
hookName,
command: hook.command,
pluginId,
})
if (backgrounded) {
shellCommandTransferred = true
asyncResolve?.({
stdout,
stderr,
output,
status: 0,
})
}
} else if (isAsyncHookJSONOutput(parsed) && forceSyncExecution) {
logForDebugging(
`Hooks: Detected async hook but forceSyncExecution is true, waiting for completion`,
)
} else {
logForDebugging(
`Hooks: Initial response is not async, continuing normal processing`,
)
}
} catch (e) {
logForDebugging(`Hooks: Failed to parse initial response as JSON: ${e}`)
}
}
})
child.stderr.on('data', data => {
stderr += data
output += data
})
const stopProgressInterval = startHookProgressInterval({
hookId,
hookName,
hookEvent,
getOutput: async () => ({ stdout, stderr, output }),
})
// Wait for stdout and stderr streams to finish before considering output complete
// This prevents a race condition where 'close' fires before all 'data' events are processed
const stdoutEndPromise = new Promise<void>(resolve => {
child.stdout.on('end', () => resolve())
})
const stderrEndPromise = new Promise<void>(resolve => {
child.stderr.on('end', () => resolve())
})
// Write to stdin, making sure to handle EPIPE errors that can happen when
// the hook command exits before reading all input.
// Note: EPIPE handling is difficult to set up in testing since Bun and Node
// have different behaviors.
// TODO: Add tests for EPIPE handling.
// Skip if stdin was already written (e.g., by config-based async hook path)
const stdinWritePromise = stdinWritten
? Promise.resolve()
: new Promise<void>((resolve, reject) => {
child.stdin.on('error', err => {
// When requestPrompt is provided, stdin stays open for prompt responses.
// EPIPE errors from later writes (after process exits) are expected -- suppress them.
if (!requestPrompt) {
reject(err)
} else {
logForDebugging(
`Hooks: stdin error during prompt flow (likely process exited): ${err}`,
)
}
})
// Explicitly specify UTF-8 encoding to ensure proper handling of Unicode characters
child.stdin.write(jsonInput + '\n', 'utf8')
// When requestPrompt is provided, keep stdin open for prompt responses
if (!requestPrompt) {
child.stdin.end()
}
resolve()
})
// Create promise for child process error
const childErrorPromise = new Promise<never>((_, reject) => {
child.on('error', reject)
})
// Create promise for child process close - but only resolve after streams end
// to ensure all output has been collected
const childClosePromise = new Promise<{
stdout: string
stderr: string
output: string
status: number
aborted?: boolean
}>(resolve => {
let exitCode: number | null = null
child.on('close', code => {
exitCode = code ?? 1
// Wait for both streams to end before resolving with the final output
void Promise.all([stdoutEndPromise, stderrEndPromise]).then(() => {
// Strip lines we processed as prompt requests so parseHookOutput
// only sees the final hook result. Content-matching against the set
// of actually-processed lines means prompt JSON can never leak
// through (fail-closed), regardless of line positioning.
const finalStdout =
processedPromptLines.size === 0
? stdout
: stdout
.split('\n')
.filter(line => !processedPromptLines.has(line.trim()))
.join('\n')
resolve({
stdout: finalStdout,
stderr,
output,
status: exitCode!,
aborted: signal.aborted,
})
})
})
})
// Race between stdin write, async detection, and process completion
try {
if (shouldEmitDiag) {
logForDiagnosticsNoPII('info', 'hook_spawn_started', {
hook_event_name: hookEvent,
index: hookIndex,
})
}
await Promise.race([stdinWritePromise, childErrorPromise])
// Wait for any pending prompt responses before resolving
const result = await Promise.race([
childIsAsyncPromise,
childClosePromise,
childErrorPromise,
])
// Ensure all queued prompt responses have been sent
await promptChain
diagExitCode = result.status
diagAborted = result.aborted ?? false
return result
} catch (error) {
// Handle errors from stdin write or child process
const code = getErrnoCode(error)
diagExitCode = 1
if (code === 'EPIPE') {
logForDebugging(
'EPIPE error while writing to hook stdin (hook command likely closed early)',
)
const errMsg =
'Hook command closed stdin before hook input was fully written (EPIPE)'
return {
stdout: '',
stderr: errMsg,
output: errMsg,
status: 1,
}
} else if (code === 'ABORT_ERR') {
diagAborted = true
return {
stdout: '',
stderr: 'Hook cancelled',
output: 'Hook cancelled',
status: 1,
aborted: true,
}
} else {
const errorMsg = errorMessage(error)
const errOutput = `Error occurred while executing hook command: ${errorMsg}`
return {
stdout: '',
stderr: errOutput,
output: errOutput,
status: 1,
}
}
} finally {
if (shouldEmitDiag) {
logForDiagnosticsNoPII('info', 'hook_spawn_completed', {
hook_event_name: hookEvent,
index: hookIndex,
duration_ms: Date.now() - diagStartMs,
exit_code: diagExitCode,
aborted: diagAborted,
})
}
stopProgressInterval()
// Clean up stream resources unless ownership was transferred (e.g., to async hook registry)
if (!shellCommandTransferred) {
shellCommand.cleanup()
}
}
}
/**
* Check if a match query matches a hook matcher pattern
* @param matchQuery The query to match (e.g., 'Write', 'Edit', 'Bash')
* @param matcher The matcher pattern - can be:
* - Simple string for exact match (e.g., 'Write')
* - Pipe-separated list for multiple exact matches (e.g., 'Write|Edit')
* - Regex pattern (e.g., '^Write.*', '.*', '^(Write|Edit)$')
* @returns true if the query matches the pattern
*/
function matchesPattern(matchQuery: string, matcher: string): boolean {
if (!matcher || matcher === '*') {
return true
}
// Check if it's a simple string or pipe-separated list (no regex special chars except |)
if (/^[a-zA-Z0-9_|]+$/.test(matcher)) {
// Handle pipe-separated exact matches
if (matcher.includes('|')) {
const patterns = matcher
.split('|')
.map(p => normalizeLegacyToolName(p.trim()))
return patterns.includes(matchQuery)
}
// Simple exact match
return matchQuery === normalizeLegacyToolName(matcher)
}
// Otherwise treat as regex
try {
const regex = new RegExp(matcher)
if (regex.test(matchQuery)) {
return true
}
// Also test against legacy names so patterns like "^Task$" still match
for (const legacyName of getLegacyToolNames(matchQuery)) {
if (regex.test(legacyName)) {
return true
}
}
return false
} catch {
// If the regex is invalid, log error and return false
logForDebugging(`Invalid regex pattern in hook matcher: ${matcher}`)
return false
}
}
type IfConditionMatcher = (ifCondition: string) => boolean
/**
* Prepare a matcher for hook `if` conditions. Expensive work (tool lookup,
* Zod validation, tree-sitter parsing for Bash) happens once here; the
* returned closure is called per hook. Returns undefined for non-tool events.
*/
async function prepareIfConditionMatcher(
hookInput: HookInput,
tools: Tools | undefined,
): Promise<IfConditionMatcher | undefined> {
if (
hookInput.hook_event_name !== 'PreToolUse' &&
hookInput.hook_event_name !== 'PostToolUse' &&
hookInput.hook_event_name !== 'PostToolUseFailure' &&
hookInput.hook_event_name !== 'PermissionRequest'
) {
return undefined
}
const toolName = normalizeLegacyToolName(hookInput.tool_name)
const tool = tools && findToolByName(tools, hookInput.tool_name)
const input = tool?.inputSchema.safeParse(hookInput.tool_input)
const patternMatcher =
input?.success && tool?.preparePermissionMatcher
? await tool.preparePermissionMatcher(input.data)
: undefined
return ifCondition => {
const parsed = permissionRuleValueFromString(ifCondition)
if (normalizeLegacyToolName(parsed.toolName) !== toolName) {
return false
}
if (!parsed.ruleContent) {
return true
}
return patternMatcher ? patternMatcher(parsed.ruleContent) : false
}
}
type FunctionHookMatcher = {
matcher: string
hooks: FunctionHook[]
}
/**
* A hook paired with optional plugin context.
* Used when returning matched hooks so we can apply plugin env vars at execution time.
*/
type MatchedHook = {
hook: HookCommand | HookCallback | FunctionHook
pluginRoot?: string
pluginId?: string
skillRoot?: string
hookSource?: string
}
function isInternalHook(matched: MatchedHook): boolean {
return matched.hook.type === 'callback' && matched.hook.internal === true
}
/**
* Build a dedup key for a matched hook, namespaced by source context.
*
* Settings-file hooks (no pluginRoot/skillRoot) share the '' prefix so the
* same command defined in user/project/local still collapses to one — the
* original intent of the dedup. Plugin/skill hooks get their root as the
* prefix, so two plugins sharing an unexpanded `${CLAUDE_PLUGIN_ROOT}/hook.sh`
* template don't collapse: after expansion they point to different files.
*/
function hookDedupKey(m: MatchedHook, payload: string): string {
return `${m.pluginRoot ?? m.skillRoot ?? ''}\0${payload}`
}
/**
* Build a map of {sanitizedPluginName: hookCount} from matched hooks.
* Only logs actual names for official marketplace plugins; others become 'third-party'.
*/
function getPluginHookCounts(
hooks: MatchedHook[],
): Record<string, number> | undefined {
const pluginHooks = hooks.filter(h => h.pluginId)
if (pluginHooks.length === 0) {
return undefined
}
const counts: Record<string, number> = {}
for (const h of pluginHooks) {
const atIndex = h.pluginId!.lastIndexOf('@')
const isOfficial =
atIndex > 0 &&
ALLOWED_OFFICIAL_MARKETPLACE_NAMES.has(h.pluginId!.slice(atIndex + 1))
const key = isOfficial ? h.pluginId! : 'third-party'
counts[key] = (counts[key] || 0) + 1
}
return counts
}
/**
* Build a map of {hookType: count} from matched hooks.
*/
function getHookTypeCounts(hooks: MatchedHook[]): Record<string, number> {
const counts: Record<string, number> = {}
for (const h of hooks) {
counts[h.hook.type] = (counts[h.hook.type] || 0) + 1
}
return counts
}
function getHooksConfig(
appState: AppState | undefined,
sessionId: string,
hookEvent: HookEvent,
): Array<
| HookMatcher
| HookCallbackMatcher
| FunctionHookMatcher
| PluginHookMatcher
| SkillHookMatcher
| SessionDerivedHookMatcher
> {
// HookMatcher is a zod-stripped {matcher, hooks} so snapshot matchers can be
// pushed directly without re-wrapping.
const hooks: Array<
| HookMatcher
| HookCallbackMatcher
| FunctionHookMatcher
| PluginHookMatcher
| SkillHookMatcher
| SessionDerivedHookMatcher
> = [...(getHooksConfigFromSnapshot()?.[hookEvent] ?? [])]
// Check if only managed hooks should run (used for both registered and session hooks)
const managedOnly = shouldAllowManagedHooksOnly()
// Process registered hooks (SDK callbacks and plugin native hooks)
const registeredHooks = getRegisteredHooks()?.[hookEvent]
if (registeredHooks) {
for (const matcher of registeredHooks) {
// Skip plugin hooks when restricted to managed hooks only
// Plugin hooks have pluginRoot set, SDK callbacks do not
if (managedOnly && 'pluginRoot' in matcher) {
continue
}
hooks.push(matcher)
}
}
// Merge session hooks for the current session only
// Function hooks (like structured output enforcement) must be scoped to their session
// to prevent hooks from one agent leaking to another (e.g., verification agent to main agent)
// Skip session hooks entirely when allowManagedHooksOnly is set —
// this prevents frontmatter hooks from agents/skills from bypassing the policy.
// strictPluginOnlyCustomization does NOT block here — it gates at the
// REGISTRATION sites (runAgent.ts:526 for agent frontmatter hooks) where
// agentDefinition.source is known. A blanket block here would also kill
// plugin-provided agents' frontmatter hooks, which is too broad.
// Also skip if appState not provided (for backwards compatibility)
if (!managedOnly && appState !== undefined) {
const sessionHooks = getSessionHooks(appState, sessionId, hookEvent).get(
hookEvent,
)
if (sessionHooks) {
// SessionDerivedHookMatcher already includes optional skillRoot
for (const matcher of sessionHooks) {
hooks.push(matcher)
}
}
// Merge session function hooks separately (can't be persisted to HookMatcher format)
const sessionFunctionHooks = getSessionFunctionHooks(
appState,
sessionId,
hookEvent,
).get(hookEvent)
if (sessionFunctionHooks) {
for (const matcher of sessionFunctionHooks) {
hooks.push(matcher)
}
}
}
return hooks
}
/**
* Lightweight existence check for hooks on a given event. Mirrors the sources
* assembled by getHooksConfig() but stops at the first hit without building
* the full merged config.
*
* Intentionally over-approximates: returns true if any matcher exists for the
* event, even if managed-only filtering or pattern matching would later
* discard it. A false positive just means we proceed to the full matching
* path; a false negative would skip a hook, so we err on the side of true.
*
* Used to skip createBaseHookInput (getTranscriptPathForSession path joins)
* and getMatchingHooks on hot paths where hooks are typically unconfigured.
* See hasInstructionsLoadedHook / hasWorktreeCreateHook for the same pattern.
*/
function hasHookForEvent(
hookEvent: HookEvent,
appState: AppState | undefined,
sessionId: string,
): boolean {
const snap = getHooksConfigFromSnapshot()?.[hookEvent]
if (snap && snap.length > 0) return true
const reg = getRegisteredHooks()?.[hookEvent]
if (reg && reg.length > 0) return true
if (appState?.sessionHooks.get(sessionId)?.hooks[hookEvent]) return true
return false
}
/**
* Get hook commands that match the given query
* @param appState The current app state (optional for backwards compatibility)
* @param sessionId The current session ID (main session or agent ID)
* @param hookEvent The hook event
* @param hookInput The hook input for matching
* @returns Array of matched hooks with optional plugin context
*/
export async function getMatchingHooks(
appState: AppState | undefined,
sessionId: string,
hookEvent: HookEvent,
hookInput: HookInput,
tools?: Tools,
): Promise<MatchedHook[]> {
try {
const hookMatchers = getHooksConfig(appState, sessionId, hookEvent)
// If you change the criteria below, then you must change
// src/utils/hooks/hooksConfigManager.ts as well.
let matchQuery: string | undefined = undefined
switch (hookInput.hook_event_name) {
case 'PreToolUse':
case 'PostToolUse':
case 'PostToolUseFailure':
case 'PermissionRequest':
case 'PermissionDenied':
matchQuery = hookInput.tool_name
break
case 'SessionStart':
matchQuery = hookInput.source
break
case 'Setup':
matchQuery = hookInput.trigger
break
case 'PreCompact':
case 'PostCompact':
matchQuery = hookInput.trigger
break
case 'Notification':
matchQuery = hookInput.notification_type
break
case 'SessionEnd':
matchQuery = hookInput.reason
break
case 'StopFailure':
matchQuery = hookInput.error
break
case 'SubagentStart':
matchQuery = hookInput.agent_type
break
case 'SubagentStop':
matchQuery = hookInput.agent_type
break
case 'TeammateIdle':
case 'TaskCreated':
case 'TaskCompleted':
break
case 'Elicitation':
matchQuery = hookInput.mcp_server_name
break
case 'ElicitationResult':
matchQuery = hookInput.mcp_server_name
break
case 'ConfigChange':
matchQuery = hookInput.source
break
case 'InstructionsLoaded':
matchQuery = hookInput.load_reason
break
case 'FileChanged':
matchQuery = basename(hookInput.file_path)
break
default:
break
}
logForDebugging(
`Getting matching hook commands for ${hookEvent} with query: ${matchQuery}`,
{ level: 'verbose' },
)
logForDebugging(`Found ${hookMatchers.length} hook matchers in settings`, {
level: 'verbose',
})
// Extract hooks with their plugin context (if any)
const filteredMatchers = matchQuery
? hookMatchers.filter(
matcher =>
!matcher.matcher || matchesPattern(matchQuery, matcher.matcher),
)
: hookMatchers
const matchedHooks: MatchedHook[] = filteredMatchers.flatMap(matcher => {
// Check if this is a PluginHookMatcher (has pluginRoot) or SkillHookMatcher (has skillRoot)
const pluginRoot =
'pluginRoot' in matcher ? matcher.pluginRoot : undefined
const pluginId = 'pluginId' in matcher ? matcher.pluginId : undefined
const skillRoot = 'skillRoot' in matcher ? matcher.skillRoot : undefined
const hookSource = pluginRoot
? 'pluginName' in matcher
? `plugin:${matcher.pluginName}`
: 'plugin'
: skillRoot
? 'skillName' in matcher
? `skill:${matcher.skillName}`
: 'skill'
: 'settings'
return matcher.hooks.map(hook => ({
hook,
pluginRoot,
pluginId,
skillRoot,
hookSource,
}))
})
// Deduplicate hooks by command/prompt/url within the same source context.
// Key is namespaced by pluginRoot/skillRoot (see hookDedupKey above) so
// cross-plugin template collisions don't drop hooks (gh-29724).
//
// Note: new Map(entries) keeps the LAST entry on key collision, not first.
// For settings hooks this means the last-merged scope wins; for
// same-plugin duplicates the pluginRoot is identical so it doesn't matter.
// Fast-path: callback/function hooks don't need dedup (each is unique).
// Skip the 6-pass filter + 4×Map + 4×Array.from below when all hooks are
// callback/function — the common case for internal hooks like
// sessionFileAccessHooks/attributionHooks (44x faster in microbench).
if (
matchedHooks.every(
m => m.hook.type === 'callback' || m.hook.type === 'function',
)
) {
return matchedHooks
}
// Helper to extract the `if` condition from a hook for dedup keys.
// Hooks with different `if` conditions are distinct even if otherwise identical.
const getIfCondition = (hook: { if?: string }): string => hook.if ?? ''
const uniqueCommandHooks = Array.from(
new Map(
matchedHooks
.filter(
(
m,
): m is MatchedHook & { hook: HookCommand & { type: 'command' } } =>
m.hook.type === 'command',
)
// shell is part of identity: {command:'echo x', shell:'bash'}
// and {command:'echo x', shell:'powershell'} are distinct hooks,
// not duplicates. Default to 'bash' so legacy configs (no shell
// field) still dedup against explicit shell:'bash'.
.map(m => [
hookDedupKey(
m,
`${m.hook.shell ?? DEFAULT_HOOK_SHELL}\0${m.hook.command}\0${getIfCondition(m.hook)}`,
),
m,
]),
).values(),
)
const uniquePromptHooks = Array.from(
new Map(
matchedHooks
.filter(m => m.hook.type === 'prompt')
.map(m => [
hookDedupKey(
m,
`${(m.hook as { prompt: string }).prompt}\0${getIfCondition(m.hook as { if?: string })}`,
),
m,
]),
).values(),
)
const uniqueAgentHooks = Array.from(
new Map(
matchedHooks
.filter(m => m.hook.type === 'agent')
.map(m => [
hookDedupKey(
m,
`${(m.hook as { prompt: string }).prompt}\0${getIfCondition(m.hook as { if?: string })}`,
),
m,
]),
).values(),
)
const uniqueHttpHooks = Array.from(
new Map(
matchedHooks
.filter(m => m.hook.type === 'http')
.map(m => [
hookDedupKey(
m,
`${(m.hook as { url: string }).url}\0${getIfCondition(m.hook as { if?: string })}`,
),
m,
]),
).values(),
)
const callbackHooks = matchedHooks.filter(m => m.hook.type === 'callback')
// Function hooks don't need deduplication - each callback is unique
const functionHooks = matchedHooks.filter(m => m.hook.type === 'function')
const uniqueHooks = [
...uniqueCommandHooks,
...uniquePromptHooks,
...uniqueAgentHooks,
...uniqueHttpHooks,
...callbackHooks,
...functionHooks,
]
// Filter hooks based on their `if` condition. This allows hooks to specify
// conditions like "Bash(git *)" to only run for git commands, avoiding
// process spawning overhead for non-matching commands.
const hasIfCondition = uniqueHooks.some(
h =>
(h.hook.type === 'command' ||
h.hook.type === 'prompt' ||
h.hook.type === 'agent' ||
h.hook.type === 'http') &&
(h.hook as { if?: string }).if,
)
const ifMatcher = hasIfCondition
? await prepareIfConditionMatcher(hookInput, tools)
: undefined
const ifFilteredHooks = uniqueHooks.filter(h => {
if (
h.hook.type !== 'command' &&
h.hook.type !== 'prompt' &&
h.hook.type !== 'agent' &&
h.hook.type !== 'http'
) {
return true
}
const ifCondition = (h.hook as { if?: string }).if
if (!ifCondition) {
return true
}
if (!ifMatcher) {
logForDebugging(
`Hook if condition "${ifCondition}" cannot be evaluated for non-tool event ${hookInput.hook_event_name}`,
)
return false
}
if (ifMatcher(ifCondition)) {
return true
}
logForDebugging(
`Skipping hook due to if condition "${ifCondition}" not matching`,
)
return false
})
// HTTP hooks are not supported for SessionStart/Setup events. In headless
// mode the sandbox ask callback deadlocks because the structuredInput
// consumer hasn't started yet when these hooks fire.
const filteredHooks =
hookEvent === 'SessionStart' || hookEvent === 'Setup'
? ifFilteredHooks.filter(h => {
if (h.hook.type === 'http') {
logForDebugging(
`Skipping HTTP hook ${(h.hook as { url: string }).url} — HTTP hooks are not supported for ${hookEvent}`,
)
return false
}
return true
})
: ifFilteredHooks
logForDebugging(
`Matched ${filteredHooks.length} unique hooks for query "${matchQuery || 'no match query'}" (${matchedHooks.length} before deduplication)`,
{ level: 'verbose' },
)
return filteredHooks
} catch {
return []
}
}
/**
* Format a list of blocking errors from a PreTool hook's configured commands.
* @param hookName The name of the hook (e.g., 'PreToolUse:Write', 'PreToolUse:Edit', 'PreToolUse:Bash')
* @param blockingErrors Array of blocking errors from hooks
* @returns Formatted blocking message
*/
export function getPreToolHookBlockingMessage(
hookName: string,
blockingError: HookBlockingError,
): string {
return `${hookName} hook error: ${blockingError.blockingError}`
}
/**
* Format a list of blocking errors from a Stop hook's configured commands.
* @param blockingErrors Array of blocking errors from hooks
* @returns Formatted message to give feedback to the model
*/
export function getStopHookMessage(blockingError: HookBlockingError): string {
return `Stop hook feedback:\n${blockingError.blockingError}`
}
/**
* Format a blocking error from a TeammateIdle hook.
* @param blockingError The blocking error from the hook
* @returns Formatted message to give feedback to the model
*/
export function getTeammateIdleHookMessage(
blockingError: HookBlockingError,
): string {
return `TeammateIdle hook feedback:\n${blockingError.blockingError}`
}
/**
* Format a blocking error from a TaskCreated hook.
* @param blockingError The blocking error from the hook
* @returns Formatted message to give feedback to the model
*/
export function getTaskCreatedHookMessage(
blockingError: HookBlockingError,
): string {
return `TaskCreated hook feedback:\n${blockingError.blockingError}`
}
/**
* Format a blocking error from a TaskCompleted hook.
* @param blockingError The blocking error from the hook
* @returns Formatted message to give feedback to the model
*/
export function getTaskCompletedHookMessage(
blockingError: HookBlockingError,
): string {
return `TaskCompleted hook feedback:\n${blockingError.blockingError}`
}
/**
* Format a list of blocking errors from a UserPromptSubmit hook's configured commands.
* @param blockingErrors Array of blocking errors from hooks
* @returns Formatted blocking message
*/
export function getUserPromptSubmitHookBlockingMessage(
blockingError: HookBlockingError,
): string {
return `UserPromptSubmit operation blocked by hook:\n${blockingError.blockingError}`
}
/**
* Common logic for executing hooks
* @param hookInput The structured hook input that will be validated and converted to JSON
* @param toolUseID The ID for tracking this hook execution
* @param matchQuery The query to match against hook matchers
* @param signal Optional AbortSignal to cancel hook execution
* @param timeoutMs Optional timeout in milliseconds for hook execution
* @param toolUseContext Optional ToolUseContext for prompt-based hooks (required if using prompt hooks)
* @param messages Optional conversation history for prompt/function hooks
* @returns Async generator that yields progress messages and hook results
*/
async function* executeHooks({
hookInput,
toolUseID,
matchQuery,
signal,
timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
toolUseContext,
messages,
forceSyncExecution,
requestPrompt,
toolInputSummary,
}: {
hookInput: HookInput
toolUseID: string
matchQuery?: string
signal?: AbortSignal
timeoutMs?: number
toolUseContext?: ToolUseContext
messages?: Message[]
forceSyncExecution?: boolean
requestPrompt?: (
sourceName: string,
toolInputSummary?: string | null,
) => (request: PromptRequest) => Promise<PromptResponse>
toolInputSummary?: string | null
}): AsyncGenerator<AggregatedHookResult> {
if (shouldDisableAllHooksIncludingManaged()) {
return
}
if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) {
return
}
const hookEvent = hookInput.hook_event_name
const hookName = matchQuery ? `${hookEvent}:${matchQuery}` : hookEvent
// Bind the prompt callback to this hook's name and tool input summary so the UI can display context
const boundRequestPrompt = requestPrompt?.(hookName, toolInputSummary)
// SECURITY: ALL hooks require workspace trust in interactive mode
// This centralized check prevents RCE vulnerabilities for all current and future hooks
if (shouldSkipHookDueToTrust()) {
logForDebugging(
`Skipping ${hookName} hook execution - workspace trust not accepted`,
)
return
}
const appState = toolUseContext ? toolUseContext.getAppState() : undefined
// Use the agent's session ID if available, otherwise fall back to main session
const sessionId = toolUseContext?.agentId ?? getSessionId()
const matchingHooks = await getMatchingHooks(
appState,
sessionId,
hookEvent,
hookInput,
toolUseContext?.options?.tools,
)
if (matchingHooks.length === 0) {
return
}
if (signal?.aborted) {
return
}
const userHooks = matchingHooks.filter(h => !isInternalHook(h))
if (userHooks.length > 0) {
const pluginHookCounts = getPluginHookCounts(userHooks)
const hookTypeCounts = getHookTypeCounts(userHooks)
logEvent(`tengu_run_hook`, {
hookName:
hookName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
numCommands: userHooks.length,
hookTypeCounts: jsonStringify(
hookTypeCounts,
) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
...(pluginHookCounts && {
pluginHookCounts: jsonStringify(
pluginHookCounts,
) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
}),
})
} else {
// Fast-path: all hooks are internal callbacks (sessionFileAccessHooks,
// attributionHooks). These return {} and don't use the abort signal, so we
// can skip span/progress/abortSignal/processHookJSONOutput/resultLoop.
// Measured: 6.01µs → ~1.8µs per PostToolUse hit (-70%).
const batchStartTime = Date.now()
const context = toolUseContext
? {
getAppState: toolUseContext.getAppState,
updateAttributionState: toolUseContext.updateAttributionState,
}
: undefined
for (const [i, { hook }] of matchingHooks.entries()) {
if (hook.type === 'callback') {
await hook.callback(hookInput, toolUseID, signal, i, context)
}
}
const totalDurationMs = Date.now() - batchStartTime
getStatsStore()?.observe('hook_duration_ms', totalDurationMs)
addToTurnHookDuration(totalDurationMs)
logEvent(`tengu_repl_hook_finished`, {
hookName:
hookName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
numCommands: matchingHooks.length,
numSuccess: matchingHooks.length,
numBlocking: 0,
numNonBlockingError: 0,
numCancelled: 0,
totalDurationMs,
})
return
}
// Collect hook definitions for beta tracing telemetry
const hookDefinitionsJson = isBetaTracingEnabled()
? jsonStringify(getHookDefinitionsForTelemetry(matchingHooks))
: '[]'
// Log hook execution start to OTEL (only for beta tracing)
if (isBetaTracingEnabled()) {
void logOTelEvent('hook_execution_start', {
hook_event: hookEvent,
hook_name: hookName,
num_hooks: String(matchingHooks.length),
managed_only: String(shouldAllowManagedHooksOnly()),
hook_definitions: hookDefinitionsJson,
hook_source: shouldAllowManagedHooksOnly() ? 'policySettings' : 'merged',
})
}
// Start hook span for beta tracing
const hookSpan = startHookSpan(
hookEvent,
hookName,
matchingHooks.length,
hookDefinitionsJson,
)
// Yield progress messages for each hook before execution
for (const { hook } of matchingHooks) {
yield {
message: {
type: 'progress',
data: {
type: 'hook_progress',
hookEvent,
hookName,
command: getHookDisplayText(hook),
...(hook.type === 'prompt' && { promptText: hook.prompt }),
...('statusMessage' in hook &&
hook.statusMessage != null && {
statusMessage: hook.statusMessage,
}),
},
parentToolUseID: toolUseID,
toolUseID,
timestamp: new Date().toISOString(),
uuid: randomUUID(),
},
}
}
// Track wall-clock time for the entire hook batch
const batchStartTime = Date.now()
// Lazy-once stringify of hookInput. Shared across all command/prompt/agent/http
// hooks in this batch (hookInput is never mutated). Callback/function hooks
// return before reaching this, so batches with only those pay no stringify cost.
let jsonInputResult:
| { ok: true; value: string }
| { ok: false; error: unknown }
| undefined
function getJsonInput() {
if (jsonInputResult !== undefined) {
return jsonInputResult
}
try {
return (jsonInputResult = { ok: true, value: jsonStringify(hookInput) })
} catch (error) {
logError(
Error(`Failed to stringify hook ${hookName} input`, { cause: error }),
)
return (jsonInputResult = { ok: false, error })
}
}
// Run all hooks in parallel with individual timeouts
const hookPromises = matchingHooks.map(async function* (
{ hook, pluginRoot, pluginId, skillRoot },
hookIndex,
): AsyncGenerator<HookResult> {
if (hook.type === 'callback') {
const callbackTimeoutMs = hook.timeout ? hook.timeout * 1000 : timeoutMs
const { signal: abortSignal, cleanup } = createCombinedAbortSignal(
signal,
{ timeoutMs: callbackTimeoutMs },
)
yield executeHookCallback({
toolUseID,
hook,
hookEvent,
hookInput,
signal: abortSignal,
hookIndex,
toolUseContext,
}).finally(cleanup)
return
}
if (hook.type === 'function') {
if (!messages) {
yield {
message: createAttachmentMessage({
type: 'hook_error_during_execution',
hookName,
toolUseID,
hookEvent,
content: 'Messages not provided for function hook',
}),
outcome: 'non_blocking_error',
hook,
}
return
}
// Function hooks only come from session storage with callback embedded
yield executeFunctionHook({
hook,
messages,
hookName,
toolUseID,
hookEvent,
timeoutMs,
signal,
})
return
}
// Command and prompt hooks need jsonInput
const commandTimeoutMs = hook.timeout ? hook.timeout * 1000 : timeoutMs
const { signal: abortSignal, cleanup } = createCombinedAbortSignal(signal, {
timeoutMs: commandTimeoutMs,
})
const hookId = randomUUID()
const hookStartMs = Date.now()
const hookCommand = getHookDisplayText(hook)
try {
const jsonInputRes = getJsonInput()
if (!jsonInputRes.ok) {
yield {
message: createAttachmentMessage({
type: 'hook_error_during_execution',
hookName,
toolUseID,
hookEvent,
content: `Failed to prepare hook input: ${errorMessage(jsonInputRes.error)}`,
command: hookCommand,
durationMs: Date.now() - hookStartMs,
}),
outcome: 'non_blocking_error',
hook,
}
cleanup()
return
}
const jsonInput = jsonInputRes.value
if (hook.type === 'prompt') {
if (!toolUseContext) {
throw new Error(
'ToolUseContext is required for prompt hooks. This is a bug.',
)
}
const promptResult = await execPromptHook(
hook,
hookName,
hookEvent,
jsonInput,
abortSignal,
toolUseContext,
messages,
toolUseID,
)
// Inject timing fields for hook visibility
if (promptResult.message?.type === 'attachment') {
const att = promptResult.message.attachment
if (
att.type === 'hook_success' ||
att.type === 'hook_non_blocking_error'
) {
att.command = hookCommand
att.durationMs = Date.now() - hookStartMs
}
}
yield promptResult
cleanup?.()
return
}
if (hook.type === 'agent') {
if (!toolUseContext) {
throw new Error(
'ToolUseContext is required for agent hooks. This is a bug.',
)
}
if (!messages) {
throw new Error(
'Messages are required for agent hooks. This is a bug.',
)
}
const agentResult = await execAgentHook(
hook,
hookName,
hookEvent,
jsonInput,
abortSignal,
toolUseContext,
toolUseID,
messages,
'agent_type' in hookInput
? (hookInput.agent_type as string)
: undefined,
)
// Inject timing fields for hook visibility
if (agentResult.message?.type === 'attachment') {
const att = agentResult.message.attachment
if (
att.type === 'hook_success' ||
att.type === 'hook_non_blocking_error'
) {
att.command = hookCommand
att.durationMs = Date.now() - hookStartMs
}
}
yield agentResult
cleanup?.()
return
}
if (hook.type === 'http') {
emitHookStarted(hookId, hookName, hookEvent)
// execHttpHook manages its own timeout internally via hook.timeout or
// DEFAULT_HTTP_HOOK_TIMEOUT_MS, so pass the parent signal directly
// to avoid double-stacking timeouts with abortSignal.
const httpResult = await execHttpHook(
hook,
hookEvent,
jsonInput,
signal,
)
cleanup?.()
if (httpResult.aborted) {
emitHookResponse({
hookId,
hookName,
hookEvent,
output: 'Hook cancelled',
stdout: '',
stderr: '',
exitCode: undefined,
outcome: 'cancelled',
})
yield {
message: createAttachmentMessage({
type: 'hook_cancelled',
hookName,
toolUseID,
hookEvent,
}),
outcome: 'cancelled' as const,
hook,
}
return
}
if (httpResult.error || !httpResult.ok) {
const stderr =
httpResult.error || `HTTP ${httpResult.statusCode} from ${hook.url}`
emitHookResponse({
hookId,
hookName,
hookEvent,
output: stderr,
stdout: '',
stderr,
exitCode: httpResult.statusCode,
outcome: 'error',
})
yield {
message: createAttachmentMessage({
type: 'hook_non_blocking_error',
hookName,
toolUseID,
hookEvent,
stderr,
stdout: '',
exitCode: httpResult.statusCode ?? 0,
}),
outcome: 'non_blocking_error' as const,
hook,
}
return
}
// HTTP hooks must return JSON — parse and validate through Zod
const { json: httpJson, validationError: httpValidationError } =
parseHttpHookOutput(httpResult.body)
if (httpValidationError) {
emitHookResponse({
hookId,
hookName,
hookEvent,
output: httpResult.body,
stdout: httpResult.body,
stderr: `JSON validation failed: ${httpValidationError}`,
exitCode: httpResult.statusCode,
outcome: 'error',
})
yield {
message: createAttachmentMessage({
type: 'hook_non_blocking_error',
hookName,
toolUseID,
hookEvent,
stderr: `JSON validation failed: ${httpValidationError}`,
stdout: httpResult.body,
exitCode: httpResult.statusCode ?? 0,
}),
outcome: 'non_blocking_error' as const,
hook,
}
return
}
if (httpJson && isAsyncHookJSONOutput(httpJson)) {
// Async response: treat as success (no further processing)
emitHookResponse({
hookId,
hookName,
hookEvent,
output: httpResult.body,
stdout: httpResult.body,
stderr: '',
exitCode: httpResult.statusCode,
outcome: 'success',
})
yield {
outcome: 'success' as const,
hook,
}
return
}
if (httpJson) {
const processed = processHookJSONOutput({
json: httpJson,
command: hook.url,
hookName,
toolUseID,
hookEvent,
expectedHookEvent: hookEvent,
stdout: httpResult.body,
stderr: '',
exitCode: httpResult.statusCode,
})
emitHookResponse({
hookId,
hookName,
hookEvent,
output: httpResult.body,
stdout: httpResult.body,
stderr: '',
exitCode: httpResult.statusCode,
outcome: 'success',
})
yield {
...processed,
outcome: 'success' as const,
hook,
}
return
}
return
}
emitHookStarted(hookId, hookName, hookEvent)
const result = await execCommandHook(
hook,
hookEvent,
hookName,
jsonInput,
abortSignal,
hookId,
hookIndex,
pluginRoot,
pluginId,
skillRoot,
forceSyncExecution,
boundRequestPrompt,
)
cleanup?.()
const durationMs = Date.now() - hookStartMs
if (result.backgrounded) {
yield {
outcome: 'success' as const,
hook,
}
return
}
if (result.aborted) {
emitHookResponse({
hookId,
hookName,
hookEvent,
output: result.output,
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.status,
outcome: 'cancelled',
})
yield {
message: createAttachmentMessage({
type: 'hook_cancelled',
hookName,
toolUseID,
hookEvent,
command: hookCommand,
durationMs,
}),
outcome: 'cancelled' as const,
hook,
}
return
}
// Try JSON parsing first
const { json, plainText, validationError } = parseHookOutput(
result.stdout,
)
if (validationError) {
emitHookResponse({
hookId,
hookName,
hookEvent,
output: result.output,
stdout: result.stdout,
stderr: `JSON validation failed: ${validationError}`,
exitCode: 1,
outcome: 'error',
})
yield {
message: createAttachmentMessage({
type: 'hook_non_blocking_error',
hookName,
toolUseID,
hookEvent,
stderr: `JSON validation failed: ${validationError}`,
stdout: result.stdout,
exitCode: 1,
command: hookCommand,
durationMs,
}),
outcome: 'non_blocking_error' as const,
hook,
}
return
}
if (json) {
// Async responses were already backgrounded during execution
if (isAsyncHookJSONOutput(json)) {
yield {
outcome: 'success' as const,
hook,
}
return
}
// Process JSON output
const processed = processHookJSONOutput({
json,
command: hookCommand,
hookName,
toolUseID,
hookEvent,
expectedHookEvent: hookEvent,
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.status,
durationMs,
})
// Handle suppressOutput (skip for async responses)
if (
isSyncHookJSONOutput(json) &&
!json.suppressOutput &&
plainText &&
result.status === 0
) {
// Still show non-JSON output if not suppressed
const content = `${chalk.bold(hookName)} completed`
emitHookResponse({
hookId,
hookName,
hookEvent,
output: result.output,
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.status,
outcome: 'success',
})
yield {
...processed,
message:
processed.message ||
createAttachmentMessage({
type: 'hook_success',
hookName,
toolUseID,
hookEvent,
content,
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.status,
command: hookCommand,
durationMs,
}),
outcome: 'success' as const,
hook,
}
return
}
emitHookResponse({
hookId,
hookName,
hookEvent,
output: result.output,
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.status,
outcome: result.status === 0 ? 'success' : 'error',
})
yield {
...processed,
outcome: 'success' as const,
hook,
}
return
}
// Fall back to existing logic for non-JSON output
if (result.status === 0) {
emitHookResponse({
hookId,
hookName,
hookEvent,
output: result.output,
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.status,
outcome: 'success',
})
yield {
message: createAttachmentMessage({
type: 'hook_success',
hookName,
toolUseID,
hookEvent,
content: result.stdout.trim(),
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.status,
command: hookCommand,
durationMs,
}),
outcome: 'success' as const,
hook,
}
return
}
// Hooks with exit code 2 provide blocking feedback
if (result.status === 2) {
emitHookResponse({
hookId,
hookName,
hookEvent,
output: result.output,
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.status,
outcome: 'error',
})
yield {
blockingError: {
blockingError: `[${hook.command}]: ${result.stderr || 'No stderr output'}`,
command: hook.command,
},
outcome: 'blocking' as const,
hook,
}
return
}
// Any other non-zero exit code is a non-critical error that should just
// be shown to the user.
emitHookResponse({
hookId,
hookName,
hookEvent,
output: result.output,
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.status,
outcome: 'error',
})
yield {
message: createAttachmentMessage({
type: 'hook_non_blocking_error',
hookName,
toolUseID,
hookEvent,
stderr: `Failed with non-blocking status code: ${result.stderr.trim() || 'No stderr output'}`,
stdout: result.stdout,
exitCode: result.status,
command: hookCommand,
durationMs,
}),
outcome: 'non_blocking_error' as const,
hook,
}
return
} catch (error) {
// Clean up on error
cleanup?.()
const errorMessage =
error instanceof Error ? error.message : String(error)
emitHookResponse({
hookId,
hookName,
hookEvent,
output: `Failed to run: ${errorMessage}`,
stdout: '',
stderr: `Failed to run: ${errorMessage}`,
exitCode: 1,
outcome: 'error',
})
yield {
message: createAttachmentMessage({
type: 'hook_non_blocking_error',
hookName,
toolUseID,
hookEvent,
stderr: `Failed to run: ${errorMessage}`,
stdout: '',
exitCode: 1,
command: hookCommand,
durationMs: Date.now() - hookStartMs,
}),
outcome: 'non_blocking_error' as const,
hook,
}
return
}
})
// Track outcomes for logging
const outcomes = {
success: 0,
blocking: 0,
non_blocking_error: 0,
cancelled: 0,
}
let permissionBehavior: PermissionResult['behavior'] | undefined
// Run all hooks in parallel and wait for all to complete
for await (const result of all(hookPromises)) {
outcomes[result.outcome]++
// Check for preventContinuation early
if (result.preventContinuation) {
logForDebugging(
`Hook ${hookEvent} (${getHookDisplayText(result.hook)}) requested preventContinuation`,
)
yield {
preventContinuation: true,
stopReason: result.stopReason,
}
}
// Handle different result types
if (result.blockingError) {
yield {
blockingError: result.blockingError,
}
}
if (result.message) {
yield { message: result.message }
}
// Yield system message separately if present
if (result.systemMessage) {
yield {
message: createAttachmentMessage({
type: 'hook_system_message',
content: result.systemMessage,
hookName,
toolUseID,
hookEvent,
}),
}
}
// Collect additional context from hooks
if (result.additionalContext) {
logForDebugging(
`Hook ${hookEvent} (${getHookDisplayText(result.hook)}) provided additionalContext (${result.additionalContext.length} chars)`,
)
yield {
additionalContexts: [result.additionalContext],
}
}
if (result.initialUserMessage) {
logForDebugging(
`Hook ${hookEvent} (${getHookDisplayText(result.hook)}) provided initialUserMessage (${result.initialUserMessage.length} chars)`,
)
yield {
initialUserMessage: result.initialUserMessage,
}
}
if (result.watchPaths && result.watchPaths.length > 0) {
logForDebugging(
`Hook ${hookEvent} (${getHookDisplayText(result.hook)}) provided ${result.watchPaths.length} watchPaths`,
)
yield {
watchPaths: result.watchPaths,
}
}
// Yield updatedMCPToolOutput if provided (from PostToolUse hooks)
if (result.updatedMCPToolOutput) {
logForDebugging(
`Hook ${hookEvent} (${getHookDisplayText(result.hook)}) replaced MCP tool output`,
)
yield {
updatedMCPToolOutput: result.updatedMCPToolOutput,
}
}
// Check for permission behavior with precedence: deny > ask > allow
if (result.permissionBehavior) {
logForDebugging(
`Hook ${hookEvent} (${getHookDisplayText(result.hook)}) returned permissionDecision: ${result.permissionBehavior}${result.hookPermissionDecisionReason ? ` (reason: ${result.hookPermissionDecisionReason})` : ''}`,
)
// Apply precedence rules
switch (result.permissionBehavior) {
case 'deny':
// deny always takes precedence
permissionBehavior = 'deny'
break
case 'ask':
// ask takes precedence over allow but not deny
if (permissionBehavior !== 'deny') {
permissionBehavior = 'ask'
}
break
case 'allow':
// allow only if no other behavior set
if (!permissionBehavior) {
permissionBehavior = 'allow'
}
break
case 'passthrough':
// passthrough doesn't set permission behavior
break
}
}
// Yield permission behavior and updatedInput if provided (from allow or ask behavior)
if (permissionBehavior !== undefined) {
const updatedInput =
result.updatedInput &&
(result.permissionBehavior === 'allow' ||
result.permissionBehavior === 'ask')
? result.updatedInput
: undefined
if (updatedInput) {
logForDebugging(
`Hook ${hookEvent} (${getHookDisplayText(result.hook)}) modified tool input keys: [${Object.keys(updatedInput).join(', ')}]`,
)
}
yield {
permissionBehavior,
hookPermissionDecisionReason: result.hookPermissionDecisionReason,
hookSource: matchingHooks.find(m => m.hook === result.hook)?.hookSource,
updatedInput,
}
}
// Yield updatedInput separately for passthrough case (no permission decision)
// This allows hooks to modify input without making a permission decision
// Note: Check result.permissionBehavior (this hook's behavior), not the aggregated permissionBehavior
if (result.updatedInput && result.permissionBehavior === undefined) {
logForDebugging(
`Hook ${hookEvent} (${getHookDisplayText(result.hook)}) modified tool input keys: [${Object.keys(result.updatedInput).join(', ')}]`,
)
yield {
updatedInput: result.updatedInput,
}
}
// Yield permission request result if provided (from PermissionRequest hooks)
if (result.permissionRequestResult) {
yield {
permissionRequestResult: result.permissionRequestResult,
}
}
// Yield retry flag if provided (from PermissionDenied hooks)
if (result.retry) {
yield {
retry: result.retry,
}
}
// Yield elicitation response if provided (from Elicitation hooks)
if (result.elicitationResponse) {
yield {
elicitationResponse: result.elicitationResponse,
}
}
// Yield elicitation result response if provided (from ElicitationResult hooks)
if (result.elicitationResultResponse) {
yield {
elicitationResultResponse: result.elicitationResultResponse,
}
}
// Invoke session hook callback if this is a command/prompt/function hook (not a callback hook)
if (appState && result.hook.type !== 'callback') {
const sessionId = getSessionId()
// Use empty string as matcher when matchQuery is undefined (e.g., for Stop hooks)
const matcher = matchQuery ?? ''
const hookEntry = getSessionHookCallback(
appState,
sessionId,
hookEvent,
matcher,
result.hook,
)
// Invoke onHookSuccess only on success outcome
if (hookEntry?.onHookSuccess && result.outcome === 'success') {
try {
hookEntry.onHookSuccess(result.hook, result as AggregatedHookResult)
} catch (error) {
logError(
Error('Session hook success callback failed', { cause: error }),
)
}
}
}
}
const totalDurationMs = Date.now() - batchStartTime
getStatsStore()?.observe('hook_duration_ms', totalDurationMs)
addToTurnHookDuration(totalDurationMs)
logEvent(`tengu_repl_hook_finished`, {
hookName:
hookName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
numCommands: matchingHooks.length,
numSuccess: outcomes.success,
numBlocking: outcomes.blocking,
numNonBlockingError: outcomes.non_blocking_error,
numCancelled: outcomes.cancelled,
totalDurationMs,
})
// Log hook execution completion to OTEL (only for beta tracing)
if (isBetaTracingEnabled()) {
const hookDefinitionsComplete =
getHookDefinitionsForTelemetry(matchingHooks)
void logOTelEvent('hook_execution_complete', {
hook_event: hookEvent,
hook_name: hookName,
num_hooks: String(matchingHooks.length),
num_success: String(outcomes.success),
num_blocking: String(outcomes.blocking),
num_non_blocking_error: String(outcomes.non_blocking_error),
num_cancelled: String(outcomes.cancelled),
managed_only: String(shouldAllowManagedHooksOnly()),
hook_definitions: jsonStringify(hookDefinitionsComplete),
hook_source: shouldAllowManagedHooksOnly() ? 'policySettings' : 'merged',
})
}
// End hook span for beta tracing
endHookSpan(hookSpan, {
numSuccess: outcomes.success,
numBlocking: outcomes.blocking,
numNonBlockingError: outcomes.non_blocking_error,
numCancelled: outcomes.cancelled,
})
}
export type HookOutsideReplResult = {
command: string
succeeded: boolean
output: string
blocked: boolean
watchPaths?: string[]
systemMessage?: string
}
export function hasBlockingResult(results: HookOutsideReplResult[]): boolean {
return results.some(r => r.blocked)
}
/**
* Execute hooks outside of the REPL (e.g. notifications, session end)
*
* Unlike executeHooks() which yields messages that are exposed to the model as
* system messages, this function only logs errors via logForDebugging (visible
* with --debug). Callers that need to surface errors to users should handle
* the returned results appropriately (e.g. executeSessionEndHooks writes to
* stderr during shutdown).
*
* @param getAppState Optional function to get the current app state (for session hooks)
* @param hookInput The structured hook input that will be validated and converted to JSON
* @param matchQuery The query to match against hook matchers
* @param signal Optional AbortSignal to cancel hook execution
* @param timeoutMs Optional timeout in milliseconds for hook execution
* @returns Array of HookOutsideReplResult objects containing command, succeeded, and output
*/
async function executeHooksOutsideREPL({
getAppState,
hookInput,
matchQuery,
signal,
timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
}: {
getAppState?: () => AppState
hookInput: HookInput
matchQuery?: string
signal?: AbortSignal
timeoutMs: number
}): Promise<HookOutsideReplResult[]> {
if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) {
return []
}
const hookEvent = hookInput.hook_event_name
const hookName = matchQuery ? `${hookEvent}:${matchQuery}` : hookEvent
if (shouldDisableAllHooksIncludingManaged()) {
logForDebugging(
`Skipping hooks for ${hookName} due to 'disableAllHooks' managed setting`,
)
return []
}
// SECURITY: ALL hooks require workspace trust in interactive mode
// This centralized check prevents RCE vulnerabilities for all current and future hooks
if (shouldSkipHookDueToTrust()) {
logForDebugging(
`Skipping ${hookName} hook execution - workspace trust not accepted`,
)
return []
}
const appState = getAppState ? getAppState() : undefined
// Use main session ID for outside-REPL hooks
const sessionId = getSessionId()
const matchingHooks = await getMatchingHooks(
appState,
sessionId,
hookEvent,
hookInput,
)
if (matchingHooks.length === 0) {
return []
}
if (signal?.aborted) {
return []
}
const userHooks = matchingHooks.filter(h => !isInternalHook(h))
if (userHooks.length > 0) {
const pluginHookCounts = getPluginHookCounts(userHooks)
const hookTypeCounts = getHookTypeCounts(userHooks)
logEvent(`tengu_run_hook`, {
hookName:
hookName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
numCommands: userHooks.length,
hookTypeCounts: jsonStringify(
hookTypeCounts,
) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
...(pluginHookCounts && {
pluginHookCounts: jsonStringify(
pluginHookCounts,
) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
}),
})
}
// Validate and stringify the hook input
let jsonInput: string
try {
jsonInput = jsonStringify(hookInput)
} catch (error) {
logError(error)
return []
}
// Run all hooks in parallel with individual timeouts
const hookPromises = matchingHooks.map(
async ({ hook, pluginRoot, pluginId }, hookIndex) => {
// Handle callback hooks
if (hook.type === 'callback') {
const callbackTimeoutMs = hook.timeout ? hook.timeout * 1000 : timeoutMs
const { signal: abortSignal, cleanup } = createCombinedAbortSignal(
signal,
{ timeoutMs: callbackTimeoutMs },
)
try {
const toolUseID = randomUUID()
const json = await hook.callback(
hookInput,
toolUseID,
abortSignal,
hookIndex,
)
cleanup?.()
if (isAsyncHookJSONOutput(json)) {
logForDebugging(
`${hookName} [callback] returned async response, returning empty output`,
)
return {
command: 'callback',
succeeded: true,
output: '',
blocked: false,
}
}
const output =
hookEvent === 'WorktreeCreate' &&
isSyncHookJSONOutput(json) &&
json.hookSpecificOutput?.hookEventName === 'WorktreeCreate'
? json.hookSpecificOutput.worktreePath
: json.systemMessage || ''
const blocked =
isSyncHookJSONOutput(json) && json.decision === 'block'
logForDebugging(`${hookName} [callback] completed successfully`)
return {
command: 'callback',
succeeded: true,
output,
blocked,
}
} catch (error) {
cleanup?.()
const errorMessage =
error instanceof Error ? error.message : String(error)
logForDebugging(
`${hookName} [callback] failed to run: ${errorMessage}`,
{ level: 'error' },
)
return {
command: 'callback',
succeeded: false,
output: errorMessage,
blocked: false,
}
}
}
// TODO: Implement prompt stop hooks outside REPL
if (hook.type === 'prompt') {
return {
command: hook.prompt,
succeeded: false,
output: 'Prompt stop hooks are not yet supported outside REPL',
blocked: false,
}
}
// TODO: Implement agent stop hooks outside REPL
if (hook.type === 'agent') {
return {
command: hook.prompt,
succeeded: false,
output: 'Agent stop hooks are not yet supported outside REPL',
blocked: false,
}
}
// Function hooks require messages array (only available in REPL context)
// For -p mode Stop hooks, use executeStopHooks which supports function hooks
if (hook.type === 'function') {
logError(
new Error(
`Function hook reached executeHooksOutsideREPL for ${hookEvent}. Function hooks should only be used in REPL context (Stop hooks).`,
),
)
return {
command: 'function',
succeeded: false,
output: 'Internal error: function hook executed outside REPL context',
blocked: false,
}
}
// Handle HTTP hooks (no toolUseContext needed - just HTTP POST).
// execHttpHook handles its own timeout internally via hook.timeout or
// DEFAULT_HTTP_HOOK_TIMEOUT_MS, so we pass signal directly.
if (hook.type === 'http') {
try {
const httpResult = await execHttpHook(
hook,
hookEvent,
jsonInput,
signal,
)
if (httpResult.aborted) {
logForDebugging(`${hookName} [${hook.url}] cancelled`)
return {
command: hook.url,
succeeded: false,
output: 'Hook cancelled',
blocked: false,
}
}
if (httpResult.error || !httpResult.ok) {
const errMsg =
httpResult.error ||
`HTTP ${httpResult.statusCode} from ${hook.url}`
logForDebugging(`${hookName} [${hook.url}] failed: ${errMsg}`, {
level: 'error',
})
return {
command: hook.url,
succeeded: false,
output: errMsg,
blocked: false,
}
}
// HTTP hooks must return JSON — parse and validate through Zod
const { json: httpJson, validationError: httpValidationError } =
parseHttpHookOutput(httpResult.body)
if (httpValidationError) {
throw new Error(httpValidationError)
}
if (httpJson && !isAsyncHookJSONOutput(httpJson)) {
logForDebugging(
`Parsed JSON output from HTTP hook: ${jsonStringify(httpJson)}`,
{ level: 'verbose' },
)
}
const jsonBlocked =
httpJson &&
!isAsyncHookJSONOutput(httpJson) &&
isSyncHookJSONOutput(httpJson) &&
httpJson.decision === 'block'
// WorktreeCreate's consumer reads `output` as the bare filesystem
// path. Command hooks provide it via stdout; http hooks provide it
// via hookSpecificOutput.worktreePath. Without worktreePath, emit ''
// so the consumer's length filter skips it instead of treating the
// raw '{}' body as a path.
const output =
hookEvent === 'WorktreeCreate'
? httpJson &&
isSyncHookJSONOutput(httpJson) &&
httpJson.hookSpecificOutput?.hookEventName === 'WorktreeCreate'
? httpJson.hookSpecificOutput.worktreePath
: ''
: httpResult.body
return {
command: hook.url,
succeeded: true,
output,
blocked: !!jsonBlocked,
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error)
logForDebugging(
`${hookName} [${hook.url}] failed to run: ${errorMessage}`,
{ level: 'error' },
)
return {
command: hook.url,
succeeded: false,
output: errorMessage,
blocked: false,
}
}
}
// Handle command hooks
const commandTimeoutMs = hook.timeout ? hook.timeout * 1000 : timeoutMs
const { signal: abortSignal, cleanup } = createCombinedAbortSignal(
signal,
{ timeoutMs: commandTimeoutMs },
)
try {
const result = await execCommandHook(
hook,
hookEvent,
hookName,
jsonInput,
abortSignal,
randomUUID(),
hookIndex,
pluginRoot,
pluginId,
)
// Clear timeout if hook completes
cleanup?.()
if (result.aborted) {
logForDebugging(`${hookName} [${hook.command}] cancelled`)
return {
command: hook.command,
succeeded: false,
output: 'Hook cancelled',
blocked: false,
}
}
logForDebugging(
`${hookName} [${hook.command}] completed with status ${result.status}`,
)
// Parse JSON for any messages to print out.
const { json, validationError } = parseHookOutput(result.stdout)
if (validationError) {
// Validation error is logged via logForDebugging and returned in output
throw new Error(validationError)
}
if (json && !isAsyncHookJSONOutput(json)) {
logForDebugging(
`Parsed JSON output from hook: ${jsonStringify(json)}`,
{ level: 'verbose' },
)
}
// Blocked if exit code 2 or JSON decision: 'block'
const jsonBlocked =
json &&
!isAsyncHookJSONOutput(json) &&
isSyncHookJSONOutput(json) &&
json.decision === 'block'
const blocked = result.status === 2 || !!jsonBlocked
// For successful hooks (exit code 0), use stdout; for failed hooks, use stderr
const output =
result.status === 0 ? result.stdout || '' : result.stderr || ''
const watchPaths =
json &&
isSyncHookJSONOutput(json) &&
json.hookSpecificOutput &&
'watchPaths' in json.hookSpecificOutput
? json.hookSpecificOutput.watchPaths
: undefined
const systemMessage =
json && isSyncHookJSONOutput(json) ? json.systemMessage : undefined
return {
command: hook.command,
succeeded: result.status === 0,
output,
blocked,
watchPaths,
systemMessage,
}
} catch (error) {
// Clean up on error
cleanup?.()
const errorMessage =
error instanceof Error ? error.message : String(error)
logForDebugging(
`${hookName} [${hook.command}] failed to run: ${errorMessage}`,
{ level: 'error' },
)
return {
command: hook.command,
succeeded: false,
output: errorMessage,
blocked: false,
}
}
},
)
// Wait for all hooks to complete and collect results
return await Promise.all(hookPromises)
}
/**
* Execute pre-tool hooks if configured
* @param toolName The name of the tool (e.g., 'Write', 'Edit', 'Bash')
* @param toolUseID The ID of the tool use
* @param toolInput The input that will be passed to the tool
* @param permissionMode Optional permission mode from toolPermissionContext
* @param signal Optional AbortSignal to cancel hook execution
* @param timeoutMs Optional timeout in milliseconds for hook execution
* @param toolUseContext Optional ToolUseContext for prompt-based hooks
* @returns Async generator that yields progress messages and returns blocking errors
*/
export async function* executePreToolHooks<ToolInput>(
toolName: string,
toolUseID: string,
toolInput: ToolInput,
toolUseContext: ToolUseContext,
permissionMode?: string,
signal?: AbortSignal,
timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
requestPrompt?: (
sourceName: string,
toolInputSummary?: string | null,
) => (request: PromptRequest) => Promise<PromptResponse>,
toolInputSummary?: string | null,
): AsyncGenerator<AggregatedHookResult> {
const appState = toolUseContext.getAppState()
const sessionId = toolUseContext.agentId ?? getSessionId()
if (!hasHookForEvent('PreToolUse', appState, sessionId)) {
return
}
logForDebugging(`executePreToolHooks called for tool: ${toolName}`, {
level: 'verbose',
})
const hookInput: PreToolUseHookInput = {
...createBaseHookInput(permissionMode, undefined, toolUseContext),
hook_event_name: 'PreToolUse',
tool_name: toolName,
tool_input: toolInput,
tool_use_id: toolUseID,
}
yield* executeHooks({
hookInput,
toolUseID,
matchQuery: toolName,
signal,
timeoutMs,
toolUseContext,
requestPrompt,
toolInputSummary,
})
}
/**
* Execute post-tool hooks if configured
* @param toolName The name of the tool (e.g., 'Write', 'Edit', 'Bash')
* @param toolUseID The ID of the tool use
* @param toolInput The input that was passed to the tool
* @param toolResponse The response from the tool
* @param toolUseContext ToolUseContext for prompt-based hooks
* @param permissionMode Optional permission mode from toolPermissionContext
* @param signal Optional AbortSignal to cancel hook execution
* @param timeoutMs Optional timeout in milliseconds for hook execution
* @returns Async generator that yields progress messages and blocking errors for automated feedback
*/
export async function* executePostToolHooks<ToolInput, ToolResponse>(
toolName: string,
toolUseID: string,
toolInput: ToolInput,
toolResponse: ToolResponse,
toolUseContext: ToolUseContext,
permissionMode?: string,
signal?: AbortSignal,
timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
): AsyncGenerator<AggregatedHookResult> {
const hookInput: PostToolUseHookInput = {
...createBaseHookInput(permissionMode, undefined, toolUseContext),
hook_event_name: 'PostToolUse',
tool_name: toolName,
tool_input: toolInput,
tool_response: toolResponse,
tool_use_id: toolUseID,
}
yield* executeHooks({
hookInput,
toolUseID,
matchQuery: toolName,
signal,
timeoutMs,
toolUseContext,
})
}
/**
* Execute post-tool-use-failure hooks if configured
* @param toolName The name of the tool (e.g., 'Write', 'Edit', 'Bash')
* @param toolUseID The ID of the tool use
* @param toolInput The input that was passed to the tool
* @param error The error message from the failed tool call
* @param toolUseContext ToolUseContext for prompt-based hooks
* @param isInterrupt Whether the tool was interrupted by user
* @param permissionMode Optional permission mode from toolPermissionContext
* @param signal Optional AbortSignal to cancel hook execution
* @param timeoutMs Optional timeout in milliseconds for hook execution
* @returns Async generator that yields progress messages and blocking errors
*/
export async function* executePostToolUseFailureHooks<ToolInput>(
toolName: string,
toolUseID: string,
toolInput: ToolInput,
error: string,
toolUseContext: ToolUseContext,
isInterrupt?: boolean,
permissionMode?: string,
signal?: AbortSignal,
timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
): AsyncGenerator<AggregatedHookResult> {
const appState = toolUseContext.getAppState()
const sessionId = toolUseContext.agentId ?? getSessionId()
if (!hasHookForEvent('PostToolUseFailure', appState, sessionId)) {
return
}
const hookInput: PostToolUseFailureHookInput = {
...createBaseHookInput(permissionMode, undefined, toolUseContext),
hook_event_name: 'PostToolUseFailure',
tool_name: toolName,
tool_input: toolInput,
tool_use_id: toolUseID,
error,
is_interrupt: isInterrupt,
}
yield* executeHooks({
hookInput,
toolUseID,
matchQuery: toolName,
signal,
timeoutMs,
toolUseContext,
})
}
export async function* executePermissionDeniedHooks<ToolInput>(
toolName: string,
toolUseID: string,
toolInput: ToolInput,
reason: string,
toolUseContext: ToolUseContext,
permissionMode?: string,
signal?: AbortSignal,
timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
): AsyncGenerator<AggregatedHookResult> {
const appState = toolUseContext.getAppState()
const sessionId = toolUseContext.agentId ?? getSessionId()
if (!hasHookForEvent('PermissionDenied', appState, sessionId)) {
return
}
const hookInput: PermissionDeniedHookInput = {
...createBaseHookInput(permissionMode, undefined, toolUseContext),
hook_event_name: 'PermissionDenied',
tool_name: toolName,
tool_input: toolInput,
tool_use_id: toolUseID,
reason,
}
yield* executeHooks({
hookInput,
toolUseID,
matchQuery: toolName,
signal,
timeoutMs,
toolUseContext,
})
}
/**
* Execute notification hooks if configured
* @param notificationData The notification data to pass to hooks
* @param timeoutMs Optional timeout in milliseconds for hook execution
* @returns Promise that resolves when all hooks complete
*/
export async function executeNotificationHooks(
notificationData: {
message: string
title?: string
notificationType: string
},
timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
): Promise<void> {
const { message, title, notificationType } = notificationData
const hookInput: NotificationHookInput = {
...createBaseHookInput(undefined),
hook_event_name: 'Notification',
message,
title,
notification_type: notificationType,
}
await executeHooksOutsideREPL({
hookInput,
timeoutMs,
matchQuery: notificationType,
})
}
export async function executeStopFailureHooks(
lastMessage: AssistantMessage,
toolUseContext?: ToolUseContext,
timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
): Promise<void> {
const appState = toolUseContext?.getAppState()
// executeHooksOutsideREPL hardcodes main sessionId (:2738). Agent frontmatter
// hooks (registerFrontmatterHooks) key by agentId; gating with agentId here
// would pass the gate but fail execution. Align gate with execution.
const sessionId = getSessionId()
if (!hasHookForEvent('StopFailure', appState, sessionId)) return
const lastAssistantText =
extractTextContent(lastMessage.message.content, '\n').trim() || undefined
// Some createAssistantAPIErrorMessage call sites omit `error` (e.g.
// image-size at errors.ts:431). Default to 'unknown' so matcher filtering
// at getMatchingHooks:1525 always applies.
const error = lastMessage.error ?? 'unknown'
const hookInput: StopFailureHookInput = {
...createBaseHookInput(undefined, undefined, toolUseContext),
hook_event_name: 'StopFailure',
error,
error_details: lastMessage.errorDetails,
last_assistant_message: lastAssistantText,
}
await executeHooksOutsideREPL({
getAppState: toolUseContext?.getAppState,
hookInput,
timeoutMs,
matchQuery: error,
})
}
/**
* Execute stop hooks if configured
* @param toolUseContext ToolUseContext for prompt-based hooks
* @param permissionMode permission mode from toolPermissionContext
* @param signal AbortSignal to cancel hook execution
* @param stopHookActive Whether this call is happening within another stop hook
* @param isSubagent Whether the current execution context is a subagent
* @param messages Optional conversation history for prompt/function hooks
* @returns Async generator that yields progress messages and blocking errors
*/
export async function* executeStopHooks(
permissionMode?: string,
signal?: AbortSignal,
timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
stopHookActive: boolean = false,
subagentId?: AgentId,
toolUseContext?: ToolUseContext,
messages?: Message[],
agentType?: string,
requestPrompt?: (
sourceName: string,
toolInputSummary?: string | null,
) => (request: PromptRequest) => Promise<PromptResponse>,
): AsyncGenerator<AggregatedHookResult> {
const hookEvent = subagentId ? 'SubagentStop' : 'Stop'
const appState = toolUseContext?.getAppState()
const sessionId = toolUseContext?.agentId ?? getSessionId()
if (!hasHookForEvent(hookEvent, appState, sessionId)) {
return
}
// Extract text content from the last assistant message so hooks can
// inspect the final response without reading the transcript file.
const lastAssistantMessage = messages
? getLastAssistantMessage(messages)
: undefined
const lastAssistantText = lastAssistantMessage
? extractTextContent(lastAssistantMessage.message.content, '\n').trim() ||
undefined
: undefined
const hookInput: StopHookInput | SubagentStopHookInput = subagentId
? {
...createBaseHookInput(permissionMode),
hook_event_name: 'SubagentStop',
stop_hook_active: stopHookActive,
agent_id: subagentId,
agent_transcript_path: getAgentTranscriptPath(subagentId),
agent_type: agentType ?? '',
last_assistant_message: lastAssistantText,
}
: {
...createBaseHookInput(permissionMode),
hook_event_name: 'Stop',
stop_hook_active: stopHookActive,
last_assistant_message: lastAssistantText,
}
// Trust check is now centralized in executeHooks()
yield* executeHooks({
hookInput,
toolUseID: randomUUID(),
signal,
timeoutMs,
toolUseContext,
messages,
requestPrompt,
})
}
/**
* Execute TeammateIdle hooks when a teammate is about to go idle.
* If a hook blocks (exit code 2), the teammate should continue working instead of going idle.
* @param teammateName The name of the teammate going idle
* @param teamName The team this teammate belongs to
* @param permissionMode Optional permission mode
* @param signal Optional AbortSignal to cancel hook execution
* @param timeoutMs Optional timeout in milliseconds for hook execution
* @returns Async generator that yields progress messages and blocking errors
*/
export async function* executeTeammateIdleHooks(
teammateName: string,
teamName: string,
permissionMode?: string,
signal?: AbortSignal,
timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
): AsyncGenerator<AggregatedHookResult> {
const hookInput: TeammateIdleHookInput = {
...createBaseHookInput(permissionMode),
hook_event_name: 'TeammateIdle',
teammate_name: teammateName,
team_name: teamName,
}
yield* executeHooks({
hookInput,
toolUseID: randomUUID(),
signal,
timeoutMs,
})
}
/**
* Execute TaskCreated hooks when a task is being created.
* If a hook blocks (exit code 2), the task creation should be prevented and feedback returned.
* @param taskId The ID of the task being created
* @param taskSubject The subject/title of the task
* @param taskDescription Optional description of the task
* @param teammateName Optional name of the teammate creating the task
* @param teamName Optional team name
* @param permissionMode Optional permission mode
* @param signal Optional AbortSignal to cancel hook execution
* @param timeoutMs Optional timeout in milliseconds for hook execution
* @param toolUseContext Optional ToolUseContext for resolving appState and sessionId
* @returns Async generator that yields progress messages and blocking errors
*/
export async function* executeTaskCreatedHooks(
taskId: string,
taskSubject: string,
taskDescription?: string,
teammateName?: string,
teamName?: string,
permissionMode?: string,
signal?: AbortSignal,
timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
toolUseContext?: ToolUseContext,
): AsyncGenerator<AggregatedHookResult> {
const hookInput: TaskCreatedHookInput = {
...createBaseHookInput(permissionMode),
hook_event_name: 'TaskCreated',
task_id: taskId,
task_subject: taskSubject,
task_description: taskDescription,
teammate_name: teammateName,
team_name: teamName,
}
yield* executeHooks({
hookInput,
toolUseID: randomUUID(),
signal,
timeoutMs,
toolUseContext,
})
}
/**
* Execute TaskCompleted hooks when a task is being marked as completed.
* If a hook blocks (exit code 2), the task completion should be prevented and feedback returned.
* @param taskId The ID of the task being completed
* @param taskSubject The subject/title of the task
* @param taskDescription Optional description of the task
* @param teammateName Optional name of the teammate completing the task
* @param teamName Optional team name
* @param permissionMode Optional permission mode
* @param signal Optional AbortSignal to cancel hook execution
* @param timeoutMs Optional timeout in milliseconds for hook execution
* @param toolUseContext Optional ToolUseContext for resolving appState and sessionId
* @returns Async generator that yields progress messages and blocking errors
*/
export async function* executeTaskCompletedHooks(
taskId: string,
taskSubject: string,
taskDescription?: string,
teammateName?: string,
teamName?: string,
permissionMode?: string,
signal?: AbortSignal,
timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
toolUseContext?: ToolUseContext,
): AsyncGenerator<AggregatedHookResult> {
const hookInput: TaskCompletedHookInput = {
...createBaseHookInput(permissionMode),
hook_event_name: 'TaskCompleted',
task_id: taskId,
task_subject: taskSubject,
task_description: taskDescription,
teammate_name: teammateName,
team_name: teamName,
}
yield* executeHooks({
hookInput,
toolUseID: randomUUID(),
signal,
timeoutMs,
toolUseContext,
})
}
/**
* Execute start hooks if configured
* @param prompt The user prompt that will be passed to the tool
* @param permissionMode Permission mode from toolPermissionContext
* @param toolUseContext ToolUseContext for prompt-based hooks
* @returns Async generator that yields progress messages and hook results
*/
export async function* executeUserPromptSubmitHooks(
prompt: string,
permissionMode: string,
toolUseContext: ToolUseContext,
requestPrompt?: (
sourceName: string,
toolInputSummary?: string | null,
) => (request: PromptRequest) => Promise<PromptResponse>,
): AsyncGenerator<AggregatedHookResult> {
const appState = toolUseContext.getAppState()
const sessionId = toolUseContext.agentId ?? getSessionId()
if (!hasHookForEvent('UserPromptSubmit', appState, sessionId)) {
return
}
const hookInput: UserPromptSubmitHookInput = {
...createBaseHookInput(permissionMode),
hook_event_name: 'UserPromptSubmit',
prompt,
}
yield* executeHooks({
hookInput,
toolUseID: randomUUID(),
signal: toolUseContext.abortController.signal,
timeoutMs: TOOL_HOOK_EXECUTION_TIMEOUT_MS,
toolUseContext,
requestPrompt,
})
}
/**
* Execute session start hooks if configured
* @param source The source of the session start (startup, resume, clear)
* @param sessionId Optional The session id to use as hook input
* @param agentType Optional The agent type (from --agent flag) running this session
* @param model Optional The model being used for this session
* @param signal Optional AbortSignal to cancel hook execution
* @param timeoutMs Optional timeout in milliseconds for hook execution
* @returns Async generator that yields progress messages and hook results
*/
export async function* executeSessionStartHooks(
source: 'startup' | 'resume' | 'clear' | 'compact',
sessionId?: string,
agentType?: string,
model?: string,
signal?: AbortSignal,
timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
forceSyncExecution?: boolean,
): AsyncGenerator<AggregatedHookResult> {
const hookInput: SessionStartHookInput = {
...createBaseHookInput(undefined, sessionId),
hook_event_name: 'SessionStart',
source,
agent_type: agentType,
model,
}
yield* executeHooks({
hookInput,
toolUseID: randomUUID(),
matchQuery: source,
signal,
timeoutMs,
forceSyncExecution,
})
}
/**
* Execute setup hooks if configured
* @param trigger The trigger type ('init' or 'maintenance')
* @param signal Optional AbortSignal to cancel hook execution
* @param timeoutMs Optional timeout in milliseconds for hook execution
* @param forceSyncExecution If true, async hooks will not be backgrounded
* @returns Async generator that yields progress messages and hook results
*/
export async function* executeSetupHooks(
trigger: 'init' | 'maintenance',
signal?: AbortSignal,
timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
forceSyncExecution?: boolean,
): AsyncGenerator<AggregatedHookResult> {
const hookInput: SetupHookInput = {
...createBaseHookInput(undefined),
hook_event_name: 'Setup',
trigger,
}
yield* executeHooks({
hookInput,
toolUseID: randomUUID(),
matchQuery: trigger,
signal,
timeoutMs,
forceSyncExecution,
})
}
/**
* Execute subagent start hooks if configured
* @param agentId The unique identifier for the subagent
* @param agentType The type/name of the subagent being started
* @param signal Optional AbortSignal to cancel hook execution
* @param timeoutMs Optional timeout in milliseconds for hook execution
* @returns Async generator that yields progress messages and hook results
*/
export async function* executeSubagentStartHooks(
agentId: string,
agentType: string,
signal?: AbortSignal,
timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
): AsyncGenerator<AggregatedHookResult> {
const hookInput: SubagentStartHookInput = {
...createBaseHookInput(undefined),
hook_event_name: 'SubagentStart',
agent_id: agentId,
agent_type: agentType,
}
yield* executeHooks({
hookInput,
toolUseID: randomUUID(),
matchQuery: agentType,
signal,
timeoutMs,
})
}
/**
* Execute pre-compact hooks if configured
* @param compactData The compact data to pass to hooks
* @param signal Optional AbortSignal to cancel hook execution
* @param timeoutMs Optional timeout in milliseconds for hook execution
* @returns Object with optional newCustomInstructions and userDisplayMessage
*/
export async function executePreCompactHooks(
compactData: {
trigger: 'manual' | 'auto'
customInstructions: string | null
},
signal?: AbortSignal,
timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
): Promise<{
newCustomInstructions?: string
userDisplayMessage?: string
}> {
const hookInput: PreCompactHookInput = {
...createBaseHookInput(undefined),
hook_event_name: 'PreCompact',
trigger: compactData.trigger,
custom_instructions: compactData.customInstructions,
}
const results = await executeHooksOutsideREPL({
hookInput,
matchQuery: compactData.trigger,
signal,
timeoutMs,
})
if (results.length === 0) {
return {}
}
// Extract custom instructions from successful hooks with non-empty output
const successfulOutputs = results
.filter(result => result.succeeded && result.output.trim().length > 0)
.map(result => result.output.trim())
// Build user display messages with command info
const displayMessages: string[] = []
for (const result of results) {
if (result.succeeded) {
if (result.output.trim()) {
displayMessages.push(
`PreCompact [${result.command}] completed successfully: ${result.output.trim()}`,
)
} else {
displayMessages.push(
`PreCompact [${result.command}] completed successfully`,
)
}
} else {
if (result.output.trim()) {
displayMessages.push(
`PreCompact [${result.command}] failed: ${result.output.trim()}`,
)
} else {
displayMessages.push(`PreCompact [${result.command}] failed`)
}
}
}
return {
newCustomInstructions:
successfulOutputs.length > 0 ? successfulOutputs.join('\n\n') : undefined,
userDisplayMessage:
displayMessages.length > 0 ? displayMessages.join('\n') : undefined,
}
}
/**
* Execute post-compact hooks if configured
* @param compactData The compact data to pass to hooks, including the summary
* @param signal Optional AbortSignal to cancel hook execution
* @param timeoutMs Optional timeout in milliseconds for hook execution
* @returns Object with optional userDisplayMessage
*/
export async function executePostCompactHooks(
compactData: {
trigger: 'manual' | 'auto'
compactSummary: string
},
signal?: AbortSignal,
timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
): Promise<{
userDisplayMessage?: string
}> {
const hookInput: PostCompactHookInput = {
...createBaseHookInput(undefined),
hook_event_name: 'PostCompact',
trigger: compactData.trigger,
compact_summary: compactData.compactSummary,
}
const results = await executeHooksOutsideREPL({
hookInput,
matchQuery: compactData.trigger,
signal,
timeoutMs,
})
if (results.length === 0) {
return {}
}
const displayMessages: string[] = []
for (const result of results) {
if (result.succeeded) {
if (result.output.trim()) {
displayMessages.push(
`PostCompact [${result.command}] completed successfully: ${result.output.trim()}`,
)
} else {
displayMessages.push(
`PostCompact [${result.command}] completed successfully`,
)
}
} else {
if (result.output.trim()) {
displayMessages.push(
`PostCompact [${result.command}] failed: ${result.output.trim()}`,
)
} else {
displayMessages.push(`PostCompact [${result.command}] failed`)
}
}
}
return {
userDisplayMessage:
displayMessages.length > 0 ? displayMessages.join('\n') : undefined,
}
}
/**
* Execute session end hooks if configured
* @param reason The reason for ending the session
* @param options Optional parameters including app state functions and signal
* @returns Promise that resolves when all hooks complete
*/
export async function executeSessionEndHooks(
reason: ExitReason,
options?: {
getAppState?: () => AppState
setAppState?: (updater: (prev: AppState) => AppState) => void
signal?: AbortSignal
timeoutMs?: number
},
): Promise<void> {
const {
getAppState,
setAppState,
signal,
timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
} = options || {}
const hookInput: SessionEndHookInput = {
...createBaseHookInput(undefined),
hook_event_name: 'SessionEnd',
reason,
}
const results = await executeHooksOutsideREPL({
getAppState,
hookInput,
matchQuery: reason,
signal,
timeoutMs,
})
// During shutdown, Ink is unmounted so we can write directly to stderr
for (const result of results) {
if (!result.succeeded && result.output) {
process.stderr.write(
`SessionEnd hook [${result.command}] failed: ${result.output}\n`,
)
}
}
// Clear session hooks after execution
if (setAppState) {
const sessionId = getSessionId()
clearSessionHooks(setAppState, sessionId)
}
}
/**
* Execute permission request hooks if configured
* These hooks are called when a permission dialog would be displayed to the user.
* Hooks can approve or deny the permission request programmatically.
* @param toolName The name of the tool requesting permission
* @param toolUseID The ID of the tool use
* @param toolInput The input that would be passed to the tool
* @param toolUseContext ToolUseContext for the request
* @param permissionMode Optional permission mode from toolPermissionContext
* @param permissionSuggestions Optional permission suggestions (the "always allow" options)
* @param signal Optional AbortSignal to cancel hook execution
* @param timeoutMs Optional timeout in milliseconds for hook execution
* @returns Async generator that yields progress messages and returns aggregated result
*/
export async function* executePermissionRequestHooks<ToolInput>(
toolName: string,
toolUseID: string,
toolInput: ToolInput,
toolUseContext: ToolUseContext,
permissionMode?: string,
permissionSuggestions?: PermissionUpdate[],
signal?: AbortSignal,
timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
requestPrompt?: (
sourceName: string,
toolInputSummary?: string | null,
) => (request: PromptRequest) => Promise<PromptResponse>,
toolInputSummary?: string | null,
): AsyncGenerator<AggregatedHookResult> {
logForDebugging(`executePermissionRequestHooks called for tool: ${toolName}`)
const hookInput: PermissionRequestHookInput = {
...createBaseHookInput(permissionMode, undefined, toolUseContext),
hook_event_name: 'PermissionRequest',
tool_name: toolName,
tool_input: toolInput,
permission_suggestions: permissionSuggestions,
}
yield* executeHooks({
hookInput,
toolUseID,
matchQuery: toolName,
signal,
timeoutMs,
toolUseContext,
requestPrompt,
toolInputSummary,
})
}
export type ConfigChangeSource =
| 'user_settings'
| 'project_settings'
| 'local_settings'
| 'policy_settings'
| 'skills'
/**
* Execute config change hooks when configuration files change during a session.
* Fired by file watchers when settings, skills, or commands change on disk.
* Enables enterprise admins to audit/log configuration changes for security.
*
* Policy settings are enterprise-managed and must never be blockable by hooks.
* Hooks still fire (for audit logging) but blocking results are ignored — callers
* will always see an empty result for policy sources.
*
* @param source The type of config that changed
* @param filePath Optional path to the changed file
* @param timeoutMs Optional timeout in milliseconds for hook execution
*/
export async function executeConfigChangeHooks(
source: ConfigChangeSource,
filePath?: string,
timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
): Promise<HookOutsideReplResult[]> {
const hookInput: ConfigChangeHookInput = {
...createBaseHookInput(undefined),
hook_event_name: 'ConfigChange',
source,
file_path: filePath,
}
const results = await executeHooksOutsideREPL({
hookInput,
timeoutMs,
matchQuery: source,
})
// Policy settings are enterprise-managed — hooks fire for audit logging
// but must never block policy changes from being applied
if (source === 'policy_settings') {
return results.map(r => ({ ...r, blocked: false }))
}
return results
}
async function executeEnvHooks(
hookInput: HookInput,
timeoutMs: number,
): Promise<{
results: HookOutsideReplResult[]
watchPaths: string[]
systemMessages: string[]
}> {
const results = await executeHooksOutsideREPL({ hookInput, timeoutMs })
if (results.length > 0) {
invalidateSessionEnvCache()
}
const watchPaths = results.flatMap(r => r.watchPaths ?? [])
const systemMessages = results
.map(r => r.systemMessage)
.filter((m): m is string => !!m)
return { results, watchPaths, systemMessages }
}
export function executeCwdChangedHooks(
oldCwd: string,
newCwd: string,
timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
): Promise<{
results: HookOutsideReplResult[]
watchPaths: string[]
systemMessages: string[]
}> {
const hookInput: CwdChangedHookInput = {
...createBaseHookInput(undefined),
hook_event_name: 'CwdChanged',
old_cwd: oldCwd,
new_cwd: newCwd,
}
return executeEnvHooks(hookInput, timeoutMs)
}
export function executeFileChangedHooks(
filePath: string,
event: 'change' | 'add' | 'unlink',
timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
): Promise<{
results: HookOutsideReplResult[]
watchPaths: string[]
systemMessages: string[]
}> {
const hookInput: FileChangedHookInput = {
...createBaseHookInput(undefined),
hook_event_name: 'FileChanged',
file_path: filePath,
event,
}
return executeEnvHooks(hookInput, timeoutMs)
}
export type InstructionsLoadReason =
| 'session_start'
| 'nested_traversal'
| 'path_glob_match'
| 'include'
| 'compact'
export type InstructionsMemoryType = 'User' | 'Project' | 'Local' | 'Managed'
/**
* Check if InstructionsLoaded hooks are configured (without executing them).
* Callers should check this before invoking executeInstructionsLoadedHooks to avoid
* building hook inputs for every instruction file when no hook is configured.
*
* Checks both settings-file hooks (getHooksConfigFromSnapshot) and registered
* hooks (plugin hooks + SDK callback hooks via registerHookCallbacks). Session-
* derived hooks (structured output enforcement etc.) are internal and not checked.
*/
export function hasInstructionsLoadedHook(): boolean {
const snapshotHooks = getHooksConfigFromSnapshot()?.['InstructionsLoaded']
if (snapshotHooks && snapshotHooks.length > 0) return true
const registeredHooks = getRegisteredHooks()?.['InstructionsLoaded']
if (registeredHooks && registeredHooks.length > 0) return true
return false
}
/**
* Execute InstructionsLoaded hooks when an instruction file (CLAUDE.md or
* .claude/rules/*.md) is loaded into context. Fire-and-forget — this hook is
* for observability/audit only and does not support blocking.
*
* Dispatch sites:
* - Eager load at session start (getMemoryFiles in claudemd.ts)
* - Eager reload after compaction (getMemoryFiles cache cleared by
* runPostCompactCleanup; next call reports load_reason: 'compact')
* - Lazy load when Claude touches a file that triggers nested CLAUDE.md or
* conditional rules with paths: frontmatter (memoryFilesToAttachments in
* attachments.ts)
*/
export async function executeInstructionsLoadedHooks(
filePath: string,
memoryType: InstructionsMemoryType,
loadReason: InstructionsLoadReason,
options?: {
globs?: string[]
triggerFilePath?: string
parentFilePath?: string
timeoutMs?: number
},
): Promise<void> {
const {
globs,
triggerFilePath,
parentFilePath,
timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
} = options ?? {}
const hookInput: InstructionsLoadedHookInput = {
...createBaseHookInput(undefined),
hook_event_name: 'InstructionsLoaded',
file_path: filePath,
memory_type: memoryType,
load_reason: loadReason,
globs,
trigger_file_path: triggerFilePath,
parent_file_path: parentFilePath,
}
await executeHooksOutsideREPL({
hookInput,
timeoutMs,
matchQuery: loadReason,
})
}
/** Result of an elicitation hook execution (non-REPL path). */
export type ElicitationHookResult = {
elicitationResponse?: ElicitationResponse
blockingError?: HookBlockingError
}
/** Result of an elicitation-result hook execution (non-REPL path). */
export type ElicitationResultHookResult = {
elicitationResultResponse?: ElicitationResponse
blockingError?: HookBlockingError
}
/**
* Parse elicitation-specific fields from a HookOutsideReplResult.
* Mirrors the relevant branches of processHookJSONOutput for Elicitation
* and ElicitationResult hook events.
*/
function parseElicitationHookOutput(
result: HookOutsideReplResult,
expectedEventName: 'Elicitation' | 'ElicitationResult',
): {
response?: ElicitationResponse
blockingError?: HookBlockingError
} {
// Exit code 2 = blocking (same as executeHooks path)
if (result.blocked && !result.succeeded) {
return {
blockingError: {
blockingError: result.output || `Elicitation blocked by hook`,
command: result.command,
},
}
}
if (!result.output.trim()) {
return {}
}
// Try to parse JSON output for structured elicitation response
const trimmed = result.output.trim()
if (!trimmed.startsWith('{')) {
return {}
}
try {
const parsed = hookJSONOutputSchema().parse(JSON.parse(trimmed))
if (isAsyncHookJSONOutput(parsed)) {
return {}
}
if (!isSyncHookJSONOutput(parsed)) {
return {}
}
// Check for top-level decision: 'block' (exit code 0 + JSON block)
if (parsed.decision === 'block' || result.blocked) {
return {
blockingError: {
blockingError: parsed.reason || 'Elicitation blocked by hook',
command: result.command,
},
}
}
const specific = parsed.hookSpecificOutput
if (!specific || specific.hookEventName !== expectedEventName) {
return {}
}
if (!specific.action) {
return {}
}
const response: ElicitationResponse = {
action: specific.action,
content: specific.content as ElicitationResponse['content'] | undefined,
}
const out: {
response?: ElicitationResponse
blockingError?: HookBlockingError
} = { response }
if (specific.action === 'decline') {
out.blockingError = {
blockingError:
parsed.reason ||
(expectedEventName === 'Elicitation'
? 'Elicitation denied by hook'
: 'Elicitation result blocked by hook'),
command: result.command,
}
}
return out
} catch {
return {}
}
}
export async function executeElicitationHooks({
serverName,
message,
requestedSchema,
permissionMode,
signal,
timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
mode,
url,
elicitationId,
}: {
serverName: string
message: string
requestedSchema?: Record<string, unknown>
permissionMode?: string
signal?: AbortSignal
timeoutMs?: number
mode?: 'form' | 'url'
url?: string
elicitationId?: string
}): Promise<ElicitationHookResult> {
const hookInput: ElicitationHookInput = {
...createBaseHookInput(permissionMode),
hook_event_name: 'Elicitation',
mcp_server_name: serverName,
message,
mode,
url,
elicitation_id: elicitationId,
requested_schema: requestedSchema,
}
const results = await executeHooksOutsideREPL({
hookInput,
matchQuery: serverName,
signal,
timeoutMs,
})
let elicitationResponse: ElicitationResponse | undefined
let blockingError: HookBlockingError | undefined
for (const result of results) {
const parsed = parseElicitationHookOutput(result, 'Elicitation')
if (parsed.blockingError) {
blockingError = parsed.blockingError
}
if (parsed.response) {
elicitationResponse = parsed.response
}
}
return { elicitationResponse, blockingError }
}
export async function executeElicitationResultHooks({
serverName,
action,
content,
permissionMode,
signal,
timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
mode,
elicitationId,
}: {
serverName: string
action: 'accept' | 'decline' | 'cancel'
content?: Record<string, unknown>
permissionMode?: string
signal?: AbortSignal
timeoutMs?: number
mode?: 'form' | 'url'
elicitationId?: string
}): Promise<ElicitationResultHookResult> {
const hookInput: ElicitationResultHookInput = {
...createBaseHookInput(permissionMode),
hook_event_name: 'ElicitationResult',
mcp_server_name: serverName,
elicitation_id: elicitationId,
mode,
action,
content,
}
const results = await executeHooksOutsideREPL({
hookInput,
matchQuery: serverName,
signal,
timeoutMs,
})
let elicitationResultResponse: ElicitationResponse | undefined
let blockingError: HookBlockingError | undefined
for (const result of results) {
const parsed = parseElicitationHookOutput(result, 'ElicitationResult')
if (parsed.blockingError) {
blockingError = parsed.blockingError
}
if (parsed.response) {
elicitationResultResponse = parsed.response
}
}
return { elicitationResultResponse, blockingError }
}
/**
* Execute status line command if configured
* @param statusLineInput The structured status input that will be converted to JSON
* @param signal Optional AbortSignal to cancel hook execution
* @param timeoutMs Optional timeout in milliseconds for hook execution
* @returns The status line text to display, or undefined if no command configured
*/
export async function executeStatusLineCommand(
statusLineInput: StatusLineCommandInput,
signal?: AbortSignal,
timeoutMs: number = 5000, // Short timeout for status line
logResult: boolean = false,
): Promise<string | undefined> {
// Check if all hooks (including statusLine) are disabled by managed settings
if (shouldDisableAllHooksIncludingManaged()) {
return undefined
}
// SECURITY: ALL hooks require workspace trust in interactive mode
// This centralized check prevents RCE vulnerabilities for all current and future hooks
if (shouldSkipHookDueToTrust()) {
logForDebugging(
`Skipping StatusLine command execution - workspace trust not accepted`,
)
return undefined
}
// When disableAllHooks is set in non-managed settings, only managed statusLine runs
// (non-managed settings cannot disable managed commands, but non-managed commands are disabled)
let statusLine
if (shouldAllowManagedHooksOnly()) {
statusLine = getSettingsForSource('policySettings')?.statusLine
} else {
statusLine = getSettings_DEPRECATED()?.statusLine
}
if (!statusLine || statusLine.type !== 'command') {
return undefined
}
// Use provided signal or create a default one
const abortSignal = signal || AbortSignal.timeout(timeoutMs)
try {
// Convert status input to JSON
const jsonInput = jsonStringify(statusLineInput)
const result = await execCommandHook(
statusLine,
'StatusLine',
'statusLine',
jsonInput,
abortSignal,
randomUUID(),
)
if (result.aborted) {
return undefined
}
// For successful hooks (exit code 0), use stdout
if (result.status === 0) {
// Trim and split output into lines, then join with newlines
const output = result.stdout
.trim()
.split('\n')
.flatMap(line => line.trim() || [])
.join('\n')
if (output) {
if (logResult) {
logForDebugging(
`StatusLine [${statusLine.command}] completed with status ${result.status}`,
)
}
return output
}
} else if (logResult) {
logForDebugging(
`StatusLine [${statusLine.command}] completed with status ${result.status}`,
{ level: 'warn' },
)
}
return undefined
} catch (error) {
logForDebugging(`Status hook failed: ${error}`, { level: 'error' })
return undefined
}
}
/**
* Execute file suggestion command if configured
* @param fileSuggestionInput The structured input that will be converted to JSON
* @param signal Optional AbortSignal to cancel hook execution
* @param timeoutMs Optional timeout in milliseconds for hook execution
* @returns Array of file paths, or empty array if no command configured
*/
export async function executeFileSuggestionCommand(
fileSuggestionInput: FileSuggestionCommandInput,
signal?: AbortSignal,
timeoutMs: number = 5000, // Short timeout for typeahead suggestions
): Promise<string[]> {
// Check if all hooks are disabled by managed settings
if (shouldDisableAllHooksIncludingManaged()) {
return []
}
// SECURITY: ALL hooks require workspace trust in interactive mode
// This centralized check prevents RCE vulnerabilities for all current and future hooks
if (shouldSkipHookDueToTrust()) {
logForDebugging(
`Skipping FileSuggestion command execution - workspace trust not accepted`,
)
return []
}
// When disableAllHooks is set in non-managed settings, only managed fileSuggestion runs
// (non-managed settings cannot disable managed commands, but non-managed commands are disabled)
let fileSuggestion
if (shouldAllowManagedHooksOnly()) {
fileSuggestion = getSettingsForSource('policySettings')?.fileSuggestion
} else {
fileSuggestion = getSettings_DEPRECATED()?.fileSuggestion
}
if (!fileSuggestion || fileSuggestion.type !== 'command') {
return []
}
// Use provided signal or create a default one
const abortSignal = signal || AbortSignal.timeout(timeoutMs)
try {
const jsonInput = jsonStringify(fileSuggestionInput)
const hook = { type: 'command' as const, command: fileSuggestion.command }
const result = await execCommandHook(
hook,
'FileSuggestion',
'FileSuggestion',
jsonInput,
abortSignal,
randomUUID(),
)
if (result.aborted || result.status !== 0) {
return []
}
return result.stdout
.split('\n')
.map(line => line.trim())
.filter(Boolean)
} catch (error) {
logForDebugging(`File suggestion helper failed: ${error}`, {
level: 'error',
})
return []
}
}
async function executeFunctionHook({
hook,
messages,
hookName,
toolUseID,
hookEvent,
timeoutMs,
signal,
}: {
hook: FunctionHook
messages: Message[]
hookName: string
toolUseID: string
hookEvent: HookEvent
timeoutMs: number
signal?: AbortSignal
}): Promise<HookResult> {
const callbackTimeoutMs = hook.timeout ?? timeoutMs
const { signal: abortSignal, cleanup } = createCombinedAbortSignal(signal, {
timeoutMs: callbackTimeoutMs,
})
try {
// Check if already aborted
if (abortSignal.aborted) {
cleanup()
return {
outcome: 'cancelled',
hook,
}
}
// Execute callback with abort signal
const passed = await new Promise<boolean>((resolve, reject) => {
// Handle abort signal
const onAbort = () => reject(new Error('Function hook cancelled'))
abortSignal.addEventListener('abort', onAbort)
// Execute callback
Promise.resolve(hook.callback(messages, abortSignal))
.then(result => {
abortSignal.removeEventListener('abort', onAbort)
resolve(result)
})
.catch(error => {
abortSignal.removeEventListener('abort', onAbort)
reject(error)
})
})
cleanup()
if (passed) {
return {
outcome: 'success',
hook,
}
}
return {
blockingError: {
blockingError: hook.errorMessage,
command: 'function',
},
outcome: 'blocking',
hook,
}
} catch (error) {
cleanup()
// Handle cancellation
if (
error instanceof Error &&
(error.message === 'Function hook cancelled' ||
error.name === 'AbortError')
) {
return {
outcome: 'cancelled',
hook,
}
}
// Log for monitoring
logError(error)
return {
message: createAttachmentMessage({
type: 'hook_error_during_execution',
hookName,
toolUseID,
hookEvent,
content:
error instanceof Error
? error.message
: 'Function hook execution error',
}),
outcome: 'non_blocking_error',
hook,
}
}
}
async function executeHookCallback({
toolUseID,
hook,
hookEvent,
hookInput,
signal,
hookIndex,
toolUseContext,
}: {
toolUseID: string
hook: HookCallback
hookEvent: HookEvent
hookInput: HookInput
signal: AbortSignal
hookIndex?: number
toolUseContext?: ToolUseContext
}): Promise<HookResult> {
// Create context for callbacks that need state access
const context = toolUseContext
? {
getAppState: toolUseContext.getAppState,
updateAttributionState: toolUseContext.updateAttributionState,
}
: undefined
const json = await hook.callback(
hookInput,
toolUseID,
signal,
hookIndex,
context,
)
if (isAsyncHookJSONOutput(json)) {
return {
outcome: 'success',
hook,
}
}
const processed = processHookJSONOutput({
json,
command: 'callback',
// TODO: If the hook came from a plugin, use the full path to the plugin for easier debugging
hookName: `${hookEvent}:Callback`,
toolUseID,
hookEvent,
expectedHookEvent: hookEvent,
// Callbacks don't have stdout/stderr/exitCode
stdout: undefined,
stderr: undefined,
exitCode: undefined,
})
return {
...processed,
outcome: 'success',
hook,
}
}
/**
* Check if WorktreeCreate hooks are configured (without executing them).
*
* Checks both settings-file hooks (getHooksConfigFromSnapshot) and registered
* hooks (plugin hooks + SDK callback hooks via registerHookCallbacks).
*
* Must mirror the managedOnly filtering in getHooksConfig() — when
* shouldAllowManagedHooksOnly() is true, plugin hooks (pluginRoot set) are
* skipped at execution, so we must also skip them here. Otherwise this returns
* true but executeWorktreeCreateHook() finds no matching hooks and throws,
* blocking the git-worktree fallback.
*/
export function hasWorktreeCreateHook(): boolean {
const snapshotHooks = getHooksConfigFromSnapshot()?.['WorktreeCreate']
if (snapshotHooks && snapshotHooks.length > 0) return true
const registeredHooks = getRegisteredHooks()?.['WorktreeCreate']
if (!registeredHooks || registeredHooks.length === 0) return false
// Mirror getHooksConfig(): skip plugin hooks in managed-only mode
const managedOnly = shouldAllowManagedHooksOnly()
return registeredHooks.some(
matcher => !(managedOnly && 'pluginRoot' in matcher),
)
}
/**
* Execute WorktreeCreate hooks.
* Returns the worktree path from hook stdout.
* Throws if hooks fail or produce no output.
* Callers should check hasWorktreeCreateHook() before calling this.
*/
export async function executeWorktreeCreateHook(
name: string,
): Promise<{ worktreePath: string }> {
const hookInput = {
...createBaseHookInput(undefined),
hook_event_name: 'WorktreeCreate' as const,
name,
}
const results = await executeHooksOutsideREPL({
hookInput,
timeoutMs: TOOL_HOOK_EXECUTION_TIMEOUT_MS,
})
// Find the first successful result with non-empty output
const successfulResult = results.find(
r => r.succeeded && r.output.trim().length > 0,
)
if (!successfulResult) {
const failedOutputs = results
.filter(r => !r.succeeded)
.map(r => `${r.command}: ${r.output.trim() || 'no output'}`)
throw new Error(
`WorktreeCreate hook failed: ${failedOutputs.join('; ') || 'no successful output'}`,
)
}
const worktreePath = successfulResult.output.trim()
return { worktreePath }
}
/**
* Execute WorktreeRemove hooks if configured.
* Returns true if hooks were configured and ran, false if no hooks are configured.
*
* Checks both settings-file hooks (getHooksConfigFromSnapshot) and registered
* hooks (plugin hooks + SDK callback hooks via registerHookCallbacks).
*/
export async function executeWorktreeRemoveHook(
worktreePath: string,
): Promise<boolean> {
const snapshotHooks = getHooksConfigFromSnapshot()?.['WorktreeRemove']
const registeredHooks = getRegisteredHooks()?.['WorktreeRemove']
const hasSnapshotHooks = snapshotHooks && snapshotHooks.length > 0
const hasRegisteredHooks = registeredHooks && registeredHooks.length > 0
if (!hasSnapshotHooks && !hasRegisteredHooks) {
return false
}
const hookInput = {
...createBaseHookInput(undefined),
hook_event_name: 'WorktreeRemove' as const,
worktree_path: worktreePath,
}
const results = await executeHooksOutsideREPL({
hookInput,
timeoutMs: TOOL_HOOK_EXECUTION_TIMEOUT_MS,
})
if (results.length === 0) {
return false
}
for (const result of results) {
if (!result.succeeded) {
logForDebugging(
`WorktreeRemove hook failed [${result.command}]: ${result.output.trim()}`,
{ level: 'error' },
)
}
}
return true
}
function getHookDefinitionsForTelemetry(
matchedHooks: MatchedHook[],
): Array<{ type: string; command?: string; prompt?: string; name?: string }> {
return matchedHooks.map(({ hook }) => {
if (hook.type === 'command') {
return { type: 'command', command: hook.command }
} else if (hook.type === 'prompt') {
return { type: 'prompt', prompt: hook.prompt }
} else if (hook.type === 'http') {
return { type: 'http', command: hook.url }
} else if (hook.type === 'function') {
return { type: 'function', name: 'function' }
} else if (hook.type === 'callback') {
return { type: 'callback', name: 'callback' }
}
return { type: 'unknown' }
})
}