feat: extension file reload — watch for plugin changes and hot-reload runtime (#6347)

* feat: extension file reload — watch for plugin changes and hot-reload runtime

- Extract refreshExtensionRuntime to centralize MCP, skills, subagents, hooks, and memory refresh
- Add ExtensionFileWatcher (chokidar) for auto-detecting extension file changes
- Add ExtensionRefreshState with per-session scoped instance and mutation suppression
- Replace monkey-patching with ExtensionManager native mutation listeners
- Add /reload-plugins slash command with i18n-aware summary across all 9 locales
- Add auto-refresh of extension content (commands/skills/agents) on file change
- Add HookRegistry.reloadConfiguredHooks() with correct error recovery
- Fix async mutation pairing via id-based Map instead of LIFO stack
- Fix bootstrap watcher close() UB with queueMicrotask deferral
- Fix concurrent refresh with runningRef/pendingRef guard
- Fix error propagation from refreshExtensionContentRuntime to UI
- Fix isIgnored cross-platform path splitting (path.sep → regex)
- Fix wrong ExtensionMutationEvent type via import from core
- Fix addItem on unmounted component with mountedRef guard
- Set followSymlinks: false on chokidar watchers

* fix: address extension reload review feedback

* docs: expand extension file reload design

* fix: harden extension reload watcher state

* fix(core): tag extension refresh legs

* fix(cli): harden extension reload state handling

* fix(cli): clarify extension reload failure state

* fix(cli): tighten extension reload boundaries

* chore: resolve main conflicts for extension reload

* chore: drop unrelated merge formatting changes

* fix(core): harden extension refresh edge cases

---------

Co-authored-by: 俊良 <zzj542558@alibaba-inc.com>
Co-authored-by: 易良 <1204183885@qq.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
Zqc 2026-07-08 19:16:21 +08:00 committed by GitHub
parent 271664b34b
commit 151d269413
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 3921 additions and 203 deletions

View file

@ -0,0 +1,607 @@
# Extension File Reload Design
## Background
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 CLI decides when filesystem changes should trigger a reload or a user
notification;
- Core owns how extension runtime state is refreshed;
- UI components consume a small event/state object instead of polling extension
files directly.
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.
## Current Code Assessment
- `ExtensionManager` already loads extension manifests, convention directories,
install metadata, enablement state, marketplace source state, commands,
skills, agents, hooks, MCP declarations, and LSP declarations.
- UI extension operations already call `ExtensionManager.refreshTools()` after
changing runtime-relevant state. That path refreshes MCP, skills, subagents,
hooks, and hierarchical memory through Core.
- Slash command completion is built by `CommandService.create()` from loaders.
Extension commands and skill-backed slash commands do not automatically
appear unless `reloadCommands()` rebuilds that command service.
- Skill and subagent managers have cache refresh APIs, but those caches are
separate from slash command completion.
- Hooks are owned by `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.
- Linked extensions can live outside the user extension directory, so watching
only `~/.qwen/extensions` misses active development workflows.
## Goals
Make extension changes take effect in the current interactive session without a
full CLI restart:
- keep UI extension mutations immediately effective;
- detect manual extension edits, additions, and removals under the user
extension directory;
- detect edits in linked extension source directories;
- auto-refresh content-level capability files under `commands/`, `skills/`,
and `agents/`;
- prompt the user to run `/reload-plugins` for package-level changes;
- refresh hooks as part of runtime reload without losing agent-scoped hooks;
- keep slash command completion in sync with command and skill changes;
- suppress watcher notifications for changes written by Qwen's own extension
mutations;
- surface MCP and hook reload failures instead of reporting a misleading
successful reload summary.
## Non-goals
- Do not make hook file edits content-auto-refreshable. Hook behavior can affect
command execution and security-sensitive workflows, so hook edits are treated
as package-level changes.
- Do not hot-reload arbitrary extension files. Unknown files are ignored unless
they are resolved context files.
- Do not add per-extension incremental MCP restart. This design continues to use
the existing MCP reinitialization entry point.
- Do not change extension discovery, conversion, installation source parsing, or
marketplace semantics.
- Do not support runtime toggling of bare mode. The watcher is simply not
started in bare mode.
## Code Structure
The implementation is intentionally split by layer.
```text
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.
```
## Design
### 1. Classify Filesystem Changes
`ExtensionFileWatcher` maps a chokidar event to one of three outcomes:
```ts
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.
### 2. Watch User and Linked Extension Roots
`ExtensionFileWatcher.startWatching()` builds watch roots from:
1. `Storage.getUserExtensionsDir()`, when it exists;
2. active linked extension source paths from install metadata;
3. the parent of the user extension directory, only when the extension
directory does not exist yet.
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:
```ts
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`.
### 3. Share Reload State Through ExtensionRefreshState
`ExtensionRefreshState` is the small event/state primitive shared by the
watcher, the slash command processor, and `/reload-plugins`.
Key methods:
```ts
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.
### 4. Suppress Watcher Noise During Programmatic Mutations
`ExtensionManager` exposes:
```ts
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.
### 5. Refresh Runtime State From Core
`refreshExtensionRuntime()` is the Core-side runtime refresh entry point used by
extension UI mutations.
It refreshes in this order:
1. `config.reinitializeMcpServers(config.getSettingsMcpServers())`
2. `config.getSkillManager()?.refreshCache()`
3. `config.getSubagentManager().refreshCache()`
4. `config.getHookSystem()?.reload()`
5. `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:
- MCP failure propagates immediately and later runtime legs do not run.
- Hook reload failure propagates after parallel refresh legs and memory refresh
settle.
- Skill refresh failure is logged and best-effort.
- Subagent refresh failure is logged and best-effort.
- Hierarchical memory refresh failure is logged and best-effort.
### 6. Reload Package-Level Changes With /reload-plugins
`reloadPluginsRuntime()` is the CLI-side runtime reload helper used by the
slash command:
```ts
async function reloadPluginsRuntime(options: {
config: Config;
reloadCommands?: () => void | Promise<void>;
}): Promise<ReloadPluginsSummary>;
```
Flow:
1. `config.getExtensionManager().refreshCache()`
2. `config.getExtensionManager().refreshTools()`
3. `reloadCommands()`
4. summarize active extension capabilities
The summary counts active extension declarations for:
- extensions;
- commands;
- skills;
- agents;
- hooks;
- extension MCP servers;
- extension LSP servers.
`/reload-plugins` owns the user-facing command behavior:
1. require `config`;
2. emit `ExtensionsReloadStarted`;
3. call `reloadPluginsRuntime()`;
4. call `clearExtensionsChanged()` on success or failure;
5. return either a localized info summary or an error message.
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.
### 7. Auto-Refresh Content-Level Changes
`refreshExtensionContentRuntime()` is used for content-only filesystem changes.
Flow:
1. refresh extension cache;
2. refresh skill cache;
3. refresh subagent cache;
4. reload slash commands;
5. aggregate errors and throw a single message if any leg failed.
The slash command processor listens for `ExtensionContentChanged` and debounces
the refresh by 250 ms. It serializes refreshes with:
```ts
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.
### 8. Reload Hooks Without Dropping Agent-Scoped Hooks
`HookRegistry.reloadConfiguredHooks()` replaces only configured hook entries.
It preserves entries with `agentScope !== undefined`, because those are
temporary hooks registered for subagent execution.
Flow:
1. save `previousEntries`;
2. keep `agentEntries`;
3. set registry entries to `agentEntries`;
4. run `processHooksFromConfig()`;
5. on failure, restore `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.
### 9. Wire State Into Interactive UI
Interactive startup creates one shared `ExtensionRefreshState`:
```ts
const extensionRefreshState = new ExtensionRefreshState();
const extensionFileWatcher = isBareMode(argv.bare)
? undefined
: new ExtensionFileWatcher(config, undefined, extensionRefreshState);
```
That state is passed through:
```text
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.
## Event Flows
### Content File Edit
```text
edit extension commands/skills/agents file
-> ExtensionFileWatcher classifies as auto
-> ExtensionRefreshState.markExtensionContentChanged()
-> useSlashCommandProcessor schedules debounced refresh
-> refreshExtensionContentRuntime()
-> ExtensionManager.refreshCache()
-> SkillManager.refreshCache()
-> SubagentManager.refreshCache()
-> reloadCommands()
```
### Package-Level File Edit
```text
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()
```
### UI Mutation
```text
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
```
## Concurrency and Ordering
- Watcher restarts are generation-guarded. Events from an old watcher instance
are ignored after `watchGeneration` changes.
- Mutation suppression is paired by mutation id, not stack order.
- `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.
- Content auto-refresh is serialized in the slash command processor. Concurrent
events coalesce into at most one pending rerun.
- `/reload-plugins` emits `ExtensionsReloadStarted` and `ExtensionsReloaded` so
pending content refresh timers are canceled around manual reload.
- Package-level stale state wins over content auto-refresh. If a stale reload is
needed, content auto-refresh exits and waits for `/reload-plugins`.
## Failure Semantics
| 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. |
## Tests
### Core Tests
`packages/core/src/extension/extension-runtime-refresh.test.ts`
- returns early without config;
- refreshes MCP before skills/subagents/hooks/memory;
- propagates MCP reconcile failures;
- keeps skill refresh failure best-effort;
- propagates hook reload failures after other refresh legs settle;
- keeps hierarchical memory failure best-effort.
`packages/core/src/extension/extensionManager.test.ts`
- emits mutation start/end around disable;
- emits mutation end when disable fails;
- emits mutation start/end around install, including nested enable events;
- emits mutation start/end around uninstall;
- emits mutation start/end around update temp directory failure;
- does not emit mutation events for favorite changes or source timestamp
updates;
- preserves existing extension loading, command discovery, hook loading, and
refreshTools coverage.
`packages/core/src/hooks/hookRegistry.test.ts`
- reloads configured hooks;
- preserves agent-scoped hooks during reload;
- restores previous entries when configured hook reload fails.
`packages/core/src/hooks/hookSystem.test.ts`
- delegates reload to the hook registry.
### CLI Tests
`packages/cli/src/config/extension-refresh-state.test.ts`
- emits stale refresh events once until cleared;
- emits content refresh events;
- suppresses notifications during mutation suppression;
- clears stale state and suppression windows correctly.
`packages/cli/src/config/extension-file-watcher.test.ts`
- classifies commands, skills, and agents as auto-refresh;
- classifies manifests, install metadata, hooks, context files, and extension
topology changes as stale;
- ignores unknown files and ignored directories;
- watches linked extension sources;
- suppresses notifications during programmatic mutation;
- restarts watching after mutation settlement;
- handles late creation of the extension directory.
`packages/cli/src/config/extension-runtime-reload.test.ts`
- reloads extension cache, runtime tools, and slash commands for
`/reload-plugins`;
- summarizes active extension capabilities;
- refreshes content runtime components;
- aggregates content auto-refresh failures.
`packages/cli/src/ui/commands/reload-plugins-command.test.ts`
- registers the command as interactive-only behavior;
- returns an error when config is missing;
- reloads runtime and clears stale state on success;
- clears stale state on failure and returns an error.
`packages/cli/src/services/BuiltinCommandLoader.test.ts`
- includes `/reload-plugins` in built-in command loading.
### Manual Verification
Manual verification should cover:
1. Enable an extension from the UI and confirm commands, skills, agents, MCP,
hooks, and context are refreshed without restarting.
2. Disable the same extension and confirm runtime capabilities are removed or no
longer offered.
3. Edit a command file under `commands/` and confirm slash command completion
updates automatically.
4. Edit a skill file under `skills/` and confirm skill-backed slash command
completion updates automatically.
5. Edit an agent file under `agents/` and confirm agent cache behavior reflects
the change.
6. Edit `hooks/hooks.json`, `qwen-extension.json`, install metadata, context
files, or extension directory topology and confirm the UI asks for
`/reload-plugins`.
7. Run `/reload-plugins` and confirm the summary reports extensions, commands,
skills, agents, hooks, extension MCP servers, and extension LSP servers.
8. Force a reload failure and confirm the UI reports the error, then a later
filesystem change can still trigger another notification.
## Tradeoffs
- Hooks are treated as package-level stale changes even though a configured hook
reload API exists. This avoids silently changing hook execution behavior from
a background filesystem event.
- MCP refresh remains full runtime reinitialization. Per-extension incremental
MCP restart would reduce cost but would expand this PR into MCP ownership and
reconciliation logic.
- The watcher classifies unknown files as ignored instead of stale. This reduces
noise for build artifacts but means extension authors must put runtime
capability files in the supported convention directories.
- Linked extension roots are watched directly. This improves authoring
ergonomics but can increase watcher count for users with many linked
extensions.
## Future Work
- Add per-extension incremental MCP reconciliation.
- Add user-visible diagnostics for fatal watcher errors such as `ENOSPC` or
`EMFILE`.
- Consider a typed reload result from `refreshExtensionRuntime()` if callers
need partial-success summaries.
- Optimize linked extension source lookup with a precomputed root map if many
linked extensions become common.
- Revisit hook content auto-refresh only if hook reload can be made explicit,
observable, and safe enough for background application.

View file

@ -0,0 +1,524 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as path from 'node:path';
import type { Config } from '@qwen-code/qwen-code-core';
import { ExtensionFileWatcher } from './extension-file-watcher.js';
import type { ExtensionRefreshState } from './extension-refresh-state.js';
type EventHandler = (...args: unknown[]) => void;
interface MockWatcherEntry {
target: string | string[];
options: Record<string, unknown>;
handlers: Record<string, EventHandler>;
instance: {
on: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
};
}
const { mockWatchers, mockWatch, mockExistsSync } = vi.hoisted(() => {
const mockWatchers: MockWatcherEntry[] = [];
const mockExistsSync = vi.fn().mockReturnValue(true);
const mockWatch = vi
.fn()
.mockImplementation(
(target: string | string[], options: Record<string, unknown>) => {
const handlers: Record<string, EventHandler> = {};
const instance = {
on: vi
.fn()
.mockImplementation((event: string, handler: EventHandler) => {
handlers[event] = handler;
return instance;
}),
close: vi.fn().mockResolvedValue(undefined),
};
mockWatchers.push({ target, options, handlers, instance });
return instance;
},
);
return { mockWatchers, mockWatch, mockExistsSync };
});
vi.mock('chokidar', () => ({
watch: mockWatch,
}));
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return {
...actual,
existsSync: mockExistsSync,
};
});
function configWithExtensions(extensions: unknown[]): Config {
return {
getExtensions: () => extensions,
getActiveExtensions: () => extensions,
getExtensionManager: () => ({
addMutationListener: vi.fn(() => vi.fn()),
}),
} as unknown as Config;
}
function createRefreshState(): ExtensionRefreshState {
return {
markExtensionContentChanged: vi.fn(),
markExtensionsChanged: vi.fn(),
beginSuppression: vi.fn((onSettle?: () => void) => () => onSettle?.()),
} as unknown as ExtensionRefreshState;
}
function fireAllEvent(
watcherIndex: number,
event: string,
changedPath: string,
) {
mockWatchers[watcherIndex].handlers['all']?.(event, changedPath);
}
describe('ExtensionFileWatcher', () => {
const extensionsDir = '/home/user/.qwen/extensions';
beforeEach(() => {
vi.clearAllMocks();
mockWatchers.length = 0;
mockExistsSync.mockReturnValue(true);
});
it('watches the extensions directory and linked extension sources', () => {
const linkedSource = path.resolve('relative-linked-extension');
const refreshState = createRefreshState();
const watcher = new ExtensionFileWatcher(
configWithExtensions([
{
path: linkedSource,
config: {},
installMetadata: {
type: 'link',
source: 'relative-linked-extension',
},
contextFiles: [],
},
]),
extensionsDir,
refreshState,
);
watcher.startWatching();
expect(mockWatch).toHaveBeenCalledOnce();
expect(mockWatchers[0].target).toEqual([extensionsDir, linkedSource]);
expect(mockWatchers[0].options).toEqual(
expect.objectContaining({
ignoreInitial: true,
followSymlinks: false,
}),
);
});
it('marks refresh needed for manual extension add and remove events', () => {
const refreshState = createRefreshState();
const watcher = new ExtensionFileWatcher(
configWithExtensions([]),
extensionsDir,
refreshState,
);
watcher.startWatching();
fireAllEvent(0, 'addDir', `${extensionsDir}/new-extension`);
fireAllEvent(0, 'unlinkDir', `${extensionsDir}/old-extension`);
expect(refreshState.markExtensionsChanged).toHaveBeenCalledTimes(2);
});
it('marks stale refresh needed for inventory and hook files', () => {
const refreshState = createRefreshState();
const watcher = new ExtensionFileWatcher(
configWithExtensions([
{
path: `${extensionsDir}/alpha`,
config: {},
installMetadata: undefined,
contextFiles: [],
},
]),
extensionsDir,
refreshState,
);
watcher.startWatching();
fireAllEvent(0, 'change', `${extensionsDir}/alpha/qwen-extension.json`);
fireAllEvent(0, 'change', `${extensionsDir}/alpha/hooks/hooks.json`);
fireAllEvent(0, 'change', `${extensionsDir}/extension-enablement.json`);
expect(refreshState.markExtensionsChanged).toHaveBeenCalledTimes(3);
expect(refreshState.markExtensionContentChanged).not.toHaveBeenCalled();
});
it('does not mark preferences or marketplace metadata as stale', () => {
const refreshState = createRefreshState();
const watcher = new ExtensionFileWatcher(
configWithExtensions([]),
extensionsDir,
refreshState,
);
watcher.startWatching();
fireAllEvent(0, 'change', `${extensionsDir}/extension-preferences.json`);
fireAllEvent(0, 'change', `${extensionsDir}/marketplaces.json`);
expect(refreshState.markExtensionsChanged).not.toHaveBeenCalled();
expect(refreshState.markExtensionContentChanged).not.toHaveBeenCalled();
});
it('auto-refreshes command, skill, and agent content changes', () => {
const refreshState = createRefreshState();
const watcher = new ExtensionFileWatcher(
configWithExtensions([]),
extensionsDir,
refreshState,
);
watcher.startWatching();
fireAllEvent(0, 'add', `${extensionsDir}/alpha/commands/run.toml`);
fireAllEvent(0, 'unlink', `${extensionsDir}/alpha/skills/demo/SKILL.md`);
fireAllEvent(0, 'change', `${extensionsDir}/alpha/agents/reviewer.md`);
expect(refreshState.markExtensionContentChanged).toHaveBeenCalledTimes(3);
expect(refreshState.markExtensionsChanged).not.toHaveBeenCalled();
});
it('treats content events as stale when the extension manifest is gone', () => {
mockExistsSync.mockImplementation(
(filePath: string) => !filePath.endsWith('/alpha/qwen-extension.json'),
);
const refreshState = createRefreshState();
const watcher = new ExtensionFileWatcher(
configWithExtensions([]),
extensionsDir,
refreshState,
);
watcher.startWatching();
fireAllEvent(0, 'unlink', `${extensionsDir}/alpha/commands/run.toml`);
expect(refreshState.markExtensionsChanged).toHaveBeenCalledOnce();
expect(refreshState.markExtensionContentChanged).not.toHaveBeenCalled();
});
it('marks refresh needed for linked source changes and context files', () => {
const refreshState = createRefreshState();
const watcher = new ExtensionFileWatcher(
configWithExtensions([
{
path: '/tmp/linked-extension',
config: {},
installMetadata: { type: 'link', source: '/tmp/linked-extension' },
contextFiles: ['/tmp/linked-extension/GEMINI.md'],
},
]),
extensionsDir,
refreshState,
);
watcher.startWatching();
fireAllEvent(0, 'change', '/tmp/linked-extension/qwen-extension.json');
fireAllEvent(0, 'change', '/tmp/linked-extension/GEMINI.md');
fireAllEvent(0, 'change', '/tmp/linked-extension/commands/run.toml');
expect(refreshState.markExtensionsChanged).toHaveBeenCalledTimes(2);
expect(refreshState.markExtensionContentChanged).toHaveBeenCalledOnce();
});
it('marks refresh needed for file-backed hook and LSP config changes', () => {
const refreshState = createRefreshState();
const watcher = new ExtensionFileWatcher(
configWithExtensions([
{
path: `${extensionsDir}/alpha`,
config: {
hooks: 'config/hooks.json',
lspServers: '/tmp/alpha-lsp.json',
},
installMetadata: undefined,
contextFiles: [],
},
]),
extensionsDir,
refreshState,
);
watcher.startWatching();
fireAllEvent(0, 'change', `${extensionsDir}/alpha/config/hooks.json`);
fireAllEvent(0, 'change', '/tmp/alpha-lsp.json');
expect(refreshState.markExtensionsChanged).toHaveBeenCalledTimes(2);
expect(refreshState.markExtensionContentChanged).not.toHaveBeenCalled();
});
it('does not watch inactive linked extension sources or context files', () => {
const activeSource = '/tmp/active-linked-extension';
const inactiveSource = '/tmp/inactive-linked-extension';
const refreshState = createRefreshState();
const watcher = new ExtensionFileWatcher(
{
getExtensions: () => [
{
path: activeSource,
config: {},
installMetadata: { type: 'link', source: activeSource },
contextFiles: [`${activeSource}/QWEN.md`],
},
{
path: inactiveSource,
config: {},
installMetadata: { type: 'link', source: inactiveSource },
contextFiles: [`${inactiveSource}/QWEN.md`],
},
],
getActiveExtensions: () => [
{
path: activeSource,
config: {},
installMetadata: { type: 'link', source: activeSource },
contextFiles: [`${activeSource}/QWEN.md`],
},
],
getExtensionManager: () => ({
addMutationListener: vi.fn(() => vi.fn()),
}),
} as unknown as Config,
extensionsDir,
refreshState,
);
watcher.startWatching();
expect(mockWatchers[0].target).toEqual([extensionsDir, activeSource]);
fireAllEvent(0, 'change', `${inactiveSource}/QWEN.md`);
fireAllEvent(0, 'change', `${inactiveSource}/commands/run.toml`);
expect(refreshState.markExtensionsChanged).not.toHaveBeenCalled();
expect(refreshState.markExtensionContentChanged).not.toHaveBeenCalled();
});
it('ignores unrelated files', () => {
const refreshState = createRefreshState();
const watcher = new ExtensionFileWatcher(
configWithExtensions([]),
extensionsDir,
refreshState,
);
watcher.startWatching();
fireAllEvent(0, 'change', `${extensionsDir}/alpha/README.md`);
fireAllEvent(0, 'change', `${extensionsDir}/alpha/node_modules/pkg/x.js`);
fireAllEvent(0, 'change', `${extensionsDir}/alpha/.DS_Store`);
expect(refreshState.markExtensionsChanged).not.toHaveBeenCalled();
const ignored = mockWatchers[0].options['ignored'] as (
filePath: string,
) => boolean;
expect(ignored('C:/Users/me/.qwen/extensions/alpha/.git/config')).toBe(
true,
);
expect(
ignored('C:/Users/me/.qwen/extensions/alpha/node_modules/pkg/index.js'),
).toBe(true);
});
it('bootstraps on the parent when the extensions directory does not exist', () => {
mockExistsSync.mockReturnValue(false);
const refreshState = createRefreshState();
const watcher = new ExtensionFileWatcher(
configWithExtensions([]),
extensionsDir,
refreshState,
);
watcher.startWatching();
expect(mockWatch).toHaveBeenCalledOnce();
expect(mockWatchers[0].target).toBe('/home/user/.qwen');
expect(mockWatchers[0].options).toEqual(
expect.objectContaining({
ignoreInitial: true,
followSymlinks: false,
depth: 0,
}),
);
});
it('restarts watching after extension manager mutations settle', () => {
const refreshState = createRefreshState();
let mutationListener:
| ((event: {
id: number;
phase: 'start' | 'end';
operation: string;
}) => void)
| undefined;
const manager = {
addMutationListener: vi.fn(
(
listener: (event: {
id: number;
phase: 'start' | 'end';
operation: string;
}) => void,
) => {
mutationListener = listener;
return vi.fn();
},
),
};
const watcher = new ExtensionFileWatcher(
{
getExtensions: () => [],
getActiveExtensions: () => [],
getExtensionManager: () => manager,
} as unknown as Config,
extensionsDir,
refreshState,
);
watcher.startWatching();
mutationListener?.({
id: 1,
phase: 'start',
operation: 'installExtension',
});
mutationListener?.({
id: 1,
phase: 'end',
operation: 'installExtension',
});
expect(mockWatch).toHaveBeenCalledTimes(2);
expect(mockWatchers[0].instance.close).toHaveBeenCalledOnce();
});
it('ignores buffered events from stopped watchers', () => {
const refreshState = createRefreshState();
const watcher = new ExtensionFileWatcher(
configWithExtensions([]),
extensionsDir,
refreshState,
);
watcher.startWatching();
const oldWatcherIndex = 0;
watcher.restartWatching();
fireAllEvent(oldWatcherIndex, 'addDir', `${extensionsDir}/old-buffered`);
expect(refreshState.markExtensionsChanged).not.toHaveBeenCalled();
});
it('pairs overlapping extension manager mutations by id', () => {
const refreshState = createRefreshState();
let mutationListener:
| ((event: {
id: number;
phase: 'start' | 'end';
operation: string;
}) => void)
| undefined;
const endSuppressions = new Map<number, () => void>();
vi.mocked(refreshState.beginSuppression).mockImplementation((onSettle) => {
const endSuppression = vi.fn(() => onSettle?.());
endSuppressions.set(endSuppressions.size + 1, endSuppression);
return endSuppression;
});
const manager = {
addMutationListener: vi.fn(
(
listener: (event: {
id: number;
phase: 'start' | 'end';
operation: string;
}) => void,
) => {
mutationListener = listener;
return vi.fn();
},
),
};
const watcher = new ExtensionFileWatcher(
{
getExtensions: () => [],
getActiveExtensions: () => [],
getExtensionManager: () => manager,
} as unknown as Config,
extensionsDir,
refreshState,
);
watcher.startWatching();
mutationListener?.({ id: 1, phase: 'start', operation: 'enableExtension' });
mutationListener?.({
id: 2,
phase: 'start',
operation: 'disableExtension',
});
mutationListener?.({ id: 1, phase: 'end', operation: 'enableExtension' });
mutationListener?.({ id: 2, phase: 'end', operation: 'disableExtension' });
expect(endSuppressions.get(1)).toHaveBeenCalledOnce();
expect(endSuppressions.get(2)).toHaveBeenCalledOnce();
});
it('ends pending mutation suppressions when watching stops', () => {
const refreshState = createRefreshState();
const endSuppression = vi.fn();
vi.mocked(refreshState.beginSuppression).mockReturnValue(endSuppression);
let mutationListener:
| ((event: {
id: number;
phase: 'start' | 'end';
operation: string;
}) => void)
| undefined;
const manager = {
addMutationListener: vi.fn(
(
listener: (event: {
id: number;
phase: 'start' | 'end';
operation: string;
}) => void,
) => {
mutationListener = listener;
return vi.fn();
},
),
};
const watcher = new ExtensionFileWatcher(
{
getExtensions: () => [],
getActiveExtensions: () => [],
getExtensionManager: () => manager,
} as unknown as Config,
extensionsDir,
refreshState,
);
watcher.startWatching();
mutationListener?.({
id: 1,
phase: 'start',
operation: 'installExtension',
});
watcher.stopWatching();
expect(endSuppression).toHaveBeenCalledOnce();
});
});

View file

@ -0,0 +1,345 @@
/**
* @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']);
const EXTENSION_FILES = new Set([
'qwen-extension.json',
'.qwen-extension-install.json',
]);
// Keep these sets in sync with extension directory conventions. New runtime
// directories must be classified here as either content-auto-refreshable or
// package-stale.
const AUTO_REFRESH_DIRS = new Set(['commands', 'skills', 'agents']);
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 staleFiles = 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.staleFiles = this.getStaleFiles();
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),
})
.on('all', (event: string, changedPath: string) => {
if (generation !== this.watchGeneration) return;
const resolvedPath = path.resolve(changedPath);
const action = this.getRefreshAction(
event as WatchEvent,
resolvedPath,
);
let marked = false;
if (action === 'auto') {
marked = this.refreshState.markExtensionContentChanged(
'extension content files changed',
);
} else if (action === 'stale') {
marked = this.refreshState.markExtensionsChanged(
'extension files changed',
);
}
debugLogger.debug('Extension file event classified', {
event,
path: resolvedPath,
action,
marked,
});
})
.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[] {
const roots = new Set<string>();
if (fs.existsSync(this.extensionsDir)) {
roots.add(this.extensionsDir);
}
for (const extension of this.config.getActiveExtensions()) {
if (extension.installMetadata?.type === 'link') {
const rawSource = extension.installMetadata.source;
const source = rawSource ? path.resolve(rawSource) : undefined;
if (source && fs.existsSync(source)) {
roots.add(source);
}
}
}
return [...roots];
}
private getStaleFiles(): Set<string> {
const files = new Set<string>();
for (const extension of this.config.getActiveExtensions()) {
for (const filePath of extension.contextFiles) {
files.add(path.resolve(filePath));
}
const configured = extension.config.contextFileName;
const names =
configured === undefined
? ['QWEN.md']
: Array.isArray(configured)
? configured
: [configured];
for (const name of names) {
files.add(path.resolve(extension.path, name));
}
this.addManifestFileReference(
files,
extension.path,
extension.config.hooks,
);
this.addManifestFileReference(
files,
extension.path,
extension.config.lspServers,
);
}
return files;
}
private addManifestFileReference(
files: Set<string>,
extensionPath: string,
value: unknown,
): void {
if (typeof value !== 'string') return;
files.add(
path.isAbsolute(value)
? path.resolve(value)
: path.resolve(extensionPath, value),
);
}
private watchExtensionsParent(): void {
this.closeBootstrapWatcher();
const parentDir = path.dirname(this.extensionsDir);
const dirBasename = path.basename(this.extensionsDir);
const generation = this.watchGeneration;
this.bootstrapWatcher = watchFs(parentDir, {
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.staleFiles.has(changedPath)) {
return 'stale';
}
if (changedPath === path.resolve(this.extensionsDir)) {
if (event === 'unlinkDir') {
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(
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.getActiveExtensions()) {
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 {
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,
this.refreshState.beginSuppression(() =>
this.restartAfterMutation(),
),
);
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();
}
}
private closeBootstrapWatcher(): void {
const bootstrapWatcher = this.bootstrapWatcher;
this.bootstrapWatcher = undefined;
bootstrapWatcher?.close().catch((error: unknown) => {
debugLogger.warn('Extension bootstrap watcher close error:', error);
});
}
}

View file

@ -0,0 +1,250 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ExtensionRefreshState } from './extension-refresh-state.js';
import { AppEvent } from '../utils/events.js';
describe('extension refresh state', () => {
let refreshState: ExtensionRefreshState;
beforeEach(() => {
refreshState = new ExtensionRefreshState();
});
it('deduplicates refresh notifications until cleared', () => {
const listener = vi.fn();
refreshState.on(AppEvent.ExtensionRefreshNeeded, listener);
try {
expect(
refreshState.markExtensionsChanged('extension files changed'),
).toBe(true);
expect(refreshState.needsExtensionRefresh()).toBe(true);
expect(listener).toHaveBeenCalledWith('extension files changed');
expect(
refreshState.markExtensionsChanged('extension files changed again'),
).toBe(false);
expect(listener).toHaveBeenCalledTimes(1);
refreshState.clearExtensionsChanged();
expect(refreshState.needsExtensionRefresh()).toBe(false);
expect(
refreshState.markExtensionsChanged('extension files changed again'),
).toBe(true);
expect(listener).toHaveBeenCalledTimes(2);
} finally {
refreshState.off(AppEvent.ExtensionRefreshNeeded, listener);
}
});
it('suppresses watcher notifications during known mutations', async () => {
const staleListener = vi.fn();
const contentListener = vi.fn();
refreshState.on(AppEvent.ExtensionRefreshNeeded, staleListener);
refreshState.on(AppEvent.ExtensionContentChanged, contentListener);
try {
await refreshState.suppressNotifications(async () => {
expect(
refreshState.markExtensionsChanged('extension files changed'),
).toBe(false);
expect(
refreshState.markExtensionContentChanged('extension content changed'),
).toBe(false);
});
expect(refreshState.needsExtensionRefresh()).toBe(false);
expect(staleListener).not.toHaveBeenCalled();
expect(contentListener).not.toHaveBeenCalled();
} finally {
refreshState.off(AppEvent.ExtensionRefreshNeeded, staleListener);
refreshState.off(AppEvent.ExtensionContentChanged, contentListener);
}
});
it('clears the post-mutation suppression window after reload', async () => {
const listener = vi.fn();
refreshState.on(AppEvent.ExtensionRefreshNeeded, listener);
try {
await refreshState.suppressNotifications(async () => {});
expect(refreshState.markExtensionsChanged('during suppress window')).toBe(
false,
);
refreshState.clearExtensionsChanged();
expect(refreshState.markExtensionsChanged('after reload')).toBe(true);
expect(listener).toHaveBeenCalledWith('after reload');
} finally {
refreshState.off(AppEvent.ExtensionRefreshNeeded, listener);
}
});
it('preserves stale state for changes that arrive during reload', () => {
const listener = vi.fn();
refreshState.on(AppEvent.ExtensionRefreshNeeded, listener);
try {
expect(refreshState.markExtensionsChanged('before reload')).toBe(true);
refreshState.notifyExtensionsReloadStarted();
expect(refreshState.markExtensionsChanged('during reload')).toBe(false);
refreshState.clearExtensionsChanged();
expect(refreshState.needsExtensionRefresh()).toBe(true);
expect(listener).toHaveBeenCalledTimes(2);
expect(listener).toHaveBeenLastCalledWith(
'extension files changed during reload',
);
refreshState.clearExtensionsChanged();
expect(refreshState.needsExtensionRefresh()).toBe(false);
} finally {
refreshState.off(AppEvent.ExtensionRefreshNeeded, listener);
}
});
it('defers content changes that arrive during reload', () => {
const contentListener = vi.fn();
refreshState.on(AppEvent.ExtensionContentChanged, contentListener);
try {
refreshState.notifyExtensionsReloadStarted();
expect(
refreshState.markExtensionContentChanged(
'content changed during reload',
),
).toBe(false);
expect(contentListener).not.toHaveBeenCalled();
refreshState.clearExtensionsChanged();
expect(refreshState.needsExtensionRefresh()).toBe(false);
expect(contentListener).toHaveBeenCalledOnce();
expect(contentListener).toHaveBeenCalledWith(
'extension content files changed during reload',
);
} finally {
refreshState.off(AppEvent.ExtensionContentChanged, contentListener);
}
});
it('lets stale changes during reload take priority over content changes', () => {
const staleListener = vi.fn();
const contentListener = vi.fn();
refreshState.on(AppEvent.ExtensionRefreshNeeded, staleListener);
refreshState.on(AppEvent.ExtensionContentChanged, contentListener);
try {
refreshState.notifyExtensionsReloadStarted();
expect(refreshState.markExtensionContentChanged('content changed')).toBe(
false,
);
expect(refreshState.markExtensionsChanged('manifest changed')).toBe(
false,
);
refreshState.clearExtensionsChanged();
expect(refreshState.needsExtensionRefresh()).toBe(true);
expect(staleListener).toHaveBeenCalledOnce();
expect(staleListener).toHaveBeenCalledWith(
'extension files changed during reload',
);
expect(contentListener).not.toHaveBeenCalled();
} finally {
refreshState.off(AppEvent.ExtensionRefreshNeeded, staleListener);
refreshState.off(AppEvent.ExtensionContentChanged, contentListener);
}
});
it('does not emit content changes while stale refresh is needed', () => {
const contentListener = vi.fn();
refreshState.on(AppEvent.ExtensionContentChanged, contentListener);
try {
expect(refreshState.markExtensionsChanged('manifest changed')).toBe(true);
expect(refreshState.markExtensionContentChanged('content changed')).toBe(
false,
);
expect(contentListener).not.toHaveBeenCalled();
} finally {
refreshState.off(AppEvent.ExtensionContentChanged, contentListener);
}
});
it('keeps refresh needed when reload fails', () => {
const staleListener = vi.fn();
const reloadedListener = vi.fn();
refreshState.on(AppEvent.ExtensionRefreshNeeded, staleListener);
refreshState.on(AppEvent.ExtensionsReloaded, reloadedListener);
try {
refreshState.notifyExtensionsReloadStarted();
refreshState.markExtensionsReloadFailed('reload failed');
expect(refreshState.needsExtensionRefresh()).toBe(true);
expect(reloadedListener).toHaveBeenCalledOnce();
expect(staleListener).toHaveBeenCalledWith('reload failed');
} finally {
refreshState.off(AppEvent.ExtensionRefreshNeeded, staleListener);
refreshState.off(AppEvent.ExtensionsReloaded, reloadedListener);
}
});
it('preserves changes that arrive before a reload failure', () => {
const staleListener = vi.fn();
const contentListener = vi.fn();
refreshState.on(AppEvent.ExtensionRefreshNeeded, staleListener);
refreshState.on(AppEvent.ExtensionContentChanged, contentListener);
try {
refreshState.notifyExtensionsReloadStarted();
expect(refreshState.isReloadInProgress()).toBe(true);
expect(refreshState.markExtensionContentChanged('content changed')).toBe(
false,
);
expect(refreshState.markExtensionsChanged('manifest changed')).toBe(
false,
);
refreshState.markExtensionsReloadFailed('reload failed');
expect(refreshState.isReloadInProgress()).toBe(false);
expect(refreshState.needsExtensionRefresh()).toBe(true);
expect(staleListener).toHaveBeenCalledWith('reload failed');
refreshState.clearExtensionsChanged();
expect(refreshState.needsExtensionRefresh()).toBe(true);
expect(staleListener).toHaveBeenCalledWith(
'extension files changed during reload',
);
expect(contentListener).not.toHaveBeenCalled();
} finally {
refreshState.off(AppEvent.ExtensionRefreshNeeded, staleListener);
refreshState.off(AppEvent.ExtensionContentChanged, contentListener);
}
});
it('settles only after all overlapping suppressions end', () => {
const onSettle = vi.fn();
const endFirst = refreshState.beginSuppression(onSettle);
const endSecond = refreshState.beginSuppression(onSettle);
endFirst();
expect(onSettle).not.toHaveBeenCalled();
endSecond();
expect(onSettle).toHaveBeenCalledOnce();
endSecond();
expect(onSettle).toHaveBeenCalledOnce();
});
});

View file

@ -0,0 +1,162 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { EventEmitter } from 'node:events';
import { AppEvent } from '../utils/events.js';
const SUPPRESS_AFTER_MS = 1000;
export const EXTENSION_RELOAD_FAILED_REASON = 'extension reload failed';
export class ExtensionRefreshState {
private extensionRefreshNeeded = false;
private reloadInProgress = false;
private changedDuringReload = false;
private contentChangedDuringReload = false;
private suppressionDepth = 0;
private suppressUntil = 0;
constructor(private readonly events = new EventEmitter()) {}
on(event: AppEvent, listener: (...args: unknown[]) => void): void {
this.events.on(event, listener);
}
off(event: AppEvent, listener: (...args: unknown[]) => void): void {
this.events.off(event, listener);
}
markExtensionsChanged(reason?: string): boolean {
if (this.isSuppressed()) {
return false;
}
if (this.reloadInProgress) {
this.changedDuringReload = true;
return false;
}
if (this.extensionRefreshNeeded) {
return false;
}
this.extensionRefreshNeeded = true;
this.events.emit(AppEvent.ExtensionRefreshNeeded, reason);
return true;
}
markExtensionContentChanged(reason?: string): boolean {
if (this.isSuppressed()) {
return false;
}
if (this.reloadInProgress) {
this.contentChangedDuringReload = true;
return false;
}
if (this.extensionRefreshNeeded) {
return false;
}
this.events.emit(AppEvent.ExtensionContentChanged, reason);
return true;
}
clearExtensionsChanged(): void {
const changedDuringReload = this.changedDuringReload;
const contentChangedDuringReload = this.contentChangedDuringReload;
this.extensionRefreshNeeded = changedDuringReload;
this.reloadInProgress = false;
this.changedDuringReload = false;
this.contentChangedDuringReload = false;
this.suppressUntil = 0;
this.events.emit(AppEvent.ExtensionsReloaded);
if (changedDuringReload) {
this.events.emit(
AppEvent.ExtensionRefreshNeeded,
'extension files changed during reload',
);
} else if (contentChangedDuringReload) {
this.events.emit(
AppEvent.ExtensionContentChanged,
'extension content files changed during reload',
);
}
}
notifyExtensionsReloadStarted(): void {
this.reloadInProgress = true;
this.changedDuringReload = false;
this.contentChangedDuringReload = false;
this.events.emit(AppEvent.ExtensionsReloadStarted);
}
markExtensionsReloadFailed(reason = EXTENSION_RELOAD_FAILED_REASON): void {
const changedDuringReload = this.changedDuringReload;
const contentChangedDuringReload = this.contentChangedDuringReload;
this.extensionRefreshNeeded = true;
this.reloadInProgress = false;
this.changedDuringReload = changedDuringReload;
this.contentChangedDuringReload = contentChangedDuringReload;
this.suppressUntil = 0;
this.events.emit(AppEvent.ExtensionsReloaded);
this.events.emit(AppEvent.ExtensionRefreshNeeded, reason);
}
needsExtensionRefresh(): boolean {
return this.extensionRefreshNeeded;
}
isReloadInProgress(): boolean {
return this.reloadInProgress;
}
beginSuppression(onSettle?: () => void): () => void {
this.suppressionDepth++;
let settled = false;
return () => {
if (settled) return;
settled = true;
this.suppressionDepth = Math.max(0, this.suppressionDepth - 1);
this.suppressUntil = Date.now() + SUPPRESS_AFTER_MS;
if (this.suppressionDepth === 0) {
onSettle?.();
}
};
}
suppressNotifications<T>(fn: () => T, onSettle?: () => void): T {
const endSuppression = this.beginSuppression(onSettle);
try {
const result = fn();
if (isPromiseLike(result)) {
return Promise.resolve(result).finally(endSuppression) as T;
}
endSuppression();
return result;
} catch (error) {
endSuppression();
throw error;
}
}
resetForTesting(): void {
this.extensionRefreshNeeded = false;
this.reloadInProgress = false;
this.changedDuringReload = false;
this.contentChangedDuringReload = false;
this.suppressionDepth = 0;
this.suppressUntil = 0;
}
private isSuppressed(): boolean {
return this.suppressionDepth > 0 || Date.now() < this.suppressUntil;
}
}
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
return (
typeof value === 'object' &&
value !== null &&
'then' in value &&
typeof value.then === 'function'
);
}

View file

@ -0,0 +1,170 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it, vi } from 'vitest';
import type { Config } from '@qwen-code/qwen-code-core';
import {
refreshExtensionContentRuntime,
reloadPluginsRuntime,
} from './extension-runtime-reload.js';
describe('reloadPluginsRuntime', () => {
it('refreshes extension runtime and returns active extension counts', async () => {
const refreshCache = vi.fn();
const refreshTools = vi.fn();
const reloadCommands = vi.fn();
const config = {
isSafeMode: () => false,
getExtensionManager: () => ({
refreshCache,
refreshTools,
}),
getActiveExtensions: () => [
{
commands: ['a', 'b'],
skills: [{ name: 's1' }],
agents: [{ name: 'a1' }, { name: 'a2' }],
hooks: {
UserPromptSubmit: [
{
hooks: [
{ type: 'command', command: 'echo one' },
{ type: 'command', command: 'echo two' },
],
},
],
},
mcpServers: {
one: { command: 'node' },
},
config: {
lspServers: {
ts: {},
},
},
},
],
} as unknown as Config;
const summary = await reloadPluginsRuntime({ config, reloadCommands });
expect(refreshCache).toHaveBeenCalledOnce();
expect(refreshTools).toHaveBeenCalledOnce();
expect(reloadCommands).toHaveBeenCalledOnce();
expect(summary).toEqual({
extensionCount: 1,
commandCount: 2,
skillCount: 1,
agentCount: 2,
hookCount: 2,
mcpServerCount: 1,
lspServerCount: 1,
});
});
it('rejects without refreshing extensions in safe mode', async () => {
const refreshCache = vi.fn();
const refreshTools = vi.fn();
const reloadCommands = vi.fn();
const config = {
isSafeMode: () => true,
getExtensionManager: () => ({
refreshCache,
refreshTools,
}),
} as unknown as Config;
await expect(
reloadPluginsRuntime({ config, reloadCommands }),
).rejects.toThrow('Extension reload is disabled in safe mode.');
expect(refreshCache).not.toHaveBeenCalled();
expect(refreshTools).not.toHaveBeenCalled();
expect(reloadCommands).not.toHaveBeenCalled();
});
});
describe('refreshExtensionContentRuntime', () => {
it('refreshes extension cache, skills, agents, and slash commands', async () => {
const refreshCache = vi.fn();
const refreshSkillCache = vi.fn();
const refreshSubagentCache = vi.fn();
const reloadCommands = vi.fn();
const config = {
isSafeMode: () => false,
getExtensionManager: () => ({ refreshCache }),
getSkillManager: () => ({ refreshCache: refreshSkillCache }),
getSubagentManager: () => ({ refreshCache: refreshSubagentCache }),
} as unknown as Config;
await refreshExtensionContentRuntime({ config, reloadCommands });
expect(refreshCache).toHaveBeenCalledOnce();
expect(refreshSkillCache).toHaveBeenCalledOnce();
expect(refreshSubagentCache).toHaveBeenCalledOnce();
expect(reloadCommands).toHaveBeenCalledOnce();
});
it('continues refreshing content before reporting refresh failures', async () => {
const refreshCache = vi.fn().mockRejectedValue(new Error('cache failed'));
const refreshSkillCache = vi
.fn()
.mockRejectedValue(new Error('skills failed'));
const refreshSubagentCache = vi.fn();
const reloadCommands = vi.fn();
const config = {
isSafeMode: () => false,
getExtensionManager: () => ({ refreshCache }),
getSkillManager: () => ({ refreshCache: refreshSkillCache }),
getSubagentManager: () => ({ refreshCache: refreshSubagentCache }),
} as unknown as Config;
await expect(
refreshExtensionContentRuntime({ config, reloadCommands }),
).rejects.toThrow('cache failed; skills failed');
expect(refreshCache).toHaveBeenCalledOnce();
expect(refreshSkillCache).toHaveBeenCalledOnce();
expect(refreshSubagentCache).toHaveBeenCalledOnce();
expect(reloadCommands).toHaveBeenCalledOnce();
});
it('continues refreshing content when subagent manager is not initialized', async () => {
const refreshCache = vi.fn();
const refreshSkillCache = vi.fn();
const reloadCommands = vi.fn();
const config = {
isSafeMode: () => false,
getExtensionManager: () => ({ refreshCache }),
getSkillManager: () => ({ refreshCache: refreshSkillCache }),
getSubagentManager: () => undefined,
} as unknown as Config;
await expect(
refreshExtensionContentRuntime({ config, reloadCommands }),
).resolves.toBeUndefined();
expect(refreshCache).toHaveBeenCalledOnce();
expect(refreshSkillCache).toHaveBeenCalledOnce();
expect(reloadCommands).toHaveBeenCalledOnce();
});
it('skips content refresh in safe mode', async () => {
const refreshCache = vi.fn();
const reloadCommands = vi.fn();
const config = {
isSafeMode: () => true,
getExtensionManager: () => ({ refreshCache }),
} as unknown as Config;
await expect(
refreshExtensionContentRuntime({ config, reloadCommands }),
).resolves.toBeUndefined();
expect(refreshCache).not.toHaveBeenCalled();
expect(reloadCommands).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,125 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import {
createDebugLogger,
type Config,
type Extension,
} from '@qwen-code/qwen-code-core';
const debugLogger = createDebugLogger('EXTENSION_RUNTIME_RELOAD');
export interface ReloadPluginsSummary {
extensionCount: number;
commandCount: number;
skillCount: number;
agentCount: number;
hookCount: number;
mcpServerCount: number;
lspServerCount: number;
}
export async function reloadPluginsRuntime(options: {
config: Config;
reloadCommands?: () => void | Promise<void>;
}): Promise<ReloadPluginsSummary> {
if (options.config.isSafeMode()) {
throw new Error('Extension reload is disabled in safe mode.');
}
const manager = options.config.getExtensionManager();
await manager.refreshCache();
await manager.refreshTools();
await options.reloadCommands?.();
return summarizeExtensions(options.config.getActiveExtensions());
}
export async function refreshExtensionContentRuntime(options: {
config: Config;
reloadCommands?: () => void | Promise<void>;
}): Promise<void> {
if (options.config.isSafeMode()) return;
const manager = options.config.getExtensionManager();
const errors: unknown[] = [];
try {
await manager.refreshCache();
} catch (error) {
errors.push(error);
debugLogger.warn(
'refreshExtensionContentRuntime: refreshCache failed:',
error,
);
}
const settled = await Promise.allSettled([
options.config.getSkillManager()?.refreshCache(),
options.config.getSubagentManager()?.refreshCache(),
options.reloadCommands?.(),
]);
for (const result of settled) {
if (result.status === 'rejected') {
errors.push(result.reason);
debugLogger.warn(
'refreshExtensionContentRuntime: a refresh leg failed:',
result.reason,
);
}
}
if (errors.length > 0) {
throw new Error(
errors
.map((error) =>
error instanceof Error ? error.message : String(error),
)
.join('; '),
);
}
}
function summarizeExtensions(extensions: Extension[]): ReloadPluginsSummary {
return extensions.reduce<ReloadPluginsSummary>(
(summary, extension) => {
summary.extensionCount++;
summary.commandCount += extension.commands?.length ?? 0;
summary.skillCount += extension.skills?.length ?? 0;
summary.agentCount += extension.agents?.length ?? 0;
summary.hookCount += countHooks(extension);
summary.mcpServerCount += Object.keys(extension.mcpServers ?? {}).length;
summary.lspServerCount += countLspServers(extension);
return summary;
},
{
extensionCount: 0,
commandCount: 0,
skillCount: 0,
agentCount: 0,
hookCount: 0,
mcpServerCount: 0,
lspServerCount: 0,
},
);
}
function countHooks(extension: Extension): number {
return Object.values(extension.hooks ?? {}).reduce(
(sum, definitions) =>
sum +
(definitions ?? []).reduce(
(innerSum, definition) => innerSum + (definition.hooks?.length ?? 0),
0,
),
0,
);
}
function countLspServers(extension: Extension): number {
const lspServers = extension.config.lspServers;
if (!lspServers) return 0;
if (typeof lspServers === 'string') return 1;
return Object.keys(lspServers).length;
}

View file

@ -201,6 +201,14 @@ vi.mock('./config/lsp-config-watcher.js', () => ({
},
}));
vi.mock('./config/extension-file-watcher.js', () => ({
ExtensionFileWatcher: class {
startWatching() {}
restartWatching() {}
stopWatching() {}
},
}));
function withLspDisabledConfig<T extends object>(
config: T,
): T & {

View file

@ -42,6 +42,8 @@ import {
import { SettingsWatcher } from './config/settingsWatcher.js';
import { registerMcpHotReload } from './config/hot-reload.js';
import { LspConfigWatcher } from './config/lsp-config-watcher.js';
import { ExtensionFileWatcher } from './config/extension-file-watcher.js';
import { ExtensionRefreshState } from './config/extension-refresh-state.js';
import { initializeI18n, resolveLanguageSetting } from './i18n/index.js';
import {
setupStartupWorktree,
@ -78,6 +80,25 @@ import { initializeLlmOutputLanguage } from './utils/languageUtils.js';
const debugLogger = createDebugLogger('STARTUP');
interface RuntimeLspReinitializeResult {
reconcile: {
added: string[];
removed: string[];
restarted: string[];
unchanged: string[];
failed: string[];
};
skipped: Array<{ name: string }>;
}
interface RuntimeLspClient {
reinitialize?: () => Promise<RuntimeLspReinitializeResult>;
}
interface RuntimeLspConfig {
reinitializeLsp?: () => Promise<RuntimeLspReinitializeResult | undefined>;
}
function clearCorruptionEnvVars(): void {
delete process.env[ENV_CORRUPTED_PATH];
delete process.env[ENV_WAS_RECOVERED];
@ -615,6 +636,28 @@ export async function main() {
registerLspHotReload(config, registerCleanup);
const extensionRefreshState = new ExtensionRefreshState();
const extensionFileWatcher =
isBareMode(argv.bare) || config.isSafeMode()
? undefined
: new ExtensionFileWatcher(config, undefined, extensionRefreshState);
extensionFileWatcher?.startWatching();
if (extensionFileWatcher) {
const restartExtensionWatcher = () =>
extensionFileWatcher.restartWatching();
extensionRefreshState.on(
AppEvent.ExtensionsReloaded,
restartExtensionWatcher,
);
registerCleanup(() => {
extensionRefreshState.off(
AppEvent.ExtensionsReloaded,
restartExtensionWatcher,
);
extensionFileWatcher.stopWatching();
});
}
// Phase D-1: persist the WorktreeSession sidecar so Phase C's restore
// machinery on a subsequent `--resume` picks the worktree back up, and
// capture any override of a previously-resumed session's worktree so
@ -850,6 +893,7 @@ export async function main() {
initializationResult!,
{
postRenderConnectIde: deferIdeConnection,
extensionRefreshState,
},
);
// Clean up corruption env vars so subsequent relaunch children
@ -1047,9 +1091,15 @@ export function registerLspHotReload(
config: Config,
registerCleanup: (fn: () => void | Promise<void>) => void,
): void {
const lspClient = config.getLspClient?.() as
| (ReturnType<Config['getLspClient']> & RuntimeLspClient)
| undefined;
const runtimeConfig = config as Config & RuntimeLspConfig;
const reinitializeLsp = runtimeConfig.reinitializeLsp;
if (
config.isLspEnabled?.() !== true ||
!config.getLspClient?.()?.reinitialize
!lspClient?.reinitialize ||
!reinitializeLsp
) {
return;
}
@ -1068,7 +1118,7 @@ export function registerLspHotReload(
);
let errorReported = false;
try {
const result = await config.reinitializeLsp();
const result = await reinitializeLsp();
if (result) {
const failedServers = getRuntimeReloadFailedNames(result.reconcile);
debugLogger.info(

View file

@ -2060,4 +2060,34 @@ export default {
'Ús: /history collapse-on-resume|expand-on-resume|expand-now',
'History collapsed: {{n}} messages hidden. Use /history expand-now to show.':
'Història reduïda: {{n}} missatges ocults. Utilitzeu /history expand-now per mostrar.',
// ============================================================================
// reload-plugins command
// ============================================================================
'{{count}} extension': '{{count}} extension',
'{{count}} extensions': '{{count}} extensions',
'{{count}} command': '{{count}} command',
'{{count}} commands': '{{count}} commands',
'{{count}} skill': '{{count}} skill',
'{{count}} skills': '{{count}} skills',
'{{count}} agent': '{{count}} agent',
'{{count}} agents': '{{count}} agents',
'{{count}} hook': '{{count}} hook',
'{{count}} hooks': '{{count}} hooks',
'{{count}} extension MCP server': '{{count}} extension MCP server',
'{{count}} extension MCP servers': '{{count}} extension MCP servers',
'{{count}} extension LSP server': '{{count}} extension LSP server',
'{{count}} extension LSP servers': '{{count}} extension LSP servers',
'Reload extension changes from disk': 'Reload extension changes from disk',
'Reloaded extensions: {{summary}}': 'Reloaded extensions: {{summary}}',
'Reload failed: {{message}}': 'Reload failed: {{message}}',
'Reload failed.': 'Reload failed.',
'Extensions changed on disk. Run /reload-plugins to apply updates.':
'Extensions changed on disk. Run /reload-plugins to apply updates.',
'Failed to refresh extension content: {{message}}. Run /reload-plugins to apply updates.':
'Failed to refresh extension content: {{message}}. Run /reload-plugins to apply updates.',
'Failed to refresh extension content. Run /reload-plugins to apply updates.':
'Failed to refresh extension content. Run /reload-plugins to apply updates.',
'Extension reload did not complete. Run /reload-plugins to try again.':
'Extension reload did not complete. Run /reload-plugins to try again.',
};

View file

@ -2105,4 +2105,34 @@ export default {
in: 'ein',
out: 'aus',
'In/Out': 'Ein/Aus',
// ============================================================================
// reload-plugins command
// ============================================================================
'{{count}} extension': '{{count}} extension',
'{{count}} extensions': '{{count}} extensions',
'{{count}} command': '{{count}} command',
'{{count}} commands': '{{count}} commands',
'{{count}} skill': '{{count}} skill',
'{{count}} skills': '{{count}} skills',
'{{count}} agent': '{{count}} agent',
'{{count}} agents': '{{count}} agents',
'{{count}} hook': '{{count}} hook',
'{{count}} hooks': '{{count}} hooks',
'{{count}} extension MCP server': '{{count}} extension MCP server',
'{{count}} extension MCP servers': '{{count}} extension MCP servers',
'{{count}} extension LSP server': '{{count}} extension LSP server',
'{{count}} extension LSP servers': '{{count}} extension LSP servers',
'Reload extension changes from disk': 'Reload extension changes from disk',
'Reloaded extensions: {{summary}}': 'Reloaded extensions: {{summary}}',
'Reload failed: {{message}}': 'Reload failed: {{message}}',
'Reload failed.': 'Reload failed.',
'Extensions changed on disk. Run /reload-plugins to apply updates.':
'Extensions changed on disk. Run /reload-plugins to apply updates.',
'Failed to refresh extension content: {{message}}. Run /reload-plugins to apply updates.':
'Failed to refresh extension content: {{message}}. Run /reload-plugins to apply updates.',
'Failed to refresh extension content. Run /reload-plugins to apply updates.':
'Failed to refresh extension content. Run /reload-plugins to apply updates.',
'Extension reload did not complete. Run /reload-plugins to try again.':
'Extension reload did not complete. Run /reload-plugins to try again.',
};

View file

@ -2552,4 +2552,34 @@ export default {
reqs: 'reqs',
in: 'in',
out: 'out',
// ============================================================================
// reload-plugins command
// ============================================================================
'{{count}} extension': '{{count}} extension',
'{{count}} extensions': '{{count}} extensions',
'{{count}} command': '{{count}} command',
'{{count}} commands': '{{count}} commands',
'{{count}} skill': '{{count}} skill',
'{{count}} skills': '{{count}} skills',
'{{count}} agent': '{{count}} agent',
'{{count}} agents': '{{count}} agents',
'{{count}} hook': '{{count}} hook',
'{{count}} hooks': '{{count}} hooks',
'{{count}} extension MCP server': '{{count}} extension MCP server',
'{{count}} extension MCP servers': '{{count}} extension MCP servers',
'{{count}} extension LSP server': '{{count}} extension LSP server',
'{{count}} extension LSP servers': '{{count}} extension LSP servers',
'Reload extension changes from disk': 'Reload extension changes from disk',
'Reloaded extensions: {{summary}}': 'Reloaded extensions: {{summary}}',
'Reload failed: {{message}}': 'Reload failed: {{message}}',
'Reload failed.': 'Reload failed.',
'Extensions changed on disk. Run /reload-plugins to apply updates.':
'Extensions changed on disk. Run /reload-plugins to apply updates.',
'Failed to refresh extension content: {{message}}. Run /reload-plugins to apply updates.':
'Failed to refresh extension content: {{message}}. Run /reload-plugins to apply updates.',
'Failed to refresh extension content. Run /reload-plugins to apply updates.':
'Failed to refresh extension content. Run /reload-plugins to apply updates.',
'Extension reload did not complete. Run /reload-plugins to try again.':
'Extension reload did not complete. Run /reload-plugins to try again.',
};

View file

@ -2107,4 +2107,34 @@ export default {
in: 'ent.',
out: 'sort.',
'In/Out': 'Ent/Sort',
// ============================================================================
// reload-plugins command
// ============================================================================
'{{count}} extension': '{{count}} extension',
'{{count}} extensions': '{{count}} extensions',
'{{count}} command': '{{count}} command',
'{{count}} commands': '{{count}} commands',
'{{count}} skill': '{{count}} skill',
'{{count}} skills': '{{count}} skills',
'{{count}} agent': '{{count}} agent',
'{{count}} agents': '{{count}} agents',
'{{count}} hook': '{{count}} hook',
'{{count}} hooks': '{{count}} hooks',
'{{count}} extension MCP server': '{{count}} extension MCP server',
'{{count}} extension MCP servers': '{{count}} extension MCP servers',
'{{count}} extension LSP server': '{{count}} extension LSP server',
'{{count}} extension LSP servers': '{{count}} extension LSP servers',
'Reload extension changes from disk': 'Reload extension changes from disk',
'Reloaded extensions: {{summary}}': 'Reloaded extensions: {{summary}}',
'Reload failed: {{message}}': 'Reload failed: {{message}}',
'Reload failed.': 'Reload failed.',
'Extensions changed on disk. Run /reload-plugins to apply updates.':
'Extensions changed on disk. Run /reload-plugins to apply updates.',
'Failed to refresh extension content: {{message}}. Run /reload-plugins to apply updates.':
'Failed to refresh extension content: {{message}}. Run /reload-plugins to apply updates.',
'Failed to refresh extension content. Run /reload-plugins to apply updates.':
'Failed to refresh extension content. Run /reload-plugins to apply updates.',
'Extension reload did not complete. Run /reload-plugins to try again.':
'Extension reload did not complete. Run /reload-plugins to try again.',
};

View file

@ -1869,4 +1869,34 @@ export default {
in: '入力',
out: '出力',
'In/Out': '入力/出力',
// ============================================================================
// reload-plugins command
// ============================================================================
'{{count}} extension': '{{count}} extension',
'{{count}} extensions': '{{count}} extensions',
'{{count}} command': '{{count}} command',
'{{count}} commands': '{{count}} commands',
'{{count}} skill': '{{count}} skill',
'{{count}} skills': '{{count}} skills',
'{{count}} agent': '{{count}} agent',
'{{count}} agents': '{{count}} agents',
'{{count}} hook': '{{count}} hook',
'{{count}} hooks': '{{count}} hooks',
'{{count}} extension MCP server': '{{count}} extension MCP server',
'{{count}} extension MCP servers': '{{count}} extension MCP servers',
'{{count}} extension LSP server': '{{count}} extension LSP server',
'{{count}} extension LSP servers': '{{count}} extension LSP servers',
'Reload extension changes from disk': 'Reload extension changes from disk',
'Reloaded extensions: {{summary}}': 'Reloaded extensions: {{summary}}',
'Reload failed: {{message}}': 'Reload failed: {{message}}',
'Reload failed.': 'Reload failed.',
'Extensions changed on disk. Run /reload-plugins to apply updates.':
'Extensions changed on disk. Run /reload-plugins to apply updates.',
'Failed to refresh extension content: {{message}}. Run /reload-plugins to apply updates.':
'Failed to refresh extension content: {{message}}. Run /reload-plugins to apply updates.',
'Failed to refresh extension content. Run /reload-plugins to apply updates.':
'Failed to refresh extension content. Run /reload-plugins to apply updates.',
'Extension reload did not complete. Run /reload-plugins to try again.':
'Extension reload did not complete. Run /reload-plugins to try again.',
};

View file

@ -2091,4 +2091,34 @@ export default {
in: 'ent.',
out: 'saída',
'In/Out': 'Ent/Saída',
// ============================================================================
// reload-plugins command
// ============================================================================
'{{count}} extension': '{{count}} extension',
'{{count}} extensions': '{{count}} extensions',
'{{count}} command': '{{count}} command',
'{{count}} commands': '{{count}} commands',
'{{count}} skill': '{{count}} skill',
'{{count}} skills': '{{count}} skills',
'{{count}} agent': '{{count}} agent',
'{{count}} agents': '{{count}} agents',
'{{count}} hook': '{{count}} hook',
'{{count}} hooks': '{{count}} hooks',
'{{count}} extension MCP server': '{{count}} extension MCP server',
'{{count}} extension MCP servers': '{{count}} extension MCP servers',
'{{count}} extension LSP server': '{{count}} extension LSP server',
'{{count}} extension LSP servers': '{{count}} extension LSP servers',
'Reload extension changes from disk': 'Reload extension changes from disk',
'Reloaded extensions: {{summary}}': 'Reloaded extensions: {{summary}}',
'Reload failed: {{message}}': 'Reload failed: {{message}}',
'Reload failed.': 'Reload failed.',
'Extensions changed on disk. Run /reload-plugins to apply updates.':
'Extensions changed on disk. Run /reload-plugins to apply updates.',
'Failed to refresh extension content: {{message}}. Run /reload-plugins to apply updates.':
'Failed to refresh extension content: {{message}}. Run /reload-plugins to apply updates.',
'Failed to refresh extension content. Run /reload-plugins to apply updates.':
'Failed to refresh extension content. Run /reload-plugins to apply updates.',
'Extension reload did not complete. Run /reload-plugins to try again.':
'Extension reload did not complete. Run /reload-plugins to try again.',
};

View file

@ -2079,4 +2079,34 @@ export default {
in: 'вх.',
out: 'вых.',
'In/Out': 'Вх/Вых',
// ============================================================================
// reload-plugins command
// ============================================================================
'{{count}} extension': '{{count}} extension',
'{{count}} extensions': '{{count}} extensions',
'{{count}} command': '{{count}} command',
'{{count}} commands': '{{count}} commands',
'{{count}} skill': '{{count}} skill',
'{{count}} skills': '{{count}} skills',
'{{count}} agent': '{{count}} agent',
'{{count}} agents': '{{count}} agents',
'{{count}} hook': '{{count}} hook',
'{{count}} hooks': '{{count}} hooks',
'{{count}} extension MCP server': '{{count}} extension MCP server',
'{{count}} extension MCP servers': '{{count}} extension MCP servers',
'{{count}} extension LSP server': '{{count}} extension LSP server',
'{{count}} extension LSP servers': '{{count}} extension LSP servers',
'Reload extension changes from disk': 'Reload extension changes from disk',
'Reloaded extensions: {{summary}}': 'Reloaded extensions: {{summary}}',
'Reload failed: {{message}}': 'Reload failed: {{message}}',
'Reload failed.': 'Reload failed.',
'Extensions changed on disk. Run /reload-plugins to apply updates.':
'Extensions changed on disk. Run /reload-plugins to apply updates.',
'Failed to refresh extension content: {{message}}. Run /reload-plugins to apply updates.':
'Failed to refresh extension content: {{message}}. Run /reload-plugins to apply updates.',
'Failed to refresh extension content. Run /reload-plugins to apply updates.':
'Failed to refresh extension content. Run /reload-plugins to apply updates.',
'Extension reload did not complete. Run /reload-plugins to try again.':
'Extension reload did not complete. Run /reload-plugins to try again.',
};

View file

@ -2145,4 +2145,34 @@ export default {
' (not in model registry)': '(不在模型註冊表中)',
'start server': '啟動伺服器',
'No compression needed.': '無需壓縮。',
// ============================================================================
// reload-plugins 命令
// ============================================================================
'{{count}} extension': '{{count}} 個擴充',
'{{count}} extensions': '{{count}} 個擴充',
'{{count}} command': '{{count}} 個指令',
'{{count}} commands': '{{count}} 個指令',
'{{count}} skill': '{{count}} 個技能',
'{{count}} skills': '{{count}} 個技能',
'{{count}} agent': '{{count}} 個代理',
'{{count}} agents': '{{count}} 個代理',
'{{count}} hook': '{{count}} 個鉤子',
'{{count}} hooks': '{{count}} 個鉤子',
'{{count}} extension MCP server': '{{count}} 個擴充 MCP 伺服器',
'{{count}} extension MCP servers': '{{count}} 個擴充 MCP 伺服器',
'{{count}} extension LSP server': '{{count}} 個擴充 LSP 伺服器',
'{{count}} extension LSP servers': '{{count}} 個擴充 LSP 伺服器',
'Reload extension changes from disk': '從磁碟重新載入擴充變更',
'Reloaded extensions: {{summary}}': '已重新載入擴充:{{summary}}',
'Reload failed: {{message}}': '重新載入失敗:{{message}}',
'Reload failed.': '重新載入失敗。',
'Extensions changed on disk. Run /reload-plugins to apply updates.':
'磁碟上的擴充已變更。執行 /reload-plugins 來套用更新。',
'Failed to refresh extension content: {{message}}. Run /reload-plugins to apply updates.':
'擴充內容重新整理失敗:{{message}}。執行 /reload-plugins 來套用更新。',
'Failed to refresh extension content. Run /reload-plugins to apply updates.':
'擴充內容重新整理失敗。執行 /reload-plugins 來套用更新。',
'Extension reload did not complete. Run /reload-plugins to try again.':
'擴充重新載入未完成。執行 /reload-plugins 重試。',
};

View file

@ -2347,4 +2347,34 @@ export default {
'中国 (China) - 阿里云百炼': '中国 - 阿里云百炼',
'阿里云百炼 (aliyun.com)': '阿里云百炼aliyun.com',
'No compression needed.': '无需压缩。',
// ============================================================================
// reload-plugins 命令
// ============================================================================
'{{count}} extension': '{{count}} 个扩展',
'{{count}} extensions': '{{count}} 个扩展',
'{{count}} command': '{{count}} 个命令',
'{{count}} commands': '{{count}} 个命令',
'{{count}} skill': '{{count}} 个技能',
'{{count}} skills': '{{count}} 个技能',
'{{count}} agent': '{{count}} 个代理',
'{{count}} agents': '{{count}} 个代理',
'{{count}} hook': '{{count}} 个钩子',
'{{count}} hooks': '{{count}} 个钩子',
'{{count}} extension MCP server': '{{count}} 个扩展 MCP 服务器',
'{{count}} extension MCP servers': '{{count}} 个扩展 MCP 服务器',
'{{count}} extension LSP server': '{{count}} 个扩展 LSP 服务器',
'{{count}} extension LSP servers': '{{count}} 个扩展 LSP 服务器',
'Reload extension changes from disk': '从磁盘重新加载扩展变更',
'Reloaded extensions: {{summary}}': '已重新加载扩展:{{summary}}',
'Reload failed: {{message}}': '重新加载失败:{{message}}',
'Reload failed.': '重新加载失败。',
'Extensions changed on disk. Run /reload-plugins to apply updates.':
'磁盘上的扩展已变更。运行 /reload-plugins 来应用更新。',
'Failed to refresh extension content: {{message}}. Run /reload-plugins to apply updates.':
'扩展内容刷新失败:{{message}}。运行 /reload-plugins 来应用更新。',
'Failed to refresh extension content. Run /reload-plugins to apply updates.':
'扩展内容刷新失败。运行 /reload-plugins 来应用更新。',
'Extension reload did not complete. Run /reload-plugins to try again.':
'扩展重新加载未完成。运行 /reload-plugins 重试。',
};

View file

@ -292,4 +292,14 @@ describe('BuiltinCommandLoader', () => {
const hooksCmd2 = commands2.find((c) => c.name === 'hooks');
expect(hooksCmd2).toBeDefined();
});
it('should register the /reload-plugins command', async () => {
const loader = new BuiltinCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
const command = commands.find((c) => c.name === 'reload-plugins');
expect(command).toBeDefined();
expect(command?.kind).toBe(CommandKind.BUILT_IN);
});
});

View file

@ -54,6 +54,7 @@ import { permissionsCommand } from '../ui/commands/permissionsCommand.js';
import { trustCommand } from '../ui/commands/trustCommand.js';
import { quitCommand } from '../ui/commands/quitCommand.js';
import { recapCommand } from '../ui/commands/recapCommand.js';
import { reloadPluginsCommand } from '../ui/commands/reload-plugins-command.js';
import { renameCommand } from '../ui/commands/renameCommand.js';
import { restoreCommand } from '../ui/commands/restoreCommand.js';
import { resumeCommand } from '../ui/commands/resumeCommand.js';
@ -155,6 +156,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
...(this.config?.getFolderTrust() ? [trustCommand] : []),
quitCommand,
recapCommand,
reloadPluginsCommand,
renameCommand,
restoreCommand(this.config),
resumeCommand,

View file

@ -166,6 +166,7 @@ import {
} from './utils/workflow-keyword.js';
import { type LoadedSettings, SettingScope } from '../config/settings.js';
import { type InitializationResult } from '../core/initializer.js';
import { ExtensionRefreshState } from '../config/extension-refresh-state.js';
import { useFocus } from './hooks/useFocus.js';
import { useAwaySummary } from './hooks/useAwaySummary.js';
import { useBracketedPaste } from './hooks/useBracketedPaste.js';
@ -424,6 +425,7 @@ interface AppContainerProps {
startupWarnings?: string[];
version: string;
initializationResult: InitializationResult;
extensionRefreshState?: ExtensionRefreshState;
}
/**
@ -440,6 +442,10 @@ const SHELL_HEIGHT_PADDING = 10;
export const AppContainer = (props: AppContainerProps) => {
const { settings, config, initializationResult } = props;
const extensionRefreshState = useMemo(
() => props.extensionRefreshState ?? new ExtensionRefreshState(),
[props.extensionRefreshState],
);
const historyManager = useHistory();
// `useHistory()` returns a fresh memoized object whenever `history` changes,
// so depending on `historyManager` directly inside event-handler callbacks
@ -1505,6 +1511,7 @@ export const AppContainer = (props: AppContainerProps) => {
logger,
historyManager.updateItem,
setSessionName,
extensionRefreshState,
);
// onDebugMessage should log to debug logfile, not update footer debugMessage

View file

@ -0,0 +1,106 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Config } from '@qwen-code/qwen-code-core';
import { reloadPluginsCommand } from './reload-plugins-command.js';
import type { CommandContext } from './types.js';
import { reloadPluginsRuntime } from '../../config/extension-runtime-reload.js';
import type { ExtensionRefreshState } from '../../config/extension-refresh-state.js';
vi.mock('../../config/extension-runtime-reload.js', () => ({
reloadPluginsRuntime: vi.fn(async () => ({
extensionCount: 1,
commandCount: 2,
skillCount: 3,
agentCount: 4,
hookCount: 5,
mcpServerCount: 6,
lspServerCount: 7,
})),
}));
describe('reloadPluginsCommand', () => {
let extensionRefreshState: ExtensionRefreshState;
beforeEach(() => {
vi.clearAllMocks();
extensionRefreshState = {
clearExtensionsChanged: vi.fn(),
markExtensionsReloadFailed: vi.fn(),
notifyExtensionsReloadStarted: vi.fn(),
} as unknown as ExtensionRefreshState;
});
it('returns an error when config is missing', async () => {
const context = {
services: { config: null, extensionRefreshState },
ui: { reloadCommands: vi.fn() },
} as unknown as CommandContext;
const result = await reloadPluginsCommand.action?.(context, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Config not loaded.',
});
expect(reloadPluginsRuntime).not.toHaveBeenCalled();
expect(extensionRefreshState.clearExtensionsChanged).not.toHaveBeenCalled();
expect(
extensionRefreshState.notifyExtensionsReloadStarted,
).not.toHaveBeenCalled();
});
it('reloads extension runtime and clears stale state', async () => {
const config = {} as Config;
const reloadCommands = vi.fn();
const context = {
services: { config, extensionRefreshState },
ui: { reloadCommands },
} as unknown as CommandContext;
const result = await reloadPluginsCommand.action?.(context, '');
expect(
extensionRefreshState.notifyExtensionsReloadStarted,
).toHaveBeenCalledOnce();
expect(reloadPluginsRuntime).toHaveBeenCalledWith({
config,
reloadCommands,
});
expect(extensionRefreshState.clearExtensionsChanged).toHaveBeenCalledOnce();
expect(result).toEqual({
type: 'message',
messageType: 'info',
content:
'Reloaded extensions: 1 extension · 2 commands · 3 skills · 4 agents · 5 hooks · 6 extension MCP servers · 7 extension LSP servers',
});
});
it('preserves stale state when reload fails', async () => {
vi.mocked(reloadPluginsRuntime).mockRejectedValueOnce(new Error('boom'));
const context = {
services: { config: {} as Config, extensionRefreshState },
ui: { reloadCommands: vi.fn() },
} as unknown as CommandContext;
const result = await reloadPluginsCommand.action?.(context, '');
expect(
extensionRefreshState.notifyExtensionsReloadStarted,
).toHaveBeenCalledOnce();
expect(extensionRefreshState.clearExtensionsChanged).not.toHaveBeenCalled();
expect(
extensionRefreshState.markExtensionsReloadFailed,
).toHaveBeenCalledOnce();
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Reload failed: boom',
});
});
});

View file

@ -0,0 +1,104 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import {
reloadPluginsRuntime,
type ReloadPluginsSummary,
} from '../../config/extension-runtime-reload.js';
import { ExtensionRefreshState } from '../../config/extension-refresh-state.js';
import { t } from '../../i18n/index.js';
import {
CommandKind,
type CommandContext,
type MessageActionReturn,
type SlashCommand,
} from './types.js';
function summaryCountTerm(
count: number,
singularKey: string,
pluralKey: string,
): string {
return t(count === 1 ? singularKey : pluralKey, {
count: String(count),
});
}
export function formatReloadPluginsSummary(summary: ReloadPluginsSummary) {
return [
summaryCountTerm(
summary.extensionCount,
'{{count}} extension',
'{{count}} extensions',
),
summaryCountTerm(
summary.commandCount,
'{{count}} command',
'{{count}} commands',
),
summaryCountTerm(summary.skillCount, '{{count}} skill', '{{count}} skills'),
summaryCountTerm(summary.agentCount, '{{count}} agent', '{{count}} agents'),
summaryCountTerm(summary.hookCount, '{{count}} hook', '{{count}} hooks'),
summaryCountTerm(
summary.mcpServerCount,
'{{count}} extension MCP server',
'{{count}} extension MCP servers',
),
summaryCountTerm(
summary.lspServerCount,
'{{count}} extension LSP server',
'{{count}} extension LSP servers',
),
].join(' · ');
}
export const reloadPluginsCommand: SlashCommand = {
name: 'reload-plugins',
get description() {
return t('Reload extension changes from disk');
},
kind: CommandKind.BUILT_IN,
supportedModes: ['interactive'] as const,
action: async (context: CommandContext): Promise<MessageActionReturn> => {
const config = context.services.config;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: t('Config not loaded.'),
};
}
const extensionRefreshState =
context.services.extensionRefreshState ?? new ExtensionRefreshState();
try {
extensionRefreshState.notifyExtensionsReloadStarted();
const summary = await reloadPluginsRuntime({
config,
reloadCommands: context.ui.reloadCommands,
});
extensionRefreshState.clearExtensionsChanged();
return {
type: 'message',
messageType: 'info',
content: t('Reloaded extensions: {{summary}}', {
summary: formatReloadPluginsSummary(summary),
}),
};
} catch (error) {
extensionRefreshState.markExtensionsReloadFailed();
return {
type: 'message',
messageType: 'error',
content:
error instanceof Error
? t('Reload failed: {{message}}', { message: error.message })
: t('Reload failed.'),
};
}
},
};

View file

@ -24,6 +24,7 @@ import type {
ExtensionUpdateAction,
ExtensionUpdateStatus,
} from '../state/extensions.js';
import type { ExtensionRefreshState } from '../../config/extension-refresh-state.js';
// Grouped dependencies for clarity and easier mocking
export interface CommandContext {
@ -50,6 +51,7 @@ export interface CommandContext {
config: Config | null;
settings: LoadedSettings;
logger: Logger | null;
extensionRefreshState?: ExtensionRefreshState;
};
// UI state and history management
ui: {

View file

@ -23,6 +23,8 @@ import { MessageType } from '../types.js';
import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js';
import { FileCommandLoader } from '../../services/FileCommandLoader.js';
import { McpPromptLoader } from '../../services/McpPromptLoader.js';
import { ExtensionRefreshState } from '../../config/extension-refresh-state.js';
import { refreshExtensionContentRuntime } from '../../config/extension-runtime-reload.js';
import {
type GeminiClient,
SlashCommandStatus,
@ -111,6 +113,10 @@ vi.mock('./useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
vi.mock('../../config/extension-runtime-reload.js', () => ({
refreshExtensionContentRuntime: vi.fn(async () => undefined),
}));
function createTestCommand(
overrides: Partial<SlashCommand>,
kind: CommandKind = CommandKind.BUILT_IN,
@ -182,6 +188,7 @@ describe('useSlashCommandProcessor', () => {
mockOpenModelDialog.mockClear();
mockOpenMemoryDialog.mockClear();
mockFireUserPromptExpansionEvent.mockResolvedValue(undefined);
vi.mocked(refreshExtensionContentRuntime).mockResolvedValue(undefined);
mockConfig.getDisableAllHooks = vi.fn().mockReturnValue(false);
mockConfig.hasHooksForEvent = vi.fn().mockReturnValue(true);
mockConfig.getHookSystem = vi.fn().mockReturnValue({
@ -197,6 +204,7 @@ describe('useSlashCommandProcessor', () => {
mcpCommands: SlashCommand[] = [],
setIsProcessing = vi.fn(),
settings: LoadedSettings = mockSettings,
extensionRefreshState?: ExtensionRefreshState,
) => {
mockBuiltinLoadCommands.mockResolvedValue(Object.freeze(builtinCommands));
mockFileLoadCommands.mockResolvedValue(Object.freeze(fileCommands));
@ -221,6 +229,8 @@ describe('useSlashCommandProcessor', () => {
true, // isConfigInitialized
null, // logger
mockUpdateItem,
undefined, // setSessionName
extensionRefreshState,
),
);
@ -1699,6 +1709,147 @@ describe('useSlashCommandProcessor', () => {
expect(abortSpy).toHaveBeenCalledTimes(1);
});
it('auto-refreshes extension content changes after debounce', async () => {
const extensionRefreshState = new ExtensionRefreshState();
const result = setupProcessorHook(
[],
[],
[],
vi.fn(),
mockSettings,
extensionRefreshState,
);
await waitFor(() => {
expect(result.current.slashCommands).toBeDefined();
});
act(() => {
extensionRefreshState.markExtensionContentChanged('content changed');
});
await waitFor(() => {
expect(refreshExtensionContentRuntime).toHaveBeenCalledOnce();
});
expect(refreshExtensionContentRuntime).toHaveBeenCalledWith({
config: mockConfig,
reloadCommands: expect.any(Function),
});
});
it('shows an error when extension content auto-refresh fails', async () => {
vi.mocked(refreshExtensionContentRuntime).mockRejectedValueOnce(
new Error('refresh failed'),
);
const extensionRefreshState = new ExtensionRefreshState();
const result = setupProcessorHook(
[],
[],
[],
vi.fn(),
mockSettings,
extensionRefreshState,
);
await waitFor(() => {
expect(result.current.slashCommands).toBeDefined();
});
act(() => {
extensionRefreshState.markExtensionContentChanged('content changed');
});
await waitFor(() => {
expect(mockAddItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Failed to refresh extension content: refresh failed. Run /reload-plugins to apply updates.',
},
expect.any(Number),
);
});
});
it('skips content auto-refresh while package reload is needed', async () => {
const extensionRefreshState = new ExtensionRefreshState();
const result = setupProcessorHook(
[],
[],
[],
vi.fn(),
mockSettings,
extensionRefreshState,
);
await waitFor(() => {
expect(result.current.slashCommands).toBeDefined();
});
act(() => {
extensionRefreshState.markExtensionsChanged('manifest changed');
extensionRefreshState.markExtensionContentChanged('content changed');
});
await new Promise((resolve) => setTimeout(resolve, 350));
expect(refreshExtensionContentRuntime).not.toHaveBeenCalled();
expect(mockAddItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Extensions changed on disk. Run /reload-plugins to apply updates.',
},
expect.any(Number),
);
});
it('shows a retry message when extension reload fails', async () => {
const extensionRefreshState = new ExtensionRefreshState();
const result = setupProcessorHook(
[],
[],
[],
vi.fn(),
mockSettings,
extensionRefreshState,
);
await waitFor(() => {
expect(result.current.slashCommands).toBeDefined();
});
act(() => {
extensionRefreshState.markExtensionsReloadFailed();
});
expect(mockAddItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Extension reload did not complete. Run /reload-plugins to try again.',
},
expect.any(Number),
);
});
it('cancels pending content auto-refresh when manual reload starts', async () => {
const extensionRefreshState = new ExtensionRefreshState();
const result = setupProcessorHook(
[],
[],
[],
vi.fn(),
mockSettings,
extensionRefreshState,
);
await waitFor(() => {
expect(result.current.slashCommands).toBeDefined();
});
act(() => {
extensionRefreshState.markExtensionContentChanged('content changed');
extensionRefreshState.notifyExtensionsReloadStarted();
});
await new Promise((resolve) => setTimeout(resolve, 350));
expect(refreshExtensionContentRuntime).not.toHaveBeenCalled();
});
it('should reload commands when SkillManager fires a change event', async () => {
const removeListener = vi.fn();
const addChangeListener = vi.fn().mockReturnValue(removeListener);

View file

@ -60,6 +60,13 @@ import {
parseStackedSlashCommands,
MAX_STACKED_SKILLS,
} from '../../utils/commands.js';
import { AppEvent } from '../../utils/events.js';
import { t } from '../../i18n/index.js';
import { refreshExtensionContentRuntime } from '../../config/extension-runtime-reload.js';
import {
EXTENSION_RELOAD_FAILED_REASON,
ExtensionRefreshState,
} from '../../config/extension-refresh-state.js';
import {
hasSlashCommandPathSeparator,
isBtwCommand,
@ -109,6 +116,7 @@ const SLASH_COMMANDS_SKIP_RECORDING = new Set([
'btw',
'history',
]);
const MAX_EXTENSION_CONTENT_REFRESH_PASSES = 5;
function getSkillCommandName(command: SlashCommand): string {
return command.skillDetail?.name ?? command.name;
@ -174,7 +182,17 @@ export const useSlashCommandProcessor = (
logger: Logger | null,
updateItem: UseHistoryManagerReturn['updateItem'],
setSessionName?: (name: string | null) => void,
extensionRefreshState?: ExtensionRefreshState,
) => {
const fallbackExtensionRefreshStateRef = useRef<ExtensionRefreshState | null>(
null,
);
if (!fallbackExtensionRefreshStateRef.current) {
fallbackExtensionRefreshStateRef.current = new ExtensionRefreshState();
}
const activeExtensionRefreshState =
extensionRefreshState ?? fallbackExtensionRefreshStateRef.current;
// Ref avoids adding `history` to the commandContext useMemo deps,
// which would cause a full context rebuild on every history append.
const historyRef = useRef(history);
@ -191,6 +209,19 @@ export const useSlashCommandProcessor = (
const commandReloadResolversRef = useRef<
Array<{ trigger: number; resolve: () => void }>
>([]);
const extensionContentRefreshTimerRef = useRef<ReturnType<
typeof setTimeout
> | null>(null);
const extensionContentRefreshRunningRef = useRef(false);
const extensionContentRefreshPendingRef = useRef(false);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
const resolveCommandReloads = useCallback((completedTrigger: number) => {
if (commandReloadResolversRef.current.length === 0) {
@ -224,6 +255,83 @@ export const useSlashCommandProcessor = (
});
});
}, [config]);
const clearPendingExtensionContentRefresh = useCallback(() => {
if (!extensionContentRefreshTimerRef.current) {
return;
}
clearTimeout(extensionContentRefreshTimerRef.current);
extensionContentRefreshTimerRef.current = null;
}, []);
const showExtensionContentRefreshError = useCallback(
(error: unknown) => {
if (!mountedRef.current) {
return;
}
addItem(
{
type: MessageType.ERROR,
text:
error instanceof Error
? t(
'Failed to refresh extension content: {{message}}. Run /reload-plugins to apply updates.',
{ message: error.message },
)
: t(
'Failed to refresh extension content. Run /reload-plugins to apply updates.',
),
},
Date.now(),
);
},
[addItem],
);
const runExtensionContentRefresh = useCallback(async () => {
if (!config) {
return;
}
if (extensionContentRefreshRunningRef.current) {
extensionContentRefreshPendingRef.current = true;
return;
}
extensionContentRefreshRunningRef.current = true;
let refreshPasses = 0;
try {
do {
if (refreshPasses >= MAX_EXTENSION_CONTENT_REFRESH_PASSES) {
extensionContentRefreshPendingRef.current = false;
showExtensionContentRefreshError(
new Error('too many extension content changes are still pending'),
);
return;
}
refreshPasses++;
extensionContentRefreshPendingRef.current = false;
if (activeExtensionRefreshState.isReloadInProgress()) {
return;
}
if (activeExtensionRefreshState.needsExtensionRefresh()) {
return;
}
await refreshExtensionContentRuntime({
config,
reloadCommands,
});
} while (extensionContentRefreshPendingRef.current);
} catch (error: unknown) {
extensionContentRefreshPendingRef.current = false;
showExtensionContentRefreshError(error);
} finally {
extensionContentRefreshRunningRef.current = false;
}
}, [
activeExtensionRefreshState,
config,
reloadCommands,
showExtensionContentRefreshError,
]);
const [shellConfirmationRequest, setShellConfirmationRequest] =
useState<null | {
commands: string[];
@ -357,6 +465,7 @@ export const useSlashCommandProcessor = (
config,
settings,
logger,
extensionRefreshState: activeExtensionRefreshState,
},
ui: {
get history() {
@ -419,6 +528,7 @@ export const useSlashCommandProcessor = (
setSessionName,
extensionsUpdateState,
isIdleRef,
activeExtensionRefreshState,
],
);
@ -509,6 +619,85 @@ export const useSlashCommandProcessor = (
});
}, [config, isConfigInitialized, reloadCommands]);
useEffect(() => {
const listener = (reason?: unknown) => {
clearPendingExtensionContentRefresh();
addItem(
{
type: MessageType.INFO,
text:
reason === EXTENSION_RELOAD_FAILED_REASON
? t(
'Extension reload did not complete. Run /reload-plugins to try again.',
)
: t(
'Extensions changed on disk. Run /reload-plugins to apply updates.',
),
},
Date.now(),
);
};
activeExtensionRefreshState.on(AppEvent.ExtensionRefreshNeeded, listener);
return () => {
activeExtensionRefreshState.off(
AppEvent.ExtensionRefreshNeeded,
listener,
);
};
}, [
activeExtensionRefreshState,
addItem,
clearPendingExtensionContentRefresh,
]);
useEffect(() => {
activeExtensionRefreshState.on(
AppEvent.ExtensionsReloadStarted,
clearPendingExtensionContentRefresh,
);
activeExtensionRefreshState.on(
AppEvent.ExtensionsReloaded,
clearPendingExtensionContentRefresh,
);
return () => {
activeExtensionRefreshState.off(
AppEvent.ExtensionsReloadStarted,
clearPendingExtensionContentRefresh,
);
activeExtensionRefreshState.off(
AppEvent.ExtensionsReloaded,
clearPendingExtensionContentRefresh,
);
};
}, [activeExtensionRefreshState, clearPendingExtensionContentRefresh]);
useEffect(() => {
if (!config) {
return;
}
const listener = () => {
clearPendingExtensionContentRefresh();
extensionContentRefreshTimerRef.current = setTimeout(() => {
extensionContentRefreshTimerRef.current = null;
void runExtensionContentRefresh();
}, 250);
};
activeExtensionRefreshState.on(AppEvent.ExtensionContentChanged, listener);
return () => {
activeExtensionRefreshState.off(
AppEvent.ExtensionContentChanged,
listener,
);
clearPendingExtensionContentRefresh();
extensionContentRefreshPendingRef.current = false;
};
}, [
clearPendingExtensionContentRefresh,
config,
activeExtensionRefreshState,
runExtensionContentRefresh,
]);
useEffect(() => {
const controller = new AbortController();
const load = async () => {

View file

@ -14,6 +14,7 @@ import {
} from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from '../config/settings.js';
import type { InitializationResult } from '../core/initializer.js';
import type { ExtensionRefreshState } from '../config/extension-refresh-state.js';
import { DualOutputBridge } from '../dualOutput/DualOutputBridge.js';
import { DualOutputContext } from '../dualOutput/DualOutputContext.js';
import { RemoteInputWatcher } from '../remoteInput/RemoteInputWatcher.js';
@ -45,6 +46,7 @@ const debugLogger = createDebugLogger('STARTUP');
export interface StartInteractiveUIOptions {
postRenderConnectIde?: boolean;
postRenderInitializeTelemetry?: boolean;
extensionRefreshState?: ExtensionRefreshState;
}
export async function startInteractiveUI(
@ -164,6 +166,7 @@ export async function startInteractiveUI(
startupWarnings={startupWarnings}
version={version}
initializationResult={initializationResult}
extensionRefreshState={options.extensionRefreshState}
/>
</BackgroundTaskViewProvider>
</AgentViewProvider>

View file

@ -25,6 +25,10 @@ export enum AppEvent {
*/
McpPendingApprovalChanged = 'mcp-pending-approval-changed',
LspStatusChanged = 'lsp-status-changed',
ExtensionContentChanged = 'extension-content-changed',
ExtensionRefreshNeeded = 'extension-refresh-needed',
ExtensionsReloadStarted = 'extensions-reload-started',
ExtensionsReloaded = 'extensions-reloaded',
StartupIdeConnectionStatusChanged = 'startup-ide-connection-status-changed',
}

View file

@ -16,56 +16,239 @@ describe('refreshExtensionRuntime', () => {
});
it('refreshes existing extension runtime components', async () => {
const restartMcpServers = vi.fn();
const refreshSkills = vi.fn();
const refreshSubagents = vi.fn();
const refreshHierarchicalMemory = vi.fn();
const order: string[] = [];
const reinitializeMcpServers = vi.fn(async () => {
order.push('mcp');
});
const reinitializeLsp = vi.fn(async () => {
order.push('lsp');
return undefined;
});
const refreshSkills = vi.fn(async () => {
order.push('skills');
});
const refreshSubagents = vi.fn(async () => {
order.push('subagents');
});
const reloadHooks = vi.fn(async () => {
order.push('hooks');
});
const refreshHierarchicalMemory = vi.fn(async () => {
order.push('memory');
});
const settingsMcpServers = { server: { command: 'cmd' } };
const config = {
getToolRegistry: () => ({ restartMcpServers }),
getSettingsMcpServers: () => settingsMcpServers,
reinitializeMcpServers,
reinitializeLsp,
getSkillManager: () => ({ refreshCache: refreshSkills }),
getSubagentManager: () => ({ refreshCache: refreshSubagents }),
getHookSystem: () => ({ reload: reloadHooks }),
refreshHierarchicalMemory,
} as unknown as ExtensionRuntimeRefreshConfig;
await refreshExtensionRuntime(config);
expect(restartMcpServers).toHaveBeenCalledOnce();
expect(reinitializeMcpServers).toHaveBeenCalledOnce();
expect(reinitializeMcpServers).toHaveBeenCalledWith(settingsMcpServers);
expect(reinitializeLsp).toHaveBeenCalledOnce();
expect(refreshSkills).toHaveBeenCalledOnce();
expect(refreshSubagents).toHaveBeenCalledOnce();
expect(reloadHooks).toHaveBeenCalledOnce();
expect(refreshHierarchicalMemory).toHaveBeenCalledOnce();
// MCP and LSP must settle first, then skills/subagents/hooks in parallel, memory last.
expect(order[0]).toBe('mcp');
expect(order[1]).toBe('lsp');
expect(order.slice(2, 5)).toEqual(
expect.arrayContaining(['skills', 'subagents', 'hooks']),
);
expect(order[5]).toBe('memory');
});
it('propagates MCP reconcile failures', async () => {
const reinitializeMcpServers = vi
.fn()
.mockRejectedValue(new Error('mcp failed'));
const refreshSkills = vi.fn();
const refreshSubagents = vi.fn();
const reloadHooks = vi.fn();
const refreshHierarchicalMemory = vi.fn();
const config = {
getSettingsMcpServers: () => undefined,
reinitializeMcpServers,
getSkillManager: () => ({ refreshCache: refreshSkills }),
getSubagentManager: () => ({ refreshCache: refreshSubagents }),
getHookSystem: () => ({ reload: reloadHooks }),
refreshHierarchicalMemory,
} as unknown as ExtensionRuntimeRefreshConfig;
await expect(refreshExtensionRuntime(config)).rejects.toThrow('mcp failed');
expect(reinitializeMcpServers).toHaveBeenCalledOnce();
expect(refreshSkills).not.toHaveBeenCalled();
expect(refreshSubagents).not.toHaveBeenCalled();
expect(reloadHooks).not.toHaveBeenCalled();
expect(refreshHierarchicalMemory).not.toHaveBeenCalled();
});
it('propagates LSP partial failures', async () => {
const reinitializeMcpServers = vi.fn();
const refreshSkills = vi.fn();
const refreshSubagents = vi.fn();
const reloadHooks = vi.fn();
const refreshHierarchicalMemory = vi.fn();
const config = {
getSettingsMcpServers: () => undefined,
reinitializeMcpServers,
reinitializeLsp: vi.fn().mockResolvedValue({
reconcile: {
added: [],
removed: [],
restarted: [],
unchanged: [],
failed: ['clangd'],
},
skipped: [],
}),
getSkillManager: () => ({ refreshCache: refreshSkills }),
getSubagentManager: () => ({ refreshCache: refreshSubagents }),
getHookSystem: () => ({ reload: reloadHooks }),
refreshHierarchicalMemory,
} as unknown as ExtensionRuntimeRefreshConfig;
await expect(refreshExtensionRuntime(config)).rejects.toThrow(
'LSP reload partially failed: clangd',
);
expect(reinitializeMcpServers).toHaveBeenCalledOnce();
expect(refreshSkills).toHaveBeenCalledOnce();
expect(refreshSubagents).toHaveBeenCalledOnce();
expect(reloadHooks).toHaveBeenCalledOnce();
expect(refreshHierarchicalMemory).toHaveBeenCalledOnce();
});
it('continues refresh legs when LSP reload throws', async () => {
const reinitializeMcpServers = vi.fn();
const refreshSkills = vi.fn();
const refreshSubagents = vi.fn();
const reloadHooks = vi.fn();
const refreshHierarchicalMemory = vi.fn();
const config = {
getSettingsMcpServers: () => undefined,
reinitializeMcpServers,
reinitializeLsp: vi.fn().mockRejectedValue(new Error('lsp failed')),
getSkillManager: () => ({ refreshCache: refreshSkills }),
getSubagentManager: () => ({ refreshCache: refreshSubagents }),
getHookSystem: () => ({ reload: reloadHooks }),
refreshHierarchicalMemory,
} as unknown as ExtensionRuntimeRefreshConfig;
await expect(refreshExtensionRuntime(config)).rejects.toThrow('lsp failed');
expect(reinitializeMcpServers).toHaveBeenCalledOnce();
expect(refreshSkills).toHaveBeenCalledOnce();
expect(refreshSubagents).toHaveBeenCalledOnce();
expect(reloadHooks).toHaveBeenCalledOnce();
expect(refreshHierarchicalMemory).toHaveBeenCalledOnce();
});
it('continues when a refreshCache leg rejects', async () => {
const restartMcpServers = vi.fn();
const reinitializeMcpServers = vi.fn();
const refreshSkills = vi.fn().mockRejectedValue(new Error('skills failed'));
const refreshSubagents = vi.fn();
const reloadHooks = vi.fn();
const refreshHierarchicalMemory = vi.fn();
const config = {
getToolRegistry: () => ({ restartMcpServers }),
getSettingsMcpServers: () => undefined,
reinitializeMcpServers,
getSkillManager: () => ({ refreshCache: refreshSkills }),
getSubagentManager: () => ({ refreshCache: refreshSubagents }),
getHookSystem: () => ({ reload: reloadHooks }),
refreshHierarchicalMemory,
} as unknown as ExtensionRuntimeRefreshConfig;
await expect(refreshExtensionRuntime(config)).resolves.toBeUndefined();
expect(restartMcpServers).toHaveBeenCalledOnce();
expect(reinitializeMcpServers).toHaveBeenCalledOnce();
expect(refreshSkills).toHaveBeenCalledOnce();
expect(refreshSubagents).toHaveBeenCalledOnce();
expect(reloadHooks).toHaveBeenCalledOnce();
expect(refreshHierarchicalMemory).toHaveBeenCalledOnce();
});
it('propagates hook reload failures after other refresh legs settle', async () => {
const reinitializeMcpServers = vi.fn();
const refreshSkills = vi.fn();
const refreshSubagents = vi.fn();
const reloadHooks = vi.fn().mockRejectedValue(new Error('hooks failed'));
const refreshHierarchicalMemory = vi.fn();
const config = {
getSettingsMcpServers: () => undefined,
reinitializeMcpServers,
getSkillManager: () => ({ refreshCache: refreshSkills }),
getSubagentManager: () => ({ refreshCache: refreshSubagents }),
getHookSystem: () => ({ reload: reloadHooks }),
refreshHierarchicalMemory,
} as unknown as ExtensionRuntimeRefreshConfig;
await expect(refreshExtensionRuntime(config)).rejects.toThrow(
'hooks failed',
);
expect(reinitializeMcpServers).toHaveBeenCalledOnce();
expect(refreshSkills).toHaveBeenCalledOnce();
expect(refreshSubagents).toHaveBeenCalledOnce();
expect(reloadHooks).toHaveBeenCalledOnce();
expect(refreshHierarchicalMemory).toHaveBeenCalledOnce();
});
it('aggregates hook and LSP reload failures', async () => {
const reinitializeMcpServers = vi.fn();
const refreshSkills = vi.fn();
const refreshSubagents = vi.fn();
const reloadHooks = vi.fn().mockRejectedValue(new Error('hooks failed'));
const refreshHierarchicalMemory = vi.fn();
const config = {
getSettingsMcpServers: () => undefined,
reinitializeMcpServers,
reinitializeLsp: vi.fn().mockRejectedValue(new Error('lsp failed')),
getSkillManager: () => ({ refreshCache: refreshSkills }),
getSubagentManager: () => ({ refreshCache: refreshSubagents }),
getHookSystem: () => ({ reload: reloadHooks }),
refreshHierarchicalMemory,
} as unknown as ExtensionRuntimeRefreshConfig;
await expect(refreshExtensionRuntime(config)).rejects.toMatchObject({
message: 'Extension runtime refresh had multiple failures',
errors: [expect.any(Error), expect.any(Error)],
});
expect(reinitializeMcpServers).toHaveBeenCalledOnce();
expect(refreshSkills).toHaveBeenCalledOnce();
expect(refreshSubagents).toHaveBeenCalledOnce();
expect(reloadHooks).toHaveBeenCalledOnce();
expect(refreshHierarchicalMemory).toHaveBeenCalledOnce();
});
it('resolves when refreshHierarchicalMemory throws', async () => {
const restartMcpServers = vi.fn();
const reinitializeMcpServers = vi.fn();
const refreshSkills = vi.fn();
const refreshSubagents = vi.fn();
const reloadHooks = vi.fn();
const config = {
getToolRegistry: () => ({ restartMcpServers }),
getSettingsMcpServers: () => undefined,
reinitializeMcpServers,
getSkillManager: () => ({ refreshCache: refreshSkills }),
getSubagentManager: () => ({ refreshCache: refreshSubagents }),
getHookSystem: () => ({ reload: reloadHooks }),
refreshHierarchicalMemory: vi
.fn()
.mockRejectedValue(new Error('memory failed')),
@ -73,31 +256,9 @@ describe('refreshExtensionRuntime', () => {
await expect(refreshExtensionRuntime(config)).resolves.toBeUndefined();
expect(restartMcpServers).toHaveBeenCalledOnce();
expect(reinitializeMcpServers).toHaveBeenCalledOnce();
expect(refreshSkills).toHaveBeenCalledOnce();
expect(refreshSubagents).toHaveBeenCalledOnce();
});
it('rejects when restartMcpServers fails', async () => {
const restartMcpServers = vi
.fn()
.mockRejectedValue(new Error('mcp failed'));
const refreshSkills = vi.fn();
const refreshSubagents = vi.fn();
const refreshHierarchicalMemory = vi.fn();
const config = {
getToolRegistry: () => ({ restartMcpServers }),
getSkillManager: () => ({ refreshCache: refreshSkills }),
getSubagentManager: () => ({ refreshCache: refreshSubagents }),
refreshHierarchicalMemory,
} as unknown as ExtensionRuntimeRefreshConfig;
await expect(refreshExtensionRuntime(config)).rejects.toThrow('mcp failed');
expect(restartMcpServers).toHaveBeenCalledOnce();
expect(refreshSkills).not.toHaveBeenCalled();
expect(refreshSubagents).not.toHaveBeenCalled();
expect(refreshHierarchicalMemory).not.toHaveBeenCalled();
expect(reloadHooks).toHaveBeenCalledOnce();
});
});

View file

@ -11,63 +11,96 @@ const debugLogger = createDebugLogger('EXTENSION_RUNTIME_REFRESH');
export type ExtensionRuntimeRefreshConfig = Pick<
Config,
| 'getToolRegistry'
| 'getSettingsMcpServers'
| 'reinitializeMcpServers'
| 'getSkillManager'
| 'getSubagentManager'
| 'getHookSystem'
| 'refreshHierarchicalMemory'
>;
> & {
reinitializeLsp?: Config['reinitializeLsp'];
};
export async function refreshExtensionRuntime(
config: ExtensionRuntimeRefreshConfig | undefined,
): Promise<void> {
if (!config) return;
// Error-handling contract:
// Tier 1 (fatal) — restartMcpServers failure propagates. MCP is a
// prerequisite for tool discovery; callers must know
// if it failed so they can surface the error.
// Tier 2 (swallow) — refreshCache failures (skills, subagents) are
// logged via warn but swallowed by allSettled.
// Tier 3 (swallow) — refreshHierarchicalMemory failure is logged via
// error but swallowed by try/catch.
// When adding new refresh steps, decide explicitly which tier applies.
await config.getToolRegistry().restartMcpServers();
// Refresh skills + subagents in parallel. Both `refreshCache` calls now
// resolve only after their async change-listener chain settles — for skills,
// that includes `SkillTool.refreshSkills()` rebuilding the model-facing tool
// description and updating `geminiClient`'s tool list. Use allSettled (rather
// than Promise.all) so a rejection from one leg does not cascade — the other
// leg's result is still applied, refreshHierarchicalMemory below still runs,
// and callers (`enableExtension`, etc.) don't unwind because of an unrelated
// transient failure.
const skillManager = config.getSkillManager();
const settled = await Promise.allSettled([
skillManager?.refreshCache(),
config.getSubagentManager().refreshCache(),
]);
for (const result of settled) {
if (result.status === 'rejected') {
debugLogger.warn(
'refreshExtensionRuntime: a refreshCache leg failed:',
result.reason,
// MCP servers must settle first — skills and subagents may depend on the
// updated MCP tool list for their own refresh (e.g. SkillTool.refreshSkills()
// rebuilds the model-facing tool description and updates geminiClient's tool
// list). A failure here is user-visible because extension MCP tools will be
// unavailable, so let callers surface it.
await config.reinitializeMcpServers(config.getSettingsMcpServers());
let lspReloadError: unknown;
try {
const lspResult = await config.reinitializeLsp?.();
const failedLspServers = lspResult?.reconcile.failed ?? [];
if (failedLspServers.length > 0) {
lspReloadError = new Error(
`LSP reload partially failed: ${failedLspServers.join(', ')}`,
);
}
} catch (err) {
debugLogger.warn('refreshExtensionRuntime: reinitializeLsp failed:', err);
lspReloadError = err;
}
// Skills, subagents, and hooks refresh in parallel. Use allSettled (rather
// than Promise.all) so a rejection from one leg does not prevent the other
// legs from applying or skip refreshHierarchicalMemory below. Hook reload
// failures are surfaced after these best-effort legs settle.
const skillManager = config.getSkillManager();
const refreshLegs = [
{ name: 'skills', promise: skillManager?.refreshCache() },
{ name: 'subagents', promise: config.getSubagentManager().refreshCache() },
{ name: 'hooks', promise: config.getHookSystem()?.reload() },
] as const;
const settled = await Promise.allSettled(
refreshLegs.map((leg) => leg.promise),
);
let hookReloadError: unknown;
settled.forEach((result, index) => {
if (result.status === 'rejected') {
debugLogger.warn(
`refreshExtensionRuntime: ${refreshLegs[index].name} failed:`,
result.reason,
);
if (refreshLegs[index].name === 'hooks') {
hookReloadError = result.reason;
}
}
});
// Await hierarchical memory refresh so callers only continue after the
// extension refresh has settled. Wrap in try/catch so a transient failure
// doesn't propagate up to `enableExtension` / `installExtension` callers,
// which have already mutated their `isActive`/`installed` flags by the time
// this function is invoked — a failed memory refresh leaves stale memory
// but should not back out the surrounding extension transition.
// but should not back out the surrounding extension transition. At this
// point enable/disable and install/uninstall callers may already have
// updated isActive state, extension cache entries, or on-disk enablement
// metadata.
try {
await config.refreshHierarchicalMemory();
} catch (err) {
debugLogger.error(
debugLogger.warn(
'refreshExtensionRuntime: refreshHierarchicalMemory failed:',
err,
);
}
const surfacedErrors = [hookReloadError, lspReloadError].filter(
(error): error is unknown => error !== undefined,
);
if (surfacedErrors.length === 1) {
throw surfacedErrors[0];
}
if (surfacedErrors.length > 1) {
throw new AggregateError(
surfacedErrors,
'Extension runtime refresh had multiple failures',
);
}
}

View file

@ -16,12 +16,15 @@ import { ExtensionStorage } from './storage.js';
import { QWEN_DIR } from '../config/storage.js';
import {
ExtensionManager,
ExtensionUpdateState,
SettingScope,
type ExtensionManagerOptions,
type Extension,
validateName,
getExtensionId,
hashValue,
type ExtensionConfig,
type ExtensionMutationEvent,
} from './extensionManager.js';
import type { MCPServerConfig, ExtensionInstallMetadata } from '../index.js';
@ -198,6 +201,36 @@ describe('extension tests', () => {
});
});
it('should emit mutation lifecycle events around install', async () => {
const archivePath = path.join(tempWorkspaceDir, 'local-extension.zip');
fs.writeFileSync(archivePath, 'not used by mocked extractor');
mockExtractArchiveFile.mockImplementation(
async (_source: string, destination: string) => {
writeExtractedExtension(destination, 'local-archive-extension');
},
);
const manager = createExtensionManager();
const events: ExtensionMutationEvent[] = [];
manager.addMutationListener((event) => events.push(event));
await manager.refreshCache();
await manager.installExtension(
{
source: archivePath,
type: 'local',
},
async () => {},
);
expect(events).toEqual([
{ id: 1, phase: 'start', operation: 'installExtension' },
{ id: 2, phase: 'start', operation: 'enableExtension' },
{ id: 2, phase: 'end', operation: 'enableExtension' },
{ id: 1, phase: 'end', operation: 'installExtension' },
]);
});
it('should clean up converted temp dir for local archive installs', async () => {
const archivePath = path.join(tempWorkspaceDir, 'gemini-extension.zip');
fs.writeFileSync(archivePath, 'not used by mocked extractor');
@ -337,6 +370,33 @@ describe('extension tests', () => {
});
});
describe('uninstallExtension', () => {
it('should emit mutation lifecycle events around uninstall', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'my-extension',
version: '1.0.0',
installMetadata: {
type: 'local',
source: tempWorkspaceDir,
originSource: 'QwenCode',
},
});
const manager = createExtensionManager();
const events: ExtensionMutationEvent[] = [];
manager.addMutationListener((event) => events.push(event));
await manager.refreshCache();
await manager.uninstallExtension('my-extension', false);
expect(events).toEqual([
{ id: 1, phase: 'start', operation: 'uninstallExtension' },
{ id: 1, phase: 'end', operation: 'uninstallExtension' },
]);
});
});
describe('loadExtension', () => {
it('should include extension path in loaded extension', async () => {
const extensionDir = path.join(userExtensionsDir, 'test-extension');
@ -518,6 +578,32 @@ describe('extension tests', () => {
expect(extensions[0].name).toBe('ext2');
});
it('keeps the previous cache when refreshCache fails before replacement', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'stable-ext',
version: '1.0.0',
});
const manager = createExtensionManager();
await manager.refreshCache();
expect(manager.getLoadedExtensions().map((ext) => ext.name)).toEqual([
'stable-ext',
]);
const internals = manager as unknown as {
loadExtensionsFromExtensionsDir: () => Promise<Extension[]>;
};
internals.loadExtensionsFromExtensionsDir = vi
.fn()
.mockRejectedValue(new Error('refresh failed'));
await expect(manager.refreshCache()).rejects.toThrow('refresh failed');
expect(manager.getLoadedExtensions().map((ext) => ext.name)).toEqual([
'stable-ext',
]);
});
describe('command discovery', () => {
it('should discover .md command files', async () => {
const extDir = createExtension({
@ -690,6 +776,50 @@ describe('extension tests', () => {
});
describe('enableExtension / disableExtension', () => {
it('should emit mutation lifecycle events around extension changes', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'my-extension',
version: '1.0.0',
});
const manager = createExtensionManager();
const events: ExtensionMutationEvent[] = [];
manager.addMutationListener((event) => events.push(event));
await manager.refreshCache();
await manager.disableExtension('my-extension', SettingScope.User);
expect(events).toEqual([
{ id: 1, phase: 'start', operation: 'disableExtension' },
{ id: 1, phase: 'end', operation: 'disableExtension' },
]);
});
it('should not emit mutation lifecycle events when validation fails', async () => {
const manager = createExtensionManager();
const events: ExtensionMutationEvent[] = [];
manager.addMutationListener((event) => events.push(event));
await expect(
manager.disableExtension('missing-extension', SettingScope.User),
).rejects.toThrow(
'Extension with name missing-extension does not exist.',
);
await expect(
manager.enableExtension('missing-extension', SettingScope.User),
).rejects.toThrow(
'Extension with name missing-extension does not exist.',
);
await expect(manager.addSource(' ')).rejects.toThrow(
'Marketplace source cannot be empty.',
);
expect(events).toEqual([]);
});
it('should disable an extension at the user scope', async () => {
createExtension({
extensionsDir: userExtensionsDir,
@ -789,6 +919,75 @@ describe('extension tests', () => {
});
});
describe('preference-only operations', () => {
it('should not emit mutation lifecycle events for preference changes', () => {
const manager = createExtensionManager();
const events: ExtensionMutationEvent[] = [];
manager.addMutationListener((event) => events.push(event));
expect(manager.toggleFavorite('my-extension')).toBe(true);
fs.writeFileSync(
path.join(userExtensionsDir, 'marketplaces.json'),
JSON.stringify([
{
name: 'marketplace',
source: 'owner/repo',
type: 'github',
addedAt: '2026-01-01T00:00:00.000Z',
},
]),
);
expect(manager.markSourceUpdated('marketplace')).toMatchObject({
name: 'marketplace',
});
expect(events).toEqual([]);
});
});
describe('updateExtension', () => {
it('should end mutation lifecycle events when temp directory creation fails', async () => {
const extensionPath = createExtension({
extensionsDir: userExtensionsDir,
name: 'my-extension',
version: '1.0.0',
installMetadata: {
type: 'local',
source: tempWorkspaceDir,
originSource: 'QwenCode',
},
});
const manager = createExtensionManager();
const events: ExtensionMutationEvent[] = [];
const callback = vi.fn();
manager.addMutationListener((event) => events.push(event));
await manager.refreshCache();
const extension = manager
.getLoadedExtensions()
.find((entry) => entry.path === extensionPath);
vi.spyOn(ExtensionStorage, 'createTmpDir').mockRejectedValueOnce(
new Error('disk full'),
);
await expect(
manager.updateExtension(
extension!,
ExtensionUpdateState.UPDATE_AVAILABLE,
callback,
),
).rejects.toThrow('disk full');
expect(events).toEqual([
{ id: 1, phase: 'start', operation: 'updateExtension' },
{ id: 1, phase: 'end', operation: 'updateExtension' },
]);
expect(callback).toHaveBeenCalledWith(
'my-extension',
ExtensionUpdateState.ERROR,
);
});
});
describe('validateExtensionOverrides', () => {
it('should mark all extensions as active if no enabled extensions are provided', async () => {
createExtension({
@ -980,32 +1179,36 @@ describe('extension tests', () => {
expect(serverConfig.env!['MISSING_VAR']).toBe('$UNDEFINED_ENV_VAR');
expect(serverConfig.env!['MISSING_VAR_BRACES']).toBe('${ALSO_UNDEFINED}');
});
describe('refreshTools and refreshMemory', () => {
describe('refreshTools', () => {
it('refreshTools should return early if config is not set', async () => {
const manager = createExtensionManager();
// Should not throw when config is undefined
await expect(manager.refreshTools()).resolves.not.toThrow();
});
it('refreshTools should always call refreshMemory', async () => {
it('refreshTools should call all refresh methods', async () => {
const mockRefreshCache = vi.fn();
const mockRestartMcpServers = vi.fn();
const mockReinitializeMcpServers = vi.fn();
const mockReloadHooks = vi.fn();
const mockRefreshHierarchicalMemory = vi.fn();
const mockSettingsMcpServers = { server: { command: 'cmd' } };
const mockConfig = {
getGeminiClient: () => ({
isInitialized: () => false,
setTools: vi.fn(),
}),
getToolRegistry: () => ({
restartMcpServers: mockRestartMcpServers,
}),
getSettingsMcpServers: () => mockSettingsMcpServers,
reinitializeMcpServers: mockReinitializeMcpServers,
getSkillManager: () => ({
refreshCache: mockRefreshCache,
}),
getSubagentManager: () => ({
refreshCache: mockRefreshCache,
}),
getHookSystem: () => ({
reload: mockReloadHooks,
}),
refreshHierarchicalMemory: mockRefreshHierarchicalMemory,
};
@ -1015,47 +1218,12 @@ describe('extension tests', () => {
await manager.refreshTools();
// refreshMemory should be called which includes these
expect(mockRestartMcpServers).toHaveBeenCalledOnce();
expect(mockReinitializeMcpServers).toHaveBeenCalledOnce();
expect(mockReinitializeMcpServers).toHaveBeenCalledWith(
mockSettingsMcpServers,
);
expect(mockRefreshCache).toHaveBeenCalledTimes(2); // skillManager and subagentManager
expect(mockRefreshHierarchicalMemory).toHaveBeenCalledOnce();
});
it('refreshMemory should return early if config is not set', async () => {
const manager = createExtensionManager();
// Should not throw when config is undefined
await expect(manager.refreshMemory()).resolves.not.toThrow();
});
it('refreshMemory should call all refresh methods', async () => {
const mockSkillRefreshCache = vi.fn();
const mockSubagentRefreshCache = vi.fn();
const mockRestartMcpServers = vi.fn();
const mockRefreshHierarchicalMemory = vi.fn();
const mockConfig = {
getToolRegistry: () => ({
restartMcpServers: mockRestartMcpServers,
}),
getSkillManager: () => ({
refreshCache: mockSkillRefreshCache,
}),
getSubagentManager: () => ({
refreshCache: mockSubagentRefreshCache,
}),
refreshHierarchicalMemory: mockRefreshHierarchicalMemory,
};
const manager = createExtensionManager();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(manager as any).config = mockConfig;
await manager.refreshMemory();
expect(mockRestartMcpServers).toHaveBeenCalledOnce();
expect(mockSkillRefreshCache).toHaveBeenCalledOnce();
expect(mockSubagentRefreshCache).toHaveBeenCalledOnce();
expect(mockReloadHooks).toHaveBeenCalledOnce();
expect(mockRefreshHierarchicalMemory).toHaveBeenCalledOnce();
});
});

View file

@ -216,6 +216,14 @@ export interface ExtensionManagerOptions {
) => Promise<string>;
}
export interface ExtensionMutationEvent {
id: number;
phase: 'start' | 'end';
operation: string;
}
export type ExtensionMutationListener = (event: ExtensionMutationEvent) => void;
// ============================================================================
// Helper Functions
// ============================================================================
@ -302,6 +310,8 @@ async function loadCommandsFromDir(dir: string): Promise<string[]> {
export class ExtensionManager {
private extensionCache: Map<string, Extension> | null = null;
private readonly mutationListeners = new Set<ExtensionMutationListener>();
private nextMutationId = 0;
// Enablement configuration (directly implemented)
private readonly configDir: string;
@ -374,6 +384,34 @@ export class ExtensionManager {
this.requestChoicePlugin = requestChoicePlugin;
}
addMutationListener(listener: ExtensionMutationListener): () => void {
this.mutationListeners.add(listener);
return () => {
this.mutationListeners.delete(listener);
};
}
private beginMutation(operation: string): () => void {
const id = ++this.nextMutationId;
this.emitMutation({ id, phase: 'start', operation });
let ended = false;
return () => {
if (ended) return;
ended = true;
this.emitMutation({ id, phase: 'end', operation });
};
}
private emitMutation(event: ExtensionMutationEvent): void {
for (const listener of this.mutationListeners) {
try {
listener(event);
} catch (error) {
debugLogger.warn('Extension mutation listener failed:', error);
}
}
}
// ==========================================================================
// Enablement functionality (directly implemented)
// ==========================================================================
@ -450,13 +488,19 @@ export class ExtensionManager {
if (!extension) {
throw new Error(`Extension with name ${name} does not exist.`);
}
const scopePath =
scope === SettingScope.Workspace ? currentDir : os.homedir();
this.enableByPath(name, true, scopePath);
const config = getTelemetryConfig(currentDir, this.telemetrySettings);
logExtensionEnable(config, new ExtensionEnableEvent(name, scope));
extension.isActive = true;
await this.refreshTools();
const endMutation = this.beginMutation('enableExtension');
try {
const scopePath =
scope === SettingScope.Workspace ? currentDir : os.homedir();
this.enableByPath(name, true, scopePath);
const config = getTelemetryConfig(currentDir, this.telemetrySettings);
logExtensionEnable(config, new ExtensionEnableEvent(name, scope));
extension.isActive = true;
await this.refreshTools();
} finally {
endMutation();
}
}
/**
@ -481,12 +525,18 @@ export class ExtensionManager {
if (!extension) {
throw new Error(`Extension with name ${name} does not exist.`);
}
const scopePath =
scope === SettingScope.Workspace ? currentDir : os.homedir();
this.disableByPath(name, true, scopePath);
logExtensionDisable(config, new ExtensionDisableEvent(name, scope));
extension.isActive = false;
await this.refreshTools();
const endMutation = this.beginMutation('disableExtension');
try {
const scopePath =
scope === SettingScope.Workspace ? currentDir : os.homedir();
this.disableByPath(name, true, scopePath);
logExtensionDisable(config, new ExtensionDisableEvent(name, scope));
extension.isActive = false;
await this.refreshTools();
} finally {
endMutation();
}
}
/**
@ -526,7 +576,12 @@ export class ExtensionManager {
}
setExtensionScope(name: string, scope: ExtensionScope): void {
this.preferencesStore.setScope(name, scope);
const endMutation = this.beginMutation('setExtensionScope');
try {
this.preferencesStore.setScope(name, scope);
} finally {
endMutation();
}
}
/** MCP servers individually disabled inside the given extension. */
@ -539,11 +594,16 @@ export class ExtensionManager {
serverName: string,
disabled: boolean,
): void {
this.preferencesStore.setMcpServerDisabled(
extensionName,
serverName,
disabled,
);
const endMutation = this.beginMutation('setMcpServerDisabled');
try {
this.preferencesStore.setMcpServerDisabled(
extensionName,
serverName,
disabled,
);
} finally {
endMutation();
}
}
// ==========================================================================
@ -588,25 +648,36 @@ export class ExtensionManager {
`Expected a .claude-plugin/marketplace.json.`,
);
}
const now = new Date().toISOString();
const entry: ExtensionSource = {
name: config.name || trimmed,
source: trimmed,
type: parseExtensionSourceType(trimmed),
addedAt: now,
lastUpdatedAt: now,
};
this.sourceRegistryStore.add(entry);
this.discoverCache = null; // sources changed -> refetch on next discover
return entry;
const endMutation = this.beginMutation('addSource');
try {
const now = new Date().toISOString();
const entry: ExtensionSource = {
name: config.name || trimmed,
source: trimmed,
type: parseExtensionSourceType(trimmed),
addedAt: now,
lastUpdatedAt: now,
};
this.sourceRegistryStore.add(entry);
this.discoverCache = null; // sources changed -> refetch on next discover
return entry;
} finally {
endMutation();
}
}
removeSource(name: string): boolean {
const removed = this.sourceRegistryStore.remove(name);
if (removed) {
this.discoverCache = null;
const endMutation = this.beginMutation('removeSource');
try {
const removed = this.sourceRegistryStore.remove(name);
if (removed) {
this.discoverCache = null;
}
return removed;
} finally {
endMutation();
}
return removed;
}
/**
@ -713,7 +784,6 @@ export class ExtensionManager {
* Refreshes the extension cache from disk.
*/
async refreshCache(options?: { names?: string[] }): Promise<void> {
this.extensionCache = new Map<string, Extension>();
const requestedNames = options?.names?.filter(Boolean) ?? [];
let extensions: Extension[];
if (requestedNames.length > 0) {
@ -729,9 +799,11 @@ export class ExtensionManager {
this.workspaceDir,
);
}
const nextCache = new Map<string, Extension>();
extensions.forEach((extension) => {
this.extensionCache!.set(extension.name, extension);
nextCache.set(extension.name, extension);
});
this.extensionCache = nextCache;
}
getLoadedExtensions(): Extension[] {
@ -1034,6 +1106,7 @@ export class ExtensionManager {
let tempDir: string | undefined;
let convertedSourcePath: string | undefined;
const endMutation = this.beginMutation('installExtension');
try {
if (!this.isWorkspaceTrusted) {
throw new Error(
@ -1376,6 +1449,8 @@ export class ExtensionManager {
);
}
throw error;
} finally {
endMutation();
}
}
@ -1387,47 +1462,52 @@ export class ExtensionManager {
isUpdate: boolean,
cwd?: string,
): Promise<void> {
const currentDir = cwd ?? this.workspaceDir;
const telemetryConfig = getTelemetryConfig(
currentDir,
this.telemetrySettings,
);
const installedExtensions = this.getLoadedExtensions();
const extension = installedExtensions.find(
(installed) =>
installed.config.name.toLowerCase() ===
extensionIdentifier.toLowerCase() ||
installed.installMetadata?.source.toLowerCase() ===
extensionIdentifier.toLowerCase(),
);
if (!extension) {
throw new Error(`Extension not found.`);
const endMutation = this.beginMutation('uninstallExtension');
try {
const currentDir = cwd ?? this.workspaceDir;
const telemetryConfig = getTelemetryConfig(
currentDir,
this.telemetrySettings,
);
const installedExtensions = this.getLoadedExtensions();
const extension = installedExtensions.find(
(installed) =>
installed.config.name.toLowerCase() ===
extensionIdentifier.toLowerCase() ||
installed.installMetadata?.source.toLowerCase() ===
extensionIdentifier.toLowerCase(),
);
if (!extension) {
throw new Error(`Extension not found.`);
}
const storage = new ExtensionStorage(
extension.installMetadata?.type === 'link'
? extension.name
: path.basename(extension.path),
);
await fs.promises.rm(storage.getExtensionDir(), {
recursive: true,
force: true,
});
if (this.extensionCache) {
this.extensionCache.delete(extension.name);
}
if (isUpdate) return;
this.removeEnablementConfig(extension.name);
this.preferencesStore.clear(extension.name);
await this.refreshTools();
logExtensionUninstall(
telemetryConfig,
new ExtensionUninstallEvent(extension.name, 'success'),
);
} finally {
endMutation();
}
const storage = new ExtensionStorage(
extension.installMetadata?.type === 'link'
? extension.name
: path.basename(extension.path),
);
await fs.promises.rm(storage.getExtensionDir(), {
recursive: true,
force: true,
});
if (this.extensionCache) {
this.extensionCache.delete(extension.name);
}
if (isUpdate) return;
this.removeEnablementConfig(extension.name);
this.preferencesStore.clear(extension.name);
await this.refreshTools();
logExtensionUninstall(
telemetryConfig,
new ExtensionUninstallEvent(extension.name, 'success'),
);
}
async performWorkspaceExtensionMigration(
@ -1498,10 +1578,12 @@ export class ExtensionManager {
callback(extension.name, ExtensionUpdateState.UP_TO_DATE);
throw new Error(`Extension is linked so does not need to be updated`);
}
const endMutation = this.beginMutation('updateExtension');
const originalVersion = extension.version;
let tempDir: string | undefined;
const tempDir = await ExtensionStorage.createTmpDir();
try {
tempDir = await ExtensionStorage.createTmpDir();
const previousExtensionConfig = this.loadExtensionConfig({
extensionDir: extension.path,
});
@ -1537,10 +1619,15 @@ export class ExtensionManager {
`Error updating extension, rolling back. ${getErrorMessage(e)}`,
);
callback(extension.name, ExtensionUpdateState.ERROR);
await copyExtension(tempDir, extension.path);
if (tempDir) {
await copyExtension(tempDir, extension.path);
}
throw e;
} finally {
await fs.promises.rm(tempDir, { recursive: true, force: true });
if (tempDir) {
await fs.promises.rm(tempDir, { recursive: true, force: true });
}
endMutation();
}
}
@ -1570,14 +1657,8 @@ export class ExtensionManager {
).filter((updateInfo) => !!updateInfo);
}
async refreshMemory(): Promise<void> {
await refreshExtensionRuntime(this.config);
}
async refreshTools(): Promise<void> {
if (!this.config) return;
// FIXME: restart all mcp servers now, this can be optimized by only restarting changed ones at here
await this.refreshMemory();
await refreshExtensionRuntime(this.config);
}
}

View file

@ -974,6 +974,136 @@ describe('HookRegistry', () => {
expect(registry.getAllHooks()).toHaveLength(0);
});
it('preserves agent-scoped hooks when configured hooks reload', async () => {
const userHooks = {
[HookEventName.PreToolUse]: [
{
matcher: 'Bash',
hooks: [
{
type: HookType.Command,
command: 'echo user',
name: 'user-hook',
},
],
},
],
};
mockConfig.getUserHooks = vi.fn().mockReturnValue(userHooks);
const registry = new HookRegistry(mockConfig);
await registry.initialize();
registry.addAgentHooks(
{
[HookEventName.PreToolUse]: [
{
matcher: 'Bash',
hooks: [
{
type: HookType.Command,
command: 'echo agent',
name: 'agent-hook',
},
],
},
],
},
'agent:test:reload',
);
mockConfig.getUserHooks = vi.fn().mockReturnValue(undefined);
await registry.reloadConfiguredHooks();
const after = registry.getAllHooks();
expect(after).toHaveLength(1);
expect(after[0].source).toBe(HooksConfigSource.Session);
expect(after[0].agentScope).toBe('agent:test:reload');
});
it('preserves configured hook enabled state when hooks reload', async () => {
const userHooks = {
[HookEventName.PreToolUse]: [
{
matcher: 'Bash',
hooks: [
{
type: HookType.Command,
command: 'echo user',
name: 'user-hook',
},
],
},
],
};
mockConfig.getUserHooks = vi.fn().mockReturnValue(userHooks);
const registry = new HookRegistry(mockConfig);
await registry.initialize();
registry.setHookEnabled('user-hook', false);
expect(registry.getHooksForEvent(HookEventName.PreToolUse)).toHaveLength(
0,
);
await registry.reloadConfiguredHooks();
const after = registry.getAllHooks();
expect(after).toHaveLength(1);
expect(after[0].enabled).toBe(false);
expect(registry.getHooksForEvent(HookEventName.PreToolUse)).toHaveLength(
0,
);
});
it('restores all previous hooks when configured hooks reload fails', async () => {
const userHooks = {
[HookEventName.PreToolUse]: [
{
matcher: 'Bash',
hooks: [
{
type: HookType.Command,
command: 'echo user',
name: 'user-hook',
},
],
},
],
};
mockConfig.getUserHooks = vi.fn().mockReturnValue(userHooks);
const registry = new HookRegistry(mockConfig);
await registry.initialize();
registry.addAgentHooks(
{
[HookEventName.PreToolUse]: [
{
matcher: 'Bash',
hooks: [
{
type: HookType.Command,
command: 'echo agent',
name: 'agent-hook',
},
],
},
],
},
'agent:test:reload-failure',
);
const before = registry.getAllHooks();
mockConfig.getUserHooks = vi.fn(() => {
throw new Error('reload failed');
});
await expect(registry.reloadConfiguredHooks()).rejects.toThrow(
'reload failed',
);
expect(registry.getAllHooks()).toEqual(before);
});
it('silently keeps entries when the hooks payload is empty', async () => {
const registry = new HookRegistry(mockConfig);
await registry.initialize();

View file

@ -85,6 +85,37 @@ export class HookRegistry {
);
}
async reloadConfiguredHooks(): Promise<void> {
const previousEntries = this.entries;
const enabledSnapshot = new Map(
previousEntries.map((entry) => [
this.getHookStateKey(entry),
entry.enabled,
]),
);
const agentEntries = this.entries.filter(
(entry) => entry.agentScope !== undefined,
);
try {
this.entries = [...agentEntries];
this.processHooksFromConfig();
for (const entry of this.entries) {
const enabled = enabledSnapshot.get(this.getHookStateKey(entry));
if (enabled !== undefined) {
entry.enabled = enabled;
}
}
} catch (err) {
this.entries = previousEntries;
throw err;
}
debugLogger.debug(
`Hook registry reloaded with ${this.entries.length} hook entries`,
);
}
/**
* Get all hook entries for a specific event
*/
@ -206,6 +237,17 @@ export class HookRegistry {
return identity;
}
private getHookStateKey(entry: HookRegistryEntry): string {
return JSON.stringify([
entry.eventName,
entry.source,
entry.agentScope ?? null,
this.getHookIdentity(entry),
entry.matcher ?? null,
entry.sequential ?? null,
]);
}
/**
* Process hooks from the config that was already loaded by the CLI
*/

View file

@ -67,6 +67,7 @@ describe('HookSystem', () => {
mockHookRegistry = {
initialize: vi.fn().mockResolvedValue(undefined),
reloadConfiguredHooks: vi.fn().mockResolvedValue(undefined),
setHookEnabled: vi.fn(),
getAllHooks: vi.fn().mockReturnValue([]),
getHooksForEvent: vi.fn().mockReturnValue([]),
@ -140,6 +141,14 @@ describe('HookSystem', () => {
});
});
describe('reload', () => {
it('should reload configured hooks', async () => {
await hookSystem.reload();
expect(mockHookRegistry.reloadConfiguredHooks).toHaveBeenCalled();
});
});
describe('getEventHandler', () => {
it('should return the hook event handler', () => {
const eventHandler = hookSystem.getEventHandler();

View file

@ -90,6 +90,11 @@ export class HookSystem {
debugLogger.debug('Hook system initialized successfully');
}
async reload(): Promise<void> {
await this.hookRegistry.reloadConfiguredHooks();
debugLogger.debug('Hook system reloaded successfully');
}
/**
* Set the messages provider for automatic conversation history passing
* to function hooks during execution