import type { ProtocolEvents, ModuleConfigEnvelope as ProtocolModuleConfigEnvelope, ModuleIdentity as ProtocolModuleIdentity, ModulePhase as ProtocolModulePhase, PluginIdentity as ProtocolPluginIdentity, } from '@proj-airi/plugin-protocol/types' import type { ActorRefFrom } from 'xstate' import type { definePlugin } from '../plugin' import type { createApis } from '../plugin/apis/client' import type { CapabilityDescriptor } from '../plugin/apis/protocol' import type { Plugin } from '../plugin/shared' import type { PluginTransport } from './transports' import { join } from 'node:path' import { cwd } from 'node:process' import { defineInvokeHandler } from '@moeru/eventa' import { moduleAnnounce, moduleAuthenticate, moduleAuthenticated, moduleCompatibilityRequest, moduleCompatibilityResult, moduleConfigurationConfigured, moduleConfigurationNeeded, modulePrepared, moduleStatus, registryModulesSync, } from '@proj-airi/plugin-protocol/types' import { literal, object, optional, string, } from 'valibot' import { createActor, createMachine } from 'xstate' import { createApis as createBoundApis } from '../plugin/apis/client' import { protocolCapabilitySnapshot, protocolCapabilityWait } from '../plugin/apis/protocol' import { protocolListProvidersEventName, protocolProviders } from '../plugin/apis/protocol/resources/providers' import { createPluginContext } from './runtimes/node' /** * Plugin Host lifecycle overview (transport-aware): * * - The host loads a plugin entrypoint (local or remote). * - The host resolves a per-plugin transport (in-memory, worker, WebSocket, electron). * - The host creates an Eventa context bound to that transport. * - The host binds SDK APIs to the context and passes them into plugin.init. * * This design allows multiple plugins in one host without shared global channels. * Each plugin instance has its own context and transport, so local and remote * plugins share the same API surface while remaining isolated. */ /** * One plugin could contribute multiple modules. * * For plugin itself, there are two ways to implement it, either local plugin, or remote plugin. * Since we have @moeru/eventa as underlying event transmission, we can drive everything in event. * * It's ok that local plugin doesn't implement the remote protocol to handle the remote plugin * RPC if doesn't wish for. Purely local UI manipulation or local resource registration is normal. * * In another word, we could implement the plugin in same eventa definition, while switching * between two different transport. * * For local plugin, local context for in-memory transport will be used. * For remote plugin, server-runtime for WebSocket based transport will be used. * * * The procedure looks like this (regardless to the underlying transport since we will implement * in both): * * 0. Channel Gateway sits on top of all channels * 1. Connect to control plane channel (from plugin-sdk, or any language implementation will impl) * 2. Authenticate with module:authenticate * 3. Negotiate protocol/api compatibility before lifecycle work starts: * 1. Plugin sends module:compatibility:request with: * - plugin protocol version * - plugin sdk api version * - optional supported ranges for backward/forward compatibility * 2. Plugin Host replies module:compatibility:result with: * - accepted version tuple (protocol + api) * - compatibility mode (exact, downgraded, rejected) * - deterministic reason if rejected * 3. If rejected, host MUST stop initialization for that plugin and emit module:status * with incompatible-version details for Configurator visibility. * 4. Plugin Host will send registry:modules:sync, this ensures the auto plugin / dependency discovery * 5. Module will now announce itself to the entire system through module:announce * 6. Module will now sync to Plugin Host that module now preparing, declaring its: * 1. Dependencies to other plugins / modules * 2. Initial Configuration (doesn't relate to capabilities) * Note that for capabilities requires Database configuration, and perhaps Memory manipulation, * plugin should orchestrate itself to contribute many capabilities / features, and the needed * configurations and credentials should be requested and configured for each capabilities * instead. * 7. During this phase, if module failed to find the needed dependency, module:status will be emitted * to allow the Plugin Host to surface errors or notice up to Configurator layer, to display the * needed warning and status. * * It's ok for module to stay online / connected to channels. In this phase, module:announce * could happen multiple times. Module is ok to listen to the sync events and decide whether to enter * the next phases if needed. * 8. During this phase, if plugin successfully configured itself and calculated / computed the possible * contributing capabilities / features, it will emit module:prepared. * 9. During this phase, if module requires more configuration to fill and enable in order to go next * phase, it's ok, it will emit module:configuration:needed. * 10. Module should now emit module:prepared. * 11. Module should now emit module:configuration:needed, for telling the shape to Configurator. * In between, for user side / Configurator side: * - module:configuration:validate:request (static check, zod/valibot or programmatic checks) * - module:configuration:validate:status (with parent event id) * - module:configuration:validate:response * - module:configuration:plan:request (actually dry-run, ensures anything during runtime works) * - module:configuration:plan:status (with parent event id) * - module:configuration:plan:response * - module:configuration:commit * - module:configuration:commit:status (with parent event id) * 12. Module previously configured will get validate, plan, and commit automatically, if failed, status * will surface to the Configurator side for further noticing to user. * 13. Module should now emit module:configuration:configured. * 14. Module should now be able to calculate / compute possible capabilities / features to be able to * contribute to the system / Plugin Host, once calculated, module:contribute:capability:offer will * be emitted in (length of) capabilities times. * * This means for 1 module that offers 5 capabilities, 5 * module:contribute:capability:offer will * be emitted. * 15. Next, module will now enter the capability / feature fill-in phase, during this phase, it's ok * to say that the plugin is running but nothing gets contributed if none of them were configured. * * For any capabilities without further configuration and fill-in from Configurator and User side, * it can be automatically activated now (which is next phase for module:contribute:capability:* * events), module:contribute:capability:configuration:configured, * module:contribute:capability:activated will be emitted. * * If further configuration and actions needed, module:contribute:capability:configuration:needed * will be emitted. * * To configure the capabilities in sequence and correct order, * - module:contribute:capability:configuration:validate:request (static check, zod/valibot or programmatic checks) * - module:contribute:capability:configuration:validate:status (with parent event id) * - module:contribute:capability:configuration:validate:response * - module:contribute:capability:configuration:plan:request (actually dry-run, ensures anything during runtime works) * - module:contribute:capability:configuration:plan:status (with parent event id) * - module:contribute:capability:configuration:plan:response * - module:contribute:capability:configuration:commit * - module:contribute:capability:configuration:commit:status (with parent event id) * similar to module:configuration are accepted. * * 16. No matter what happens, the module:status should emit with ready status now. * 17. Any time the module need to re-calculate / re-compute, or wish to be re-configured, it's ok to * emit module:status:change with needed phase to update, if need to rollback to announced phase, * Plugin Host should treat the Module to be un-prepared status, the needed procedure will be called. */ type PluginLifecycleEvent = | { type: 'SESSION_LOADED' } | { type: 'START_AUTHENTICATION' } | { type: 'AUTHENTICATED' } | { type: 'ANNOUNCED' } | { type: 'START_PREPARING' } | { type: 'WAITING_DEPENDENCIES' } | { type: 'PREPARED' } | { type: 'CONFIGURATION_NEEDED' } | { type: 'CONFIGURED' } | { type: 'READY' } | { type: 'SESSION_FAILED' } | { type: 'REANNOUNCE' } | { type: 'STOP' } const pluginLifecycleMachine = createMachine({ id: 'plugin-lifecycle', initial: 'loading', states: { 'loading': { on: { SESSION_LOADED: 'loaded', SESSION_FAILED: 'failed', }, }, 'loaded': { on: { START_AUTHENTICATION: 'authenticating', STOP: 'stopped', SESSION_FAILED: 'failed', }, }, 'authenticating': { on: { AUTHENTICATED: 'authenticated', SESSION_FAILED: 'failed', }, }, 'authenticated': { on: { ANNOUNCED: 'announced', SESSION_FAILED: 'failed', }, }, 'announced': { on: { START_PREPARING: 'preparing', CONFIGURATION_NEEDED: 'configuration-needed', STOP: 'stopped', SESSION_FAILED: 'failed', }, }, 'preparing': { on: { WAITING_DEPENDENCIES: 'waiting-deps', PREPARED: 'prepared', SESSION_FAILED: 'failed', }, }, 'waiting-deps': { on: { PREPARED: 'prepared', SESSION_FAILED: 'failed', }, }, 'prepared': { on: { CONFIGURATION_NEEDED: 'configuration-needed', CONFIGURED: 'configured', SESSION_FAILED: 'failed', }, }, 'configuration-needed': { on: { CONFIGURED: 'configured', SESSION_FAILED: 'failed', }, }, 'configured': { on: { READY: 'ready', SESSION_FAILED: 'failed', }, }, 'ready': { on: { REANNOUNCE: 'announced', CONFIGURATION_NEEDED: 'configuration-needed', STOP: 'stopped', SESSION_FAILED: 'failed', }, }, 'failed': { on: { STOP: 'stopped', }, }, 'stopped': { type: 'final', }, }, }) const lifecycleTransitionEvents: Record>> = { 'loading': { loaded: 'SESSION_LOADED', failed: 'SESSION_FAILED' }, 'loaded': { authenticating: 'START_AUTHENTICATION', stopped: 'STOP', failed: 'SESSION_FAILED' }, 'authenticating': { authenticated: 'AUTHENTICATED', failed: 'SESSION_FAILED' }, 'authenticated': { announced: 'ANNOUNCED', failed: 'SESSION_FAILED' }, 'announced': { 'preparing': 'START_PREPARING', 'configuration-needed': 'CONFIGURATION_NEEDED', 'failed': 'SESSION_FAILED', 'stopped': 'STOP' }, 'preparing': { 'waiting-deps': 'WAITING_DEPENDENCIES', 'prepared': 'PREPARED', 'failed': 'SESSION_FAILED' }, 'waiting-deps': { prepared: 'PREPARED', failed: 'SESSION_FAILED' }, 'prepared': { 'configuration-needed': 'CONFIGURATION_NEEDED', 'configured': 'CONFIGURED', 'failed': 'SESSION_FAILED' }, 'configuration-needed': { configured: 'CONFIGURED', failed: 'SESSION_FAILED' }, 'configured': { ready: 'READY', failed: 'SESSION_FAILED' }, 'ready': { 'announced': 'REANNOUNCE', 'configuration-needed': 'CONFIGURATION_NEEDED', 'failed': 'SESSION_FAILED', 'stopped': 'STOP' }, 'failed': { stopped: 'STOP' }, 'stopped': {}, } function assertTransition(session: PluginHostSession, to: PluginSessionPhase) { const eventType = lifecycleTransitionEvents[session.phase][to] if (!eventType) { throw new Error(`Invalid plugin lifecycle transition: ${session.phase} -> ${to} for module ${session.identity.id}`) } const event: PluginLifecycleEvent = { type: eventType } const snapshot = session.lifecycle.getSnapshot() if (!snapshot.can(event)) { throw new Error(`Invalid plugin lifecycle transition: ${session.phase} -> ${to} for module ${session.identity.id}`) } session.lifecycle.send(event) session.phase = session.lifecycle.getSnapshot().value as PluginSessionPhase } function markFailedTransition(session: PluginHostSession) { const event: PluginLifecycleEvent = { type: 'SESSION_FAILED' } const snapshot = session.lifecycle.getSnapshot() if (snapshot.can(event)) { session.lifecycle.send(event) session.phase = session.lifecycle.getSnapshot().value as PluginSessionPhase return } if (session.phase !== 'failed') { session.phase = 'failed' } } function isPluginDefinition(value: unknown): value is ReturnType { return typeof value === 'object' && value !== null && 'setup' in value && typeof (value as { setup?: unknown }).setup === 'function' } async function coercePluginFromModule(moduleValue: unknown): Promise { if (isPluginDefinition(moduleValue)) { return await moduleValue.setup() } if (typeof moduleValue === 'object' && moduleValue !== null) { if ('default' in moduleValue && isPluginDefinition((moduleValue as { default?: unknown }).default)) { return await (moduleValue as { default: ReturnType }).default.setup() } if ('default' in moduleValue && typeof (moduleValue as { default?: unknown }).default === 'object') { const defaultPlugin = (moduleValue as { default: Plugin }).default if (typeof defaultPlugin.init === 'function' || typeof defaultPlugin.setupModules === 'function') { return defaultPlugin } } const plugin = moduleValue as Plugin if (typeof plugin.init === 'function' || typeof plugin.setupModules === 'function') { return plugin } } throw new Error('Failed to resolve plugin module. The entrypoint must export either definePlugin(...) or Plugin hooks.') } function createModuleIdentity(name: string, index: number): ModuleIdentity { const sanitizedName = name.trim() || 'plugin' return { id: `${sanitizedName}-${index}`, kind: 'plugin', plugin: { id: sanitizedName, }, } } // TODO: Maybe support more complex version formats. function normalizeVersionList(versions: string[]) { return [...new Set(versions.map(version => version.trim()).filter(Boolean))] } function resolveSupportedVersions(preferredVersion: string, supportedVersions?: string[]) { return normalizeVersionList([preferredVersion, ...(supportedVersions ?? [])]) } function resolveNegotiatedVersion(preferredVersion: string, hostSupportedVersions: string[], peerSupportedVersions?: string[]) { const normalizedPreferredVersion = preferredVersion.trim() const normalizedHostSupportedVersions = normalizeVersionList(hostSupportedVersions) const normalizedPeerSupportedVersions = peerSupportedVersions && peerSupportedVersions.length > 0 ? normalizeVersionList(peerSupportedVersions) : undefined if (!normalizedPeerSupportedVersions?.length) { if (normalizedHostSupportedVersions.includes(normalizedPreferredVersion)) { return { acceptedVersion: normalizedPreferredVersion, exact: true, } } return { exact: false, reason: `Host does not support preferred version "${normalizedPreferredVersion}".`, } } if (normalizedPeerSupportedVersions.includes(normalizedPreferredVersion) && normalizedHostSupportedVersions.includes(normalizedPreferredVersion)) { return { acceptedVersion: normalizedPreferredVersion, exact: true, } } for (const version of normalizedHostSupportedVersions) { if (normalizedPeerSupportedVersions.includes(version)) { return { acceptedVersion: version, exact: false, } } } return { exact: false, reason: `No overlapping supported versions. host=[${normalizedHostSupportedVersions.join(', ')}]; peer=[${normalizedPeerSupportedVersions.join(', ')}].`, } } export type PluginRuntime = 'electron' | 'node' | 'web' export type ModulePhase = ProtocolModulePhase export type PluginSessionPhase = | 'loading' | 'loaded' | 'authenticating' | 'authenticated' | 'waiting-deps' | ModulePhase | 'stopped' export type PluginIdentity = ProtocolPluginIdentity export type ModuleIdentity = ProtocolModuleIdentity export type ModuleConfigEnvelope> = ProtocolModuleConfigEnvelope export type ModuleCompatibilityRequest = ProtocolEvents['module:compatibility:request'] export type ModuleCompatibilityResult = ProtocolEvents['module:compatibility:result'] export interface ManifestV1 { apiVersion: 'v1' kind: 'manifest.plugin.airi.moeru.ai' name: string entrypoints: { default?: string electron?: string node?: string web?: string } } export const manifestV1Schema = object({ apiVersion: literal('v1'), kind: literal('manifest.plugin.airi.moeru.ai'), name: string(), entrypoints: object({ default: optional(string()), electron: optional(string()), node: optional(string()), web: optional(string()), }), }) export interface PluginLoadOptions { cwd?: string runtime?: PluginRuntime } export interface PluginHostOptions { runtime?: PluginRuntime transport?: PluginTransport protocolVersion?: string apiVersion?: string supportedProtocolVersions?: string[] supportedApiVersions?: string[] } export interface PluginStartOptions { cwd?: string runtime?: PluginRuntime requireConfiguration?: boolean compatibility?: Omit requiredCapabilities?: string[] capabilityWaitTimeoutMs?: number } export interface PluginHostSession { manifest: ManifestV1 plugin: Plugin id: string index: number cwd: string identity: ModuleIdentity phase: PluginSessionPhase lifecycle: ActorRefFrom transport: PluginTransport runtime: PluginRuntime channels: { host: ReturnType } apis: ReturnType } /** * In-memory Plugin Host MVP. * * Procedure placement: * - `load(...)` covers step 0 and step 1 preparation: * - create channel gateway/context * - prepare per-plugin isolated runtime resources * - load plugin module from manifest entrypoint * - `init(...)` covers protocol/lifecycle step 2 onwards: * - authentication * - compatibility negotiation * - registry sync + announce/prepare/configure/ready flow * * The design intentionally keeps `load` and `init` separate so callers can: * - inspect/patch session state before booting, * - batch-load many plugins first, then initialize deterministically. */ export class PluginHost { private readonly loader: FileSystemLoader private readonly sessions = new Map() private readonly runtime: PluginRuntime private readonly transport: PluginTransport private readonly protocolVersion: string private readonly apiVersion: string private readonly supportedProtocolVersions: string[] private readonly supportedApiVersions: string[] private readonly capabilities = new Map() private readonly capabilityWaiters = new Map void>>() private providersListResolver: () => Promise> | Array<{ name: string }> = () => [] private sessionCounter = 0 constructor(options: PluginHostOptions = {}) { this.loader = new FileSystemLoader() this.runtime = options.runtime ?? 'electron' this.transport = options.transport ?? { kind: 'in-memory' } this.protocolVersion = options.protocolVersion ?? 'v1' this.apiVersion = options.apiVersion ?? 'v1' this.supportedProtocolVersions = resolveSupportedVersions(this.protocolVersion, options.supportedProtocolVersions) this.supportedApiVersions = resolveSupportedVersions(this.apiVersion, options.supportedApiVersions) this.markCapabilityReady(protocolListProvidersEventName, { source: 'plugin-host' }) } listSessions() { return [...this.sessions.values()] } getSession(sessionId: string) { return this.sessions.get(sessionId) } async load(manifest: ManifestV1, options: PluginLoadOptions = {}): Promise { // Step 0 (channel gateway preparation): resolve runtime and transport for this plugin. const runtime = options.runtime ?? this.runtime const sessionCwd = options.cwd ?? cwd() // Explicitly assign the default CWD. const transport = this.transport // TODO: implement other transports and runtime bindings. // alpha scope guard: // we intentionally fail fast for non in-memory transports while iterating on lifecycle design. if (transport.kind !== 'in-memory') { throw new Error(`Only in-memory transport is currently supported by PluginHost alpha. Got: ${transport.kind}`) } // Build deterministic per-session identity. // `sessionCounter` gives stable ordering for registry sync and debugging. const sessionIndex = this.sessionCounter this.sessionCounter += 1 const id = `plugin-session-${sessionIndex}` const identity = createModuleIdentity(manifest.name, sessionIndex) // Step 1 (connect/control-plane prep): create an isolated Eventa context per plugin. // All invokes/events for this plugin go through this context to prevent cross-talk. const hostChannel = createPluginContext(transport) const lifecycle = createActor(pluginLifecycleMachine) lifecycle.start() defineInvokeHandler(hostChannel, protocolCapabilityWait, async (payload) => { return await this.waitForCapability(payload.key, payload?.timeoutMs) }) defineInvokeHandler(hostChannel, protocolCapabilitySnapshot, async () => { return this.listCapabilities() }) defineInvokeHandler(hostChannel, protocolProviders.listProviders, async () => { return await this.providersListResolver() }) const session: PluginHostSession = { manifest, plugin: {}, id, index: sessionIndex, cwd: sessionCwd, identity, phase: lifecycle.getSnapshot().value as PluginSessionPhase, lifecycle, transport, runtime, channels: { host: hostChannel, }, apis: createBoundApis(hostChannel), } // Register session before loading so failure paths still have observable state. this.sessions.set(id, session) try { // Load plugin module from manifest-selected runtime entrypoint. // This is where malformed entrypoints or import errors surface. session.plugin = await this.loader.loadPluginFor(manifest, { cwd: sessionCwd, runtime, }) // Assert lifecycle progression (`loading` -> `loaded`) to keep transition rules explicit. // This prevents accidental phase drift if the method evolves later. assertTransition(session, 'loaded') return session } catch (error) { // Load failure is terminal for this session (`loading` -> `failed`). // Emit status so Configurator/observers can show deterministic diagnostics. markFailedTransition(session) session.channels.host.emit(moduleStatus, { identity: session.identity, phase: 'failed', reason: error instanceof Error ? error.message : 'Failed to load plugin.', }) throw error } } async init(sessionId: string, options: PluginStartOptions = {}): Promise { // `init` starts at procedure step 2 (authenticate) and drives lifecycle to ready. const session = this.sessions.get(sessionId) if (!session) { throw new Error(`Unable to initialize plugin session: ${sessionId}`) } // Safety gate: initialization can only begin from a successfully loaded plugin. if (session.phase !== 'loaded') { throw new Error(`Session ${sessionId} cannot initialize from phase ${session.phase}. Expected loaded.`) } try { let preparedEmitted = false // Step 2: authenticate module against host control plane. assertTransition(session, 'authenticating') session.channels.host.emit(moduleAuthenticate, { token: `${session.id}:${session.identity.id}`, }) // Mark local lifecycle after authentication handshake. assertTransition(session, 'authenticated') session.channels.host.emit(moduleAuthenticated, { authenticated: true }) // Step 3: protocol/api compatibility negotiation. const compatibilityRequest: ModuleCompatibilityRequest = { protocolVersion: this.protocolVersion, apiVersion: this.apiVersion, supportedProtocolVersions: options.compatibility?.supportedProtocolVersions, supportedApiVersions: options.compatibility?.supportedApiVersions, } session.channels.host.emit(moduleCompatibilityRequest, compatibilityRequest) const protocolNegotiation = resolveNegotiatedVersion( compatibilityRequest.protocolVersion, this.supportedProtocolVersions, compatibilityRequest.supportedProtocolVersions, ) const apiNegotiation = resolveNegotiatedVersion( compatibilityRequest.apiVersion, this.supportedApiVersions, compatibilityRequest.supportedApiVersions, ) const rejectionReasons = [ ...protocolNegotiation.acceptedVersion ? [] : [`protocol: ${protocolNegotiation.reason}`], ...apiNegotiation.acceptedVersion ? [] : [`api: ${apiNegotiation.reason}`], ] if (rejectionReasons.length > 0) { const reason = `Negotiation rejected: ${rejectionReasons.join('; ')}` session.channels.host.emit(moduleCompatibilityResult, { protocolVersion: compatibilityRequest.protocolVersion, apiVersion: compatibilityRequest.apiVersion, mode: 'rejected', reason, }) throw new Error(reason) } session.channels.host.emit(moduleCompatibilityResult, { protocolVersion: protocolNegotiation.acceptedVersion!, apiVersion: apiNegotiation.acceptedVersion!, mode: protocolNegotiation.exact && apiNegotiation.exact ? 'exact' : 'downgraded', }) // Step 4: broadcast currently known modules for dependency discovery/bootstrap. session.channels.host.emit(registryModulesSync, { modules: this.listSessions() .filter(item => item.phase !== 'stopped') .map(item => ({ name: item.manifest.name, index: item.index, identity: item.identity, })), }) // Step 5: module announcement to the shared control plane. assertTransition(session, 'announced') session.channels.host.emit(moduleAnnounce, { name: session.manifest.name, identity: session.identity, possibleEvents: [], }) session.channels.host.emit(moduleStatus, { identity: session.identity, phase: 'announced', }) // Step 6/7: preparing phase (dependency/config preparation may happen inside plugin init). assertTransition(session, 'preparing') session.channels.host.emit(moduleStatus, { identity: session.identity, phase: 'preparing', }) // Optional dependency gate before plugin-owned initialization. if (options.requiredCapabilities?.length) { const capabilityTimeoutMs = options.capabilityWaitTimeoutMs ?? 15000 const unresolvedCapabilities = options.requiredCapabilities.filter(key => !this.isCapabilityReady(key)) assertTransition(session, 'waiting-deps') session.channels.host.emit(moduleStatus, { identity: session.identity, phase: 'preparing', reason: `Waiting for capabilities: ${options.requiredCapabilities.join(', ')}`, details: { // For richer observability lifecyclePhase: 'waiting-deps', requiredCapabilities: options.requiredCapabilities, unresolvedCapabilities, timeoutMs: capabilityTimeoutMs, }, }) await this.waitForCapabilities(options.requiredCapabilities, capabilityTimeoutMs) assertTransition(session, 'prepared') session.channels.host.emit(modulePrepared, { identity: session.identity, }) session.channels.host.emit(moduleStatus, { identity: session.identity, phase: 'prepared', }) preparedEmitted = true } // Run plugin-owned init hook. Returning `false` explicitly aborts startup. const initResult = await session.plugin.init?.({ channels: session.channels, apis: session.apis, }) if (initResult === false) { throw new Error(`Plugin initialization aborted by plugin: ${session.manifest.name}`) } // Step 8/10: module prepared. if (!preparedEmitted) { assertTransition(session, 'prepared') session.channels.host.emit(modulePrepared, { identity: session.identity, }) session.channels.host.emit(moduleStatus, { identity: session.identity, phase: 'prepared', }) } // Step 9/11: allow host to stop at explicit "configuration-needed". if (options.requireConfiguration) { assertTransition(session, 'configuration-needed') session.channels.host.emit(moduleConfigurationNeeded, { identity: session.identity, reason: 'Host requested configuration before activation.', }) session.channels.host.emit(moduleStatus, { identity: session.identity, phase: 'configuration-needed', }) return session } // Step 12/13: apply default config path for alpha when no manual configuration is required. await this.applyConfiguration(session.id, { configId: `${session.identity.id}:default`, revision: 1, schemaVersion: 1, full: {}, }) // Step 14/15: plugin contributes modules/capabilities in setup hook. await session.plugin.setupModules?.({ channels: session.channels, apis: session.apis, }) // Step 16: mark ready after setup/contribution flow completes. assertTransition(session, 'ready') session.channels.host.emit(moduleStatus, { identity: session.identity, phase: 'ready', }) return session } catch (error) { // Any init failure is normalized into failed phase + status event for observability. markFailedTransition(session) session.channels.host.emit(moduleStatus, { identity: session.identity, phase: 'failed', reason: error instanceof Error ? error.message : 'Plugin host initialization failed.', }) throw error } } async start(manifest: ManifestV1, options: PluginStartOptions = {}) { // Convenience wrapper: "start" = load + init in sequence. // Keep this tiny so callers can still call `load`/`init` separately when needed. const session = await this.load(manifest, { cwd: options.cwd, runtime: options.runtime, }) return this.init(session.id, options) } async applyConfiguration(sessionId: string, config: ModuleConfigEnvelope) { // Configuration is allowed only after prepare, during configuration-needed, or while re-configuring. const session = this.sessions.get(sessionId) if (!session) { throw new Error(`Unable to configure plugin session: ${sessionId}`) } if (!['prepared', 'configuration-needed', 'configured'].includes(session.phase)) { throw new Error(`Session ${sessionId} cannot accept configuration during phase ${session.phase}.`) } // Move into configured once per cycle; repeated apply is allowed while already configured. if (session.phase !== 'configured') { assertTransition(session, 'configured') } // Emit configured payload + status so Configurator can sync active config state. session.channels.host.emit(moduleConfigurationConfigured, { identity: session.identity, config, }) session.channels.host.emit(moduleStatus, { identity: session.identity, phase: 'configured', }) return session } setProvidersListResolver(resolver: () => Promise> | Array<{ name: string }>) { this.providersListResolver = resolver this.markCapabilityReady(protocolListProvidersEventName, { source: 'plugin-host-override' }) } announceCapability(key: string, metadata?: Record) { const current = this.capabilities.get(key) const descriptor: CapabilityDescriptor = { key, state: 'announced', metadata: metadata ?? current?.metadata, updatedAt: Date.now(), } this.capabilities.set(key, descriptor) return descriptor } markCapabilityReady(key: string, metadata?: Record) { const current = this.capabilities.get(key) const descriptor: CapabilityDescriptor = { key, state: 'ready', metadata: metadata ?? current?.metadata, updatedAt: Date.now(), } this.capabilities.set(key, descriptor) const waiters = this.capabilityWaiters.get(key) if (waiters) { for (const resolve of waiters) { resolve(descriptor) } this.capabilityWaiters.delete(key) } return descriptor } markCapabilityDegraded(key: string, metadata?: Record) { const current = this.capabilities.get(key) const descriptor: CapabilityDescriptor = { key, state: 'degraded', metadata: metadata ?? current?.metadata, updatedAt: Date.now(), } this.capabilities.set(key, descriptor) return descriptor } withdrawCapability(key: string, metadata?: Record) { const current = this.capabilities.get(key) const descriptor: CapabilityDescriptor = { key, state: 'withdrawn', metadata: metadata ?? current?.metadata, updatedAt: Date.now(), } this.capabilities.set(key, descriptor) return descriptor } listCapabilities() { return [...this.capabilities.values()] } isCapabilityReady(key: string) { return this.capabilities.get(key)?.state === 'ready' } async waitForCapabilities(keys: string[], timeoutMs: number = 15000) { await Promise.all(keys.map(async key => await this.waitForCapability(key, timeoutMs))) } async waitForCapability(key: string, timeoutMs: number = 15000) { const existing = this.capabilities.get(key) if (existing?.state === 'ready') { return existing } return await new Promise((resolve, reject) => { let timeout: ReturnType | undefined const onReady = (descriptor: CapabilityDescriptor) => { if (timeout) { clearTimeout(timeout) } resolve(descriptor) } const waiters = this.capabilityWaiters.get(key) ?? new Set() waiters.add(onReady) this.capabilityWaiters.set(key, waiters) timeout = setTimeout(() => { const currentWaiters = this.capabilityWaiters.get(key) currentWaiters?.delete(onReady) if (currentWaiters && currentWaiters.size === 0) { this.capabilityWaiters.delete(key) } reject(new Error(`Capability \`${key}\` is not ready after ${timeoutMs}ms.`)) }, timeoutMs) }) } markConfigurationNeeded(sessionId: string, reason?: string) { // Explicit rollback/forward hook into "configuration-needed" phase. // Mirrors procedure step 17 where module may request reconfiguration. const session = this.sessions.get(sessionId) if (!session) { throw new Error(`Unable to update plugin session: ${sessionId}`) } if (!['prepared', 'configured', 'ready', 'announced'].includes(session.phase)) { throw new Error(`Session ${sessionId} cannot move to configuration-needed from ${session.phase}.`) } // Assert guarded transition to avoid illegal phase jumps. assertTransition(session, 'configuration-needed') session.channels.host.emit(moduleConfigurationNeeded, { identity: session.identity, reason, }) session.channels.host.emit(moduleStatus, { identity: session.identity, phase: 'configuration-needed', reason, }) return session } stop(sessionId: string) { // Stop removes session from active registry. Lifecycle first transitions to `stopped`. const session = this.sessions.get(sessionId) if (!session) { return undefined } // Prefer guarded transition when allowed; otherwise force-close as a safety fallback. if (session.phase !== 'stopped') { const canStop = session.lifecycle.getSnapshot().can({ type: 'STOP' }) if (canStop) { assertTransition(session, 'stopped') } else { session.phase = 'stopped' } } session.lifecycle.stop() this.sessions.delete(session.id) return session } async reload(sessionId: string, options: PluginStartOptions = {}) { // Reload preserves manifest/runtime intent, then performs stop + fresh start. // This intentionally creates a new session identity for deterministic re-bootstrap. const previous = this.sessions.get(sessionId) if (!previous) { throw new Error(`Unable to reload missing plugin session: ${sessionId}`) } const manifest = previous.manifest this.stop(sessionId) return this.start(manifest, { ...options, cwd: options.cwd ?? previous.cwd, runtime: options.runtime ?? previous.runtime, }) } } export class FileSystemLoader { /** * Filesystem-backed plugin module loader. * * Responsibilities: * - Resolve runtime-specific entrypoints from `ManifestV1`. * - Import plugin modules from local disk. * - Normalize module exports into either: * - lazy plugin definition (`definePlugin(...)`) via `loadLazyPluginFor`, or * - executable plugin hooks (`Plugin`) via `loadPluginFor`. */ constructor() { } /** * Resolve a manifest entrypoint for the requested runtime. * * Resolution order: * 1) `entrypoints.` * 2) `entrypoints.default` * 3) `entrypoints.electron` (legacy fallback for current local plugin manifests) * * Throws an actionable error when no entrypoint can be selected. */ resolveEntrypointFor(manifest: ManifestV1, options?: PluginLoadOptions) { const runtime = options?.runtime ?? 'electron' const root = options?.cwd ?? cwd() const entrypoint = manifest.entrypoints[runtime] ?? manifest.entrypoints.default ?? manifest.entrypoints.electron if (!entrypoint) { throw new Error('' + `Plugin entrypoint is required for runtime \`${runtime}\`. ` + 'Define one of `entrypoints.`, `entrypoints.default`, ' + 'or `entrypoints.electron` in the plugin manifest.', ) } return join(root, entrypoint) } /** * Load a lazy plugin definition (`definePlugin(...)`) without executing setup. * * Use this when host logic wants to inspect plugin metadata/setup contract first * and control when `setup()` is called. */ async loadLazyPluginFor(manifest: ManifestV1, options?: PluginLoadOptions) { const entrypoint = this.resolveEntrypointFor(manifest, options) const pluginModule = await import(entrypoint) if (isPluginDefinition(pluginModule)) { return pluginModule } if (typeof pluginModule === 'object' && pluginModule !== null) { const defaultExport = (pluginModule as { default?: unknown }).default if (isPluginDefinition(defaultExport)) { return defaultExport } } throw new Error('Plugin lazy loader expects a definePlugin(...) export.') } /** * Load and normalize a plugin entrypoint into executable `Plugin` hooks. * * Accepts: * - a direct `Plugin` export * - a default `Plugin` export * - `definePlugin(...)` (calls `setup()` and returns the resulting `Plugin`) */ async loadPluginFor(manifest: ManifestV1, options?: PluginLoadOptions) { const entrypoint = this.resolveEntrypointFor(manifest, options) const pluginModule = await import(entrypoint) return coercePluginFromModule(pluginModule) } }