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,
+17 -5
View File
@@ -5,28 +5,30 @@ import { ipcChannels } from '@shared/contracts/channels';
import type {
CancelSessionTurnInput,
CreateSessionInput,
ResolveProjectDiscoveredToolingInput,
ResolveWorkspaceDiscoveredToolingInput,
DismissSessionPlanReviewInput,
DismissSessionMcpAuthInput,
DismissSessionPlanReviewInput,
DeleteSessionInput,
StartSessionMcpAuthInput,
DuplicateSessionInput,
RenameSessionInput,
RescanProjectConfigsInput,
RescanProjectCustomizationInput,
ResolveProjectDiscoveredToolingInput,
ResolveSessionApprovalInput,
ResolveSessionUserInputInput,
ResolveWorkspaceDiscoveredToolingInput,
SaveLspProfileInput,
SaveMcpServerInput,
SavePatternInput,
SendSessionMessageInput,
SetPatternFavoriteInput,
SetProjectAgentProfileEnabledInput,
SetSessionArchivedInput,
SetSessionInteractionModeInput,
SetSessionPinnedInput,
UpdateSessionModelConfigInput,
UpdateSessionApprovalSettingsInput,
UpdateSessionToolingInput,
UpdateSessionModelConfigInput,
DeleteSessionInput,
} from '@shared/contracts/ipc';
import type { QuerySessionsInput } from '@shared/domain/sessionLibrary';
import type { AppearanceTheme } from '@shared/domain/tooling';
@@ -53,11 +55,21 @@ export function registerIpcHandlers(window: BrowserWindow, service: AryxAppServi
ipcMain.handle(ipcChannels.rescanProjectConfigs, (_event, input: RescanProjectConfigsInput) =>
service.rescanProjectConfigs(input.projectId),
);
ipcMain.handle(
ipcChannels.rescanProjectCustomization,
(_event, input: RescanProjectCustomizationInput) =>
service.rescanProjectCustomization(input.projectId),
);
ipcMain.handle(
ipcChannels.resolveProjectDiscoveredTooling,
(_event, input: ResolveProjectDiscoveredToolingInput) =>
service.resolveProjectDiscoveredTooling(input.projectId, input.serverIds, input.resolution),
);
ipcMain.handle(
ipcChannels.setProjectAgentProfileEnabled,
(_event, input: SetProjectAgentProfileEnabledInput) =>
service.setProjectAgentProfileEnabled(input.projectId, input.agentProfileId, input.enabled),
);
ipcMain.handle(ipcChannels.savePattern, (_event, input: SavePatternInput) => service.savePattern(input.pattern));
ipcMain.handle(ipcChannels.deletePattern, (_event, patternId: string) => service.deletePattern(patternId));
ipcMain.handle(ipcChannels.setPatternFavorite, (_event, input: SetPatternFavoriteInput) =>
@@ -4,6 +4,7 @@ import { createBuiltinPatterns, resolvePatternGraph } from '@shared/domain/patte
import type { PatternDefinition } from '@shared/domain/pattern';
import { isScratchpadProject, mergeScratchpadProject } from '@shared/domain/project';
import { normalizeDiscoveredToolingState } from '@shared/domain/discoveredTooling';
import { normalizeProjectCustomizationState } from '@shared/domain/projectCustomization';
import { normalizeSessionRunRecords } from '@shared/domain/runTimeline';
import type { SessionRecord } from '@shared/domain/session';
import {
@@ -73,6 +74,7 @@ export class WorkspaceRepository {
(stored.projects ?? []).map((project) => ({
...project,
discoveredTooling: normalizeDiscoveredToolingState(project.discoveredTooling),
customization: normalizeProjectCustomizationState(project.customization),
})),
this.scratchpadPath,
);
+370
View File
@@ -0,0 +1,370 @@
import { readdir, readFile } from 'node:fs/promises';
import { basename, join, relative } from 'node:path';
import { parse as parseYaml } from 'yaml';
import {
mergeProjectCustomizationState,
normalizeProjectCustomizationState,
type ProjectAgentProfile,
type ProjectCustomizationState,
type ProjectInstructionFile,
type ProjectPromptFile,
type ProjectPromptVariable,
} from '@shared/domain/projectCustomization';
import { nowIso } from '@shared/utils/ids';
const promptVariablePattern = /\$\{input:([a-zA-Z0-9_-]+):([^}]+)\}/g;
export class ProjectCustomizationScanner {
async scanProject(
projectPath: string,
current?: ProjectCustomizationState,
): Promise<ProjectCustomizationState> {
const previous = normalizeProjectCustomizationState(current);
const instructions = await this.scanInstructionFiles(projectPath, previous);
const agentProfiles = await this.scanAgentProfiles(projectPath, previous);
const promptFiles = await this.scanPromptFiles(projectPath, previous);
return mergeProjectCustomizationState(
previous,
{
instructions,
agentProfiles,
promptFiles,
},
nowIso(),
);
}
private async scanInstructionFiles(
projectPath: string,
previous: ProjectCustomizationState,
): Promise<ProjectInstructionFile[]> {
const previousByPath = new Map(previous.instructions.map((instruction) => [instruction.sourcePath, instruction]));
const sourcePaths = ['.github\\copilot-instructions.md', 'AGENTS.md'] as const;
const instructions: ProjectInstructionFile[] = [];
for (const sourcePath of sourcePaths) {
const filePath = join(projectPath, ...sourcePath.split('\\'));
const contents = await this.readProjectFile(filePath);
if (contents.kind === 'missing') {
continue;
}
if (contents.kind === 'retain-previous') {
const existing = previousByPath.get(sourcePath);
if (existing) {
instructions.push(existing);
}
continue;
}
const content = contents.value.trim();
if (!content) {
continue;
}
instructions.push({
id: buildProjectCustomizationItemId('instruction', sourcePath),
sourcePath,
content,
});
}
return instructions;
}
private async scanAgentProfiles(
projectPath: string,
previous: ProjectCustomizationState,
): Promise<ProjectAgentProfile[]> {
const previousByPath = new Map(previous.agentProfiles.map((profile) => [profile.sourcePath, profile]));
const filePaths = await this.listProjectFiles(join(projectPath, '.github', 'agents'), '.agent.md');
const profiles: ProjectAgentProfile[] = [];
for (const filePath of filePaths) {
const sourcePath = toProjectSourcePath(projectPath, filePath);
const contents = await this.readProjectFile(filePath);
if (contents.kind === 'retain-previous') {
const existing = previousByPath.get(sourcePath);
if (existing) {
profiles.push(existing);
}
continue;
}
if (contents.kind === 'missing') {
continue;
}
const parsedFile = parseProjectFrontmatter(contents.value, sourcePath);
if (!parsedFile) {
const existing = previousByPath.get(sourcePath);
if (existing) {
profiles.push(existing);
}
continue;
}
const name = readOptionalString(parsedFile.attributes, ['name'])
?? basename(filePath, '.agent.md');
const prompt = parsedFile.body.trim();
if (!name || !prompt) {
continue;
}
profiles.push({
id: buildProjectCustomizationItemId('agent', sourcePath),
name,
displayName: readOptionalString(parsedFile.attributes, ['displayName', 'display-name']),
description: readOptionalString(parsedFile.attributes, ['description']),
tools: readOptionalStringArray(parsedFile.attributes.tools),
prompt,
mcpServers: readOptionalNamedObjectMap(parsedFile.attributes['mcp-servers']),
infer: typeof parsedFile.attributes.infer === 'boolean' ? parsedFile.attributes.infer : undefined,
sourcePath,
enabled: previousByPath.get(sourcePath)?.enabled ?? true,
});
}
return profiles;
}
private async scanPromptFiles(
projectPath: string,
previous: ProjectCustomizationState,
): Promise<ProjectPromptFile[]> {
const previousByPath = new Map(previous.promptFiles.map((promptFile) => [promptFile.sourcePath, promptFile]));
const filePaths = await this.listProjectFiles(join(projectPath, '.github', 'prompts'), '.prompt.md');
const promptFiles: ProjectPromptFile[] = [];
for (const filePath of filePaths) {
const sourcePath = toProjectSourcePath(projectPath, filePath);
const contents = await this.readProjectFile(filePath);
if (contents.kind === 'retain-previous') {
const existing = previousByPath.get(sourcePath);
if (existing) {
promptFiles.push(existing);
}
continue;
}
if (contents.kind === 'missing') {
continue;
}
const parsedFile = parseProjectFrontmatter(contents.value, sourcePath);
if (!parsedFile) {
const existing = previousByPath.get(sourcePath);
if (existing) {
promptFiles.push(existing);
}
continue;
}
const template = parsedFile.body.trim();
if (!template) {
continue;
}
promptFiles.push({
id: buildProjectCustomizationItemId('prompt', sourcePath),
name: basename(filePath, '.prompt.md'),
description: readOptionalString(parsedFile.attributes, ['description']),
agent: readOptionalString(parsedFile.attributes, ['agent']),
template,
variables: extractPromptVariables(template),
sourcePath,
});
}
return promptFiles;
}
private async listProjectFiles(directoryPath: string, suffix: string): Promise<string[]> {
try {
const entries = await readdir(directoryPath, { withFileTypes: true });
return entries
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(suffix))
.map((entry) => join(directoryPath, entry.name))
.sort((left, right) => left.localeCompare(right));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return [];
}
console.warn(`[aryx customization] Failed to read directory ${directoryPath}:`, error);
return [];
}
}
private async readProjectFile(filePath: string): Promise<
| { kind: 'success'; value: string }
| { kind: 'missing' }
| { kind: 'retain-previous' }
> {
try {
return {
kind: 'success',
value: await readFile(filePath, 'utf8'),
};
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return { kind: 'missing' };
}
console.warn(`[aryx customization] Failed to read ${filePath}:`, error);
return { kind: 'retain-previous' };
}
}
}
function parseProjectFrontmatter(
contents: string,
sourcePath: string,
): { attributes: Record<string, unknown>; body: string } | undefined {
const match = /^(?:\uFEFF)?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)([\s\S]*)$/u.exec(contents);
if (!match) {
return {
attributes: {},
body: contents,
};
}
try {
const parsed = parseYaml(match[1]);
if (!isPlainObject(parsed) && parsed !== null && parsed !== undefined) {
console.warn(`[aryx customization] Ignoring non-object frontmatter in ${sourcePath}.`);
return undefined;
}
return {
attributes: isPlainObject(parsed) ? parsed : {},
body: match[2],
};
} catch (error) {
console.warn(`[aryx customization] Failed to parse frontmatter in ${sourcePath}:`, error);
return undefined;
}
}
function extractPromptVariables(template: string): ProjectPromptVariable[] {
const variables: ProjectPromptVariable[] = [];
const seenNames = new Set<string>();
let match: RegExpExecArray | null;
while ((match = promptVariablePattern.exec(template))) {
const name = match[1]?.trim();
if (!name || seenNames.has(name)) {
continue;
}
seenNames.add(name);
variables.push({
name,
placeholder: match[2]?.trim() ?? '',
});
}
promptVariablePattern.lastIndex = 0;
return variables;
}
function buildProjectCustomizationItemId(kind: 'instruction' | 'agent' | 'prompt', sourcePath: string): string {
return `project_customization_${kind}_${normalizeIdentifierSegment(sourcePath)}`;
}
function toProjectSourcePath(projectPath: string, filePath: string): string {
const relativePath = relative(projectPath, filePath).trim();
return relativePath ? relativePath.replaceAll('/', '\\') : basename(filePath);
}
function normalizeIdentifierSegment(value: string): string {
const normalized = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
return normalized.length > 0 ? normalized : 'item';
}
function readOptionalString(
record: Record<string, unknown>,
keys: ReadonlyArray<string>,
): string | undefined {
for (const key of keys) {
const value = record[key];
if (typeof value !== 'string') {
continue;
}
const trimmed = value.trim();
if (trimmed.length > 0) {
return trimmed;
}
}
return undefined;
}
function readOptionalStringArray(value: unknown): string[] | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed.length > 0 ? [trimmed] : [];
}
if (!Array.isArray(value)) {
return undefined;
}
return [...new Set(value
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0))];
}
function readOptionalNamedObjectMap(
value: unknown,
): Record<string, Record<string, unknown>> | undefined {
if (!isPlainObject(value)) {
return undefined;
}
const entries = Object.entries(value)
.map(([name, config]) => [name.trim(), normalizeYamlValue(config)] as const)
.filter(([name, config]) => name.length > 0 && isPlainObject(config))
.sort(([leftName], [rightName]) => leftName.localeCompare(rightName));
if (entries.length === 0) {
return undefined;
}
return Object.fromEntries(entries.map(([name, config]) => [name, config as Record<string, unknown>]));
}
function normalizeYamlValue(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((entry) => normalizeYamlValue(entry));
}
if (!isPlainObject(value)) {
return typeof value === 'string' ? value.trim() : value;
}
return Object.fromEntries(
Object.entries(value)
.map(([key, nestedValue]) => [key.trim(), normalizeYamlValue(nestedValue)] as const)
.filter(([key]) => key.length > 0)
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)),
);
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
+4
View File
@@ -15,8 +15,12 @@ const api: ElectronApi = {
ipcRenderer.invoke(ipcChannels.resolveWorkspaceDiscoveredTooling, input),
refreshProjectGitContext: (projectId) => ipcRenderer.invoke(ipcChannels.refreshProjectGitContext, projectId),
rescanProjectConfigs: (input) => ipcRenderer.invoke(ipcChannels.rescanProjectConfigs, input),
rescanProjectCustomization: (input) =>
ipcRenderer.invoke(ipcChannels.rescanProjectCustomization, input),
resolveProjectDiscoveredTooling: (input) =>
ipcRenderer.invoke(ipcChannels.resolveProjectDiscoveredTooling, input),
setProjectAgentProfileEnabled: (input) =>
ipcRenderer.invoke(ipcChannels.setProjectAgentProfileEnabled, input),
savePattern: (input) => ipcRenderer.invoke(ipcChannels.savePattern, input),
deletePattern: (patternId) => ipcRenderer.invoke(ipcChannels.deletePattern, patternId),
setPatternFavorite: (input) => ipcRenderer.invoke(ipcChannels.setPatternFavorite, input),
+2
View File
@@ -7,7 +7,9 @@ export const ipcChannels = {
resolveWorkspaceDiscoveredTooling: 'workspace:resolve-discovered-tooling',
refreshProjectGitContext: 'projects:refresh-git-context',
rescanProjectConfigs: 'project:rescan-configs',
rescanProjectCustomization: 'project:rescan-customization',
resolveProjectDiscoveredTooling: 'project:resolve-discovered-tooling',
setProjectAgentProfileEnabled: 'project:set-agent-profile-enabled',
savePattern: 'patterns:save',
deletePattern: 'patterns:delete',
setPatternFavorite: 'patterns:set-favorite',
+12
View File
@@ -91,12 +91,22 @@ export interface RescanProjectConfigsInput {
projectId: string;
}
export interface RescanProjectCustomizationInput {
projectId: string;
}
export interface ResolveProjectDiscoveredToolingInput {
projectId: string;
serverIds: string[];
resolution: DiscoveredToolingResolution;
}
export interface SetProjectAgentProfileEnabledInput {
projectId: string;
agentProfileId: string;
enabled: boolean;
}
export interface ResolveWorkspaceDiscoveredToolingInput {
serverIds: string[];
resolution: DiscoveredToolingResolution;
@@ -141,7 +151,9 @@ export interface ElectronApi {
resolveWorkspaceDiscoveredTooling(input: ResolveWorkspaceDiscoveredToolingInput): Promise<WorkspaceState>;
refreshProjectGitContext(projectId?: string): Promise<WorkspaceState>;
rescanProjectConfigs(input: RescanProjectConfigsInput): Promise<WorkspaceState>;
rescanProjectCustomization(input: RescanProjectCustomizationInput): Promise<WorkspaceState>;
resolveProjectDiscoveredTooling(input: ResolveProjectDiscoveredToolingInput): Promise<WorkspaceState>;
setProjectAgentProfileEnabled(input: SetProjectAgentProfileEnabledInput): Promise<WorkspaceState>;
savePattern(input: SavePatternInput): Promise<WorkspaceState>;
deletePattern(patternId: string): Promise<WorkspaceState>;
saveMcpServer(input: SaveMcpServerInput): Promise<WorkspaceState>;
+1
View File
@@ -80,6 +80,7 @@ export interface RunTurnCommand {
workspaceKind?: 'project' | 'scratchpad';
mode?: InteractionMode;
messageMode?: MessageMode;
projectInstructions?: string;
pattern: PatternDefinition;
messages: ChatMessageRecord[];
attachments?: ChatMessageAttachment[];
+2
View File
@@ -1,5 +1,6 @@
import { nowIso } from '@shared/utils/ids';
import type { ProjectDiscoveredTooling } from '@shared/domain/discoveredTooling';
import type { ProjectCustomizationState } from '@shared/domain/projectCustomization';
export type ProjectGitContextStatus = 'ready' | 'not-repository' | 'git-missing' | 'error';
@@ -39,6 +40,7 @@ export interface ProjectRecord {
addedAt: string;
git?: ProjectGitContext;
discoveredTooling?: ProjectDiscoveredTooling;
customization?: ProjectCustomizationState;
}
export const SCRATCHPAD_PROJECT_ID = 'project-scratchpad';
+276
View File
@@ -0,0 +1,276 @@
export interface ProjectInstructionFile {
id: string;
sourcePath: string;
content: string;
}
export interface ProjectAgentProfileMcpServerConfig {
[key: string]: unknown;
}
export interface ProjectAgentProfile {
id: string;
name: string;
displayName?: string;
description?: string;
tools?: string[];
prompt: string;
mcpServers?: Record<string, ProjectAgentProfileMcpServerConfig>;
infer?: boolean;
sourcePath: string;
enabled: boolean;
}
export interface ProjectPromptVariable {
name: string;
placeholder: string;
}
export interface ProjectPromptFile {
id: string;
name: string;
description?: string;
agent?: string;
template: string;
variables: ProjectPromptVariable[];
sourcePath: string;
}
export interface ProjectCustomizationState {
instructions: ProjectInstructionFile[];
agentProfiles: ProjectAgentProfile[];
promptFiles: ProjectPromptFile[];
lastScannedAt?: string;
}
export function createProjectCustomizationState(): ProjectCustomizationState {
return {
instructions: [],
agentProfiles: [],
promptFiles: [],
};
}
export function normalizeProjectCustomizationState(
value?: Partial<ProjectCustomizationState>,
): ProjectCustomizationState {
return {
instructions: (value?.instructions ?? [])
.map(normalizeProjectInstructionFile)
.filter((instruction) => instruction.content.length > 0)
.sort(compareProjectFiles),
agentProfiles: (value?.agentProfiles ?? [])
.map(normalizeProjectAgentProfile)
.filter((profile) => profile.name.length > 0 && profile.prompt.length > 0)
.sort(compareProjectFiles),
promptFiles: (value?.promptFiles ?? [])
.map(normalizeProjectPromptFile)
.filter((promptFile) => promptFile.name.length > 0 && promptFile.template.length > 0)
.sort(compareProjectFiles),
lastScannedAt: normalizeOptionalString(value?.lastScannedAt),
};
}
export function mergeProjectCustomizationState(
current: ProjectCustomizationState | undefined,
scanned: Omit<ProjectCustomizationState, 'lastScannedAt'>,
lastScannedAt: string,
): ProjectCustomizationState {
const normalizedCurrent = normalizeProjectCustomizationState(current);
const currentProfilesById = new Map(
normalizedCurrent.agentProfiles.map((profile) => [profile.id, profile]),
);
return {
instructions: scanned.instructions
.map(normalizeProjectInstructionFile)
.filter((instruction) => instruction.content.length > 0)
.sort(compareProjectFiles),
agentProfiles: scanned.agentProfiles
.map(normalizeProjectAgentProfile)
.filter((profile) => profile.name.length > 0 && profile.prompt.length > 0)
.map((profile) => ({
...profile,
enabled: currentProfilesById.get(profile.id)?.enabled ?? profile.enabled,
}))
.sort(compareProjectFiles),
promptFiles: scanned.promptFiles
.map(normalizeProjectPromptFile)
.filter((promptFile) => promptFile.name.length > 0 && promptFile.template.length > 0)
.sort(compareProjectFiles),
lastScannedAt,
};
}
export function listEnabledProjectAgentProfiles(
state?: Partial<ProjectCustomizationState>,
): ProjectAgentProfile[] {
return normalizeProjectCustomizationState(state).agentProfiles.filter((profile) => profile.enabled);
}
export function resolveProjectInstructionsContent(
state?: Partial<ProjectCustomizationState>,
): string | undefined {
const content = normalizeProjectCustomizationState(state).instructions
.map((instruction) => instruction.content)
.filter((value) => value.length > 0)
.join('\n\n')
.trim();
return content.length > 0 ? content : undefined;
}
export function setProjectAgentProfileEnabled(
state: ProjectCustomizationState | undefined,
agentProfileId: string,
enabled: boolean,
): ProjectCustomizationState {
const normalizedState = normalizeProjectCustomizationState(state);
return {
...normalizedState,
agentProfiles: normalizedState.agentProfiles.map((profile) =>
profile.id === agentProfileId
? {
...profile,
enabled,
}
: profile),
};
}
function normalizeProjectInstructionFile(file: ProjectInstructionFile): ProjectInstructionFile {
return {
id: file.id.trim(),
sourcePath: normalizePathLikeString(file.sourcePath),
content: file.content.trim(),
};
}
function normalizeProjectAgentProfile(profile: ProjectAgentProfile): ProjectAgentProfile {
const tools = normalizeOptionalStringArray(profile.tools);
const normalizedProfile: ProjectAgentProfile = {
id: profile.id.trim(),
name: profile.name.trim(),
prompt: profile.prompt.trim(),
sourcePath: normalizePathLikeString(profile.sourcePath),
enabled: profile.enabled !== false,
};
const displayName = normalizeOptionalString(profile.displayName);
if (displayName) {
normalizedProfile.displayName = displayName;
}
const description = normalizeOptionalString(profile.description);
if (description) {
normalizedProfile.description = description;
}
if (tools) {
normalizedProfile.tools = tools;
}
const mcpServers = normalizeOptionalMcpServers(profile.mcpServers);
if (mcpServers) {
normalizedProfile.mcpServers = mcpServers;
}
if (typeof profile.infer === 'boolean') {
normalizedProfile.infer = profile.infer;
}
return normalizedProfile;
}
function normalizeProjectPromptFile(promptFile: ProjectPromptFile): ProjectPromptFile {
const normalizedPromptFile: ProjectPromptFile = {
id: promptFile.id.trim(),
name: promptFile.name.trim(),
template: promptFile.template.trim(),
variables: promptFile.variables
.map((variable) => ({
name: variable.name.trim(),
placeholder: variable.placeholder.trim(),
}))
.filter((variable) => variable.name.length > 0),
sourcePath: normalizePathLikeString(promptFile.sourcePath),
};
const description = normalizeOptionalString(promptFile.description);
if (description) {
normalizedPromptFile.description = description;
}
const agent = normalizeOptionalString(promptFile.agent);
if (agent) {
normalizedPromptFile.agent = agent;
}
return normalizedPromptFile;
}
function compareProjectFiles(
left: Pick<ProjectInstructionFile | ProjectAgentProfile | ProjectPromptFile, 'sourcePath' | 'id'>,
right: Pick<ProjectInstructionFile | ProjectAgentProfile | ProjectPromptFile, 'sourcePath' | 'id'>,
): number {
return left.sourcePath.localeCompare(right.sourcePath) || left.id.localeCompare(right.id);
}
function normalizeOptionalMcpServers(
value?: Record<string, ProjectAgentProfileMcpServerConfig>,
): Record<string, ProjectAgentProfileMcpServerConfig> | undefined {
if (!value) {
return undefined;
}
const normalizedEntries = Object.entries(value)
.map(([name, config]) => [name.trim(), normalizeYamlValue(config)] as const)
.filter(([name, config]) => name.length > 0 && isPlainObject(config))
.sort(([leftName], [rightName]) => leftName.localeCompare(rightName));
if (normalizedEntries.length === 0) {
return undefined;
}
return Object.fromEntries(
normalizedEntries.map(([name, config]) => [name, config as ProjectAgentProfileMcpServerConfig]),
);
}
function normalizeOptionalString(value?: string): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function normalizeOptionalStringArray(values?: ReadonlyArray<string>): string[] | undefined {
if (!values) {
return undefined;
}
return [...new Set(values.map((value) => value.trim()).filter((value) => value.length > 0))];
}
function normalizePathLikeString(value: string): string {
return value.trim().replaceAll('/', '\\');
}
function normalizeYamlValue(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(normalizeYamlValue);
}
if (!isPlainObject(value)) {
return typeof value === 'string' ? value.trim() : value;
}
return Object.fromEntries(
Object.entries(value)
.map(([key, nestedValue]) => [key.trim(), normalizeYamlValue(nestedValue)] as const)
.filter(([key]) => key.length > 0)
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)),
);
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}