738 lines
27 KiB
TypeScript
738 lines
27 KiB
TypeScript
import type {
|
|
CodingProjectFileContent,
|
|
CodingProjectFileEntry,
|
|
CodingTextSearchResult,
|
|
ConversationChangesSnapshot,
|
|
ProductCodingCommand,
|
|
ProductCodingSkill,
|
|
ProductPiCommandInput,
|
|
} from '../../shared/coding-product-tools';
|
|
import type { CodingAttachmentStore } from '../coding-projects/attachment-store';
|
|
import { readCodingProjectConfigV2 } from '../coding-projects/project-config';
|
|
import { CodingProjectFileService } from '../coding-projects/project-files';
|
|
import {
|
|
CodingProjectServiceError,
|
|
type CodingProjectService,
|
|
} from '../coding-projects/project-service';
|
|
import type { CodingConversationService } from '../coding-runtime/conversation-service';
|
|
import type { CodingConversationRuntime } from '../coding-runtime/contracts';
|
|
import type { PiProductTools } from '../coding-runtime/pi/product-tools';
|
|
import type { DataServiceOperations } from '../services/data-service-client';
|
|
import type { PreviewDataSessionManager } from '../services/preview-data-session';
|
|
import {
|
|
type CodingPluginDefinition,
|
|
type PluginBillingMode,
|
|
} from '../../shared/coding-plugins';
|
|
import type {
|
|
CodingPluginAdapter,
|
|
PluginBackendProjection,
|
|
} from '../coding-plugins/registry';
|
|
import type { ProjectPluginService } from '../coding-plugins/project-service';
|
|
import type {
|
|
PluginBillingPolicy,
|
|
PluginCatalogOperation,
|
|
PluginPolicyAvailability,
|
|
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';
|
|
import type { DevicePackageManager } from '../coding-packages/device-package-manager';
|
|
|
|
export interface ActiveCodingProject {
|
|
id: string;
|
|
path: string;
|
|
}
|
|
|
|
export interface CodingProductHost {
|
|
fileStatus(): Promise<CodingProjectFileEntry[]>;
|
|
findFiles(query: string, limit?: number): Promise<CodingProjectFileEntry[]>;
|
|
fileContent(path: string): Promise<CodingProjectFileContent>;
|
|
searchText(pattern: string): Promise<CodingTextSearchResult[]>;
|
|
listSkills(agentId?: string): Promise<ProductCodingSkill[]>;
|
|
listCommands(conversationId: string): Promise<ProductCodingCommand[]>;
|
|
getChanges(conversationId: string): Promise<ConversationChangesSnapshot | null>;
|
|
}
|
|
|
|
export interface CodingProductComposition {
|
|
attachments: CodingAttachmentStore;
|
|
dataService: DataServiceOperations;
|
|
devicePackages: DevicePackageManager;
|
|
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;
|
|
runtime: CodingConversationRuntime;
|
|
host: CodingProductHost;
|
|
sleep(reason: 'background_sleep' | 'auth_cleanup'): Promise<void>;
|
|
shutdown(): Promise<void>;
|
|
}
|
|
|
|
export type PublicPluginInstallation = Readonly<{
|
|
status: InstallationSnapshot['status'];
|
|
pluginId: string;
|
|
releaseId?: string;
|
|
version?: string;
|
|
channel?: 'stable' | 'beta';
|
|
reason?: string;
|
|
}>;
|
|
|
|
export type PublicPluginLibrary = Readonly<{
|
|
library: MarketplaceLibrarySnapshot;
|
|
installations: readonly PublicPluginInstallation[];
|
|
}>;
|
|
|
|
/** 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<PublicPluginLibrary>;
|
|
acquire(pluginId: string): Promise<PublicPluginLibrary>;
|
|
remove(pluginId: string): Promise<PublicPluginLibrary>;
|
|
install(pluginId: string): Promise<PublicPluginInstallation>;
|
|
installBeta(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.channel ? { channel: snapshot.channel } : {}),
|
|
...(snapshot.reason ? { reason: snapshot.reason } : {}),
|
|
};
|
|
}
|
|
|
|
async function publicLibrary(
|
|
library: MarketplaceLibrarySnapshot,
|
|
packageStore: Pick<PluginPackageStore, 'getInstalled'>,
|
|
): Promise<PublicPluginLibrary> {
|
|
const installations = await Promise.all(library.items.map(async ({ pluginId }) => {
|
|
try {
|
|
const record = await packageStore.getInstalled(pluginId);
|
|
return record ? {
|
|
status: record.unavailableReason ? 'unavailable' as const : 'installed' as const,
|
|
pluginId: record.pluginId,
|
|
releaseId: record.releaseId,
|
|
version: record.version,
|
|
...(record.channel === undefined ? {} : { channel: record.channel }),
|
|
...(record.unavailableReason ? { reason: record.unavailableReason } : {}),
|
|
} : null;
|
|
} catch (error) {
|
|
const candidate = error && typeof error === 'object' && 'code' in error
|
|
&& typeof error.code === 'string'
|
|
? error.code
|
|
: '';
|
|
const reason = /^[a-z][a-z0-9_]{0,63}$/u.test(candidate)
|
|
? candidate
|
|
: 'plugin_backend_unavailable';
|
|
return { status: 'unavailable' as const, pluginId, reason };
|
|
}
|
|
}));
|
|
return {
|
|
library,
|
|
installations: installations
|
|
.filter((installation): installation is NonNullable<typeof installation> => installation !== null)
|
|
.sort((left, right) => left.pluginId.localeCompare(right.pluginId)),
|
|
};
|
|
}
|
|
|
|
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 installBeta = async (pluginId: string): Promise<PublicPluginInstallation> => {
|
|
const snapshot = await options.packageStore.resolveAndInstall({
|
|
pluginId,
|
|
makeloreVersion: options.clientVersion,
|
|
channel: 'beta',
|
|
explicitBeta: true,
|
|
});
|
|
await options.onChanged?.({ pluginId, kind: 'install' });
|
|
return publicInstallation(snapshot);
|
|
};
|
|
const uninstall = async (pluginId: string): Promise<PublicPluginInstallation> => {
|
|
const snapshot = await options.packageStore.uninstall(pluginId);
|
|
await options.onChanged?.({ pluginId, kind: 'uninstall' });
|
|
return publicInstallation(snapshot);
|
|
};
|
|
return {
|
|
readCatalog: (input = {}) => options.marketplace.readCatalog(input),
|
|
readDetail: (pluginId) => options.marketplace.readDetail(pluginId),
|
|
readLibrary: async () => publicLibrary(await options.marketplace.readLibrary(), options.packageStore),
|
|
acquire: async (pluginId) => {
|
|
const snapshot = await options.marketplace.acquire(pluginId);
|
|
await options.onChanged?.({ pluginId, kind: 'acquire' });
|
|
return publicLibrary(snapshot, options.packageStore);
|
|
},
|
|
remove: async (pluginId) => {
|
|
const snapshot = await options.marketplace.remove(pluginId);
|
|
await options.onChanged?.({ pluginId, kind: 'remove' });
|
|
return publicLibrary(snapshot, options.packageStore);
|
|
},
|
|
install: (pluginId) => install(pluginId, 'install'),
|
|
installBeta,
|
|
update: (pluginId) => install(pluginId, 'update'),
|
|
uninstall,
|
|
};
|
|
}
|
|
|
|
export type CodingPluginEffectiveState =
|
|
| 'unavailable'
|
|
| 'disabled'
|
|
| 'identity_required'
|
|
| 'authentication_required'
|
|
| 'configuration_required'
|
|
| 'ready'
|
|
| 'degraded';
|
|
|
|
export type PublicPluginBilling = Readonly<{
|
|
mode: PluginBillingMode;
|
|
availability: 'available' | 'unavailable';
|
|
notice: string;
|
|
pricingVersion?: number;
|
|
unitName?: string;
|
|
unitSize?: number;
|
|
ratePoints?: string;
|
|
minimumChargePoints?: string;
|
|
roundingMode?: 'ceil';
|
|
}>;
|
|
|
|
export interface CodingPluginProjectProjection {
|
|
schemaVersion: 1;
|
|
project: {
|
|
localProjectId: string;
|
|
durableProjectId: string | null;
|
|
};
|
|
policyStatus: PluginPolicyAvailability;
|
|
unknownPluginIds: string[];
|
|
items: Array<{
|
|
id: string;
|
|
version: string;
|
|
contractVersion: number;
|
|
requiresBackend: boolean;
|
|
displayName: string;
|
|
description: string;
|
|
enabled: boolean;
|
|
state: CodingPluginEffectiveState;
|
|
backend: PluginBackendProjection;
|
|
skills: Array<{ id: string; assignedAgentIds: string[] }>;
|
|
capabilities: Array<{
|
|
id: string;
|
|
operations: Array<{
|
|
id: string;
|
|
billing: PublicPluginBilling;
|
|
tool: {
|
|
name: string;
|
|
label: string;
|
|
description: string;
|
|
mutation: 'read' | 'write' | 'destructive';
|
|
permissions: string[];
|
|
} | null;
|
|
}>;
|
|
}>;
|
|
settingsSurface: string | null;
|
|
}>;
|
|
}
|
|
|
|
export interface CodingProjectPluginService {
|
|
list(localProjectId: string): Promise<CodingPluginProjectProjection>;
|
|
setEnabled(
|
|
localProjectId: string,
|
|
pluginId: string,
|
|
enabled: boolean,
|
|
): Promise<CodingPluginProjectProjection>;
|
|
deactivate(projectPath: string, pluginId?: string): Promise<void>;
|
|
}
|
|
|
|
export class CodingProjectPluginServiceError extends Error {
|
|
constructor(
|
|
readonly status: 400 | 404 | 500 | 503,
|
|
readonly code: string,
|
|
message: string,
|
|
) {
|
|
super(message);
|
|
this.name = 'CodingProjectPluginServiceError';
|
|
}
|
|
}
|
|
|
|
export interface CreateCodingProjectPluginServiceOptions {
|
|
projects: Pick<CodingProjectService, 'getProject'>;
|
|
projectPlugins: Pick<ProjectPluginService, 'getEnabledPluginIds' | 'setEnabled'>;
|
|
policyClient: Pick<PluginPolicyClient, 'refresh' | 'getState'>;
|
|
adapters: readonly CodingPluginAdapter[];
|
|
definitions: readonly CodingPluginDefinition[];
|
|
getDefinitions?(): readonly CodingPluginDefinition[] | Promise<readonly CodingPluginDefinition[]>;
|
|
effectiveResolver?: EffectivePluginResolver;
|
|
}
|
|
|
|
function policyOperation(
|
|
state: PluginPolicyClientState,
|
|
definition: CodingPluginDefinition,
|
|
capabilityId: string,
|
|
operation: string,
|
|
): PluginCatalogOperation | null {
|
|
const plugin = state.catalog?.plugins.find(({ plugin_id }) => plugin_id === definition.id);
|
|
if (!plugin || !plugin.supported_contract_versions.includes(definition.contractVersion)) return null;
|
|
return plugin.capabilities
|
|
.find(({ capability_id }) => capability_id === capabilityId)
|
|
?.operations.find((candidate) => candidate.operation === operation) ?? null;
|
|
}
|
|
|
|
function publicBilling(
|
|
policy: PluginBillingPolicy,
|
|
state: PluginPolicyClientState,
|
|
): PublicPluginBilling {
|
|
if (policy.mode === 'included' || policy.mode === 'external_account') {
|
|
return { mode: policy.mode, availability: 'available', notice: policy.notice };
|
|
}
|
|
if ('status' in policy && policy.status === 'billing_unavailable') {
|
|
return {
|
|
mode: 'platform_metered',
|
|
availability: 'unavailable',
|
|
notice: policy.notice,
|
|
};
|
|
}
|
|
const pricingVersion = state.catalog?.pricing_version?.version;
|
|
return {
|
|
mode: 'platform_metered',
|
|
availability: 'available',
|
|
notice: policy.notice,
|
|
...(pricingVersion === undefined ? {} : { pricingVersion }),
|
|
unitName: policy.unit_name,
|
|
unitSize: policy.unit_size,
|
|
ratePoints: policy.rate_points,
|
|
minimumChargePoints: policy.minimum_charge_points,
|
|
roundingMode: policy.rounding_mode,
|
|
};
|
|
}
|
|
|
|
function degradedBackend(): PluginBackendProjection {
|
|
return {
|
|
status: 'degraded',
|
|
code: 'plugin_backend_unavailable',
|
|
message: 'Plugin backend is temporarily unavailable',
|
|
retryable: true,
|
|
};
|
|
}
|
|
|
|
function boundedBackend(value: PluginBackendProjection): PluginBackendProjection {
|
|
switch (value.status) {
|
|
case 'not_required': return { status: 'not_required' };
|
|
case 'identity_required': return { status: 'identity_required' };
|
|
case 'authentication_required': return { status: 'authentication_required' };
|
|
case 'unconfigured': return { status: 'unconfigured' };
|
|
case 'ready': return { status: 'ready' };
|
|
case 'degraded': {
|
|
const code = typeof value.code === 'string' && /^[a-z][a-z0-9_]{0,63}$/u.test(value.code)
|
|
? value.code
|
|
: 'plugin_backend_unavailable';
|
|
const message = typeof value.message === 'string' && value.message.length > 0
|
|
&& value.message.length <= 160
|
|
? value.message
|
|
: 'Plugin backend is temporarily unavailable';
|
|
const retryAfter = value.retry_after_seconds;
|
|
return {
|
|
status: 'degraded',
|
|
code,
|
|
message,
|
|
retryable: value.retryable === true,
|
|
...(Number.isSafeInteger(retryAfter) && (retryAfter as number) >= 0
|
|
&& (retryAfter as number) <= 86_400
|
|
? { retry_after_seconds: retryAfter }
|
|
: {}),
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
function effectiveState(input: {
|
|
enabled: boolean;
|
|
policyAvailable: boolean;
|
|
backend: PluginBackendProjection;
|
|
}): CodingPluginEffectiveState {
|
|
if (!input.policyAvailable) return 'unavailable';
|
|
if (!input.enabled) return 'disabled';
|
|
switch (input.backend.status) {
|
|
case 'identity_required': return 'identity_required';
|
|
case 'authentication_required': return 'authentication_required';
|
|
case 'unconfigured': return 'configuration_required';
|
|
case 'ready':
|
|
case 'not_required': return 'ready';
|
|
case 'degraded': return 'degraded';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Bounded Renderer-facing projection. It resolves a local project handle in
|
|
* Main, joins only code-owned package fields with verified policy, and keeps
|
|
* adapters and project paths behind the composition boundary.
|
|
*/
|
|
export function createCodingProjectPluginService(
|
|
options: CreateCodingProjectPluginServiceOptions,
|
|
): CodingProjectPluginService {
|
|
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) {
|
|
throw new CodingProjectPluginServiceError(400, 'plugin_request_invalid', 'projectId is required');
|
|
}
|
|
return await options.projects.getProject(id);
|
|
}
|
|
|
|
async function list(localProjectId: string): Promise<CodingPluginProjectProjection> {
|
|
const localProject = await project(localProjectId);
|
|
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(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))
|
|
: definition.requiresBackend ? degradedBackend() : { status: 'not_required' };
|
|
} catch {
|
|
backend = degradedBackend();
|
|
}
|
|
const pluginPolicy = policy.catalog?.plugins.find(({ plugin_id }) => plugin_id === definition.id);
|
|
const policyAvailable = !definition.requiresBackend || Boolean(
|
|
pluginPolicy?.supported_contract_versions.includes(definition.contractVersion),
|
|
);
|
|
const capabilityIds = [...new Set(definition.operations.map(({ capabilityId }) => capabilityId))];
|
|
const capabilities = capabilityIds.flatMap((capabilityId) => {
|
|
const operations = definition.operations
|
|
.filter((candidate) => candidate.capabilityId === capabilityId)
|
|
.flatMap((candidate) => {
|
|
const joined = policyOperation(policy, definition, capabilityId, candidate.operation);
|
|
if (!joined) return [];
|
|
const tool = candidate.toolName
|
|
? definition.tools.find(({ name }) => name === candidate.toolName)
|
|
: undefined;
|
|
return [{
|
|
id: candidate.operation,
|
|
billing: publicBilling(joined.billing, policy),
|
|
tool: tool ? {
|
|
name: tool.name,
|
|
label: tool.label,
|
|
description: tool.description,
|
|
mutation: tool.mutation,
|
|
permissions: [...tool.permissions],
|
|
} : null,
|
|
}];
|
|
});
|
|
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', 'client_incompatible', 'skill_owner_conflict', 'runtime_suspended', 'policy_unavailable',
|
|
'policy_unsupported']
|
|
.includes(effectiveReason.code)) state = 'unavailable';
|
|
return {
|
|
id: definition.id,
|
|
version: definition.version,
|
|
contractVersion: definition.contractVersion,
|
|
requiresBackend: definition.requiresBackend,
|
|
displayName: definition.displayName,
|
|
description: definition.description,
|
|
enabled,
|
|
state,
|
|
backend,
|
|
skills: definition.skills.map((skill) => ({
|
|
id: skill.id,
|
|
assignedAgentIds: agents
|
|
.filter((agent) => agent.skillIds.includes(skill.id))
|
|
.map(({ id }) => id)
|
|
.sort(),
|
|
})),
|
|
capabilities,
|
|
settingsSurface: definition.surfaces.projectSettings ?? null,
|
|
};
|
|
}));
|
|
return {
|
|
schemaVersion: 1,
|
|
project: { localProjectId: localProject.id, durableProjectId },
|
|
policyStatus: policy.status,
|
|
unknownPluginIds: [...new Set(selection.filter((id) => (
|
|
!pluginDefinitions.some((definition) => definition.id === id)
|
|
)))].sort(),
|
|
items,
|
|
};
|
|
}
|
|
|
|
return {
|
|
list,
|
|
async setEnabled(localProjectId, pluginId, enabled) {
|
|
if (typeof enabled !== 'boolean') {
|
|
throw new CodingProjectPluginServiceError(400, 'plugin_request_invalid', 'enabled must be boolean');
|
|
}
|
|
const localProject = await project(localProjectId);
|
|
await options.projectPlugins.setEnabled(localProject.path, pluginId, enabled);
|
|
return await list(localProject.id);
|
|
},
|
|
async deactivate(projectPath, pluginId) {
|
|
const targets = pluginId ? [adapters.get(pluginId)].filter(Boolean) : [...adapters.values()];
|
|
await Promise.allSettled(targets.map(async (adapter) => {
|
|
await adapter.deactivate?.(projectPath);
|
|
}));
|
|
},
|
|
};
|
|
}
|
|
|
|
export class CodingProductHostError extends Error {
|
|
constructor(
|
|
readonly status: 404 | 409,
|
|
readonly code: string,
|
|
message: string,
|
|
) {
|
|
super(message);
|
|
}
|
|
}
|
|
|
|
export interface CodingProductHostOptions {
|
|
projects: Pick<CodingProjectService, 'getActiveProject' | 'findActiveConversation'>;
|
|
productTools: PiProductTools;
|
|
files?: CodingProjectFileService;
|
|
getEnabledPluginIds?(projectPath: string): Promise<readonly string[]>;
|
|
effectiveResolver?: EffectivePluginResolver;
|
|
listPiCommands?(conversationId: string): Promise<unknown>;
|
|
}
|
|
|
|
function normalizePiCommands(value: unknown): ProductPiCommandInput[] {
|
|
const record = value && typeof value === 'object' && !Array.isArray(value)
|
|
? value as Record<string, unknown>
|
|
: null;
|
|
const candidates = Array.isArray(value)
|
|
? value
|
|
: Array.isArray(record?.commands)
|
|
? record.commands
|
|
: Array.isArray(record?.data)
|
|
? record.data
|
|
: [];
|
|
return candidates.flatMap((candidate) => {
|
|
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return [];
|
|
const command = candidate as Record<string, unknown>;
|
|
if (typeof command.name !== 'string') return [];
|
|
return [{
|
|
name: command.name,
|
|
...(typeof command.description === 'string' ? { description: command.description } : {}),
|
|
}];
|
|
});
|
|
}
|
|
|
|
export function createCodingProductHost(options: CodingProductHostOptions): CodingProductHost {
|
|
const files = options.files ?? new CodingProjectFileService();
|
|
|
|
async function activeProject(): Promise<ActiveCodingProject> {
|
|
const project = await options.projects.getActiveProject();
|
|
if (!project) {
|
|
throw new CodingProductHostError(
|
|
409,
|
|
'CODING_ACTIVE_PROJECT_REQUIRED',
|
|
'No active coding project is selected',
|
|
);
|
|
}
|
|
return project;
|
|
}
|
|
|
|
async function selectedSkillIds(
|
|
projectPath: string,
|
|
agentId?: string,
|
|
): Promise<readonly string[]> {
|
|
if (!agentId) return [];
|
|
const config = await readCodingProjectConfigV2(projectPath);
|
|
if (config.status !== 'valid') {
|
|
throw new CodingProductHostError(
|
|
409,
|
|
'CODING_PROJECT_CONFIG_INVALID',
|
|
'Coding project configuration is unavailable',
|
|
);
|
|
}
|
|
const agent = config.config.agents.find((candidate) => (
|
|
candidate.id === agentId && candidate.enabled && !candidate.archivedAt
|
|
));
|
|
if (!agent) {
|
|
throw new CodingProductHostError(
|
|
404,
|
|
'CODING_AGENT_NOT_FOUND',
|
|
'Coding project Agent does not exist',
|
|
);
|
|
}
|
|
return agent.skillIds;
|
|
}
|
|
|
|
async function conversationContext(conversationId: string): Promise<{
|
|
project: ActiveCodingProject;
|
|
skillIds: readonly string[];
|
|
}> {
|
|
let context;
|
|
try {
|
|
context = await options.projects.findActiveConversation(conversationId);
|
|
} catch (error) {
|
|
if (error instanceof CodingProjectServiceError
|
|
&& (error.status === 404 || error.status === 409)) {
|
|
throw new CodingProductHostError(error.status, error.code, error.message);
|
|
}
|
|
throw error;
|
|
}
|
|
const { project, conversation } = context;
|
|
return {
|
|
project,
|
|
skillIds: await selectedSkillIds(project.path, conversation.agentId),
|
|
};
|
|
}
|
|
|
|
return {
|
|
async fileStatus() {
|
|
const project = await activeProject();
|
|
return await files.status(project.path);
|
|
},
|
|
async findFiles(query, limit) {
|
|
const project = await activeProject();
|
|
return await files.find(project.path, query, limit);
|
|
},
|
|
async fileContent(filePath) {
|
|
const project = await activeProject();
|
|
return await files.content(project.path, filePath);
|
|
},
|
|
async searchText(pattern) {
|
|
const project = await activeProject();
|
|
return await files.search(project.path, pattern);
|
|
},
|
|
async listSkills(agentId) {
|
|
const project = await activeProject();
|
|
const assignedSkillIds = await selectedSkillIds(project.path, agentId);
|
|
const effective = agentId && 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;
|
|
// The unscoped list is the Agent assignment catalog. It must expose
|
|
// every project-enabled plugin Skill before that Skill has been assigned;
|
|
// the resolver's effective subset is only authoritative once an Agent is
|
|
// selected (and for worker-facing projections).
|
|
const enabledPluginIds = !agentId && options.getEnabledPluginIds
|
|
? await options.getEnabledPluginIds(project.path)
|
|
: effective
|
|
? effective.effectiveSkillIds
|
|
: options.getEnabledPluginIds
|
|
? await options.getEnabledPluginIds(project.path)
|
|
: [];
|
|
return await options.productTools.listSkills(
|
|
skillIds,
|
|
enabledPluginIds,
|
|
);
|
|
},
|
|
async listCommands(conversationId) {
|
|
const context = await conversationContext(conversationId);
|
|
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(skillIds, piCommands, enabledPluginIds);
|
|
},
|
|
async getChanges(conversationId) {
|
|
await conversationContext(conversationId);
|
|
return options.productTools.getChanges(conversationId);
|
|
},
|
|
};
|
|
}
|