skillChangeDetector.ts 9.8 KB

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