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