feat: add structured prompt invocation backend

- parse prompt tools metadata and carry structured promptInvocation payloads
- store prompt invocation metadata on trigger messages for replay-safe reruns
- route prompt agents through per-turn plan or Copilot agent overrides
- restrict prompt-scoped tools in sidecar session configuration
- auto-rescan project customization files with debounced watchers
- document the new customization watcher and prompt invocation flow

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-04-02 11:41:18 +02:00
co-authored by Copilot
parent aa7830f01a
commit 5d69d9d855
21 changed files with 900 additions and 47 deletions
+84 -2
View File
@@ -8,6 +8,7 @@ import type {
AgentActivityEvent,
ApprovalRequestedEvent,
ExitPlanModeRequestedEvent,
InteractionMode,
MessageMode,
McpOauthRequiredEvent,
MessageReclassifiedEvent,
@@ -44,10 +45,12 @@ import {
} from '@shared/domain/discoveredTooling';
import {
listEnabledProjectAgentProfiles,
normalizeProjectPromptInvocation,
normalizeProjectCustomizationState,
resolveProjectInstructionsContent,
setProjectAgentProfileEnabled,
type ProjectAgentProfile,
type ProjectPromptInvocation,
type ProjectCustomizationState,
} from '@shared/domain/projectCustomization';
import {
@@ -137,6 +140,7 @@ 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 { ProjectCustomizationWatcher } from '@main/services/projectCustomizationWatcher';
import {
SIDECAR_STOPPED_BEFORE_COMPLETION_MESSAGE,
SidecarClient,
@@ -199,6 +203,18 @@ function equalStringArrays(left?: readonly string[], right?: readonly string[]):
return normalizedLeft.every((value, index) => value === normalizedRight[index]);
}
function buildPromptInvocationFallbackContent(promptInvocation?: ProjectPromptInvocation): string | undefined {
if (!promptInvocation) {
return undefined;
}
return `Run prompt file: ${promptInvocation.name}`;
}
function isPlanPromptInvocation(promptInvocation?: ProjectPromptInvocation): boolean {
return promptInvocation?.agent?.trim().toLowerCase() === 'plan';
}
function isSidecarStoppedBeforeCompletionError(error: unknown): error is Error {
return error instanceof Error && error.message === SIDECAR_STOPPED_BEFORE_COMPLETION_MESSAGE;
}
@@ -227,6 +243,8 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
private readonly gitService = new GitService();
private readonly configScanner = new ConfigScannerRegistry();
private readonly customizationScanner = new ProjectCustomizationScanner();
private readonly projectCustomizationWatcher = new ProjectCustomizationWatcher((projectId) =>
this.handleProjectCustomizationWatcherChange(projectId));
private readonly probeMcpServers = probeServers;
private readonly ptyManager = new PtyManager();
private readonly pendingApprovalHandles = new Map<string, PendingApprovalHandle>();
@@ -243,6 +261,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
private projectGitRefreshTimer?: ReturnType<typeof setTimeout>;
private periodicProjectGitRefreshTimer?: ReturnType<typeof setInterval>;
private runningProjectGitRefresh?: Promise<void>;
private customizationWatcherUpdateQueue = Promise.resolve();
constructor() {
super();
@@ -289,6 +308,8 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
) {
await this.workspaceRepository.save(this.workspace);
}
await this.syncProjectCustomizationWatchers(this.workspace);
}
if (!this.didScheduleInitialProjectGitRefresh) {
@@ -322,6 +343,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
clearInterval(this.periodicProjectGitRefreshTimer);
this.periodicProjectGitRefreshTimer = undefined;
}
this.projectCustomizationWatcher.dispose();
this.ptyManager.dispose();
await this.sidecar.dispose();
void this.secretStore;
@@ -356,6 +378,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
}
async resetLocalWorkspace(): Promise<WorkspaceState> {
this.projectCustomizationWatcher.dispose();
await this.sidecar.dispose();
try {
@@ -392,6 +415,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
workspace.selectedProjectId = existing.id;
const didSyncProjectTooling = await this.syncProjectDiscoveredTooling(workspace, existing);
await this.syncProjectCustomization(existing);
await this.syncProjectCustomizationWatchers(workspace);
if (didSyncProjectTooling) {
this.pruneUnavailableSessionToolingSelections(workspace);
await this.pruneUnavailableApprovalTools(workspace);
@@ -411,6 +435,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
workspace.selectedProjectId = project.id;
await this.syncProjectDiscoveredTooling(workspace, project);
await this.syncProjectCustomization(project);
await this.syncProjectCustomizationWatchers(workspace);
return this.persistAndBroadcast(workspace);
}
@@ -434,6 +459,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
workspace.selectedSessionId = undefined;
}
await this.syncProjectCustomizationWatchers(workspace);
return this.persistAndBroadcast(workspace);
}
@@ -480,6 +506,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
const workspace = await this.loadWorkspace();
const project = this.requireProject(workspace, projectId);
await this.syncProjectCustomization(project);
await this.syncProjectCustomizationWatchers(workspace);
return this.persistAndBroadcast(workspace);
}
@@ -993,6 +1020,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
content: string,
attachments?: ChatMessageAttachment[],
messageMode?: MessageMode,
promptInvocation?: ProjectPromptInvocation,
): Promise<void> {
const workspace = await this.loadWorkspace();
const session = this.requireSession(workspace, sessionId);
@@ -1009,7 +1037,9 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
);
const projectInstructions = resolveProjectInstructionsContent(project.customization);
const preparedContent = prepareChatMessageContent(content);
const normalizedPromptInvocation = normalizeProjectPromptInvocation(promptInvocation);
const preparedContent = prepareChatMessageContent(content)
?? buildPromptInvocationFallbackContent(normalizedPromptInvocation);
if (!preparedContent) {
return;
}
@@ -1024,6 +1054,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
content: preparedContent,
createdAt: occurredAt,
attachments: attachments?.length ? attachments : undefined,
promptInvocation: normalizedPromptInvocation,
});
await this.runPreparedSessionTurn(workspace, session, project, effectivePattern, projectInstructions, {
occurredAt,
@@ -1390,6 +1421,10 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
): Promise<void> {
const workspaceKind = isScratchpadProject(project) ? 'scratchpad' : 'project';
const { occurredAt, requestId, triggerMessageId, messageMode, attachments } = options;
const promptInvocation = this.resolveRunTurnPromptInvocation(session, triggerMessageId);
const interactionMode: InteractionMode = isPlanPromptInvocation(promptInvocation)
? 'plan'
: session.interactionMode ?? 'interactive';
const runWorkingDirectory = session.cwd ?? project.path;
const preRunGitSnapshot = workspaceKind === 'project'
? await this.gitService.captureWorkingTreeSnapshot(runWorkingDirectory, occurredAt)
@@ -1439,12 +1474,13 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
sessionId: session.id,
projectPath: runWorkingDirectory,
workspaceKind,
mode: session.interactionMode ?? 'interactive',
mode: interactionMode,
messageMode,
projectInstructions,
pattern: effectivePattern,
messages: session.messages,
attachments: attachments?.length ? attachments : undefined,
promptInvocation,
tooling: this.buildRunTurnToolingConfig(workspace, session),
resumeFromCheckpoint,
});
@@ -3002,6 +3038,52 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
return true;
}
private async syncProjectCustomizationWatchers(workspace: WorkspaceState): Promise<void> {
await this.projectCustomizationWatcher.syncProjects(
workspace.projects
.filter((project) => !isScratchpadProject(project))
.map((project) => ({
id: project.id,
path: project.path,
})),
);
}
private resolveRunTurnPromptInvocation(
session: SessionRecord,
triggerMessageId: string,
): ProjectPromptInvocation | undefined {
const triggerMessage = session.messages.find((message) => message.id === triggerMessageId);
return normalizeProjectPromptInvocation(triggerMessage?.promptInvocation);
}
private async handleProjectCustomizationWatcherChange(projectId: string): Promise<void> {
await this.enqueueCustomizationWatcherUpdate(async () => {
const workspace = await this.loadWorkspace();
const project = workspace.projects.find((candidate) => candidate.id === projectId);
await this.syncProjectCustomizationWatchers(workspace);
if (!project || isScratchpadProject(project)) {
return;
}
const didSyncProjectCustomization = await this.syncProjectCustomization(project);
await this.syncProjectCustomizationWatchers(workspace);
if (didSyncProjectCustomization) {
await this.persistAndBroadcast(workspace);
}
});
}
private enqueueCustomizationWatcherUpdate(task: () => Promise<void>): Promise<void> {
const scheduledTask = this.customizationWatcherUpdateQueue.then(task, task);
this.customizationWatcherUpdateQueue = scheduledTask.then(
() => undefined,
() => undefined,
);
return scheduledTask;
}
private async syncProjectCustomization(project: ProjectRecord): Promise<boolean> {
if (isScratchpadProject(project)) {
if (!project.customization || this.equalProjectCustomizationState(project.customization, undefined)) {
+7 -1
View File
@@ -201,7 +201,13 @@ export function registerIpcHandlers(
service.editAndResendSessionMessage(input.sessionId, input.messageId, input.content, input.attachments),
);
ipcMain.handle(ipcChannels.sendSessionMessage, (_event, input: SendSessionMessageInput) =>
service.sendSessionMessage(input.sessionId, input.content, input.attachments, input.messageMode),
service.sendSessionMessage(
input.sessionId,
input.content,
input.attachments,
input.messageMode,
input.promptInvocation,
),
);
ipcMain.handle(ipcChannels.cancelSessionTurn, (_event, input: CancelSessionTurnInput) =>
service.cancelSessionTurn(input.sessionId),
+6 -1
View File
@@ -6,7 +6,11 @@ import { isScratchpadProject, mergeScratchpadProject } from '@shared/domain/proj
import { normalizeDiscoveredToolingState } from '@shared/domain/discoveredTooling';
import { normalizeProjectCustomizationState } from '@shared/domain/projectCustomization';
import { normalizeSessionRunRecords } from '@shared/domain/runTimeline';
import { normalizeSessionBranchOrigin, type SessionRecord } from '@shared/domain/session';
import {
normalizeChatMessageRecord,
normalizeSessionBranchOrigin,
type SessionRecord,
} from '@shared/domain/session';
import {
normalizeSessionToolingSelection,
normalizeWorkspaceSettings,
@@ -81,6 +85,7 @@ export class WorkspaceRepository {
const sessions = await Promise.all((stored.sessions ?? []).map(async (session): Promise<SessionRecord> => {
const normalizedSession: SessionRecord = {
...session,
messages: (session.messages ?? []).map(normalizeChatMessageRecord),
branchOrigin: normalizeSessionBranchOrigin(session.branchOrigin),
runs: normalizeSessionRunRecords(session.runs),
tooling: normalizeSessionToolingSelection(session.tooling),
@@ -169,6 +169,7 @@ export class ProjectCustomizationScanner {
name: readOptionalString(parsedFile.attributes, ['name']) ?? basename(filePath, '.prompt.md'),
description: readOptionalString(parsedFile.attributes, ['description']),
agent: readOptionalString(parsedFile.attributes, ['agent']),
tools: readOptionalStringArray(parsedFile.attributes.tools),
template,
variables: extractPromptVariables(template),
sourcePath,
@@ -0,0 +1,169 @@
import { watch } from 'node:fs';
import { readdir } from 'node:fs/promises';
import { join } from 'node:path';
export interface ProjectCustomizationWatchTarget {
id: string;
path: string;
}
type ProjectWatchHandle = {
close(): void;
};
type ProjectWatchFactory = (
directoryPath: string,
onChange: () => void,
) => ProjectWatchHandle;
type ProjectWatchPathResolver = (projectPath: string) => Promise<string[]>;
export class ProjectCustomizationWatcher {
private readonly watchFactory: ProjectWatchFactory;
private readonly resolveWatchPaths: ProjectWatchPathResolver;
private readonly debounceMs: number;
private readonly watchHandlesByProjectId = new Map<string, Map<string, ProjectWatchHandle>>();
private readonly pendingTimers = new Map<string, ReturnType<typeof setTimeout>>();
constructor(
private readonly onChange: (projectId: string) => void | Promise<void>,
options?: {
watchFactory?: ProjectWatchFactory;
resolveWatchPaths?: ProjectWatchPathResolver;
debounceMs?: number;
},
) {
this.watchFactory = options?.watchFactory ?? createProjectWatchHandle;
this.resolveWatchPaths = options?.resolveWatchPaths ?? collectProjectCustomizationWatchPaths;
this.debounceMs = options?.debounceMs ?? 250;
}
async syncProjects(projects: ReadonlyArray<ProjectCustomizationWatchTarget>): Promise<void> {
const nextProjectsById = new Map(projects.map((project) => [project.id, project]));
for (const projectId of this.watchHandlesByProjectId.keys()) {
if (!nextProjectsById.has(projectId)) {
this.unwatchProject(projectId);
}
}
for (const project of projects) {
await this.syncProject(project);
}
}
dispose(): void {
for (const projectId of this.watchHandlesByProjectId.keys()) {
this.unwatchProject(projectId);
}
}
private async syncProject(project: ProjectCustomizationWatchTarget): Promise<void> {
const nextWatchPaths = new Set(await this.resolveWatchPaths(project.path));
const currentWatchHandles = this.watchHandlesByProjectId.get(project.id) ?? new Map<string, ProjectWatchHandle>();
for (const [watchPath, handle] of currentWatchHandles) {
if (nextWatchPaths.has(watchPath)) {
continue;
}
handle.close();
currentWatchHandles.delete(watchPath);
}
for (const watchPath of nextWatchPaths) {
if (currentWatchHandles.has(watchPath)) {
continue;
}
try {
currentWatchHandles.set(watchPath, this.watchFactory(watchPath, () => this.scheduleChange(project.id)));
} catch (error) {
console.warn(`[aryx customization] Failed to watch ${watchPath}:`, error);
}
}
if (currentWatchHandles.size > 0) {
this.watchHandlesByProjectId.set(project.id, currentWatchHandles);
return;
}
this.watchHandlesByProjectId.delete(project.id);
}
private unwatchProject(projectId: string): void {
const timer = this.pendingTimers.get(projectId);
if (timer) {
clearTimeout(timer);
this.pendingTimers.delete(projectId);
}
const watchHandles = this.watchHandlesByProjectId.get(projectId);
if (!watchHandles) {
return;
}
for (const handle of watchHandles.values()) {
handle.close();
}
this.watchHandlesByProjectId.delete(projectId);
}
private scheduleChange(projectId: string): void {
const existingTimer = this.pendingTimers.get(projectId);
if (existingTimer) {
clearTimeout(existingTimer);
}
const timer = setTimeout(() => {
this.pendingTimers.delete(projectId);
void Promise.resolve(this.onChange(projectId)).catch((error) => {
console.warn(`[aryx customization] Failed to process watcher update for ${projectId}:`, error);
});
}, this.debounceMs);
timer.unref?.();
this.pendingTimers.set(projectId, timer);
}
}
export async function collectProjectCustomizationWatchPaths(projectPath: string): Promise<string[]> {
const paths = new Set<string>([projectPath]);
for (const relativeRoot of ['.github', '.claude']) {
for (const directoryPath of await collectExistingDirectories(join(projectPath, relativeRoot))) {
paths.add(directoryPath);
}
}
return [...paths].sort((left, right) => left.localeCompare(right));
}
async function collectExistingDirectories(rootPath: string): Promise<string[]> {
try {
const directories = [rootPath];
const entries = await readdir(rootPath, { withFileTypes: true });
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
if (!entry.isDirectory()) {
continue;
}
directories.push(...await collectExistingDirectories(join(rootPath, entry.name)));
}
return directories;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return [];
}
console.warn(`[aryx customization] Failed to enumerate watch paths under ${rootPath}:`, error);
return [];
}
}
function createProjectWatchHandle(directoryPath: string, onChange: () => void): ProjectWatchHandle {
return watch(directoryPath, { persistent: false }, () => {
onChange();
});
}
+2
View File
@@ -20,6 +20,7 @@ import type {
} from '@shared/domain/tooling';
import type { WorkspaceState } from '@shared/domain/workspace';
import type { ChatMessageAttachment } from '@shared/domain/attachment';
import type { ProjectPromptInvocation } from '@shared/domain/projectCustomization';
export interface CreateSessionInput {
projectId: string;
@@ -35,6 +36,7 @@ export interface SendSessionMessageInput {
content: string;
attachments?: ChatMessageAttachment[];
messageMode?: MessageMode;
promptInvocation?: ProjectPromptInvocation;
}
export interface CancelSessionTurnInput {
+2
View File
@@ -3,6 +3,7 @@ import type { ApprovalCheckpointKind, ApprovalDecision } from '@shared/domain/ap
import type { ChatMessageRecord } from '@shared/domain/session';
import type { RuntimeToolDefinition } from '@shared/domain/tooling';
import type { ChatMessageAttachment } from '@shared/domain/attachment';
import type { ProjectPromptInvocation } from '@shared/domain/projectCustomization';
export interface SidecarModeCapability {
available: boolean;
@@ -90,6 +91,7 @@ export interface RunTurnCommand {
pattern: PatternDefinition;
messages: ChatMessageRecord[];
attachments?: ChatMessageAttachment[];
promptInvocation?: ProjectPromptInvocation;
tooling?: RunTurnToolingConfig;
resumeFromCheckpoint?: WorkflowCheckpointResume;
}
+56
View File
@@ -37,11 +37,22 @@ export interface ProjectPromptFile {
name: string;
description?: string;
agent?: string;
tools?: string[];
template: string;
variables: ProjectPromptVariable[];
sourcePath: string;
}
export interface ProjectPromptInvocation {
id: string;
name: string;
sourcePath: string;
resolvedPrompt: string;
description?: string;
agent?: string;
tools?: string[];
}
export interface ProjectCustomizationState {
instructions: ProjectInstructionFile[];
agentProfiles: ProjectAgentProfile[];
@@ -262,9 +273,54 @@ function normalizeProjectPromptFile(promptFile: ProjectPromptFile): ProjectPromp
normalizedPromptFile.agent = agent;
}
const tools = normalizeOptionalStringArray(promptFile.tools);
if (tools) {
normalizedPromptFile.tools = tools;
}
return normalizedPromptFile;
}
export function normalizeProjectPromptInvocation(
promptInvocation?: ProjectPromptInvocation,
): ProjectPromptInvocation | undefined {
if (!promptInvocation) {
return undefined;
}
const normalizedPromptInvocation: ProjectPromptInvocation = {
id: promptInvocation.id.trim(),
name: promptInvocation.name.trim(),
sourcePath: normalizePathLikeString(promptInvocation.sourcePath),
resolvedPrompt: promptInvocation.resolvedPrompt.trim(),
};
if (
normalizedPromptInvocation.id.length === 0
|| normalizedPromptInvocation.name.length === 0
|| normalizedPromptInvocation.resolvedPrompt.length === 0
) {
return undefined;
}
const description = normalizeOptionalString(promptInvocation.description);
if (description) {
normalizedPromptInvocation.description = description;
}
const agent = normalizeOptionalString(promptInvocation.agent);
if (agent) {
normalizedPromptInvocation.agent = agent;
}
const tools = normalizeOptionalStringArray(promptInvocation.tools);
if (tools) {
normalizedPromptInvocation.tools = tools;
}
return normalizedPromptInvocation;
}
function compareProjectFiles(
left: Pick<ProjectInstructionFile | ProjectAgentProfile | ProjectPromptFile, 'sourcePath' | 'id'>,
right: Pick<ProjectInstructionFile | ProjectAgentProfile | ProjectPromptFile, 'sourcePath' | 'id'>,
+19
View File
@@ -16,6 +16,10 @@ import type { PendingPlanReviewRecord } from '@shared/domain/planReview';
import type { PendingMcpAuthRecord } from '@shared/domain/mcpAuth';
import type { ChatMessageAttachment } from '@shared/domain/attachment';
import type { InteractionMode } from '@shared/contracts/sidecar';
import {
normalizeProjectPromptInvocation,
type ProjectPromptInvocation,
} from '@shared/domain/projectCustomization';
export type ChatRole = 'system' | 'user' | 'assistant';
export type ChatMessageKind = 'response' | 'thinking';
@@ -38,6 +42,7 @@ export interface ChatMessageRecord {
isPinned?: boolean;
pending?: boolean;
attachments?: ChatMessageAttachment[];
promptInvocation?: ProjectPromptInvocation;
}
export interface SessionBranchOrigin {
@@ -166,6 +171,20 @@ export function resolveSessionModelConfig(
};
}
export function normalizeChatMessageRecord(message: ChatMessageRecord): ChatMessageRecord {
const normalizedMessage: ChatMessageRecord = {
...message,
};
const promptInvocation = normalizeProjectPromptInvocation(message.promptInvocation);
if (promptInvocation) {
normalizedMessage.promptInvocation = promptInvocation;
} else {
delete normalizedMessage.promptInvocation;
}
return normalizedMessage;
}
export function applySessionModelConfig(
pattern: PatternDefinition,
session: SessionRecord,
+20
View File
@@ -1,6 +1,10 @@
import type { PatternDefinition } from '@shared/domain/pattern';
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
import type { ChatMessageAttachment } from '@shared/domain/attachment';
import {
normalizeProjectPromptInvocation,
type ProjectPromptInvocation,
} from '@shared/domain/projectCustomization';
import {
resolveSessionTitle,
type ChatMessageRecord,
@@ -175,11 +179,26 @@ function cloneAttachments(attachments?: ChatMessageAttachment[]): ChatMessageAtt
return cloned && cloned.length > 0 ? cloned : undefined;
}
function clonePromptInvocation(
promptInvocation?: ProjectPromptInvocation,
): ProjectPromptInvocation | undefined {
const cloned = normalizeProjectPromptInvocation(promptInvocation);
if (!cloned) {
return undefined;
}
return {
...cloned,
tools: cloned.tools ? [...cloned.tools] : undefined,
};
}
function cloneChatMessageRecord(message: ChatMessageRecord): ChatMessageRecord {
return {
...message,
pending: false,
attachments: cloneAttachments(message.attachments),
promptInvocation: clonePromptInvocation(message.promptInvocation),
};
}
@@ -386,6 +405,7 @@ export function editAndResendSessionRecord(
editedMessage.content = content;
editedMessage.attachments = cloneAttachments(attachments);
editedMessage.promptInvocation = undefined;
return {
...createDerivedSessionRecord(session, sessionId, editedAt),