feat: integrate marketplace plugins with coding runtime

This commit is contained in:
2026-08-28 18:02:51 +08:00
parent 1d64b89499
commit 05917a789a
21 changed files with 1921 additions and 29 deletions

View File

@@ -35,6 +35,18 @@ import type {
PluginPolicyClient,
PluginPolicyClientState,
} from '../services/plugin-policy-client';
import type {
CatalogPage,
CatalogQuery,
MarketplaceClient,
PluginDetail,
} from '../coding-plugins/marketplace-client';
import type {
InstallationSnapshot,
PluginPackageStore,
} from '../coding-plugins/package-store';
import type { MarketplaceLibrarySnapshot } from '../coding-plugins/account-plugin-cache';
import type { EffectivePluginResolver } from '../coding-plugins/effective-resolver';
export interface ActiveCodingProject {
id: string;
@@ -56,6 +68,9 @@ export interface CodingProductComposition {
dataService: DataServiceOperations;
previewDataSession?: PreviewDataSessionManager;
productTools: PiProductTools;
pluginMarketplace: CodingPluginMarketplaceService;
/** Alias retained for Main callers that refer to the Marketplace service directly. */
marketplace: CodingPluginMarketplaceService;
plugins: CodingProjectPluginService;
projects: CodingProjectService;
conversations: CodingConversationService;
@@ -65,6 +80,83 @@ export interface CodingProductComposition {
shutdown(): Promise<void>;
}
export type PublicPluginInstallation = Readonly<{
status: InstallationSnapshot['status'];
pluginId: string;
releaseId?: string;
version?: string;
reason?: string;
}>;
/** Main-owned Marketplace facade. It never exposes package roots or manifests. */
export interface CodingPluginMarketplaceService {
readCatalog(input?: CatalogQuery): Promise<CatalogPage>;
readDetail(pluginId: string): Promise<PluginDetail>;
readLibrary(): Promise<MarketplaceLibrarySnapshot>;
acquire(pluginId: string): Promise<MarketplaceLibrarySnapshot>;
remove(pluginId: string): Promise<MarketplaceLibrarySnapshot>;
install(pluginId: string): Promise<PublicPluginInstallation>;
update(pluginId: string): Promise<PublicPluginInstallation>;
uninstall(pluginId: string): Promise<PublicPluginInstallation>;
}
export interface CreateCodingPluginMarketplaceServiceOptions {
marketplace: MarketplaceClient;
packageStore: PluginPackageStore;
clientVersion: string;
onChanged?(event: {
pluginId: string;
kind: 'acquire' | 'remove' | 'install' | 'update' | 'uninstall';
}): Promise<void> | void;
}
function publicInstallation(snapshot: InstallationSnapshot): PublicPluginInstallation {
return {
status: snapshot.status,
pluginId: snapshot.pluginId,
...(snapshot.releaseId ? { releaseId: snapshot.releaseId } : {}),
...(snapshot.version ? { version: snapshot.version } : {}),
...(snapshot.reason ? { reason: snapshot.reason } : {}),
};
}
export function createCodingPluginMarketplaceService(
options: CreateCodingPluginMarketplaceServiceOptions,
): CodingPluginMarketplaceService {
const install = async (pluginId: string, kind: 'install' | 'update'): Promise<PublicPluginInstallation> => {
const snapshot = await options.packageStore.resolveAndInstall({
pluginId,
makeloreVersion: options.clientVersion,
channel: 'stable',
});
await options.onChanged?.({ pluginId, kind });
return publicInstallation(snapshot);
};
const uninstall = async (pluginId: string): Promise<PublicPluginInstallation> => {
const snapshot = await options.packageStore.removeUnused(pluginId);
await options.onChanged?.({ pluginId, kind: 'uninstall' });
return publicInstallation(snapshot);
};
return {
readCatalog: (input = {}) => options.marketplace.readCatalog(input),
readDetail: (pluginId) => options.marketplace.readDetail(pluginId),
readLibrary: () => options.marketplace.readLibrary(),
acquire: async (pluginId) => {
const snapshot = await options.marketplace.acquire(pluginId);
await options.onChanged?.({ pluginId, kind: 'acquire' });
return snapshot;
},
remove: async (pluginId) => {
const snapshot = await options.marketplace.remove(pluginId);
await options.onChanged?.({ pluginId, kind: 'remove' });
return snapshot;
},
install: (pluginId) => install(pluginId, 'install'),
update: (pluginId) => install(pluginId, 'update'),
uninstall,
};
}
export type CodingPluginEffectiveState =
| 'unavailable'
| 'disabled'
@@ -149,6 +241,8 @@ export interface CreateCodingProjectPluginServiceOptions {
policyClient: Pick<PluginPolicyClient, 'refresh' | 'getState'>;
adapters: readonly CodingPluginAdapter[];
definitions: readonly CodingPluginDefinition[];
getDefinitions?(): readonly CodingPluginDefinition[] | Promise<readonly CodingPluginDefinition[]>;
effectiveResolver?: EffectivePluginResolver;
}
function policyOperation(
@@ -256,9 +350,18 @@ function effectiveState(input: {
export function createCodingProjectPluginService(
options: CreateCodingProjectPluginServiceOptions,
): CodingProjectPluginService {
const definitions = options.definitions;
const adapters = new Map(options.adapters.map((adapter) => [adapter.pluginId, adapter]));
async function definitions(): Promise<readonly CodingPluginDefinition[]> {
const result = new Map(options.definitions.map((definition) => [definition.id, definition]));
for (const definition of options.getDefinitions ? await options.getDefinitions() : []) {
// Bundled definitions are code-owned and cannot be shadowed by an
// installed package reusing their identifier.
if (!result.has(definition.id)) result.set(definition.id, definition);
}
return [...result.values()];
}
async function project(localProjectId: string) {
const id = localProjectId.trim();
if (!id) {
@@ -269,24 +372,42 @@ export function createCodingProjectPluginService(
async function list(localProjectId: string): Promise<CodingPluginProjectProjection> {
const localProject = await project(localProjectId);
await options.policyClient.refresh();
const pluginDefinitions = await definitions();
if (pluginDefinitions.some(({ requiresBackend }) => requiresBackend)) {
await options.policyClient.refresh();
}
const policy = options.policyClient.getState();
const [selection, config] = await Promise.all([
options.projectPlugins.getEnabledPluginIds(localProject.path),
readCodingProjectConfigV2(localProject.path),
]);
const assignedSkillIds = config.status === 'valid'
? [...new Set(config.config.agents
.filter((agent) => agent.enabled && !agent.archivedAt)
.flatMap(({ skillIds }) => skillIds))]
: [];
const effective = options.effectiveResolver
? await options.effectiveResolver.resolve({
projectId: localProject.id,
projectPath: localProject.path,
assignedSkillIds,
role: 'parent',
})
: null;
const durableProjectId = config.status === 'valid' ? config.config.projectId ?? null : null;
const items = await Promise.all(definitions.map(async (definition) => {
const items = await Promise.all(pluginDefinitions.map(async (definition) => {
const enabled = selection.includes(definition.id);
const adapter = adapters.get(definition.id);
let backend: PluginBackendProjection;
try {
backend = adapter ? boundedBackend(await adapter.inspect(localProject.path)) : degradedBackend();
backend = adapter
? boundedBackend(await adapter.inspect(localProject.path))
: definition.requiresBackend ? degradedBackend() : { status: 'not_required' };
} catch {
backend = degradedBackend();
}
const pluginPolicy = policy.catalog?.plugins.find(({ plugin_id }) => plugin_id === definition.id);
const policyAvailable = Boolean(
const policyAvailable = !definition.requiresBackend || Boolean(
pluginPolicy?.supported_contract_versions.includes(definition.contractVersion),
);
const capabilityIds = [...new Set(definition.operations.map(({ capabilityId }) => capabilityId))];
@@ -314,6 +435,16 @@ export function createCodingProjectPluginService(
return operations.length > 0 ? [{ id: capabilityId, operations }] : [];
});
const agents = config.status === 'valid' ? config.config.agents : [];
const effectiveReason = effective?.unavailableReasons.find(({ pluginId }) => pluginId === definition.id);
const hasEffectiveSkill = effective?.effectiveSkillIds.some((skillId) => (
definition.skills.some(({ id }) => id === skillId)
)) ?? false;
let state = effectiveState({ enabled, policyAvailable, backend });
if (effective && effectiveReason?.code === 'project_disabled') state = 'disabled';
else if (effective && !hasEffectiveSkill && effectiveReason
&& ['account_required', 'library_required', 'library_unavailable', 'release_not_installed',
'release_invalid', 'runtime_suspended', 'policy_unavailable', 'policy_unsupported']
.includes(effectiveReason.code)) state = 'unavailable';
return {
id: definition.id,
version: definition.version,
@@ -322,7 +453,7 @@ export function createCodingProjectPluginService(
displayName: definition.displayName,
description: definition.description,
enabled,
state: effectiveState({ enabled, policyAvailable, backend }),
state,
backend,
skills: definition.skills.map((skill) => ({
id: skill.id,
@@ -377,6 +508,7 @@ export interface CodingProductHostOptions {
productTools: PiProductTools;
files?: CodingProjectFileService;
getEnabledPluginIds?(projectPath: string): Promise<readonly string[]>;
effectiveResolver?: EffectivePluginResolver;
listPiCommands?(conversationId: string): Promise<unknown>;
}
@@ -483,23 +615,53 @@ export function createCodingProductHost(options: CodingProductHostOptions): Codi
},
async listSkills(agentId) {
const project = await activeProject();
const enabledPluginIds = options.getEnabledPluginIds
? await options.getEnabledPluginIds(project.path)
: [];
const assignedSkillIds = await selectedSkillIds(project.path, agentId);
const effective = options.effectiveResolver
? await options.effectiveResolver.resolve({
projectId: project.id,
projectPath: project.path,
assignedSkillIds,
role: 'parent',
})
: null;
// Keep the raw assignment for the Renderer projection so a known
// disabled Skill remains visible as selected/ineffective. The resolver
// still supplies the availability set; only the worker opener receives
// the effective subset.
const skillIds = assignedSkillIds;
const enabledPluginIds = effective
? effective.effectiveSkillIds
: options.getEnabledPluginIds
? await options.getEnabledPluginIds(project.path)
: [];
return await options.productTools.listSkills(
await selectedSkillIds(project.path, agentId),
skillIds,
enabledPluginIds,
);
},
async listCommands(conversationId) {
const context = await conversationContext(conversationId);
const enabledPluginIds = options.getEnabledPluginIds
? await options.getEnabledPluginIds(context.project.path)
: [];
const effective = options.effectiveResolver
? await options.effectiveResolver.resolve({
projectId: context.project.id,
projectPath: context.project.path,
assignedSkillIds: context.skillIds,
role: 'parent',
})
: null;
// Commands are projected from the assignment, while ProductTools marks
// only resolver-approved Skills effective. This preserves disabled
// assignment state without loading it into a worker.
const skillIds = context.skillIds;
const enabledPluginIds = effective
? effective.effectiveSkillIds
: options.getEnabledPluginIds
? await options.getEnabledPluginIds(context.project.path)
: [];
const piCommands = options.listPiCommands
? normalizePiCommands(await options.listPiCommands(conversationId))
: [];
return await options.productTools.listCommands(context.skillIds, piCommands, enabledPluginIds);
return await options.productTools.listCommands(skillIds, piCommands, enabledPluginIds);
},
async getChanges(conversationId) {
await conversationContext(conversationId);