Skip to content
432 changes: 432 additions & 0 deletions packages/cli/src/config/extension-file-watcher.test.ts

Large diffs are not rendered by default.

304 changes: 304 additions & 0 deletions packages/cli/src/config/extension-file-watcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,304 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/

import * as fs from 'node:fs';
import * as path from 'node:path';
import { watch as watchFs, type FSWatcher } from 'chokidar';
import {
createDebugLogger,
isSubpath,
Storage,
type Config,
type ExtensionMutationEvent,
} from '@qwen-code/qwen-code-core';
import { ExtensionRefreshState } from './extension-refresh-state.js';

const debugLogger = createDebugLogger('EXTENSION_FILE_WATCHER');

const TOP_LEVEL_FILES = new Set([
'extension-enablement.json',
'extension-preferences.json',
Comment thread
ZijianZhang989 marked this conversation as resolved.
Outdated
'marketplaces.json',
]);

const EXTENSION_FILES = new Set([
'qwen-extension.json',
'.qwen-extension-install.json',
]);

const AUTO_REFRESH_DIRS = new Set(['commands', 'skills', 'agents']);
Comment thread
ZijianZhang989 marked this conversation as resolved.
const STALE_DIRS = new Set(['hooks']);

type WatchEvent = 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir';
type RefreshAction = 'auto' | 'stale';

