diff --git a/src/node/config.ts b/src/node/config.ts index 25fe09a3d3..8e56ce4a44 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -1878,6 +1878,69 @@ export class Config { return null; } + /** + * Watch providers.jsonc for external edits. Fires callback (debounced 300 ms) + * on any create/modify/delete event. Returns a cleanup function. + * + * We watch the parent directory rather than the file directly so that + * creates (first-time manual edit) are also detected on all platforms. + */ + watchProvidersFile(callback: () => void): () => void { + const filename = path.basename(this.providersFile); + let debounceTimer: ReturnType | null = null; + + const fire = (): void => { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + debounceTimer = null; + callback(); + }, 300); + }; + + // Anything inside this block can fail in restricted environments: + // - ensurePrivateDirSync: read-only filesystem, unwritable MUX_ROOT + // - fs.watch: ENOENT, network filesystems (NFS/SMB), watch-limit + // exhaustion (ENOSPC on Linux), unsupported virtualized mounts. + // We degrade gracefully in every case: log once, return a no-op + // cleanup, and let the rest of provider config keep working. The UI + // just won't auto-refresh on manual edits in that environment (same + // as the pre-PR behaviour). + let watcher: fs.FSWatcher; + try { + // The mux home directory may not exist on a fresh install. Create it + // so fs.watch doesn't throw ENOENT; the directory being empty is fine. + if (!fs.existsSync(this.rootDir)) { + ensurePrivateDirSync(this.rootDir); + } + + // persistent: false so the watcher doesn't prevent the process (or + // Jest) from exiting when nothing else is keeping the event loop alive. + watcher = fs.watch(this.rootDir, { persistent: false }, (_eventType, changedFilename) => { + // changedFilename can be null on some platforms/kernels (notably + // older macOS FSEvents). When we can't tell which file changed, + // assume providers.jsonc might have and let the consumer re-fetch + // — better an extra refresh than a missed one, since this is the + // exact scenario the feature is meant to fix. + if (changedFilename != null && changedFilename !== filename) return; + fire(); + }); + } catch (error) { + log.warn( + `Could not watch providers.jsonc for external edits (${this.rootDir}); manual edits will require a restart to take effect:`, + error + ); + const noop = (): void => { + // Nothing to clean up — watcher setup never completed. + }; + return noop; + } + + return () => { + if (debounceTimer) clearTimeout(debounceTimer); + watcher.close(); + }; + } + /** * Save providers configuration to JSONC file * @param config The providers configuration to save diff --git a/src/node/services/providerService.ts b/src/node/services/providerService.ts index 0dd93364e9..356cb3b0cc 100644 --- a/src/node/services/providerService.ts +++ b/src/node/services/providerService.ts @@ -132,6 +132,7 @@ export class ProviderService { private readonly policyService: PolicyService | null; private readonly emitter = new EventEmitter(); private lastWarnedShadowedCustomProviderIds: Set | null = null; + private readonly stopWatchingProvidersFile: () => void; constructor( private readonly config: Config, @@ -141,6 +142,19 @@ export class ProviderService { // The provider config subscription may have many concurrent listeners (e.g. multiple windows). // Avoid noisy MaxListenersExceededWarning for normal usage. this.emitter.setMaxListeners(50); + // Notify subscribers when providers.jsonc is edited externally (e.g. manual edits). + this.stopWatchingProvidersFile = this.config.watchProvidersFile(() => + this.notifyConfigChanged() + ); + } + + /** + * Release long-lived OS handles (e.g. the providers.jsonc file watcher). + * Called by ServiceContainer.dispose() during shutdown and by tests. + */ + dispose(): void { + this.stopWatchingProvidersFile(); + this.emitter.removeAllListeners(); } /** diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index f0cbc98db0..d241473abe 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -543,6 +543,7 @@ export class ServiceContainer { this.copilotOauthService.dispose(); this.serverAuthService.dispose(); + this.providerService.dispose(); await this.backgroundProcessManager.terminateAll(); } }