/ utils / hooks.ts
hooks.ts
   1  // biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
   2  /**
   3   * Hooks are user-defined shell commands that can be executed at various points
   4   * in Claude Code's lifecycle.
   5   */
   6  import { basename } from 'path'
   7  import { spawn, type ChildProcessWithoutNullStreams } from 'child_process'
   8  import { pathExists } from './file.js'
   9  import { wrapSpawn } from './ShellCommand.js'
  10  import { TaskOutput } from './task/TaskOutput.js'
  11  import { getCwd } from './cwd.js'
  12  import { randomUUID } from 'crypto'
  13  import { formatShellPrefixCommand } from './bash/shellPrefix.js'
  14  import {
  15    getHookEnvFilePath,
  16    invalidateSessionEnvCache,
  17  } from './sessionEnvironment.js'
  18  import { subprocessEnv } from './subprocessEnv.js'
  19  import { getPlatform } from './platform.js'
  20  import { findGitBashPath, windowsPathToPosixPath } from './windowsPaths.js'
  21  import { getCachedPowerShellPath } from './shell/powershellDetection.js'
  22  import { DEFAULT_HOOK_SHELL } from './shell/shellProvider.js'
  23  import { buildPowerShellArgs } from './shell/powershellProvider.js'
  24  import {
  25    loadPluginOptions,
  26    substituteUserConfigVariables,
  27  } from './plugins/pluginOptionsStorage.js'
  28  import { getPluginDataDir } from './plugins/pluginDirectories.js'
  29  import {
  30    getSessionId,
  31    getProjectRoot,
  32    getIsNonInteractiveSession,
  33    getRegisteredHooks,
  34    getStatsStore,
  35    addToTurnHookDuration,
  36    getOriginalCwd,
  37    getMainThreadAgentType,
  38  } from '../bootstrap/state.js'
  39  import { checkHasTrustDialogAccepted } from './config.js'
  40  import {
  41    getHooksConfigFromSnapshot,
  42    shouldAllowManagedHooksOnly,
  43    shouldDisableAllHooksIncludingManaged,
  44  } from './hooks/hooksConfigSnapshot.js'
  45  import {
  46    getTranscriptPathForSession,
  47    getAgentTranscriptPath,
  48  } from './sessionStorage.js'
  49  import type { AgentId } from '../types/ids.js'
  50  import {
  51    getSettings_DEPRECATED,
  52    getSettingsForSource,
  53  } from './settings/settings.js'
  54  import {
  55    logEvent,
  56    type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  57  } from 'src/services/analytics/index.js'
  58  import { logOTelEvent } from './telemetry/events.js'
  59  import { ALLOWED_OFFICIAL_MARKETPLACE_NAMES } from './plugins/schemas.js'
  60  import {
  61    startHookSpan,
  62    endHookSpan,
  63    isBetaTracingEnabled,
  64  } from './telemetry/sessionTracing.js'
  65  import {
  66    hookJSONOutputSchema,
  67    promptRequestSchema,
  68    type HookCallback,
  69    type HookCallbackMatcher,
  70    type PromptRequest,
  71    type PromptResponse,
  72    isAsyncHookJSONOutput,
  73    isSyncHookJSONOutput,
  74    type PermissionRequestResult,
  75  } from '../types/hooks.js'
  76  import type {
  77    HookEvent,
  78    HookInput,
  79    HookJSONOutput,
  80    NotificationHookInput,
  81    PostToolUseHookInput,
  82    PostToolUseFailureHookInput,
  83    PermissionDeniedHookInput,
  84    PreCompactHookInput,
  85    PostCompactHookInput,
  86    PreToolUseHookInput,
  87    SessionStartHookInput,
  88    SessionEndHookInput,
  89    SetupHookInput,
  90    StopHookInput,
  91    StopFailureHookInput,
  92    SubagentStartHookInput,
  93    SubagentStopHookInput,
  94    TeammateIdleHookInput,
  95    TaskCreatedHookInput,
  96    TaskCompletedHookInput,
  97    ConfigChangeHookInput,
  98    CwdChangedHookInput,
  99    FileChangedHookInput,
 100    InstructionsLoadedHookInput,
 101    UserPromptSubmitHookInput,
 102    PermissionRequestHookInput,
 103    ElicitationHookInput,
 104    ElicitationResultHookInput,
 105    PermissionUpdate,
 106    ExitReason,
 107    SyncHookJSONOutput,
 108    AsyncHookJSONOutput,
 109  } from 'src/entrypoints/agentSdkTypes.js'
 110  import type { StatusLineCommandInput } from '../types/statusLine.js'
 111  import type { ElicitResult } from '@modelcontextprotocol/sdk/types.js'
 112  import type { FileSuggestionCommandInput } from '../types/fileSuggestion.js'
 113  import type { HookResultMessage } from 'src/types/message.js'
 114  import chalk from 'chalk'
 115  import type {
 116    HookMatcher,
 117    HookCommand,
 118    PluginHookMatcher,
 119    SkillHookMatcher,
 120  } from './settings/types.js'
 121  import { getHookDisplayText } from './hooks/hooksSettings.js'
 122  import { logForDebugging } from './debug.js'
 123  import { logForDiagnosticsNoPII } from './diagLogs.js'
 124  import { firstLineOf } from './stringUtils.js'
 125  import {
 126    normalizeLegacyToolName,
 127    getLegacyToolNames,
 128    permissionRuleValueFromString,
 129  } from './permissions/permissionRuleParser.js'
 130  import { logError } from './log.js'
 131  import { createCombinedAbortSignal } from './combinedAbortSignal.js'
 132  import type { PermissionResult } from './permissions/PermissionResult.js'
 133  import { registerPendingAsyncHook } from './hooks/AsyncHookRegistry.js'
 134  import { enqueuePendingNotification } from './messageQueueManager.js'
 135  import {
 136    extractTextContent,
 137    getLastAssistantMessage,
 138    wrapInSystemReminder,
 139  } from './messages.js'
 140  import {
 141    emitHookStarted,
 142    emitHookResponse,
 143    startHookProgressInterval,
 144  } from './hooks/hookEvents.js'
 145  import { createAttachmentMessage } from './attachments.js'
 146  import { all } from './generators.js'
 147  import { findToolByName, type Tools, type ToolUseContext } from '../Tool.js'
 148  import { execPromptHook } from './hooks/execPromptHook.js'
 149  import type { Message, AssistantMessage } from '../types/message.js'
 150  import { execAgentHook } from './hooks/execAgentHook.js'
 151  import { execHttpHook } from './hooks/execHttpHook.js'
 152  import type { ShellCommand } from './ShellCommand.js'
 153  import {
 154    getSessionHooks,
 155    getSessionFunctionHooks,
 156    getSessionHookCallback,
 157    clearSessionHooks,
 158    type SessionDerivedHookMatcher,
 159    type FunctionHook,
 160  } from './hooks/sessionHooks.js'
 161  import type { AppState } from '../state/AppState.js'
 162  import { jsonStringify, jsonParse } from './slowOperations.js'
 163  import { isEnvTruthy } from './envUtils.js'
 164  import { errorMessage, getErrnoCode } from './errors.js'
 165  
 166  const TOOL_HOOK_EXECUTION_TIMEOUT_MS = 10 * 60 * 1000
 167  
 168  /**
 169   * SessionEnd hooks run during shutdown/clear and need a much tighter bound
 170   * than TOOL_HOOK_EXECUTION_TIMEOUT_MS. This value is used by callers as both
 171   * the per-hook default timeout AND the overall AbortSignal cap (hooks run in
 172   * parallel, so one value suffices). Overridable via env var for users whose
 173   * teardown scripts need more time.
 174   */
 175  const SESSION_END_HOOK_TIMEOUT_MS_DEFAULT = 1500
 176  export function getSessionEndHookTimeoutMs(): number {
 177    const raw = process.env.CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS
 178    const parsed = raw ? parseInt(raw, 10) : NaN
 179    return Number.isFinite(parsed) && parsed > 0
 180      ? parsed
 181      : SESSION_END_HOOK_TIMEOUT_MS_DEFAULT
 182  }
 183  
 184  function executeInBackground({
 185    processId,
 186    hookId,
 187    shellCommand,
 188    asyncResponse,
 189    hookEvent,
 190    hookName,
 191    command,
 192    asyncRewake,
 193    pluginId,
 194  }: {
 195    processId: string
 196    hookId: string
 197    shellCommand: ShellCommand
 198    asyncResponse: AsyncHookJSONOutput
 199    hookEvent: HookEvent | 'StatusLine' | 'FileSuggestion'
 200    hookName: string
 201    command: string
 202    asyncRewake?: boolean
 203    pluginId?: string
 204  }): boolean {
 205    if (asyncRewake) {
 206      // asyncRewake hooks bypass the registry entirely. On completion, if exit
 207      // code 2 (blocking error), enqueue as a task-notification so it wakes the
 208      // model via useQueueProcessor (idle) or gets injected mid-query via
 209      // queued_command attachments (busy).
 210      //
 211      // NOTE: We deliberately do NOT call shellCommand.background() here, because
 212      // it calls taskOutput.spillToDisk() which breaks in-memory stdout/stderr
 213      // capture (getStderr() returns '' in disk mode). The StreamWrappers stay
 214      // attached and pipe data into the in-memory TaskOutput buffers. The abort
 215      // handler already no-ops on 'interrupt' reason (user submitted a new
 216      // message), so the hook survives new prompts. A hard cancel (Escape) WILL
 217      // kill the hook via the abort handler, which is the desired behavior.
 218      void shellCommand.result.then(async result => {
 219        // result resolves on 'exit', but stdio 'data' events may still be
 220        // pending. Yield to I/O so the StreamWrapper data handlers drain into
 221        // TaskOutput before we read it.
 222        await new Promise(resolve => setImmediate(resolve))
 223        const stdout = await shellCommand.taskOutput.getStdout()
 224        const stderr = shellCommand.taskOutput.getStderr()
 225        shellCommand.cleanup()
 226        emitHookResponse({
 227          hookId,
 228          hookName,
 229          hookEvent,
 230          output: stdout + stderr,
 231          stdout,
 232          stderr,
 233          exitCode: result.code,
 234          outcome: result.code === 0 ? 'success' : 'error',
 235        })
 236        if (result.code === 2) {
 237          enqueuePendingNotification({
 238            value: wrapInSystemReminder(
 239              `Stop hook blocking error from command "${hookName}": ${stderr || stdout}`,
 240            ),
 241            mode: 'task-notification',
 242          })
 243        }
 244      })
 245      return true
 246    }
 247  
 248    // TaskOutput on the ShellCommand accumulates data — no stream listeners needed
 249    if (!shellCommand.background(processId)) {
 250      return false
 251    }
 252  
 253    registerPendingAsyncHook({
 254      processId,
 255      hookId,
 256      asyncResponse,
 257      hookEvent,
 258      hookName,
 259      command,
 260      shellCommand,
 261      pluginId,
 262    })
 263  
 264    return true
 265  }
 266  
 267  /**
 268   * Checks if a hook should be skipped due to lack of workspace trust.
 269   *
 270   * ALL hooks require workspace trust because they execute arbitrary commands from
 271   * .claude/settings.json. This is a defense-in-depth security measure.
 272   *
 273   * Context: Hooks are captured via captureHooksConfigSnapshot() before the trust
 274   * dialog is shown. While most hooks won't execute until after trust is established
 275   * through normal program flow, enforcing trust for ALL hooks prevents:
 276   * - Future bugs where a hook might accidentally execute before trust
 277   * - Any codepath that might trigger hooks before trust dialog
 278   * - Security issues from hook execution in untrusted workspaces
 279   *
 280   * Historical vulnerabilities that prompted this check:
 281   * - SessionEnd hooks executing when user declines trust dialog
 282   * - SubagentStop hooks executing when subagent completes before trust
 283   *
 284   * @returns true if hook should be skipped, false if it should execute
 285   */
 286  export function shouldSkipHookDueToTrust(): boolean {
 287    // In non-interactive mode (SDK), trust is implicit - always execute
 288    const isInteractive = !getIsNonInteractiveSession()
 289    if (!isInteractive) {
 290      return false
 291    }
 292  
 293    // In interactive mode, ALL hooks require trust
 294    const hasTrust = checkHasTrustDialogAccepted()
 295    return !hasTrust
 296  }
 297  
 298  /**
 299   * Creates the base hook input that's common to all hook types
 300   */
 301  export function createBaseHookInput(
 302    permissionMode?: string,
 303    sessionId?: string,
 304    // Typed narrowly (not ToolUseContext) so callers can pass toolUseContext
 305    // directly via structural typing without this function depending on Tool.ts.
 306    agentInfo?: { agentId?: string; agentType?: string },
 307  ): {
 308    session_id: string
 309    transcript_path: string
 310    cwd: string
 311    permission_mode?: string
 312    agent_id?: string
 313    agent_type?: string
 314  } {
 315    const resolvedSessionId = sessionId ?? getSessionId()
 316    // agent_type: subagent's type (from toolUseContext) takes precedence over
 317    // the session's --agent flag. Hooks use agent_id presence to distinguish
 318    // subagent calls from main-thread calls in a --agent session.
 319    const resolvedAgentType = agentInfo?.agentType ?? getMainThreadAgentType()
 320    return {
 321      session_id: resolvedSessionId,
 322      transcript_path: getTranscriptPathForSession(resolvedSessionId),
 323      cwd: getCwd(),
 324      permission_mode: permissionMode,
 325      agent_id: agentInfo?.agentId,
 326      agent_type: resolvedAgentType,
 327    }
 328  }
 329  
 330  export interface HookBlockingError {
 331    blockingError: string
 332    command: string
 333  }
 334  
 335  /** Re-export ElicitResult from MCP SDK as ElicitationResponse for backward compat. */
 336  export type ElicitationResponse = ElicitResult
 337  
 338  export interface HookResult {
 339    message?: HookResultMessage
 340    systemMessage?: string
 341    blockingError?: HookBlockingError
 342    outcome: 'success' | 'blocking' | 'non_blocking_error' | 'cancelled'
 343    preventContinuation?: boolean
 344    stopReason?: string
 345    permissionBehavior?: 'ask' | 'deny' | 'allow' | 'passthrough'
 346    hookPermissionDecisionReason?: string
 347    additionalContext?: string
 348    initialUserMessage?: string
 349    updatedInput?: Record<string, unknown>
 350    updatedMCPToolOutput?: unknown
 351    permissionRequestResult?: PermissionRequestResult
 352    elicitationResponse?: ElicitationResponse
 353    watchPaths?: string[]
 354    elicitationResultResponse?: ElicitationResponse
 355    retry?: boolean
 356    hook: HookCommand | HookCallback | FunctionHook
 357  }
 358  
 359  export type AggregatedHookResult = {
 360    message?: HookResultMessage
 361    blockingError?: HookBlockingError
 362    preventContinuation?: boolean
 363    stopReason?: string
 364    hookPermissionDecisionReason?: string
 365    hookSource?: string
 366    permissionBehavior?: PermissionResult['behavior']
 367    additionalContexts?: string[]
 368    initialUserMessage?: string
 369    updatedInput?: Record<string, unknown>
 370    updatedMCPToolOutput?: unknown
 371    permissionRequestResult?: PermissionRequestResult
 372    watchPaths?: string[]
 373    elicitationResponse?: ElicitationResponse
 374    elicitationResultResponse?: ElicitationResponse
 375    retry?: boolean
 376  }
 377  
 378  /**
 379   * Parse and validate a JSON string against the hook output Zod schema.
 380   * Returns the validated output or formatted validation errors.
 381   */
 382  function validateHookJson(
 383    jsonString: string,
 384  ): { json: HookJSONOutput } | { validationError: string } {
 385    const parsed = jsonParse(jsonString)
 386    const validation = hookJSONOutputSchema().safeParse(parsed)
 387    if (validation.success) {
 388      logForDebugging('Successfully parsed and validated hook JSON output')
 389      return { json: validation.data }
 390    }
 391    const errors = validation.error.issues
 392      .map(err => `  - ${err.path.join('.')}: ${err.message}`)
 393      .join('\n')
 394    return {
 395      validationError: `Hook JSON output validation failed:\n${errors}\n\nThe hook's output was: ${jsonStringify(parsed, null, 2)}`,
 396    }
 397  }
 398  
 399  function parseHookOutput(stdout: string): {
 400    json?: HookJSONOutput
 401    plainText?: string
 402    validationError?: string
 403  } {
 404    const trimmed = stdout.trim()
 405    if (!trimmed.startsWith('{')) {
 406      logForDebugging('Hook output does not start with {, treating as plain text')
 407      return { plainText: stdout }
 408    }
 409  
 410    try {
 411      const result = validateHookJson(trimmed)
 412      if ('json' in result) {
 413        return result
 414      }
 415      // For command hooks, include the schema hint in the error message
 416      const errorMessage = `${result.validationError}\n\nExpected schema:\n${jsonStringify(
 417        {
 418          continue: 'boolean (optional)',
 419          suppressOutput: 'boolean (optional)',
 420          stopReason: 'string (optional)',
 421          decision: '"approve" | "block" (optional)',
 422          reason: 'string (optional)',
 423          systemMessage: 'string (optional)',
 424          permissionDecision: '"allow" | "deny" | "ask" (optional)',
 425          hookSpecificOutput: {
 426            'for PreToolUse': {
 427              hookEventName: '"PreToolUse"',
 428              permissionDecision: '"allow" | "deny" | "ask" (optional)',
 429              permissionDecisionReason: 'string (optional)',
 430              updatedInput: 'object (optional) - Modified tool input to use',
 431            },
 432            'for UserPromptSubmit': {
 433              hookEventName: '"UserPromptSubmit"',
 434              additionalContext: 'string (required)',
 435            },
 436            'for PostToolUse': {
 437              hookEventName: '"PostToolUse"',
 438              additionalContext: 'string (optional)',
 439            },
 440          },
 441        },
 442        null,
 443        2,
 444      )}`
 445      logForDebugging(errorMessage)
 446      return { plainText: stdout, validationError: errorMessage }
 447    } catch (e) {
 448      logForDebugging(`Failed to parse hook output as JSON: ${e}`)
 449      return { plainText: stdout }
 450    }
 451  }
 452  
 453  function parseHttpHookOutput(body: string): {
 454    json?: HookJSONOutput
 455    validationError?: string
 456  } {
 457    const trimmed = body.trim()
 458  
 459    if (trimmed === '') {
 460      const validation = hookJSONOutputSchema().safeParse({})
 461      if (validation.success) {
 462        logForDebugging(
 463          'HTTP hook returned empty body, treating as empty JSON object',
 464        )
 465        return { json: validation.data }
 466      }
 467    }
 468  
 469    if (!trimmed.startsWith('{')) {
 470      const validationError = `HTTP hook must return JSON, but got non-JSON response body: ${trimmed.length > 200 ? trimmed.slice(0, 200) + '\u2026' : trimmed}`
 471      logForDebugging(validationError)
 472      return { validationError }
 473    }
 474  
 475    try {
 476      const result = validateHookJson(trimmed)
 477      if ('json' in result) {
 478        return result
 479      }
 480      logForDebugging(result.validationError)
 481      return result
 482    } catch (e) {
 483      const validationError = `HTTP hook must return valid JSON, but parsing failed: ${e}`
 484      logForDebugging(validationError)
 485      return { validationError }
 486    }
 487  }
 488  
 489  function processHookJSONOutput({
 490    json,
 491    command,
 492    hookName,
 493    toolUseID,
 494    hookEvent,
 495    expectedHookEvent,
 496    stdout,
 497    stderr,
 498    exitCode,
 499    durationMs,
 500  }: {
 501    json: SyncHookJSONOutput
 502    command: string
 503    hookName: string
 504    toolUseID: string
 505    hookEvent: HookEvent
 506    expectedHookEvent?: HookEvent
 507    stdout?: string
 508    stderr?: string
 509    exitCode?: number
 510    durationMs?: number
 511  }): Partial<HookResult> {
 512    const result: Partial<HookResult> = {}
 513  
 514    // At this point we know it's a sync response
 515    const syncJson = json
 516  
 517    // Handle common elements
 518    if (syncJson.continue === false) {
 519      result.preventContinuation = true
 520      if (syncJson.stopReason) {
 521        result.stopReason = syncJson.stopReason
 522      }
 523    }
 524  
 525    if (json.decision) {
 526      switch (json.decision) {
 527        case 'approve':
 528          result.permissionBehavior = 'allow'
 529          break
 530        case 'block':
 531          result.permissionBehavior = 'deny'
 532          result.blockingError = {
 533            blockingError: json.reason || 'Blocked by hook',
 534            command,
 535          }
 536          break
 537        default:
 538          // Handle unknown decision types as errors
 539          throw new Error(
 540            `Unknown hook decision type: ${json.decision}. Valid types are: approve, block`,
 541          )
 542      }
 543    }
 544  
 545    // Handle systemMessage field
 546    if (json.systemMessage) {
 547      result.systemMessage = json.systemMessage
 548    }
 549  
 550    // Handle PreToolUse specific
 551    if (
 552      json.hookSpecificOutput?.hookEventName === 'PreToolUse' &&
 553      json.hookSpecificOutput.permissionDecision
 554    ) {
 555      switch (json.hookSpecificOutput.permissionDecision) {
 556        case 'allow':
 557          result.permissionBehavior = 'allow'
 558          break
 559        case 'deny':
 560          result.permissionBehavior = 'deny'
 561          result.blockingError = {
 562            blockingError: json.reason || 'Blocked by hook',
 563            command,
 564          }
 565          break
 566        case 'ask':
 567          result.permissionBehavior = 'ask'
 568          break
 569        default:
 570          // Handle unknown decision types as errors
 571          throw new Error(
 572            `Unknown hook permissionDecision type: ${json.hookSpecificOutput.permissionDecision}. Valid types are: allow, deny, ask`,
 573          )
 574      }
 575    }
 576    if (result.permissionBehavior !== undefined && json.reason !== undefined) {
 577      result.hookPermissionDecisionReason = json.reason
 578    }
 579  
 580    // Handle hookSpecificOutput
 581    if (json.hookSpecificOutput) {
 582      // Validate hook event name matches expected if provided
 583      if (
 584        expectedHookEvent &&
 585        json.hookSpecificOutput.hookEventName !== expectedHookEvent
 586      ) {
 587        throw new Error(
 588          `Hook returned incorrect event name: expected '${expectedHookEvent}' but got '${json.hookSpecificOutput.hookEventName}'. Full stdout: ${jsonStringify(json, null, 2)}`,
 589        )
 590      }
 591  
 592      switch (json.hookSpecificOutput.hookEventName) {
 593        case 'PreToolUse':
 594          // Override with more specific permission decision if provided
 595          if (json.hookSpecificOutput.permissionDecision) {
 596            switch (json.hookSpecificOutput.permissionDecision) {
 597              case 'allow':
 598                result.permissionBehavior = 'allow'
 599                break
 600              case 'deny':
 601                result.permissionBehavior = 'deny'
 602                result.blockingError = {
 603                  blockingError:
 604                    json.hookSpecificOutput.permissionDecisionReason ||
 605                    json.reason ||
 606                    'Blocked by hook',
 607                  command,
 608                }
 609                break
 610              case 'ask':
 611                result.permissionBehavior = 'ask'
 612                break
 613            }
 614          }
 615          result.hookPermissionDecisionReason =
 616            json.hookSpecificOutput.permissionDecisionReason
 617          // Extract updatedInput if provided
 618          if (json.hookSpecificOutput.updatedInput) {
 619            result.updatedInput = json.hookSpecificOutput.updatedInput
 620          }
 621          // Extract additionalContext if provided
 622          result.additionalContext = json.hookSpecificOutput.additionalContext
 623          break
 624        case 'UserPromptSubmit':
 625          result.additionalContext = json.hookSpecificOutput.additionalContext
 626          break
 627        case 'SessionStart':
 628          result.additionalContext = json.hookSpecificOutput.additionalContext
 629          result.initialUserMessage = json.hookSpecificOutput.initialUserMessage
 630          if (
 631            'watchPaths' in json.hookSpecificOutput &&
 632            json.hookSpecificOutput.watchPaths
 633          ) {
 634            result.watchPaths = json.hookSpecificOutput.watchPaths
 635          }
 636          break
 637        case 'Setup':
 638          result.additionalContext = json.hookSpecificOutput.additionalContext
 639          break
 640        case 'SubagentStart':
 641          result.additionalContext = json.hookSpecificOutput.additionalContext
 642          break
 643        case 'PostToolUse':
 644          result.additionalContext = json.hookSpecificOutput.additionalContext
 645          // Extract updatedMCPToolOutput if provided
 646          if (json.hookSpecificOutput.updatedMCPToolOutput) {
 647            result.updatedMCPToolOutput =
 648              json.hookSpecificOutput.updatedMCPToolOutput
 649          }
 650          break
 651        case 'PostToolUseFailure':
 652          result.additionalContext = json.hookSpecificOutput.additionalContext
 653          break
 654        case 'PermissionDenied':
 655          result.retry = json.hookSpecificOutput.retry
 656          break
 657        case 'PermissionRequest':
 658          // Extract the permission request decision
 659          if (json.hookSpecificOutput.decision) {
 660            result.permissionRequestResult = json.hookSpecificOutput.decision
 661            // Also update permissionBehavior for consistency
 662            result.permissionBehavior =
 663              json.hookSpecificOutput.decision.behavior === 'allow'
 664                ? 'allow'
 665                : 'deny'
 666            if (
 667              json.hookSpecificOutput.decision.behavior === 'allow' &&
 668              json.hookSpecificOutput.decision.updatedInput
 669            ) {
 670              result.updatedInput = json.hookSpecificOutput.decision.updatedInput
 671            }
 672          }
 673          break
 674        case 'Elicitation':
 675          if (json.hookSpecificOutput.action) {
 676            result.elicitationResponse = {
 677              action: json.hookSpecificOutput.action,
 678              content: json.hookSpecificOutput.content as
 679                | ElicitationResponse['content']
 680                | undefined,
 681            }
 682            if (json.hookSpecificOutput.action === 'decline') {
 683              result.blockingError = {
 684                blockingError: json.reason || 'Elicitation denied by hook',
 685                command,
 686              }
 687            }
 688          }
 689          break
 690        case 'ElicitationResult':
 691          if (json.hookSpecificOutput.action) {
 692            result.elicitationResultResponse = {
 693              action: json.hookSpecificOutput.action,
 694              content: json.hookSpecificOutput.content as
 695                | ElicitationResponse['content']
 696                | undefined,
 697            }
 698            if (json.hookSpecificOutput.action === 'decline') {
 699              result.blockingError = {
 700                blockingError:
 701                  json.reason || 'Elicitation result blocked by hook',
 702                command,
 703              }
 704            }
 705          }
 706          break
 707      }
 708    }
 709  
 710    return {
 711      ...result,
 712      message: result.blockingError
 713        ? createAttachmentMessage({
 714            type: 'hook_blocking_error',
 715            hookName,
 716            toolUseID,
 717            hookEvent,
 718            blockingError: result.blockingError,
 719          })
 720        : createAttachmentMessage({
 721            type: 'hook_success',
 722            hookName,
 723            toolUseID,
 724            hookEvent,
 725            // JSON-output hooks inject context via additionalContext →
 726            // hook_additional_context, not this field. Empty content suppresses
 727            // the trivial "X hook success: Success" system-reminder that
 728            // otherwise pollutes every turn (messages.ts:3577 skips on '').
 729            content: '',
 730            stdout,
 731            stderr,
 732            exitCode,
 733            command,
 734            durationMs,
 735          }),
 736    }
 737  }
 738  
 739  /**
 740   * Execute a command-based hook using bash or PowerShell.
 741   *
 742   * Shell resolution: hook.shell → 'bash'. PowerShell hooks spawn pwsh
 743   * with -NoProfile -NonInteractive -Command and skip bash-specific prep
 744   * (POSIX path conversion, .sh auto-prepend, CLAUDE_CODE_SHELL_PREFIX).
 745   * See docs/design/ps-shell-selection.md §5.1.
 746   */
 747  async function execCommandHook(
 748    hook: HookCommand & { type: 'command' },
 749    hookEvent: HookEvent | 'StatusLine' | 'FileSuggestion',
 750    hookName: string,
 751    jsonInput: string,
 752    signal: AbortSignal,
 753    hookId: string,
 754    hookIndex?: number,
 755    pluginRoot?: string,
 756    pluginId?: string,
 757    skillRoot?: string,
 758    forceSyncExecution?: boolean,
 759    requestPrompt?: (request: PromptRequest) => Promise<PromptResponse>,
 760  ): Promise<{
 761    stdout: string
 762    stderr: string
 763    output: string
 764    status: number
 765    aborted?: boolean
 766    backgrounded?: boolean
 767  }> {
 768    // Gated to once-per-session events to keep diag_log volume bounded.
 769    // started/completed live inside the try/finally so setup-path throws
 770    // don't orphan a started marker — that'd be indistinguishable from a hang.
 771    const shouldEmitDiag =
 772      hookEvent === 'SessionStart' ||
 773      hookEvent === 'Setup' ||
 774      hookEvent === 'SessionEnd'
 775    const diagStartMs = Date.now()
 776    let diagExitCode: number | undefined
 777    let diagAborted = false
 778  
 779    const isWindows = getPlatform() === 'windows'
 780  
 781    // --
 782    // Per-hook shell selection (phase 1 of docs/design/ps-shell-selection.md).
 783    // Resolution order: hook.shell → DEFAULT_HOOK_SHELL. The defaultShell
 784    // fallback (settings.defaultShell) is phase 2 — not wired yet.
 785    //
 786    // The bash path is the historical default and stays unchanged. The
 787    // PowerShell path deliberately skips the Windows-specific bash
 788    // accommodations (cygpath conversion, .sh auto-prepend, POSIX-quoted
 789    // SHELL_PREFIX).
 790    const shellType = hook.shell ?? DEFAULT_HOOK_SHELL
 791  
 792    const isPowerShell = shellType === 'powershell'
 793  
 794    // --
 795    // Windows bash path: hooks run via Git Bash (Cygwin), NOT cmd.exe.
 796    //
 797    // This means every path we put into env vars or substitute into the command
 798    // string MUST be a POSIX path (/c/Users/foo), not a Windows path
 799    // (C:\Users\foo or C:/Users/foo). Git Bash cannot resolve Windows paths.
 800    //
 801    // windowsPathToPosixPath() is pure-JS regex conversion (no cygpath shell-out):
 802    // C:\Users\foo -> /c/Users/foo, UNC preserved, slashes flipped. Memoized
 803    // (LRU-500) so repeated calls are cheap.
 804    //
 805    // PowerShell path: use native paths — skip the conversion entirely.
 806    // PowerShell expects Windows paths on Windows (and native paths on
 807    // Unix where pwsh is also available).
 808    const toHookPath =
 809      isWindows && !isPowerShell
 810        ? (p: string) => windowsPathToPosixPath(p)
 811        : (p: string) => p
 812  
 813    // Set CLAUDE_PROJECT_DIR to the stable project root (not the worktree path).
 814    // getProjectRoot() is never updated when entering a worktree, so hooks that
 815    // reference $CLAUDE_PROJECT_DIR always resolve relative to the real repo root.
 816    const projectDir = getProjectRoot()
 817  
 818    // Substitute ${CLAUDE_PLUGIN_ROOT} and ${user_config.X} in the command string.
 819    // Order matches MCP/LSP (plugin vars FIRST, then user config) so a user-
 820    // entered value containing the literal text ${CLAUDE_PLUGIN_ROOT} is treated
 821    // as opaque — not re-interpreted as a template.
 822    let command = hook.command
 823    let pluginOpts: ReturnType<typeof loadPluginOptions> | undefined
 824    if (pluginRoot) {
 825      // Plugin directory gone (orphan GC race, concurrent session deleted it):
 826      // throw so callers yield a non-blocking error. Running would fail — and
 827      // `python3 <missing>.py` exits 2, the hook protocol's "block" code, which
 828      // bricks UserPromptSubmit/Stop until restart. The pre-check is necessary
 829      // because exit-2-from-missing-script is indistinguishable from an
 830      // intentional block after spawn.
 831      if (!(await pathExists(pluginRoot))) {
 832        throw new Error(
 833          `Plugin directory does not exist: ${pluginRoot}` +
 834            (pluginId ? ` (${pluginId} — run /plugin to reinstall)` : ''),
 835        )
 836      }
 837      // Inline both ROOT and DATA substitution instead of calling
 838      // substitutePluginVariables(). That helper normalizes \ → / on Windows
 839      // unconditionally — correct for bash (toHookPath already produced /c/...
 840      // so it's a no-op) but wrong for PS where toHookPath is identity and we
 841      // want native C:\... backslashes. Inlining also lets us use the function-
 842      // form .replace() so paths containing $ aren't mangled by $-pattern
 843      // interpretation (rare but possible: \\server\c$\plugin).
 844      const rootPath = toHookPath(pluginRoot)
 845      command = command.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, () => rootPath)
 846      if (pluginId) {
 847        const dataPath = toHookPath(getPluginDataDir(pluginId))
 848        command = command.replace(/\$\{CLAUDE_PLUGIN_DATA\}/g, () => dataPath)
 849      }
 850      if (pluginId) {
 851        pluginOpts = loadPluginOptions(pluginId)
 852        // Throws if a referenced key is missing — that means the hook uses a key
 853        // that's either not declared in manifest.userConfig or not yet configured.
 854        // Caught upstream like any other hook exec failure.
 855        command = substituteUserConfigVariables(command, pluginOpts)
 856      }
 857    }
 858  
 859    // On Windows (bash only), auto-prepend `bash` for .sh scripts so they
 860    // execute instead of opening in the default file handler. PowerShell
 861    // runs .ps1 files natively — no prepend needed.
 862    if (isWindows && !isPowerShell && command.trim().match(/\.sh(\s|$|")/)) {
 863      if (!command.trim().startsWith('bash ')) {
 864        command = `bash ${command}`
 865      }
 866    }
 867  
 868    // CLAUDE_CODE_SHELL_PREFIX wraps the command via POSIX quoting
 869    // (formatShellPrefixCommand uses shell-quote). This makes no sense for
 870    // PowerShell — see design §8.1. For now PS hooks ignore the prefix;
 871    // a CLAUDE_CODE_PS_SHELL_PREFIX (or shell-aware prefix) is a follow-up.
 872    const finalCommand =
 873      !isPowerShell && process.env.CLAUDE_CODE_SHELL_PREFIX
 874        ? formatShellPrefixCommand(process.env.CLAUDE_CODE_SHELL_PREFIX, command)
 875        : command
 876  
 877    const hookTimeoutMs = hook.timeout
 878      ? hook.timeout * 1000
 879      : TOOL_HOOK_EXECUTION_TIMEOUT_MS
 880  
 881    // Build env vars — all paths go through toHookPath for Windows POSIX conversion
 882    const envVars: NodeJS.ProcessEnv = {
 883      ...subprocessEnv(),
 884      CLAUDE_PROJECT_DIR: toHookPath(projectDir),
 885    }
 886  
 887    // Plugin and skill hooks both set CLAUDE_PLUGIN_ROOT (skills use the same
 888    // name for consistency — skills can migrate to plugins without code changes)
 889    if (pluginRoot) {
 890      envVars.CLAUDE_PLUGIN_ROOT = toHookPath(pluginRoot)
 891      if (pluginId) {
 892        envVars.CLAUDE_PLUGIN_DATA = toHookPath(getPluginDataDir(pluginId))
 893      }
 894    }
 895    // Expose plugin options as env vars too, so hooks can read them without
 896    // ${user_config.X} in the command string. Sensitive values included — hooks
 897    // run the user's own code, same trust boundary as reading keychain directly.
 898    if (pluginOpts) {
 899      for (const [key, value] of Object.entries(pluginOpts)) {
 900        // Sanitize non-identifier chars (bash can't ref $FOO-BAR). The schema
 901        // at schemas.ts:611 now constrains keys to /^[A-Za-z_]\w*$/ so this is
 902        // belt-and-suspenders, but cheap insurance if someone bypasses the schema.
 903        const envKey = key.replace(/[^A-Za-z0-9_]/g, '_').toUpperCase()
 904        envVars[`CLAUDE_PLUGIN_OPTION_${envKey}`] = String(value)
 905      }
 906    }
 907    if (skillRoot) {
 908      envVars.CLAUDE_PLUGIN_ROOT = toHookPath(skillRoot)
 909    }
 910  
 911    // CLAUDE_ENV_FILE points to a .sh file that the hook writes env var
 912    // definitions into; getSessionEnvironmentScript() concatenates them and
 913    // bashProvider injects the content into bash commands. A PS hook would
 914    // naturally write PS syntax ($env:FOO = 'bar'), which bash can't parse.
 915    // Skip for PS — consistent with how .sh prepend and SHELL_PREFIX are
 916    // already bash-only above.
 917    if (
 918      !isPowerShell &&
 919      (hookEvent === 'SessionStart' ||
 920        hookEvent === 'Setup' ||
 921        hookEvent === 'CwdChanged' ||
 922        hookEvent === 'FileChanged') &&
 923      hookIndex !== undefined
 924    ) {
 925      envVars.CLAUDE_ENV_FILE = await getHookEnvFilePath(hookEvent, hookIndex)
 926    }
 927  
 928    // When agent worktrees are removed, getCwd() may return a deleted path via
 929    // AsyncLocalStorage. Validate before spawning since spawn() emits async
 930    // 'error' events for missing cwd rather than throwing synchronously.
 931    const hookCwd = getCwd()
 932    const safeCwd = (await pathExists(hookCwd)) ? hookCwd : getOriginalCwd()
 933    if (safeCwd !== hookCwd) {
 934      logForDebugging(
 935        `Hooks: cwd ${hookCwd} not found, falling back to original cwd`,
 936        { level: 'warn' },
 937      )
 938    }
 939  
 940    // --
 941    // Spawn. Two completely separate paths:
 942    //
 943    //   Bash: spawn(cmd, [], { shell: <gitBashPath | true> }) — the shell
 944    //   option makes Node pass the whole string to the shell for parsing.
 945    //
 946    //   PowerShell: spawn(pwshPath, ['-NoProfile', '-NonInteractive',
 947    //   '-Command', cmd]) — explicit argv, no shell option. -NoProfile
 948    //   skips user profile scripts (faster, deterministic).
 949    //   -NonInteractive fails fast instead of prompting.
 950    //
 951    // The Git Bash hard-exit in findGitBashPath() is still in place for
 952    // bash hooks. PowerShell hooks never call it, so a Windows user with
 953    // only pwsh and shell: 'powershell' on every hook could in theory run
 954    // without Git Bash — but init.ts still calls setShellIfWindows() on
 955    // startup, which will exit first. Relaxing that is phase 1 of the
 956    // design's implementation order (separate PR).
 957    let child: ChildProcessWithoutNullStreams
 958    if (shellType === 'powershell') {
 959      const pwshPath = await getCachedPowerShellPath()
 960      if (!pwshPath) {
 961        throw new Error(
 962          `Hook "${hook.command}" has shell: 'powershell' but no PowerShell ` +
 963            `executable (pwsh or powershell) was found on PATH. Install ` +
 964            `PowerShell, or remove "shell": "powershell" to use bash.`,
 965        )
 966      }
 967      child = spawn(pwshPath, buildPowerShellArgs(finalCommand), {
 968        env: envVars,
 969        cwd: safeCwd,
 970        // Prevent visible console window on Windows (no-op on other platforms)
 971        windowsHide: true,
 972      }) as ChildProcessWithoutNullStreams
 973    } else {
 974      // On Windows, use Git Bash explicitly (cmd.exe can't run bash syntax).
 975      // On other platforms, shell: true uses /bin/sh.
 976      const shell = isWindows ? findGitBashPath() : true
 977      child = spawn(finalCommand, [], {
 978        env: envVars,
 979        cwd: safeCwd,
 980        shell,
 981        // Prevent visible console window on Windows (no-op on other platforms)
 982        windowsHide: true,
 983      }) as ChildProcessWithoutNullStreams
 984    }
 985  
 986    // Hooks use pipe mode — stdout must be streamed into JS so we can parse
 987    // the first response line to detect async hooks ({"async": true}).
 988    const hookTaskOutput = new TaskOutput(`hook_${child.pid}`, null)
 989    const shellCommand = wrapSpawn(child, signal, hookTimeoutMs, hookTaskOutput)
 990    // Track whether shellCommand ownership was transferred (e.g., to async hook registry)
 991    let shellCommandTransferred = false
 992    // Track whether stdin has already been written (to avoid "write after end" errors)
 993    let stdinWritten = false
 994  
 995    if ((hook.async || hook.asyncRewake) && !forceSyncExecution) {
 996      const processId = `async_hook_${child.pid}`
 997      logForDebugging(
 998        `Hooks: Config-based async hook, backgrounding process ${processId}`,
 999      )
1000  
1001      // Write stdin before backgrounding so the hook receives its input.
1002      // The trailing newline matches the sync path (L1000). Without it,
1003      // bash `read -r line` returns exit 1 (EOF before delimiter) — the
1004      // variable IS populated but `if read -r line; then ...` skips the
1005      // branch. See gh-30509 / CC-161.
1006      child.stdin.write(jsonInput + '\n', 'utf8')
1007      child.stdin.end()
1008      stdinWritten = true
1009  
1010      const backgrounded = executeInBackground({
1011        processId,
1012        hookId,
1013        shellCommand,
1014        asyncResponse: { async: true, asyncTimeout: hookTimeoutMs },
1015        hookEvent,
1016        hookName,
1017        command: hook.command,
1018        asyncRewake: hook.asyncRewake,
1019        pluginId,
1020      })
1021      if (backgrounded) {
1022        return {
1023          stdout: '',
1024          stderr: '',
1025          output: '',
1026          status: 0,
1027          backgrounded: true,
1028        }
1029      }
1030    }
1031  
1032    let stdout = ''
1033    let stderr = ''
1034    let output = ''
1035  
1036    // Set up output data collection with explicit UTF-8 encoding
1037    child.stdout.setEncoding('utf8')
1038    child.stderr.setEncoding('utf8')
1039  
1040    let initialResponseChecked = false
1041  
1042    let asyncResolve:
1043      | ((result: {
1044          stdout: string
1045          stderr: string
1046          output: string
1047          status: number
1048        }) => void)
1049      | null = null
1050    const childIsAsyncPromise = new Promise<{
1051      stdout: string
1052      stderr: string
1053      output: string
1054      status: number
1055      aborted?: boolean
1056    }>(resolve => {
1057      asyncResolve = resolve
1058    })
1059  
1060    // Track trimmed prompt-request lines we processed so we can strip them
1061    // from final stdout by content match (no index tracking → no index drift)
1062    const processedPromptLines = new Set<string>()
1063    // Serialize async prompt handling so responses are sent in order
1064    let promptChain = Promise.resolve()
1065    // Line buffer for detecting prompt requests in streaming output
1066    let lineBuffer = ''
1067  
1068    child.stdout.on('data', data => {
1069      stdout += data
1070      output += data
1071  
1072      // When requestPrompt is provided, parse stdout line-by-line for prompt requests
1073      if (requestPrompt) {
1074        lineBuffer += data
1075        const lines = lineBuffer.split('\n')
1076        lineBuffer = lines.pop() ?? '' // last element is an incomplete line
1077  
1078        for (const line of lines) {
1079          const trimmed = line.trim()
1080          if (!trimmed) continue
1081  
1082          try {
1083            const parsed = jsonParse(trimmed)
1084            const validation = promptRequestSchema().safeParse(parsed)
1085            if (validation.success) {
1086              processedPromptLines.add(trimmed)
1087              logForDebugging(
1088                `Hooks: Detected prompt request from hook: ${trimmed}`,
1089              )
1090              // Chain the async handling to serialize prompt responses
1091              const promptReq = validation.data
1092              const reqPrompt = requestPrompt
1093              promptChain = promptChain.then(async () => {
1094                try {
1095                  const response = await reqPrompt(promptReq)
1096                  child.stdin.write(jsonStringify(response) + '\n', 'utf8')
1097                } catch (err) {
1098                  logForDebugging(`Hooks: Prompt request handling failed: ${err}`)
1099                  // User cancelled or prompt failed — close stdin so the hook
1100                  // process doesn't hang waiting for input
1101                  child.stdin.destroy()
1102                }
1103              })
1104              continue
1105            }
1106          } catch {
1107            // Not JSON, just a normal line
1108          }
1109        }
1110      }
1111  
1112      // Check for async response on first line of output. The async protocol is:
1113      // hook emits {"async":true,...} as its FIRST line, then its normal output.
1114      // We must parse ONLY the first line — if the process is fast and writes more
1115      // before this 'data' event fires, parsing the full accumulated stdout fails
1116      // and an async hook blocks for its full duration instead of backgrounding.
1117      if (!initialResponseChecked) {
1118        const firstLine = firstLineOf(stdout).trim()
1119        if (!firstLine.includes('}')) return
1120        initialResponseChecked = true
1121        logForDebugging(`Hooks: Checking first line for async: ${firstLine}`)
1122        try {
1123          const parsed = jsonParse(firstLine)
1124          logForDebugging(
1125            `Hooks: Parsed initial response: ${jsonStringify(parsed)}`,
1126          )
1127          if (isAsyncHookJSONOutput(parsed) && !forceSyncExecution) {
1128            const processId = `async_hook_${child.pid}`
1129            logForDebugging(
1130              `Hooks: Detected async hook, backgrounding process ${processId}`,
1131            )
1132  
1133            const backgrounded = executeInBackground({
1134              processId,
1135              hookId,
1136              shellCommand,
1137              asyncResponse: parsed,
1138              hookEvent,
1139              hookName,
1140              command: hook.command,
1141              pluginId,
1142            })
1143            if (backgrounded) {
1144              shellCommandTransferred = true
1145              asyncResolve?.({
1146                stdout,
1147                stderr,
1148                output,
1149                status: 0,
1150              })
1151            }
1152          } else if (isAsyncHookJSONOutput(parsed) && forceSyncExecution) {
1153            logForDebugging(
1154              `Hooks: Detected async hook but forceSyncExecution is true, waiting for completion`,
1155            )
1156          } else {
1157            logForDebugging(
1158              `Hooks: Initial response is not async, continuing normal processing`,
1159            )
1160          }
1161        } catch (e) {
1162          logForDebugging(`Hooks: Failed to parse initial response as JSON: ${e}`)
1163        }
1164      }
1165    })
1166  
1167    child.stderr.on('data', data => {
1168      stderr += data
1169      output += data
1170    })
1171  
1172    const stopProgressInterval = startHookProgressInterval({
1173      hookId,
1174      hookName,
1175      hookEvent,
1176      getOutput: async () => ({ stdout, stderr, output }),
1177    })
1178  
1179    // Wait for stdout and stderr streams to finish before considering output complete
1180    // This prevents a race condition where 'close' fires before all 'data' events are processed
1181    const stdoutEndPromise = new Promise<void>(resolve => {
1182      child.stdout.on('end', () => resolve())
1183    })
1184  
1185    const stderrEndPromise = new Promise<void>(resolve => {
1186      child.stderr.on('end', () => resolve())
1187    })
1188  
1189    // Write to stdin, making sure to handle EPIPE errors that can happen when
1190    // the hook command exits before reading all input.
1191    // Note: EPIPE handling is difficult to set up in testing since Bun and Node
1192    // have different behaviors.
1193    // TODO: Add tests for EPIPE handling.
1194    // Skip if stdin was already written (e.g., by config-based async hook path)
1195    const stdinWritePromise = stdinWritten
1196      ? Promise.resolve()
1197      : new Promise<void>((resolve, reject) => {
1198          child.stdin.on('error', err => {
1199            // When requestPrompt is provided, stdin stays open for prompt responses.
1200            // EPIPE errors from later writes (after process exits) are expected -- suppress them.
1201            if (!requestPrompt) {
1202              reject(err)
1203            } else {
1204              logForDebugging(
1205                `Hooks: stdin error during prompt flow (likely process exited): ${err}`,
1206              )
1207            }
1208          })
1209          // Explicitly specify UTF-8 encoding to ensure proper handling of Unicode characters
1210          child.stdin.write(jsonInput + '\n', 'utf8')
1211          // When requestPrompt is provided, keep stdin open for prompt responses
1212          if (!requestPrompt) {
1213            child.stdin.end()
1214          }
1215          resolve()
1216        })
1217  
1218    // Create promise for child process error
1219    const childErrorPromise = new Promise<never>((_, reject) => {
1220      child.on('error', reject)
1221    })
1222  
1223    // Create promise for child process close - but only resolve after streams end
1224    // to ensure all output has been collected
1225    const childClosePromise = new Promise<{
1226      stdout: string
1227      stderr: string
1228      output: string
1229      status: number
1230      aborted?: boolean
1231    }>(resolve => {
1232      let exitCode: number | null = null
1233  
1234      child.on('close', code => {
1235        exitCode = code ?? 1
1236  
1237        // Wait for both streams to end before resolving with the final output
1238        void Promise.all([stdoutEndPromise, stderrEndPromise]).then(() => {
1239          // Strip lines we processed as prompt requests so parseHookOutput
1240          // only sees the final hook result. Content-matching against the set
1241          // of actually-processed lines means prompt JSON can never leak
1242          // through (fail-closed), regardless of line positioning.
1243          const finalStdout =
1244            processedPromptLines.size === 0
1245              ? stdout
1246              : stdout
1247                  .split('\n')
1248                  .filter(line => !processedPromptLines.has(line.trim()))
1249                  .join('\n')
1250  
1251          resolve({
1252            stdout: finalStdout,
1253            stderr,
1254            output,
1255            status: exitCode!,
1256            aborted: signal.aborted,
1257          })
1258        })
1259      })
1260    })
1261  
1262    // Race between stdin write, async detection, and process completion
1263    try {
1264      if (shouldEmitDiag) {
1265        logForDiagnosticsNoPII('info', 'hook_spawn_started', {
1266          hook_event_name: hookEvent,
1267          index: hookIndex,
1268        })
1269      }
1270      await Promise.race([stdinWritePromise, childErrorPromise])
1271  
1272      // Wait for any pending prompt responses before resolving
1273      const result = await Promise.race([
1274        childIsAsyncPromise,
1275        childClosePromise,
1276        childErrorPromise,
1277      ])
1278      // Ensure all queued prompt responses have been sent
1279      await promptChain
1280      diagExitCode = result.status
1281      diagAborted = result.aborted ?? false
1282      return result
1283    } catch (error) {
1284      // Handle errors from stdin write or child process
1285      const code = getErrnoCode(error)
1286      diagExitCode = 1
1287  
1288      if (code === 'EPIPE') {
1289        logForDebugging(
1290          'EPIPE error while writing to hook stdin (hook command likely closed early)',
1291        )
1292        const errMsg =
1293          'Hook command closed stdin before hook input was fully written (EPIPE)'
1294        return {
1295          stdout: '',
1296          stderr: errMsg,
1297          output: errMsg,
1298          status: 1,
1299        }
1300      } else if (code === 'ABORT_ERR') {
1301        diagAborted = true
1302        return {
1303          stdout: '',
1304          stderr: 'Hook cancelled',
1305          output: 'Hook cancelled',
1306          status: 1,
1307          aborted: true,
1308        }
1309      } else {
1310        const errorMsg = errorMessage(error)
1311        const errOutput = `Error occurred while executing hook command: ${errorMsg}`
1312        return {
1313          stdout: '',
1314          stderr: errOutput,
1315          output: errOutput,
1316          status: 1,
1317        }
1318      }
1319    } finally {
1320      if (shouldEmitDiag) {
1321        logForDiagnosticsNoPII('info', 'hook_spawn_completed', {
1322          hook_event_name: hookEvent,
1323          index: hookIndex,
1324          duration_ms: Date.now() - diagStartMs,
1325          exit_code: diagExitCode,
1326          aborted: diagAborted,
1327        })
1328      }
1329      stopProgressInterval()
1330      // Clean up stream resources unless ownership was transferred (e.g., to async hook registry)
1331      if (!shellCommandTransferred) {
1332        shellCommand.cleanup()
1333      }
1334    }
1335  }
1336  
1337  /**
1338   * Check if a match query matches a hook matcher pattern
1339   * @param matchQuery The query to match (e.g., 'Write', 'Edit', 'Bash')
1340   * @param matcher The matcher pattern - can be:
1341   *   - Simple string for exact match (e.g., 'Write')
1342   *   - Pipe-separated list for multiple exact matches (e.g., 'Write|Edit')
1343   *   - Regex pattern (e.g., '^Write.*', '.*', '^(Write|Edit)$')
1344   * @returns true if the query matches the pattern
1345   */
1346  function matchesPattern(matchQuery: string, matcher: string): boolean {
1347    if (!matcher || matcher === '*') {
1348      return true
1349    }
1350    // Check if it's a simple string or pipe-separated list (no regex special chars except |)
1351    if (/^[a-zA-Z0-9_|]+$/.test(matcher)) {
1352      // Handle pipe-separated exact matches
1353      if (matcher.includes('|')) {
1354        const patterns = matcher
1355          .split('|')
1356          .map(p => normalizeLegacyToolName(p.trim()))
1357        return patterns.includes(matchQuery)
1358      }
1359      // Simple exact match
1360      return matchQuery === normalizeLegacyToolName(matcher)
1361    }
1362  
1363    // Otherwise treat as regex
1364    try {
1365      const regex = new RegExp(matcher)
1366      if (regex.test(matchQuery)) {
1367        return true
1368      }
1369      // Also test against legacy names so patterns like "^Task$" still match
1370      for (const legacyName of getLegacyToolNames(matchQuery)) {
1371        if (regex.test(legacyName)) {
1372          return true
1373        }
1374      }
1375      return false
1376    } catch {
1377      // If the regex is invalid, log error and return false
1378      logForDebugging(`Invalid regex pattern in hook matcher: ${matcher}`)
1379      return false
1380    }
1381  }
1382  
1383  type IfConditionMatcher = (ifCondition: string) => boolean
1384  
1385  /**
1386   * Prepare a matcher for hook `if` conditions. Expensive work (tool lookup,
1387   * Zod validation, tree-sitter parsing for Bash) happens once here; the
1388   * returned closure is called per hook. Returns undefined for non-tool events.
1389   */
1390  async function prepareIfConditionMatcher(
1391    hookInput: HookInput,
1392    tools: Tools | undefined,
1393  ): Promise<IfConditionMatcher | undefined> {
1394    if (
1395      hookInput.hook_event_name !== 'PreToolUse' &&
1396      hookInput.hook_event_name !== 'PostToolUse' &&
1397      hookInput.hook_event_name !== 'PostToolUseFailure' &&
1398      hookInput.hook_event_name !== 'PermissionRequest'
1399    ) {
1400      return undefined
1401    }
1402  
1403    const toolName = normalizeLegacyToolName(hookInput.tool_name)
1404    const tool = tools && findToolByName(tools, hookInput.tool_name)
1405    const input = tool?.inputSchema.safeParse(hookInput.tool_input)
1406    const patternMatcher =
1407      input?.success && tool?.preparePermissionMatcher
1408        ? await tool.preparePermissionMatcher(input.data)
1409        : undefined
1410  
1411    return ifCondition => {
1412      const parsed = permissionRuleValueFromString(ifCondition)
1413      if (normalizeLegacyToolName(parsed.toolName) !== toolName) {
1414        return false
1415      }
1416      if (!parsed.ruleContent) {
1417        return true
1418      }
1419      return patternMatcher ? patternMatcher(parsed.ruleContent) : false
1420    }
1421  }
1422  
1423  type FunctionHookMatcher = {
1424    matcher: string
1425    hooks: FunctionHook[]
1426  }
1427  
1428  /**
1429   * A hook paired with optional plugin context.
1430   * Used when returning matched hooks so we can apply plugin env vars at execution time.
1431   */
1432  type MatchedHook = {
1433    hook: HookCommand | HookCallback | FunctionHook
1434    pluginRoot?: string
1435    pluginId?: string
1436    skillRoot?: string
1437    hookSource?: string
1438  }
1439  
1440  function isInternalHook(matched: MatchedHook): boolean {
1441    return matched.hook.type === 'callback' && matched.hook.internal === true
1442  }
1443  
1444  /**
1445   * Build a dedup key for a matched hook, namespaced by source context.
1446   *
1447   * Settings-file hooks (no pluginRoot/skillRoot) share the '' prefix so the
1448   * same command defined in user/project/local still collapses to one — the
1449   * original intent of the dedup. Plugin/skill hooks get their root as the
1450   * prefix, so two plugins sharing an unexpanded `${CLAUDE_PLUGIN_ROOT}/hook.sh`
1451   * template don't collapse: after expansion they point to different files.
1452   */
1453  function hookDedupKey(m: MatchedHook, payload: string): string {
1454    return `${m.pluginRoot ?? m.skillRoot ?? ''}\0${payload}`
1455  }
1456  
1457  /**
1458   * Build a map of {sanitizedPluginName: hookCount} from matched hooks.
1459   * Only logs actual names for official marketplace plugins; others become 'third-party'.
1460   */
1461  function getPluginHookCounts(
1462    hooks: MatchedHook[],
1463  ): Record<string, number> | undefined {
1464    const pluginHooks = hooks.filter(h => h.pluginId)
1465    if (pluginHooks.length === 0) {
1466      return undefined
1467    }
1468    const counts: Record<string, number> = {}
1469    for (const h of pluginHooks) {
1470      const atIndex = h.pluginId!.lastIndexOf('@')
1471      const isOfficial =
1472        atIndex > 0 &&
1473        ALLOWED_OFFICIAL_MARKETPLACE_NAMES.has(h.pluginId!.slice(atIndex + 1))
1474      const key = isOfficial ? h.pluginId! : 'third-party'
1475      counts[key] = (counts[key] || 0) + 1
1476    }
1477    return counts
1478  }
1479  
1480  
1481  /**
1482   * Build a map of {hookType: count} from matched hooks.
1483   */
1484  function getHookTypeCounts(hooks: MatchedHook[]): Record<string, number> {
1485    const counts: Record<string, number> = {}
1486    for (const h of hooks) {
1487      counts[h.hook.type] = (counts[h.hook.type] || 0) + 1
1488    }
1489    return counts
1490  }
1491  
1492  function getHooksConfig(
1493    appState: AppState | undefined,
1494    sessionId: string,
1495    hookEvent: HookEvent,
1496  ): Array<
1497    | HookMatcher
1498    | HookCallbackMatcher
1499    | FunctionHookMatcher
1500    | PluginHookMatcher
1501    | SkillHookMatcher
1502    | SessionDerivedHookMatcher
1503  > {
1504    // HookMatcher is a zod-stripped {matcher, hooks} so snapshot matchers can be
1505    // pushed directly without re-wrapping.
1506    const hooks: Array<
1507      | HookMatcher
1508      | HookCallbackMatcher
1509      | FunctionHookMatcher
1510      | PluginHookMatcher
1511      | SkillHookMatcher
1512      | SessionDerivedHookMatcher
1513    > = [...(getHooksConfigFromSnapshot()?.[hookEvent] ?? [])]
1514  
1515    // Check if only managed hooks should run (used for both registered and session hooks)
1516    const managedOnly = shouldAllowManagedHooksOnly()
1517  
1518    // Process registered hooks (SDK callbacks and plugin native hooks)
1519    const registeredHooks = getRegisteredHooks()?.[hookEvent]
1520    if (registeredHooks) {
1521      for (const matcher of registeredHooks) {
1522        // Skip plugin hooks when restricted to managed hooks only
1523        // Plugin hooks have pluginRoot set, SDK callbacks do not
1524        if (managedOnly && 'pluginRoot' in matcher) {
1525          continue
1526        }
1527        hooks.push(matcher)
1528      }
1529    }
1530  
1531    // Merge session hooks for the current session only
1532    // Function hooks (like structured output enforcement) must be scoped to their session
1533    // to prevent hooks from one agent leaking to another (e.g., verification agent to main agent)
1534    // Skip session hooks entirely when allowManagedHooksOnly is set —
1535    // this prevents frontmatter hooks from agents/skills from bypassing the policy.
1536    // strictPluginOnlyCustomization does NOT block here — it gates at the
1537    // REGISTRATION sites (runAgent.ts:526 for agent frontmatter hooks) where
1538    // agentDefinition.source is known. A blanket block here would also kill
1539    // plugin-provided agents' frontmatter hooks, which is too broad.
1540    // Also skip if appState not provided (for backwards compatibility)
1541    if (!managedOnly && appState !== undefined) {
1542      const sessionHooks = getSessionHooks(appState, sessionId, hookEvent).get(
1543        hookEvent,
1544      )
1545      if (sessionHooks) {
1546        // SessionDerivedHookMatcher already includes optional skillRoot
1547        for (const matcher of sessionHooks) {
1548          hooks.push(matcher)
1549        }
1550      }
1551  
1552      // Merge session function hooks separately (can't be persisted to HookMatcher format)
1553      const sessionFunctionHooks = getSessionFunctionHooks(
1554        appState,
1555        sessionId,
1556        hookEvent,
1557      ).get(hookEvent)
1558      if (sessionFunctionHooks) {
1559        for (const matcher of sessionFunctionHooks) {
1560          hooks.push(matcher)
1561        }
1562      }
1563    }
1564  
1565    return hooks
1566  }
1567  
1568  /**
1569   * Lightweight existence check for hooks on a given event. Mirrors the sources
1570   * assembled by getHooksConfig() but stops at the first hit without building
1571   * the full merged config.
1572   *
1573   * Intentionally over-approximates: returns true if any matcher exists for the
1574   * event, even if managed-only filtering or pattern matching would later
1575   * discard it. A false positive just means we proceed to the full matching
1576   * path; a false negative would skip a hook, so we err on the side of true.
1577   *
1578   * Used to skip createBaseHookInput (getTranscriptPathForSession path joins)
1579   * and getMatchingHooks on hot paths where hooks are typically unconfigured.
1580   * See hasInstructionsLoadedHook / hasWorktreeCreateHook for the same pattern.
1581   */
1582  function hasHookForEvent(
1583    hookEvent: HookEvent,
1584    appState: AppState | undefined,
1585    sessionId: string,
1586  ): boolean {
1587    const snap = getHooksConfigFromSnapshot()?.[hookEvent]
1588    if (snap && snap.length > 0) return true
1589    const reg = getRegisteredHooks()?.[hookEvent]
1590    if (reg && reg.length > 0) return true
1591    if (appState?.sessionHooks.get(sessionId)?.hooks[hookEvent]) return true
1592    return false
1593  }
1594  
1595  /**
1596   * Get hook commands that match the given query
1597   * @param appState The current app state (optional for backwards compatibility)
1598   * @param sessionId The current session ID (main session or agent ID)
1599   * @param hookEvent The hook event
1600   * @param hookInput The hook input for matching
1601   * @returns Array of matched hooks with optional plugin context
1602   */
1603  export async function getMatchingHooks(
1604    appState: AppState | undefined,
1605    sessionId: string,
1606    hookEvent: HookEvent,
1607    hookInput: HookInput,
1608    tools?: Tools,
1609  ): Promise<MatchedHook[]> {
1610    try {
1611      const hookMatchers = getHooksConfig(appState, sessionId, hookEvent)
1612  
1613      // If you change the criteria below, then you must change
1614      // src/utils/hooks/hooksConfigManager.ts as well.
1615      let matchQuery: string | undefined = undefined
1616      switch (hookInput.hook_event_name) {
1617        case 'PreToolUse':
1618        case 'PostToolUse':
1619        case 'PostToolUseFailure':
1620        case 'PermissionRequest':
1621        case 'PermissionDenied':
1622          matchQuery = hookInput.tool_name
1623          break
1624        case 'SessionStart':
1625          matchQuery = hookInput.source
1626          break
1627        case 'Setup':
1628          matchQuery = hookInput.trigger
1629          break
1630        case 'PreCompact':
1631        case 'PostCompact':
1632          matchQuery = hookInput.trigger
1633          break
1634        case 'Notification':
1635          matchQuery = hookInput.notification_type
1636          break
1637        case 'SessionEnd':
1638          matchQuery = hookInput.reason
1639          break
1640        case 'StopFailure':
1641          matchQuery = hookInput.error
1642          break
1643        case 'SubagentStart':
1644          matchQuery = hookInput.agent_type
1645          break
1646        case 'SubagentStop':
1647          matchQuery = hookInput.agent_type
1648          break
1649        case 'TeammateIdle':
1650        case 'TaskCreated':
1651        case 'TaskCompleted':
1652          break
1653        case 'Elicitation':
1654          matchQuery = hookInput.mcp_server_name
1655          break
1656        case 'ElicitationResult':
1657          matchQuery = hookInput.mcp_server_name
1658          break
1659        case 'ConfigChange':
1660          matchQuery = hookInput.source
1661          break
1662        case 'InstructionsLoaded':
1663          matchQuery = hookInput.load_reason
1664          break
1665        case 'FileChanged':
1666          matchQuery = basename(hookInput.file_path)
1667          break
1668        default:
1669          break
1670      }
1671  
1672      logForDebugging(
1673        `Getting matching hook commands for ${hookEvent} with query: ${matchQuery}`,
1674        { level: 'verbose' },
1675      )
1676      logForDebugging(`Found ${hookMatchers.length} hook matchers in settings`, {
1677        level: 'verbose',
1678      })
1679  
1680      // Extract hooks with their plugin context (if any)
1681      const filteredMatchers = matchQuery
1682        ? hookMatchers.filter(
1683            matcher =>
1684              !matcher.matcher || matchesPattern(matchQuery, matcher.matcher),
1685          )
1686        : hookMatchers
1687  
1688      const matchedHooks: MatchedHook[] = filteredMatchers.flatMap(matcher => {
1689        // Check if this is a PluginHookMatcher (has pluginRoot) or SkillHookMatcher (has skillRoot)
1690        const pluginRoot =
1691          'pluginRoot' in matcher ? matcher.pluginRoot : undefined
1692        const pluginId = 'pluginId' in matcher ? matcher.pluginId : undefined
1693        const skillRoot = 'skillRoot' in matcher ? matcher.skillRoot : undefined
1694        const hookSource = pluginRoot
1695          ? 'pluginName' in matcher
1696            ? `plugin:${matcher.pluginName}`
1697            : 'plugin'
1698          : skillRoot
1699            ? 'skillName' in matcher
1700              ? `skill:${matcher.skillName}`
1701              : 'skill'
1702            : 'settings'
1703        return matcher.hooks.map(hook => ({
1704          hook,
1705          pluginRoot,
1706          pluginId,
1707          skillRoot,
1708          hookSource,
1709        }))
1710      })
1711  
1712      // Deduplicate hooks by command/prompt/url within the same source context.
1713      // Key is namespaced by pluginRoot/skillRoot (see hookDedupKey above) so
1714      // cross-plugin template collisions don't drop hooks (gh-29724).
1715      //
1716      // Note: new Map(entries) keeps the LAST entry on key collision, not first.
1717      // For settings hooks this means the last-merged scope wins; for
1718      // same-plugin duplicates the pluginRoot is identical so it doesn't matter.
1719      // Fast-path: callback/function hooks don't need dedup (each is unique).
1720      // Skip the 6-pass filter + 4×Map + 4×Array.from below when all hooks are
1721      // callback/function — the common case for internal hooks like
1722      // sessionFileAccessHooks/attributionHooks (44x faster in microbench).
1723      if (
1724        matchedHooks.every(
1725          m => m.hook.type === 'callback' || m.hook.type === 'function',
1726        )
1727      ) {
1728        return matchedHooks
1729      }
1730  
1731      // Helper to extract the `if` condition from a hook for dedup keys.
1732      // Hooks with different `if` conditions are distinct even if otherwise identical.
1733      const getIfCondition = (hook: { if?: string }): string => hook.if ?? ''
1734  
1735      const uniqueCommandHooks = Array.from(
1736        new Map(
1737          matchedHooks
1738            .filter(
1739              (
1740                m,
1741              ): m is MatchedHook & { hook: HookCommand & { type: 'command' } } =>
1742                m.hook.type === 'command',
1743            )
1744            // shell is part of identity: {command:'echo x', shell:'bash'}
1745            // and {command:'echo x', shell:'powershell'} are distinct hooks,
1746            // not duplicates. Default to 'bash' so legacy configs (no shell
1747            // field) still dedup against explicit shell:'bash'.
1748            .map(m => [
1749              hookDedupKey(
1750                m,
1751                `${m.hook.shell ?? DEFAULT_HOOK_SHELL}\0${m.hook.command}\0${getIfCondition(m.hook)}`,
1752              ),
1753              m,
1754            ]),
1755        ).values(),
1756      )
1757      const uniquePromptHooks = Array.from(
1758        new Map(
1759          matchedHooks
1760            .filter(m => m.hook.type === 'prompt')
1761            .map(m => [
1762              hookDedupKey(
1763                m,
1764                `${(m.hook as { prompt: string }).prompt}\0${getIfCondition(m.hook as { if?: string })}`,
1765              ),
1766              m,
1767            ]),
1768        ).values(),
1769      )
1770      const uniqueAgentHooks = Array.from(
1771        new Map(
1772          matchedHooks
1773            .filter(m => m.hook.type === 'agent')
1774            .map(m => [
1775              hookDedupKey(
1776                m,
1777                `${(m.hook as { prompt: string }).prompt}\0${getIfCondition(m.hook as { if?: string })}`,
1778              ),
1779              m,
1780            ]),
1781        ).values(),
1782      )
1783      const uniqueHttpHooks = Array.from(
1784        new Map(
1785          matchedHooks
1786            .filter(m => m.hook.type === 'http')
1787            .map(m => [
1788              hookDedupKey(
1789                m,
1790                `${(m.hook as { url: string }).url}\0${getIfCondition(m.hook as { if?: string })}`,
1791              ),
1792              m,
1793            ]),
1794        ).values(),
1795      )
1796      const callbackHooks = matchedHooks.filter(m => m.hook.type === 'callback')
1797      // Function hooks don't need deduplication - each callback is unique
1798      const functionHooks = matchedHooks.filter(m => m.hook.type === 'function')
1799      const uniqueHooks = [
1800        ...uniqueCommandHooks,
1801        ...uniquePromptHooks,
1802        ...uniqueAgentHooks,
1803        ...uniqueHttpHooks,
1804        ...callbackHooks,
1805        ...functionHooks,
1806      ]
1807  
1808      // Filter hooks based on their `if` condition. This allows hooks to specify
1809      // conditions like "Bash(git *)" to only run for git commands, avoiding
1810      // process spawning overhead for non-matching commands.
1811      const hasIfCondition = uniqueHooks.some(
1812        h =>
1813          (h.hook.type === 'command' ||
1814            h.hook.type === 'prompt' ||
1815            h.hook.type === 'agent' ||
1816            h.hook.type === 'http') &&
1817          (h.hook as { if?: string }).if,
1818      )
1819      const ifMatcher = hasIfCondition
1820        ? await prepareIfConditionMatcher(hookInput, tools)
1821        : undefined
1822      const ifFilteredHooks = uniqueHooks.filter(h => {
1823        if (
1824          h.hook.type !== 'command' &&
1825          h.hook.type !== 'prompt' &&
1826          h.hook.type !== 'agent' &&
1827          h.hook.type !== 'http'
1828        ) {
1829          return true
1830        }
1831        const ifCondition = (h.hook as { if?: string }).if
1832        if (!ifCondition) {
1833          return true
1834        }
1835        if (!ifMatcher) {
1836          logForDebugging(
1837            `Hook if condition "${ifCondition}" cannot be evaluated for non-tool event ${hookInput.hook_event_name}`,
1838          )
1839          return false
1840        }
1841        if (ifMatcher(ifCondition)) {
1842          return true
1843        }
1844        logForDebugging(
1845          `Skipping hook due to if condition "${ifCondition}" not matching`,
1846        )
1847        return false
1848      })
1849  
1850      // HTTP hooks are not supported for SessionStart/Setup events. In headless
1851      // mode the sandbox ask callback deadlocks because the structuredInput
1852      // consumer hasn't started yet when these hooks fire.
1853      const filteredHooks =
1854        hookEvent === 'SessionStart' || hookEvent === 'Setup'
1855          ? ifFilteredHooks.filter(h => {
1856              if (h.hook.type === 'http') {
1857                logForDebugging(
1858                  `Skipping HTTP hook ${(h.hook as { url: string }).url} — HTTP hooks are not supported for ${hookEvent}`,
1859                )
1860                return false
1861              }
1862              return true
1863            })
1864          : ifFilteredHooks
1865  
1866      logForDebugging(
1867        `Matched ${filteredHooks.length} unique hooks for query "${matchQuery || 'no match query'}" (${matchedHooks.length} before deduplication)`,
1868        { level: 'verbose' },
1869      )
1870      return filteredHooks
1871    } catch {
1872      return []
1873    }
1874  }
1875  
1876  /**
1877   * Format a list of blocking errors from a PreTool hook's configured commands.
1878   * @param hookName The name of the hook (e.g., 'PreToolUse:Write', 'PreToolUse:Edit', 'PreToolUse:Bash')
1879   * @param blockingErrors Array of blocking errors from hooks
1880   * @returns Formatted blocking message
1881   */
1882  export function getPreToolHookBlockingMessage(
1883    hookName: string,
1884    blockingError: HookBlockingError,
1885  ): string {
1886    return `${hookName} hook error: ${blockingError.blockingError}`
1887  }
1888  
1889  /**
1890   * Format a list of blocking errors from a Stop hook's configured commands.
1891   * @param blockingErrors Array of blocking errors from hooks
1892   * @returns Formatted message to give feedback to the model
1893   */
1894  export function getStopHookMessage(blockingError: HookBlockingError): string {
1895    return `Stop hook feedback:\n${blockingError.blockingError}`
1896  }
1897  
1898  /**
1899   * Format a blocking error from a TeammateIdle hook.
1900   * @param blockingError The blocking error from the hook
1901   * @returns Formatted message to give feedback to the model
1902   */
1903  export function getTeammateIdleHookMessage(
1904    blockingError: HookBlockingError,
1905  ): string {
1906    return `TeammateIdle hook feedback:\n${blockingError.blockingError}`
1907  }
1908  
1909  /**
1910   * Format a blocking error from a TaskCreated hook.
1911   * @param blockingError The blocking error from the hook
1912   * @returns Formatted message to give feedback to the model
1913   */
1914  export function getTaskCreatedHookMessage(
1915    blockingError: HookBlockingError,
1916  ): string {
1917    return `TaskCreated hook feedback:\n${blockingError.blockingError}`
1918  }
1919  
1920  /**
1921   * Format a blocking error from a TaskCompleted hook.
1922   * @param blockingError The blocking error from the hook
1923   * @returns Formatted message to give feedback to the model
1924   */
1925  export function getTaskCompletedHookMessage(
1926    blockingError: HookBlockingError,
1927  ): string {
1928    return `TaskCompleted hook feedback:\n${blockingError.blockingError}`
1929  }
1930  
1931  /**
1932   * Format a list of blocking errors from a UserPromptSubmit hook's configured commands.
1933   * @param blockingErrors Array of blocking errors from hooks
1934   * @returns Formatted blocking message
1935   */
1936  export function getUserPromptSubmitHookBlockingMessage(
1937    blockingError: HookBlockingError,
1938  ): string {
1939    return `UserPromptSubmit operation blocked by hook:\n${blockingError.blockingError}`
1940  }
1941  /**
1942   * Common logic for executing hooks
1943   * @param hookInput The structured hook input that will be validated and converted to JSON
1944   * @param toolUseID The ID for tracking this hook execution
1945   * @param matchQuery The query to match against hook matchers
1946   * @param signal Optional AbortSignal to cancel hook execution
1947   * @param timeoutMs Optional timeout in milliseconds for hook execution
1948   * @param toolUseContext Optional ToolUseContext for prompt-based hooks (required if using prompt hooks)
1949   * @param messages Optional conversation history for prompt/function hooks
1950   * @returns Async generator that yields progress messages and hook results
1951   */
1952  async function* executeHooks({
1953    hookInput,
1954    toolUseID,
1955    matchQuery,
1956    signal,
1957    timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
1958    toolUseContext,
1959    messages,
1960    forceSyncExecution,
1961    requestPrompt,
1962    toolInputSummary,
1963  }: {
1964    hookInput: HookInput
1965    toolUseID: string
1966    matchQuery?: string
1967    signal?: AbortSignal
1968    timeoutMs?: number
1969    toolUseContext?: ToolUseContext
1970    messages?: Message[]
1971    forceSyncExecution?: boolean
1972    requestPrompt?: (
1973      sourceName: string,
1974      toolInputSummary?: string | null,
1975    ) => (request: PromptRequest) => Promise<PromptResponse>
1976    toolInputSummary?: string | null
1977  }): AsyncGenerator<AggregatedHookResult> {
1978    if (shouldDisableAllHooksIncludingManaged()) {
1979      return
1980    }
1981  
1982    if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) {
1983      return
1984    }
1985  
1986    const hookEvent = hookInput.hook_event_name
1987    const hookName = matchQuery ? `${hookEvent}:${matchQuery}` : hookEvent
1988  
1989    // Bind the prompt callback to this hook's name and tool input summary so the UI can display context
1990    const boundRequestPrompt = requestPrompt?.(hookName, toolInputSummary)
1991  
1992    // SECURITY: ALL hooks require workspace trust in interactive mode
1993    // This centralized check prevents RCE vulnerabilities for all current and future hooks
1994    if (shouldSkipHookDueToTrust()) {
1995      logForDebugging(
1996        `Skipping ${hookName} hook execution - workspace trust not accepted`,
1997      )
1998      return
1999    }
2000  
2001    const appState = toolUseContext ? toolUseContext.getAppState() : undefined
2002    // Use the agent's session ID if available, otherwise fall back to main session
2003    const sessionId = toolUseContext?.agentId ?? getSessionId()
2004    const matchingHooks = await getMatchingHooks(
2005      appState,
2006      sessionId,
2007      hookEvent,
2008      hookInput,
2009      toolUseContext?.options?.tools,
2010    )
2011    if (matchingHooks.length === 0) {
2012      return
2013    }
2014  
2015    if (signal?.aborted) {
2016      return
2017    }
2018  
2019    const userHooks = matchingHooks.filter(h => !isInternalHook(h))
2020    if (userHooks.length > 0) {
2021      const pluginHookCounts = getPluginHookCounts(userHooks)
2022      const hookTypeCounts = getHookTypeCounts(userHooks)
2023      logEvent(`tengu_run_hook`, {
2024        hookName:
2025          hookName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
2026        numCommands: userHooks.length,
2027        hookTypeCounts: jsonStringify(
2028          hookTypeCounts,
2029        ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
2030        ...(pluginHookCounts && {
2031          pluginHookCounts: jsonStringify(
2032            pluginHookCounts,
2033          ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
2034        }),
2035      })
2036    } else {
2037      // Fast-path: all hooks are internal callbacks (sessionFileAccessHooks,
2038      // attributionHooks). These return {} and don't use the abort signal, so we
2039      // can skip span/progress/abortSignal/processHookJSONOutput/resultLoop.
2040      // Measured: 6.01µs → ~1.8µs per PostToolUse hit (-70%).
2041      const batchStartTime = Date.now()
2042      const context = toolUseContext
2043        ? {
2044            getAppState: toolUseContext.getAppState,
2045            updateAttributionState: toolUseContext.updateAttributionState,
2046          }
2047        : undefined
2048      for (const [i, { hook }] of matchingHooks.entries()) {
2049        if (hook.type === 'callback') {
2050          await hook.callback(hookInput, toolUseID, signal, i, context)
2051        }
2052      }
2053      const totalDurationMs = Date.now() - batchStartTime
2054      getStatsStore()?.observe('hook_duration_ms', totalDurationMs)
2055      addToTurnHookDuration(totalDurationMs)
2056      logEvent(`tengu_repl_hook_finished`, {
2057        hookName:
2058          hookName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
2059        numCommands: matchingHooks.length,
2060        numSuccess: matchingHooks.length,
2061        numBlocking: 0,
2062        numNonBlockingError: 0,
2063        numCancelled: 0,
2064        totalDurationMs,
2065      })
2066      return
2067    }
2068  
2069    // Collect hook definitions for beta tracing telemetry
2070    const hookDefinitionsJson = isBetaTracingEnabled()
2071      ? jsonStringify(getHookDefinitionsForTelemetry(matchingHooks))
2072      : '[]'
2073  
2074    // Log hook execution start to OTEL (only for beta tracing)
2075    if (isBetaTracingEnabled()) {
2076      void logOTelEvent('hook_execution_start', {
2077        hook_event: hookEvent,
2078        hook_name: hookName,
2079        num_hooks: String(matchingHooks.length),
2080        managed_only: String(shouldAllowManagedHooksOnly()),
2081        hook_definitions: hookDefinitionsJson,
2082        hook_source: shouldAllowManagedHooksOnly() ? 'policySettings' : 'merged',
2083      })
2084    }
2085  
2086    // Start hook span for beta tracing
2087    const hookSpan = startHookSpan(
2088      hookEvent,
2089      hookName,
2090      matchingHooks.length,
2091      hookDefinitionsJson,
2092    )
2093  
2094    // Yield progress messages for each hook before execution
2095    for (const { hook } of matchingHooks) {
2096      yield {
2097        message: {
2098          type: 'progress',
2099          data: {
2100            type: 'hook_progress',
2101            hookEvent,
2102            hookName,
2103            command: getHookDisplayText(hook),
2104            ...(hook.type === 'prompt' && { promptText: hook.prompt }),
2105            ...('statusMessage' in hook &&
2106              hook.statusMessage != null && {
2107                statusMessage: hook.statusMessage,
2108              }),
2109          },
2110          parentToolUseID: toolUseID,
2111          toolUseID,
2112          timestamp: new Date().toISOString(),
2113          uuid: randomUUID(),
2114        },
2115      }
2116    }
2117  
2118    // Track wall-clock time for the entire hook batch
2119    const batchStartTime = Date.now()
2120  
2121    // Lazy-once stringify of hookInput. Shared across all command/prompt/agent/http
2122    // hooks in this batch (hookInput is never mutated). Callback/function hooks
2123    // return before reaching this, so batches with only those pay no stringify cost.
2124    let jsonInputResult:
2125      | { ok: true; value: string }
2126      | { ok: false; error: unknown }
2127      | undefined
2128    function getJsonInput() {
2129      if (jsonInputResult !== undefined) {
2130        return jsonInputResult
2131      }
2132      try {
2133        return (jsonInputResult = { ok: true, value: jsonStringify(hookInput) })
2134      } catch (error) {
2135        logError(
2136          Error(`Failed to stringify hook ${hookName} input`, { cause: error }),
2137        )
2138        return (jsonInputResult = { ok: false, error })
2139      }
2140    }
2141  
2142    // Run all hooks in parallel with individual timeouts
2143    const hookPromises = matchingHooks.map(async function* (
2144      { hook, pluginRoot, pluginId, skillRoot },
2145      hookIndex,
2146    ): AsyncGenerator<HookResult> {
2147      if (hook.type === 'callback') {
2148        const callbackTimeoutMs = hook.timeout ? hook.timeout * 1000 : timeoutMs
2149        const { signal: abortSignal, cleanup } = createCombinedAbortSignal(
2150          signal,
2151          { timeoutMs: callbackTimeoutMs },
2152        )
2153        yield executeHookCallback({
2154          toolUseID,
2155          hook,
2156          hookEvent,
2157          hookInput,
2158          signal: abortSignal,
2159          hookIndex,
2160          toolUseContext,
2161        }).finally(cleanup)
2162        return
2163      }
2164  
2165      if (hook.type === 'function') {
2166        if (!messages) {
2167          yield {
2168            message: createAttachmentMessage({
2169              type: 'hook_error_during_execution',
2170              hookName,
2171              toolUseID,
2172              hookEvent,
2173              content: 'Messages not provided for function hook',
2174            }),
2175            outcome: 'non_blocking_error',
2176            hook,
2177          }
2178          return
2179        }
2180  
2181        // Function hooks only come from session storage with callback embedded
2182        yield executeFunctionHook({
2183          hook,
2184          messages,
2185          hookName,
2186          toolUseID,
2187          hookEvent,
2188          timeoutMs,
2189          signal,
2190        })
2191        return
2192      }
2193  
2194      // Command and prompt hooks need jsonInput
2195      const commandTimeoutMs = hook.timeout ? hook.timeout * 1000 : timeoutMs
2196      const { signal: abortSignal, cleanup } = createCombinedAbortSignal(signal, {
2197        timeoutMs: commandTimeoutMs,
2198      })
2199      const hookId = randomUUID()
2200      const hookStartMs = Date.now()
2201      const hookCommand = getHookDisplayText(hook)
2202  
2203      try {
2204        const jsonInputRes = getJsonInput()
2205        if (!jsonInputRes.ok) {
2206          yield {
2207            message: createAttachmentMessage({
2208              type: 'hook_error_during_execution',
2209              hookName,
2210              toolUseID,
2211              hookEvent,
2212              content: `Failed to prepare hook input: ${errorMessage(jsonInputRes.error)}`,
2213              command: hookCommand,
2214              durationMs: Date.now() - hookStartMs,
2215            }),
2216            outcome: 'non_blocking_error',
2217            hook,
2218          }
2219          cleanup()
2220          return
2221        }
2222        const jsonInput = jsonInputRes.value
2223  
2224        if (hook.type === 'prompt') {
2225          if (!toolUseContext) {
2226            throw new Error(
2227              'ToolUseContext is required for prompt hooks. This is a bug.',
2228            )
2229          }
2230          const promptResult = await execPromptHook(
2231            hook,
2232            hookName,
2233            hookEvent,
2234            jsonInput,
2235            abortSignal,
2236            toolUseContext,
2237            messages,
2238            toolUseID,
2239          )
2240          // Inject timing fields for hook visibility
2241          if (promptResult.message?.type === 'attachment') {
2242            const att = promptResult.message.attachment
2243            if (
2244              att.type === 'hook_success' ||
2245              att.type === 'hook_non_blocking_error'
2246            ) {
2247              att.command = hookCommand
2248              att.durationMs = Date.now() - hookStartMs
2249            }
2250          }
2251          yield promptResult
2252          cleanup?.()
2253          return
2254        }
2255  
2256        if (hook.type === 'agent') {
2257          if (!toolUseContext) {
2258            throw new Error(
2259              'ToolUseContext is required for agent hooks. This is a bug.',
2260            )
2261          }
2262          if (!messages) {
2263            throw new Error(
2264              'Messages are required for agent hooks. This is a bug.',
2265            )
2266          }
2267          const agentResult = await execAgentHook(
2268            hook,
2269            hookName,
2270            hookEvent,
2271            jsonInput,
2272            abortSignal,
2273            toolUseContext,
2274            toolUseID,
2275            messages,
2276            'agent_type' in hookInput
2277              ? (hookInput.agent_type as string)
2278              : undefined,
2279          )
2280          // Inject timing fields for hook visibility
2281          if (agentResult.message?.type === 'attachment') {
2282            const att = agentResult.message.attachment
2283            if (
2284              att.type === 'hook_success' ||
2285              att.type === 'hook_non_blocking_error'
2286            ) {
2287              att.command = hookCommand
2288              att.durationMs = Date.now() - hookStartMs
2289            }
2290          }
2291          yield agentResult
2292          cleanup?.()
2293          return
2294        }
2295  
2296        if (hook.type === 'http') {
2297          emitHookStarted(hookId, hookName, hookEvent)
2298  
2299          // execHttpHook manages its own timeout internally via hook.timeout or
2300          // DEFAULT_HTTP_HOOK_TIMEOUT_MS, so pass the parent signal directly
2301          // to avoid double-stacking timeouts with abortSignal.
2302          const httpResult = await execHttpHook(
2303            hook,
2304            hookEvent,
2305            jsonInput,
2306            signal,
2307          )
2308          cleanup?.()
2309  
2310          if (httpResult.aborted) {
2311            emitHookResponse({
2312              hookId,
2313              hookName,
2314              hookEvent,
2315              output: 'Hook cancelled',
2316              stdout: '',
2317              stderr: '',
2318              exitCode: undefined,
2319              outcome: 'cancelled',
2320            })
2321            yield {
2322              message: createAttachmentMessage({
2323                type: 'hook_cancelled',
2324                hookName,
2325                toolUseID,
2326                hookEvent,
2327              }),
2328              outcome: 'cancelled' as const,
2329              hook,
2330            }
2331            return
2332          }
2333  
2334          if (httpResult.error || !httpResult.ok) {
2335            const stderr =
2336              httpResult.error || `HTTP ${httpResult.statusCode} from ${hook.url}`
2337            emitHookResponse({
2338              hookId,
2339              hookName,
2340              hookEvent,
2341              output: stderr,
2342              stdout: '',
2343              stderr,
2344              exitCode: httpResult.statusCode,
2345              outcome: 'error',
2346            })
2347            yield {
2348              message: createAttachmentMessage({
2349                type: 'hook_non_blocking_error',
2350                hookName,
2351                toolUseID,
2352                hookEvent,
2353                stderr,
2354                stdout: '',
2355                exitCode: httpResult.statusCode ?? 0,
2356              }),
2357              outcome: 'non_blocking_error' as const,
2358              hook,
2359            }
2360            return
2361          }
2362  
2363          // HTTP hooks must return JSON — parse and validate through Zod
2364          const { json: httpJson, validationError: httpValidationError } =
2365            parseHttpHookOutput(httpResult.body)
2366  
2367          if (httpValidationError) {
2368            emitHookResponse({
2369              hookId,
2370              hookName,
2371              hookEvent,
2372              output: httpResult.body,
2373              stdout: httpResult.body,
2374              stderr: `JSON validation failed: ${httpValidationError}`,
2375              exitCode: httpResult.statusCode,
2376              outcome: 'error',
2377            })
2378            yield {
2379              message: createAttachmentMessage({
2380                type: 'hook_non_blocking_error',
2381                hookName,
2382                toolUseID,
2383                hookEvent,
2384                stderr: `JSON validation failed: ${httpValidationError}`,
2385                stdout: httpResult.body,
2386                exitCode: httpResult.statusCode ?? 0,
2387              }),
2388              outcome: 'non_blocking_error' as const,
2389              hook,
2390            }
2391            return
2392          }
2393  
2394          if (httpJson && isAsyncHookJSONOutput(httpJson)) {
2395            // Async response: treat as success (no further processing)
2396            emitHookResponse({
2397              hookId,
2398              hookName,
2399              hookEvent,
2400              output: httpResult.body,
2401              stdout: httpResult.body,
2402              stderr: '',
2403              exitCode: httpResult.statusCode,
2404              outcome: 'success',
2405            })
2406            yield {
2407              outcome: 'success' as const,
2408              hook,
2409            }
2410            return
2411          }
2412  
2413          if (httpJson) {
2414            const processed = processHookJSONOutput({
2415              json: httpJson,
2416              command: hook.url,
2417              hookName,
2418              toolUseID,
2419              hookEvent,
2420              expectedHookEvent: hookEvent,
2421              stdout: httpResult.body,
2422              stderr: '',
2423              exitCode: httpResult.statusCode,
2424            })
2425            emitHookResponse({
2426              hookId,
2427              hookName,
2428              hookEvent,
2429              output: httpResult.body,
2430              stdout: httpResult.body,
2431              stderr: '',
2432              exitCode: httpResult.statusCode,
2433              outcome: 'success',
2434            })
2435            yield {
2436              ...processed,
2437              outcome: 'success' as const,
2438              hook,
2439            }
2440            return
2441          }
2442  
2443          return
2444        }
2445  
2446        emitHookStarted(hookId, hookName, hookEvent)
2447  
2448        const result = await execCommandHook(
2449          hook,
2450          hookEvent,
2451          hookName,
2452          jsonInput,
2453          abortSignal,
2454          hookId,
2455          hookIndex,
2456          pluginRoot,
2457          pluginId,
2458          skillRoot,
2459          forceSyncExecution,
2460          boundRequestPrompt,
2461        )
2462        cleanup?.()
2463        const durationMs = Date.now() - hookStartMs
2464  
2465        if (result.backgrounded) {
2466          yield {
2467            outcome: 'success' as const,
2468            hook,
2469          }
2470          return
2471        }
2472  
2473        if (result.aborted) {
2474          emitHookResponse({
2475            hookId,
2476            hookName,
2477            hookEvent,
2478            output: result.output,
2479            stdout: result.stdout,
2480            stderr: result.stderr,
2481            exitCode: result.status,
2482            outcome: 'cancelled',
2483          })
2484          yield {
2485            message: createAttachmentMessage({
2486              type: 'hook_cancelled',
2487              hookName,
2488              toolUseID,
2489              hookEvent,
2490              command: hookCommand,
2491              durationMs,
2492            }),
2493            outcome: 'cancelled' as const,
2494            hook,
2495          }
2496          return
2497        }
2498  
2499        // Try JSON parsing first
2500        const { json, plainText, validationError } = parseHookOutput(
2501          result.stdout,
2502        )
2503  
2504        if (validationError) {
2505          emitHookResponse({
2506            hookId,
2507            hookName,
2508            hookEvent,
2509            output: result.output,
2510            stdout: result.stdout,
2511            stderr: `JSON validation failed: ${validationError}`,
2512            exitCode: 1,
2513            outcome: 'error',
2514          })
2515          yield {
2516            message: createAttachmentMessage({
2517              type: 'hook_non_blocking_error',
2518              hookName,
2519              toolUseID,
2520              hookEvent,
2521              stderr: `JSON validation failed: ${validationError}`,
2522              stdout: result.stdout,
2523              exitCode: 1,
2524              command: hookCommand,
2525              durationMs,
2526            }),
2527            outcome: 'non_blocking_error' as const,
2528            hook,
2529          }
2530          return
2531        }
2532  
2533        if (json) {
2534          // Async responses were already backgrounded during execution
2535          if (isAsyncHookJSONOutput(json)) {
2536            yield {
2537              outcome: 'success' as const,
2538              hook,
2539            }
2540            return
2541          }
2542  
2543          // Process JSON output
2544          const processed = processHookJSONOutput({
2545            json,
2546            command: hookCommand,
2547            hookName,
2548            toolUseID,
2549            hookEvent,
2550            expectedHookEvent: hookEvent,
2551            stdout: result.stdout,
2552            stderr: result.stderr,
2553            exitCode: result.status,
2554            durationMs,
2555          })
2556  
2557          // Handle suppressOutput (skip for async responses)
2558          if (
2559            isSyncHookJSONOutput(json) &&
2560            !json.suppressOutput &&
2561            plainText &&
2562            result.status === 0
2563          ) {
2564            // Still show non-JSON output if not suppressed
2565            const content = `${chalk.bold(hookName)} completed`
2566            emitHookResponse({
2567              hookId,
2568              hookName,
2569              hookEvent,
2570              output: result.output,
2571              stdout: result.stdout,
2572              stderr: result.stderr,
2573              exitCode: result.status,
2574              outcome: 'success',
2575            })
2576            yield {
2577              ...processed,
2578              message:
2579                processed.message ||
2580                createAttachmentMessage({
2581                  type: 'hook_success',
2582                  hookName,
2583                  toolUseID,
2584                  hookEvent,
2585                  content,
2586                  stdout: result.stdout,
2587                  stderr: result.stderr,
2588                  exitCode: result.status,
2589                  command: hookCommand,
2590                  durationMs,
2591                }),
2592              outcome: 'success' as const,
2593              hook,
2594            }
2595            return
2596          }
2597  
2598          emitHookResponse({
2599            hookId,
2600            hookName,
2601            hookEvent,
2602            output: result.output,
2603            stdout: result.stdout,
2604            stderr: result.stderr,
2605            exitCode: result.status,
2606            outcome: result.status === 0 ? 'success' : 'error',
2607          })
2608          yield {
2609            ...processed,
2610            outcome: 'success' as const,
2611            hook,
2612          }
2613          return
2614        }
2615  
2616        // Fall back to existing logic for non-JSON output
2617        if (result.status === 0) {
2618          emitHookResponse({
2619            hookId,
2620            hookName,
2621            hookEvent,
2622            output: result.output,
2623            stdout: result.stdout,
2624            stderr: result.stderr,
2625            exitCode: result.status,
2626            outcome: 'success',
2627          })
2628          yield {
2629            message: createAttachmentMessage({
2630              type: 'hook_success',
2631              hookName,
2632              toolUseID,
2633              hookEvent,
2634              content: result.stdout.trim(),
2635              stdout: result.stdout,
2636              stderr: result.stderr,
2637              exitCode: result.status,
2638              command: hookCommand,
2639              durationMs,
2640            }),
2641            outcome: 'success' as const,
2642            hook,
2643          }
2644          return
2645        }
2646  
2647        // Hooks with exit code 2 provide blocking feedback
2648        if (result.status === 2) {
2649          emitHookResponse({
2650            hookId,
2651            hookName,
2652            hookEvent,
2653            output: result.output,
2654            stdout: result.stdout,
2655            stderr: result.stderr,
2656            exitCode: result.status,
2657            outcome: 'error',
2658          })
2659          yield {
2660            blockingError: {
2661              blockingError: `[${hook.command}]: ${result.stderr || 'No stderr output'}`,
2662              command: hook.command,
2663            },
2664            outcome: 'blocking' as const,
2665            hook,
2666          }
2667          return
2668        }
2669  
2670        // Any other non-zero exit code is a non-critical error that should just
2671        // be shown to the user.
2672        emitHookResponse({
2673          hookId,
2674          hookName,
2675          hookEvent,
2676          output: result.output,
2677          stdout: result.stdout,
2678          stderr: result.stderr,
2679          exitCode: result.status,
2680          outcome: 'error',
2681        })
2682        yield {
2683          message: createAttachmentMessage({
2684            type: 'hook_non_blocking_error',
2685            hookName,
2686            toolUseID,
2687            hookEvent,
2688            stderr: `Failed with non-blocking status code: ${result.stderr.trim() || 'No stderr output'}`,
2689            stdout: result.stdout,
2690            exitCode: result.status,
2691            command: hookCommand,
2692            durationMs,
2693          }),
2694          outcome: 'non_blocking_error' as const,
2695          hook,
2696        }
2697        return
2698      } catch (error) {
2699        // Clean up on error
2700        cleanup?.()
2701  
2702        const errorMessage =
2703          error instanceof Error ? error.message : String(error)
2704        emitHookResponse({
2705          hookId,
2706          hookName,
2707          hookEvent,
2708          output: `Failed to run: ${errorMessage}`,
2709          stdout: '',
2710          stderr: `Failed to run: ${errorMessage}`,
2711          exitCode: 1,
2712          outcome: 'error',
2713        })
2714        yield {
2715          message: createAttachmentMessage({
2716            type: 'hook_non_blocking_error',
2717            hookName,
2718            toolUseID,
2719            hookEvent,
2720            stderr: `Failed to run: ${errorMessage}`,
2721            stdout: '',
2722            exitCode: 1,
2723            command: hookCommand,
2724            durationMs: Date.now() - hookStartMs,
2725          }),
2726          outcome: 'non_blocking_error' as const,
2727          hook,
2728        }
2729        return
2730      }
2731    })
2732  
2733    // Track outcomes for logging
2734    const outcomes = {
2735      success: 0,
2736      blocking: 0,
2737      non_blocking_error: 0,
2738      cancelled: 0,
2739    }
2740  
2741    let permissionBehavior: PermissionResult['behavior'] | undefined
2742  
2743    // Run all hooks in parallel and wait for all to complete
2744    for await (const result of all(hookPromises)) {
2745      outcomes[result.outcome]++
2746  
2747      // Check for preventContinuation early
2748      if (result.preventContinuation) {
2749        logForDebugging(
2750          `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) requested preventContinuation`,
2751        )
2752        yield {
2753          preventContinuation: true,
2754          stopReason: result.stopReason,
2755        }
2756      }
2757  
2758      // Handle different result types
2759      if (result.blockingError) {
2760        yield {
2761          blockingError: result.blockingError,
2762        }
2763      }
2764  
2765      if (result.message) {
2766        yield { message: result.message }
2767      }
2768  
2769      // Yield system message separately if present
2770      if (result.systemMessage) {
2771        yield {
2772          message: createAttachmentMessage({
2773            type: 'hook_system_message',
2774            content: result.systemMessage,
2775            hookName,
2776            toolUseID,
2777            hookEvent,
2778          }),
2779        }
2780      }
2781  
2782      // Collect additional context from hooks
2783      if (result.additionalContext) {
2784        logForDebugging(
2785          `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) provided additionalContext (${result.additionalContext.length} chars)`,
2786        )
2787        yield {
2788          additionalContexts: [result.additionalContext],
2789        }
2790      }
2791  
2792      if (result.initialUserMessage) {
2793        logForDebugging(
2794          `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) provided initialUserMessage (${result.initialUserMessage.length} chars)`,
2795        )
2796        yield {
2797          initialUserMessage: result.initialUserMessage,
2798        }
2799      }
2800  
2801      if (result.watchPaths && result.watchPaths.length > 0) {
2802        logForDebugging(
2803          `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) provided ${result.watchPaths.length} watchPaths`,
2804        )
2805        yield {
2806          watchPaths: result.watchPaths,
2807        }
2808      }
2809  
2810      // Yield updatedMCPToolOutput if provided (from PostToolUse hooks)
2811      if (result.updatedMCPToolOutput) {
2812        logForDebugging(
2813          `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) replaced MCP tool output`,
2814        )
2815        yield {
2816          updatedMCPToolOutput: result.updatedMCPToolOutput,
2817        }
2818      }
2819  
2820      // Check for permission behavior with precedence: deny > ask > allow
2821      if (result.permissionBehavior) {
2822        logForDebugging(
2823          `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) returned permissionDecision: ${result.permissionBehavior}${result.hookPermissionDecisionReason ? ` (reason: ${result.hookPermissionDecisionReason})` : ''}`,
2824        )
2825        // Apply precedence rules
2826        switch (result.permissionBehavior) {
2827          case 'deny':
2828            // deny always takes precedence
2829            permissionBehavior = 'deny'
2830            break
2831          case 'ask':
2832            // ask takes precedence over allow but not deny
2833            if (permissionBehavior !== 'deny') {
2834              permissionBehavior = 'ask'
2835            }
2836            break
2837          case 'allow':
2838            // allow only if no other behavior set
2839            if (!permissionBehavior) {
2840              permissionBehavior = 'allow'
2841            }
2842            break
2843          case 'passthrough':
2844            // passthrough doesn't set permission behavior
2845            break
2846        }
2847      }
2848  
2849      // Yield permission behavior and updatedInput if provided (from allow or ask behavior)
2850      if (permissionBehavior !== undefined) {
2851        const updatedInput =
2852          result.updatedInput &&
2853          (result.permissionBehavior === 'allow' ||
2854            result.permissionBehavior === 'ask')
2855            ? result.updatedInput
2856            : undefined
2857        if (updatedInput) {
2858          logForDebugging(
2859            `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) modified tool input keys: [${Object.keys(updatedInput).join(', ')}]`,
2860          )
2861        }
2862        yield {
2863          permissionBehavior,
2864          hookPermissionDecisionReason: result.hookPermissionDecisionReason,
2865          hookSource: matchingHooks.find(m => m.hook === result.hook)?.hookSource,
2866          updatedInput,
2867        }
2868      }
2869  
2870      // Yield updatedInput separately for passthrough case (no permission decision)
2871      // This allows hooks to modify input without making a permission decision
2872      // Note: Check result.permissionBehavior (this hook's behavior), not the aggregated permissionBehavior
2873      if (result.updatedInput && result.permissionBehavior === undefined) {
2874        logForDebugging(
2875          `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) modified tool input keys: [${Object.keys(result.updatedInput).join(', ')}]`,
2876        )
2877        yield {
2878          updatedInput: result.updatedInput,
2879        }
2880      }
2881      // Yield permission request result if provided (from PermissionRequest hooks)
2882      if (result.permissionRequestResult) {
2883        yield {
2884          permissionRequestResult: result.permissionRequestResult,
2885        }
2886      }
2887      // Yield retry flag if provided (from PermissionDenied hooks)
2888      if (result.retry) {
2889        yield {
2890          retry: result.retry,
2891        }
2892      }
2893      // Yield elicitation response if provided (from Elicitation hooks)
2894      if (result.elicitationResponse) {
2895        yield {
2896          elicitationResponse: result.elicitationResponse,
2897        }
2898      }
2899      // Yield elicitation result response if provided (from ElicitationResult hooks)
2900      if (result.elicitationResultResponse) {
2901        yield {
2902          elicitationResultResponse: result.elicitationResultResponse,
2903        }
2904      }
2905  
2906      // Invoke session hook callback if this is a command/prompt/function hook (not a callback hook)
2907      if (appState && result.hook.type !== 'callback') {
2908        const sessionId = getSessionId()
2909        // Use empty string as matcher when matchQuery is undefined (e.g., for Stop hooks)
2910        const matcher = matchQuery ?? ''
2911        const hookEntry = getSessionHookCallback(
2912          appState,
2913          sessionId,
2914          hookEvent,
2915          matcher,
2916          result.hook,
2917        )
2918        // Invoke onHookSuccess only on success outcome
2919        if (hookEntry?.onHookSuccess && result.outcome === 'success') {
2920          try {
2921            hookEntry.onHookSuccess(result.hook, result as AggregatedHookResult)
2922          } catch (error) {
2923            logError(
2924              Error('Session hook success callback failed', { cause: error }),
2925            )
2926          }
2927        }
2928      }
2929    }
2930  
2931    const totalDurationMs = Date.now() - batchStartTime
2932    getStatsStore()?.observe('hook_duration_ms', totalDurationMs)
2933    addToTurnHookDuration(totalDurationMs)
2934  
2935    logEvent(`tengu_repl_hook_finished`, {
2936      hookName:
2937        hookName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
2938      numCommands: matchingHooks.length,
2939      numSuccess: outcomes.success,
2940      numBlocking: outcomes.blocking,
2941      numNonBlockingError: outcomes.non_blocking_error,
2942      numCancelled: outcomes.cancelled,
2943      totalDurationMs,
2944    })
2945  
2946    // Log hook execution completion to OTEL (only for beta tracing)
2947    if (isBetaTracingEnabled()) {
2948      const hookDefinitionsComplete =
2949        getHookDefinitionsForTelemetry(matchingHooks)
2950  
2951      void logOTelEvent('hook_execution_complete', {
2952        hook_event: hookEvent,
2953        hook_name: hookName,
2954        num_hooks: String(matchingHooks.length),
2955        num_success: String(outcomes.success),
2956        num_blocking: String(outcomes.blocking),
2957        num_non_blocking_error: String(outcomes.non_blocking_error),
2958        num_cancelled: String(outcomes.cancelled),
2959        managed_only: String(shouldAllowManagedHooksOnly()),
2960        hook_definitions: jsonStringify(hookDefinitionsComplete),
2961        hook_source: shouldAllowManagedHooksOnly() ? 'policySettings' : 'merged',
2962      })
2963    }
2964  
2965    // End hook span for beta tracing
2966    endHookSpan(hookSpan, {
2967      numSuccess: outcomes.success,
2968      numBlocking: outcomes.blocking,
2969      numNonBlockingError: outcomes.non_blocking_error,
2970      numCancelled: outcomes.cancelled,
2971    })
2972  }
2973  
2974  export type HookOutsideReplResult = {
2975    command: string
2976    succeeded: boolean
2977    output: string
2978    blocked: boolean
2979    watchPaths?: string[]
2980    systemMessage?: string
2981  }
2982  
2983  export function hasBlockingResult(results: HookOutsideReplResult[]): boolean {
2984    return results.some(r => r.blocked)
2985  }
2986  
2987  /**
2988   * Execute hooks outside of the REPL (e.g. notifications, session end)
2989   *
2990   * Unlike executeHooks() which yields messages that are exposed to the model as
2991   * system messages, this function only logs errors via logForDebugging (visible
2992   * with --debug). Callers that need to surface errors to users should handle
2993   * the returned results appropriately (e.g. executeSessionEndHooks writes to
2994   * stderr during shutdown).
2995   *
2996   * @param getAppState Optional function to get the current app state (for session hooks)
2997   * @param hookInput The structured hook input that will be validated and converted to JSON
2998   * @param matchQuery The query to match against hook matchers
2999   * @param signal Optional AbortSignal to cancel hook execution
3000   * @param timeoutMs Optional timeout in milliseconds for hook execution
3001   * @returns Array of HookOutsideReplResult objects containing command, succeeded, and output
3002   */
3003  async function executeHooksOutsideREPL({
3004    getAppState,
3005    hookInput,
3006    matchQuery,
3007    signal,
3008    timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
3009  }: {
3010    getAppState?: () => AppState
3011    hookInput: HookInput
3012    matchQuery?: string
3013    signal?: AbortSignal
3014    timeoutMs: number
3015  }): Promise<HookOutsideReplResult[]> {
3016    if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) {
3017      return []
3018    }
3019  
3020    const hookEvent = hookInput.hook_event_name
3021    const hookName = matchQuery ? `${hookEvent}:${matchQuery}` : hookEvent
3022    if (shouldDisableAllHooksIncludingManaged()) {
3023      logForDebugging(
3024        `Skipping hooks for ${hookName} due to 'disableAllHooks' managed setting`,
3025      )
3026      return []
3027    }
3028  
3029    // SECURITY: ALL hooks require workspace trust in interactive mode
3030    // This centralized check prevents RCE vulnerabilities for all current and future hooks
3031    if (shouldSkipHookDueToTrust()) {
3032      logForDebugging(
3033        `Skipping ${hookName} hook execution - workspace trust not accepted`,
3034      )
3035      return []
3036    }
3037  
3038    const appState = getAppState ? getAppState() : undefined
3039    // Use main session ID for outside-REPL hooks
3040    const sessionId = getSessionId()
3041    const matchingHooks = await getMatchingHooks(
3042      appState,
3043      sessionId,
3044      hookEvent,
3045      hookInput,
3046    )
3047    if (matchingHooks.length === 0) {
3048      return []
3049    }
3050  
3051    if (signal?.aborted) {
3052      return []
3053    }
3054  
3055    const userHooks = matchingHooks.filter(h => !isInternalHook(h))
3056    if (userHooks.length > 0) {
3057      const pluginHookCounts = getPluginHookCounts(userHooks)
3058      const hookTypeCounts = getHookTypeCounts(userHooks)
3059      logEvent(`tengu_run_hook`, {
3060        hookName:
3061          hookName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
3062        numCommands: userHooks.length,
3063        hookTypeCounts: jsonStringify(
3064          hookTypeCounts,
3065        ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
3066        ...(pluginHookCounts && {
3067          pluginHookCounts: jsonStringify(
3068            pluginHookCounts,
3069          ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
3070        }),
3071      })
3072    }
3073  
3074    // Validate and stringify the hook input
3075    let jsonInput: string
3076    try {
3077      jsonInput = jsonStringify(hookInput)
3078    } catch (error) {
3079      logError(error)
3080      return []
3081    }
3082  
3083    // Run all hooks in parallel with individual timeouts
3084    const hookPromises = matchingHooks.map(
3085      async ({ hook, pluginRoot, pluginId }, hookIndex) => {
3086        // Handle callback hooks
3087        if (hook.type === 'callback') {
3088          const callbackTimeoutMs = hook.timeout ? hook.timeout * 1000 : timeoutMs
3089          const { signal: abortSignal, cleanup } = createCombinedAbortSignal(
3090            signal,
3091            { timeoutMs: callbackTimeoutMs },
3092          )
3093  
3094          try {
3095            const toolUseID = randomUUID()
3096            const json = await hook.callback(
3097              hookInput,
3098              toolUseID,
3099              abortSignal,
3100              hookIndex,
3101            )
3102  
3103            cleanup?.()
3104  
3105            if (isAsyncHookJSONOutput(json)) {
3106              logForDebugging(
3107                `${hookName} [callback] returned async response, returning empty output`,
3108              )
3109              return {
3110                command: 'callback',
3111                succeeded: true,
3112                output: '',
3113                blocked: false,
3114              }
3115            }
3116  
3117            const output =
3118              hookEvent === 'WorktreeCreate' &&
3119              isSyncHookJSONOutput(json) &&
3120              json.hookSpecificOutput?.hookEventName === 'WorktreeCreate'
3121                ? json.hookSpecificOutput.worktreePath
3122                : json.systemMessage || ''
3123            const blocked =
3124              isSyncHookJSONOutput(json) && json.decision === 'block'
3125  
3126            logForDebugging(`${hookName} [callback] completed successfully`)
3127  
3128            return {
3129              command: 'callback',
3130              succeeded: true,
3131              output,
3132              blocked,
3133            }
3134          } catch (error) {
3135            cleanup?.()
3136  
3137            const errorMessage =
3138              error instanceof Error ? error.message : String(error)
3139            logForDebugging(
3140              `${hookName} [callback] failed to run: ${errorMessage}`,
3141              { level: 'error' },
3142            )
3143            return {
3144              command: 'callback',
3145              succeeded: false,
3146              output: errorMessage,
3147              blocked: false,
3148            }
3149          }
3150        }
3151  
3152        // TODO: Implement prompt stop hooks outside REPL
3153        if (hook.type === 'prompt') {
3154          return {
3155            command: hook.prompt,
3156            succeeded: false,
3157            output: 'Prompt stop hooks are not yet supported outside REPL',
3158            blocked: false,
3159          }
3160        }
3161  
3162        // TODO: Implement agent stop hooks outside REPL
3163        if (hook.type === 'agent') {
3164          return {
3165            command: hook.prompt,
3166            succeeded: false,
3167            output: 'Agent stop hooks are not yet supported outside REPL',
3168            blocked: false,
3169          }
3170        }
3171  
3172        // Function hooks require messages array (only available in REPL context)
3173        // For -p mode Stop hooks, use executeStopHooks which supports function hooks
3174        if (hook.type === 'function') {
3175          logError(
3176            new Error(
3177              `Function hook reached executeHooksOutsideREPL for ${hookEvent}. Function hooks should only be used in REPL context (Stop hooks).`,
3178            ),
3179          )
3180          return {
3181            command: 'function',
3182            succeeded: false,
3183            output: 'Internal error: function hook executed outside REPL context',
3184            blocked: false,
3185          }
3186        }
3187  
3188        // Handle HTTP hooks (no toolUseContext needed - just HTTP POST).
3189        // execHttpHook handles its own timeout internally via hook.timeout or
3190        // DEFAULT_HTTP_HOOK_TIMEOUT_MS, so we pass signal directly.
3191        if (hook.type === 'http') {
3192          try {
3193            const httpResult = await execHttpHook(
3194              hook,
3195              hookEvent,
3196              jsonInput,
3197              signal,
3198            )
3199  
3200            if (httpResult.aborted) {
3201              logForDebugging(`${hookName} [${hook.url}] cancelled`)
3202              return {
3203                command: hook.url,
3204                succeeded: false,
3205                output: 'Hook cancelled',
3206                blocked: false,
3207              }
3208            }
3209  
3210            if (httpResult.error || !httpResult.ok) {
3211              const errMsg =
3212                httpResult.error ||
3213                `HTTP ${httpResult.statusCode} from ${hook.url}`
3214              logForDebugging(`${hookName} [${hook.url}] failed: ${errMsg}`, {
3215                level: 'error',
3216              })
3217              return {
3218                command: hook.url,
3219                succeeded: false,
3220                output: errMsg,
3221                blocked: false,
3222              }
3223            }
3224  
3225            // HTTP hooks must return JSON — parse and validate through Zod
3226            const { json: httpJson, validationError: httpValidationError } =
3227              parseHttpHookOutput(httpResult.body)
3228            if (httpValidationError) {
3229              throw new Error(httpValidationError)
3230            }
3231            if (httpJson && !isAsyncHookJSONOutput(httpJson)) {
3232              logForDebugging(
3233                `Parsed JSON output from HTTP hook: ${jsonStringify(httpJson)}`,
3234                { level: 'verbose' },
3235              )
3236            }
3237            const jsonBlocked =
3238              httpJson &&
3239              !isAsyncHookJSONOutput(httpJson) &&
3240              isSyncHookJSONOutput(httpJson) &&
3241              httpJson.decision === 'block'
3242  
3243            // WorktreeCreate's consumer reads `output` as the bare filesystem
3244            // path. Command hooks provide it via stdout; http hooks provide it
3245            // via hookSpecificOutput.worktreePath. Without worktreePath, emit ''
3246            // so the consumer's length filter skips it instead of treating the
3247            // raw '{}' body as a path.
3248            const output =
3249              hookEvent === 'WorktreeCreate'
3250                ? httpJson &&
3251                  isSyncHookJSONOutput(httpJson) &&
3252                  httpJson.hookSpecificOutput?.hookEventName === 'WorktreeCreate'
3253                  ? httpJson.hookSpecificOutput.worktreePath
3254                  : ''
3255                : httpResult.body
3256  
3257            return {
3258              command: hook.url,
3259              succeeded: true,
3260              output,
3261              blocked: !!jsonBlocked,
3262            }
3263          } catch (error) {
3264            const errorMessage =
3265              error instanceof Error ? error.message : String(error)
3266            logForDebugging(
3267              `${hookName} [${hook.url}] failed to run: ${errorMessage}`,
3268              { level: 'error' },
3269            )
3270            return {
3271              command: hook.url,
3272              succeeded: false,
3273              output: errorMessage,
3274              blocked: false,
3275            }
3276          }
3277        }
3278  
3279        // Handle command hooks
3280        const commandTimeoutMs = hook.timeout ? hook.timeout * 1000 : timeoutMs
3281        const { signal: abortSignal, cleanup } = createCombinedAbortSignal(
3282          signal,
3283          { timeoutMs: commandTimeoutMs },
3284        )
3285        try {
3286          const result = await execCommandHook(
3287            hook,
3288            hookEvent,
3289            hookName,
3290            jsonInput,
3291            abortSignal,
3292            randomUUID(),
3293            hookIndex,
3294            pluginRoot,
3295            pluginId,
3296          )
3297  
3298          // Clear timeout if hook completes
3299          cleanup?.()
3300  
3301          if (result.aborted) {
3302            logForDebugging(`${hookName} [${hook.command}] cancelled`)
3303            return {
3304              command: hook.command,
3305              succeeded: false,
3306              output: 'Hook cancelled',
3307              blocked: false,
3308            }
3309          }
3310  
3311          logForDebugging(
3312            `${hookName} [${hook.command}] completed with status ${result.status}`,
3313          )
3314  
3315          // Parse JSON for any messages to print out.
3316          const { json, validationError } = parseHookOutput(result.stdout)
3317          if (validationError) {
3318            // Validation error is logged via logForDebugging and returned in output
3319            throw new Error(validationError)
3320          }
3321          if (json && !isAsyncHookJSONOutput(json)) {
3322            logForDebugging(
3323              `Parsed JSON output from hook: ${jsonStringify(json)}`,
3324              { level: 'verbose' },
3325            )
3326          }
3327  
3328          // Blocked if exit code 2 or JSON decision: 'block'
3329          const jsonBlocked =
3330            json &&
3331            !isAsyncHookJSONOutput(json) &&
3332            isSyncHookJSONOutput(json) &&
3333            json.decision === 'block'
3334          const blocked = result.status === 2 || !!jsonBlocked
3335  
3336          // For successful hooks (exit code 0), use stdout; for failed hooks, use stderr
3337          const output =
3338            result.status === 0 ? result.stdout || '' : result.stderr || ''
3339  
3340          const watchPaths =
3341            json &&
3342            isSyncHookJSONOutput(json) &&
3343            json.hookSpecificOutput &&
3344            'watchPaths' in json.hookSpecificOutput
3345              ? json.hookSpecificOutput.watchPaths
3346              : undefined
3347  
3348          const systemMessage =
3349            json && isSyncHookJSONOutput(json) ? json.systemMessage : undefined
3350  
3351          return {
3352            command: hook.command,
3353            succeeded: result.status === 0,
3354            output,
3355            blocked,
3356            watchPaths,
3357            systemMessage,
3358          }
3359        } catch (error) {
3360          // Clean up on error
3361          cleanup?.()
3362  
3363          const errorMessage =
3364            error instanceof Error ? error.message : String(error)
3365          logForDebugging(
3366            `${hookName} [${hook.command}] failed to run: ${errorMessage}`,
3367            { level: 'error' },
3368          )
3369          return {
3370            command: hook.command,
3371            succeeded: false,
3372            output: errorMessage,
3373            blocked: false,
3374          }
3375        }
3376      },
3377    )
3378  
3379    // Wait for all hooks to complete and collect results
3380    return await Promise.all(hookPromises)
3381  }
3382  
3383  /**
3384   * Execute pre-tool hooks if configured
3385   * @param toolName The name of the tool (e.g., 'Write', 'Edit', 'Bash')
3386   * @param toolUseID The ID of the tool use
3387   * @param toolInput The input that will be passed to the tool
3388   * @param permissionMode Optional permission mode from toolPermissionContext
3389   * @param signal Optional AbortSignal to cancel hook execution
3390   * @param timeoutMs Optional timeout in milliseconds for hook execution
3391   * @param toolUseContext Optional ToolUseContext for prompt-based hooks
3392   * @returns Async generator that yields progress messages and returns blocking errors
3393   */
3394  export async function* executePreToolHooks<ToolInput>(
3395    toolName: string,
3396    toolUseID: string,
3397    toolInput: ToolInput,
3398    toolUseContext: ToolUseContext,
3399    permissionMode?: string,
3400    signal?: AbortSignal,
3401    timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
3402    requestPrompt?: (
3403      sourceName: string,
3404      toolInputSummary?: string | null,
3405    ) => (request: PromptRequest) => Promise<PromptResponse>,
3406    toolInputSummary?: string | null,
3407  ): AsyncGenerator<AggregatedHookResult> {
3408    const appState = toolUseContext.getAppState()
3409    const sessionId = toolUseContext.agentId ?? getSessionId()
3410    if (!hasHookForEvent('PreToolUse', appState, sessionId)) {
3411      return
3412    }
3413  
3414    logForDebugging(`executePreToolHooks called for tool: ${toolName}`, {
3415      level: 'verbose',
3416    })
3417  
3418    const hookInput: PreToolUseHookInput = {
3419      ...createBaseHookInput(permissionMode, undefined, toolUseContext),
3420      hook_event_name: 'PreToolUse',
3421      tool_name: toolName,
3422      tool_input: toolInput,
3423      tool_use_id: toolUseID,
3424    }
3425  
3426    yield* executeHooks({
3427      hookInput,
3428      toolUseID,
3429      matchQuery: toolName,
3430      signal,
3431      timeoutMs,
3432      toolUseContext,
3433      requestPrompt,
3434      toolInputSummary,
3435    })
3436  }
3437  
3438  /**
3439   * Execute post-tool hooks if configured
3440   * @param toolName The name of the tool (e.g., 'Write', 'Edit', 'Bash')
3441   * @param toolUseID The ID of the tool use
3442   * @param toolInput The input that was passed to the tool
3443   * @param toolResponse The response from the tool
3444   * @param toolUseContext ToolUseContext for prompt-based hooks
3445   * @param permissionMode Optional permission mode from toolPermissionContext
3446   * @param signal Optional AbortSignal to cancel hook execution
3447   * @param timeoutMs Optional timeout in milliseconds for hook execution
3448   * @returns Async generator that yields progress messages and blocking errors for automated feedback
3449   */
3450  export async function* executePostToolHooks<ToolInput, ToolResponse>(
3451    toolName: string,
3452    toolUseID: string,
3453    toolInput: ToolInput,
3454    toolResponse: ToolResponse,
3455    toolUseContext: ToolUseContext,
3456    permissionMode?: string,
3457    signal?: AbortSignal,
3458    timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
3459  ): AsyncGenerator<AggregatedHookResult> {
3460    const hookInput: PostToolUseHookInput = {
3461      ...createBaseHookInput(permissionMode, undefined, toolUseContext),
3462      hook_event_name: 'PostToolUse',
3463      tool_name: toolName,
3464      tool_input: toolInput,
3465      tool_response: toolResponse,
3466      tool_use_id: toolUseID,
3467    }
3468  
3469    yield* executeHooks({
3470      hookInput,
3471      toolUseID,
3472      matchQuery: toolName,
3473      signal,
3474      timeoutMs,
3475      toolUseContext,
3476    })
3477  }
3478  
3479  /**
3480   * Execute post-tool-use-failure hooks if configured
3481   * @param toolName The name of the tool (e.g., 'Write', 'Edit', 'Bash')
3482   * @param toolUseID The ID of the tool use
3483   * @param toolInput The input that was passed to the tool
3484   * @param error The error message from the failed tool call
3485   * @param toolUseContext ToolUseContext for prompt-based hooks
3486   * @param isInterrupt Whether the tool was interrupted by user
3487   * @param permissionMode Optional permission mode from toolPermissionContext
3488   * @param signal Optional AbortSignal to cancel hook execution
3489   * @param timeoutMs Optional timeout in milliseconds for hook execution
3490   * @returns Async generator that yields progress messages and blocking errors
3491   */
3492  export async function* executePostToolUseFailureHooks<ToolInput>(
3493    toolName: string,
3494    toolUseID: string,
3495    toolInput: ToolInput,
3496    error: string,
3497    toolUseContext: ToolUseContext,
3498    isInterrupt?: boolean,
3499    permissionMode?: string,
3500    signal?: AbortSignal,
3501    timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
3502  ): AsyncGenerator<AggregatedHookResult> {
3503    const appState = toolUseContext.getAppState()
3504    const sessionId = toolUseContext.agentId ?? getSessionId()
3505    if (!hasHookForEvent('PostToolUseFailure', appState, sessionId)) {
3506      return
3507    }
3508  
3509    const hookInput: PostToolUseFailureHookInput = {
3510      ...createBaseHookInput(permissionMode, undefined, toolUseContext),
3511      hook_event_name: 'PostToolUseFailure',
3512      tool_name: toolName,
3513      tool_input: toolInput,
3514      tool_use_id: toolUseID,
3515      error,
3516      is_interrupt: isInterrupt,
3517    }
3518  
3519    yield* executeHooks({
3520      hookInput,
3521      toolUseID,
3522      matchQuery: toolName,
3523      signal,
3524      timeoutMs,
3525      toolUseContext,
3526    })
3527  }
3528  
3529  export async function* executePermissionDeniedHooks<ToolInput>(
3530    toolName: string,
3531    toolUseID: string,
3532    toolInput: ToolInput,
3533    reason: string,
3534    toolUseContext: ToolUseContext,
3535    permissionMode?: string,
3536    signal?: AbortSignal,
3537    timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
3538  ): AsyncGenerator<AggregatedHookResult> {
3539    const appState = toolUseContext.getAppState()
3540    const sessionId = toolUseContext.agentId ?? getSessionId()
3541    if (!hasHookForEvent('PermissionDenied', appState, sessionId)) {
3542      return
3543    }
3544  
3545    const hookInput: PermissionDeniedHookInput = {
3546      ...createBaseHookInput(permissionMode, undefined, toolUseContext),
3547      hook_event_name: 'PermissionDenied',
3548      tool_name: toolName,
3549      tool_input: toolInput,
3550      tool_use_id: toolUseID,
3551      reason,
3552    }
3553  
3554    yield* executeHooks({
3555      hookInput,
3556      toolUseID,
3557      matchQuery: toolName,
3558      signal,
3559      timeoutMs,
3560      toolUseContext,
3561    })
3562  }
3563  
3564  /**
3565   * Execute notification hooks if configured
3566   * @param notificationData The notification data to pass to hooks
3567   * @param timeoutMs Optional timeout in milliseconds for hook execution
3568   * @returns Promise that resolves when all hooks complete
3569   */
3570  export async function executeNotificationHooks(
3571    notificationData: {
3572      message: string
3573      title?: string
3574      notificationType: string
3575    },
3576    timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
3577  ): Promise<void> {
3578    const { message, title, notificationType } = notificationData
3579    const hookInput: NotificationHookInput = {
3580      ...createBaseHookInput(undefined),
3581      hook_event_name: 'Notification',
3582      message,
3583      title,
3584      notification_type: notificationType,
3585    }
3586  
3587    await executeHooksOutsideREPL({
3588      hookInput,
3589      timeoutMs,
3590      matchQuery: notificationType,
3591    })
3592  }
3593  
3594  export async function executeStopFailureHooks(
3595    lastMessage: AssistantMessage,
3596    toolUseContext?: ToolUseContext,
3597    timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
3598  ): Promise<void> {
3599    const appState = toolUseContext?.getAppState()
3600    // executeHooksOutsideREPL hardcodes main sessionId (:2738). Agent frontmatter
3601    // hooks (registerFrontmatterHooks) key by agentId; gating with agentId here
3602    // would pass the gate but fail execution. Align gate with execution.
3603    const sessionId = getSessionId()
3604    if (!hasHookForEvent('StopFailure', appState, sessionId)) return
3605  
3606    const lastAssistantText =
3607      extractTextContent(lastMessage.message.content, '\n').trim() || undefined
3608  
3609    // Some createAssistantAPIErrorMessage call sites omit `error` (e.g.
3610    // image-size at errors.ts:431). Default to 'unknown' so matcher filtering
3611    // at getMatchingHooks:1525 always applies.
3612    const error = lastMessage.error ?? 'unknown'
3613    const hookInput: StopFailureHookInput = {
3614      ...createBaseHookInput(undefined, undefined, toolUseContext),
3615      hook_event_name: 'StopFailure',
3616      error,
3617      error_details: lastMessage.errorDetails,
3618      last_assistant_message: lastAssistantText,
3619    }
3620  
3621    await executeHooksOutsideREPL({
3622      getAppState: toolUseContext?.getAppState,
3623      hookInput,
3624      timeoutMs,
3625      matchQuery: error,
3626    })
3627  }
3628  
3629  /**
3630   * Execute stop hooks if configured
3631   * @param toolUseContext ToolUseContext for prompt-based hooks
3632   * @param permissionMode permission mode from toolPermissionContext
3633   * @param signal AbortSignal to cancel hook execution
3634   * @param stopHookActive Whether this call is happening within another stop hook
3635   * @param isSubagent Whether the current execution context is a subagent
3636   * @param messages Optional conversation history for prompt/function hooks
3637   * @returns Async generator that yields progress messages and blocking errors
3638   */
3639  export async function* executeStopHooks(
3640    permissionMode?: string,
3641    signal?: AbortSignal,
3642    timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
3643    stopHookActive: boolean = false,
3644    subagentId?: AgentId,
3645    toolUseContext?: ToolUseContext,
3646    messages?: Message[],
3647    agentType?: string,
3648    requestPrompt?: (
3649      sourceName: string,
3650      toolInputSummary?: string | null,
3651    ) => (request: PromptRequest) => Promise<PromptResponse>,
3652  ): AsyncGenerator<AggregatedHookResult> {
3653    const hookEvent = subagentId ? 'SubagentStop' : 'Stop'
3654    const appState = toolUseContext?.getAppState()
3655    const sessionId = toolUseContext?.agentId ?? getSessionId()
3656    if (!hasHookForEvent(hookEvent, appState, sessionId)) {
3657      return
3658    }
3659  
3660    // Extract text content from the last assistant message so hooks can
3661    // inspect the final response without reading the transcript file.
3662    const lastAssistantMessage = messages
3663      ? getLastAssistantMessage(messages)
3664      : undefined
3665    const lastAssistantText = lastAssistantMessage
3666      ? extractTextContent(lastAssistantMessage.message.content, '\n').trim() ||
3667        undefined
3668      : undefined
3669  
3670    const hookInput: StopHookInput | SubagentStopHookInput = subagentId
3671      ? {
3672          ...createBaseHookInput(permissionMode),
3673          hook_event_name: 'SubagentStop',
3674          stop_hook_active: stopHookActive,
3675          agent_id: subagentId,
3676          agent_transcript_path: getAgentTranscriptPath(subagentId),
3677          agent_type: agentType ?? '',
3678          last_assistant_message: lastAssistantText,
3679        }
3680      : {
3681          ...createBaseHookInput(permissionMode),
3682          hook_event_name: 'Stop',
3683          stop_hook_active: stopHookActive,
3684          last_assistant_message: lastAssistantText,
3685        }
3686  
3687    // Trust check is now centralized in executeHooks()
3688    yield* executeHooks({
3689      hookInput,
3690      toolUseID: randomUUID(),
3691      signal,
3692      timeoutMs,
3693      toolUseContext,
3694      messages,
3695      requestPrompt,
3696    })
3697  }
3698  
3699  /**
3700   * Execute TeammateIdle hooks when a teammate is about to go idle.
3701   * If a hook blocks (exit code 2), the teammate should continue working instead of going idle.
3702   * @param teammateName The name of the teammate going idle
3703   * @param teamName The team this teammate belongs to
3704   * @param permissionMode Optional permission mode
3705   * @param signal Optional AbortSignal to cancel hook execution
3706   * @param timeoutMs Optional timeout in milliseconds for hook execution
3707   * @returns Async generator that yields progress messages and blocking errors
3708   */
3709  export async function* executeTeammateIdleHooks(
3710    teammateName: string,
3711    teamName: string,
3712    permissionMode?: string,
3713    signal?: AbortSignal,
3714    timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
3715  ): AsyncGenerator<AggregatedHookResult> {
3716    const hookInput: TeammateIdleHookInput = {
3717      ...createBaseHookInput(permissionMode),
3718      hook_event_name: 'TeammateIdle',
3719      teammate_name: teammateName,
3720      team_name: teamName,
3721    }
3722  
3723    yield* executeHooks({
3724      hookInput,
3725      toolUseID: randomUUID(),
3726      signal,
3727      timeoutMs,
3728    })
3729  }
3730  
3731  /**
3732   * Execute TaskCreated hooks when a task is being created.
3733   * If a hook blocks (exit code 2), the task creation should be prevented and feedback returned.
3734   * @param taskId The ID of the task being created
3735   * @param taskSubject The subject/title of the task
3736   * @param taskDescription Optional description of the task
3737   * @param teammateName Optional name of the teammate creating the task
3738   * @param teamName Optional team name
3739   * @param permissionMode Optional permission mode
3740   * @param signal Optional AbortSignal to cancel hook execution
3741   * @param timeoutMs Optional timeout in milliseconds for hook execution
3742   * @param toolUseContext Optional ToolUseContext for resolving appState and sessionId
3743   * @returns Async generator that yields progress messages and blocking errors
3744   */
3745  export async function* executeTaskCreatedHooks(
3746    taskId: string,
3747    taskSubject: string,
3748    taskDescription?: string,
3749    teammateName?: string,
3750    teamName?: string,
3751    permissionMode?: string,
3752    signal?: AbortSignal,
3753    timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
3754    toolUseContext?: ToolUseContext,
3755  ): AsyncGenerator<AggregatedHookResult> {
3756    const hookInput: TaskCreatedHookInput = {
3757      ...createBaseHookInput(permissionMode),
3758      hook_event_name: 'TaskCreated',
3759      task_id: taskId,
3760      task_subject: taskSubject,
3761      task_description: taskDescription,
3762      teammate_name: teammateName,
3763      team_name: teamName,
3764    }
3765  
3766    yield* executeHooks({
3767      hookInput,
3768      toolUseID: randomUUID(),
3769      signal,
3770      timeoutMs,
3771      toolUseContext,
3772    })
3773  }
3774  
3775  /**
3776   * Execute TaskCompleted hooks when a task is being marked as completed.
3777   * If a hook blocks (exit code 2), the task completion should be prevented and feedback returned.
3778   * @param taskId The ID of the task being completed
3779   * @param taskSubject The subject/title of the task
3780   * @param taskDescription Optional description of the task
3781   * @param teammateName Optional name of the teammate completing the task
3782   * @param teamName Optional team name
3783   * @param permissionMode Optional permission mode
3784   * @param signal Optional AbortSignal to cancel hook execution
3785   * @param timeoutMs Optional timeout in milliseconds for hook execution
3786   * @param toolUseContext Optional ToolUseContext for resolving appState and sessionId
3787   * @returns Async generator that yields progress messages and blocking errors
3788   */
3789  export async function* executeTaskCompletedHooks(
3790    taskId: string,
3791    taskSubject: string,
3792    taskDescription?: string,
3793    teammateName?: string,
3794    teamName?: string,
3795    permissionMode?: string,
3796    signal?: AbortSignal,
3797    timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
3798    toolUseContext?: ToolUseContext,
3799  ): AsyncGenerator<AggregatedHookResult> {
3800    const hookInput: TaskCompletedHookInput = {
3801      ...createBaseHookInput(permissionMode),
3802      hook_event_name: 'TaskCompleted',
3803      task_id: taskId,
3804      task_subject: taskSubject,
3805      task_description: taskDescription,
3806      teammate_name: teammateName,
3807      team_name: teamName,
3808    }
3809  
3810    yield* executeHooks({
3811      hookInput,
3812      toolUseID: randomUUID(),
3813      signal,
3814      timeoutMs,
3815      toolUseContext,
3816    })
3817  }
3818  
3819  /**
3820   * Execute start hooks if configured
3821   * @param prompt The user prompt that will be passed to the tool
3822   * @param permissionMode Permission mode from toolPermissionContext
3823   * @param toolUseContext ToolUseContext for prompt-based hooks
3824   * @returns Async generator that yields progress messages and hook results
3825   */
3826  export async function* executeUserPromptSubmitHooks(
3827    prompt: string,
3828    permissionMode: string,
3829    toolUseContext: ToolUseContext,
3830    requestPrompt?: (
3831      sourceName: string,
3832      toolInputSummary?: string | null,
3833    ) => (request: PromptRequest) => Promise<PromptResponse>,
3834  ): AsyncGenerator<AggregatedHookResult> {
3835    const appState = toolUseContext.getAppState()
3836    const sessionId = toolUseContext.agentId ?? getSessionId()
3837    if (!hasHookForEvent('UserPromptSubmit', appState, sessionId)) {
3838      return
3839    }
3840  
3841    const hookInput: UserPromptSubmitHookInput = {
3842      ...createBaseHookInput(permissionMode),
3843      hook_event_name: 'UserPromptSubmit',
3844      prompt,
3845    }
3846  
3847    yield* executeHooks({
3848      hookInput,
3849      toolUseID: randomUUID(),
3850      signal: toolUseContext.abortController.signal,
3851      timeoutMs: TOOL_HOOK_EXECUTION_TIMEOUT_MS,
3852      toolUseContext,
3853      requestPrompt,
3854    })
3855  }
3856  
3857  /**
3858   * Execute session start hooks if configured
3859   * @param source The source of the session start (startup, resume, clear)
3860   * @param sessionId Optional The session id to use as hook input
3861   * @param agentType Optional The agent type (from --agent flag) running this session
3862   * @param model Optional The model being used for this session
3863   * @param signal Optional AbortSignal to cancel hook execution
3864   * @param timeoutMs Optional timeout in milliseconds for hook execution
3865   * @returns Async generator that yields progress messages and hook results
3866   */
3867  export async function* executeSessionStartHooks(
3868    source: 'startup' | 'resume' | 'clear' | 'compact',
3869    sessionId?: string,
3870    agentType?: string,
3871    model?: string,
3872    signal?: AbortSignal,
3873    timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
3874    forceSyncExecution?: boolean,
3875  ): AsyncGenerator<AggregatedHookResult> {
3876    const hookInput: SessionStartHookInput = {
3877      ...createBaseHookInput(undefined, sessionId),
3878      hook_event_name: 'SessionStart',
3879      source,
3880      agent_type: agentType,
3881      model,
3882    }
3883  
3884    yield* executeHooks({
3885      hookInput,
3886      toolUseID: randomUUID(),
3887      matchQuery: source,
3888      signal,
3889      timeoutMs,
3890      forceSyncExecution,
3891    })
3892  }
3893  
3894  /**
3895   * Execute setup hooks if configured
3896   * @param trigger The trigger type ('init' or 'maintenance')
3897   * @param signal Optional AbortSignal to cancel hook execution
3898   * @param timeoutMs Optional timeout in milliseconds for hook execution
3899   * @param forceSyncExecution If true, async hooks will not be backgrounded
3900   * @returns Async generator that yields progress messages and hook results
3901   */
3902  export async function* executeSetupHooks(
3903    trigger: 'init' | 'maintenance',
3904    signal?: AbortSignal,
3905    timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
3906    forceSyncExecution?: boolean,
3907  ): AsyncGenerator<AggregatedHookResult> {
3908    const hookInput: SetupHookInput = {
3909      ...createBaseHookInput(undefined),
3910      hook_event_name: 'Setup',
3911      trigger,
3912    }
3913  
3914    yield* executeHooks({
3915      hookInput,
3916      toolUseID: randomUUID(),
3917      matchQuery: trigger,
3918      signal,
3919      timeoutMs,
3920      forceSyncExecution,
3921    })
3922  }
3923  
3924  /**
3925   * Execute subagent start hooks if configured
3926   * @param agentId The unique identifier for the subagent
3927   * @param agentType The type/name of the subagent being started
3928   * @param signal Optional AbortSignal to cancel hook execution
3929   * @param timeoutMs Optional timeout in milliseconds for hook execution
3930   * @returns Async generator that yields progress messages and hook results
3931   */
3932  export async function* executeSubagentStartHooks(
3933    agentId: string,
3934    agentType: string,
3935    signal?: AbortSignal,
3936    timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
3937  ): AsyncGenerator<AggregatedHookResult> {
3938    const hookInput: SubagentStartHookInput = {
3939      ...createBaseHookInput(undefined),
3940      hook_event_name: 'SubagentStart',
3941      agent_id: agentId,
3942      agent_type: agentType,
3943    }
3944  
3945    yield* executeHooks({
3946      hookInput,
3947      toolUseID: randomUUID(),
3948      matchQuery: agentType,
3949      signal,
3950      timeoutMs,
3951    })
3952  }
3953  
3954  /**
3955   * Execute pre-compact hooks if configured
3956   * @param compactData The compact data to pass to hooks
3957   * @param signal Optional AbortSignal to cancel hook execution
3958   * @param timeoutMs Optional timeout in milliseconds for hook execution
3959   * @returns Object with optional newCustomInstructions and userDisplayMessage
3960   */
3961  export async function executePreCompactHooks(
3962    compactData: {
3963      trigger: 'manual' | 'auto'
3964      customInstructions: string | null
3965    },
3966    signal?: AbortSignal,
3967    timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
3968  ): Promise<{
3969    newCustomInstructions?: string
3970    userDisplayMessage?: string
3971  }> {
3972    const hookInput: PreCompactHookInput = {
3973      ...createBaseHookInput(undefined),
3974      hook_event_name: 'PreCompact',
3975      trigger: compactData.trigger,
3976      custom_instructions: compactData.customInstructions,
3977    }
3978  
3979    const results = await executeHooksOutsideREPL({
3980      hookInput,
3981      matchQuery: compactData.trigger,
3982      signal,
3983      timeoutMs,
3984    })
3985  
3986    if (results.length === 0) {
3987      return {}
3988    }
3989  
3990    // Extract custom instructions from successful hooks with non-empty output
3991    const successfulOutputs = results
3992      .filter(result => result.succeeded && result.output.trim().length > 0)
3993      .map(result => result.output.trim())
3994  
3995    // Build user display messages with command info
3996    const displayMessages: string[] = []
3997    for (const result of results) {
3998      if (result.succeeded) {
3999        if (result.output.trim()) {
4000          displayMessages.push(
4001            `PreCompact [${result.command}] completed successfully: ${result.output.trim()}`,
4002          )
4003        } else {
4004          displayMessages.push(
4005            `PreCompact [${result.command}] completed successfully`,
4006          )
4007        }
4008      } else {
4009        if (result.output.trim()) {
4010          displayMessages.push(
4011            `PreCompact [${result.command}] failed: ${result.output.trim()}`,
4012          )
4013        } else {
4014          displayMessages.push(`PreCompact [${result.command}] failed`)
4015        }
4016      }
4017    }
4018  
4019    return {
4020      newCustomInstructions:
4021        successfulOutputs.length > 0 ? successfulOutputs.join('\n\n') : undefined,
4022      userDisplayMessage:
4023        displayMessages.length > 0 ? displayMessages.join('\n') : undefined,
4024    }
4025  }
4026  
4027  /**
4028   * Execute post-compact hooks if configured
4029   * @param compactData The compact data to pass to hooks, including the summary
4030   * @param signal Optional AbortSignal to cancel hook execution
4031   * @param timeoutMs Optional timeout in milliseconds for hook execution
4032   * @returns Object with optional userDisplayMessage
4033   */
4034  export async function executePostCompactHooks(
4035    compactData: {
4036      trigger: 'manual' | 'auto'
4037      compactSummary: string
4038    },
4039    signal?: AbortSignal,
4040    timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
4041  ): Promise<{
4042    userDisplayMessage?: string
4043  }> {
4044    const hookInput: PostCompactHookInput = {
4045      ...createBaseHookInput(undefined),
4046      hook_event_name: 'PostCompact',
4047      trigger: compactData.trigger,
4048      compact_summary: compactData.compactSummary,
4049    }
4050  
4051    const results = await executeHooksOutsideREPL({
4052      hookInput,
4053      matchQuery: compactData.trigger,
4054      signal,
4055      timeoutMs,
4056    })
4057  
4058    if (results.length === 0) {
4059      return {}
4060    }
4061  
4062    const displayMessages: string[] = []
4063    for (const result of results) {
4064      if (result.succeeded) {
4065        if (result.output.trim()) {
4066          displayMessages.push(
4067            `PostCompact [${result.command}] completed successfully: ${result.output.trim()}`,
4068          )
4069        } else {
4070          displayMessages.push(
4071            `PostCompact [${result.command}] completed successfully`,
4072          )
4073        }
4074      } else {
4075        if (result.output.trim()) {
4076          displayMessages.push(
4077            `PostCompact [${result.command}] failed: ${result.output.trim()}`,
4078          )
4079        } else {
4080          displayMessages.push(`PostCompact [${result.command}] failed`)
4081        }
4082      }
4083    }
4084  
4085    return {
4086      userDisplayMessage:
4087        displayMessages.length > 0 ? displayMessages.join('\n') : undefined,
4088    }
4089  }
4090  
4091  /**
4092   * Execute session end hooks if configured
4093   * @param reason The reason for ending the session
4094   * @param options Optional parameters including app state functions and signal
4095   * @returns Promise that resolves when all hooks complete
4096   */
4097  export async function executeSessionEndHooks(
4098    reason: ExitReason,
4099    options?: {
4100      getAppState?: () => AppState
4101      setAppState?: (updater: (prev: AppState) => AppState) => void
4102      signal?: AbortSignal
4103      timeoutMs?: number
4104    },
4105  ): Promise<void> {
4106    const {
4107      getAppState,
4108      setAppState,
4109      signal,
4110      timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
4111    } = options || {}
4112  
4113    const hookInput: SessionEndHookInput = {
4114      ...createBaseHookInput(undefined),
4115      hook_event_name: 'SessionEnd',
4116      reason,
4117    }
4118  
4119    const results = await executeHooksOutsideREPL({
4120      getAppState,
4121      hookInput,
4122      matchQuery: reason,
4123      signal,
4124      timeoutMs,
4125    })
4126  
4127    // During shutdown, Ink is unmounted so we can write directly to stderr
4128    for (const result of results) {
4129      if (!result.succeeded && result.output) {
4130        process.stderr.write(
4131          `SessionEnd hook [${result.command}] failed: ${result.output}\n`,
4132        )
4133      }
4134    }
4135  
4136    // Clear session hooks after execution
4137    if (setAppState) {
4138      const sessionId = getSessionId()
4139      clearSessionHooks(setAppState, sessionId)
4140    }
4141  }
4142  
4143  /**
4144   * Execute permission request hooks if configured
4145   * These hooks are called when a permission dialog would be displayed to the user.
4146   * Hooks can approve or deny the permission request programmatically.
4147   * @param toolName The name of the tool requesting permission
4148   * @param toolUseID The ID of the tool use
4149   * @param toolInput The input that would be passed to the tool
4150   * @param toolUseContext ToolUseContext for the request
4151   * @param permissionMode Optional permission mode from toolPermissionContext
4152   * @param permissionSuggestions Optional permission suggestions (the "always allow" options)
4153   * @param signal Optional AbortSignal to cancel hook execution
4154   * @param timeoutMs Optional timeout in milliseconds for hook execution
4155   * @returns Async generator that yields progress messages and returns aggregated result
4156   */
4157  export async function* executePermissionRequestHooks<ToolInput>(
4158    toolName: string,
4159    toolUseID: string,
4160    toolInput: ToolInput,
4161    toolUseContext: ToolUseContext,
4162    permissionMode?: string,
4163    permissionSuggestions?: PermissionUpdate[],
4164    signal?: AbortSignal,
4165    timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
4166    requestPrompt?: (
4167      sourceName: string,
4168      toolInputSummary?: string | null,
4169    ) => (request: PromptRequest) => Promise<PromptResponse>,
4170    toolInputSummary?: string | null,
4171  ): AsyncGenerator<AggregatedHookResult> {
4172    logForDebugging(`executePermissionRequestHooks called for tool: ${toolName}`)
4173  
4174    const hookInput: PermissionRequestHookInput = {
4175      ...createBaseHookInput(permissionMode, undefined, toolUseContext),
4176      hook_event_name: 'PermissionRequest',
4177      tool_name: toolName,
4178      tool_input: toolInput,
4179      permission_suggestions: permissionSuggestions,
4180    }
4181  
4182    yield* executeHooks({
4183      hookInput,
4184      toolUseID,
4185      matchQuery: toolName,
4186      signal,
4187      timeoutMs,
4188      toolUseContext,
4189      requestPrompt,
4190      toolInputSummary,
4191    })
4192  }
4193  
4194  export type ConfigChangeSource =
4195    | 'user_settings'
4196    | 'project_settings'
4197    | 'local_settings'
4198    | 'policy_settings'
4199    | 'skills'
4200  
4201  /**
4202   * Execute config change hooks when configuration files change during a session.
4203   * Fired by file watchers when settings, skills, or commands change on disk.
4204   * Enables enterprise admins to audit/log configuration changes for security.
4205   *
4206   * Policy settings are enterprise-managed and must never be blockable by hooks.
4207   * Hooks still fire (for audit logging) but blocking results are ignored — callers
4208   * will always see an empty result for policy sources.
4209   *
4210   * @param source The type of config that changed
4211   * @param filePath Optional path to the changed file
4212   * @param timeoutMs Optional timeout in milliseconds for hook execution
4213   */
4214  export async function executeConfigChangeHooks(
4215    source: ConfigChangeSource,
4216    filePath?: string,
4217    timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
4218  ): Promise<HookOutsideReplResult[]> {
4219    const hookInput: ConfigChangeHookInput = {
4220      ...createBaseHookInput(undefined),
4221      hook_event_name: 'ConfigChange',
4222      source,
4223      file_path: filePath,
4224    }
4225  
4226    const results = await executeHooksOutsideREPL({
4227      hookInput,
4228      timeoutMs,
4229      matchQuery: source,
4230    })
4231  
4232    // Policy settings are enterprise-managed — hooks fire for audit logging
4233    // but must never block policy changes from being applied
4234    if (source === 'policy_settings') {
4235      return results.map(r => ({ ...r, blocked: false }))
4236    }
4237  
4238    return results
4239  }
4240  
4241  async function executeEnvHooks(
4242    hookInput: HookInput,
4243    timeoutMs: number,
4244  ): Promise<{
4245    results: HookOutsideReplResult[]
4246    watchPaths: string[]
4247    systemMessages: string[]
4248  }> {
4249    const results = await executeHooksOutsideREPL({ hookInput, timeoutMs })
4250    if (results.length > 0) {
4251      invalidateSessionEnvCache()
4252    }
4253    const watchPaths = results.flatMap(r => r.watchPaths ?? [])
4254    const systemMessages = results
4255      .map(r => r.systemMessage)
4256      .filter((m): m is string => !!m)
4257    return { results, watchPaths, systemMessages }
4258  }
4259  
4260  export function executeCwdChangedHooks(
4261    oldCwd: string,
4262    newCwd: string,
4263    timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
4264  ): Promise<{
4265    results: HookOutsideReplResult[]
4266    watchPaths: string[]
4267    systemMessages: string[]
4268  }> {
4269    const hookInput: CwdChangedHookInput = {
4270      ...createBaseHookInput(undefined),
4271      hook_event_name: 'CwdChanged',
4272      old_cwd: oldCwd,
4273      new_cwd: newCwd,
4274    }
4275    return executeEnvHooks(hookInput, timeoutMs)
4276  }
4277  
4278  export function executeFileChangedHooks(
4279    filePath: string,
4280    event: 'change' | 'add' | 'unlink',
4281    timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
4282  ): Promise<{
4283    results: HookOutsideReplResult[]
4284    watchPaths: string[]
4285    systemMessages: string[]
4286  }> {
4287    const hookInput: FileChangedHookInput = {
4288      ...createBaseHookInput(undefined),
4289      hook_event_name: 'FileChanged',
4290      file_path: filePath,
4291      event,
4292    }
4293    return executeEnvHooks(hookInput, timeoutMs)
4294  }
4295  
4296  export type InstructionsLoadReason =
4297    | 'session_start'
4298    | 'nested_traversal'
4299    | 'path_glob_match'
4300    | 'include'
4301    | 'compact'
4302  
4303  export type InstructionsMemoryType = 'User' | 'Project' | 'Local' | 'Managed'
4304  
4305  /**
4306   * Check if InstructionsLoaded hooks are configured (without executing them).
4307   * Callers should check this before invoking executeInstructionsLoadedHooks to avoid
4308   * building hook inputs for every instruction file when no hook is configured.
4309   *
4310   * Checks both settings-file hooks (getHooksConfigFromSnapshot) and registered
4311   * hooks (plugin hooks + SDK callback hooks via registerHookCallbacks). Session-
4312   * derived hooks (structured output enforcement etc.) are internal and not checked.
4313   */
4314  export function hasInstructionsLoadedHook(): boolean {
4315    const snapshotHooks = getHooksConfigFromSnapshot()?.['InstructionsLoaded']
4316    if (snapshotHooks && snapshotHooks.length > 0) return true
4317    const registeredHooks = getRegisteredHooks()?.['InstructionsLoaded']
4318    if (registeredHooks && registeredHooks.length > 0) return true
4319    return false
4320  }
4321  
4322  /**
4323   * Execute InstructionsLoaded hooks when an instruction file (CLAUDE.md or
4324   * .claude/rules/*.md) is loaded into context. Fire-and-forget — this hook is
4325   * for observability/audit only and does not support blocking.
4326   *
4327   * Dispatch sites:
4328   * - Eager load at session start (getMemoryFiles in claudemd.ts)
4329   * - Eager reload after compaction (getMemoryFiles cache cleared by
4330   *   runPostCompactCleanup; next call reports load_reason: 'compact')
4331   * - Lazy load when Claude touches a file that triggers nested CLAUDE.md or
4332   *   conditional rules with paths: frontmatter (memoryFilesToAttachments in
4333   *   attachments.ts)
4334   */
4335  export async function executeInstructionsLoadedHooks(
4336    filePath: string,
4337    memoryType: InstructionsMemoryType,
4338    loadReason: InstructionsLoadReason,
4339    options?: {
4340      globs?: string[]
4341      triggerFilePath?: string
4342      parentFilePath?: string
4343      timeoutMs?: number
4344    },
4345  ): Promise<void> {
4346    const {
4347      globs,
4348      triggerFilePath,
4349      parentFilePath,
4350      timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
4351    } = options ?? {}
4352  
4353    const hookInput: InstructionsLoadedHookInput = {
4354      ...createBaseHookInput(undefined),
4355      hook_event_name: 'InstructionsLoaded',
4356      file_path: filePath,
4357      memory_type: memoryType,
4358      load_reason: loadReason,
4359      globs,
4360      trigger_file_path: triggerFilePath,
4361      parent_file_path: parentFilePath,
4362    }
4363  
4364    await executeHooksOutsideREPL({
4365      hookInput,
4366      timeoutMs,
4367      matchQuery: loadReason,
4368    })
4369  }
4370  
4371  /** Result of an elicitation hook execution (non-REPL path). */
4372  export type ElicitationHookResult = {
4373    elicitationResponse?: ElicitationResponse
4374    blockingError?: HookBlockingError
4375  }
4376  
4377  /** Result of an elicitation-result hook execution (non-REPL path). */
4378  export type ElicitationResultHookResult = {
4379    elicitationResultResponse?: ElicitationResponse
4380    blockingError?: HookBlockingError
4381  }
4382  
4383  /**
4384   * Parse elicitation-specific fields from a HookOutsideReplResult.
4385   * Mirrors the relevant branches of processHookJSONOutput for Elicitation
4386   * and ElicitationResult hook events.
4387   */
4388  function parseElicitationHookOutput(
4389    result: HookOutsideReplResult,
4390    expectedEventName: 'Elicitation' | 'ElicitationResult',
4391  ): {
4392    response?: ElicitationResponse
4393    blockingError?: HookBlockingError
4394  } {
4395    // Exit code 2 = blocking (same as executeHooks path)
4396    if (result.blocked && !result.succeeded) {
4397      return {
4398        blockingError: {
4399          blockingError: result.output || `Elicitation blocked by hook`,
4400          command: result.command,
4401        },
4402      }
4403    }
4404  
4405    if (!result.output.trim()) {
4406      return {}
4407    }
4408  
4409    // Try to parse JSON output for structured elicitation response
4410    const trimmed = result.output.trim()
4411    if (!trimmed.startsWith('{')) {
4412      return {}
4413    }
4414  
4415    try {
4416      const parsed = hookJSONOutputSchema().parse(JSON.parse(trimmed))
4417      if (isAsyncHookJSONOutput(parsed)) {
4418        return {}
4419      }
4420      if (!isSyncHookJSONOutput(parsed)) {
4421        return {}
4422      }
4423  
4424      // Check for top-level decision: 'block' (exit code 0 + JSON block)
4425      if (parsed.decision === 'block' || result.blocked) {
4426        return {
4427          blockingError: {
4428            blockingError: parsed.reason || 'Elicitation blocked by hook',
4429            command: result.command,
4430          },
4431        }
4432      }
4433  
4434      const specific = parsed.hookSpecificOutput
4435      if (!specific || specific.hookEventName !== expectedEventName) {
4436        return {}
4437      }
4438  
4439      if (!specific.action) {
4440        return {}
4441      }
4442  
4443      const response: ElicitationResponse = {
4444        action: specific.action,
4445        content: specific.content as ElicitationResponse['content'] | undefined,
4446      }
4447  
4448      const out: {
4449        response?: ElicitationResponse
4450        blockingError?: HookBlockingError
4451      } = { response }
4452  
4453      if (specific.action === 'decline') {
4454        out.blockingError = {
4455          blockingError:
4456            parsed.reason ||
4457            (expectedEventName === 'Elicitation'
4458              ? 'Elicitation denied by hook'
4459              : 'Elicitation result blocked by hook'),
4460          command: result.command,
4461        }
4462      }
4463  
4464      return out
4465    } catch {
4466      return {}
4467    }
4468  }
4469  
4470  export async function executeElicitationHooks({
4471    serverName,
4472    message,
4473    requestedSchema,
4474    permissionMode,
4475    signal,
4476    timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
4477    mode,
4478    url,
4479    elicitationId,
4480  }: {
4481    serverName: string
4482    message: string
4483    requestedSchema?: Record<string, unknown>
4484    permissionMode?: string
4485    signal?: AbortSignal
4486    timeoutMs?: number
4487    mode?: 'form' | 'url'
4488    url?: string
4489    elicitationId?: string
4490  }): Promise<ElicitationHookResult> {
4491    const hookInput: ElicitationHookInput = {
4492      ...createBaseHookInput(permissionMode),
4493      hook_event_name: 'Elicitation',
4494      mcp_server_name: serverName,
4495      message,
4496      mode,
4497      url,
4498      elicitation_id: elicitationId,
4499      requested_schema: requestedSchema,
4500    }
4501  
4502    const results = await executeHooksOutsideREPL({
4503      hookInput,
4504      matchQuery: serverName,
4505      signal,
4506      timeoutMs,
4507    })
4508  
4509    let elicitationResponse: ElicitationResponse | undefined
4510    let blockingError: HookBlockingError | undefined
4511  
4512    for (const result of results) {
4513      const parsed = parseElicitationHookOutput(result, 'Elicitation')
4514      if (parsed.blockingError) {
4515        blockingError = parsed.blockingError
4516      }
4517      if (parsed.response) {
4518        elicitationResponse = parsed.response
4519      }
4520    }
4521  
4522    return { elicitationResponse, blockingError }
4523  }
4524  
4525  export async function executeElicitationResultHooks({
4526    serverName,
4527    action,
4528    content,
4529    permissionMode,
4530    signal,
4531    timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
4532    mode,
4533    elicitationId,
4534  }: {
4535    serverName: string
4536    action: 'accept' | 'decline' | 'cancel'
4537    content?: Record<string, unknown>
4538    permissionMode?: string
4539    signal?: AbortSignal
4540    timeoutMs?: number
4541    mode?: 'form' | 'url'
4542    elicitationId?: string
4543  }): Promise<ElicitationResultHookResult> {
4544    const hookInput: ElicitationResultHookInput = {
4545      ...createBaseHookInput(permissionMode),
4546      hook_event_name: 'ElicitationResult',
4547      mcp_server_name: serverName,
4548      elicitation_id: elicitationId,
4549      mode,
4550      action,
4551      content,
4552    }
4553  
4554    const results = await executeHooksOutsideREPL({
4555      hookInput,
4556      matchQuery: serverName,
4557      signal,
4558      timeoutMs,
4559    })
4560  
4561    let elicitationResultResponse: ElicitationResponse | undefined
4562    let blockingError: HookBlockingError | undefined
4563  
4564    for (const result of results) {
4565      const parsed = parseElicitationHookOutput(result, 'ElicitationResult')
4566      if (parsed.blockingError) {
4567        blockingError = parsed.blockingError
4568      }
4569      if (parsed.response) {
4570        elicitationResultResponse = parsed.response
4571      }
4572    }
4573  
4574    return { elicitationResultResponse, blockingError }
4575  }
4576  
4577  /**
4578   * Execute status line command if configured
4579   * @param statusLineInput The structured status input that will be converted to JSON
4580   * @param signal Optional AbortSignal to cancel hook execution
4581   * @param timeoutMs Optional timeout in milliseconds for hook execution
4582   * @returns The status line text to display, or undefined if no command configured
4583   */
4584  export async function executeStatusLineCommand(
4585    statusLineInput: StatusLineCommandInput,
4586    signal?: AbortSignal,
4587    timeoutMs: number = 5000, // Short timeout for status line
4588    logResult: boolean = false,
4589  ): Promise<string | undefined> {
4590    // Check if all hooks (including statusLine) are disabled by managed settings
4591    if (shouldDisableAllHooksIncludingManaged()) {
4592      return undefined
4593    }
4594  
4595    // SECURITY: ALL hooks require workspace trust in interactive mode
4596    // This centralized check prevents RCE vulnerabilities for all current and future hooks
4597    if (shouldSkipHookDueToTrust()) {
4598      logForDebugging(
4599        `Skipping StatusLine command execution - workspace trust not accepted`,
4600      )
4601      return undefined
4602    }
4603  
4604    // When disableAllHooks is set in non-managed settings, only managed statusLine runs
4605    // (non-managed settings cannot disable managed commands, but non-managed commands are disabled)
4606    let statusLine
4607    if (shouldAllowManagedHooksOnly()) {
4608      statusLine = getSettingsForSource('policySettings')?.statusLine
4609    } else {
4610      statusLine = getSettings_DEPRECATED()?.statusLine
4611    }
4612  
4613    if (!statusLine || statusLine.type !== 'command') {
4614      return undefined
4615    }
4616  
4617    // Use provided signal or create a default one
4618    const abortSignal = signal || AbortSignal.timeout(timeoutMs)
4619  
4620    try {
4621      // Convert status input to JSON
4622      const jsonInput = jsonStringify(statusLineInput)
4623  
4624      const result = await execCommandHook(
4625        statusLine,
4626        'StatusLine',
4627        'statusLine',
4628        jsonInput,
4629        abortSignal,
4630        randomUUID(),
4631      )
4632  
4633      if (result.aborted) {
4634        return undefined
4635      }
4636  
4637      // For successful hooks (exit code 0), use stdout
4638      if (result.status === 0) {
4639        // Trim and split output into lines, then join with newlines
4640        const output = result.stdout
4641          .trim()
4642          .split('\n')
4643          .flatMap(line => line.trim() || [])
4644          .join('\n')
4645  
4646        if (output) {
4647          if (logResult) {
4648            logForDebugging(
4649              `StatusLine [${statusLine.command}] completed with status ${result.status}`,
4650            )
4651          }
4652          return output
4653        }
4654      } else if (logResult) {
4655        logForDebugging(
4656          `StatusLine [${statusLine.command}] completed with status ${result.status}`,
4657          { level: 'warn' },
4658        )
4659      }
4660  
4661      return undefined
4662    } catch (error) {
4663      logForDebugging(`Status hook failed: ${error}`, { level: 'error' })
4664      return undefined
4665    }
4666  }
4667  
4668  /**
4669   * Execute file suggestion command if configured
4670   * @param fileSuggestionInput The structured input that will be converted to JSON
4671   * @param signal Optional AbortSignal to cancel hook execution
4672   * @param timeoutMs Optional timeout in milliseconds for hook execution
4673   * @returns Array of file paths, or empty array if no command configured
4674   */
4675  export async function executeFileSuggestionCommand(
4676    fileSuggestionInput: FileSuggestionCommandInput,
4677    signal?: AbortSignal,
4678    timeoutMs: number = 5000, // Short timeout for typeahead suggestions
4679  ): Promise<string[]> {
4680    // Check if all hooks are disabled by managed settings
4681    if (shouldDisableAllHooksIncludingManaged()) {
4682      return []
4683    }
4684  
4685    // SECURITY: ALL hooks require workspace trust in interactive mode
4686    // This centralized check prevents RCE vulnerabilities for all current and future hooks
4687    if (shouldSkipHookDueToTrust()) {
4688      logForDebugging(
4689        `Skipping FileSuggestion command execution - workspace trust not accepted`,
4690      )
4691      return []
4692    }
4693  
4694    // When disableAllHooks is set in non-managed settings, only managed fileSuggestion runs
4695    // (non-managed settings cannot disable managed commands, but non-managed commands are disabled)
4696    let fileSuggestion
4697    if (shouldAllowManagedHooksOnly()) {
4698      fileSuggestion = getSettingsForSource('policySettings')?.fileSuggestion
4699    } else {
4700      fileSuggestion = getSettings_DEPRECATED()?.fileSuggestion
4701    }
4702  
4703    if (!fileSuggestion || fileSuggestion.type !== 'command') {
4704      return []
4705    }
4706  
4707    // Use provided signal or create a default one
4708    const abortSignal = signal || AbortSignal.timeout(timeoutMs)
4709  
4710    try {
4711      const jsonInput = jsonStringify(fileSuggestionInput)
4712  
4713      const hook = { type: 'command' as const, command: fileSuggestion.command }
4714  
4715      const result = await execCommandHook(
4716        hook,
4717        'FileSuggestion',
4718        'FileSuggestion',
4719        jsonInput,
4720        abortSignal,
4721        randomUUID(),
4722      )
4723  
4724      if (result.aborted || result.status !== 0) {
4725        return []
4726      }
4727  
4728      return result.stdout
4729        .split('\n')
4730        .map(line => line.trim())
4731        .filter(Boolean)
4732    } catch (error) {
4733      logForDebugging(`File suggestion helper failed: ${error}`, {
4734        level: 'error',
4735      })
4736      return []
4737    }
4738  }
4739  
4740  async function executeFunctionHook({
4741    hook,
4742    messages,
4743    hookName,
4744    toolUseID,
4745    hookEvent,
4746    timeoutMs,
4747    signal,
4748  }: {
4749    hook: FunctionHook
4750    messages: Message[]
4751    hookName: string
4752    toolUseID: string
4753    hookEvent: HookEvent
4754    timeoutMs: number
4755    signal?: AbortSignal
4756  }): Promise<HookResult> {
4757    const callbackTimeoutMs = hook.timeout ?? timeoutMs
4758    const { signal: abortSignal, cleanup } = createCombinedAbortSignal(signal, {
4759      timeoutMs: callbackTimeoutMs,
4760    })
4761  
4762    try {
4763      // Check if already aborted
4764      if (abortSignal.aborted) {
4765        cleanup()
4766        return {
4767          outcome: 'cancelled',
4768          hook,
4769        }
4770      }
4771  
4772      // Execute callback with abort signal
4773      const passed = await new Promise<boolean>((resolve, reject) => {
4774        // Handle abort signal
4775        const onAbort = () => reject(new Error('Function hook cancelled'))
4776        abortSignal.addEventListener('abort', onAbort)
4777  
4778        // Execute callback
4779        Promise.resolve(hook.callback(messages, abortSignal))
4780          .then(result => {
4781            abortSignal.removeEventListener('abort', onAbort)
4782            resolve(result)
4783          })
4784          .catch(error => {
4785            abortSignal.removeEventListener('abort', onAbort)
4786            reject(error)
4787          })
4788      })
4789  
4790      cleanup()
4791  
4792      if (passed) {
4793        return {
4794          outcome: 'success',
4795          hook,
4796        }
4797      }
4798      return {
4799        blockingError: {
4800          blockingError: hook.errorMessage,
4801          command: 'function',
4802        },
4803        outcome: 'blocking',
4804        hook,
4805      }
4806    } catch (error) {
4807      cleanup()
4808  
4809      // Handle cancellation
4810      if (
4811        error instanceof Error &&
4812        (error.message === 'Function hook cancelled' ||
4813          error.name === 'AbortError')
4814      ) {
4815        return {
4816          outcome: 'cancelled',
4817          hook,
4818        }
4819      }
4820  
4821      // Log for monitoring
4822      logError(error)
4823      return {
4824        message: createAttachmentMessage({
4825          type: 'hook_error_during_execution',
4826          hookName,
4827          toolUseID,
4828          hookEvent,
4829          content:
4830            error instanceof Error
4831              ? error.message
4832              : 'Function hook execution error',
4833        }),
4834        outcome: 'non_blocking_error',
4835        hook,
4836      }
4837    }
4838  }
4839  
4840  async function executeHookCallback({
4841    toolUseID,
4842    hook,
4843    hookEvent,
4844    hookInput,
4845    signal,
4846    hookIndex,
4847    toolUseContext,
4848  }: {
4849    toolUseID: string
4850    hook: HookCallback
4851    hookEvent: HookEvent
4852    hookInput: HookInput
4853    signal: AbortSignal
4854    hookIndex?: number
4855    toolUseContext?: ToolUseContext
4856  }): Promise<HookResult> {
4857    // Create context for callbacks that need state access
4858    const context = toolUseContext
4859      ? {
4860          getAppState: toolUseContext.getAppState,
4861          updateAttributionState: toolUseContext.updateAttributionState,
4862        }
4863      : undefined
4864    const json = await hook.callback(
4865      hookInput,
4866      toolUseID,
4867      signal,
4868      hookIndex,
4869      context,
4870    )
4871    if (isAsyncHookJSONOutput(json)) {
4872      return {
4873        outcome: 'success',
4874        hook,
4875      }
4876    }
4877  
4878    const processed = processHookJSONOutput({
4879      json,
4880      command: 'callback',
4881      // TODO: If the hook came from a plugin, use the full path to the plugin for easier debugging
4882      hookName: `${hookEvent}:Callback`,
4883      toolUseID,
4884      hookEvent,
4885      expectedHookEvent: hookEvent,
4886      // Callbacks don't have stdout/stderr/exitCode
4887      stdout: undefined,
4888      stderr: undefined,
4889      exitCode: undefined,
4890    })
4891    return {
4892      ...processed,
4893      outcome: 'success',
4894      hook,
4895    }
4896  }
4897  
4898  /**
4899   * Check if WorktreeCreate hooks are configured (without executing them).
4900   *
4901   * Checks both settings-file hooks (getHooksConfigFromSnapshot) and registered
4902   * hooks (plugin hooks + SDK callback hooks via registerHookCallbacks).
4903   *
4904   * Must mirror the managedOnly filtering in getHooksConfig() — when
4905   * shouldAllowManagedHooksOnly() is true, plugin hooks (pluginRoot set) are
4906   * skipped at execution, so we must also skip them here. Otherwise this returns
4907   * true but executeWorktreeCreateHook() finds no matching hooks and throws,
4908   * blocking the git-worktree fallback.
4909   */
4910  export function hasWorktreeCreateHook(): boolean {
4911    const snapshotHooks = getHooksConfigFromSnapshot()?.['WorktreeCreate']
4912    if (snapshotHooks && snapshotHooks.length > 0) return true
4913    const registeredHooks = getRegisteredHooks()?.['WorktreeCreate']
4914    if (!registeredHooks || registeredHooks.length === 0) return false
4915    // Mirror getHooksConfig(): skip plugin hooks in managed-only mode
4916    const managedOnly = shouldAllowManagedHooksOnly()
4917    return registeredHooks.some(
4918      matcher => !(managedOnly && 'pluginRoot' in matcher),
4919    )
4920  }
4921  
4922  /**
4923   * Execute WorktreeCreate hooks.
4924   * Returns the worktree path from hook stdout.
4925   * Throws if hooks fail or produce no output.
4926   * Callers should check hasWorktreeCreateHook() before calling this.
4927   */
4928  export async function executeWorktreeCreateHook(
4929    name: string,
4930  ): Promise<{ worktreePath: string }> {
4931    const hookInput = {
4932      ...createBaseHookInput(undefined),
4933      hook_event_name: 'WorktreeCreate' as const,
4934      name,
4935    }
4936  
4937    const results = await executeHooksOutsideREPL({
4938      hookInput,
4939      timeoutMs: TOOL_HOOK_EXECUTION_TIMEOUT_MS,
4940    })
4941  
4942    // Find the first successful result with non-empty output
4943    const successfulResult = results.find(
4944      r => r.succeeded && r.output.trim().length > 0,
4945    )
4946  
4947    if (!successfulResult) {
4948      const failedOutputs = results
4949        .filter(r => !r.succeeded)
4950        .map(r => `${r.command}: ${r.output.trim() || 'no output'}`)
4951      throw new Error(
4952        `WorktreeCreate hook failed: ${failedOutputs.join('; ') || 'no successful output'}`,
4953      )
4954    }
4955  
4956    const worktreePath = successfulResult.output.trim()
4957    return { worktreePath }
4958  }
4959  
4960  /**
4961   * Execute WorktreeRemove hooks if configured.
4962   * Returns true if hooks were configured and ran, false if no hooks are configured.
4963   *
4964   * Checks both settings-file hooks (getHooksConfigFromSnapshot) and registered
4965   * hooks (plugin hooks + SDK callback hooks via registerHookCallbacks).
4966   */
4967  export async function executeWorktreeRemoveHook(
4968    worktreePath: string,
4969  ): Promise<boolean> {
4970    const snapshotHooks = getHooksConfigFromSnapshot()?.['WorktreeRemove']
4971    const registeredHooks = getRegisteredHooks()?.['WorktreeRemove']
4972    const hasSnapshotHooks = snapshotHooks && snapshotHooks.length > 0
4973    const hasRegisteredHooks = registeredHooks && registeredHooks.length > 0
4974    if (!hasSnapshotHooks && !hasRegisteredHooks) {
4975      return false
4976    }
4977  
4978    const hookInput = {
4979      ...createBaseHookInput(undefined),
4980      hook_event_name: 'WorktreeRemove' as const,
4981      worktree_path: worktreePath,
4982    }
4983  
4984    const results = await executeHooksOutsideREPL({
4985      hookInput,
4986      timeoutMs: TOOL_HOOK_EXECUTION_TIMEOUT_MS,
4987    })
4988  
4989    if (results.length === 0) {
4990      return false
4991    }
4992  
4993    for (const result of results) {
4994      if (!result.succeeded) {
4995        logForDebugging(
4996          `WorktreeRemove hook failed [${result.command}]: ${result.output.trim()}`,
4997          { level: 'error' },
4998        )
4999      }
5000    }
5001  
5002    return true
5003  }
5004  
5005  function getHookDefinitionsForTelemetry(
5006    matchedHooks: MatchedHook[],
5007  ): Array<{ type: string; command?: string; prompt?: string; name?: string }> {
5008    return matchedHooks.map(({ hook }) => {
5009      if (hook.type === 'command') {
5010        return { type: 'command', command: hook.command }
5011      } else if (hook.type === 'prompt') {
5012        return { type: 'prompt', prompt: hook.prompt }
5013      } else if (hook.type === 'http') {
5014        return { type: 'http', command: hook.url }
5015      } else if (hook.type === 'function') {
5016        return { type: 'function', name: 'function' }
5017      } else if (hook.type === 'callback') {
5018        return { type: 'callback', name: 'callback' }
5019      }
5020      return { type: 'unknown' }
5021    })
5022  }