export class ExtensionFileWatcher {
private watcher?: FSWatcher;
private bootstrapWatcher?: FSWatcher;
private mutationListenerDisposer?: () => void;
private mutationSuppressionEnds = new Map<number, () => void>();
private contextFiles = new Set<string>();
private watching = false;
private watchGeneration = 0;

constructor(
private readonly config: Config,
private readonly extensionsDir = Storage.getUserExtensionsDir(),
private readonly refreshState = new ExtensionRefreshState(),
) {}

startWatching(): void {
this.stopWatching();
this.watching = true;
const generation = ++this.watchGeneration;
this.subscribeExtensionManagerMutations();
this.contextFiles = this.getContextFiles();
const roots = this.getWatchRoots();

if (roots.length > 0) {
this.watcher = watchFs(roots, {
ignoreInitial: true,
followSymlinks: false,
awaitWriteFinish: {
stabilityThreshold: 200,
pollInterval: 50,
},
ignored: (filePath: string) => this.isIgnored(filePath),
Comment thread
ZijianZhang989 marked this conversation as resolved.
})
.on('all', (event: string, changedPath: string) => {
if (generation !== this.watchGeneration) return;
const action = this.getRefreshAction(
Comment thread
ZijianZhang989 marked this conversation as resolved.
event as WatchEvent,
path.resolve(changedPath),
);
if (action === 'auto') {
this.refreshState.markExtensionContentChanged(
'extension content files changed',
);
} else if (action === 'stale') {
this.refreshState.markExtensionsChanged('extension files changed');
}
})
.on('error', (error: unknown) => {
debugLogger.warn('Extension file watcher error:', error);
});
}

if (!fs.existsSync(this.extensionsDir)) {
this.watchExtensionsParent();
}
}

stopWatching(): void {
const watcher = this.watcher;
const bootstrapWatcher = this.bootstrapWatcher;
this.watcher = undefined;
this.bootstrapWatcher = undefined;
this.watching = false;
this.watchGeneration++;
this.mutationListenerDisposer?.();
this.mutationListenerDisposer = undefined;
this.endPendingMutationSuppressions();
watcher?.close().catch((error: unknown) => {
debugLogger.warn('Extension file watcher close error:', error);
});
bootstrapWatcher?.close().catch((error: unknown) => {
debugLogger.warn('Extension bootstrap watcher close error:', error);
});
}

restartWatching(): void {
this.startWatching();
}

private getWatchRoots(): string[] {
Comment thread
ZijianZhang989 marked this conversation as resolved.
const roots = new Set<string>();
if (fs.existsSync(this.extensionsDir)) {
roots.add(this.extensionsDir);
}
for (const extension of this.config.getExtensions()) {
if (extension.installMetadata?.type === 'link') {
const rawSource = extension.installMetadata.source;
Comment thread
ZijianZhang989 marked this conversation as resolved.
const source = rawSource ? path.resolve(rawSource) : undefined;
if (source && fs.existsSync(source)) {
roots.add(source);
}
}
}
return [...roots];
}

private getContextFiles(): Set<string> {
const files = new Set<string>();
for (const extension of this.config.getExtensions()) {
for (const filePath of extension.contextFiles) {
files.add(path.resolve(filePath));
}
const configured = extension.config.contextFileName;
Comment thread
ZijianZhang989 marked this conversation as resolved.
const names =
configured === undefined
? ['QWEN.md']
: Array.isArray(configured)
? configured
: [configured];
for (const name of names) {
files.add(path.resolve(extension.path, name));
}
}
return files;
}

private watchExtensionsParent(): void {
const parentDir = path.dirname(this.extensionsDir);
const dirBasename = path.basename(this.extensionsDir);
const generation = this.watchGeneration;
this.bootstrapWatcher = watchFs(parentDir, {
Comment thread
ZijianZhang989 marked this conversation as resolved.
ignoreInitial: true,
followSymlinks: false,
depth: 0,
ignored: (filePath: string) =>
filePath !== parentDir && path.basename(filePath) !== dirBasename,
})
.on('all', (_event: string, changedPath: string) => {
if (generation !== this.watchGeneration) return;
if (path.basename(changedPath) !== dirBasename) return;
if (!fs.existsSync(this.extensionsDir)) return;
this.refreshState.markExtensionsChanged('extension directory created');
queueMicrotask(() => {
if (this.watching) {
this.restartWatching();
}
});
})
.on('error', (error: unknown) => {
debugLogger.warn('Extension bootstrap watcher error:', error);
});
}

private getRefreshAction(
event: WatchEvent,
changedPath: string,
): RefreshAction | false {
if (this.contextFiles.has(changedPath)) {
return 'stale';
}
if (changedPath === path.resolve(this.extensionsDir)) {
if (event === 'unlinkDir') {
Comment thread
ZijianZhang989 marked this conversation as resolved.
this.watchExtensionsParent();
return 'stale';
}
return false;
}
if (isSubpath(this.extensionsDir, changedPath)) {
return this.getUserExtensionRefreshAction(event, changedPath);
}
return this.getLinkedExtensionRefreshAction(changedPath);
}

private getUserExtensionRefreshAction(
event: WatchEvent,
changedPath: string,
): RefreshAction | false {
const relative = path.relative(this.extensionsDir, changedPath);
const parts = relative.split(path.sep).filter(Boolean);
if (parts.length === 1) {
if (TOP_LEVEL_FILES.has(parts[0])) return 'stale';
if (event === 'addDir' || event === 'unlinkDir') return 'stale';
return false;
}
if (
!fs.existsSync(
Comment thread
ZijianZhang989 marked this conversation as resolved.
path.join(this.extensionsDir, parts[0], 'qwen-extension.json'),
)
) {
return 'stale';
}
const runtimePath = parts.slice(1);
return this.getRuntimePathRefreshAction(runtimePath);
}

private getLinkedExtensionRefreshAction(
changedPath: string,
): RefreshAction | false {
for (const extension of this.config.getExtensions()) {
if (extension.installMetadata?.type !== 'link') continue;
const rawSource = extension.installMetadata.source;
const source = rawSource ? path.resolve(rawSource) : undefined;
if (!source || !isSubpath(source, changedPath)) continue;
const relative = path.relative(source, changedPath);
const parts = relative.split(path.sep).filter(Boolean);
return parts.length === 0
? 'stale'
: this.getRuntimePathRefreshAction(parts);
}
return false;
}

private getRuntimePathRefreshAction(parts: string[]): RefreshAction | false {
Comment thread
ZijianZhang989 marked this conversation as resolved.
if (EXTENSION_FILES.has(parts[0]) || STALE_DIRS.has(parts[0])) {
return 'stale';
}
if (AUTO_REFRESH_DIRS.has(parts[0])) {
return 'auto';
}
return false;
}

private isIgnored(filePath: string): boolean {
const normalized = filePath.replace(/\\/g, '/');
const searchablePath = `/${normalized}/`;
if (
searchablePath.includes('/node_modules/') ||
searchablePath.includes('/.git/')
) {
return true;
}
const basename = normalized.split('/').pop() ?? '';
return (
basename === '.DS_Store' ||
basename.endsWith('~') ||
basename.endsWith('.swp') ||
basename.endsWith('.tmp')
);
}

private subscribeExtensionManagerMutations(): void {
const manager = this.config.getExtensionManager();
this.mutationListenerDisposer = manager.addMutationListener(
(event: ExtensionMutationEvent) => {
if (event.phase === 'start') {
this.mutationSuppressionEnds.set(
event.id,
Comment thread
ZijianZhang989 marked this conversation as resolved.
this.refreshState.beginSuppression(() =>
this.restartAfterMutation(),
Comment thread
ZijianZhang989 marked this conversation as resolved.
),
);
return;
}
const endSuppression = this.mutationSuppressionEnds.get(event.id);
if (!endSuppression) {
return;
}
this.mutationSuppressionEnds.delete(event.id);
endSuppression();
},
);
}

private endPendingMutationSuppressions(): void {
const endSuppressions = [...this.mutationSuppressionEnds.values()];
this.mutationSuppressionEnds.clear();
for (const endSuppression of endSuppressions) {
endSuppression();
}
}

private restartAfterMutation(): void {
if (this.watching) {
this.restartWatching();
}
}
}
Loading
Loading