source dump of claude code
at main 311 lines 10 kB view raw
1import chokidar, { type FSWatcher } from 'chokidar' 2import * as platformPath from 'path' 3import { getAdditionalDirectoriesForClaudeMd } from '../../bootstrap/state.js' 4import { 5 clearCommandMemoizationCaches, 6 clearCommandsCache, 7} from '../../commands.js' 8import { 9 type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 10 logEvent, 11} from '../../services/analytics/index.js' 12import { 13 clearSkillCaches, 14 getSkillsPath, 15 onDynamicSkillsLoaded, 16} from '../../skills/loadSkillsDir.js' 17import { resetSentSkillNames } from '../attachments.js' 18import { registerCleanup } from '../cleanupRegistry.js' 19import { logForDebugging } from '../debug.js' 20import { getFsImplementation } from '../fsOperations.js' 21import { executeConfigChangeHooks, hasBlockingResult } from '../hooks.js' 22import { createSignal } from '../signal.js' 23 24/** 25 * Time in milliseconds to wait for file writes to stabilize before processing. 26 */ 27const FILE_STABILITY_THRESHOLD_MS = 1000 28 29/** 30 * Polling interval in milliseconds for checking file stability. 31 */ 32const FILE_STABILITY_POLL_INTERVAL_MS = 500 33 34/** 35 * Time in milliseconds to debounce rapid skill change events into a single 36 * reload. Prevents cascading reloads when many skill files change at once 37 * (e.g. during auto-update or when another session modifies skill directories). 38 * Without this, each file change triggers a full clearSkillCaches() + 39 * clearCommandsCache() + listener notification cycle, which can deadlock the 40 * event loop when dozens of events fire in rapid succession. 41 */ 42const RELOAD_DEBOUNCE_MS = 300 43 44/** 45 * Polling interval for chokidar when usePolling is enabled. 46 * Skill files change rarely (manual edits, git operations), so a 2s interval 47 * trades negligible latency for far fewer stat() calls than the default 100ms. 48 */ 49const POLLING_INTERVAL_MS = 2000 50 51/** 52 * Bun's native fs.watch() has a PathWatcherManager deadlock (oven-sh/bun#27469, 53 * #26385): closing a watcher on the main thread while the File Watcher thread 54 * is delivering events can hang both threads in __ulock_wait2 forever. Chokidar 55 * with depth: 2 on large skill trees (hundreds of subdirs) triggers this 56 * reliably when a git operation touches many directories at once — chokidar 57 * internally closes/reopens per-directory FSWatchers as dirs are added/removed. 58 * 59 * Workaround: use stat() polling under Bun. No FSWatcher = no deadlock. 60 * The fix is pending upstream; remove this once the Bun PR lands. 61 */ 62const USE_POLLING = typeof Bun !== 'undefined' 63 64let watcher: FSWatcher | null = null 65let reloadTimer: ReturnType<typeof setTimeout> | null = null 66const pendingChangedPaths = new Set<string>() 67let initialized = false 68let disposed = false 69let dynamicSkillsCallbackRegistered = false 70let unregisterCleanup: (() => void) | null = null 71const skillsChanged = createSignal() 72 73// Test overrides for timing constants 74let testOverrides: { 75 stabilityThreshold?: number 76 pollInterval?: number 77 reloadDebounce?: number 78 /** Chokidar fs.stat polling interval when USE_POLLING is active. */ 79 chokidarInterval?: number 80} | null = null 81 82/** 83 * Initialize file watching for skill directories 84 */ 85export async function initialize(): Promise<void> { 86 if (initialized || disposed) return 87 initialized = true 88 89 // Register callback for when dynamic skills are loaded (only once) 90 if (!dynamicSkillsCallbackRegistered) { 91 dynamicSkillsCallbackRegistered = true 92 onDynamicSkillsLoaded(() => { 93 // Clear memoization caches so new skills are picked up 94 // Note: we use clearCommandMemoizationCaches (not clearCommandsCache) 95 // because clearCommandsCache would call clearSkillCaches which 96 // wipes out the dynamic skills we just loaded 97 clearCommandMemoizationCaches() 98 // Notify listeners that skills changed 99 skillsChanged.emit() 100 }) 101 } 102 103 const paths = await getWatchablePaths() 104 if (paths.length === 0) return 105 106 logForDebugging( 107 `Watching for changes in skill/command directories: ${paths.join(', ')}...`, 108 ) 109 110 watcher = chokidar.watch(paths, { 111 persistent: true, 112 ignoreInitial: true, 113 depth: 2, // Skills use skill-name/SKILL.md format 114 awaitWriteFinish: { 115 stabilityThreshold: 116 testOverrides?.stabilityThreshold ?? FILE_STABILITY_THRESHOLD_MS, 117 pollInterval: 118 testOverrides?.pollInterval ?? FILE_STABILITY_POLL_INTERVAL_MS, 119 }, 120 // Ignore special file types (sockets, FIFOs, devices) - they cannot be watched 121 // and will error with EOPNOTSUPP on macOS. Only allow regular files and directories. 122 ignored: (path, stats) => { 123 if (stats && !stats.isFile() && !stats.isDirectory()) return true 124 // Ignore .git directories 125 return path.split(platformPath.sep).some(dir => dir === '.git') 126 }, 127 ignorePermissionErrors: true, 128 usePolling: USE_POLLING, 129 interval: testOverrides?.chokidarInterval ?? POLLING_INTERVAL_MS, 130 atomic: true, 131 }) 132 133 watcher.on('add', handleChange) 134 watcher.on('change', handleChange) 135 watcher.on('unlink', handleChange) 136 137 // Register cleanup to properly dispose of the file watcher during graceful shutdown 138 unregisterCleanup = registerCleanup(async () => { 139 await dispose() 140 }) 141} 142 143/** 144 * Clean up file watcher 145 */ 146export function dispose(): Promise<void> { 147 disposed = true 148 if (unregisterCleanup) { 149 unregisterCleanup() 150 unregisterCleanup = null 151 } 152 let closePromise: Promise<void> = Promise.resolve() 153 if (watcher) { 154 closePromise = watcher.close() 155 watcher = null 156 } 157 if (reloadTimer) { 158 clearTimeout(reloadTimer) 159 reloadTimer = null 160 } 161 pendingChangedPaths.clear() 162 skillsChanged.clear() 163 return closePromise 164} 165 166/** 167 * Subscribe to skill changes 168 */ 169export const subscribe = skillsChanged.subscribe 170 171async function getWatchablePaths(): Promise<string[]> { 172 const fs = getFsImplementation() 173 const paths: string[] = [] 174 175 // User skills directory (~/.claude/skills) 176 const userSkillsPath = getSkillsPath('userSettings', 'skills') 177 if (userSkillsPath) { 178 try { 179 await fs.stat(userSkillsPath) 180 paths.push(userSkillsPath) 181 } catch { 182 // Path doesn't exist, skip it 183 } 184 } 185 186 // User commands directory (~/.claude/commands) 187 const userCommandsPath = getSkillsPath('userSettings', 'commands') 188 if (userCommandsPath) { 189 try { 190 await fs.stat(userCommandsPath) 191 paths.push(userCommandsPath) 192 } catch { 193 // Path doesn't exist, skip it 194 } 195 } 196 197 // Project skills directory (.claude/skills) 198 const projectSkillsPath = getSkillsPath('projectSettings', 'skills') 199 if (projectSkillsPath) { 200 try { 201 // For project settings, resolve to absolute path 202 const absolutePath = platformPath.resolve(projectSkillsPath) 203 await fs.stat(absolutePath) 204 paths.push(absolutePath) 205 } catch { 206 // Path doesn't exist, skip it 207 } 208 } 209 210 // Project commands directory (.claude/commands) 211 const projectCommandsPath = getSkillsPath('projectSettings', 'commands') 212 if (projectCommandsPath) { 213 try { 214 // For project settings, resolve to absolute path 215 const absolutePath = platformPath.resolve(projectCommandsPath) 216 await fs.stat(absolutePath) 217 paths.push(absolutePath) 218 } catch { 219 // Path doesn't exist, skip it 220 } 221 } 222 223 // Additional directories (--add-dir) skills 224 for (const dir of getAdditionalDirectoriesForClaudeMd()) { 225 const additionalSkillsPath = platformPath.join(dir, '.claude', 'skills') 226 try { 227 await fs.stat(additionalSkillsPath) 228 paths.push(additionalSkillsPath) 229 } catch { 230 // Path doesn't exist, skip it 231 } 232 } 233 234 return paths 235} 236 237function handleChange(path: string): void { 238 logForDebugging(`Detected skill change: ${path}`) 239 logEvent('tengu_skill_file_changed', { 240 source: 241 'chokidar' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 242 }) 243 244 scheduleReload(path) 245} 246 247/** 248 * Debounce rapid skill changes into a single reload. When many skill files 249 * change at once (e.g. auto-update installs a new binary and a new session 250 * touches skill directories), each file fires its own chokidar event. Without 251 * debouncing, each event triggers clearSkillCaches() + clearCommandsCache() + 252 * listener notification — 30 events means 30 full reload cycles, which can 253 * deadlock the Bun event loop via rapid FSWatcher watch/unwatch churn. 254 */ 255function scheduleReload(changedPath: string): void { 256 pendingChangedPaths.add(changedPath) 257 if (reloadTimer) clearTimeout(reloadTimer) 258 reloadTimer = setTimeout(async () => { 259 reloadTimer = null 260 const paths = [...pendingChangedPaths] 261 pendingChangedPaths.clear() 262 // Fire ConfigChange hook once for the batch — the hook query is always 263 // 'skills' so firing per-path (which can be hundreds during a git 264 // operation) just spams the hook matcher with identical queries. Pass the 265 // first path as a representative; hooks can inspect all paths via the 266 // skills directory if they need the full set. 267 const results = await executeConfigChangeHooks('skills', paths[0]!) 268 if (hasBlockingResult(results)) { 269 logForDebugging( 270 `ConfigChange hook blocked skill reload (${paths.length} paths)`, 271 ) 272 return 273 } 274 clearSkillCaches() 275 clearCommandsCache() 276 resetSentSkillNames() 277 skillsChanged.emit() 278 }, testOverrides?.reloadDebounce ?? RELOAD_DEBOUNCE_MS) 279} 280 281/** 282 * Reset internal state for testing purposes only. 283 */ 284export async function resetForTesting(overrides?: { 285 stabilityThreshold?: number 286 pollInterval?: number 287 reloadDebounce?: number 288 chokidarInterval?: number 289}): Promise<void> { 290 // Clean up existing watcher if present to avoid resource leaks 291 if (watcher) { 292 await watcher.close() 293 watcher = null 294 } 295 if (reloadTimer) { 296 clearTimeout(reloadTimer) 297 reloadTimer = null 298 } 299 pendingChangedPaths.clear() 300 skillsChanged.clear() 301 initialized = false 302 disposed = false 303 testOverrides = overrides ?? null 304} 305 306export const skillChangeDetector = { 307 initialize, 308 dispose, 309 subscribe, 310 resetForTesting, 311}