docs/design/extension-file-reload.md
Extension changes currently enter the runtime from two different directions.
User-initiated UI mutations, such as enable, disable, install, uninstall, and
update, already go through ExtensionManager and can refresh runtime state
directly. Out-of-band filesystem changes, such as editing an installed
extension's skills/, commands/, hooks/, or qwen-extension.json, are not
owned by a single UI action and therefore need a watcher-driven path.
This design adds that missing watcher path while preserving the direct mutation path. It follows the same layering used by the MCP and LSP hot-reload designs:
The key constraint is that not every extension file can be safely hot-applied in
the same way. Content-like capability files can be refreshed automatically, but
package-level changes should ask the user to run /reload-plugins so the
extension cache, runtime tools, hooks, context files, and slash command list are
rebuilt from one coherent snapshot.
ExtensionManager already loads extension manifests, convention directories,
install metadata, enablement state, marketplace source state, commands,
skills, agents, hooks, MCP declarations, and LSP declarations.ExtensionManager.refreshTools() after
changing runtime-relevant state. That path refreshes MCP, skills, subagents,
hooks, and hierarchical memory through Core.CommandService.create() from loaders.
Extension commands and skill-backed slash commands do not automatically
appear unless reloadCommands() rebuilds that command service.HookSystem and HookRegistry. Recreating the whole hook
system would lose agent-scoped temporary hooks, so reload must target
configured hooks only.SettingsWatcher and existing MCP/LSP watchers do not cover installed
extension package content. Extension-specific files need their own watcher.~/.qwen/extensions misses active development workflows.Make extension changes take effect in the current interactive session without a full CLI restart:
commands/, skills/,
and agents/;/reload-plugins for package-level changes;The implementation is intentionally split by layer.
packages/core/src/extension/
extensionManager.ts
Extension mutation lifecycle events.
UI mutation methods still own direct runtime refresh.
extension-runtime-refresh.ts
Core runtime refresh contract for extension mutations.
packages/core/src/hooks/
hookRegistry.ts
Reload configured hooks while preserving agent-scoped hooks.
hookSystem.ts
Public hook reload facade used by extension runtime refresh.
packages/cli/src/config/
extension-refresh-state.ts
Shared event/state object for watcher, slash processor, and reload command.
extension-file-watcher.ts
Filesystem watcher and path classifier.
extension-runtime-reload.ts
CLI reload helpers for /reload-plugins and content auto-refresh.
packages/cli/src/ui/commands/
reload-plugins-command.ts
Interactive slash command for package-level extension reload.
packages/cli/src/ui/hooks/
slashCommandProcessor.ts
Event consumers for stale notifications and content auto-refresh.
packages/cli/src/
gemini.tsx
ui/AppContainer.tsx
ui/startInteractiveUI.tsx
Startup and dependency injection for ExtensionRefreshState and watcher.
ExtensionFileWatcher maps a chokidar event to one of three outcomes:
type RefreshAction = 'auto' | 'stale' | false;
The classification is deliberately conservative.
| Path class | Action | Reason |
|---|---|---|
commands/** | auto | Slash command loaders can rebuild from the existing extension cache. |
skills/** | auto | Skill cache and slash command loaders can rebuild without changing package identity. |
agents/** | auto | Subagent cache can rebuild without changing package identity. |
hooks/** | stale | Hook execution behavior should be reloaded from a coherent package snapshot. |
qwen-extension.json | stale | Manifest can change commands, skills, agents, hooks, MCP, LSP, context file names, and metadata. |
.qwen-extension-install.json | stale | Install metadata affects linked source roots and package identity. |
| configured context files | stale | Model context can change and should be reloaded explicitly. |
| extension directory add/remove | stale | Installed extension topology changed. |
| top-level extension config files | stale | Enablement, preferences, or marketplaces changed outside UI mutation path. |
| unknown files | ignored | Avoid refreshing for build artifacts or unrelated data. |
The same classifier is used for user-installed extensions and linked extension source roots. For linked roots, the watcher first finds the owning linked extension and then classifies the path relative to that source root.
ExtensionFileWatcher.startWatching() builds watch roots from:
Storage.getUserExtensionsDir(), when it exists;The parent bootstrap watcher covers first extension installation or manual
creation of the extension directory after startup. When the directory appears,
the watcher marks extension state stale and schedules restartWatching() in a
microtask. Scheduling the restart avoids closing the bootstrap watcher while
chokidar is still dispatching the event.
Watcher options:
watchFs(roots, {
ignoreInitial: true,
followSymlinks: false,
awaitWriteFinish: {
stabilityThreshold: 200,
pollInterval: 50,
},
ignored: (filePath) => this.isIgnored(filePath),
});
followSymlinks: false keeps an extension from causing Qwen to watch arbitrary
external paths through symlinks. The ignore filter skips node_modules, .git,
common editor backup files, swap files, temporary files, and .DS_Store.
ExtensionRefreshState is the small event/state primitive shared by the
watcher, the slash command processor, and /reload-plugins.
Key methods:
markExtensionsChanged(reason?: string): boolean;
markExtensionContentChanged(reason?: string): boolean;
clearExtensionsChanged(): void;
notifyExtensionsReloadStarted(): void;
needsExtensionRefresh(): boolean;
beginSuppression(onSettle?: () => void): () => void;
suppressNotifications<T>(fn: () => T, onSettle?: () => void): T;
Events:
| Event | Producer | Consumer | Meaning |
|---|---|---|---|
ExtensionContentChanged | ExtensionFileWatcher | useSlashCommandProcessor | Content-level files changed; schedule auto-refresh. |
ExtensionRefreshNeeded | ExtensionFileWatcher | useSlashCommandProcessor | Package-level state changed; tell the user to run /reload-plugins. |
ExtensionsReloadStarted | /reload-plugins | useSlashCommandProcessor | Cancel pending content refresh timers before manual reload. |
ExtensionsReloaded | /reload-plugins, watcher restart path | watcher and slash processor | Clear stale flags and restart/cancel pending work. |
markExtensionsChanged() deduplicates stale notifications until the state is
cleared. Content-change notifications are not deduplicated by this state object,
because the slash command processor owns debounce and serialization.
ExtensionManager exposes:
interface ExtensionMutationEvent {
id: number;
phase: 'start' | 'end';
operation: string;
}
addMutationListener(listener: ExtensionMutationListener): () => void;
Runtime-relevant mutation methods call beginMutation() and always emit a
matching end event in finally.
Methods that emit mutation events:
enableExtension()disableExtension()installExtension()uninstallExtension()updateExtension()addSource()removeSource()setExtensionScope()setMcpServerDisabled()Methods that do not emit mutation events:
toggleFavorite()markSourceUpdated()The watcher keeps mutation id -> end suppression callback in a Map. This is
important because install can trigger enable internally, and separate mutations
can overlap. Pairing by id avoids relying on stack order.
When the outer suppression depth reaches zero, the watcher restarts. That refreshes linked source roots, context file names, and active extension metadata after the mutation has settled.
refreshExtensionRuntime() is the Core-side runtime refresh entry point used by
extension UI mutations.
It refreshes in this order:
config.reinitializeMcpServers(config.getSettingsMcpServers())config.getSkillManager()?.refreshCache()config.getSubagentManager().refreshCache()config.getHookSystem()?.reload()config.refreshHierarchicalMemory()MCP reinitialization runs first because skill and subagent tool descriptions can depend on the updated MCP tool list.
Skills, subagents, and hooks run through Promise.allSettled() so one rejected
leg does not prevent the others from applying. Hook reload failure is stored and
rethrown after hierarchical memory has had a chance to refresh. This keeps hook
failures visible while still applying best-effort cache refreshes.
Failure contract:
reloadPluginsRuntime() is the CLI-side runtime reload helper used by the
slash command:
async function reloadPluginsRuntime(options: {
config: Config;
reloadCommands?: () => void | Promise<void>;
}): Promise<ReloadPluginsSummary>;
Flow:
config.getExtensionManager().refreshCache()config.getExtensionManager().refreshTools()reloadCommands()The summary counts active extension declarations for:
/reload-plugins owns the user-facing command behavior:
config;ExtensionsReloadStarted;reloadPluginsRuntime();clearExtensionsChanged() on success or failure;Clearing stale state on failure is intentional. If a failed reload left
extensionRefreshNeeded = true, future file watcher notifications would be
deduplicated away and content auto-refresh would keep bypassing itself.
refreshExtensionContentRuntime() is used for content-only filesystem changes.
Flow:
The slash command processor listens for ExtensionContentChanged and debounces
the refresh by 250 ms. It serializes refreshes with:
extensionContentRefreshRunningRef;
extensionContentRefreshPendingRef;
If a content event arrives while a refresh is running, the processor marks another pass as pending and runs that pass after the current one finishes. A small upper bound prevents a noisy editor or build process from keeping the same refresh task alive indefinitely.
If ExtensionRefreshState.needsExtensionRefresh() is true, content
auto-refresh exits early. The package-level reload must run first so command,
skill, agent, hook, MCP, LSP, and context state are rebuilt from one extension
cache snapshot.
HookRegistry.reloadConfiguredHooks() replaces only configured hook entries.
It preserves entries with agentScope !== undefined, because those are
temporary hooks registered for subagent execution.
Flow:
previousEntries;agentEntries;agentEntries;processHooksFromConfig();previousEntries and rethrow.HookSystem.reload() is a narrow facade that delegates to
hookRegistry.reloadConfiguredHooks(). Runtime reload therefore does not need
to recreate the whole hook system.
This reload path does not re-read user or project settings files from disk.
processHooksFromConfig() re-processes the current Config values for
user/project hooks and the refreshed extension config values. Settings file
reload remains owned by the settings reload path; /reload-plugins is scoped to
extension runtime state.
Interactive startup creates one shared ExtensionRefreshState:
const extensionRefreshState = new ExtensionRefreshState();
const extensionFileWatcher = isBareMode(argv.bare)
? undefined
: new ExtensionFileWatcher(config, undefined, extensionRefreshState);
That state is passed through:
gemini.tsx
-> startInteractiveUI(...)
-> AppContainer
-> useSlashCommandProcessor
-> CommandContext.services.extensionRefreshState
AppContainer creates a fallback ExtensionRefreshState only when one was not
provided. This keeps tests and alternate UI entry points simple while the main
interactive path shares state between watcher and slash command processing.
Cleanup unregisters the reload listener and stops the watcher.
edit extension commands/skills/agents file
-> ExtensionFileWatcher classifies as auto
-> ExtensionRefreshState.markExtensionContentChanged()
-> useSlashCommandProcessor schedules debounced refresh
-> refreshExtensionContentRuntime()
-> ExtensionManager.refreshCache()
-> SkillManager.refreshCache()
-> SubagentManager.refreshCache()
-> reloadCommands()
edit qwen-extension.json/hooks/context/install metadata/topology
-> ExtensionFileWatcher classifies as stale
-> ExtensionRefreshState.markExtensionsChanged()
-> useSlashCommandProcessor prints:
"Extensions changed on disk. Run /reload-plugins to apply updates."
-> user runs /reload-plugins
-> reloadPluginsRuntime()
-> ExtensionManager.refreshCache()
-> ExtensionManager.refreshTools()
-> reloadCommands()
user enables/disables/installs/uninstalls/updates extension
-> ExtensionManager emits mutation start
-> ExtensionRefreshState begins suppression
-> ExtensionManager writes disk/runtime state
-> ExtensionManager.refreshTools()
-> refreshExtensionRuntime()
-> ExtensionManager emits mutation end
-> suppression settles
-> ExtensionFileWatcher restarts with fresh roots/context files
watchGeneration changes.stopWatching() ends all pending suppressions before dropping watcher
references, so suppression depth cannot leak when the watcher is stopped
while a mutation is in flight./reload-plugins emits ExtensionsReloadStarted and ExtensionsReloaded so
pending content refresh timers are canceled around manual reload./reload-plugins.| Path | Behavior |
|---|---|
MCP reinitialization in mutation or /reload-plugins | Propagates. A success message would be misleading because extension MCP tools may be unavailable. |
Hook reload in mutation or /reload-plugins | Propagates after other parallel refresh legs settle. A success summary would be misleading because configured hooks may not be registered. |
| Skill cache refresh during mutation | Logged and best-effort. |
| Subagent cache refresh during mutation | Logged and best-effort. |
| Hierarchical memory refresh during mutation | Logged and best-effort. It should not roll back already-written extension state. |
| Content auto-refresh failure | Aggregated and shown in the UI with a /reload-plugins fallback. |
/reload-plugins failure | Returns an error message and clears stale state so future watcher notifications can fire again. |
| Hook registry reload failure | Restores previous hook entries and rethrows. |
| Watcher error | Logged through debug logger; the session continues. |
packages/core/src/extension/extension-runtime-refresh.test.ts
packages/core/src/extension/extensionManager.test.ts
packages/core/src/hooks/hookRegistry.test.ts
packages/core/src/hooks/hookSystem.test.ts
packages/cli/src/config/extension-refresh-state.test.ts
packages/cli/src/config/extension-file-watcher.test.ts
packages/cli/src/config/extension-runtime-reload.test.ts
/reload-plugins;packages/cli/src/ui/commands/reload-plugins-command.test.ts
packages/cli/src/services/BuiltinCommandLoader.test.ts
/reload-plugins in built-in command loading.Manual verification should cover:
commands/ and confirm slash command completion
updates automatically.skills/ and confirm skill-backed slash command
completion updates automatically.agents/ and confirm agent cache behavior reflects
the change.hooks/hooks.json, qwen-extension.json, install metadata, context
files, or extension directory topology and confirm the UI asks for
/reload-plugins./reload-plugins and confirm the summary reports extensions, commands,
skills, agents, hooks, extension MCP servers, and extension LSP servers.ENOSPC or
EMFILE.refreshExtensionRuntime() if callers
need partial-success summaries.