mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
feat(web-shell): add extension management (#5398)
Co-authored-by: ytahdn <ytahdn@gmail.com>
This commit is contained in:
parent
4274c6c6c8
commit
18cc73ce05
32 changed files with 4213 additions and 55 deletions
|
|
@ -26,6 +26,7 @@ export default tseslint.config(
|
|||
'.integration-tests/**',
|
||||
'packages/**/.integration-test/**',
|
||||
'dist/**',
|
||||
'demo/**/dist/**',
|
||||
'docs-site/.next/**',
|
||||
'docs-site/out/**',
|
||||
'.qwen/**',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -413,8 +413,17 @@ export type {
|
|||
DaemonExtensionInstallType,
|
||||
DaemonExtensionOriginSource,
|
||||
DaemonExtensionCapabilities,
|
||||
DaemonExtensionDetails,
|
||||
DaemonExtensionEntry,
|
||||
DaemonExtensionUpdateState,
|
||||
DaemonWorkspaceExtensionsStatus,
|
||||
ExtensionInstallRequest,
|
||||
ExtensionInstallResponse,
|
||||
ExtensionMutationResponse,
|
||||
ExtensionRefreshResponse,
|
||||
ExtensionScope,
|
||||
ExtensionScopeRequest,
|
||||
ExtensionUpdateCheckResponse,
|
||||
HeartbeatResult,
|
||||
MCPServerConfigShape,
|
||||
PermissionOutcome,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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[]> = {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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': '会话上下文加载失败,当前模式可能不准确。',
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue