feat(web-shell): add extension management (#5398)

Co-authored-by: ytahdn <ytahdn@gmail.com>
This commit is contained in:
ytahdn 2026-06-20 08:37:22 +08:00 committed by GitHub
parent 4274c6c6c8
commit 18cc73ce05
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 4213 additions and 55 deletions

View file

@ -26,6 +26,7 @@ export default tseslint.config(
'.integration-tests/**',
'packages/**/.integration-test/**',
'dist/**',
'demo/**/dist/**',
'docs-site/.next/**',
'docs-site/out/**',
'.qwen/**',

View file

@ -384,6 +384,118 @@ describe('createAcpSessionBridge', () => {
await bridge.shutdown();
});
it('refreshes extensions across live sessions and broadcasts merged results', async () => {
const handles: ChannelHandle[] = [];
const bridge = makeBridge({
channelFactory: async () => {
const h = makeChannel({
extMethodImpl: (method, params) => {
if (
method === 'qwen/control/workspace/extensions/refresh' &&
String(params['sessionId']).endsWith('#2')
) {
throw new Error('refresh failed');
}
return {};
},
});
handles.push(h);
return h.channel;
},
});
const first = await bridge.spawnOrAttach({
workspaceCwd: WS_A,
sessionScope: 'thread',
});
const second = await bridge.spawnOrAttach({
workspaceCwd: WS_A,
sessionScope: 'thread',
});
const abort = new AbortController();
const iter = bridge.subscribeEvents(first.sessionId, {
signal: abort.signal,
});
const nextEvent = iter[Symbol.asyncIterator]().next();
const result = await bridge.refreshExtensionsForAllSessions({
status: 'updated',
name: 'test-ext',
});
expect(result).toEqual({ refreshed: 1, failed: 1 });
expect(handles[0]?.agent.extMethodCalls).toEqual([
{
method: 'qwen/control/workspace/extensions/refresh',
params: { sessionId: first.sessionId },
},
{
method: 'qwen/control/workspace/extensions/refresh',
params: { sessionId: second.sessionId },
},
]);
const event = await nextEvent;
expect(event.value).toMatchObject({
type: 'extensions_changed',
data: {
status: 'updated',
name: 'test-ext',
refreshed: 1,
failed: 1,
},
});
abort.abort();
await bridge.shutdown();
});
it('does not refresh or broadcast extensions when no sessions are live', async () => {
const bridge = makeBridge();
await expect(bridge.refreshExtensionsForAllSessions()).resolves.toEqual({
refreshed: 0,
failed: 0,
});
await bridge.shutdown();
});
it('skips dying sessions when refreshing extensions', async () => {
let releaseKill: (() => void) | undefined;
const handles: ChannelHandle[] = [];
const bridge = makeBridge({
channelFactory: async () => {
const h = makeChannel();
const originalKill = h.channel.kill;
h.channel.kill = async () => {
await new Promise<void>((resolve) => {
releaseKill = resolve;
});
await originalKill();
};
handles.push(h);
return h.channel;
},
});
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const killPromise = bridge.killSession(session.sessionId);
await vi.waitFor(() => {
expect(releaseKill).toBeDefined();
});
await expect(bridge.refreshExtensionsForAllSessions()).resolves.toEqual({
refreshed: 0,
failed: 0,
});
expect(
handles[0]?.agent.extMethodCalls.filter(
(call) => call.method === 'qwen/control/workspace/extensions/refresh',
),
).toEqual([]);
releaseKill?.();
await killPromise;
await bridge.shutdown();
});
it('rejects session status requests for unknown sessions', async () => {
const bridge = makeBridge({
channelFactory: async () => makeChannel().channel,

View file

@ -3812,6 +3812,61 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
);
},
async refreshExtensionsForAllSessions(data) {
const sessions = Array.from(byId.values());
const results = await Promise.all(
sessions.map(async (entry) => {
const info = channelInfoForEntry(entry);
if (!info || info.isDying) {
return { refreshed: 0, failed: 0 };
}
try {
await Promise.race([
withTimeout(
entry.connection.extMethod(
SERVE_CONTROL_EXT_METHODS.workspaceExtensionsRefresh,
{ sessionId: entry.sessionId },
),
30_000,
SERVE_CONTROL_EXT_METHODS.workspaceExtensionsRefresh,
),
getTransportClosedReject(entry),
]);
return { refreshed: 1, failed: 0 };
} catch (err) {
writeServeDebugLine(
`refreshExtensions: session ${entry.sessionId} failed: ` +
`${err instanceof Error ? err.message : String(err)}`,
);
return { refreshed: 0, failed: 1 };
}
}),
);
const refreshed = results.reduce(
(sum, result) => sum + result.refreshed,
0,
);
const failed = results.reduce((sum, result) => sum + result.failed, 0);
if (refreshed > 0 || failed > 0 || data?.status !== undefined) {
broadcastWorkspaceEvent({
type: 'extensions_changed',
data: { ...data, refreshed, failed },
});
}
return { refreshed, failed };
},
broadcastExtensionsChanged(data) {
broadcastWorkspaceEvent({
type: 'extensions_changed',
data,
});
},
async setSessionModel(sessionId, req, context) {
const entry = byId.get(sessionId);
if (!entry) throw new SessionNotFoundError(sessionId);

View file

@ -239,6 +239,22 @@ export interface BridgeDaemonStatusSnapshot {
sessions: BridgeDaemonSessionDiagnostic[];
}
export interface BridgeExtensionsChangedData {
refreshed: number;
failed: number;
status?:
| 'installed'
| 'enabled'
| 'disabled'
| 'updated'
| 'uninstalled'
| 'failed';
source?: string;
name?: string;
version?: string;
error?: string;
}
export interface AcpSessionBridge {
/** Read-only daemon diagnostics for status endpoints. */
getDaemonStatusSnapshot(): BridgeDaemonStatusSnapshot;
@ -471,6 +487,20 @@ export interface AcpSessionBridge {
/** Read workspace-level installed extension status. */
getWorkspaceExtensionsStatus(): Promise<ServeWorkspaceExtensionsStatus>;
/**
* Broadcast extension refresh to all active sessions and emit an
* `extensions_changed` workspace event when complete.
*/
refreshExtensionsForAllSessions(
data?: Omit<BridgeExtensionsChangedData, 'refreshed' | 'failed'>,
): Promise<{
refreshed: number;
failed: number;
}>;
/** Emit an extension lifecycle event without refreshing sessions. */
broadcastExtensionsChanged(data: BridgeExtensionsChangedData): void;
/**
* Switch the active model service for a session. Throws
* `SessionNotFoundError` for unknown ids.

View file

@ -140,6 +140,7 @@ export const SERVE_CONTROL_EXT_METHODS = {
workspaceMcpRuntimeAdd: 'qwen/control/workspace/mcp/runtime-add',
workspaceMcpRuntimeRemove: 'qwen/control/workspace/mcp/runtime-remove',
workspaceReload: 'qwen/control/workspace/reload',
workspaceExtensionsRefresh: 'qwen/control/workspace/extensions/refresh',
} as const;
export type ServeStatus =
@ -873,6 +874,26 @@ export interface ServeExtensionCapabilities {
hasSettings: boolean;
}
export type ServeExtensionUpdateState =
| 'checking for updates'
| 'updated, needs restart'
| 'updating'
| 'updated'
| 'update available'
| 'up to date'
| 'error'
| 'not updatable'
| 'unknown';
export interface ServeExtensionDetails {
mcpServers: string[];
commands: string[];
skills: string[];
agents: string[];
contextFiles: string[];
settings: string[];
}
export interface ServeExtensionEntry {
kind: 'extension';
id: string;
@ -886,7 +907,9 @@ export interface ServeExtensionEntry {
originSource?: ServeExtensionOriginSource;
ref?: string;
autoUpdate?: boolean;
updateState?: ServeExtensionUpdateState;
capabilities: ServeExtensionCapabilities;
details?: ServeExtensionDetails;
}
export interface ServeWorkspaceExtensionsStatus {

View file

@ -6245,4 +6245,116 @@ describe('sessionLanguage multi-session propagation', () => {
mockConnectionState.resolve();
await agentPromise;
});
it('refreshes extension commands for the live session', async () => {
const extensionManager = {
refreshCache: vi.fn().mockResolvedValue(undefined),
refreshTools: vi.fn().mockResolvedValue(undefined),
};
const cfg = makeConfig({
getSessionId: vi.fn().mockReturnValue('s-ext'),
getExtensionManager: vi.fn().mockReturnValue(extensionManager),
});
const sendAvailableCommandsUpdate = vi.fn().mockResolvedValue(undefined);
vi.mocked(loadSettings).mockReturnValue({
merged: { mcpServers: {} },
getUserHooks: vi.fn().mockReturnValue({}),
getProjectHooks: vi.fn().mockReturnValue({}),
} as unknown as LoadedSettings);
vi.mocked(loadCliConfig).mockResolvedValue(cfg as unknown as Config);
vi.mocked(Session).mockImplementation(
() =>
({
getId: vi.fn().mockReturnValue('s-ext'),
getConfig: vi.fn().mockReturnValue(cfg),
sendAvailableCommandsUpdate,
installRewriter: vi.fn(),
startCronScheduler: vi.fn(),
dispose: vi.fn(),
}) as unknown as InstanceType<typeof Session>,
);
const agentPromise = runAcpAgent(
makeConfig() as unknown as Config,
{ merged: { mcpServers: {} } } as unknown as LoadedSettings,
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
});
await agent.newSession({ cwd: '/ext', mcpServers: [] });
await expect(
agent.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceExtensionsRefresh, {
sessionId: 's-ext',
}),
).resolves.toEqual({ ok: true });
expect(extensionManager.refreshCache).toHaveBeenCalledOnce();
expect(extensionManager.refreshTools).toHaveBeenCalledOnce();
expect(sendAvailableCommandsUpdate).toHaveBeenCalledOnce();
mockConnectionState.resolve();
await agentPromise;
});
it('still sends available commands update when extension tool refresh fails', async () => {
const extensionManager = {
refreshCache: vi.fn().mockResolvedValue(undefined),
refreshTools: vi.fn().mockRejectedValue(new Error('bad tool schema')),
};
const cfg = makeConfig({
getSessionId: vi.fn().mockReturnValue('s-ext'),
getExtensionManager: vi.fn().mockReturnValue(extensionManager),
});
const sendAvailableCommandsUpdate = vi.fn().mockResolvedValue(undefined);
vi.mocked(loadSettings).mockReturnValue({
merged: { mcpServers: {} },
getUserHooks: vi.fn().mockReturnValue({}),
getProjectHooks: vi.fn().mockReturnValue({}),
} as unknown as LoadedSettings);
vi.mocked(loadCliConfig).mockResolvedValue(cfg as unknown as Config);
vi.mocked(Session).mockImplementation(
() =>
({
getId: vi.fn().mockReturnValue('s-ext'),
getConfig: vi.fn().mockReturnValue(cfg),
sendAvailableCommandsUpdate,
installRewriter: vi.fn(),
startCronScheduler: vi.fn(),
dispose: vi.fn(),
}) as unknown as InstanceType<typeof Session>,
);
const agentPromise = runAcpAgent(
makeConfig() as unknown as Config,
{ merged: { mcpServers: {} } } as unknown as LoadedSettings,
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
});
await agent.newSession({ cwd: '/ext', mcpServers: [] });
await expect(
agent.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceExtensionsRefresh, {
sessionId: 's-ext',
}),
).resolves.toEqual({ ok: true });
expect(extensionManager.refreshCache).toHaveBeenCalledOnce();
expect(extensionManager.refreshTools).toHaveBeenCalledOnce();
expect(sendAvailableCommandsUpdate).toHaveBeenCalledOnce();
mockConnectionState.resolve();
await agentPromise;
});
});

View file

@ -4545,6 +4545,16 @@ class QwenAgent implements Agent {
? { autoUpdate: ext.installMetadata.autoUpdate }
: {}),
capabilities,
updateState: ext.installMetadata ? 'unknown' : 'not updatable',
details: {
mcpServers: ext.mcpServers ? Object.keys(ext.mcpServers) : [],
commands: ext.commands ?? [],
skills: ext.skills?.map((skill) => skill.name) ?? [],
agents: ext.agents?.map((agent) => agent.name) ?? [],
contextFiles: ext.contextFiles,
settings:
ext.resolvedSettings?.map((setting) => setting.name) ?? [],
},
};
},
);
@ -6004,6 +6014,23 @@ class QwenAgent implements Agent {
);
return result as unknown as Record<string, unknown>;
}
case SERVE_CONTROL_EXT_METHODS.workspaceExtensionsRefresh: {
const sessionId = params['sessionId'] as string;
const session = this.sessionOrThrow(sessionId);
const extensionManager = session.getConfig().getExtensionManager();
await extensionManager.refreshCache();
try {
await extensionManager.refreshTools();
} catch (err) {
debugLogger.warn(
`Extension tool refresh failed for session ${sessionId}: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
await session.sendAvailableCommandsUpdate();
return { ok: true };
}
case 'deleteSession': {
const sessionId = params['sessionId'] as string;
if (!sessionId || !SESSION_ID_RE.test(sessionId)) {

View file

@ -983,6 +983,8 @@ export async function runQwenServe(
bridge.queryWorkspaceStatus(method, idle),
invokeWorkspaceCommand: (method, params, invokeOpts) =>
bridge.invokeWorkspaceCommand(method, params, invokeOpts),
refreshExtensionsForAllSessions: () =>
bridge.refreshExtensionsForAllSessions(),
publishWorkspaceEvent: (event) => bridge.publishWorkspaceEvent(event),
});

File diff suppressed because it is too large Load diff

View file

@ -9,11 +9,16 @@ import * as net from 'node:net';
import * as path from 'node:path';
import express from 'express';
import type { Application, NextFunction, Request, Response } from 'express';
import type { ApprovalMode } from '@qwen-code/qwen-code-core';
import {
APPROVAL_MODES,
ALL_PROVIDERS,
BTW_MAX_INPUT_LENGTH,
ExtensionUpdateState,
ExtensionManager,
checkForExtensionUpdate,
redactUrlCredentials,
SettingScope,
parseInstallSource,
SessionService,
shouldShowStep,
TrustGateError,
@ -25,6 +30,10 @@ import {
recordDaemonHttpRequest,
recordDaemonHttpResponse,
withDaemonRequestSpan,
type ApprovalMode,
type Extension,
type ExtensionInstallMetadata,
type ExtensionSetting,
} from '@qwen-code/qwen-code-core';
import { writeStderrLine } from '../utils/stdioHelpers.js';
import type { DaemonLogger } from './daemonLogger.js';
@ -53,6 +62,8 @@ import { createBridgeFileSystemAdapter } from './bridgeFileSystemAdapter.js';
import { createDaemonStatusProvider } from './daemonStatusProvider.js';
import { isServeDebugMode } from './debugMode.js';
import { SUPPORTED_LANGUAGES } from '../i18n/index.js';
import { loadSettings } from '../config/settings.js';
import { isWorkspaceTrusted } from '../config/trustedFolders.js';
import { isLoopbackBind } from './loopbackBinds.js';
import { mountAcpHttp, type AcpHttpHandle } from './acpHttp/index.js';
import {
@ -128,6 +139,12 @@ import {
setRateLimiter,
type RateLimiterInstance,
} from './rateLimit.js';
import {
STATUS_SCHEMA_VERSION,
type ServeExtensionCapabilities,
type ServeExtensionEntry,
type ServeWorkspaceExtensionsStatus,
} from './status.js';
let activeSseCount = 0;
export function getActiveSseCount(): number {
@ -432,6 +449,59 @@ function parseIPv6FirstHextet(host: string): number | undefined {
return Number.parseInt(first, 16);
}
function parseLegacyIPv4Part(value: string, max: number): number | undefined {
let parsed: number;
if (/^0x[0-9a-f]+$/i.test(value)) {
parsed = Number.parseInt(value.slice(2), 16);
} else if (/^0[0-7]+$/.test(value)) {
parsed = Number.parseInt(value, 8);
} else if (/^[0-9]+$/.test(value)) {
parsed = Number.parseInt(value, 10);
} else {
return undefined;
}
return parsed <= max ? parsed : undefined;
}
// Match URL parsers that still accept inet_aton-style IPv4 aliases, so blocked
// host checks also catch SSH sources such as git@0177.1:owner/repo.git.
function parseLegacyIPv4Host(host: string): string | undefined {
const parts = host.split('.');
if (parts.length < 1 || parts.length > 4 || parts.some((part) => !part)) {
return undefined;
}
// In legacy one-, two-, and three-part IPv4 forms, the final part carries the
// remaining bytes rather than a single octet.
const maxLastPart = [0xffffffff, 0xffffff, 0xffff, 0xff][parts.length - 1];
if (maxLastPart === undefined) return undefined;
const parsed = parts.map((part, index) =>
parseLegacyIPv4Part(part, index === parts.length - 1 ? maxLastPart : 0xff),
);
if (parsed.some((part) => part === undefined)) return undefined;
const values = parsed as number[];
const numeric =
values.length === 1
? values[0]
: values.length === 2
? values[0] * 0x1000000 + values[1]
: values.length === 3
? values[0] * 0x1000000 + values[1] * 0x10000 + values[2]
: values[0] * 0x1000000 +
values[1] * 0x10000 +
values[2] * 0x100 +
values[3];
return [
Math.floor(numeric / 0x1000000) & 0xff,
Math.floor(numeric / 0x10000) & 0xff,
Math.floor(numeric / 0x100) & 0xff,
numeric & 0xff,
].join('.');
}
function isBlockedAuthProviderHost(hostname: string): boolean {
const stripped = hostname.endsWith('.') ? hostname.slice(0, -1) : hostname;
const host = stripped.toLowerCase();
@ -439,9 +509,10 @@ function isBlockedAuthProviderHost(hostname: string): boolean {
const bareHost =
host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
const ipVersion = net.isIP(bareHost);
const blocklistHost = parseLegacyIPv4Host(bareHost) ?? bareHost;
const ipVersion = net.isIP(blocklistHost);
if (ipVersion === 4) {
const parts = bareHost.split('.').map((part) => Number(part));
const parts = blocklistHost.split('.').map((part) => Number(part));
const [a, b] = parts;
return (
a === 0 ||
@ -455,8 +526,8 @@ function isBlockedAuthProviderHost(hostname: string): boolean {
}
if (ipVersion === 6) {
if (bareHost === '::' || bareHost === '::1') return true;
const firstHextet = parseIPv6FirstHextet(bareHost);
if (blocklistHost === '::' || blocklistHost === '::1') return true;
const firstHextet = parseIPv6FirstHextet(blocklistHost);
if (
firstHextet !== undefined &&
((firstHextet >= 0xfe80 && firstHextet <= 0xfebf) ||
@ -464,8 +535,8 @@ function isBlockedAuthProviderHost(hostname: string): boolean {
) {
return true;
}
if (bareHost.startsWith('::ffff:')) {
const suffix = bareHost.slice('::ffff:'.length);
if (blocklistHost.startsWith('::ffff:')) {
const suffix = blocklistHost.slice('::ffff:'.length);
if (net.isIP(suffix) === 4) {
return isBlockedAuthProviderHost(suffix);
}
@ -1133,8 +1204,375 @@ export function createServeApp(
bridge.queryWorkspaceStatus(method, idle),
invokeWorkspaceCommand: (method, params, invokeOpts) =>
bridge.invokeWorkspaceCommand(method, params, invokeOpts),
refreshExtensionsForAllSessions: () =>
bridge.refreshExtensionsForAllSessions(),
publishWorkspaceEvent: (event) => bridge.publishWorkspaceEvent(event),
});
let extensionInstallQueue: Promise<unknown> = Promise.resolve();
let extensionInstallQueueDepth = 0;
const MAX_EXTENSION_INSTALL_QUEUE_DEPTH = 10;
const enqueueExtensionInstall = async <T>(run: () => Promise<T>) => {
if (extensionInstallQueueDepth >= MAX_EXTENSION_INSTALL_QUEUE_DEPTH) {
throw new Error('Extension operation queue is full');
}
extensionInstallQueueDepth += 1;
const next = extensionInstallQueue.then(run, run).finally(() => {
extensionInstallQueueDepth -= 1;
});
extensionInstallQueue = next.catch(() => undefined);
return next;
};
const EXTENSION_MUTATION_TIMEOUT_MS = 120_000;
const EXTENSION_REFRESH_TIMEOUT_MS = 30_000;
const isExtensionQueueFullError = (err: unknown): boolean =>
err instanceof Error && err.message === 'Extension operation queue is full';
const sendExtensionQueueFull = (res: Response) => {
res.status(429).json({
error: 'Extension operation queue is full',
code: 'extension_queue_full',
});
};
const withExtensionTimeout = async <T>(
promise: Promise<T>,
timeoutMs: number,
operation: string,
): Promise<T> =>
await new Promise<T>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error(`${operation} timed out after ${timeoutMs}ms`));
}, timeoutMs);
promise.then(
(value) => {
clearTimeout(timeout);
resolve(value);
},
(err: unknown) => {
clearTimeout(timeout);
reject(err);
},
);
});
const createExtensionManager = () =>
new ExtensionManager({
workspaceDir: boundWorkspace,
isWorkspaceTrusted: !!isWorkspaceTrusted(
loadSettings(boundWorkspace).merged,
),
requestConsent: () => Promise.resolve(),
requestSetting: async (setting: ExtensionSetting) => {
throw new Error(
`Extension setting "${setting.envVar}" requires interactive configuration and is not supported over the daemon install endpoint.`,
);
},
requestChoicePlugin: async () => {
throw new Error(
'Marketplace plugin selection is not supported over the daemon install endpoint. Specify a plugin name in the source.',
);
},
});
const validateExtensionMutationClient = (
req: Request,
res: Response,
route: string,
): boolean => {
const clientId = parseAndValidateWorkspaceClientId(req, res, bridge);
if (clientId === null) return false;
if (clientId === undefined) {
res.status(400).json({
error: 'Missing X-Qwen-Client-Id header',
code: 'missing_client_id',
});
return false;
}
buildWorkspaceCtx(req, route, clientId);
return true;
};
const parseExtensionScope = (
body: Record<string, unknown>,
res: Response,
): SettingScope | null => {
const scope = body['scope'];
if (scope !== 'user' && scope !== 'workspace') {
res
.status(400)
.json({ error: '`scope` must be either "user" or "workspace"' });
return null;
}
return scope === 'user' ? SettingScope.User : SettingScope.Workspace;
};
const parseExtensionRegistryUrl = (
value: string,
res: Response,
): string | null => {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
res.status(400).json({ error: '`registry` must be a valid URL' });
return null;
}
if (parsed.protocol !== 'https:') {
res.status(400).json({ error: '`registry` must use https' });
return null;
}
if (parsed.username || parsed.password) {
res
.status(400)
.json({ error: '`registry` must not include credentials' });
return null;
}
if (isBlockedAuthProviderHost(parsed.hostname)) {
res.status(400).json({ error: '`registry` host is not allowed' });
return null;
}
return parsed.toString().replace(/\/$/, '');
};
const parsePotentialSourceUrl = (source: string): URL | null => {
if (/^[a-zA-Z]:[\\/]/.test(source)) return null;
try {
return new URL(source);
} catch {
const sshMatch = /^(?:[^@]+@)?(\[[^\]]+\]|[^:]+):/.exec(source);
if (!sshMatch?.[1]) return null;
try {
return new URL(`ssh://${sshMatch[1]}`);
} catch {
return null;
}
}
};
const validateExtensionSourceHost = (
source: string,
res: Response,
): boolean => {
const parsed = parsePotentialSourceUrl(source);
if (!parsed) return true;
if (parsed.username || parsed.password) {
res.status(400).json({ error: '`source` must not include credentials' });
return false;
}
if (isBlockedAuthProviderHost(parsed.hostname)) {
res.status(400).json({ error: '`source` host is not allowed' });
return false;
}
if (parsed.protocol !== 'https:' && parsed.protocol !== 'ssh:') {
res.status(400).json({ error: '`source` must use https or ssh' });
return false;
}
return true;
};
const validateExtensionSourceMetadata = (
installMetadata: ExtensionInstallMetadata,
): boolean => {
if (installMetadata.type !== 'git') return true;
const parsed = parsePotentialSourceUrl(installMetadata.source);
return (
!!parsed &&
(parsed.protocol === 'https:' || parsed.protocol === 'ssh:') &&
!isBlockedAuthProviderHost(parsed.hostname)
);
};
const findLoadedExtension = (
extensionManager: ExtensionManager,
extensionName: string,
): Extension | undefined => {
const requested = extensionName.toLowerCase();
const extensions = extensionManager.getLoadedExtensions();
const byName = extensions.find(
(extension) => extension.name.toLowerCase() === requested,
);
if (byName) return byName;
if (!extensionName.includes('://') && !extensionName.includes('@')) {
return undefined;
}
return extensions.find(
(extension) =>
extension.installMetadata?.source?.toLowerCase() === requested,
);
};
type ExtensionMutationEvent = {
status: 'installed' | 'enabled' | 'disabled' | 'updated' | 'uninstalled';
source?: string;
name?: string;
version?: string;
};
const runQueuedExtensionMutation = (
operation: string,
failureContext: { source?: string; name?: string },
res: Response,
run: (
extensionManager: ExtensionManager,
) => Promise<ExtensionMutationEvent>,
): void => {
if (extensionInstallQueueDepth >= MAX_EXTENSION_INSTALL_QUEUE_DEPTH) {
sendExtensionQueueFull(res);
return;
}
res.status(202).json({ accepted: true });
void enqueueExtensionInstall(async () => {
try {
const extensionManager = createExtensionManager();
await extensionManager.refreshCache();
const event = await withExtensionTimeout(
run(extensionManager),
EXTENSION_MUTATION_TIMEOUT_MS,
`extension ${operation}`,
);
extensionsStatusCache = undefined;
try {
const result = await bridge.refreshExtensionsForAllSessions(event);
writeStderrLine(
`qwen serve: extensions ${operation}: refreshed ${result.refreshed} session(s), ${result.failed} failed`,
);
} catch (refreshErr) {
const message = redactUrlCredentials(
refreshErr instanceof Error
? refreshErr.message
: String(refreshErr),
);
try {
bridge.broadcastExtensionsChanged({
...event,
refreshed: 0,
failed: 1,
error: message.slice(0, 500),
});
} catch (broadcastErr) {
writeStderrLine(
`qwen serve: extensions ${operation}: failed to broadcast refresh failure: ${
broadcastErr instanceof Error
? redactUrlCredentials(broadcastErr.message)
: String(broadcastErr)
}`,
);
}
writeStderrLine(
`qwen serve: extensions ${operation}: mutation succeeded but refresh failed: ${message}`,
);
}
} catch (err) {
const message = redactUrlCredentials(
err instanceof Error ? err.message : String(err),
);
try {
bridge.broadcastExtensionsChanged({
status: 'failed',
...(failureContext.source
? { source: redactUrlCredentials(failureContext.source) }
: {}),
...(failureContext.name ? { name: failureContext.name } : {}),
refreshed: 0,
failed: 0,
error: message.slice(0, 500),
});
} catch (broadcastErr) {
writeStderrLine(
`qwen serve: extensions ${operation}: failed to broadcast failure: ${
broadcastErr instanceof Error
? redactUrlCredentials(broadcastErr.message)
: String(broadcastErr)
}`,
);
}
try {
writeStderrLine(
`qwen serve: extensions ${operation}: background task failed: ${message}`,
);
} catch {
// Keep queued background work from surfacing as unhandledRejection.
}
}
}).catch((err) => {
try {
writeStderrLine(
`qwen serve: extensions ${operation}: queued task failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
} catch {
// Last-resort guard for detached async work.
}
});
};
let extensionsStatusCache:
| { expiresAt: number; value: ServeWorkspaceExtensionsStatus }
| undefined;
const buildLocalExtensionsStatus =
async (): Promise<ServeWorkspaceExtensionsStatus> => {
const now = Date.now();
if (extensionsStatusCache && extensionsStatusCache.expiresAt > now) {
return extensionsStatusCache.value;
}
const extensionManager = createExtensionManager();
await extensionManager.refreshCache();
const entries: ServeExtensionEntry[] = extensionManager
.getLoadedExtensions()
.map((ext): ServeExtensionEntry => {
const capabilities: ServeExtensionCapabilities = {
mcpServerCount: ext.mcpServers
? Object.keys(ext.mcpServers).length
: 0,
skillCount: ext.skills?.length ?? 0,
agentCount: ext.agents?.length ?? 0,
hookCount: ext.hooks
? Object.values(ext.hooks).reduce(
(sum, defs) => sum + (defs?.length ?? 0),
0,
)
: 0,
commandCount: ext.commands?.length ?? 0,
contextFileCount: ext.contextFiles.length,
channelCount: ext.channels ? Object.keys(ext.channels).length : 0,
hasSettings: (ext.settings?.length ?? 0) > 0,
};
return {
kind: 'extension',
id: ext.id,
name: ext.name,
...(ext.displayName ? { displayName: ext.displayName } : {}),
version: ext.version,
isActive: ext.isActive,
path: ext.path,
...(ext.installMetadata?.source
? { source: redactUrlCredentials(ext.installMetadata.source) }
: {}),
...(ext.installMetadata?.type
? { installType: ext.installMetadata.type }
: {}),
...(ext.installMetadata?.originSource
? { originSource: ext.installMetadata.originSource }
: {}),
...(ext.installMetadata?.ref
? { ref: ext.installMetadata.ref }
: {}),
...(ext.installMetadata?.autoUpdate !== undefined
? { autoUpdate: ext.installMetadata.autoUpdate }
: {}),
updateState: ext.installMetadata ? 'unknown' : 'not updatable',
capabilities,
details: {
mcpServers: ext.mcpServers ? Object.keys(ext.mcpServers) : [],
commands: ext.commands ?? [],
skills: ext.skills?.map((skill) => skill.name) ?? [],
agents: ext.agents?.map((agent) => agent.name) ?? [],
contextFiles: ext.contextFiles,
settings:
ext.resolvedSettings?.map((setting) => setting.name) ?? [],
},
};
});
const status = {
v: STATUS_SCHEMA_VERSION,
workspaceCwd: boundWorkspace,
initialized: true,
extensions: entries,
};
extensionsStatusCache = {
expiresAt: now + 2_000,
value: status,
};
return status;
};
// Order matters: rejection guards (CORS / Host allowlist / bearer auth)
// run BEFORE the JSON body parser. Otherwise an unauthenticated POST
@ -1618,13 +2056,422 @@ export function createServeApp(
// GET /workspace/extensions — read-only installed extension status.
app.get('/workspace/extensions', async (req, res) => {
try {
const ctx = buildWorkspaceCtx(req, 'GET /workspace/extensions');
res.status(200).json(await workspace.getWorkspaceExtensionsStatus(ctx));
buildWorkspaceCtx(req, 'GET /workspace/extensions');
res.status(200).json(await buildLocalExtensionsStatus());
} catch (err) {
sendBridgeError(res, err, { route: 'GET /workspace/extensions' });
}
});
// POST /workspace/extensions/install — install an extension and refresh
// all active sessions asynchronously.
app.post(
'/workspace/extensions/install',
mutate({ strict: true }),
async (req, res) => {
try {
if (
!validateExtensionMutationClient(
req,
res,
'POST /workspace/extensions/install',
)
) {
return;
}
const body = safeBody(req);
const source = body['source'];
const ref = body['ref'];
const autoUpdate = body['autoUpdate'];
const allowPreRelease = body['allowPreRelease'];
const registry = body['registry'];
const consent = body['consent'];
if (!source || typeof source !== 'string') {
res.status(400).json({ error: 'Missing or invalid source' });
return;
}
if (ref !== undefined && (typeof ref !== 'string' || ref === '')) {
res.status(400).json({ error: '`ref` must be a string' });
return;
}
if (typeof ref === 'string' && ref.startsWith('-')) {
res.status(400).json({ error: '`ref` must not start with "-"' });
return;
}
if (autoUpdate !== undefined && typeof autoUpdate !== 'boolean') {
res.status(400).json({ error: '`autoUpdate` must be a boolean' });
return;
}
if (
allowPreRelease !== undefined &&
typeof allowPreRelease !== 'boolean'
) {
res
.status(400)
.json({ error: '`allowPreRelease` must be a boolean' });
return;
}
if (registry !== undefined && typeof registry !== 'string') {
res.status(400).json({ error: '`registry` must be a string' });
return;
}
const registryUrl =
registry !== undefined
? parseExtensionRegistryUrl(registry, res)
: undefined;
if (registryUrl === null) return;
if (consent !== true) {
res.status(400).json({
error: 'Extension installation requires explicit consent',
});
return;
}
if (!validateExtensionSourceHost(source, res)) {
return;
}
runQueuedExtensionMutation(
'install',
{ source },
res,
async (extensionManager) => {
const installMetadata = await parseInstallSource(source);
if (
installMetadata.type !== 'git' &&
installMetadata.type !== 'github-release' &&
installMetadata.type !== 'npm'
) {
throw new Error(
'Only GitHub, Git, and npm extension installs are supported over the daemon endpoint.',
);
}
if (installMetadata.type === 'npm' && ref) {
throw new Error('--ref is not applicable for npm extensions.');
}
if (installMetadata.type !== 'npm' && registry) {
throw new Error(
'--registry is only applicable for npm extensions.',
);
}
if (!validateExtensionSourceMetadata(installMetadata)) {
throw new Error('`source` host is not allowed');
}
if (installMetadata.type === 'npm' && registryUrl) {
installMetadata.registryUrl = registryUrl;
}
const extension = await extensionManager.installExtension(
{ ...installMetadata, ref, autoUpdate, allowPreRelease },
() => Promise.resolve(),
);
return {
status: 'installed',
source,
name: extension.name,
version: extension.config.version,
};
},
);
} catch (err) {
sendBridgeError(res, err, {
route: 'POST /workspace/extensions/install',
});
}
},
);
app.post(
'/workspace/extensions/check-updates',
mutate({ strict: true }),
async (req, res) => {
try {
if (
!validateExtensionMutationClient(
req,
res,
'POST /workspace/extensions/check-updates',
)
) {
return;
}
const states = await enqueueExtensionInstall(async () =>
withExtensionTimeout(
(async () => {
const extensionManager = createExtensionManager();
await extensionManager.refreshCache();
const updateStates: Record<string, string> = {};
await extensionManager.checkForAllExtensionUpdates(
(name, state) => {
updateStates[name] = state;
},
);
return updateStates;
})(),
EXTENSION_REFRESH_TIMEOUT_MS,
'extension update check',
),
);
res.status(200).json({ states });
} catch (err) {
if (isExtensionQueueFullError(err)) {
sendExtensionQueueFull(res);
return;
}
sendBridgeError(res, err, {
route: 'POST /workspace/extensions/check-updates',
});
}
},
);
app.post(
'/workspace/extensions/refresh',
mutate({ strict: true }),
async (req, res) => {
try {
if (
!validateExtensionMutationClient(
req,
res,
'POST /workspace/extensions/refresh',
)
) {
return;
}
const result = await enqueueExtensionInstall(async () =>
withExtensionTimeout(
workspace.refreshExtensionsForAllSessions(),
EXTENSION_REFRESH_TIMEOUT_MS,
'extension refresh',
),
);
res.status(200).json(result);
} catch (err) {
if (isExtensionQueueFullError(err)) {
sendExtensionQueueFull(res);
return;
}
sendBridgeError(res, err, {
route: 'POST /workspace/extensions/refresh',
});
}
},
);
app.post(
'/workspace/extensions/:name/enable',
mutate({ strict: true }),
async (req, res) => {
try {
if (
!validateExtensionMutationClient(
req,
res,
'POST /workspace/extensions/:name/enable',
)
) {
return;
}
const name = req.params['name'];
if (!name) {
res.status(400).json({ error: 'Missing extension name' });
return;
}
const scope = parseExtensionScope(safeBody(req), res);
if (scope === null) return;
runQueuedExtensionMutation(
'enable',
{ name },
res,
async (extensionManager) => {
const extension = findLoadedExtension(extensionManager, name);
if (!extension) {
throw new Error(`Extension "${name}" not found`);
}
await extensionManager.enableExtension(
extension.name,
scope,
boundWorkspace,
);
return { status: 'enabled', name: extension.name };
},
);
} catch (err) {
sendBridgeError(res, err, {
route: 'POST /workspace/extensions/:name/enable',
});
}
},
);
app.post(
'/workspace/extensions/:name/disable',
mutate({ strict: true }),
async (req, res) => {
try {
if (
!validateExtensionMutationClient(
req,
res,
'POST /workspace/extensions/:name/disable',
)
) {
return;
}
const name = req.params['name'];
if (!name) {
res.status(400).json({ error: 'Missing extension name' });
return;
}
const scope = parseExtensionScope(safeBody(req), res);
if (scope === null) return;
runQueuedExtensionMutation(
'disable',
{ name },
res,
async (extensionManager) => {
const extension = findLoadedExtension(extensionManager, name);
if (!extension) {
throw new Error(`Extension "${name}" not found`);
}
await extensionManager.disableExtension(
extension.name,
scope,
boundWorkspace,
);
return { status: 'disabled', name: extension.name };
},
);
} catch (err) {
sendBridgeError(res, err, {
route: 'POST /workspace/extensions/:name/disable',
});
}
},
);
app.post(
'/workspace/extensions/:name/update',
mutate({ strict: true }),
async (req, res) => {
try {
if (
!validateExtensionMutationClient(
req,
res,
'POST /workspace/extensions/:name/update',
)
) {
return;
}
const name = req.params['name'];
if (!name) {
res.status(400).json({ error: 'Missing extension name' });
return;
}
runQueuedExtensionMutation(
'update',
{ name },
res,
async (extensionManager) => {
const extension = findLoadedExtension(extensionManager, name);
if (!extension) {
throw new Error(`Extension "${name}" not found`);
}
let updateError: unknown;
const updateState = await withExtensionTimeout(
checkForExtensionUpdate(extension, extensionManager).catch(
(err: unknown) => {
updateError = err;
return ExtensionUpdateState.ERROR;
},
),
EXTENSION_REFRESH_TIMEOUT_MS,
'extension update check',
);
if (updateState === ExtensionUpdateState.ERROR) {
const message =
updateError === undefined
? undefined
: redactUrlCredentials(
updateError instanceof Error
? updateError.message
: String(updateError),
);
throw new Error(
`Update check failed for extension "${extension.name}"${
message ? `: ${message}` : ''
}`,
);
}
if (updateState !== ExtensionUpdateState.UPDATE_AVAILABLE) {
throw new Error(`Extension "${extension.name}" has no update`);
}
const info = await extensionManager.updateExtension(
extension,
updateState,
() => undefined,
);
return {
status: 'updated',
name: extension.name,
...(info?.updatedVersion ? { version: info.updatedVersion } : {}),
};
},
);
} catch (err) {
sendBridgeError(res, err, {
route: 'POST /workspace/extensions/:name/update',
});
}
},
);
app.delete(
'/workspace/extensions/:name',
mutate({ strict: true }),
async (req, res) => {
try {
if (
!validateExtensionMutationClient(
req,
res,
'DELETE /workspace/extensions/:name',
)
) {
return;
}
const name = req.params['name'];
if (!name) {
res.status(400).json({ error: 'Missing extension name' });
return;
}
runQueuedExtensionMutation(
'uninstall',
{ name },
res,
async (extensionManager) => {
const extension = findLoadedExtension(extensionManager, name);
if (!extension) {
throw new Error(`Extension "${name}" not found`);
}
await extensionManager.uninstallExtension(
extension.name,
false,
boundWorkspace,
);
return { status: 'uninstalled', name: extension.name };
},
);
} catch (err) {
sendBridgeError(res, err, {
route: 'DELETE /workspace/extensions/:name',
});
}
},
);
// Workspace file routes (read-only + mutation).
registerWorkspaceFileReadRoutes(app, {
parseClientId: parseClientIdHeader,

View file

@ -389,6 +389,47 @@ describe('createDaemonWorkspaceService', () => {
});
});
describe('refreshExtensionsForAllSessions', () => {
it('delegates to the all-session refresh callback', async () => {
const invokeWorkspaceCommand = vi.fn();
const refreshExtensionsForAllSessions = vi
.fn()
.mockResolvedValue({ refreshed: 2, failed: 1 });
const svc = createDaemonWorkspaceService(
makeDeps({ invokeWorkspaceCommand, refreshExtensionsForAllSessions }),
);
const result = await svc.refreshExtensionsForAllSessions();
expect(result).toEqual({ refreshed: 2, failed: 1 });
expect(refreshExtensionsForAllSessions).toHaveBeenCalledOnce();
expect(invokeWorkspaceCommand).not.toHaveBeenCalled();
});
it('returns a failed result when the refresh callback is not wired', async () => {
const svc = createDaemonWorkspaceService(makeDeps());
await expect(svc.refreshExtensionsForAllSessions()).resolves.toEqual({
refreshed: 0,
failed: 1,
});
});
it('returns a failed result when the refresh callback rejects', async () => {
const refreshExtensionsForAllSessions = vi
.fn()
.mockRejectedValue(new Error('bridge down'));
const svc = createDaemonWorkspaceService(
makeDeps({ refreshExtensionsForAllSessions }),
);
await expect(svc.refreshExtensionsForAllSessions()).resolves.toEqual({
refreshed: 0,
failed: 1,
});
});
});
describe('restartMcpServer', () => {
it('calls invokeWorkspaceCommand with correct method and params', async () => {
const invokeWorkspaceCommand = vi.fn().mockResolvedValue({

View file

@ -139,6 +139,7 @@ export function createDaemonWorkspaceService(
persistDisabledTools,
queryWorkspaceStatus,
invokeWorkspaceCommand,
refreshExtensionsForAllSessions: refreshExtensionsForAllSessionsOnBridge,
publishWorkspaceEvent,
} = deps;
@ -620,5 +621,19 @@ export function createDaemonWorkspaceService(
childError,
};
},
async refreshExtensionsForAllSessions() {
try {
if (!refreshExtensionsForAllSessionsOnBridge) {
throw new Error('refreshExtensionsForAllSessions is not wired');
}
return await refreshExtensionsForAllSessionsOnBridge();
} catch (err) {
writeStderrLine(
`qwen serve: refreshExtensionsForAllSessions failed: ${err instanceof Error ? err.message : String(err)}`,
);
return { refreshed: 0, failed: 1 };
}
},
};
}

View file

@ -76,6 +76,11 @@ export type InvokeWorkspaceCommandFn = <T>(
opts?: { timeoutMs?: number },
) => Promise<T>;
export type RefreshExtensionsForAllSessionsFn = () => Promise<{
refreshed: number;
failed: number;
}>;
/**
* The unified facade for workspace-scoped daemon operations. Routes
* delegate here instead of reaching into the bridge for workspace
@ -143,6 +148,12 @@ export interface DaemonWorkspaceService {
/** Reload all settings (env + model + permissions + tools + memory). */
reload(ctx: WorkspaceRequestContext): Promise<ReloadResponse>;
/** Broadcast extension refresh to all active sessions (fire-and-forget). */
refreshExtensionsForAllSessions(): Promise<{
refreshed: number;
failed: number;
}>;
}
// -- Result types for workspace mutations --
@ -232,6 +243,12 @@ export interface DaemonWorkspaceServiceDeps {
*/
invokeWorkspaceCommand: InvokeWorkspaceCommandFn;
/**
* Broadcast an extension refresh to every live session. This must not
* delegate to `invokeWorkspaceCommand`, which targets only one live channel.
*/
refreshExtensionsForAllSessions?: RefreshExtensionsForAllSessionsFn;
/**
* Publish a workspace-wide event to all sessions' SSE buses.
* Used after mutations that affect all connected clients.

View file

@ -25,7 +25,9 @@ const rootDir = join(__dirname, '..');
// Bumped from 116KB to 118KB for the transport abstraction layer (~1.5KB).
// Bumped from 118KB to 119KB for the mid-turn drain surface (enqueue methods +
// `mid_turn_message_injected` event type/guard/registration, ~150 bytes).
const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 119 * 1024;
// Bumped from 119KB to 122KB for the workspace extension management surface
// (install/update/enable/disable/uninstall/refresh/check update endpoints).
const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 122 * 1024;
rmSync(join(rootDir, 'dist'), { recursive: true, force: true });
mkdirSync(join(rootDir, 'dist'), { recursive: true });

View file

@ -81,6 +81,12 @@ import type {
DaemonRewindResult,
DaemonSessionHooksStatus,
DaemonWorkspaceExtensionsStatus,
ExtensionMutationResponse,
ExtensionInstallRequest,
ExtensionInstallResponse,
ExtensionScopeRequest,
ExtensionRefreshResponse,
ExtensionUpdateCheckResponse,
DaemonWorkspaceHooksStatus,
DaemonWorkspaceSettingsStatus,
DaemonSettingUpdateResult,
@ -562,6 +568,29 @@ export class DaemonClient {
return new DaemonHttpError(res.status, body, `${label}: ${detail}`);
}
private async jsonRequest<T>(
path: string,
label: string,
opts: { method?: string; body?: unknown; clientId?: string } = {},
): Promise<T> {
const hasBody = opts.body !== undefined;
return await this.fetchWithTimeout(
`${this.baseUrl}${path}`,
{
...(opts.method ? { method: opts.method } : {}),
headers: this.headers(
hasBody ? { 'Content-Type': 'application/json' } : {},
opts.clientId,
),
...(hasBody ? { body: JSON.stringify(opts.body) } : {}),
},
async (res) => {
if (!res.ok) throw await this.failOnError(res, label);
return (await res.json()) as T;
},
);
}
// -- Lifecycle / discovery ---------------------------------------------
async health(): Promise<{ status: string }> {
@ -662,14 +691,86 @@ export class DaemonClient {
}
async workspaceExtensions(): Promise<DaemonWorkspaceExtensionsStatus> {
return await this.fetchWithTimeout(
`${this.baseUrl}/workspace/extensions`,
{ headers: this.headers() },
async (res) => {
if (!res.ok)
throw await this.failOnError(res, 'GET /workspace/extensions');
return (await res.json()) as DaemonWorkspaceExtensionsStatus;
},
return await this.jsonRequest<DaemonWorkspaceExtensionsStatus>(
'/workspace/extensions',
'GET /workspace/extensions',
);
}
async installExtension(
params: ExtensionInstallRequest,
clientId?: string,
): Promise<ExtensionInstallResponse> {
return await this.jsonRequest<ExtensionInstallResponse>(
'/workspace/extensions/install',
'POST /workspace/extensions/install',
{ method: 'POST', body: params, clientId },
);
}
async checkExtensionUpdates(
clientId?: string,
): Promise<ExtensionUpdateCheckResponse> {
return await this.jsonRequest<ExtensionUpdateCheckResponse>(
'/workspace/extensions/check-updates',
'POST /workspace/extensions/check-updates',
{ method: 'POST', body: {}, clientId },
);
}
async refreshExtensions(
clientId?: string,
): Promise<ExtensionRefreshResponse> {
return await this.jsonRequest<ExtensionRefreshResponse>(
'/workspace/extensions/refresh',
'POST /workspace/extensions/refresh',
{ method: 'POST', body: {}, clientId },
);
}
async enableExtension(
name: string,
params: ExtensionScopeRequest,
clientId?: string,
): Promise<ExtensionMutationResponse> {
return await this.jsonRequest<ExtensionMutationResponse>(
`/workspace/extensions/${encodeURIComponent(name)}/enable`,
'POST /workspace/extensions/:name/enable',
{ method: 'POST', body: params, clientId },
);
}
async disableExtension(
name: string,
params: ExtensionScopeRequest,
clientId?: string,
): Promise<ExtensionMutationResponse> {
return await this.jsonRequest<ExtensionMutationResponse>(
`/workspace/extensions/${encodeURIComponent(name)}/disable`,
'POST /workspace/extensions/:name/disable',
{ method: 'POST', body: params, clientId },
);
}
async updateExtension(
name: string,
clientId?: string,
): Promise<ExtensionMutationResponse> {
return await this.jsonRequest<ExtensionMutationResponse>(
`/workspace/extensions/${encodeURIComponent(name)}/update`,
'POST /workspace/extensions/:name/update',
{ method: 'POST', body: {}, clientId },
);
}
async uninstallExtension(
name: string,
clientId?: string,
): Promise<ExtensionMutationResponse> {
return await this.jsonRequest<ExtensionMutationResponse>(
`/workspace/extensions/${encodeURIComponent(name)}`,
'DELETE /workspace/extensions/:name',
{ method: 'DELETE', clientId },
);
}

View file

@ -83,6 +83,10 @@ export const DAEMON_KNOWN_EVENT_TYPE_VALUES = [
// `DELETE /workspace/mcp/servers/:name` when an entry was actually
// removed. Idempotent skip ('not_present') does NOT emit.
'mcp_server_removed',
// Extensions lifecycle events. Fired by background extension install/refresh
// work. Carries refreshed/failed session counts, and may include install
// success/failure details.
'extensions_changed',
// Multi-client permission coordination events.
// `permission_partial_vote` only fires under `consensus` policy;
// `permission_forbidden` fires under `designated` (originator
@ -738,6 +742,28 @@ export type DaemonMcpServerRemovedEvent = DaemonEventEnvelope<
DaemonMcpServerRemovedData
>;
export interface DaemonExtensionsChangedData {
readonly refreshed: number;
readonly failed: number;
readonly status?:
| 'installed'
| 'enabled'
| 'disabled'
| 'updated'
| 'uninstalled'
| 'failed';
readonly source?: string;
readonly name?: string;
readonly version?: string;
readonly error?: string;
[key: string]: unknown;
}
export type DaemonExtensionsChangedEvent = DaemonEventEnvelope<
'extensions_changed',
DaemonExtensionsChangedData
>;
export interface DaemonSessionSnapshotData {
sessionId: string;
currentModelId: string | null;
@ -968,7 +994,8 @@ export type DaemonMcpGuardrailEvent =
*/
export type DaemonWorkspaceMutationEvent =
| DaemonMemoryChangedEvent
| DaemonAgentChangedEvent;
| DaemonAgentChangedEvent
| DaemonExtensionsChangedEvent;
/**
* Daemon assist push events non-terminal UX hints emitted by the ACP
@ -1469,6 +1496,10 @@ export function asKnownDaemonEvent(
return isMcpServerRemovedData(event.data)
? (event as DaemonMcpServerRemovedEvent)
: undefined;
case 'extensions_changed':
return isExtensionsChangedData(event.data)
? (event as DaemonExtensionsChangedEvent)
: undefined;
case 'turn_complete':
return isTurnCompleteData(event.data)
? (event as DaemonTurnCompleteEvent)
@ -1860,6 +1891,7 @@ export function reduceDaemonSessionEvent(
case 'mcp_server_added':
case 'mcp_server_removed':
case 'settings_reloaded':
case 'extensions_changed':
case MID_TURN_MESSAGE_INJECTED_EVENT:
return base;
case 'session_rewound':
@ -2634,6 +2666,38 @@ function isMcpServerRemovedData(
return true;
}
function isExtensionsChangedData(
value: unknown,
): value is DaemonExtensionsChangedData {
if (!isRecord(value)) return false;
if (typeof value['refreshed'] !== 'number') return false;
if (typeof value['failed'] !== 'number') return false;
if (
value['status'] !== undefined &&
value['status'] !== 'installed' &&
value['status'] !== 'enabled' &&
value['status'] !== 'disabled' &&
value['status'] !== 'updated' &&
value['status'] !== 'uninstalled' &&
value['status'] !== 'failed'
) {
return false;
}
if (value['source'] !== undefined && typeof value['source'] !== 'string') {
return false;
}
if (value['name'] !== undefined && typeof value['name'] !== 'string') {
return false;
}
if (value['version'] !== undefined && typeof value['version'] !== 'string') {
return false;
}
if (value['error'] !== undefined && typeof value['error'] !== 'string') {
return false;
}
return true;
}
function isSessionBranchedData(
value: unknown,
): value is DaemonSessionBranchedData {

View file

@ -413,8 +413,17 @@ export type {
DaemonExtensionInstallType,
DaemonExtensionOriginSource,
DaemonExtensionCapabilities,
DaemonExtensionDetails,
DaemonExtensionEntry,
DaemonExtensionUpdateState,
DaemonWorkspaceExtensionsStatus,
ExtensionInstallRequest,
ExtensionInstallResponse,
ExtensionMutationResponse,
ExtensionRefreshResponse,
ExtensionScope,
ExtensionScopeRequest,
ExtensionUpdateCheckResponse,
HeartbeatResult,
MCPServerConfigShape,
PermissionOutcome,

View file

@ -1685,10 +1685,31 @@ export interface DaemonExtensionCapabilities {
hasSettings: boolean;
}
export type DaemonExtensionUpdateState =
| 'checking for updates'
| 'updated, needs restart'
| 'updating'
| 'updated'
| 'update available'
| 'up to date'
| 'error'
| 'not updatable'
| 'unknown';
export interface DaemonExtensionDetails {
mcpServers: string[];
commands: string[];
skills: string[];
agents: string[];
contextFiles: string[];
settings: string[];
}
export interface DaemonExtensionEntry {
kind: 'extension';
id: string;
name: string;
displayName?: string;
version: string;
isActive: boolean;
path: string;
@ -1697,7 +1718,9 @@ export interface DaemonExtensionEntry {
originSource?: DaemonExtensionOriginSource;
ref?: string;
autoUpdate?: boolean;
updateState?: DaemonExtensionUpdateState;
capabilities: DaemonExtensionCapabilities;
details?: DaemonExtensionDetails;
}
export interface DaemonWorkspaceExtensionsStatus {
@ -1707,3 +1730,33 @@ export interface DaemonWorkspaceExtensionsStatus {
extensions: DaemonExtensionEntry[];
errors?: DaemonStatusCell[];
}
export interface ExtensionInstallRequest {
source: string;
ref?: string;
autoUpdate?: boolean;
allowPreRelease?: boolean;
registry?: string;
consent?: boolean;
}
export interface ExtensionInstallResponse {
accepted: true;
}
export type ExtensionMutationResponse = ExtensionInstallResponse;
export type ExtensionScope = 'user' | 'workspace';
export interface ExtensionScopeRequest {
scope: ExtensionScope;
}
export interface ExtensionUpdateCheckResponse {
states: Record<string, DaemonExtensionUpdateState>;
}
export interface ExtensionRefreshResponse {
refreshed: number;
failed: number;
}

View file

@ -267,6 +267,9 @@ export function normalizeDaemonEvent(
case 'mcp_server_restart_refused':
return normalizeMcpServerRestartRefused(event, base);
case 'extensions_changed':
return normalizeExtensionsChanged(event, base);
// ── Auth device-flow events (RFC 8628) ─────────────────
case 'auth_device_flow_started':
return normalizeAuthDeviceFlowStarted(event, base);
@ -1252,6 +1255,46 @@ function normalizeMcpServerRestartRefused(
];
}
function normalizeExtensionsChanged(
event: DaemonEvent,
base: NormalizedEventBase,
): DaemonUiEvent[] {
const refreshed = numberField(event.data, 'refreshed');
const failed = numberField(event.data, 'failed');
const status = getString(event.data, 'status');
const source = getString(event.data, 'source');
const name = getString(event.data, 'name');
const version = getString(event.data, 'version');
const error = getString(event.data, 'error');
if (refreshed === undefined || failed === undefined) {
return fallbackDebug(event, base, 'malformed extensions_changed payload');
}
if (
status !== undefined &&
status !== 'installed' &&
status !== 'enabled' &&
status !== 'disabled' &&
status !== 'updated' &&
status !== 'uninstalled' &&
status !== 'failed'
) {
return fallbackDebug(event, base, 'malformed extensions_changed payload');
}
return [
{
...base,
type: 'workspace.extensions.changed',
refreshed,
failed,
...(status ? { status } : {}),
...(source ? { source } : {}),
...(name ? { name } : {}),
...(version ? { version } : {}),
...(error ? { error } : {}),
},
];
}
function normalizeAuthDeviceFlowStarted(
event: DaemonEvent,
base: NormalizedEventBase,

View file

@ -144,6 +144,34 @@ export function daemonUiEventToTerminalText(event: DaemonUiEvent): string {
`${event.serverName} restart refused: ${event.reason}`,
'33',
);
case 'workspace.extensions.changed':
if (event.status === 'failed') {
return terminalLine(
'ext',
`extension action failed${
event.name
? ` ${event.name}`
: event.source
? ` ${event.source}`
: ''
}: ${event.error ?? 'unknown error'}`,
'31',
);
}
if (event.status === 'installed') {
return terminalLine(
'ext',
`installed ${event.name ?? event.source ?? 'extension'}${
event.version ? ` v${event.version}` : ''
} (${event.refreshed} refreshed, ${event.failed} failed)`,
'36',
);
}
return terminalLine(
'ext',
`extensions refreshed (${event.refreshed} ok, ${event.failed} failed)`,
'36',
);
case 'auth.device_flow.started':
return terminalLine(
'auth',

View file

@ -52,6 +52,7 @@ export type DaemonUiEventType =
| 'workspace.mcp.child_refused'
| 'workspace.mcp.server_restarted'
| 'workspace.mcp.server_restart_refused'
| 'workspace.extensions.changed'
// Auth flow events (Wave 4 OAuth)
| 'auth.device_flow.started'
| 'auth.device_flow.throttled'
@ -431,6 +432,23 @@ export interface DaemonUiMcpServerRestartRefusedEvent
reason: 'in_flight' | 'disabled' | 'budget_would_exceed';
}
export interface DaemonUiExtensionsChangedEvent extends DaemonUiEventBase {
type: 'workspace.extensions.changed';
refreshed: number;
failed: number;
status?:
| 'installed'
| 'enabled'
| 'disabled'
| 'updated'
| 'uninstalled'
| 'failed';
source?: string;
name?: string;
version?: string;
error?: string;
}
/*
* Auth device-flow events (Wave 4 OAuth, RFC 8628)
* */
@ -513,6 +531,7 @@ export type DaemonUiEvent =
| DaemonUiMcpChildRefusedEvent
| DaemonUiMcpServerRestartedEvent
| DaemonUiMcpServerRestartRefusedEvent
| DaemonUiExtensionsChangedEvent
// Auth device-flow events
| DaemonUiAuthDeviceFlowEvent;

View file

@ -1543,6 +1543,21 @@ describe('daemon UI normalizer and transcript reducer', () => {
expect(output).not.toContain('\x00');
});
it('renders extension failures without assuming install failed', () => {
const output = daemonUiEventToTerminalText({
type: 'workspace.extensions.changed',
refreshed: 0,
failed: 0,
status: 'failed',
name: 'test-extension',
error: 'Extension mutation failed',
});
expect(output).toContain('extension action failed test-extension');
expect(output).toContain('Extension mutation failed');
expect(output).not.toContain('install failed');
});
it('strips terminal control and bidi spoofing sequences', () => {
const output = sanitizeTerminalText(
'\u202etxt.exe\u001b[31mred\roverwrite\u001bPhidden\u001b\\ok',
@ -2058,6 +2073,60 @@ describe('daemon UI normalizer — Wave 3/4 event coverage (PR-A)', () => {
});
});
it('normalizes extension install lifecycle details', () => {
const installed = normalizeDaemonEvent(
envelopeOf('extensions_changed', {
refreshed: 1,
failed: 0,
status: 'installed',
source: 'owner/repo',
name: 'test-extension',
version: '1.2.3',
}),
);
expect(installed[0]).toMatchObject({
type: 'workspace.extensions.changed',
refreshed: 1,
failed: 0,
status: 'installed',
source: 'owner/repo',
name: 'test-extension',
version: '1.2.3',
});
const updated = normalizeDaemonEvent(
envelopeOf('extensions_changed', {
refreshed: 1,
failed: 0,
status: 'updated',
name: 'test-extension',
version: '1.2.4',
}),
);
expect(updated[0]).toMatchObject({
type: 'workspace.extensions.changed',
status: 'updated',
name: 'test-extension',
version: '1.2.4',
});
const failed = normalizeDaemonEvent(
envelopeOf('extensions_changed', {
refreshed: 0,
failed: 0,
status: 'failed',
source: 'owner/repo',
error: 'install failed',
}),
);
expect(failed[0]).toMatchObject({
type: 'workspace.extensions.changed',
status: 'failed',
source: 'owner/repo',
error: 'install failed',
});
});
it('normalizes auth_device_flow lifecycle (started → throttled → authorized)', () => {
const started = normalizeDaemonEvent(
envelopeOf('auth_device_flow_started', {

View file

@ -18,6 +18,7 @@ import {
useTranscriptBlocks,
useTranscriptStore,
useWorkspaceActions,
useWorkspaceEventSignals,
type DaemonSessionNotice,
type DaemonStreamingState,
} from '@qwen-code/webui/daemon-react-sdk';
@ -65,6 +66,7 @@ import {
AuthMessage,
} from './components/messages/AuthMessage';
import { ToolsDialog } from './components/dialogs/ToolsDialog';
import { ExtensionsDialog } from './components/dialogs/ExtensionsDialog';
import {
SETTINGS_ACTIVE_EVENT,
SettingsMessage,
@ -1005,11 +1007,78 @@ export function App({
const [showHelpDialog, setShowHelpDialog] = useState(false);
const [showThemeDialog, setShowThemeDialog] = useState(false);
const [showToolsDialog, setShowToolsDialog] = useState(false);
const [showExtensionsDialog, setShowExtensionsDialog] = useState(false);
const [settingsInlineOpen, setSettingsInlineOpen] = useState(false);
const [memoryInlineOpen, setMemoryInlineOpen] = useState(false);
const [authInlineOpen, setAuthInlineOpen] = useState(false);
const [memoryRefreshSignal, setMemoryRefreshSignal] = useState(0);
const [memoryAddSignal, setMemoryAddSignal] = useState(0);
// Refresh commands when extensions change (install/uninstall/update).
const workspaceEventSignals = useWorkspaceEventSignals();
const extensionsVersionRef = useRef(
workspaceEventSignals?.extensionsVersion ?? 0,
);
useEffect(() => {
const current = workspaceEventSignals?.extensionsVersion ?? 0;
if (current !== extensionsVersionRef.current) {
extensionsVersionRef.current = current;
const change = workspaceEventSignals?.lastExtensionChange;
if (change?.status === 'failed') {
store.dispatch([
{
type: 'error',
text: t('extensions.action.failed', {
name: change.name ?? '',
source: change.source ?? '',
error: change.error ?? t('error.unknown'),
}),
},
]);
return;
}
if (change?.status === 'installed') {
const name = change.name ?? change.source ?? t('extensions.label');
store.dispatch([
{
type: 'status',
text: change.version
? t('extensions.install.installedWithVersion', {
name,
version: change.version,
})
: t('extensions.install.installed', { name }),
},
]);
} else if (change?.status) {
const name = change.name ?? change.source ?? t('extensions.label');
const key =
change.status === 'updated' && change.version
? 'extensions.manage.updatedWithVersion'
: `extensions.manage.${change.status}`;
store.dispatch([
{
type: 'status',
text: t(key, { name, version: change.version ?? '' }),
},
]);
}
sessionActions.refreshCommands().catch(() => {
store.dispatch([
{
type: 'error',
text: t('extensions.commands.refreshFailed'),
},
]);
});
}
}, [
workspaceEventSignals?.extensionsVersion,
workspaceEventSignals?.lastExtensionChange,
sessionActions,
store,
t,
]);
const [memoryAddScope, setMemoryAddScope] = useState<'workspace' | 'global'>(
'workspace',
);
@ -1051,7 +1120,8 @@ export function App({
showReleaseDialog ||
showHelpDialog ||
showThemeDialog ||
showToolsDialog;
showToolsDialog ||
showExtensionsDialog;
const inlinePanelOpen =
approvalModeInlineOpen ||
authInlineOpen ||
@ -2245,6 +2315,130 @@ export function App({
setAgentsInlineMode(agentsMode);
return true;
}
if (cmd === 'extensions') {
const args = text.slice(match[0].length).trim();
const subCommand = args.split(/\s+/)[0]?.toLowerCase();
if (!subCommand || subCommand === 'manage') {
store.appendLocalUserMessage(text);
setShowExtensionsDialog(true);
return true;
}
if (subCommand === 'install') {
const tokens = args.slice('install'.length).trim().split(/\s+/);
let source = '';
let ref: string | undefined;
let registry: string | undefined;
let autoUpdate: boolean | undefined;
let allowPreRelease: boolean | undefined;
let parseError: string | null = null;
for (let index = 0; index < tokens.length; index++) {
const token = tokens[index];
if (!token) continue;
if (token === '--auto-update') {
autoUpdate = true;
} else if (
token === '--pre-release' ||
token === '--allow-pre-release'
) {
allowPreRelease = true;
} else if (token === '--ref' || token === '--registry') {
const value = tokens[index + 1];
if (!value || value.startsWith('--')) {
parseError = t('extensions.install.missingOptionValue', {
option: token,
});
break;
}
if (token === '--ref') {
ref = value;
} else {
registry = value;
}
index += 1;
} else if (token.startsWith('--')) {
parseError = t('extensions.install.unknownOption', {
option: token,
});
break;
} else if (!source) {
source = token;
} else {
parseError = t('extensions.install.usage');
break;
}
}
if (parseError) {
store.appendLocalUserMessage(text);
store.dispatch([{ type: 'error', text: parseError }]);
return true;
}
if (!source) {
store.appendLocalUserMessage(text);
store.dispatch([
{
type: 'error',
text: t('extensions.install.usage'),
},
]);
return true;
}
if (promptBlocked) {
store.appendLocalUserMessage(text);
store.dispatch([
{
type: 'error',
text: t('extensions.install.waitForTurn'),
},
]);
return true;
}
const clientId = connectionRef.current.clientId;
if (!clientId) {
store.appendLocalUserMessage(text);
store.dispatch([
{
type: 'error',
text: t('extensions.install.waitForSession'),
},
]);
return true;
}
store.appendLocalUserMessage(text);
store.dispatch([
{
type: 'status',
text: t('extensions.install.started', { source }),
},
]);
workspaceActions
.installExtension(
{
source,
...(ref ? { ref } : {}),
...(registry ? { registry } : {}),
...(autoUpdate !== undefined ? { autoUpdate } : {}),
...(allowPreRelease !== undefined
? { allowPreRelease }
: {}),
consent: true,
},
clientId,
)
.then(() => undefined)
.catch((error: unknown) => {
reportError(error, t('extensions.install.requestFailed'));
});
return true;
}
store.appendLocalUserMessage(text);
store.dispatch([
{
type: 'error',
text: t('extensions.install.usage'),
},
]);
return true;
}
if (cmd === 'clear') {
sessionActions.newSession().catch((error: unknown) => {
reportError(error, 'Failed to create a new session');
@ -2865,6 +3059,11 @@ export function App({
{showToolsDialog && (
<ToolsDialog onClose={() => setShowToolsDialog(false)} />
)}
{showExtensionsDialog && (
<ExtensionsDialog
onClose={() => setShowExtensionsDialog(false)}
/>
)}
</div>
)}

View file

@ -55,6 +55,10 @@ const SUBCOMMAND_TREE_ZH: Record<string, SubcommandNode[]> = {
},
{ name: 'output', description: '设置 LLM 输出语言' },
],
extensions: [
{ name: 'manage', description: '管理扩展' },
{ name: 'install', description: '安装扩展' },
],
};
const SUBCOMMAND_TREE_EN: Record<string, SubcommandNode[]> = {
@ -83,6 +87,10 @@ const SUBCOMMAND_TREE_EN: Record<string, SubcommandNode[]> = {
},
{ name: 'output', description: 'Set LLM output language' },
],
extensions: [
{ name: 'manage', description: 'Manage installed extensions' },
{ name: 'install', description: 'Install an extension from a source' },
],
};
const IMPLICIT_SUBCOMMAND_TREE_ZH: Record<string, SubcommandNode[]> = {

View file

@ -0,0 +1,574 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
useConnection,
useWorkspaceActions,
useWorkspaceEventSignals,
} from '@qwen-code/webui/daemon-react-sdk';
import type {
DaemonExtensionEntry,
DaemonExtensionUpdateState,
} from '@qwen-code/sdk/daemon';
import { useDelayedGlobalKeyDown } from '../../hooks/useDelayedGlobalKeyDown';
import { useI18n } from '../../i18n';
import { dp } from './dialogStyles';
interface ExtensionsDialogProps {
onClose: () => void;
}
type View = 'list' | 'actions' | 'details' | 'scope' | 'uninstall';
type Scope = 'user' | 'workspace';
type Mutation = 'enable' | 'disable';
interface ExtensionAction {
label: string;
hint?: string;
disabled?: boolean;
run: () => void;
}
const UPDATE_AVAILABLE: DaemonExtensionUpdateState = 'update available';
function statusLabel(
extension: DaemonExtensionEntry,
t: ReturnType<typeof useI18n>['t'],
): string {
return extension.isActive
? t('extensions.manage.status.enabled')
: t('extensions.manage.status.disabled');
}
function updateLabel(
state: DaemonExtensionUpdateState | undefined,
t: ReturnType<typeof useI18n>['t'],
): string {
switch (state) {
case 'update available':
return t('extensions.manage.updateAvailable');
case 'up to date':
return t('extensions.manage.upToDate');
case 'not updatable':
return t('extensions.manage.notUpdatable');
case 'checking for updates':
return t('extensions.manage.checkingUpdates');
case 'error':
return t('extensions.manage.updateError');
default:
return t('extensions.manage.unknownUpdate');
}
}
function joinList(values: string[] | undefined): string {
return values && values.length > 0 ? values.join(', ') : '-';
}
export function ExtensionsDialog({ onClose }: ExtensionsDialogProps) {
const { t } = useI18n();
const connection = useConnection();
const actions = useWorkspaceActions();
const signals = useWorkspaceEventSignals();
const [view, setView] = useState<View>('list');
const [selectedIdx, setSelectedIdx] = useState(0);
const [actionIdx, setActionIdx] = useState(0);
const [scopeIdx, setScopeIdx] = useState(0);
const [scopeMutation, setScopeMutation] = useState<Mutation>('disable');
const [extensions, setExtensions] = useState<DaemonExtensionEntry[]>([]);
const [updateStates, setUpdateStates] = useState<
Record<string, DaemonExtensionUpdateState>
>({});
const [loading, setLoading] = useState(false);
const [checking, setChecking] = useState(false);
const [busyName, setBusyName] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const listRef = useRef<HTMLDivElement>(null);
const selected = extensions[selectedIdx];
const load = useCallback(() => {
setLoading(true);
return actions
.loadExtensionsStatus()
.then((status) => {
setExtensions(status.extensions ?? []);
setMessage(status.errors?.[0]?.error ?? null);
})
.catch((error: unknown) => {
setMessage(error instanceof Error ? error.message : String(error));
})
.finally(() => setLoading(false));
}, [actions]);
const checkUpdates = useCallback(() => {
const clientId = connection.clientId;
if (!clientId) {
setMessage(t('extensions.install.waitForSession'));
return Promise.resolve();
}
setChecking(true);
return actions
.checkExtensionUpdates(clientId)
.then((result) => setUpdateStates(result.states))
.catch((error: unknown) => {
setMessage(error instanceof Error ? error.message : String(error));
})
.finally(() => setChecking(false));
}, [actions, connection.clientId, t]);
const refreshSessions = useCallback(() => {
const clientId = connection.clientId;
if (!clientId) {
setMessage(t('extensions.install.waitForSession'));
return;
}
setChecking(true);
actions
.refreshExtensions(clientId)
.then(async (result) => {
setMessage(
t('extensions.manage.refreshed', {
refreshed: result.refreshed,
failed: result.failed,
}),
);
await load();
await checkUpdates();
})
.catch((error: unknown) => {
setMessage(error instanceof Error ? error.message : String(error));
})
.finally(() => setChecking(false));
}, [actions, checkUpdates, connection.clientId, load, t]);
useEffect(() => {
load();
}, [load]);
useEffect(() => {
if (extensions.length > 0) checkUpdates();
}, [checkUpdates, extensions.length]);
useEffect(() => {
if ((signals?.extensionsVersion ?? 0) > 0) {
setUpdateStates({});
load();
}
}, [load, signals?.extensionsVersion]);
useEffect(() => {
if (selectedIdx >= extensions.length && extensions.length > 0) {
setSelectedIdx(extensions.length - 1);
}
}, [extensions.length, selectedIdx]);
useEffect(() => {
const activeIndex =
view === 'actions'
? actionIdx
: view === 'scope'
? scopeIdx
: selectedIdx;
const el = listRef.current?.children[activeIndex] as
| HTMLElement
| undefined;
el?.scrollIntoView({ block: 'nearest' });
}, [actionIdx, scopeIdx, selectedIdx, view]);
const runMutation = useCallback(
(run: (clientId: string) => Promise<unknown>, name: string) => {
const clientId = connection.clientId;
if (!clientId) {
setMessage(t('extensions.install.waitForSession'));
return;
}
setBusyName(name);
setMessage(null);
run(clientId)
.then(() => setMessage(t('extensions.manage.queued', { name })))
.catch((error: unknown) => {
setMessage(error instanceof Error ? error.message : String(error));
})
.finally(() => setBusyName(null));
},
[connection.clientId, t],
);
const actionsForSelected: ExtensionAction[] = useMemo(() => {
if (!selected) return [];
const state = updateStates[selected.name] ?? selected.updateState;
const items: ExtensionAction[] = [
{
label: t('extensions.manage.viewDetails'),
run: () => setView('details'),
},
];
items.push({
label: t('extensions.manage.update'),
hint: updateLabel(state, t),
disabled: state !== UPDATE_AVAILABLE || busyName === selected.name,
run: () =>
runMutation(
(clientId) => actions.updateExtension(selected.name, clientId),
selected.name,
),
});
items.push({
label: selected.isActive
? t('extensions.manage.disable')
: t('extensions.manage.enable'),
disabled: busyName === selected.name,
run: () => {
setScopeMutation(selected.isActive ? 'disable' : 'enable');
setScopeIdx(0);
setView('scope');
},
});
items.push({
label: t('extensions.manage.uninstallAction'),
disabled: busyName === selected.name,
run: () => setView('uninstall'),
});
return items;
}, [actions, busyName, runMutation, selected, t, updateStates]);
useEffect(() => {
if (actionIdx >= actionsForSelected.length && actionsForSelected.length > 0)
setActionIdx(actionsForSelected.length - 1);
}, [actionIdx, actionsForSelected.length]);
const selectScope = useCallback(
(scope: Scope) => {
if (!selected) return;
runMutation(
(clientId) =>
scopeMutation === 'enable'
? actions.enableExtension(selected.name, { scope }, clientId)
: actions.disableExtension(selected.name, { scope }, clientId),
selected.name,
);
setView('actions');
},
[actions, runMutation, scopeMutation, selected],
);
useDelayedGlobalKeyDown(
(e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
if (view === 'list') onClose();
else if (view === 'actions') setView('list');
else if (view === 'details') setView('actions');
else if (view === 'scope') setView('actions');
else if (view === 'uninstall') setView('actions');
return;
}
if (e.key === 'ArrowDown' || e.key === 'j') {
e.preventDefault();
if (view === 'list')
setSelectedIdx((i) =>
Math.min(i + 1, Math.max(extensions.length - 1, 0)),
);
else if (view === 'actions')
setActionIdx((i) =>
Math.min(i + 1, Math.max(actionsForSelected.length - 1, 0)),
);
else if (view === 'scope') setScopeIdx((i) => Math.min(i + 1, 1));
return;
}
if (e.key === 'ArrowUp' || e.key === 'k') {
e.preventDefault();
if (view === 'list') setSelectedIdx((i) => Math.max(i - 1, 0));
else if (view === 'actions') setActionIdx((i) => Math.max(i - 1, 0));
else if (view === 'scope') setScopeIdx((i) => Math.max(i - 1, 0));
return;
}
if (e.key === 'Enter') {
e.preventDefault();
if (view === 'list' && selected) {
setView('actions');
setActionIdx(0);
} else if (view === 'actions') {
const action = actionsForSelected[actionIdx];
if (action && !action.disabled) action.run();
} else if (view === 'scope') {
selectScope(scopeIdx === 0 ? 'user' : 'workspace');
} else if (view === 'uninstall' && selected) {
runMutation(
(clientId) => actions.uninstallExtension(selected.name, clientId),
selected.name,
);
setView('list');
}
return;
}
if (e.key === 'r') {
e.preventDefault();
if (view === 'list') {
refreshSessions();
}
}
},
[
actionIdx,
actions,
actionsForSelected,
checkUpdates,
extensions.length,
load,
onClose,
refreshSessions,
runMutation,
scopeIdx,
selectScope,
selected,
view,
],
);
const title =
view === 'list'
? t('extensions.manage.title')
: view === 'details'
? t('extensions.manage.detailsTitle')
: (selected?.name ?? t('extensions.manage.title'));
const footer =
view === 'list'
? t('extensions.manage.footer.list')
: view === 'details'
? t('extensions.manage.footer.back')
: view === 'uninstall'
? t('extensions.manage.footer.confirm')
: t('extensions.manage.footer.select');
return (
<div className={dp('resume-picker')}>
<div className={dp('resume-picker-header')}>
<span className={dp('resume-picker-title')}>{title}</span>
{view === 'list' && (
<span className={dp('resume-picker-count')}>
{t('extensions.manage.count', { count: extensions.length })}
</span>
)}
<button
className={dp('resume-picker-close')}
onClick={onClose}
title={t('common.close')}
>
ESC
</button>
</div>
<div className={dp('resume-picker-search')}>
<span className={dp('resume-picker-search-hint')}>
{message ||
(loading
? t('extensions.manage.loading')
: checking
? t('extensions.manage.checkingUpdates')
: t('common.enterSelect'))}
</span>
</div>
<div className={dp('resume-picker-sep')} />
{view === 'list' && (
<div className={dp('resume-picker-list')} ref={listRef}>
{!loading && extensions.length === 0 && (
<div className={dp('resume-picker-empty')}>
{t('extensions.manage.empty')}
</div>
)}
{extensions.map((extension, i) => {
const state = updateStates[extension.name] ?? extension.updateState;
return (
<div
key={extension.id || extension.name}
className={dp(
'resume-picker-item',
i === selectedIdx ? 'selected' : undefined,
)}
onClick={() => {
setSelectedIdx(i);
setView('actions');
setActionIdx(0);
}}
onMouseEnter={() => setSelectedIdx(i)}
>
<div className={dp('resume-picker-item-row')}>
<span className={dp('resume-picker-item-prefix')}>
{i === selectedIdx ? '' : ' '}
</span>
<span className={dp('resume-picker-item-title')}>
{extension.name}
</span>
<span className={dp('resume-picker-item-badge')}>
v{extension.version}
</span>
<span className={dp('resume-picker-item-badge')}>
{statusLabel(extension, t)}
</span>
</div>
<div className={dp('resume-picker-item-meta')}>
{updateLabel(state, t)}
</div>
</div>
);
})}
</div>
)}
{view === 'actions' && selected && (
<div className={dp('resume-picker-list')} ref={listRef}>
<div className={dp('dialog-detail')}>
<div>
{t('extensions.manage.version')} {selected.version}
</div>
<div>
{t('extensions.manage.status')} {statusLabel(selected, t)}
</div>
<div>
{t('extensions.manage.source')} {selected.source ?? '-'}
</div>
</div>
{actionsForSelected.map((action, i) => (
<div
key={action.label}
className={dp(
'resume-picker-item',
i === actionIdx ? 'selected' : undefined,
action.disabled ? 'disabled' : undefined,
)}
onClick={() => {
if (!action.disabled) action.run();
}}
onMouseEnter={() => setActionIdx(i)}
>
<span className={dp('resume-picker-item-prefix')}>
{i === actionIdx ? '' : ' '}
</span>
<span className={dp('resume-picker-item-title')}>
{action.label}
</span>
{action.hint && (
<span className={dp('resume-picker-item-badge')}>
{action.hint}
</span>
)}
</div>
))}
</div>
)}
{view === 'details' && selected && (
<ExtensionDetails extension={selected} />
)}
{view === 'scope' && selected && (
<div className={dp('resume-picker-list')} ref={listRef}>
{(['user', 'workspace'] as const).map((scope, i) => (
<div
key={scope}
className={dp(
'resume-picker-item',
i === scopeIdx ? 'selected' : undefined,
)}
onClick={() => selectScope(scope)}
onMouseEnter={() => setScopeIdx(i)}
>
<span className={dp('resume-picker-item-prefix')}>
{i === scopeIdx ? '' : ' '}
</span>
<span className={dp('resume-picker-item-title')}>
{scope === 'user'
? t('settings.scope.user')
: t('settings.scope.workspace')}
</span>
</div>
))}
</div>
)}
{view === 'uninstall' && selected && (
<div className={dp('dialog-detail')}>
<div>
{t('extensions.manage.uninstallConfirm', { name: selected.name })}
</div>
<button
className={dp('dialog-danger-button')}
disabled={busyName === selected.name}
onClick={() => {
runMutation(
(clientId) =>
actions.uninstallExtension(selected.name, clientId),
selected.name,
);
setView('list');
}}
>
{t('extensions.manage.uninstallAction')}
</button>
</div>
)}
<div className={dp('resume-picker-sep')} />
<div className={dp('resume-picker-footer')}>{footer}</div>
</div>
);
}
function ExtensionDetails({ extension }: { extension: DaemonExtensionEntry }) {
const { t } = useI18n();
const details = extension.details;
return (
<div className={dp('resume-picker-list')}>
<div className={dp('resume-picker-detail-panel')}>
<Detail label={t('extensions.manage.name')} value={extension.name} />
<Detail
label={t('extensions.manage.version')}
value={extension.version}
/>
<Detail
label={t('extensions.manage.status')}
value={statusLabel(extension, t)}
/>
<Detail label={t('extensions.manage.path')} value={extension.path} />
<Detail
label={t('extensions.manage.source')}
value={extension.source ?? '-'}
/>
<Detail
label={t('extensions.manage.commands')}
value={joinList(details?.commands)}
/>
<Detail
label={t('extensions.manage.skills')}
value={joinList(details?.skills)}
/>
<Detail
label={t('extensions.manage.agents')}
value={joinList(details?.agents)}
/>
<Detail
label={t('extensions.manage.mcpServers')}
value={joinList(details?.mcpServers)}
/>
<Detail
label={t('extensions.manage.contextFiles')}
value={joinList(details?.contextFiles)}
/>
<Detail
label={t('extensions.manage.settings')}
value={joinList(details?.settings)}
/>
</div>
</div>
);
}
function Detail({ label, value }: { label: string; value: string }) {
return (
<div className={dp('resume-picker-detail-row')}>
<span className={dp('resume-picker-detail-label')}>{label}</span>
<span className={dp('resume-picker-detail-value')}>{value}</span>
</div>
);
}

View file

@ -97,6 +97,12 @@ export function getLocalCommands(t: Translate): CommandInfo[] {
argumentHint: '<session-id>',
},
{ name: 'settings', description: t('local.settings') },
{
name: 'extensions',
description: t('local.extensions'),
argumentHint: 'manage|install <source>',
subcommands: ['manage', 'install'],
},
];
return commands.map((command) => ({
...command,

View file

@ -355,6 +355,7 @@ const EN: Messages = {
'bug.popupBlocked': 'Popup blocked — please allow popups and try again.',
'bug.submitted': 'Bug report opened in a new tab.',
'clear.blocked': 'Cannot clear while streaming — cancel first (Esc).',
'error.unknown': 'Unknown error',
'shell.command': 'Shell Command',
'compact.enabled': 'Compact mode enabled',
'compact.disabled': 'Compact mode disabled',
@ -457,6 +458,84 @@ const EN: Messages = {
'local.status': 'Show version info',
'local.theme': 'Change theme',
'local.settings': 'View and edit settings',
'local.extensions':
'Manage extensions. Usage: /extensions manage|install <source>',
'extensions.label': 'extension',
'extensions.action.failed': (v) =>
`Extension action failed${
v?.name ? ` for "${v.name}"` : v?.source ? ` from "${v.source}"` : ''
}: ${v?.error ?? 'Unknown error'}`,
'extensions.commands.refreshFailed': 'Failed to refresh extension commands.',
'extensions.install.failed': (v) =>
`Failed to install extension${
v?.source ? ` from "${v.source}"` : ''
}: ${v?.error ?? 'Unknown error'}`,
'extensions.manage.agents': 'Agents:',
'extensions.manage.checkingUpdates': 'Checking for updates...',
'extensions.manage.commands': 'Commands:',
'extensions.manage.contextFiles': 'Context files:',
'extensions.manage.count': (v) => `${v?.count ?? 0} extensions installed`,
'extensions.manage.detailsTitle': 'Extension Details',
'extensions.manage.disable': 'Disable Extension',
'extensions.manage.disabled': (v) =>
`Extension "${v?.name ?? 'extension'}" disabled.`,
'extensions.manage.empty': 'No extensions installed.',
'extensions.manage.enable': 'Enable Extension',
'extensions.manage.enabled': (v) =>
`Extension "${v?.name ?? 'extension'}" enabled.`,
'extensions.manage.footer.back': 'Esc to go back',
'extensions.manage.footer.confirm': 'Enter confirm · Esc cancel',
'extensions.manage.footer.list':
'↑↓ to navigate · Enter select · r refresh · Esc close',
'extensions.manage.footer.select': '↑↓ to navigate · Enter select · Esc back',
'extensions.manage.loading': 'Loading extensions...',
'extensions.manage.mcpServers': 'MCP servers:',
'extensions.manage.name': 'Name:',
'extensions.manage.notUpdatable': 'not updatable',
'extensions.manage.path': 'Path:',
'extensions.manage.queued': (v) =>
`Extension action queued for "${v?.name ?? 'extension'}".`,
'extensions.manage.refreshed': (v) =>
`Extensions refreshed in ${v?.refreshed ?? 0} session(s), ${v?.failed ?? 0} failed.`,
'extensions.manage.settings': 'Settings:',
'extensions.manage.skills': 'Skills:',
'extensions.manage.source': 'Source:',
'extensions.manage.status': 'Status:',
'extensions.manage.status.disabled': 'disabled',
'extensions.manage.status.enabled': 'enabled',
'extensions.manage.title': 'Manage Extensions',
'extensions.manage.unknownUpdate': 'unknown',
'extensions.manage.uninstalled': (v) =>
`Extension "${v?.name ?? 'extension'}" uninstalled.`,
'extensions.manage.uninstallAction': 'Uninstall Extension',
'extensions.manage.uninstallConfirm': (v) =>
`Uninstall extension "${v?.name ?? 'extension'}"?`,
'extensions.manage.upToDate': 'up to date',
'extensions.manage.update': 'Update Extension',
'extensions.manage.updateAvailable': 'update available',
'extensions.manage.updateError': 'update check failed',
'extensions.manage.updated': (v) =>
`Extension "${v?.name ?? 'extension'}" updated.`,
'extensions.manage.updatedWithVersion': (v) =>
`Extension "${v?.name ?? 'extension'}" updated to v${v?.version ?? ''}.`,
'extensions.manage.version': 'Version:',
'extensions.manage.viewDetails': 'View Details',
'extensions.install.installed': (v) =>
`Extension "${v?.name ?? 'extension'}" installed.`,
'extensions.install.installedWithVersion': (v) =>
`Extension "${v?.name ?? 'extension'}" v${v?.version ?? ''} installed.`,
'extensions.install.missingOptionValue': (v) =>
`Missing value for ${v?.option ?? 'option'}`,
'extensions.install.requestFailed': 'Failed to install extension',
'extensions.install.started': (v) =>
`Installing extension from "${v?.source ?? ''}"...`,
'extensions.install.unknownOption': (v) =>
`Unknown option ${v?.option ?? ''}`,
'extensions.install.usage': 'Usage: /extensions manage|install <source>',
'extensions.install.waitForSession':
'Wait for the session to connect before installing an extension.',
'extensions.install.waitForTurn':
'Wait for the current turn to finish before installing an extension.',
'local.tools': 'List available tools. Usage: /tools [desc]',
'loadWarning.commands':
'Failed to load command list; slash commands may be incomplete.',
@ -1249,6 +1328,7 @@ const ZH: Messages = {
'bug.popupBlocked': '弹窗被拦截,请允许弹窗后重试。',
'bug.submitted': 'Bug 报告已在新标签页中打开。',
'clear.blocked': '流式输出中无法清屏 — 先按 Esc 取消。',
'error.unknown': '未知错误',
'shell.command': 'Shell 命令',
'compact.enabled': '紧凑模式已开启',
'compact.disabled': '紧凑模式已关闭',
@ -1347,6 +1427,75 @@ const ZH: Messages = {
'local.status': '查看版本信息',
'local.theme': '切换主题',
'local.settings': '查看和编辑设置',
'local.extensions': '管理扩展。用法: /extensions manage|install <source>',
'extensions.label': '扩展',
'extensions.action.failed': (v) =>
`扩展操作失败${v?.name ? `${v.name}` : v?.source ? `${v.source}` : ''}${
v?.error ?? '未知错误'
}`,
'extensions.commands.refreshFailed': '刷新扩展命令失败。',
'extensions.install.failed': (v) =>
`安装扩展失败${v?.source ? `${v.source}` : ''}${
v?.error ?? '未知错误'
}`,
'extensions.manage.agents': '智能体:',
'extensions.manage.checkingUpdates': '正在检查更新...',
'extensions.manage.commands': '命令:',
'extensions.manage.contextFiles': '上下文文件:',
'extensions.manage.count': (v) => `已安装 ${v?.count ?? 0} 个扩展`,
'extensions.manage.detailsTitle': '扩展详情',
'extensions.manage.disable': '禁用扩展',
'extensions.manage.disabled': (v) => `扩展 "${v?.name ?? '扩展'}" 已禁用。`,
'extensions.manage.empty': '未安装扩展。',
'extensions.manage.enable': '启用扩展',
'extensions.manage.enabled': (v) => `扩展 "${v?.name ?? '扩展'}" 已启用。`,
'extensions.manage.footer.back': 'Esc 返回',
'extensions.manage.footer.confirm': 'Enter 确认 · Esc 取消',
'extensions.manage.footer.list': '↑↓ 导航 · Enter 选择 · r 刷新 · Esc 关闭',
'extensions.manage.footer.select': '↑↓ 导航 · Enter 选择 · Esc 返回',
'extensions.manage.loading': '正在加载扩展...',
'extensions.manage.mcpServers': 'MCP servers',
'extensions.manage.name': '名称:',
'extensions.manage.notUpdatable': '不可更新',
'extensions.manage.path': '路径:',
'extensions.manage.queued': (v) =>
`扩展 "${v?.name ?? '扩展'}" 的操作已提交。`,
'extensions.manage.refreshed': (v) =>
`已刷新 ${v?.refreshed ?? 0} 个 session${v?.failed ?? 0} 个失败。`,
'extensions.manage.settings': '设置:',
'extensions.manage.skills': 'Skills',
'extensions.manage.source': '来源:',
'extensions.manage.status': '状态:',
'extensions.manage.status.disabled': '已禁用',
'extensions.manage.status.enabled': '已启用',
'extensions.manage.title': '管理扩展',
'extensions.manage.unknownUpdate': '未知',
'extensions.manage.uninstalled': (v) =>
`扩展 "${v?.name ?? '扩展'}" 已卸载。`,
'extensions.manage.uninstallAction': '卸载扩展',
'extensions.manage.uninstallConfirm': (v) =>
`确定卸载扩展 "${v?.name ?? '扩展'}"`,
'extensions.manage.upToDate': '已是最新',
'extensions.manage.update': '更新扩展',
'extensions.manage.updateAvailable': '有可用更新',
'extensions.manage.updateError': '检查更新失败',
'extensions.manage.updated': (v) => `扩展 "${v?.name ?? '扩展'}" 已更新。`,
'extensions.manage.updatedWithVersion': (v) =>
`扩展 "${v?.name ?? '扩展'}" 已更新到 v${v?.version ?? ''}`,
'extensions.manage.version': '版本:',
'extensions.manage.viewDetails': '查看详情',
'extensions.install.installed': (v) => `扩展 "${v?.name ?? '扩展'}" 已安装。`,
'extensions.install.installedWithVersion': (v) =>
`扩展 "${v?.name ?? '扩展'}" v${v?.version ?? ''} 已安装。`,
'extensions.install.missingOptionValue': (v) =>
`${v?.option ?? '选项'} 缺少值`,
'extensions.install.requestFailed': '安装扩展失败',
'extensions.install.started': (v) =>
`正在从 "${v?.source ?? ''}" 安装扩展...`,
'extensions.install.unknownOption': (v) => `未知选项 ${v?.option ?? ''}`,
'extensions.install.usage': '用法: /extensions manage|install <source>',
'extensions.install.waitForSession': '等待会话连接后再安装扩展。',
'extensions.install.waitForTurn': '等待当前回合结束后再安装扩展。',
'local.tools': '列出可用工具。用法: /tools [desc]',
'loadWarning.commands': '命令列表加载失败,斜杠命令可能不完整。',
'loadWarning.context': '会话上下文加载失败,当前模式可能不准确。',

View file

@ -117,6 +117,9 @@ export { useDaemonWorkspaceActions as useWorkspaceActions } from './daemon/index
/** Like `useWorkspace()` but returns null when outside a WorkspaceProvider. */
export { useOptionalDaemonWorkspace as useOptionalWorkspace } from './daemon/index.js';
/** Workspace-level event signals (memory/agents/tools/settings/mcp/extensions version counters). */
export { useDaemonWorkspaceEventSignals as useWorkspaceEventSignals } from './daemon/index.js';
// ── Transcript Hooks (low-level) ──────────────────────────────────
/** Raw transcript blocks from the SSE stream. For custom message conversion. */

View file

@ -142,6 +142,7 @@ const INITIAL_WORKSPACE_EVENT_SIGNALS: DaemonWorkspaceEventSignals = {
toolsVersion: 0,
settingsVersion: 0,
mcpVersion: 0,
extensionsVersion: 0,
initVersion: 0,
authVersion: 0,
};
@ -1578,7 +1579,7 @@ function getNumber(
}
function bumpWorkspaceEventSignals(
events: ReadonlyArray<{ type: string }>,
events: readonly DaemonUiEvent[],
setSignals: Dispatch<SetStateAction<DaemonWorkspaceEventSignals>>,
): void {
let memory = 0;
@ -1586,6 +1587,10 @@ function bumpWorkspaceEventSignals(
let tools = 0;
let settings = 0;
let mcp = 0;
let extensions = 0;
let lastExtensionChange:
| DaemonWorkspaceEventSignals['lastExtensionChange']
| undefined;
let init = 0;
let auth = 0;
@ -1609,6 +1614,18 @@ function bumpWorkspaceEventSignals(
case 'workspace.mcp.server_restart_refused':
mcp += 1;
break;
case 'workspace.extensions.changed':
extensions += 1;
lastExtensionChange = {
...(event.status ? { status: event.status } : {}),
...(event.source ? { source: event.source } : {}),
...(event.name ? { name: event.name } : {}),
...(event.version ? { version: event.version } : {}),
...(event.error ? { error: event.error } : {}),
refreshed: event.refreshed,
failed: event.failed,
};
break;
case 'workspace.initialized':
init += 1;
break;
@ -1624,7 +1641,8 @@ function bumpWorkspaceEventSignals(
}
}
if (memory + agents + tools + settings + mcp + init + auth === 0) return;
if (memory + agents + tools + settings + mcp + extensions + init + auth === 0)
return;
setSignals((current) => ({
memoryVersion: current.memoryVersion + memory,
@ -1632,6 +1650,8 @@ function bumpWorkspaceEventSignals(
toolsVersion: current.toolsVersion + tools,
settingsVersion: current.settingsVersion + settings,
mcpVersion: current.mcpVersion + mcp,
extensionsVersion: current.extensionsVersion + extensions,
...(lastExtensionChange ? { lastExtensionChange } : {}),
initVersion: current.initVersion + init,
authVersion: current.authVersion + auth,
}));

View file

@ -322,6 +322,22 @@ export interface DaemonWorkspaceEventSignals {
toolsVersion: number;
settingsVersion: number;
mcpVersion: number;
extensionsVersion: number;
lastExtensionChange?: {
status?:
| 'installed'
| 'enabled'
| 'disabled'
| 'updated'
| 'uninstalled'
| 'failed';
source?: string;
name?: string;
version?: string;
error?: string;
refreshed: number;
failed: number;
};
initVersion: number;
authVersion: number;
}

View file

@ -119,6 +119,14 @@ export function createDaemonWorkspaceActions({
);
},
async loadExtensionsStatus() {
const client = requireClient(getClient, 'Load extensions failed');
return withActionTimeout(
client.workspaceExtensions(),
'Load extensions timed out',
);
},
async loadToolsStatus() {
const client = requireClient(getClient, 'Load tools failed');
return withActionTimeout(client.workspaceTools(), 'Load tools timed out');
@ -345,6 +353,62 @@ export function createDaemonWorkspaceActions({
);
},
async installExtension(params, clientId) {
const client = requireClient(getClient, 'Install extension failed');
return withActionTimeout(
client.installExtension(params, clientId),
'Install extension timed out',
);
},
async checkExtensionUpdates(clientId) {
const client = requireClient(getClient, 'Check extension updates failed');
return withActionTimeout(
client.checkExtensionUpdates(clientId),
'Check extension updates timed out',
);
},
async refreshExtensions(clientId) {
const client = requireClient(getClient, 'Refresh extensions failed');
return withActionTimeout(
client.refreshExtensions(clientId),
'Refresh extensions timed out',
);
},
async enableExtension(name, params, clientId) {
const client = requireClient(getClient, 'Enable extension failed');
return withActionTimeout(
client.enableExtension(name, params, clientId),
'Enable extension timed out',
);
},
async disableExtension(name, params, clientId) {
const client = requireClient(getClient, 'Disable extension failed');
return withActionTimeout(
client.disableExtension(name, params, clientId),
'Disable extension timed out',
);
},
async updateExtension(name, clientId) {
const client = requireClient(getClient, 'Update extension failed');
return withActionTimeout(
client.updateExtension(name, clientId),
'Update extension timed out',
);
},
async uninstallExtension(name, clientId) {
const client = requireClient(getClient, 'Uninstall extension failed');
return withActionTimeout(
client.uninstallExtension(name, clientId),
'Uninstall extension timed out',
);
},
async startDeviceFlow(providerId) {
const client = requireClient(getClient, 'Start device flow failed');
return withActionTimeout(

View file

@ -18,6 +18,12 @@ import type {
DaemonGeneratedAgentContent,
DaemonDeviceFlowStartResult,
DaemonDeviceFlowState,
ExtensionMutationResponse,
ExtensionRefreshResponse,
ExtensionScopeRequest,
ExtensionInstallRequest,
ExtensionInstallResponse,
ExtensionUpdateCheckResponse,
DaemonInitWorkspaceResult,
DaemonMcpRestartResult,
DaemonMcpManageAction,
@ -26,6 +32,7 @@ import type {
DaemonWorkspaceAgentDetail,
DaemonWorkspaceAgentsStatus,
DaemonWorkspaceEnvStatus,
DaemonWorkspaceExtensionsStatus,
DaemonWorkspaceFile,
DaemonWorkspaceFileBytes,
DaemonWorkspaceFileEditRequest,
@ -153,6 +160,9 @@ export interface DaemonWorkspaceActions {
// Skills (read-only)
loadSkillsStatus(): Promise<DaemonWorkspaceSkillsStatus>;
// Extensions
loadExtensionsStatus(): Promise<DaemonWorkspaceExtensionsStatus>;
// Tools
loadToolsStatus(): Promise<DaemonWorkspaceToolsStatus>;
setWorkspaceToolEnabled(toolName: string, enabled: boolean): Promise<unknown>;
@ -212,6 +222,34 @@ export interface DaemonWorkspaceActions {
scope?: 'workspace' | 'global',
): Promise<DaemonAgentMutationResult>;
// Extensions
installExtension(
params: ExtensionInstallRequest,
clientId?: string,
): Promise<ExtensionInstallResponse>;
checkExtensionUpdates(
clientId?: string,
): Promise<ExtensionUpdateCheckResponse>;
refreshExtensions(clientId?: string): Promise<ExtensionRefreshResponse>;
enableExtension(
name: string,
params: ExtensionScopeRequest,
clientId?: string,
): Promise<ExtensionMutationResponse>;
disableExtension(
name: string,
params: ExtensionScopeRequest,
clientId?: string,
): Promise<ExtensionMutationResponse>;
updateExtension(
name: string,
clientId?: string,
): Promise<ExtensionMutationResponse>;
uninstallExtension(
name: string,
clientId?: string,
): Promise<ExtensionMutationResponse>;
// Auth device-flow
startDeviceFlow(
providerId: DaemonAuthProviderId,