Files
moeka-project/packages/plugin-sdk/src/plugin-host/core.ts
T

875 lines
32 KiB
TypeScript

import type {
ProtocolEvents,
ModuleConfigEnvelope as ProtocolModuleConfigEnvelope,
ModuleIdentity as ProtocolModuleIdentity,
ModulePhase as ProtocolModulePhase,
PluginIdentity as ProtocolPluginIdentity,
} from '@proj-airi/plugin-protocol/types'
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 { 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.
*/
const lifecycleTransitionRules: Record<PluginSessionPhase, PluginSessionPhase[]> = {
'loading': ['loaded', 'failed'],
'loaded': ['authenticating', 'stopped', 'failed'],
'authenticating': ['authenticated', 'failed'],
'authenticated': ['announced', 'failed'],
'announced': ['preparing', 'configuration-needed', 'failed', 'stopped'],
'preparing': ['waiting-deps', 'prepared', 'failed'],
'waiting-deps': ['prepared', 'failed'],
'prepared': ['configuration-needed', 'configured', 'failed'],
'configuration-needed': ['configured', 'failed'],
'configured': ['ready', 'failed'],
'ready': ['announced', 'configuration-needed', 'failed', 'stopped'],
'failed': ['stopped'],
'stopped': [],
}
function assertTransition(session: PluginHostSession, to: PluginSessionPhase) {
const allowed = lifecycleTransitionRules[session.phase]
if (!allowed.includes(to)) {
throw new Error(`Invalid plugin lifecycle transition: ${session.phase} -> ${to} for module ${session.identity.id}`)
}
session.phase = to
}
function isPluginDefinition(value: unknown): value is ReturnType<typeof definePlugin> {
return typeof value === 'object'
&& value !== null
&& 'setup' in value
&& typeof (value as { setup?: unknown }).setup === 'function'
}
async function coercePluginFromModule(moduleValue: unknown): Promise<Plugin> {
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<typeof definePlugin> }).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,
},
}
}
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<C = Record<string, unknown>> = ProtocolModuleConfigEnvelope<C>
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
}
export interface PluginStartOptions {
cwd?: string
runtime?: PluginRuntime
requireConfiguration?: boolean
compatibility?: Omit<ModuleCompatibilityRequest, 'protocolVersion' | 'apiVersion'>
requiredCapabilities?: string[]
capabilityWaitTimeoutMs?: number
}
export interface PluginHostSession {
manifest: ManifestV1
plugin: Plugin
id: string
index: number
identity: ModuleIdentity
phase: PluginSessionPhase
transport: PluginTransport
runtime: PluginRuntime
channels: {
host: ReturnType<typeof createPluginContext>
}
apis: ReturnType<typeof createApis>
}
/**
* 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<string, PluginHostSession>()
private readonly runtime: PluginRuntime
private readonly transport: PluginTransport
private readonly protocolVersion: string
private readonly apiVersion: string
private readonly capabilities = new Map<string, CapabilityDescriptor>()
private readonly capabilityWaiters = new Map<string, Set<(descriptor: CapabilityDescriptor) => void>>()
private providersListResolver: () => Promise<Array<{ name: string }>> | 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.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<PluginHostSession> {
// Step 0 (channel gateway preparation): resolve runtime and transport for this plugin.
const runtime = options.runtime ?? this.runtime
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)
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,
identity,
phase: 'loading',
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: options.cwd,
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.
assertTransition(session, 'failed')
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<PluginHostSession> {
// `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)
session.channels.host.emit(moduleCompatibilityResult, {
protocolVersion: compatibilityRequest.protocolVersion,
apiVersion: compatibilityRequest.apiVersion,
mode: 'exact',
})
// 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) {
assertTransition(session, 'waiting-deps')
session.channels.host.emit(moduleStatus, {
identity: session.identity,
phase: 'preparing',
reason: `Waiting for capabilities: ${options.requiredCapabilities.join(', ')}`,
})
await this.waitForCapabilities(options.requiredCapabilities, options.capabilityWaitTimeoutMs)
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.
const currentPhase = session.phase
if (lifecycleTransitionRules[currentPhase].includes('failed')) {
assertTransition(session, 'failed')
}
else {
session.phase = 'failed'
}
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 }>> | Array<{ name: string }>) {
this.providersListResolver = resolver
this.markCapabilityReady(protocolListProvidersEventName, { source: 'plugin-host-override' })
}
announceCapability(key: string, metadata?: Record<string, unknown>) {
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<string, unknown>) {
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
}
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<CapabilityDescriptor>((resolve, reject) => {
let timeout: ReturnType<typeof setTimeout> | 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') {
if (lifecycleTransitionRules[session.phase].includes('stopped')) {
assertTransition(session, 'stopped')
}
else {
session.phase = 'stopped'
}
}
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
const cwdValue = options.cwd
this.stop(sessionId)
return this.start(manifest, {
...options,
cwd: cwdValue,
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.<runtime>`
* 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.<runtime>`, `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)
}
}