import type { TypedSkillDiagnostics } from "./skills/types.ts"; import type { ArgumentDefinitions, TypedCommandLookup } from "./types.ts"; import type { RegisteredTypedCommand } from "./pi/command-types.ts"; export type { TypedCommandLookup } from "./types.ts"; export type TypedCommandRegistryListener = () => void; type RegistrationRecord = { readonly id: symbol; readonly ownerId: symbol; readonly source: "extension" | "skill"; readonly localName: string; readonly invocationName: string; readonly command: RegisteredTypedCommand; }; /** Narrow registry subscription consumed by live Pi UI sessions. */ export type TypedCommandSubscription = { onChanged(listener: TypedCommandRegistryListener): () => void; }; export type RegisterTypedCommandMetadataOptions = { readonly invocationName?: string; readonly ownerId?: symbol; readonly id?: symbol; }; const DEFAULT_OWNER_ID = Symbol("pi-typed-args.owner.default"); function commandWithRegistration(record: RegistrationRecord): RegisteredTypedCommand { return { ...record.command, invocationName: record.invocationName, registrationId: record.id, ownerId: record.ownerId, }; } /** * Instance-owned typed-command metadata registry. * * Each instance is isolated. Pi's optional process-wide discovery is composed separately in the * Pi adapter so core consumers and tests never depend on ambient global state. */ export class TypedCommandRegistry implements TypedCommandLookup, TypedCommandSubscription { readonly #commands = new Map(); readonly #records = new Map(); readonly #skillDiagnostics = new Map(); readonly #listeners = new Set(); register( command: RegisteredTypedCommand, options: RegisterTypedCommandMetadataOptions = {}, ): string { const invocationName = this.registerWithoutNotification(command, options); this.notifyListeners(); return invocationName; } unregister( command: RegisteredTypedCommand, ): void { const record = this.findExisting(command); if (record === undefined) { return; } this.deleteRecord(record); this.notifyListeners(); } replaceSkills( commands: readonly RegisteredTypedCommand[], diagnostics: readonly TypedSkillDiagnostics[] = [], ): void { for (const record of this.#records.values()) { if (record.source === "skill") { this.deleteRecord(record); } } this.#skillDiagnostics.clear(); for (const command of commands) { this.registerWithoutNotification(command, {}); } for (const diagnostic of diagnostics) { this.#skillDiagnostics.set(`skill:${diagnostic.name}`, diagnostic); } this.notifyListeners(); } get(name: string): RegisteredTypedCommand | undefined { const record = this.#commands.get(name); if (record === undefined) { return undefined; } return commandWithRegistration(record); } list(): readonly RegisteredTypedCommand[] { return [...this.#commands.values()] .map(commandWithRegistration) .sort((left, right) => (left.invocationName ?? left.name).localeCompare( right.invocationName ?? right.name, ), ); } getSkillDiagnostics(name: string): TypedSkillDiagnostics | undefined { return this.#skillDiagnostics.get(name); } onChanged(listener: TypedCommandRegistryListener): () => void { this.#listeners.add(listener); return () => { this.#listeners.delete(listener); }; } private notifyListeners(): void { for (const listener of this.#listeners) { listener(); } } private registerWithoutNotification( command: RegisteredTypedCommand, options: RegisterTypedCommandMetadataOptions, ): string { this.removeExisting(command); const id = options.id ?? Symbol(command.name); const recordWithId = this.#records.get(id); if (recordWithId !== undefined) { this.deleteRecord(recordWithId); } const ownerId = options.ownerId ?? DEFAULT_OWNER_ID; const invocationName = options.invocationName ?? this.nextInvocationName(command.name); const recordWithInvocationName = this.#commands.get(invocationName); if (recordWithInvocationName !== undefined) { this.deleteRecord(recordWithInvocationName); } const record: RegistrationRecord = { id, ownerId, source: command.source ?? "extension", localName: command.name, invocationName, command, }; this.#records.set(id, record); this.#commands.set(invocationName, record); return invocationName; } private nextInvocationName(localName: string): string { if (!this.#commands.has(localName)) { return localName; } let suffix = 1; while (this.#commands.has(`${localName}:${suffix}`)) { suffix += 1; } return `${localName}:${suffix}`; } private findExisting(command: RegisteredTypedCommand): RegistrationRecord | undefined { if (command.registrationId !== undefined) { return this.#records.get(command.registrationId); } for (const record of this.#records.values()) { if (record.command === command) { return record; } } return undefined; } private removeExisting(command: RegisteredTypedCommand): void { const record = this.findExisting(command); if (record !== undefined) { this.deleteRecord(record); } } private deleteRecord(record: RegistrationRecord): void { if (this.#commands.get(record.invocationName) === record) { this.#commands.delete(record.invocationName); } this.#records.delete(record.id); } } /** Create an isolated command registry for an application composition root or test. */ export function createTypedCommandRegistry(): TypedCommandRegistry { return new TypedCommandRegistry(); }