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);
}