hooks.ts 158 KB

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