feat: support project copilot customization

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-28 17:29:37 +01:00
co-authored by Copilot
parent f53907755a
commit 75b9ff667a
20 changed files with 1354 additions and 16 deletions
+176 -5
View File
@@ -9,6 +9,7 @@ import type {
ApprovalRequestedEvent,
ExitPlanModeRequestedEvent,
McpOauthRequiredEvent,
RunTurnCustomAgentConfig,
RunTurnToolingConfig,
SidecarCapabilities,
TurnDeltaEvent,
@@ -34,6 +35,14 @@ import {
type DiscoveredToolingState,
type DiscoveredToolingStatus,
} from '@shared/domain/discoveredTooling';
import {
listEnabledProjectAgentProfiles,
normalizeProjectCustomizationState,
resolveProjectInstructionsContent,
setProjectAgentProfileEnabled,
type ProjectAgentProfile,
type ProjectCustomizationState,
} from '@shared/domain/projectCustomization';
import {
approvalPolicyRequiresCheckpoint,
dequeuePendingApprovalState,
@@ -104,6 +113,7 @@ import { WorkspaceRepository } from '@main/persistence/workspaceRepository';
import { getScratchpadSessionPath } from '@main/persistence/appPaths';
import { SecretStore } from '@main/secrets/secretStore';
import { ConfigScannerRegistry } from '@main/services/configScanner';
import { ProjectCustomizationScanner } from '@main/services/customizationScanner';
import {
SIDECAR_STOPPED_BEFORE_COMPLETION_MESSAGE,
SidecarClient,
@@ -167,6 +177,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
private readonly secretStore = new SecretStore();
private readonly gitService = new GitService();
private readonly configScanner = new ConfigScannerRegistry();
private readonly customizationScanner = new ProjectCustomizationScanner();
private readonly pendingApprovalHandles = new Map<string, PendingApprovalHandle>();
private readonly pendingUserInputHandles = new Map<string, PendingUserInputHandle>();
private workspace?: WorkspaceState;
@@ -193,11 +204,15 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
const didSyncProjectTooling = selectedProject
? await this.syncProjectDiscoveredTooling(this.workspace, selectedProject)
: false;
const didSyncProjectCustomization = selectedProject
? await this.syncProjectCustomization(selectedProject)
: false;
const didPruneSelections = this.pruneUnavailableSessionToolingSelections(this.workspace);
const didPruneApprovalTools = await this.pruneUnavailableApprovalTools(this.workspace);
if (
didSyncUserTooling
|| didSyncProjectTooling
|| didSyncProjectCustomization
|| didPruneSelections
|| didPruneApprovalTools
|| this.cleanupInterruptedSessions(this.workspace)
@@ -262,6 +277,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
if (existing) {
workspace.selectedProjectId = existing.id;
const didSyncProjectTooling = await this.syncProjectDiscoveredTooling(workspace, existing);
await this.syncProjectCustomization(existing);
if (didSyncProjectTooling) {
this.pruneUnavailableSessionToolingSelections(workspace);
await this.pruneUnavailableApprovalTools(workspace);
@@ -280,6 +296,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
workspace.projects.push(project);
workspace.selectedProjectId = project.id;
await this.syncProjectDiscoveredTooling(workspace, project);
await this.syncProjectCustomization(project);
return this.persistAndBroadcast(workspace);
}
@@ -331,6 +348,28 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
return this.persistAndBroadcast(workspace);
}
async rescanProjectCustomization(projectId: string): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const project = this.requireProject(workspace, projectId);
await this.syncProjectCustomization(project);
return this.persistAndBroadcast(workspace);
}
async setProjectAgentProfileEnabled(
projectId: string,
agentProfileId: string,
enabled: boolean,
): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const project = this.requireProject(workspace, projectId);
project.customization = setProjectAgentProfileEnabled(
project.customization,
agentProfileId,
enabled,
);
return this.persistAndBroadcast(workspace);
}
async resolveProjectDiscoveredTooling(
projectId: string,
serverIds: string[],
@@ -624,7 +663,11 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
}
const project = this.requireProject(workspace, session.projectId);
const pattern = this.requirePattern(workspace, session.patternId);
const effectivePattern = await this.buildEffectivePattern(pattern, session);
const effectivePattern = this.applyProjectCustomizationToPattern(
await this.buildEffectivePattern(pattern, session),
project,
);
const projectInstructions = resolveProjectInstructionsContent(project.customization);
const preparedContent = prepareChatMessageContent(content);
if (!preparedContent) {
@@ -679,6 +722,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
workspaceKind,
mode: session.interactionMode ?? 'interactive',
messageMode,
projectInstructions,
pattern: effectivePattern,
messages: session.messages,
attachments: attachments?.length ? attachments : undefined,
@@ -1155,13 +1199,31 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
? [this.requireProject(workspace, projectId)]
: workspace.projects;
let changed = false;
let didRefreshGit = false;
let didSyncProjectTooling = false;
let didSyncProjectCustomization = false;
for (const project of projects) {
const projectChanged = await this.refreshGitContextForProject(project);
changed = projectChanged || changed;
didRefreshGit = await this.refreshGitContextForProject(project) || didRefreshGit;
didSyncProjectTooling = await this.syncProjectDiscoveredTooling(workspace, project) || didSyncProjectTooling;
didSyncProjectCustomization = await this.syncProjectCustomization(project) || didSyncProjectCustomization;
}
return changed ? this.persistAndBroadcast(workspace) : workspace;
const didPruneSelections = didSyncProjectTooling
? this.pruneUnavailableSessionToolingSelections(workspace)
: false;
const didPruneApprovalTools = didSyncProjectTooling
? await this.pruneUnavailableApprovalTools(workspace)
: false;
return (
didRefreshGit
|| didSyncProjectTooling
|| didSyncProjectCustomization
|| didPruneSelections
|| didPruneApprovalTools
)
? this.persistAndBroadcast(workspace)
: workspace;
}
async selectProject(projectId?: string): Promise<WorkspaceState> {
@@ -1169,6 +1231,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
if (projectId) {
const project = this.requireProject(workspace, projectId);
const didSyncProjectTooling = await this.syncProjectDiscoveredTooling(workspace, project);
await this.syncProjectCustomization(project);
if (didSyncProjectTooling) {
this.pruneUnavailableSessionToolingSelections(workspace);
await this.pruneUnavailableApprovalTools(workspace);
@@ -1193,6 +1256,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
const session = this.requireSession(workspace, sessionId);
const project = this.requireProject(workspace, session.projectId);
const didSyncProjectTooling = await this.syncProjectDiscoveredTooling(workspace, project);
await this.syncProjectCustomization(project);
if (didSyncProjectTooling) {
this.pruneUnavailableSessionToolingSelections(workspace);
await this.pruneUnavailableApprovalTools(workspace);
@@ -1842,6 +1906,77 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
return normalizePatternModels(patternWithApprovalSettings, modelCatalog);
}
private applyProjectCustomizationToPattern(
pattern: PatternDefinition,
project: ProjectRecord,
): PatternDefinition {
if (isScratchpadProject(project)) {
return pattern;
}
const projectCustomAgents = this.buildProjectCustomAgents(project.customization);
if (projectCustomAgents.length === 0) {
return pattern;
}
const [primaryAgent, ...remainingAgents] = pattern.agents;
if (!primaryAgent) {
return pattern;
}
const existingCustomAgents = primaryAgent.copilot?.customAgents ?? [];
const existingAgentNames = new Set(existingCustomAgents.map((agent) => agent.name.toLowerCase()));
const mergedCustomAgents = [
...existingCustomAgents,
...projectCustomAgents.filter((agent) => !existingAgentNames.has(agent.name.toLowerCase())),
];
return {
...pattern,
agents: [
{
...primaryAgent,
copilot: {
...primaryAgent.copilot,
customAgents: mergedCustomAgents,
},
},
...remainingAgents,
],
};
}
private buildProjectCustomAgents(
customization?: ProjectCustomizationState,
): RunTurnCustomAgentConfig[] {
return listEnabledProjectAgentProfiles(customization).map((profile) => this.mapProjectAgentProfile(profile));
}
private mapProjectAgentProfile(profile: ProjectAgentProfile): RunTurnCustomAgentConfig {
const customAgent: RunTurnCustomAgentConfig = {
name: profile.name,
prompt: profile.prompt,
};
if (profile.displayName) {
customAgent.displayName = profile.displayName;
}
if (profile.description) {
customAgent.description = profile.description;
}
if (profile.tools) {
customAgent.tools = profile.tools;
}
if (profile.infer !== undefined) {
customAgent.infer = profile.infer;
}
return customAgent;
}
private async listKnownApprovalToolNames(
workspace: WorkspaceState,
project?: ProjectRecord,
@@ -1920,6 +2055,25 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
return true;
}
private async syncProjectCustomization(project: ProjectRecord): Promise<boolean> {
if (isScratchpadProject(project)) {
if (!project.customization || this.equalProjectCustomizationState(project.customization, undefined)) {
return false;
}
project.customization = undefined;
return true;
}
const nextState = await this.customizationScanner.scanProject(project.path, project.customization);
if (this.equalProjectCustomizationState(project.customization, nextState)) {
return false;
}
project.customization = nextState;
return true;
}
private async syncProjectDiscoveredTooling(
workspace: WorkspaceState,
project: ProjectRecord,
@@ -1988,6 +2142,23 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
=== JSON.stringify(normalizeDiscoveredToolingState(right).mcpServers);
}
private equalProjectCustomizationState(
left?: ProjectCustomizationState,
right?: ProjectCustomizationState,
): boolean {
const normalizedLeft = normalizeProjectCustomizationState(left);
const normalizedRight = normalizeProjectCustomizationState(right);
return JSON.stringify({
instructions: normalizedLeft.instructions,
agentProfiles: normalizedLeft.agentProfiles,
promptFiles: normalizedLeft.promptFiles,
}) === JSON.stringify({
instructions: normalizedRight.instructions,
agentProfiles: normalizedRight.agentProfiles,
promptFiles: normalizedRight.promptFiles,
});
}
private updateSessionRun(
session: SessionRecord,
requestId: string,