/ utils / sessionStart.ts
sessionStart.ts
  1  import { getMainThreadAgentType } from '../bootstrap/state.js'
  2  import type { HookResultMessage } from '../types/message.js'
  3  import { createAttachmentMessage } from './attachments.js'
  4  import { logForDebugging } from './debug.js'
  5  import { withDiagnosticsTiming } from './diagLogs.js'
  6  import { isBareMode } from './envUtils.js'
  7  import { updateWatchPaths } from './hooks/fileChangedWatcher.js'
  8  import { shouldAllowManagedHooksOnly } from './hooks/hooksConfigSnapshot.js'
  9  import { executeSessionStartHooks, executeSetupHooks } from './hooks.js'
 10  import { logError } from './log.js'
 11  import { loadPluginHooks } from './plugins/loadPluginHooks.js'
 12  
 13  type SessionStartHooksOptions = {
 14    sessionId?: string
 15    agentType?: string
 16    model?: string
 17    forceSyncExecution?: boolean
 18  }
 19  
 20  // Set by processSessionStartHooks when a hook emits initialUserMessage;
 21  // consumed once by takeInitialUserMessage. This side channel avoids changing
 22  // the Promise<HookResultMessage[]> return type that main.tsx and print.ts
 23  // both already await on (sessionStartHooksPromise is kicked in main.tsx and
 24  // joined later — rippling a structural return-type change through that
 25  // handoff would touch five callsites for what is a print-mode-only value).
 26  let pendingInitialUserMessage: string | undefined
 27  
 28  export function takeInitialUserMessage(): string | undefined {
 29    const v = pendingInitialUserMessage
 30    pendingInitialUserMessage = undefined
 31    return v
 32  }
 33  
 34  // Note to CLAUDE: do not add ANY "warmup" logic. It is **CRITICAL** that you do not add extra work on startup.
 35  export async function processSessionStartHooks(
 36    source: 'startup' | 'resume' | 'clear' | 'compact',
 37    {
 38      sessionId,
 39      agentType,
 40      model,
 41      forceSyncExecution,
 42    }: SessionStartHooksOptions = {},
 43  ): Promise<HookResultMessage[]> {
 44    // --bare skips all hooks. executeHooks already early-returns under --bare
 45    // (hooks.ts:1861), but this skips the loadPluginHooks() await below too —
 46    // no point loading plugin hooks that'll never run.
 47    if (isBareMode()) {
 48      return []
 49    }
 50    const hookMessages: HookResultMessage[] = []
 51    const additionalContexts: string[] = []
 52    const allWatchPaths: string[] = []
 53  
 54    // Skip loading plugin hooks if restricted to managed hooks only
 55    // Plugin hooks are untrusted external code that should be blocked by policy
 56    if (shouldAllowManagedHooksOnly()) {
 57      logForDebugging('Skipping plugin hooks - allowManagedHooksOnly is enabled')
 58    } else {
 59      // Ensure plugin hooks are loaded before executing SessionStart hooks.
 60      // loadPluginHooks() may be called early during startup (fire-and-forget, non-blocking)
 61      // to pre-load hooks, but we must guarantee hooks are registered before executing them.
 62      // This function is memoized, so if hooks are already loaded, this returns immediately
 63      // with negligible overhead (just a cache lookup).
 64      try {
 65        await withDiagnosticsTiming('load_plugin_hooks', () => loadPluginHooks())
 66      } catch (error) {
 67        // Log error but don't crash - continue with session start without plugin hooks
 68        /* eslint-disable no-restricted-syntax -- both branches wrap with context, not a toError case */
 69        const enhancedError =
 70          error instanceof Error
 71            ? new Error(
 72                `Failed to load plugin hooks during ${source}: ${error.message}`,
 73              )
 74            : new Error(
 75                `Failed to load plugin hooks during ${source}: ${String(error)}`,
 76              )
 77        /* eslint-enable no-restricted-syntax */
 78  
 79        if (error instanceof Error && error.stack) {
 80          enhancedError.stack = error.stack
 81        }
 82  
 83        logError(enhancedError)
 84  
 85        // Provide specific guidance based on error type
 86        const errorMessage =
 87          error instanceof Error ? error.message : String(error)
 88        let userGuidance = ''
 89  
 90        if (
 91          errorMessage.includes('Failed to clone') ||
 92          errorMessage.includes('network') ||
 93          errorMessage.includes('ETIMEDOUT') ||
 94          errorMessage.includes('ENOTFOUND')
 95        ) {
 96          userGuidance =
 97            'This appears to be a network issue. Check your internet connection and try again.'
 98        } else if (
 99          errorMessage.includes('Permission denied') ||
100          errorMessage.includes('EACCES') ||
101          errorMessage.includes('EPERM')
102        ) {
103          userGuidance =
104            'This appears to be a permissions issue. Check file permissions on ~/.claude/plugins/'
105        } else if (
106          errorMessage.includes('Invalid') ||
107          errorMessage.includes('parse') ||
108          errorMessage.includes('JSON') ||
109          errorMessage.includes('schema')
110        ) {
111          userGuidance =
112            'This appears to be a configuration issue. Check your plugin settings in .claude/settings.json'
113        } else {
114          userGuidance =
115            'Please fix the plugin configuration or remove problematic plugins from your settings.'
116        }
117  
118        logForDebugging(
119          `Warning: Failed to load plugin hooks. SessionStart hooks from plugins will not execute. ` +
120            `Error: ${errorMessage}. ${userGuidance}`,
121          { level: 'warn' },
122        )
123  
124        // Continue execution - plugin hooks won't be available, but project-level hooks
125        // from .claude/settings.json (loaded via captureHooksConfigSnapshot) will still work
126      }
127    }
128  
129    // Execute SessionStart hooks, ignoring blocking errors
130    // Use the provided agentType or fall back to the one stored in bootstrap state
131    const resolvedAgentType = agentType ?? getMainThreadAgentType()
132    for await (const hookResult of executeSessionStartHooks(
133      source,
134      sessionId,
135      resolvedAgentType,
136      model,
137      undefined,
138      undefined,
139      forceSyncExecution,
140    )) {
141      if (hookResult.message) {
142        hookMessages.push(hookResult.message)
143      }
144      if (
145        hookResult.additionalContexts &&
146        hookResult.additionalContexts.length > 0
147      ) {
148        additionalContexts.push(...hookResult.additionalContexts)
149      }
150      if (hookResult.initialUserMessage) {
151        pendingInitialUserMessage = hookResult.initialUserMessage
152      }
153      if (hookResult.watchPaths && hookResult.watchPaths.length > 0) {
154        allWatchPaths.push(...hookResult.watchPaths)
155      }
156    }
157  
158    if (allWatchPaths.length > 0) {
159      updateWatchPaths(allWatchPaths)
160    }
161  
162    // If hooks provided additional context, add it as a message
163    if (additionalContexts.length > 0) {
164      const contextMessage = createAttachmentMessage({
165        type: 'hook_additional_context',
166        content: additionalContexts,
167        hookName: 'SessionStart',
168        toolUseID: 'SessionStart',
169        hookEvent: 'SessionStart',
170      })
171      hookMessages.push(contextMessage)
172    }
173  
174    return hookMessages
175  }
176  
177  export async function processSetupHooks(
178    trigger: 'init' | 'maintenance',
179    { forceSyncExecution }: { forceSyncExecution?: boolean } = {},
180  ): Promise<HookResultMessage[]> {
181    // Same rationale as processSessionStartHooks above.
182    if (isBareMode()) {
183      return []
184    }
185    const hookMessages: HookResultMessage[] = []
186    const additionalContexts: string[] = []
187  
188    if (shouldAllowManagedHooksOnly()) {
189      logForDebugging('Skipping plugin hooks - allowManagedHooksOnly is enabled')
190    } else {
191      try {
192        await loadPluginHooks()
193      } catch (error) {
194        const errorMessage =
195          error instanceof Error ? error.message : String(error)
196        logForDebugging(
197          `Warning: Failed to load plugin hooks. Setup hooks from plugins will not execute. Error: ${errorMessage}`,
198          { level: 'warn' },
199        )
200      }
201    }
202  
203    for await (const hookResult of executeSetupHooks(
204      trigger,
205      undefined,
206      undefined,
207      forceSyncExecution,
208    )) {
209      if (hookResult.message) {
210        hookMessages.push(hookResult.message)
211      }
212      if (
213        hookResult.additionalContexts &&
214        hookResult.additionalContexts.length > 0
215      ) {
216        additionalContexts.push(...hookResult.additionalContexts)
217      }
218    }
219  
220    if (additionalContexts.length > 0) {
221      const contextMessage = createAttachmentMessage({
222        type: 'hook_additional_context',
223        content: additionalContexts,
224        hookName: 'Setup',
225        toolUseID: 'Setup',
226        hookEvent: 'Setup',
227      })
228      hookMessages.push(contextMessage)
229    }
230  
231    return hookMessages
232  }