export interface PluginTerminalProviderBridge { listPluginTerminalProviders(options: NetcattyTerminalProviderQuery): Promise>; providePluginTerminal(request: NetcattyTerminalProviderRequest): Promise>; cancelPluginTerminalRequest(requestId: string): Promise; publishPluginTerminalSessionEvent(event: NetcattyTerminalSessionEvent): Promise>; onPluginContributionsChanged?(callback: () => void): () => void; } export interface PluginTerminalProviderResponse { readonly requestId: string; readonly stale: boolean; readonly results: ReadonlyArray; } export interface PluginTerminalProviderRequestOptions { readonly signal?: AbortSignal; } function freezeValue(value: T): Readonly { const clone = structuredClone(value); const freeze = (item: unknown): void => { if (!item || typeof item !== 'object' || Object.isFrozen(item)) return; for (const child of Array.isArray(item) ? item : Object.values(item)) freeze(child); Object.freeze(item); }; freeze(clone); return clone as Readonly; } function requestKey(sessionId: string, kind: NetcattyTerminalProviderKind, supersessionKey: string): string { return `${sessionId}\0${kind}\0${supersessionKey}`; } function createRequestId(): string { return `terminal-${crypto.randomUUID()}`; } function mergeLifecycleSessionSnapshot( previous: NetcattyTerminalSessionSnapshot | undefined, event: NetcattyTerminalSessionEvent, ): Readonly { const session: NetcattyTerminalSessionSnapshot = { ...(previous ?? {}), ...event.session }; if (event.type === 'disconnected' || event.type === 'reconnected') { if (!Object.hasOwn(event.session, 'cwd')) delete session.cwd; if (!Object.hasOwn(event.session, 'title')) delete session.title; if (!Object.hasOwn(event.session, 'alternateScreen')) delete session.alternateScreen; } if (event.type === 'cwdChanged' && !Object.hasOwn(event.session, 'cwd')) delete session.cwd; if (event.type === 'titleChanged' && !Object.hasOwn(event.session, 'title')) delete session.title; return freezeValue(session); } export class PluginTerminalProviderRegistry { readonly #bridge: PluginTerminalProviderBridge; readonly #activeRequests = new Map(); readonly #sessionSnapshots = new Map(); readonly #providerListeners = new Set<() => void>(); readonly #providerListCache = new Map>(); readonly #pendingProviderLists = new Map>>(); readonly #disposeContributionListener: (() => void) | undefined; #providerListGeneration = 0; #bridgeAvailability: 'unknown' | 'available' | 'unavailable' = 'unknown'; #disposed = false; constructor(bridge: PluginTerminalProviderBridge) { this.#bridge = bridge; this.#disposeContributionListener = bridge.onPluginContributionsChanged?.(() => { this.#providerListGeneration += 1; this.#bridgeAvailability = 'unknown'; this.#providerListCache.clear(); this.#pendingProviderLists.clear(); for (const requestId of this.#activeRequests.values()) { void this.#bridge.cancelPluginTerminalRequest(requestId).catch(() => false); } this.#activeRequests.clear(); for (const listener of [...this.#providerListeners]) { try { listener(); } catch { /* isolate application listeners */ } } }); } onDidChangeProviders(listener: () => void): () => void { if (this.#disposed) return () => {}; this.#providerListeners.add(listener); return () => this.#providerListeners.delete(listener); } async listProviders(query: NetcattyTerminalProviderQuery): Promise> { if (this.#disposed) return Object.freeze([]); if (this.#bridgeAvailability === 'unavailable') return Object.freeze([]); const key = JSON.stringify(query); const cached = this.#providerListCache.get(key); if (cached) return cached; const pending = this.#pendingProviderLists.get(key); if (pending) return pending; const generation = this.#providerListGeneration; const request = (async () => { try { const providers = freezeValue(await this.#bridge.listPluginTerminalProviders(query)); if (generation === this.#providerListGeneration) { if (this.#bridgeAvailability !== 'unavailable') this.#bridgeAvailability = 'available'; this.#providerListCache.set(key, providers); } return providers; } catch (error) { if (generation === this.#providerListGeneration) { this.#bridgeAvailability = 'unavailable'; this.#providerListCache.clear(); } throw error; } finally { if (this.#pendingProviderLists.get(key) === request) this.#pendingProviderLists.delete(key); } })(); this.#pendingProviderLists.set(key, request); return request; } async request( request: Omit & { supersessionKey?: string }, options: PluginTerminalProviderRequestOptions = {}, ): Promise { if (this.#disposed || options.signal?.aborted) { return Object.freeze({ requestId: '', stale: true, results: Object.freeze([]) }); } const { supersessionKey: rawSupersessionKey, ...providerRequest } = request; const supersessionKey = typeof rawSupersessionKey === 'string' && rawSupersessionKey.length > 0 && rawSupersessionKey.length <= 128 ? rawSupersessionKey : 'default'; const previousSession = this.#sessionSnapshots.get(request.session.sessionId); const session = freezeValue({ ...(previousSession ?? {}), ...request.session }); this.#sessionSnapshots.set(session.sessionId, session); const key = requestKey(session.sessionId, request.kind, supersessionKey); const previousRequestId = this.#activeRequests.get(key); if (previousRequestId) void this.#bridge.cancelPluginTerminalRequest(previousRequestId).catch(() => false); const requestId = createRequestId(); this.#activeRequests.set(key, requestId); let bridgeRequestStarted = false; let aborted = false; let resolveAborted: (() => void) | undefined; const abortedRequest = new Promise<{ readonly status: 'aborted'; }>((resolve) => { resolveAborted = () => resolve({ status: 'aborted' }); }); const onAbort = () => { if (aborted) return; aborted = true; if (this.#activeRequests.get(key) === requestId) this.#activeRequests.delete(key); if (bridgeRequestStarted) { void this.#bridge.cancelPluginTerminalRequest(requestId).catch(() => false); } resolveAborted?.(); }; options.signal?.addEventListener('abort', onAbort, { once: true }); try { if (options.signal?.aborted) onAbort(); if (aborted) { return Object.freeze({ requestId, stale: true, results: Object.freeze([]) }); } bridgeRequestStarted = true; let bridgeResponse: Promise>; try { bridgeResponse = this.#bridge.providePluginTerminal({ ...providerRequest, session, requestId, }); } catch (error) { bridgeResponse = Promise.reject(error); } const bridgeRequest = bridgeResponse .then( (results) => ({ status: 'fulfilled' as const, results }), (error: unknown) => ({ status: 'rejected' as const, error }), ); const outcome = options.signal ? await Promise.race([bridgeRequest, abortedRequest]) : await bridgeRequest; if (outcome.status === 'aborted') { return Object.freeze({ requestId, stale: true, results: Object.freeze([]) }); } if (outcome.status === 'rejected') { const stale = this.#disposed || this.#activeRequests.get(key) !== requestId; if (stale) return Object.freeze({ requestId, stale: true, results: Object.freeze([]) }); throw outcome.error; } const results = freezeValue(outcome.results); const stale = this.#disposed || this.#activeRequests.get(key) !== requestId; return Object.freeze({ requestId, stale, results: stale ? Object.freeze([]) : results, }); } catch (error) { const stale = this.#disposed || this.#activeRequests.get(key) !== requestId; if (stale) return Object.freeze({ requestId, stale: true, results: Object.freeze([]) }); throw error; } finally { options.signal?.removeEventListener('abort', onAbort); if (this.#activeRequests.get(key) === requestId) this.#activeRequests.delete(key); } } async publishSessionEvent(event: NetcattyTerminalSessionEvent): Promise { if (this.#disposed) return; const previous = this.#sessionSnapshots.get(event.session.sessionId); const session = mergeLifecycleSessionSnapshot(previous, event); if (event.type === 'disposed') this.#sessionSnapshots.delete(session.sessionId); else this.#sessionSnapshots.set(session.sessionId, session); if (this.#bridgeAvailability === 'unavailable') return; try { await this.#bridge.publishPluginTerminalSessionEvent(freezeValue({ ...event, session })); this.#bridgeAvailability = 'available'; } catch (error) { this.#bridgeAvailability = 'unavailable'; throw error; } } cancelSession(sessionId: string): void { this.#sessionSnapshots.delete(sessionId); for (const [key, requestId] of [...this.#activeRequests]) { if (!key.startsWith(`${sessionId}\0`)) continue; this.#activeRequests.delete(key); void this.#bridge.cancelPluginTerminalRequest(requestId).catch(() => false); } } dispose(): void { if (this.#disposed) return; this.#disposed = true; this.#providerListGeneration += 1; for (const requestId of this.#activeRequests.values()) { void this.#bridge.cancelPluginTerminalRequest(requestId).catch(() => false); } this.#activeRequests.clear(); this.#sessionSnapshots.clear(); this.#providerListeners.clear(); this.#providerListCache.clear(); this.#pendingProviderLists.clear(); this.#disposeContributionListener?.(); } } export async function collectPluginTerminalProviderKinds( registry: PluginTerminalProviderRegistry | null, kinds: readonly NetcattyTerminalProviderKind[], ): Promise> { if (!registry) return new Set(); try { const enumerations = await Promise.all(kinds.map(async (kind) => ({ kind, providers: await registry.listProviders({ kind }), }))); return new Set(enumerations .filter((entry) => entry.providers.length > 0) .map((entry) => entry.kind)); } catch { return new Set(); } } export function isPluginTerminalProviderKindAvailable( kinds: ReadonlySet | null, kind: NetcattyTerminalProviderKind, ): boolean { return kinds?.has(kind) ?? false; } export class PluginTerminalProviderAvailability { #generation = 0; #kinds: ReadonlySet | null = null; async refresh( registry: PluginTerminalProviderRegistry | null, kinds: readonly NetcattyTerminalProviderKind[], ): Promise { const generation = ++this.#generation; const next = await collectPluginTerminalProviderKinds(registry, kinds); if (generation !== this.#generation) return false; this.#kinds = next; return true; } has(kind: NetcattyTerminalProviderKind): boolean { return isPluginTerminalProviderKindAvailable(this.#kinds, kind); } } export function createWindowPluginTerminalProviderRegistry( bridge: NetcattyBridge | undefined = typeof window === 'undefined' ? undefined : netcattyBridge.get(), ): PluginTerminalProviderRegistry | null { if (!bridge?.listPluginTerminalProviders || !bridge.providePluginTerminal || !bridge.cancelPluginTerminalRequest || !bridge.publishPluginTerminalSessionEvent) return null; return new PluginTerminalProviderRegistry({ listPluginTerminalProviders: (options) => bridge.listPluginTerminalProviders!(options), providePluginTerminal: (request) => bridge.providePluginTerminal!(request), cancelPluginTerminalRequest: (requestId) => bridge.cancelPluginTerminalRequest!(requestId), publishPluginTerminalSessionEvent: (event) => bridge.publishPluginTerminalSessionEvent!(event), onPluginContributionsChanged: bridge.onPluginContributionsChanged ? (callback) => bridge.onPluginContributionsChanged!(callback) : undefined, }); } let windowRegistry: PluginTerminalProviderRegistry | null | undefined; export function getWindowPluginTerminalProviderRegistry(): PluginTerminalProviderRegistry | null { if (windowRegistry !== undefined) return windowRegistry; if (typeof window === 'undefined') return null; windowRegistry = createWindowPluginTerminalProviderRegistry(netcattyBridge.get()); return windowRegistry; } import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';