/ utils / skills / skillChangeDetector.ts
skillChangeDetector.ts
  1  import chokidar, { type FSWatcher } from 'chokidar'
  2  import * as platformPath from 'path'
  3  import { getAdditionalDirectoriesForClaudeMd } from '../../bootstrap/state.js'
  4  import {
  5    clearCommandMemoizationCaches,
  6    clearCommandsCache,
  7  } from '../../commands.js'
  8  import {
  9    type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 10    logEvent,
 11  } from '../../services/analytics/index.js'
 12  import {
 13    clearSkillCaches,
 14    getSkillsPath,
 15    onDynamicSkillsLoaded,
 16  } from '../../skills/loadSkillsDir.js'
 17  import { resetSentSkillNames } from '../attachments.js'
 18  import { registerCleanup } from '../cleanupRegistry.js'
 19  import { logForDebugging } from '../debug.js'
 20  import { getFsImplementation } from '../fsOperations.js'
 21  import { executeConfigChangeHooks, hasBlockingResult } from '../hooks.js'
 22  import { createSignal } from '../signal.js'
 23  
 24  /**
 25   * Time in milliseconds to wait for file writes to stabilize before processing.
 26   */
 27  const FILE_STABILITY_THRESHOLD_MS = 1000
 28  
 29  /**
 30   * Polling interval in milliseconds for checking file stability.
 31   */
 32  const 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   */
 42  const 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   */
 49  const 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   */
 62  const USE_POLLING = typeof Bun !== 'undefined'
 63  
 64  let watcher: FSWatcher | null = null
 65  let reloadTimer: ReturnType<typeof setTimeout> | null = null
 66  const pendingChangedPaths = new Set<string>()
 67  let initialized = false
 68  let disposed = false
 69  let dynamicSkillsCallbackRegistered = false
 70  let unregisterCleanup: (() => void) | null = null
 71  const skillsChanged = createSignal()
 72  
 73  // Test overrides for timing constants
 74  let 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   */
 85  export 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   */
146  export 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   */
169  export const subscribe = skillsChanged.subscribe
170  
171  async 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  
237  function 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   */
255  function 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   */
284  export 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  
306  export const skillChangeDetector = {
307    initialize,
308    dispose,
309    subscribe,
310    resetForTesting,
311  }