/ src / utils / sessionEnvironment.ts
sessionEnvironment.ts
  1  import { mkdir, readdir, readFile, writeFile } from 'fs/promises'
  2  import { join } from 'path'
  3  import { getSessionId } from '../bootstrap/state.js'
  4  import { logForDebugging } from './debug.js'
  5  import { getClaudeConfigHomeDir } from './envUtils.js'
  6  import { errorMessage, getErrnoCode } from './errors.js'
  7  import { getPlatform } from './platform.js'
  8  
  9  // Cache states:
 10  // undefined = not yet loaded (need to check disk)
 11  // null = checked disk, no files exist (don't check again)
 12  // string = loaded and cached (use cached value)
 13  let sessionEnvScript: string | null | undefined = undefined
 14  
 15  export async function getSessionEnvDirPath(): Promise<string> {
 16    const sessionEnvDir = join(
 17      getClaudeConfigHomeDir(),
 18      'session-env',
 19      getSessionId(),
 20    )
 21    await mkdir(sessionEnvDir, { recursive: true })
 22    return sessionEnvDir
 23  }
 24  
 25  export async function getHookEnvFilePath(
 26    hookEvent: 'Setup' | 'SessionStart' | 'CwdChanged' | 'FileChanged',
 27    hookIndex: number,
 28  ): Promise<string> {
 29    const prefix = hookEvent.toLowerCase()
 30    return join(await getSessionEnvDirPath(), `${prefix}-hook-${hookIndex}.sh`)
 31  }
 32  
 33  export async function clearCwdEnvFiles(): Promise<void> {
 34    try {
 35      const dir = await getSessionEnvDirPath()
 36      const files = await readdir(dir)
 37      await Promise.all(
 38        files
 39          .filter(
 40            f =>
 41              (f.startsWith('filechanged-hook-') ||
 42                f.startsWith('cwdchanged-hook-')) &&
 43              HOOK_ENV_REGEX.test(f),
 44          )
 45          .map(f => writeFile(join(dir, f), '')),
 46      )
 47    } catch (e: unknown) {
 48      const code = getErrnoCode(e)
 49      if (code !== 'ENOENT') {
 50        logForDebugging(`Failed to clear cwd env files: ${errorMessage(e)}`)
 51      }
 52    }
 53  }
 54  
 55  export function invalidateSessionEnvCache(): void {
 56    logForDebugging('Invalidating session environment cache')
 57    sessionEnvScript = undefined
 58  }
 59  
 60  export async function getSessionEnvironmentScript(): Promise<string | null> {
 61    if (getPlatform() === 'windows') {
 62      logForDebugging('Session environment not yet supported on Windows')
 63      return null
 64    }
 65  
 66    if (sessionEnvScript !== undefined) {
 67      return sessionEnvScript
 68    }
 69  
 70    const scripts: string[] = []
 71  
 72    // Check for CLAUDE_ENV_FILE passed from parent process (e.g., HFI trajectory runner)
 73    // This allows venv/conda activation to persist across shell commands
 74    const envFile = process.env.CLAUDE_ENV_FILE
 75    if (envFile) {
 76      try {
 77        const envScript = (await readFile(envFile, 'utf8')).trim()
 78        if (envScript) {
 79          scripts.push(envScript)
 80          logForDebugging(
 81            `Session environment loaded from CLAUDE_ENV_FILE: ${envFile} (${envScript.length} chars)`,
 82          )
 83        }
 84      } catch (e: unknown) {
 85        const code = getErrnoCode(e)
 86        if (code !== 'ENOENT') {
 87          logForDebugging(`Failed to read CLAUDE_ENV_FILE: ${errorMessage(e)}`)
 88        }
 89      }
 90    }
 91  
 92    // Load hook environment files from session directory
 93    const sessionEnvDir = await getSessionEnvDirPath()
 94    try {
 95      const files = await readdir(sessionEnvDir)
 96      // We are sorting the hook env files by the order in which they are listed
 97      // in the settings.json file so that the resulting env is deterministic
 98      const hookFiles = files
 99        .filter(f => HOOK_ENV_REGEX.test(f))
100        .sort(sortHookEnvFiles)
101  
102      for (const file of hookFiles) {
103        const filePath = join(sessionEnvDir, file)
104        try {
105          const content = (await readFile(filePath, 'utf8')).trim()
106          if (content) {
107            scripts.push(content)
108          }
109        } catch (e: unknown) {
110          const code = getErrnoCode(e)
111          if (code !== 'ENOENT') {
112            logForDebugging(
113              `Failed to read hook file ${filePath}: ${errorMessage(e)}`,
114            )
115          }
116        }
117      }
118  
119      if (hookFiles.length > 0) {
120        logForDebugging(
121          `Session environment loaded from ${hookFiles.length} hook file(s)`,
122        )
123      }
124    } catch (e: unknown) {
125      const code = getErrnoCode(e)
126      if (code !== 'ENOENT') {
127        logForDebugging(
128          `Failed to load session environment from hooks: ${errorMessage(e)}`,
129        )
130      }
131    }
132  
133    if (scripts.length === 0) {
134      logForDebugging('No session environment scripts found')
135      sessionEnvScript = null
136      return sessionEnvScript
137    }
138  
139    sessionEnvScript = scripts.join('\n')
140    logForDebugging(
141      `Session environment script ready (${sessionEnvScript.length} chars total)`,
142    )
143    return sessionEnvScript
144  }
145  
146  const HOOK_ENV_PRIORITY: Record<string, number> = {
147    setup: 0,
148    sessionstart: 1,
149    cwdchanged: 2,
150    filechanged: 3,
151  }
152  const HOOK_ENV_REGEX =
153    /^(setup|sessionstart|cwdchanged|filechanged)-hook-(\d+)\.sh$/
154  
155  function sortHookEnvFiles(a: string, b: string): number {
156    const aMatch = a.match(HOOK_ENV_REGEX)
157    const bMatch = b.match(HOOK_ENV_REGEX)
158    const aType = aMatch?.[1] || ''
159    const bType = bMatch?.[1] || ''
160    if (aType !== bType) {
161      return (HOOK_ENV_PRIORITY[aType] ?? 99) - (HOOK_ENV_PRIORITY[bType] ?? 99)
162    }
163    const aIndex = parseInt(aMatch?.[2] || '0', 10)
164    const bIndex = parseInt(bMatch?.[2] || '0', 10)
165    return aIndex - bIndex
166  }