diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index da945546d..c21d36864 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -344,6 +344,7 @@ export async function main() { extensionEnablementManager, argv, ); + registerCleanup(() => config.shutdown()); if (config.getListExtensions()) { console.log('Installed extensions:'); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 34dbb4649..1787fb6a7 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -650,6 +650,7 @@ export class Config { this.promptRegistry = new PromptRegistry(); this.subagentManager = new SubagentManager(this); this.skillManager = new SkillManager(this); + await this.skillManager.startWatching(); // Load session subagents if they were provided before initialization if (this.sessionSubagents.length > 0) { @@ -734,6 +735,13 @@ export class Config { return this.sessionId; } + /** + * Releases resources owned by the config instance. + */ + async shutdown(): Promise { + this.skillManager?.stopWatching(); + } + /** * Starts a new session and resets session-scoped services. */ diff --git a/packages/core/src/skills/skill-manager.ts b/packages/core/src/skills/skill-manager.ts index 77cec15fd..6d4b3d15e 100644 --- a/packages/core/src/skills/skill-manager.ts +++ b/packages/core/src/skills/skill-manager.ts @@ -5,6 +5,7 @@ */ import * as fs from 'fs/promises'; +import * as fsSync from 'fs'; import * as path from 'path'; import * as os from 'os'; import { parse as parseYaml } from '../utils/yaml-parser.js'; @@ -29,6 +30,9 @@ export class SkillManager { private skillsCache: Map | null = null; private readonly changeListeners: Set<() => void> = new Set(); private parseErrors: Map = new Map(); + private readonly watchers: Map = new Map(); + private watchStarted = false; + private refreshTimer: NodeJS.Timeout | null = null; constructor(private readonly config: Config) {} @@ -221,6 +225,34 @@ export class SkillManager { this.notifyChangeListeners(); } + /** + * Starts watching skill directories for changes. + */ + async startWatching(): Promise { + if (this.watchStarted) { + return; + } + + this.watchStarted = true; + await this.refreshCache(); + this.updateWatchersFromCache(); + } + + /** + * Stops watching skill directories for changes. + */ + stopWatching(): void { + for (const watcher of this.watchers.values()) { + watcher.close(); + } + this.watchers.clear(); + this.watchStarted = false; + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; + } + } + /** * Parses a SKILL.md file and returns the configuration. * @@ -449,4 +481,69 @@ export class SkillManager { this.skillsCache.set(level, levelSkills); } } + + private updateWatchersFromCache(): void { + const desiredPaths = new Set(); + const recursiveSupported = + process.platform === 'darwin' || process.platform === 'win32'; + + for (const level of ['project', 'user'] as const) { + const baseDir = this.getSkillsBaseDir(level); + const parentDir = path.dirname(baseDir); + if (fsSync.existsSync(parentDir)) { + desiredPaths.add(parentDir); + } + if (fsSync.existsSync(baseDir)) { + desiredPaths.add(baseDir); + } + + const levelSkills = this.skillsCache?.get(level) || []; + for (const skill of levelSkills) { + const skillDir = path.dirname(skill.filePath); + if (fsSync.existsSync(skillDir)) { + desiredPaths.add(skillDir); + } + } + } + + for (const existingPath of this.watchers.keys()) { + if (!desiredPaths.has(existingPath)) { + this.watchers.get(existingPath)?.close(); + this.watchers.delete(existingPath); + } + } + + for (const watchPath of desiredPaths) { + if (this.watchers.has(watchPath)) { + continue; + } + + try { + const watcher = fsSync.watch( + watchPath, + { recursive: recursiveSupported }, + () => { + this.scheduleRefresh(); + }, + ); + this.watchers.set(watchPath, watcher); + } catch (error) { + console.warn( + `Failed to watch skills directory at ${watchPath}:`, + error, + ); + } + } + } + + private scheduleRefresh(): void { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + } + + this.refreshTimer = setTimeout(() => { + this.refreshTimer = null; + void this.refreshCache().then(() => this.updateWatchersFromCache()); + }, 150); + } }