feat(plugin-sdk,stage-tamagotchi): rework of plugin structure, integrated kits api, now plugin manifest is plugin.airi.json

This commit is contained in:
Neko Ayaka
2026-04-22 13:52:11 +08:00
parent ea2b30e2b8
commit 509c00d9a5
41 changed files with 4712 additions and 616 deletions
+5 -5
View File
@@ -134,11 +134,6 @@ app.whenReady().then(async () => {
build: async () => setupMcpStdioManager(),
})
const pluginHost = injeca.provide('modules:plugin-host', {
dependsOn: { serverChannel },
build: () => setupPluginHost(),
})
const windowAuthManager = injeca.provide('services:window-auth-manager', () => createWindowAuthManagerService())
// BeatSync will create a background window to capture and process audio.
@@ -161,6 +156,11 @@ app.whenReady().then(async () => {
build: ({ dependsOn }) => setupWidgetsWindowManager(dependsOn),
})
const pluginHost = injeca.provide('modules:plugin-host', {
dependsOn: { serverChannel, widgetsManager },
build: ({ dependsOn }) => setupPluginHost({ widgetsManager: dependsOn.widgetsManager }),
})
const aboutWindow = injeca.provide('windows:about', {
dependsOn: { autoUpdater, i18n, serverChannel },
build: ({ dependsOn }) => setupAboutWindowReusable(dependsOn),
@@ -27,11 +27,12 @@ export interface ExtensionStaticAssetServer extends ServerManager {
}
/**
* Creates the standalone extension static asset server.
* Creates the low-level extension static asset transport server.
*
* Use when:
* - Main process must serve plugin iframe assets via local loopback HTTP
* - Tokenized auth is required for all plugin asset requests
* - A higher-level plugin asset service needs an HTTP transport adapter
*
* Expects:
* - `getManifestEntryByName` returns up-to-date plugin root/version map
@@ -23,7 +23,7 @@ function normalizePathPrefix(pathPrefix: string) {
return ''
}
return normalized.endsWith('/') ? normalized : `${normalized}/`
return normalized
}
function createOpaqueToken() {
@@ -103,8 +103,15 @@ export function createExtensionAssetTokenStore(options: { now?: () => number } =
return unauthorized('EXTENSION_ASSET_PATH_EMPTY', 'asset path is empty')
}
if (record.pathPrefix && !normalizedAssetPath.startsWith(record.pathPrefix)) {
return unauthorized('EXTENSION_ASSET_PATH_PREFIX_MISMATCH', 'asset path is outside allowed prefix')
if (record.pathPrefix) {
const isDirectoryPrefix = record.pathPrefix.endsWith('/')
const isAllowed = isDirectoryPrefix
? normalizedAssetPath.startsWith(record.pathPrefix)
: normalizedAssetPath === record.pathPrefix
if (!isAllowed) {
return unauthorized('EXTENSION_ASSET_PATH_PREFIX_MISMATCH', 'asset path is outside allowed prefix')
}
}
return { ok: true }
@@ -10,6 +10,7 @@ import {
parsePluginAssetRequestPath,
resolvePluginAssetFilePath,
} from './asset-mount'
import { resolveWidgetAssetRoute } from './kits/widget'
describe('asset-mount', () => {
const tempRoots: string[] = []
@@ -54,4 +55,26 @@ describe('asset-mount', () => {
await expect(resolvePluginAssetFilePath(root, 'dist/ui/index.html')).resolves.toContain('dist/ui/index.html')
await expect(resolvePluginAssetFilePath(root, '../outside.txt')).resolves.toBeUndefined()
})
it('derives widget route asset path and token prefix with /ui semantics', () => {
expect(resolveWidgetAssetRoute('./ui/index.html')).toEqual({
routeAssetPath: 'index.html',
tokenPathPrefix: 'index.html',
})
expect(resolveWidgetAssetRoute('ui/index.html')).toEqual({
routeAssetPath: 'index.html',
tokenPathPrefix: 'index.html',
})
expect(resolveWidgetAssetRoute('ui/assets/index.html')).toEqual({
routeAssetPath: 'assets/index.html',
tokenPathPrefix: 'assets/',
})
expect(resolveWidgetAssetRoute('assets/index.html')).toEqual({
routeAssetPath: 'assets/index.html',
tokenPathPrefix: 'assets/',
})
})
})
@@ -0,0 +1,118 @@
import type { ExtensionStaticAssetManifestEntry } from '../../http-server/http/extension-static-assets'
import type { ServerManager } from '../../http-server/server-manager/types'
import { createExtensionStaticAssetServer } from '../../http-server/http/extension-static-assets'
/**
* Describes one plugin asset access token issuance request.
*
* Use when:
* - A plugin-owned asset URL must be mounted behind the local loopback server
* - Snapshot builders need a transport-agnostic way to authorize one plugin asset route
*
* Expects:
* - `pluginId` matches a manifest entry registered in the asset host
* - `pathPrefix` is scoped to the mounted route prefix accepted by the token store
*
* Returns:
* - N/A
*/
export interface PluginAssetAccessTokenInput {
pluginId: string
version: string
sessionId: string
pathPrefix: string
ttlMs: number
}
/**
* Describes the plugin asset methods needed while building renderer-facing snapshots.
*
* Use when:
* - Snapshot builders must request route-scoped asset tokens without depending on HTTP server internals
* - Host bootstrap wants to layer caching or policy on top of the raw asset transport
*
* Expects:
* - `routeAssetPath` identifies the mounted asset file being exposed in the snapshot
* - Implementations may use `routeAssetPath` for caching even if the transport ignores it
*
* Returns:
* - N/A
*/
export interface PluginAssetSnapshotService {
getBaseUrl: () => string | undefined
issueAccessToken: (input: {
pluginId: string
version: string
sessionId: string
routeAssetPath: string
pathPrefix: string
}) => string
}
/**
* Defines the plugin-owned asset hosting service used by the plugin host.
*
* Use when:
* - Plugin snapshots need mounted asset URLs without depending on the H3 server shape
* - Host teardown must revoke plugin asset access independently from widget/gamelet logic
*
* Expects:
* - Implementations own the underlying transport and token lifecycle
*
* Returns:
* - A startable/stoppable asset-hosting service with generic plugin-facing methods
*/
export interface PluginAssetService extends ServerManager {
getBaseUrl: () => string | undefined
issueAccessToken: (input: PluginAssetAccessTokenInput) => string
revokeByPluginId: (pluginId: string) => void
revokeAll: () => void
}
/**
* Creates the plugin asset host service backed by the extension static asset server.
*
* Use when:
* - The plugin host needs to expose mounted asset URLs to renderer snapshots
* - Asset token lifecycle should stay inside the plugin domain instead of the HTTP server layer
*
* Expects:
* - `getManifestEntryByName` returns the latest plugin root/version map
*
* Returns:
* - A plugin-facing asset host service with generic plugin asset methods
*/
export function createPluginAssetService(options: {
getManifestEntryByName: () => Map<string, ExtensionStaticAssetManifestEntry>
}): PluginAssetService {
const server = createExtensionStaticAssetServer(options)
return {
key: 'plugin-assets',
async start() {
await server.start()
},
async stop() {
await server.stop()
},
getBaseUrl() {
return server.getBaseUrl()
},
issueAccessToken(input) {
return server.issueToken({
extensionId: input.pluginId,
version: input.version,
sessionId: input.sessionId,
pathPrefix: input.pathPrefix,
ttlMs: input.ttlMs,
})
},
revokeByPluginId(pluginId) {
server.revokeByExtensionId(pluginId)
},
revokeAll() {
server.revokeAll()
},
}
}
@@ -4,7 +4,7 @@ This sample plugin is for validating plugin host behavior in the **Plugin Host I
## Files
- `devtools-sample-plugin.json`: plugin manifest (`ManifestV1`)
- `plugin.airi.json`: plugin manifest (`ManifestV1`)
- `devtools-sample-plugin.mjs`: plugin implementation
The manifest declares the protocol permissions required by `apis.providers.listProviders()`: invoke `capabilities:wait`, invoke `resources:providers:list-providers`, read the provider resource, and wait for the provider-list capability.
@@ -0,0 +1,161 @@
import type { FSWatcher } from 'node:fs'
import type { useLogg } from '@guiiai/logg'
import type { ManifestEntry, PluginConfig } from '../../types'
import { watch as watchFile } from 'node:fs'
/**
* Declares the host-owned callbacks needed by the plugin auto-reload feature.
*
* Use when:
* - Installing the optional auto-reload feature into the Electron plugin host
* - Keeping file-watcher ownership outside the core host bootstrap
*
* Expects:
* - `reload` unloads, refreshes, and loads the named plugin
* - `resolveWatchPaths` returns stable absolute file paths for the plugin
* - `getConfig`, `listEntries`, and `isLoaded` always reflect current host state
*
* Returns:
* - N/A
*/
export interface PluginAutoReloadFeatureOptions {
log: ReturnType<typeof useLogg>
getConfig: () => PluginConfig
listEntries: () => ManifestEntry[]
isLoaded: (name: string) => boolean
resolveWatchPaths: (name: string) => string[]
reload: (name: string, changedPath: string) => Promise<void>
}
/**
* Manages optional plugin auto-reload watchers and debounce timers.
*
* Use when:
* - The Electron plugin host wants manifest and entrypoint file watching as an installable feature
* - Host bootstrap should delegate watcher lifecycle and reload scheduling out of `host/index.ts`
*
* Expects:
* - Call `sync()` after registry/config/load-state changes
* - Call `clearPlugin(name)` before unloading or disabling a plugin
* - Call `dispose()` during host shutdown
*
* Returns:
* - The installed auto-reload feature controller
*/
export function createPluginAutoReloadFeature(options: PluginAutoReloadFeatureOptions) {
const autoReloadInFlight = new Set<string>()
const autoReloadTimers = new Map<string, ReturnType<typeof setTimeout>>()
const autoReloadWatchers = new Map<string, FSWatcher[]>()
const clearTimer = (name: string) => {
const timer = autoReloadTimers.get(name)
if (!timer) {
return
}
clearTimeout(timer)
autoReloadTimers.delete(name)
}
const closeWatchers = (name: string) => {
const watchers = autoReloadWatchers.get(name)
if (!watchers) {
return
}
for (const watcher of watchers) {
watcher.close()
}
autoReloadWatchers.delete(name)
}
const reloadPluginByName = async (name: string, changedPath: string) => {
if (autoReloadInFlight.has(name)) {
return
}
autoReloadInFlight.add(name)
try {
await options.reload(name, changedPath)
options.log.log('plugin auto-reloaded after file change', { plugin: name, path: changedPath })
}
catch (error) {
options.log.withError(error).withFields({ plugin: name, path: changedPath }).error('plugin auto-reload failed')
}
finally {
autoReloadInFlight.delete(name)
}
}
const scheduleReload = (name: string, changedPath: string) => {
clearTimer(name)
autoReloadTimers.set(name, setTimeout(() => {
autoReloadTimers.delete(name)
void reloadPluginByName(name, changedPath)
}, 180))
}
return {
sync() {
const enabledNames = new Set(options.getConfig().autoReload)
const desiredNames = new Set(options.listEntries()
.map(entry => entry.manifest.name)
.filter(name => enabledNames.has(name) && options.isLoaded(name)))
for (const name of autoReloadWatchers.keys()) {
if (!desiredNames.has(name)) {
clearTimer(name)
closeWatchers(name)
}
}
for (const name of desiredNames) {
if (autoReloadWatchers.has(name)) {
continue
}
const watchPaths = options.resolveWatchPaths(name)
if (watchPaths.length === 0) {
continue
}
const watchers: FSWatcher[] = []
for (const watchPath of watchPaths) {
try {
const watcher = watchFile(watchPath, { persistent: false }, () => scheduleReload(name, watchPath))
watcher.on('error', (error) => {
options.log.withError(error).withFields({ plugin: name, path: watchPath }).warn('plugin auto-reload watcher error')
})
watchers.push(watcher)
}
catch (error) {
options.log.withError(error).withFields({ plugin: name, path: watchPath }).warn('failed to watch plugin file for auto-reload')
}
}
if (watchers.length > 0) {
autoReloadWatchers.set(name, watchers)
}
}
},
clearPlugin(name: string) {
clearTimer(name)
closeWatchers(name)
},
dispose() {
const managedNames = new Set([
...autoReloadTimers.keys(),
...autoReloadWatchers.keys(),
])
for (const name of managedNames) {
clearTimer(name)
closeWatchers(name)
}
},
}
}
@@ -0,0 +1,72 @@
import type { PluginConfig } from '../types'
import { array, object, record, string } from 'valibot'
import { createConfig } from '../../../../libs/electron/persistence'
const pluginConfigSchema = object({
enabled: array(string()),
autoReload: array(string()),
known: record(string(), object({
path: string(),
})),
})
function createDefaultPluginConfig(): PluginConfig {
return {
enabled: [],
autoReload: [],
known: {},
}
}
/**
* Persists plugin host enablement and discovery metadata.
*
* Use when:
* - Bootstrapping the Electron plugin host
* - Reading or updating `plugins-v1.json` state
*
* Expects:
* - `setup()` runs before `get()` or `update()`
* - Consumers write complete `PluginConfig` snapshots
*
* Returns:
* - Accessors around the persisted plugin config document
*/
export interface PluginHostConfigStore {
setup: () => void
get: () => PluginConfig
update: (config: PluginConfig) => void
}
/**
* Creates the persisted config store used by the plugin host bootstrap.
*
* Use when:
* - Host bootstrap modules need config persistence without inlining schema setup
*
* Expects:
* - Electron `app.getPath('userData')` is available through the persistence layer
*
* Returns:
* - A small config store that always falls back to the default plugin config
*/
export function createPluginHostConfigStore(): PluginHostConfigStore {
const pluginConfig = createConfig('plugins', 'v1.json', pluginConfigSchema, {
default: createDefaultPluginConfig(),
autoHeal: true,
})
return {
setup() {
pluginConfig.setup()
},
get() {
return pluginConfig.get() ?? createDefaultPluginConfig()
},
update(config) {
pluginConfig.update(config)
},
}
}
@@ -0,0 +1,85 @@
import type { PluginHost } from '@proj-airi/plugin-sdk/plugin-host'
import type {
PluginHostDebugSnapshot,
PluginHostModuleSummary,
} from '../../../../../shared/eventa/plugin/host'
import type { PluginAssetSnapshotService } from '../assets'
import type { ManifestEntry, PluginConfig } from '../types'
import { rewriteWidgetModuleAssetUrl } from '../kits/widget'
import { buildPluginRegistrySnapshot } from './registry'
/**
* Builds the debug snapshot exposed by the Electron plugin host inspector.
*
* Use when:
* - Renderer devtools need sessions, kits, modules, and capability state
* - Widget iframe asset URLs must be rewritten to mounted plugin asset URLs
*
* Expects:
* - `host` is the initialized plugin host instance
* - `manifestEntryByName` contains entries for any plugin-owned modules being inspected
* - `pluginAssetService` owns plugin asset URL/token lifecycle when mounted asset URLs are needed
*
* Returns:
* - A full debug snapshot with registry, sessions, kits, modules, and capabilities
*/
export function buildPluginHostDebugSnapshot(options: {
host: PluginHost
pluginsRoot: string
entries: ManifestEntry[]
config: PluginConfig
loaded: Set<string>
manifestEntryByName: Map<string, ManifestEntry>
pluginAssetService?: PluginAssetSnapshotService
}): PluginHostDebugSnapshot {
const pluginAssetService = options.pluginAssetService
return {
registry: buildPluginRegistrySnapshot({
pluginsRoot: options.pluginsRoot,
entries: options.entries,
config: options.config,
loaded: options.loaded,
}),
sessions: options.host.listSessions().map(session => ({
id: session.id,
manifestName: session.manifest.name,
phase: session.phase,
runtime: session.runtime,
moduleId: session.identity.id,
})),
kits: options.host.listKits(),
modules: options.host
.listBindings()
.map(module =>
rewriteWidgetModuleAssetUrl(
module as PluginHostModuleSummary,
options.manifestEntryByName,
{
pluginAssetBaseUrl: pluginAssetService?.getBaseUrl(),
...(pluginAssetService
? {
issueAssetToken: ({ extensionId, version, sessionId, routeAssetPath, tokenPathPrefix }: {
extensionId: string
version: string
sessionId: string
routeAssetPath: string
tokenPathPrefix: string
}) => pluginAssetService.issueAccessToken({
pluginId: extensionId,
version,
sessionId,
routeAssetPath,
pathPrefix: tokenPathPrefix,
}),
}
: {}),
},
),
) as PluginHostDebugSnapshot['modules'],
capabilities: options.host.listCapabilities(),
refreshedAt: Date.now(),
}
}
@@ -0,0 +1,481 @@
import type {
PluginHostDebugSnapshot,
PluginRegistrySnapshot,
} from '../../../../../shared/eventa/plugin/host'
import type { PluginAssetSnapshotService } from '../assets'
import type {
PluginHostService,
SetupPluginHostOptions,
} from '../types'
import { dirname, join } from 'node:path'
import { useLogg } from '@guiiai/logg'
import { PluginHost } from '@proj-airi/plugin-sdk/plugin-host'
import { app } from 'electron'
import { createPluginAssetService } from '../assets'
import { createPluginAutoReloadFeature } from '../features/auto-reload'
import { createBuiltInPluginKitRuntime } from '../kits'
import { createPluginHostConfigStore } from './config'
import { buildPluginHostDebugSnapshot } from './debug'
import {
buildPluginRegistrySnapshot,
createManifestForLoad,
createPluginHostRegistry,
resolvePluginRuntimeEntrypointPath,
} from './registry'
const extensionAssetTokenTtlMs = 30 * 24 * 60 * 60 * 1000
/**
* Internal plugin host bootstrap service used by the public `setupPluginHost(...)` facade.
*
* Use when:
* - `plugins/index.ts` needs a smaller orchestration layer with the same caller-facing API
* - Host wiring should stay separate from config, registry, and snapshot helpers
*
* Expects:
* - Consumers treat this as an internal bootstrap surface and keep the public facade unchanged
* - `widgetsManager` is ready before startup begins
*
* Returns:
* - The plain `PluginHostService` fields plus internal helpers for list/load/unload/inspect/dispose
*/
export interface PluginHostHostService extends PluginHostService {
/**
* Lists the current plugin registry snapshot.
*
* Use when:
* - IPC callers need the latest discovered plugin entries and enablement state
* - Host operations need a refreshed renderer-facing registry view
*
* Expects:
* - Manifest discovery can be refreshed before the snapshot is built
*
* Returns:
* - The latest plugin registry snapshot for renderer consumption
*/
list: () => Promise<PluginRegistrySnapshot>
/**
* Persists whether one plugin is enabled.
*
* Use when:
* - Renderer controls toggle plugin enablement
* - Host state must remember a known manifest path for a plugin name
*
* Expects:
* - `payload.name` matches a discovered or previously known plugin
* - `payload.path` is only needed when the manifest is not currently discoverable
*
* Returns:
* - The updated plugin registry snapshot after persistence
*/
setEnabled: (payload: { name: string, enabled: boolean, path?: string }) => Promise<PluginRegistrySnapshot>
/**
* Persists whether one loaded plugin should use auto-reload.
*
* Use when:
* - Renderer controls toggle plugin file watching during development
* - Host features need to resync optional watcher state after config changes
*
* Expects:
* - `payload.name` matches one plugin entry in config or discovery state
*
* Returns:
* - The updated plugin registry snapshot after persistence
*/
setAutoReload: (payload: { name: string, enabled: boolean }) => Promise<PluginRegistrySnapshot>
/**
* Loads every plugin currently marked as enabled.
*
* Use when:
* - App startup wants to restore persisted enabled plugins
* - Renderer requests a bulk load after configuration changes
*
* Expects:
* - Discovery state is current before load begins
*
* Returns:
* - The plugin registry snapshot after load attempts finish
*/
loadEnabled: () => Promise<PluginRegistrySnapshot>
/**
* Loads one plugin by manifest name.
*
* Use when:
* - Renderer explicitly requests one plugin to start
* - Host features need to restart a plugin after manifest or entrypoint changes
*
* Expects:
* - `name` resolves to a manifest entry in the current registry
*
* Returns:
* - The plugin registry snapshot after the load completes
*/
load: (name: string) => Promise<PluginRegistrySnapshot>
/**
* Stops one loaded plugin by manifest name.
*
* Use when:
* - Renderer explicitly requests one plugin to stop
* - Host features need to stop a plugin before reload or disposal
*
* Expects:
* - `name` identifies a plugin that may or may not currently be loaded
*
* Returns:
* - The plugin registry snapshot after unload bookkeeping completes
*/
unload: (name: string) => PluginRegistrySnapshot
/**
* Builds the full plugin host debug snapshot.
*
* Use when:
* - Devtools need sessions, kits, bindings, capabilities, and rewritten asset URLs
* - Host debugging needs a fresh runtime snapshot after registry refresh
*
* Expects:
* - The host and plugin asset service are both initialized
*
* Returns:
* - The full debug snapshot exposed through plugin inspection IPC
*/
inspect: () => Promise<PluginHostDebugSnapshot>
/**
* Returns the mounted base URL for plugin-served assets.
*
* Use when:
* - Renderer code needs to construct extension asset URLs
* - Snapshot consumers need the current loopback asset mount base
*
* Expects:
* - The plugin asset service may be started before this is called
*
* Returns:
* - The current plugin asset base URL, or an empty string when unavailable
*/
getAssetBaseUrl: () => string
/**
* Disposes optional host features and asset hosting resources.
*
* Use when:
* - Electron shutdown needs to stop plugin-owned background work
* - Tests need to release watchers and local asset servers deterministically
*
* Expects:
* - Disposal may be called after partial startup or after prior plugin failures
*
* Returns:
* - A promise that resolves after feature and asset cleanup finish
*/
dispose: () => Promise<void>
}
/**
* Builds the extracted Electron plugin host bootstrap used by the public facade.
*
* Use when:
* - The public plugin service wants one internal bootstrap entrypoint
* - Tests need direct access to the internal host bootstrap helper
*
* Expects:
* - Electron `app.getPath('userData')` is available
* - Plugin manifests live under `<userData>/plugins/v1`
*
* Returns:
* - The internal bootstrap service that powers the public plugin-host IPC facade
*/
export async function setupPluginHostHostService(
options: SetupPluginHostOptions,
): Promise<PluginHostHostService> {
const log = useLogg('main/plugin-host').useGlobalConfig()
const pluginsRoot = join(app.getPath('userData'), 'plugins', 'v1')
// Config
const pluginConfig = createPluginHostConfigStore()
pluginConfig.setup()
// Kit API, Host
const builtInKitRuntime = createBuiltInPluginKitRuntime(options)
const host = new PluginHost({ runtime: 'electron', contributions: builtInKitRuntime.contributions })
builtInKitRuntime.attachHost(host) // reverse dependency injection
log.withFields({ pluginsRoot }).log('loading plugin manifests')
// Once kit injected the host, then apply kits
builtInKitRuntime.registerHostKits(host)
// plugin registry
const pluginRegistry = createPluginHostRegistry({ pluginsRoot, log })
await pluginRegistry.refresh()
log.withFields({ count: pluginRegistry.listEntries().length }).log('plugin manifests loaded')
for (const entry of pluginRegistry.listEntries()) {
log.withFields({ name: entry.manifest.name, path: entry.path }).log('plugin manifest found')
}
// Plugin feature: Static Assets serving
const pluginAssetService = createPluginAssetService({
getManifestEntryByName: () => pluginRegistry.getManifestEntryByName(),
})
await pluginAssetService.start()
const loaded = new Set<string>()
const loadedSessionIds = new Map<string, string>()
const moduleAssetTokenCache = new Map<string, string>()
const refreshManifests = async () => {
await pluginRegistry.refresh()
}
const getConfig = () => pluginConfig.get()
const listSnapshot = (): PluginRegistrySnapshot => {
return buildPluginRegistrySnapshot({
pluginsRoot,
entries: pluginRegistry.listEntries(),
config: getConfig(),
loaded,
})
}
const issueModuleAssetToken = (input: {
pluginId: string
version: string
sessionId: string
routeAssetPath: string
pathPrefix: string
}) => {
const { pluginId, version, sessionId, routeAssetPath, pathPrefix } = input
const cacheKey = `${pluginId}:${version}:${sessionId}:${routeAssetPath}`
const cachedToken = moduleAssetTokenCache.get(cacheKey)
if (cachedToken) {
return cachedToken
}
const token = pluginAssetService.issueAccessToken({
pluginId,
version,
sessionId,
pathPrefix,
ttlMs: extensionAssetTokenTtlMs,
})
moduleAssetTokenCache.set(cacheKey, token)
return token
}
const pluginAssetSnapshotService: PluginAssetSnapshotService = {
getBaseUrl: pluginAssetService.getBaseUrl,
issueAccessToken: ({ pluginId, version, sessionId, routeAssetPath, pathPrefix }) => {
return issueModuleAssetToken({
pluginId,
version,
sessionId,
routeAssetPath,
pathPrefix,
})
},
}
const inspectSnapshot = (): PluginHostDebugSnapshot => {
return buildPluginHostDebugSnapshot({
host,
pluginsRoot,
entries: pluginRegistry.listEntries(),
config: getConfig(),
loaded,
manifestEntryByName: pluginRegistry.getManifestEntryByName(),
pluginAssetService: pluginAssetSnapshotService,
})
}
const loadPluginByName = async (
name: string,
loadOptions: { cacheBustKey?: string } = {},
) => {
if (loaded.has(name)) {
return
}
const entry = pluginRegistry.findManifestEntry(name)
if (!entry) {
throw new Error(`Plugin manifest not found: ${name}`)
}
const manifestForLoad = createManifestForLoad(entry, loadOptions)
const session = await host.start(manifestForLoad, { cwd: dirname(entry.path) })
loaded.add(name)
loadedSessionIds.set(name, session.id)
log.log('plugin loaded', { plugin: name, sessionId: session.id })
}
const stopLoadedPluginByName = (name: string) => {
const sessionId = loadedSessionIds.get(name)
if (!sessionId) {
loaded.delete(name)
return
}
host.stop(sessionId)
loadedSessionIds.delete(name)
loaded.delete(name)
for (const key of moduleAssetTokenCache.keys()) {
if (key.startsWith(`${name}:`)) {
moduleAssetTokenCache.delete(key)
}
}
log.log('plugin unloaded', { plugin: name, sessionId })
}
const resolveAutoReloadWatchPaths = (name: string) => {
const entry = pluginRegistry.findManifestEntry(name)
if (!entry) {
return []
}
const entrypointPath = resolvePluginRuntimeEntrypointPath(entry)
return [...new Set([entry.path, entrypointPath].filter((path): path is string => Boolean(path)))]
}
// Plugin feature: Auto-reload for plugins
const autoReloadFeature = createPluginAutoReloadFeature({
log,
getConfig,
listEntries: () => pluginRegistry.listEntries(),
isLoaded: name => loaded.has(name),
resolveWatchPaths: resolveAutoReloadWatchPaths,
reload: async (name) => {
stopLoadedPluginByName(name)
await refreshManifests()
await loadPluginByName(name, { cacheBustKey: `auto-reload-${Date.now()}` })
},
})
const unloadPluginByName = (name: string) => {
autoReloadFeature.clearPlugin(name)
stopLoadedPluginByName(name)
}
const loadEnabledPlugins = async () => {
const config = getConfig()
for (const entry of pluginRegistry.listEntries()) {
const name = entry.manifest.name
if (!config.enabled.includes(name)) {
continue
}
if (loaded.has(name)) {
continue
}
try {
await loadPluginByName(name)
}
catch (error) {
log.withError(error).withFields({ plugin: name }).error('plugin failed to start')
}
}
autoReloadFeature.sync()
}
await refreshManifests()
await loadEnabledPlugins()
autoReloadFeature.sync()
return {
host,
manifests: pluginRegistry.listManifests(),
async list() {
await refreshManifests()
autoReloadFeature.sync()
return listSnapshot()
},
async setEnabled(payload) {
await refreshManifests()
const config = getConfig()
const enabled = new Set(config.enabled)
if (payload.enabled) {
enabled.add(payload.name)
}
else {
enabled.delete(payload.name)
pluginAssetService.revokeByPluginId(payload.name)
}
const entry = pluginRegistry.findManifestEntry(payload.name)
const manifestPath = entry?.path ?? payload.path ?? ''
pluginConfig.update({
enabled: [...enabled],
autoReload: config.autoReload,
known: {
...config.known,
[payload.name]: { path: manifestPath },
},
})
autoReloadFeature.sync()
return listSnapshot()
},
async setAutoReload(payload) {
await refreshManifests()
const config = getConfig()
const autoReload = new Set(config.autoReload)
if (payload.enabled) {
autoReload.add(payload.name)
}
else {
autoReload.delete(payload.name)
}
pluginConfig.update({
...config,
autoReload: [...autoReload],
})
autoReloadFeature.sync()
return listSnapshot()
},
async loadEnabled() {
await refreshManifests()
await loadEnabledPlugins()
autoReloadFeature.sync()
return listSnapshot()
},
async load(name) {
await refreshManifests()
await loadPluginByName(name)
autoReloadFeature.sync()
return listSnapshot()
},
unload(name) {
unloadPluginByName(name)
autoReloadFeature.sync()
return listSnapshot()
},
async inspect() {
await refreshManifests()
autoReloadFeature.sync()
return inspectSnapshot()
},
getAssetBaseUrl() {
return pluginAssetService.getBaseUrl() ?? ''
},
async dispose() {
autoReloadFeature.dispose()
pluginAssetService.revokeAll()
await pluginAssetService.stop()
},
}
}
@@ -0,0 +1,349 @@
import type { Dirent } from 'node:fs'
import type { useLogg } from '@guiiai/logg'
import type { ManifestV1 } from '@proj-airi/plugin-sdk/plugin-host'
import type {
PluginManifestSummary,
PluginRegistrySnapshot,
} from '../../../../../shared/eventa/plugin/host'
import type { ManifestEntry, PluginConfig } from '../types'
import { mkdir, readdir, readFile, realpath, stat } from 'node:fs/promises'
import { dirname, isAbsolute, join, resolve } from 'node:path'
import { manifestV1Schema } from '@proj-airi/plugin-sdk/plugin-host'
import { safeParse } from 'valibot'
export const pluginManifestFileName = 'plugin.airi.json'
function isManifestV1(value: unknown): value is ManifestV1 {
return safeParse(manifestV1Schema, value).success
}
async function realPathOf(entry: Dirent<string>, options?: { cwd?: string }): Promise<{ resolved: false, path?: string, error?: unknown } | { resolved: true, path: string, error?: unknown }> {
if (!entry.isSymbolicLink()) {
return { resolved: false }
}
try {
const resolvedPath = await realpath(join(options?.cwd ?? '', entry.name))
const stats = await stat(resolvedPath)
if (stats.isFile() || stats.isDirectory()) {
return { resolved: true, path: resolvedPath }
}
return { resolved: false }
}
catch (error) {
return { resolved: false, error }
}
}
/**
* Loads plugin manifests from plugin subdirectories under the configured root.
*
* Use when:
* - Refreshing the plugin registry state from disk
* - Resolving symlink-backed plugin directories before manifest parsing
*
* Expects:
* - Root directory may not exist yet
* - Each plugin is nested under its own child directory
* - Each plugin directory may include `plugin.airi.json` and optional `package.json`
*
* Returns:
* - Array of validated manifest entries with resolved paths and version metadata
*/
export async function loadManifestsFrom(
dir: string,
log: ReturnType<typeof useLogg>,
): Promise<ManifestEntry[]> {
await mkdir(dir, { recursive: true })
const entries = await readdir(dir, { withFileTypes: true })
const manifests: ManifestEntry[] = []
const manifestPaths: Array<{ path: string, rootDir: string }> = []
for (const entry of entries) {
if (!entry.isDirectory()) {
if (entry.isSymbolicLink()) {
const { resolved, error } = await realPathOf(entry, { cwd: dir })
if (error) {
log.withError(error).withFields({ name: entry.name }).warn('failed to resolve plugin manifest path, skipping')
continue
}
if (!resolved) {
log.withFields({ name: entry.name }).warn('found symlink that does not resolve to a file, skipping')
continue
}
}
else {
continue
}
}
let pluginDir = join(dir, entry.name)
if (entry.isSymbolicLink()) {
const { path, resolved } = await realPathOf(entry, { cwd: dir })
if (resolved) {
pluginDir = path
}
else {
log.withFields({ name: entry.name }).warn('found symlink that does not resolve to a file, skipping')
continue
}
}
const pluginEntries = await readdir(pluginDir, { withFileTypes: true })
const manifestEntry = pluginEntries.find(candidate => candidate.name === pluginManifestFileName)
if (!manifestEntry) {
continue
}
const manifestPath = join(pluginDir, pluginManifestFileName)
if (manifestEntry.isFile()) {
manifestPaths.push({ path: manifestPath, rootDir: pluginDir })
continue
}
if (!manifestEntry.isSymbolicLink()) {
continue
}
try {
const resolvedPath = await realpath(manifestPath)
const stats = await stat(resolvedPath)
if (!stats.isFile()) {
continue
}
manifestPaths.push({ path: manifestPath, rootDir: pluginDir })
}
catch (error) {
log.withError(error).withFields({ name: manifestEntry.name }).warn('failed to resolve symlink, skipping')
}
}
for (const manifestPath of manifestPaths) {
try {
const raw = await readFile(manifestPath.path, 'utf-8')
const parsed = JSON.parse(raw) as unknown
if (!isManifestV1(parsed)) {
log.warn('invalid plugin manifest schema', { path: manifestPath.path })
continue
}
let version = '0.0.0'
try {
const packageJsonRaw = await readFile(join(manifestPath.rootDir, 'package.json'), 'utf-8')
const packageJson = JSON.parse(packageJsonRaw) as Record<string, unknown>
if (typeof packageJson.version === 'string' && packageJson.version.trim()) {
version = packageJson.version.trim()
}
}
catch {
// Ignore package.json read failures; plugin manifests without package metadata
// still load with a deterministic fallback version.
}
manifests.push({
manifest: parsed,
path: manifestPath.path,
rootDir: manifestPath.rootDir,
version,
})
}
catch (error) {
log.withError(error).withFields({ path: manifestPath.path }).error('failed to read plugin manifest')
}
}
return manifests
}
/**
* Builds a renderer-facing plugin summary from manifest, config, and runtime state.
*
* Use when:
* - Registry snapshots need one UI-friendly entry per discovered plugin
*
* Expects:
* - `entry` corresponds to a currently discovered manifest
* - `config` is the latest persisted plugin config
* - `loaded` tracks currently running plugin names
*
* Returns:
* - Stable manifest summary for UI consumption
*/
export function createPluginSummary(
entry: ManifestEntry,
config: PluginConfig,
loaded: Set<string>,
): PluginManifestSummary {
const name = entry.manifest.name
return {
name,
entrypoints: entry.manifest.entrypoints,
path: entry.path,
enabled: config.enabled.includes(name),
autoReload: config.autoReload.includes(name),
loaded: loaded.has(name),
isNew: !config.known[name],
}
}
/**
* Builds the renderer-facing plugin registry snapshot.
*
* Use when:
* - IPC clients request the plugin list
* - Internal host operations need a fresh registry view after config or load changes
*
* Expects:
* - `entries`, `config`, and `loaded` come from the latest in-memory host state
*
* Returns:
* - A stable registry snapshot for renderer consumption
*/
export function buildPluginRegistrySnapshot(options: {
pluginsRoot: string
entries: ManifestEntry[]
config: PluginConfig
loaded: Set<string>
}): PluginRegistrySnapshot {
return {
root: options.pluginsRoot,
plugins: options.entries.map(entry => createPluginSummary(entry, options.config, options.loaded)),
}
}
/**
* Resolves the absolute runtime entrypoint path used by load and auto-reload flows.
*
* Use when:
* - File watching needs the runtime entrypoint path
* - Host loading needs to reason about the resolved runtime file
*
* Expects:
* - Entrypoint is either absolute or relative to the manifest directory
*
* Returns:
* - Absolute file path when entrypoint exists; otherwise `undefined`
*/
export function resolvePluginRuntimeEntrypointPath(entry: ManifestEntry): string | undefined {
const entrypoint = entry.manifest.entrypoints.electron ?? entry.manifest.entrypoints.default
if (!entrypoint) {
return undefined
}
const manifestDir = dirname(entry.path)
return isAbsolute(entrypoint) ? entrypoint : resolve(manifestDir, entrypoint)
}
function appendCacheBustKey(entrypoint: string, cacheBustKey: string): string {
const delimiter = entrypoint.includes('?') ? '&' : '?'
return `${entrypoint}${delimiter}cacheBust=${encodeURIComponent(cacheBustKey)}`
}
/**
* Produces the manifest used for runtime loading, optionally with a cache-busted entrypoint.
*
* Use when:
* - Loading a plugin normally
* - Reloading a plugin after file changes to avoid stale module cache
*
* Expects:
* - `cacheBustKey` is omitted for standard loads
* - `cacheBustKey` is deterministic enough for one reload cycle when provided
*
* Returns:
* - Original manifest or cloned manifest with cache-busted runtime entrypoint
*/
export function createManifestForLoad(
entry: ManifestEntry,
options: { cacheBustKey?: string },
): ManifestV1 {
if (!options.cacheBustKey) {
return entry.manifest
}
const manifest = structuredClone(entry.manifest)
if (manifest.entrypoints.electron) {
manifest.entrypoints.electron = appendCacheBustKey(manifest.entrypoints.electron, options.cacheBustKey)
}
else if (manifest.entrypoints.default) {
manifest.entrypoints.default = appendCacheBustKey(manifest.entrypoints.default, options.cacheBustKey)
}
return manifest
}
/**
* Tracks the manifest registry state used by the Electron plugin host.
*
* Use when:
* - Refreshing plugin manifests from disk
* - Looking up manifests by plugin name during load or inspect operations
*
* Expects:
* - `refresh()` is called before consumers read entries or manifests
* - `pluginsRoot` points at the plugin manifest root under user data
*
* Returns:
* - Read access to the current manifest entries, manifest list, and lookup map
*/
export interface PluginHostRegistry {
getRoot: () => string
refresh: () => Promise<ManifestEntry[]>
listEntries: () => ManifestEntry[]
listManifests: () => ManifestV1[]
findManifestEntry: (name: string) => ManifestEntry | undefined
getManifestEntryByName: () => Map<string, ManifestEntry>
}
/**
* Creates the manifest registry store used by the plugin host bootstrap.
*
* Use when:
* - Host bootstrap needs in-memory manifest lookup and refresh operations
*
* Expects:
* - `log` is the plugin-host logger used for manifest loading diagnostics
*
* Returns:
* - A registry wrapper around the current manifest entry array and lookup map
*/
export function createPluginHostRegistry(options: {
pluginsRoot: string
log: ReturnType<typeof useLogg>
}): PluginHostRegistry {
let entries: ManifestEntry[] = []
let manifests: ManifestV1[] = []
let manifestEntryByName = new Map<string, ManifestEntry>()
return {
getRoot() {
return options.pluginsRoot
},
async refresh() {
entries = await loadManifestsFrom(options.pluginsRoot, options.log)
manifestEntryByName = new Map()
for (const entry of entries) {
if (!manifestEntryByName.has(entry.manifest.name)) {
manifestEntryByName.set(entry.manifest.name, entry)
}
}
manifests = entries.map(entry => entry.manifest)
return entries
},
listEntries() {
return entries
},
listManifests() {
return manifests
},
findManifestEntry(name) {
return manifestEntryByName.get(name)
},
getManifestEntryByName() {
return manifestEntryByName
},
}
}
File diff suppressed because it is too large Load Diff
@@ -1,206 +1,41 @@
import type { Dirent } from 'node:fs'
import type { ManifestV1 } from '@proj-airi/plugin-sdk/plugin-host'
import type {
PluginHostDebugSnapshot,
PluginManifestSummary,
PluginRegistrySnapshot,
} from '../../../../shared/eventa'
PluginHostService,
SetupPluginHostOptions,
} from './types'
import { mkdir, readdir, readFile, realpath, stat } from 'node:fs/promises'
import { dirname, extname, join } from 'node:path'
import { useLogg } from '@guiiai/logg'
import { defineInvoke, defineInvokeHandler } from '@moeru/eventa'
import {
defineInvoke,
defineInvokeHandler,
} from '@moeru/eventa'
import { createContext } from '@moeru/eventa/adapters/electron/main'
import { manifestV1Schema, PluginHost } from '@proj-airi/plugin-sdk/plugin-host'
import { app, ipcMain } from 'electron'
import { array, object, record, safeParse, string } from 'valibot'
import {
app,
ipcMain,
} from 'electron'
import {
electronPluginGetAssetBaseUrl,
} from '../../../../shared/eventa/plugin/assets'
import {
electronPluginUpdateCapability,
pluginProtocolListProviders,
pluginProtocolListProvidersEventName,
} from '../../../../shared/eventa/plugin/capabilities'
import {
electronPluginInspect,
electronPluginList,
electronPluginLoad,
electronPluginLoadEnabled,
electronPluginSetAutoReload,
electronPluginSetEnabled,
electronPluginUnload,
electronPluginUpdateCapability,
pluginProtocolListProviders,
pluginProtocolListProvidersEventName,
} from '../../../../shared/eventa'
import { createConfig } from '../../../libs/electron/persistence'
interface PluginHostService {
host: PluginHost
manifests: ManifestV1[]
}
interface CapabilityAwarePluginHost extends PluginHost {
setResourceResolver: <T>(key: string, resolver: () => Promise<T> | T) => void
announceCapability: (key: string, metadata?: Record<string, unknown>) => {
key: string
state: 'announced' | 'ready' | 'degraded' | 'withdrawn'
metadata?: Record<string, unknown>
updatedAt: number
}
markCapabilityReady: (key: string, metadata?: Record<string, unknown>) => {
key: string
state: 'announced' | 'ready' | 'degraded' | 'withdrawn'
metadata?: Record<string, unknown>
updatedAt: number
}
markCapabilityDegraded: (key: string, metadata?: Record<string, unknown>) => {
key: string
state: 'announced' | 'ready' | 'degraded' | 'withdrawn'
metadata?: Record<string, unknown>
updatedAt: number
}
withdrawCapability: (key: string, metadata?: Record<string, unknown>) => {
key: string
state: 'announced' | 'ready' | 'degraded' | 'withdrawn'
metadata?: Record<string, unknown>
updatedAt: number
}
}
interface PluginConfig {
enabled: string[]
known: Record<string, { path: string }>
}
interface ManifestEntry {
manifest: ManifestV1
path: string
}
const pluginConfigSchema = object({
enabled: array(string()),
known: record(string(), object({
path: string(),
})),
})
function isManifestV1(value: unknown): value is ManifestV1 {
return safeParse(manifestV1Schema, value).success
}
async function realPathOf(entry: Dirent<string>, options?: { cwd?: string }): Promise<{ resolved: false, path?: string, error?: unknown } | { resolved: true, path: string, error?: unknown }> {
if (!entry.isSymbolicLink()) {
return { resolved: false }
}
try {
const resolvedPath = await realpath(join(options?.cwd ?? '', entry.name))
const stats = await stat(resolvedPath)
if (stats.isFile() || stats.isDirectory()) {
return { resolved: true, path: resolvedPath }
}
return { resolved: false }
}
catch (error) {
return { resolved: false, error }
}
}
async function loadManifestsFrom(dir: string, log: ReturnType<typeof useLogg>): Promise<ManifestEntry[]> {
await mkdir(dir, { recursive: true })
const entries = await readdir(dir, { withFileTypes: true })
const manifests: ManifestEntry[] = []
const manifestPaths: string[] = []
for (const entry of entries) {
if (!entry.isDirectory()) {
if (entry.isSymbolicLink()) {
const { resolved, error } = await realPathOf(entry, { cwd: dir })
if (error) {
log.withError(error).withFields({ name: entry.name }).warn('failed to resolve plugin manifest path, skipping')
continue
}
if (!resolved) {
log.withFields({ name: entry.name }).warn('found symlink that does not resolve to a file, skipping')
continue
}
}
else {
continue
}
}
let pluginDir = join(dir, entry.name)
if (entry.isSymbolicLink()) {
const { path, resolved } = await realPathOf(entry, { cwd: dir })
if (resolved) {
pluginDir = path
}
else {
log.withFields({ name: entry.name }).warn('found symlink that does not resolve to a file, skipping')
continue
}
}
const pluginEntries = await readdir(pluginDir, { withFileTypes: true })
for (const pluginEntry of pluginEntries) {
if (pluginEntry.isSymbolicLink()) {
try {
const resolvedPath = await realpath(join(pluginDir, pluginEntry.name))
const stats = await stat(resolvedPath)
if (!stats.isFile()) {
continue
}
if (extname(resolvedPath) !== '.json') {
continue
}
}
catch (error) {
log.withError(error).withFields({ name: pluginEntry.name }).warn('failed to resolve symlink, skipping')
continue
}
manifestPaths.push(join(pluginDir, pluginEntry.name))
}
if (pluginEntry.isFile() && extname(pluginEntry.name) === '.json') {
manifestPaths.push(join(pluginDir, pluginEntry.name))
}
continue
}
}
for (const path of manifestPaths) {
try {
const raw = await readFile(path, 'utf-8')
const parsed = JSON.parse(raw) as unknown
if (!isManifestV1(parsed)) {
log.warn('invalid plugin manifest schema', { path })
continue
}
manifests.push({ manifest: parsed, path })
}
catch (error) {
log.withError(error).withFields({ path }).error('failed to read plugin manifest')
}
}
return manifests
}
function createPluginSummary(entry: ManifestEntry, config: PluginConfig, loaded: Set<string>): PluginManifestSummary {
const name = entry.manifest.name
return {
name,
entrypoints: entry.manifest.entrypoints,
path: entry.path,
enabled: config.enabled.includes(name),
loaded: loaded.has(name),
isNew: !config.known[name],
}
}
} from '../../../../shared/eventa/plugin/host'
import {
electronPluginInvokeTool,
electronPluginListAgentTools,
electronPluginListXsaiTools,
} from '../../../../shared/eventa/plugin/tools'
import { setupPluginHostHostService } from './host'
/**
* Initializes the Electron plugin host and wires IPC handlers.
@@ -215,185 +50,62 @@ function createPluginSummary(entry: ManifestEntry, config: PluginConfig, loaded:
*
* Persists enablement/known state to `plugins-v1.json` alongside config data.
*
* - Windows: %APPDATA%\${appId}\plugins-v1.json
* - Windows: %APPDATA%\${appId}/plugins-v1.json
* - Linux: $XDG_CONFIG_HOME/${appId}/plugins-v1.json or ~/.config/${appId}/plugins-v1.json
* - macOS: ~/Library/Application Support/${appId}/plugins-v1.json
*/
export async function setupPluginHost(): Promise<PluginHostService> {
const log = useLogg('main/plugin-host').useGlobalConfig()
const pluginsRoot = join(app.getPath('userData'), 'plugins', 'v1')
const pluginConfig = createConfig('plugins', 'v1.json', pluginConfigSchema, {
default: {
enabled: [],
known: {},
},
autoHeal: true,
})
pluginConfig.setup()
const host = new PluginHost({ runtime: 'electron' })
// NOTICE: stage-tamagotchi currently typechecks against package exports while plugin-sdk changes
// are source-local in this workspace. Cast keeps the bridge typed until package dist is regenerated.
const capabilityHost = host as CapabilityAwarePluginHost
log.withFields({ pluginsRoot }).log('loading plugin manifests')
let entries = await loadManifestsFrom(pluginsRoot, log)
log.withFields({ count: entries.length }).log('plugin manifests loaded')
let manifests = entries.map((entry) => {
log.withFields({ name: entry.manifest.name, path: entry.path }).log('plugin manifest found')
return entry.manifest
})
const loaded = new Set<string>()
const loadedSessionIds = new Map<string, string>()
const refreshManifests = async () => {
entries = await loadManifestsFrom(pluginsRoot, log)
manifests = entries.map(entry => entry.manifest)
}
const getConfig = (): PluginConfig => {
return pluginConfig.get() ?? { enabled: [], known: {} }
}
const toSnapshot = (): PluginRegistrySnapshot => {
const config = getConfig()
return {
root: pluginsRoot,
plugins: entries.map(entry => createPluginSummary(entry, config, loaded)),
}
}
const toDebugSnapshot = (): PluginHostDebugSnapshot => {
return {
registry: toSnapshot(),
sessions: host.listSessions().map(session => ({
id: session.id,
manifestName: session.manifest.name,
phase: session.phase,
runtime: session.runtime,
moduleId: session.identity.id,
})),
capabilities: capabilityHost.listCapabilities(),
refreshedAt: Date.now(),
}
}
const findManifestEntry = (name: string) => {
return entries.find(entry => entry.manifest.name === name)
}
const loadPluginByName = async (name: string) => {
if (loaded.has(name))
return
const entry = findManifestEntry(name)
if (!entry) {
throw new Error(`Plugin manifest not found: ${name}`)
}
const session = await host.start(entry.manifest, { cwd: dirname(entry.path) })
loaded.add(name)
loadedSessionIds.set(name, session.id)
log.log('plugin loaded', { plugin: name, sessionId: session.id })
}
const unloadPluginByName = (name: string) => {
const sessionId = loadedSessionIds.get(name)
if (!sessionId) {
loaded.delete(name)
return
}
host.stop(sessionId)
loadedSessionIds.delete(name)
loaded.delete(name)
log.log('plugin unloaded', { plugin: name, sessionId })
}
const loadEnabled = async () => {
const config = getConfig()
for (const entry of entries) {
const name = entry.manifest.name
if (!config.enabled.includes(name))
continue
if (loaded.has(name))
continue
try {
await loadPluginByName(name)
}
catch (error) {
log.withError(error).withFields({ plugin: name }).error('plugin failed to start')
}
}
}
export async function setupPluginHost(options: SetupPluginHostOptions): Promise<PluginHostService> {
const hostService = await setupPluginHostHostService(options)
const { context } = createContext(ipcMain)
const invokePluginProtocolListProviders = defineInvoke(context, pluginProtocolListProviders)
defineInvokeHandler(context, electronPluginList, async () => {
// IPC: fetch current plugin list by refreshing manifests and returning a snapshot.
await refreshManifests()
return toSnapshot()
return await hostService.list()
})
defineInvokeHandler(context, electronPluginSetEnabled, async (payload) => {
// IPC: toggle a plugin's enabled state, persist config, and return updated snapshot.
await refreshManifests()
const config = getConfig()
const enabled = new Set(config.enabled)
if (payload?.enabled)
enabled.add(payload.name)
else
enabled.delete(payload.name)
return await hostService.setEnabled(payload)
})
const entry = entries.find(candidate => candidate.manifest.name === payload.name)
const manifestPath = entry?.path ?? payload.path ?? ''
const nextConfig: PluginConfig = {
enabled: [...enabled],
known: {
...config.known,
[payload.name]: { path: manifestPath },
},
}
pluginConfig.update(nextConfig)
return toSnapshot()
defineInvokeHandler(context, electronPluginSetAutoReload, async (payload) => {
return await hostService.setAutoReload(payload)
})
defineInvokeHandler(context, electronPluginLoadEnabled, async () => {
// IPC: load all enabled plugins and return the latest snapshot.
await refreshManifests()
await loadEnabled()
return toSnapshot()
return await hostService.loadEnabled()
})
defineInvokeHandler(context, electronPluginLoad, async (payload) => {
await refreshManifests()
await loadPluginByName(payload.name)
return toSnapshot()
return await hostService.load(payload.name)
})
defineInvokeHandler(context, electronPluginUnload, async (payload) => {
unloadPluginByName(payload.name)
return toSnapshot()
return hostService.unload(payload.name)
})
defineInvokeHandler(context, electronPluginInspect, async () => {
await refreshManifests()
return toDebugSnapshot()
return await hostService.inspect()
})
defineInvokeHandler(context, electronPluginGetAssetBaseUrl, async () => {
return hostService.getAssetBaseUrl()
})
defineInvokeHandler(context, electronPluginListAgentTools, async () => {
return await hostService.host.listAvailableToolDescriptors()
})
defineInvokeHandler(context, electronPluginListXsaiTools, async () => {
return await hostService.host.listSerializedXsaiTools()
})
defineInvokeHandler(context, electronPluginInvokeTool, async (payload) => {
return await hostService.host.invokeTool(payload.ownerPluginId, payload.name, payload.input)
})
defineInvokeHandler(context, electronPluginUpdateCapability, async (payload) => {
if (payload.key === pluginProtocolListProvidersEventName && payload.state === 'ready') {
capabilityHost.setResourceResolver(
hostService.host.setResourceResolver(
pluginProtocolListProvidersEventName,
async () => await invokePluginProtocolListProviders(),
)
@@ -401,13 +113,13 @@ export async function setupPluginHost(): Promise<PluginHostService> {
switch (payload.state) {
case 'announced':
return capabilityHost.announceCapability(payload.key, payload.metadata)
return hostService.host.announceCapability(payload.key, payload.metadata)
case 'ready':
return capabilityHost.markCapabilityReady(payload.key, payload.metadata)
return hostService.host.markCapabilityReady(payload.key, payload.metadata)
case 'degraded':
return capabilityHost.markCapabilityDegraded(payload.key, payload.metadata)
return hostService.host.markCapabilityDegraded(payload.key, payload.metadata)
case 'withdrawn':
return capabilityHost.withdrawCapability(payload.key, payload.metadata)
return hostService.host.withdrawCapability(payload.key, payload.metadata)
default: {
const unexpectedState: never = payload.state
throw new Error(`Unsupported capability state: ${unexpectedState}`)
@@ -415,9 +127,14 @@ export async function setupPluginHost(): Promise<PluginHostService> {
}
})
// Initialize enabled plugins during module setup so startup is bound to injeca lifecycle.
await refreshManifests()
await loadEnabled()
if (typeof app.once === 'function') {
app.once('before-quit', () => {
void hostService.dispose()
})
}
return { host, manifests }
return {
host: hostService.host,
manifests: hostService.manifests,
}
}
@@ -0,0 +1,207 @@
import type {
BindingRecord,
HostDataRecord,
PluginHost,
} from '@proj-airi/plugin-sdk/plugin-host'
import type { WidgetWindowSize } from '../../../../../../shared/eventa'
import { isPlainObject } from 'es-toolkit'
function cloneRecord<TValue>(value: TValue): TValue {
return structuredClone(value)
}
function toRecord(value: unknown): Record<string, unknown> | undefined {
return isPlainObject(value) ? cloneRecord(value as Record<string, unknown>) : undefined
}
function toHostDataRecord(value: unknown): HostDataRecord | undefined {
return isPlainObject(value) ? cloneRecord(value as HostDataRecord) : undefined
}
function toWindowSize(value: unknown): WidgetWindowSize | undefined {
if (!isPlainObject(value)) {
return undefined
}
if (typeof value.width !== 'number' || typeof value.height !== 'number') {
return undefined
}
return cloneRecord(value as WidgetWindowSize)
}
/**
* Resolves one owned gamelet binding and rejects mismatched ownership.
*
* Use when:
* - Plugin sessions invoke `session.apis.gamelets.*`
* - The gamelet kit must enforce plugin and session ownership before touching widget state
*
* Expects:
* - `host` is the active plugin host instance
* - `moduleId` refers to a binding announced through `kit.gamelet`
*
* Returns:
* - The owned gamelet binding record when ownership and kit checks pass
*/
export function getOwnedGameletBindingOrThrow(params: {
host: PluginHost
ownerPluginId: string
ownerSessionId: string
moduleId: string
}): BindingRecord<HostDataRecord> {
const binding = params.host.getBinding(params.moduleId)
if (!binding) {
throw new Error(`Gamelet module not found: ${params.moduleId}`)
}
if (binding.ownerPluginId !== params.ownerPluginId) {
throw new Error(`Gamelet module \`${params.moduleId}\` is not owned by plugin \`${params.ownerPluginId}\`.`)
}
if (binding.ownerSessionId !== params.ownerSessionId) {
throw new Error(`Gamelet module \`${params.moduleId}\` is not owned by session \`${params.ownerSessionId}\`.`)
}
if (binding.kitId !== 'kit.gamelet') {
throw new Error(`Module \`${params.moduleId}\` is not a gamelet binding.`)
}
return binding
}
/**
* Derives the widget window size for one gamelet binding.
*
* Use when:
* - Opening or reconfiguring a gamelet-backed widget
* - Preferring module config while preserving current window size when the config omits it
*
* Expects:
* - `moduleConfig` is the binding config stored on the host
*
* Returns:
* - The configured window size or the current widget snapshot size
*/
export function getGameletWidgetWindowSize(params: {
moduleConfig: HostDataRecord
existingSnapshot?: { windowSize?: unknown }
}): WidgetWindowSize | undefined {
const widgetConfig = toRecord(params.moduleConfig.widget)
const windowSize = toWindowSize(widgetConfig?.windowSize)
return windowSize ?? toWindowSize(params.existingSnapshot?.windowSize)
}
/**
* Derives the current display title for a gamelet widget.
*
* Use when:
* - Opening or updating a widget-backed gamelet
* - Preserving a current title when the binding config does not provide one
*
* Expects:
* - `moduleId` is the stable fallback title when neither config nor widget props define one
*
* Returns:
* - The best available title for the widget shell
*/
export function getGameletTitle(params: {
moduleId: string
moduleConfig: HostDataRecord
existingComponentProps?: Record<string, unknown>
}): string {
const configuredTitle = typeof params.moduleConfig.title === 'string' && params.moduleConfig.title.trim()
? params.moduleConfig.title
: undefined
const currentTitle = typeof params.existingComponentProps?.title === 'string' && params.existingComponentProps.title.trim()
? params.existingComponentProps.title
: undefined
return configuredTitle ?? currentTitle ?? params.moduleId
}
/**
* Reads the persisted gamelet config payload stored under `config.current`.
*
* Use when:
* - Hydrating widget payload state from the host binding config
* - Merging `gamelets.configure(...)` patches into the stored config
*
* Expects:
* - `moduleConfig` is the full binding config record for one gamelet
*
* Returns:
* - The stored config payload, or an empty record when it has not been set yet
*/
export function getStoredGameletConfig(moduleConfig: HostDataRecord): HostDataRecord {
const configSection = toHostDataRecord(moduleConfig.config)
return toHostDataRecord(configSection?.current) ?? {}
}
/**
* Merges one `gamelets.configure(...)` patch into the stored binding config.
*
* Use when:
* - Updating the host binding config and the mirrored widget payload together
*
* Expects:
* - `patch` is a JSON-compatible config patch
*
* Returns:
* - The merged `current` payload and the next full binding config record
*/
export function mergeGameletConfigPatch(params: {
moduleConfig: HostDataRecord
patch: HostDataRecord
}): {
nextCurrentConfig: HostDataRecord
nextConfig: HostDataRecord
} {
const nextCurrentConfig: HostDataRecord = {
...getStoredGameletConfig(params.moduleConfig),
...cloneRecord(params.patch),
}
const nextConfig: HostDataRecord = {
...cloneRecord(params.moduleConfig),
config: {
...toHostDataRecord(params.moduleConfig.config),
current: nextCurrentConfig,
},
}
return {
nextCurrentConfig,
nextConfig,
}
}
/**
* Builds the extension-ui component props used for one gamelet widget.
*
* Use when:
* - Opening or updating a gamelet-backed extension-ui widget
* - Preserving unrelated existing component props while replacing payload-specific fields
*
* Expects:
* - `moduleId` and `title` are already resolved for the current binding state
*
* Returns:
* - The next component props payload sent to the widgets manager
*/
export function createGameletWidgetProps(params: {
moduleId: string
title: string
payload?: Record<string, unknown>
windowSize?: WidgetWindowSize
existingComponentProps?: Record<string, unknown>
}): Record<string, unknown> {
return {
...params.existingComponentProps,
moduleId: params.moduleId,
title: params.title,
...(params.windowSize ? { windowSize: params.windowSize } : {}),
...(params.payload ? { payload: params.payload } : {}),
}
}
@@ -0,0 +1,344 @@
import type {
HostDataRecord,
KitDescriptor,
PluginHost,
PluginHostContribution,
} from '@proj-airi/plugin-sdk/plugin-host'
import type { PluginHostGameletWidgetsManager } from '../../types'
import { isPlainObject } from 'es-toolkit'
import {
createGameletWidgetProps,
getGameletTitle,
getGameletWidgetWindowSize,
getOwnedGameletBindingOrThrow,
getStoredGameletConfig,
mergeGameletConfigPatch,
} from './gamelet-widget-state'
/**
* Identifies the stage-tamagotchi permission key used to open a host-backed gamelet surface.
*
* Use when:
* - Declaring or asserting permission for `session.apis.gamelets.open(...)`
* - Reusing the stable gamelet event key in stage-owned tests
*
* Expects:
* - The gamelet kit contribution and its tests share this stage-owned constant
*
* Returns:
* - The permission/event key string for opening gamelets
*/
export const pluginGameletApiOpenEventName = 'proj-airi:plugin-sdk:apis:client:gamelets:open'
/**
* Identifies the stage-tamagotchi permission key used to update a host-backed gamelet surface.
*
* Use when:
* - Declaring or asserting permission for `session.apis.gamelets.configure(...)`
* - Reusing the stable gamelet event key in stage-owned tests
*
* Expects:
* - The gamelet kit contribution and its tests share this stage-owned constant
*
* Returns:
* - The permission/event key string for configuring gamelets
*/
export const pluginGameletApiConfigureEventName = 'proj-airi:plugin-sdk:apis:client:gamelets:configure'
/**
* Identifies the stage-tamagotchi permission key used to close a host-backed gamelet surface.
*
* Use when:
* - Declaring or asserting permission for `session.apis.gamelets.close(...)`
* - Reusing the stable gamelet event key in stage-owned tests
*
* Expects:
* - The gamelet kit contribution and its tests share this stage-owned constant
*
* Returns:
* - The permission/event key string for closing gamelets
*/
export const pluginGameletApiCloseEventName = 'proj-airi:plugin-sdk:apis:client:gamelets:close'
/**
* Identifies the stage-tamagotchi permission key used to query whether a gamelet is open.
*
* Use when:
* - Declaring or asserting permission for `session.apis.gamelets.isOpen(...)`
* - Reusing the stable gamelet event key in stage-owned tests
*
* Expects:
* - The gamelet kit contribution and its tests share this stage-owned constant
*
* Returns:
* - The permission/event key string for querying gamelets
*/
export const pluginGameletApiIsOpenEventName = 'proj-airi:plugin-sdk:apis:client:gamelets:is-open'
function cloneRecord<TValue>(value: TValue): TValue {
return structuredClone(value)
}
function toRecord(value: unknown): Record<string, unknown> | undefined {
return isPlainObject(value) ? cloneRecord(value as Record<string, unknown>) : undefined
}
/**
* Declares the built-in gamelet kit exposed by `stage-tamagotchi`.
*
* Use when:
* - Bootstrapping the Electron plugin host with gamelet support
* - Reading the stable built-in gamelet kit descriptor in tests or snapshots
*
* Expects:
* - The host registers this descriptor during startup
*
* Returns:
* - The gamelet kit descriptor used for `kit.gamelet`
*/
export const gameletPluginKitDescriptor = {
kitId: 'kit.gamelet',
version: '1.0.0',
runtimes: ['electron', 'web'],
capabilities: [
{ key: 'kit.gamelet.runtime', actions: ['announce', 'activate', 'update', 'withdraw', 'publish', 'subscribe'] },
],
} satisfies KitDescriptor
/**
* Registers the built-in gamelet kit on one host instance.
*
* Use when:
* - Bootstrapping the Electron plugin host with gamelet kit support
* - Keeping gamelet descriptor registration inside the gamelet kit module
*
* Expects:
* - `host` is the initialized plugin host instance
*
* Returns:
* - The registered gamelet kit descriptor
*/
export function registerGameletPluginKit(host: PluginHost): KitDescriptor {
return host.registerKit(gameletPluginKitDescriptor)
}
/**
* Creates the installable gamelet host contribution for `session.apis.gamelets`.
*
* Use when:
* - `stage-tamagotchi` needs plugin sessions to open, configure, close, or inspect gamelet widgets
* - The root plugin host bootstrap should consume a kit-owned contribution instead of embedding gamelet logic
*
* Expects:
* - `attachHost(...)` is called immediately after constructing `PluginHost`
* - `widgetsManager` already manages extension-ui widget state
*
* Returns:
* - A contribution plus an attach step that binds it to the constructed host instance
*/
export function createGameletHostContribution(options: {
widgetsManager: PluginHostGameletWidgetsManager
}): {
attachHost: (host: PluginHost) => void
contribution: PluginHostContribution
} {
let host: PluginHost | undefined
const openWidgetIdsBySession = new Map<string, Set<string>>()
const cleanupPromisesBySession = new Map<string, Promise<void>>()
const requireHost = () => {
if (!host) {
throw new Error('Gamelet host contribution has not been attached to a PluginHost instance.')
}
return host
}
const trackOpenWidget = (sessionId: string, moduleId: string) => {
const widgetIds = openWidgetIdsBySession.get(sessionId) ?? new Set<string>()
widgetIds.add(moduleId)
openWidgetIdsBySession.set(sessionId, widgetIds)
}
const untrackOpenWidget = (sessionId: string, moduleId: string) => {
const widgetIds = openWidgetIdsBySession.get(sessionId)
if (!widgetIds) {
return
}
widgetIds.delete(moduleId)
if (widgetIds.size === 0) {
openWidgetIdsBySession.delete(sessionId)
}
}
return {
attachHost(instance) {
host = instance
},
contribution: {
install(context) {
context.registerLifecycleHook('session-stopped', ({ session }) => {
const widgetIds = openWidgetIdsBySession.get(session.sessionId)
if (!widgetIds) {
return
}
const widgetIdsToRemove = [...widgetIds]
const cleanupPromise = Promise
.allSettled(widgetIdsToRemove.map(widgetId => options.widgetsManager.removeWidget(widgetId)))
.then(() => {
openWidgetIdsBySession.delete(session.sessionId)
cleanupPromisesBySession.delete(session.sessionId)
})
cleanupPromisesBySession.set(session.sessionId, cleanupPromise)
void cleanupPromise.catch(() => {})
})
context.registerSessionApi('gamelets', ({ session, assertPermission }) => ({
async open(id: string, params?: HostDataRecord) {
assertPermission({
area: 'apis',
action: 'invoke',
key: pluginGameletApiOpenEventName,
})
const module = getOwnedGameletBindingOrThrow({
host: requireHost(),
ownerPluginId: session.ownerPluginId,
ownerSessionId: session.sessionId,
moduleId: id,
})
const existingSnapshot = options.widgetsManager.getWidgetSnapshot(id)
const existingComponentProps = toRecord(existingSnapshot?.componentProps)
const payload = params
? cloneRecord(params)
: (toRecord(existingComponentProps?.payload) ?? getStoredGameletConfig(module.config))
const windowSize = getGameletWidgetWindowSize({
moduleConfig: module.config,
existingSnapshot,
})
const componentProps = createGameletWidgetProps({
moduleId: id,
title: getGameletTitle({
moduleId: id,
moduleConfig: module.config,
existingComponentProps,
}),
payload,
windowSize,
existingComponentProps,
})
if (existingSnapshot) {
await options.widgetsManager.updateWidget({
id,
componentProps,
windowSize,
})
await options.widgetsManager.openWindow({ id })
trackOpenWidget(session.sessionId, id)
return
}
await options.widgetsManager.pushWidget({
id,
componentName: 'extension-ui',
componentProps,
size: 'm',
ttlMs: 0,
windowSize,
})
trackOpenWidget(session.sessionId, id)
},
async configure(id: string, patch: HostDataRecord) {
assertPermission({
area: 'apis',
action: 'invoke',
key: pluginGameletApiConfigureEventName,
})
const module = getOwnedGameletBindingOrThrow({
host: requireHost(),
ownerPluginId: session.ownerPluginId,
ownerSessionId: session.sessionId,
moduleId: id,
})
const { nextConfig } = mergeGameletConfigPatch({
moduleConfig: module.config,
patch,
})
requireHost().updateBinding(module.ownerSessionId, id, { config: nextConfig })
const existingSnapshot = options.widgetsManager.getWidgetSnapshot(id)
if (!existingSnapshot) {
return
}
const existingComponentProps = toRecord(existingSnapshot.componentProps)
const existingPayload = toRecord(existingComponentProps?.payload) ?? {}
const windowSize = getGameletWidgetWindowSize({
moduleConfig: nextConfig,
existingSnapshot,
})
await options.widgetsManager.updateWidget({
id,
componentProps: createGameletWidgetProps({
moduleId: id,
title: getGameletTitle({
moduleId: id,
moduleConfig: nextConfig,
existingComponentProps,
}),
payload: {
...existingPayload,
...cloneRecord(patch),
},
windowSize,
existingComponentProps,
}),
windowSize,
})
},
async close(id: string) {
assertPermission({
area: 'apis',
action: 'invoke',
key: pluginGameletApiCloseEventName,
})
getOwnedGameletBindingOrThrow({
host: requireHost(),
ownerPluginId: session.ownerPluginId,
ownerSessionId: session.sessionId,
moduleId: id,
})
await options.widgetsManager.removeWidget(id)
untrackOpenWidget(session.sessionId, id)
},
async isOpen(id: string) {
assertPermission({
area: 'apis',
action: 'invoke',
key: pluginGameletApiIsOpenEventName,
})
getOwnedGameletBindingOrThrow({
host: requireHost(),
ownerPluginId: session.ownerPluginId,
ownerSessionId: session.sessionId,
moduleId: id,
})
return options.widgetsManager.getWidgetSnapshot(id) !== undefined
},
}))
},
},
}
}
@@ -0,0 +1,43 @@
import type { PluginHost } from '@proj-airi/plugin-sdk/plugin-host'
import type { SetupPluginHostOptions } from '../types'
import {
createGameletHostContribution,
registerGameletPluginKit,
} from './gamelet'
import { registerWidgetPluginKit } from './widget'
/**
* Creates the built-in kit runtime installed by the Electron plugin host.
*
* Use when:
* - Host bootstrap should depend on a kit-layer API instead of wiring widget/gamelet details inline
* - Built-in kit registration and contributions should remain outside the host layer
*
* Expects:
* - `widgetsManager` is initialized before host construction
*
* Returns:
* - Helpers to attach contributions and register built-in kits on the host
*/
export function createBuiltInPluginKitRuntime(options: SetupPluginHostOptions): {
contributions: ReturnType<typeof createGameletHostContribution>['contribution'][]
attachHost: (host: PluginHost) => void
registerHostKits: (host: PluginHost) => void
} {
const gameletContribution = createGameletHostContribution({
widgetsManager: options.widgetsManager,
})
return {
contributions: [gameletContribution.contribution],
attachHost(host) {
gameletContribution.attachHost(host)
},
registerHostKits(host) {
registerWidgetPluginKit(host)
registerGameletPluginKit(host)
},
}
}
@@ -0,0 +1,189 @@
import type { PluginHostModuleSummary } from '../../../../../../shared/eventa/plugin/host'
import type { ManifestEntry } from '../../types'
import { isPlainObject } from 'es-toolkit'
import {
buildMountedPluginAssetPath,
normalizePluginAssetPath,
} from '../../asset-mount'
const trailingSlashesPattern = /\/+$/
/**
* Describes one widget iframe asset as seen from the mounted `/ui` route.
*
* Use when:
* - Converting plugin config asset paths into mounted extension asset URLs
* - Issuing tokens that must validate against route-relative asset paths
*
* Expects:
* - `routeAssetPath` is relative to `/_airi/extensions/:extensionId/ui/`
* - `tokenPathPrefix` is a directory prefix under that same route, or empty for root
*
* Returns:
* - N/A
*/
export interface WidgetAssetRoute {
routeAssetPath: string
tokenPathPrefix: string
}
function normalizeWidgetAssetPath(assetPath: string): string | undefined {
const trimmed = assetPath.trim().replaceAll('\\', '/')
if (!trimmed) {
return undefined
}
const withoutRelativePrefix = trimmed.startsWith('./')
? trimmed.slice(2)
: trimmed
return normalizePluginAssetPath(withoutRelativePrefix)
}
function withSearchParams(url: string, query: Record<string, string>) {
const next = new URL(url)
for (const [key, value] of Object.entries(query)) {
next.searchParams.set(key, value)
}
return next.toString()
}
/**
* Normalizes a widget iframe asset path into `/ui` route semantics.
*
* Use when:
* - Building mounted widget iframe URLs
* - Issuing asset tokens that must validate against the `/ui` static asset route
* - Keeping widget route semantics owned by the widget kit module
*
* Expects:
* - `assetPath` points to a file-like path under plugin static assets
*
* Returns:
* - The route-relative asset path and the allowed token prefix for that route
*/
export function resolveWidgetAssetRoute(assetPath: string): WidgetAssetRoute | undefined {
const normalized = normalizeWidgetAssetPath(assetPath)
if (!normalized) {
return undefined
}
const routeAssetPath = normalized.startsWith('ui/')
? normalized.slice(3)
: normalized
if (!routeAssetPath) {
return undefined
}
const segments = routeAssetPath.split('/').filter(Boolean)
if (segments.length <= 1) {
return {
routeAssetPath,
tokenPathPrefix: routeAssetPath,
}
}
return {
routeAssetPath,
tokenPathPrefix: `${segments.slice(0, -1).join('/')}/`,
}
}
/**
* Rewrites widget iframe config to use mounted plugin asset URLs.
*
* Use when:
* - Building plugin inspect snapshots with renderer-consumable widget iframe URLs
* - Issuing temporary asset tokens for widget-owned iframe assets
*
* Expects:
* - Module config may contain widget iframe `src` or `assetPath` fields
* - Mapping includes a manifest entry for `module.ownerPluginId`
*
* Returns:
* - Original module when rewrite is not applicable
* - Cloned module with injected iframe `src` when asset path mount succeeds
*/
export function rewriteWidgetModuleAssetUrl(
module: PluginHostModuleSummary,
manifestEntryByName: Map<string, ManifestEntry>,
options?: {
pluginAssetBaseUrl?: string
issueAssetToken?: (input: {
extensionId: string
version: string
sessionId: string
routeAssetPath: string
tokenPathPrefix: string
}) => string
},
): PluginHostModuleSummary {
const entry = manifestEntryByName.get(module.ownerPluginId)
if (!entry) {
return module
}
const config = isPlainObject(module.config) ? module.config as Record<string, unknown> : {}
const widgetConfig = isPlainObject(config.widget) ? config.widget as Record<string, unknown> : {}
const iframeConfig = isPlainObject(widgetConfig.iframe) ? widgetConfig.iframe as Record<string, unknown> : {}
const iframeSrc = typeof iframeConfig.src === 'string' ? iframeConfig.src.trim() : ''
if (iframeSrc) {
return module
}
const assetPath = normalizeWidgetAssetPath(
typeof iframeConfig.assetPath === 'string'
? iframeConfig.assetPath
: typeof widgetConfig.iframeAssetPath === 'string'
? widgetConfig.iframeAssetPath
: typeof config.iframeAssetPath === 'string'
? config.iframeAssetPath
: '',
)
if (!assetPath) {
return module
}
const widgetAssetRoute = resolveWidgetAssetRoute(assetPath)
if (!widgetAssetRoute) {
return module
}
const mountedPath = buildMountedPluginAssetPath({
extensionId: entry.manifest.name,
assetPath: widgetAssetRoute.routeAssetPath,
})
if (!mountedPath) {
return module
}
const mountedAbsoluteUrl = options?.pluginAssetBaseUrl
? new URL(mountedPath, `${options.pluginAssetBaseUrl.replace(trailingSlashesPattern, '')}/`).toString()
: mountedPath
const assetToken = options?.issueAssetToken?.({
extensionId: entry.manifest.name,
version: entry.version,
sessionId: module.ownerSessionId,
routeAssetPath: widgetAssetRoute.routeAssetPath,
tokenPathPrefix: widgetAssetRoute.tokenPathPrefix,
})
const iframeSourceUrl = assetToken
? withSearchParams(mountedAbsoluteUrl, { t: assetToken })
: mountedAbsoluteUrl
return {
...module,
config: {
...config,
widget: {
...widgetConfig,
iframe: {
...iframeConfig,
src: iframeSourceUrl,
},
},
},
}
}
@@ -0,0 +1,48 @@
import type {
KitDescriptor,
PluginHost,
} from '@proj-airi/plugin-sdk/plugin-host'
export {
resolveWidgetAssetRoute,
rewriteWidgetModuleAssetUrl,
} from './asset-url'
/**
* Declares the built-in widget kit exposed by `stage-tamagotchi`.
*
* Use when:
* - Bootstrapping the Electron plugin host with widget support
* - Reading the stable built-in widget kit descriptor in tests or snapshots
*
* Expects:
* - The host registers this descriptor during startup
*
* Returns:
* - The widget kit descriptor used for `kit.widget`
*/
export const widgetPluginKitDescriptor = {
kitId: 'kit.widget',
version: '1.0.0',
runtimes: ['electron', 'web'],
capabilities: [
{ key: 'kit.widget.module', actions: ['announce', 'activate', 'update', 'withdraw'] },
],
} satisfies KitDescriptor
/**
* Registers the built-in widget kit on one host instance.
*
* Use when:
* - Bootstrapping the Electron plugin host with widget kit support
* - Keeping widget descriptor registration inside the widget kit module
*
* Expects:
* - `host` is the initialized plugin host instance
*
* Returns:
* - The registered widget kit descriptor
*/
export function registerWidgetPluginKit(host: PluginHost): KitDescriptor {
return host.registerKit(widgetPluginKitDescriptor)
}
@@ -0,0 +1,146 @@
import type { ManifestV1, PluginHost } from '@proj-airi/plugin-sdk/plugin-host'
import type {
WidgetsAddPayload,
WidgetSnapshot,
WidgetsUpdatePayload,
} from '../../../../shared/eventa'
/**
* Runtime-facing plugin host service bundle returned by setup.
*
* Use when:
* - Bootstrapping plugin infrastructure during Electron startup
* - Accessing loaded manifests after host initialization
*
* Expects:
* - `host` is an initialized Electron runtime plugin host
* - `manifests` reflect the latest loaded manifest snapshot at setup time
*
* Returns:
* - A stable object containing host instance and manifest list
*/
export interface PluginHostService {
host: PluginHost
manifests: ManifestV1[]
}
/**
* Describes the widget manager surface required by plugin-driven gamelet APIs.
*
* Use when:
* - `setupPluginHost(...)` needs to open, update, or close extension-ui widgets
*
* Expects:
* - Widget ids remain stable and may be reused for the same module id
*
* Returns:
* - The minimal widget-manager contract consumed by the plugin host service
*/
export interface PluginHostGameletWidgetsManager {
openWindow: (params?: { id?: string }) => Promise<void>
pushWidget: (payload: WidgetsAddPayload) => Promise<string>
updateWidget: (payload: WidgetsUpdatePayload) => Promise<void>
removeWidget: (id: string) => Promise<void>
getWidgetSnapshot: (id: string) => WidgetSnapshot | undefined
}
/**
* Configures the runtime dependencies required by `setupPluginHost(...)`.
*
* Use when:
* - Wiring the plugin host during Electron startup
* - Providing test doubles for plugin-driven gamelet orchestration
*
* Expects:
* - `widgetsManager` is already initialized and ready to manage overlay widgets
*
* Returns:
* - N/A
*/
export interface SetupPluginHostOptions {
widgetsManager: PluginHostGameletWidgetsManager
}
/**
* Binding announcement payload used by plugin-side runtime registration.
*
* Use when:
* - Announcing a new module for a registered kit
* - Reusing existing module ownership with the same module identifier
*
* Expects:
* - `moduleId` is unique per owner session/plugin pair
* - `kitId` and `kitModuleType` map to a registered kit descriptor
* - `config` is a JSON-compatible record
*
* Returns:
* - N/A
*/
export interface PluginHostBindingAnnounceInput {
moduleId: string
kitId: string
kitModuleType: string
config: Record<string, unknown>
}
/**
* Optional filters for listing announced bindings.
*
* Use when:
* - Querying only modules from one session
* - Querying modules belonging to one kit
*
* Expects:
* - Any provided key is treated as a strict equality filter
*
* Returns:
* - N/A
*/
export interface PluginHostBindingListOptions {
ownerSessionId?: string
kitId?: string
}
/**
* Persisted plugin configuration snapshot.
*
* Use when:
* - Reading/writing enabled and auto-reload plugin state
* - Keeping known plugin manifest path metadata
*
* Expects:
* - Arrays contain plugin manifest names
* - `known` maps plugin names to canonical manifest paths
*
* Returns:
* - N/A
*/
export interface PluginConfig {
enabled: string[]
autoReload: string[]
known: Record<string, { path: string }>
}
/**
* Internal manifest record with resolved location and package version.
*
* Use when:
* - Loading plugin manifests from disk
* - Resolving runtime entrypoints and extension asset metadata
*
* Expects:
* - `manifest` is schema-validated
* - `path` points to `plugin.airi.json`
* - `rootDir` is the plugin root directory
* - `version` is discovered from package metadata or fallback
*
* Returns:
* - N/A
*/
export interface ManifestEntry {
manifest: ManifestV1
path: string
rootDir: string
version: string
}
@@ -2,11 +2,11 @@ import type { BrowserWindow, Rectangle } from 'electron'
import type { InferOutput } from 'valibot'
import type {
PluginModuleWidgetPayload,
WidgetsAddPayload,
WidgetSnapshot,
WidgetsUpdatePayload,
} from '../../../shared/eventa'
import type { PluginModuleWidgetPayload } from '../../../shared/eventa/plugin/host'
import type { I18n } from '../../libs/i18n'
import type { ServerChannel } from '../../services/airi/channel-server'
+11 -7
View File
@@ -26,6 +26,16 @@ import ResizeHandler from './components/ResizeHandler.vue'
import {
electronGetServerChannelConfig,
electronSettingsNavigate,
electronStartTrackMousePosition,
i18nSetLocale,
} from '../shared/eventa'
import {
electronPluginUpdateCapability,
pluginProtocolListProviders,
pluginProtocolListProvidersEventName,
} from '../shared/eventa/plugin/capabilities'
import {
electronPluginInspect,
electronPluginList,
electronPluginLoad,
@@ -33,13 +43,7 @@ import {
electronPluginSetAutoReload,
electronPluginSetEnabled,
electronPluginUnload,
electronPluginUpdateCapability,
electronSettingsNavigate,
electronStartTrackMousePosition,
i18nSetLocale,
pluginProtocolListProviders,
pluginProtocolListProvidersEventName,
} from '../shared/eventa'
} from '../shared/eventa/plugin/host'
import { initializeElectronAuthCallbackBridge } from './bridges/electron-auth-callback'
import { initializeStageThreeRuntimeTraceBridge } from './bridges/stage-three-runtime-trace'
import { useTamagotchiMcpToolsStore } from './stores/mcp-tools'
@@ -31,7 +31,7 @@ vi.mock('@proj-airi/electron-vueuse', () => ({
}))
describe('useTamagotchiPluginToolsStore', async () => {
const { useTamagotchiPluginToolsStore } = await import('./plugin-tools')
const { useTamagotchiPluginToolsStore } = await import('./tools')
beforeEach(() => {
setActivePinia(createPinia())
@@ -3,7 +3,7 @@ import { useLlmToolsStore } from '@proj-airi/stage-ui/stores/llm-tools'
import { rawTool } from '@xsai/tool'
import { defineStore } from 'pinia'
import { electronPluginInvokeTool, electronPluginListXsaiTools } from '../../shared/eventa'
import { electronPluginInvokeTool, electronPluginListXsaiTools } from '../../shared/eventa/plugin/tools'
/**
* Registers Electron-backed plugin xsai tools into the shared LLM tools store.
@@ -1,13 +1,14 @@
<script setup lang="ts">
import type { ComponentPublicInstance } from 'vue'
import type { PluginHostModuleSummary, PluginModuleWidgetPayload } from '../../../../shared/eventa'
import type { PluginHostModuleSummary, PluginModuleWidgetPayload } from '../../../../shared/eventa/plugin/host'
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { isPlainObject } from 'es-toolkit'
import { computed, shallowRef } from 'vue'
import { electronPluginGetAssetBaseUrl, electronPluginInspect } from '../../../../shared/eventa'
import { electronPluginGetAssetBaseUrl } from '../../../../shared/eventa/plugin/assets'
import { electronPluginInspect } from '../../../../shared/eventa/plugin/host'
import { useExtensionUIForModule } from '../composables/use-extension-ui-for-module'
import { useIframeMessagePort } from '../composables/use-iframe-message-port'
import { canRenderExtensionUi, sanitizeExtensionUiRenderProps } from '../host'
@@ -1,6 +1,6 @@
import type { ComputedRef } from 'vue'
import type { PluginHostModuleSummary } from '../../../../shared/eventa'
import type { PluginHostModuleSummary } from '../../../../shared/eventa/plugin/host'
import { errorMessageFrom } from '@moeru/std'
import { isPlainObject } from 'es-toolkit'
@@ -22,6 +22,7 @@ function firstString(...values: unknown[]) {
}
const trailingSlashesPattern = /\/+$/
const mountedPluginAssetPathPrefix = '/_airi/extensions/'
/**
* Resolves the inspected extension UI module snapshot and derives iframe-facing config for the host.
@@ -123,7 +124,7 @@ export function useExtensionUIForModule(options: {
return undefined
}
if (src.startsWith('/_airi/plugins/')) {
if (src.startsWith(mountedPluginAssetPathPrefix)) {
const baseUrl = pluginAssetBaseUrl.value
if (!baseUrl) {
return undefined
@@ -136,7 +137,7 @@ export function useExtensionUIForModule(options: {
})
const iframeMountError = computed(() => {
if (!iframeSrc.value?.startsWith('/_airi/plugins/')) {
if (!iframeSrc.value?.startsWith(mountedPluginAssetPathPrefix)) {
return undefined
}
@@ -1,7 +1,7 @@
import type { MaybeElementRef } from '@vueuse/core'
import type { ComputedRef } from 'vue'
import type { PluginHostModuleSummary } from '../../../../shared/eventa'
import type { PluginHostModuleSummary } from '../../../../shared/eventa/plugin/host'
import { unrefElement } from '@vueuse/core'
import { onBeforeUnmount, shallowRef, watch } from 'vue'
@@ -1,4 +1,4 @@
import type { PluginHostModuleSummary } from '../../../shared/eventa'
import type { PluginHostModuleSummary } from '../../../shared/eventa/plugin/host'
const extensionUiDispatchReservedPropKeys = new Set([
'modelValue',
@@ -1,4 +1,4 @@
import type { PluginHostModuleSummary } from '../../../../shared/eventa'
import type { PluginHostModuleSummary } from '../../../../shared/eventa/plugin/host'
import { defineEventa } from '@moeru/eventa'
@@ -51,19 +51,6 @@ export interface ElectronUpdaterPreferences {
export const electronGetUpdaterPreferences = defineInvokeEventa<ElectronUpdaterPreferences>('eventa:invoke:electron:auto-updater:get-preferences')
export const electronSetUpdaterPreferences = defineInvokeEventa<ElectronUpdaterPreferences, ElectronUpdaterPreferences>('eventa:invoke:electron:auto-updater:set-preferences')
export const electronPluginList = defineInvokeEventa<PluginRegistrySnapshot>('eventa:invoke:electron:plugins:list')
export const electronPluginSetEnabled = defineInvokeEventa<PluginRegistrySnapshot, { name: string, enabled: boolean, path?: string }>('eventa:invoke:electron:plugins:set-enabled')
export const electronPluginSetAutoReload = defineInvokeEventa<PluginRegistrySnapshot, { name: string, enabled: boolean }>('eventa:invoke:electron:plugins:set-auto-reload')
export const electronPluginLoadEnabled = defineInvokeEventa<PluginRegistrySnapshot>('eventa:invoke:electron:plugins:load-enabled')
export const electronPluginLoad = defineInvokeEventa<PluginRegistrySnapshot, { name: string }>('eventa:invoke:electron:plugins:load')
export const electronPluginUnload = defineInvokeEventa<PluginRegistrySnapshot, { name: string }>('eventa:invoke:electron:plugins:unload')
export const electronPluginInspect = defineInvokeEventa<PluginHostDebugSnapshot>('eventa:invoke:electron:plugins:inspect')
export const electronPluginGetAssetBaseUrl = defineInvokeEventa<string>('eventa:invoke:electron:plugins:asset-base-url')
export const electronPluginUpdateCapability = defineInvokeEventa<PluginCapabilityState, PluginCapabilityPayload>('eventa:invoke:electron:plugins:capability:update')
export const pluginProtocolListProvidersEventName = 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers'
export const pluginProtocolListProviders = defineInvokeEventa<Array<{ name: string }>>(pluginProtocolListProvidersEventName)
export const captionIsFollowingWindowChanged = defineEventa<boolean>('eventa:event:electron:windows:caption-overlay:is-following-window-changed')
export const captionGetIsFollowingWindow = defineInvokeEventa<boolean>('eventa:invoke:electron:windows:caption-overlay:get-is-following-window')
@@ -106,15 +93,6 @@ export interface WidgetWindowSize {
maxHeight?: number
}
export interface PluginModuleWidgetPayload {
moduleId: string
title?: string
widgetComponent?: string
componentProps?: Record<string, any>
payload?: Record<string, any>
windowSize?: WidgetWindowSize
}
export interface WidgetsAddPayload {
id?: string
componentName: string
@@ -143,96 +121,6 @@ export interface WidgetsUpdatePayload {
ttlMs?: number
}
export interface PluginManifestSummary {
name: string
entrypoints: Record<string, string | undefined>
path: string
enabled: boolean
autoReload: boolean
loaded: boolean
isNew: boolean
}
export interface PluginRegistrySnapshot {
root: string
plugins: PluginManifestSummary[]
}
// TODO: Replace these manually duplicated IPC types with re-exports from
// @proj-airi/plugin-sdk (CapabilityDescriptor) once stage-ui and the shared
// eventa layer can depend on the SDK without introducing unwanted coupling.
export interface PluginCapabilityPayload {
key: string
state: 'announced' | 'ready' | 'degraded' | 'withdrawn'
metadata?: Record<string, unknown>
}
export interface PluginCapabilityState {
key: string
state: 'announced' | 'ready' | 'degraded' | 'withdrawn'
metadata?: Record<string, unknown>
updatedAt: number
}
export interface PluginHostSessionSummary {
id: string
manifestName: string
phase: string
runtime: 'electron' | 'node' | 'web'
moduleId: string
}
export interface PluginHostKitCapabilitySummary {
key: string
actions: string[]
}
export interface PluginHostKitSummary {
kitId: string
version: string
capabilities: PluginHostKitCapabilitySummary[]
runtimes: Array<'electron' | 'node' | 'web'>
}
export interface PluginHostModuleSummary {
moduleId: string
ownerSessionId: string
ownerPluginId: string
kitId: string
kitModuleType: string
state: 'announced' | 'active' | 'degraded' | 'withdrawn'
runtime: 'electron' | 'node' | 'web'
revision: number
updatedAt: number
config: Record<string, unknown>
}
export interface PluginHostDebugSnapshot {
registry: PluginRegistrySnapshot
sessions: PluginHostSessionSummary[]
kits: PluginHostKitSummary[]
modules: PluginHostModuleSummary[]
capabilities: PluginCapabilityState[]
refreshedAt: number
}
export interface ElectronPluginToolDescriptor {
id: string
title: string
description: string
activation: {
keywords: string[]
patterns: string[]
}
}
export interface ElectronPluginXsaiToolDefinition {
ownerPluginId: string
name: string
description: string
parameters: Record<string, unknown>
}
export interface ElectronMcpStdioServerConfig {
command: string
args?: string[]
@@ -292,13 +180,6 @@ export const electronMcpApplyAndRestart = defineInvokeEventa<ElectronMcpStdioApp
export const electronMcpGetRuntimeStatus = defineInvokeEventa<ElectronMcpStdioRuntimeStatus>('eventa:invoke:electron:mcp:get-runtime-status')
export const electronMcpListTools = defineInvokeEventa<ElectronMcpToolDescriptor[]>('eventa:invoke:electron:mcp:list-tools')
export const electronMcpCallTool = defineInvokeEventa<ElectronMcpCallToolResult, ElectronMcpCallToolPayload>('eventa:invoke:electron:mcp:call-tool')
export const electronPluginListAgentTools = defineInvokeEventa<ElectronPluginToolDescriptor[]>('eventa:invoke:electron:plugins:tools:list')
export const electronPluginListXsaiTools = defineInvokeEventa<ElectronPluginXsaiToolDefinition[]>('eventa:invoke:electron:plugins:tools:list-xsai')
export const electronPluginInvokeTool = defineInvokeEventa<unknown, {
ownerPluginId: string
name: string
input: unknown
}>('eventa:invoke:electron:plugins:tools:invoke')
export const widgetsOpenWindow = defineInvokeEventa<void, { id?: string }>('eventa:invoke:electron:windows:widgets:open')
export const widgetsAdd = defineInvokeEventa<string | undefined, WidgetsAddPayload>('eventa:invoke:electron:windows:widgets:add')
@@ -381,5 +262,9 @@ export const electronAuthLogout = defineInvokeEventa<void>('eventa:invoke:electr
export const i18nSetLocale = defineInvokeEventa<void, Locale>('eventa:invoke:electron:i18n:set-locale')
export const i18nGetLocale = defineInvokeEventa<Locale>('eventa:invoke:electron:i18n:get-locale')
export * from './plugin/assets'
export * from './plugin/capabilities'
export * from './plugin/host'
export * from './plugin/tools'
export { electron } from '@proj-airi/electron-eventa'
export * from '@proj-airi/electron-eventa/electron-updater'
@@ -0,0 +1,3 @@
import { defineInvokeEventa } from '@moeru/eventa'
export const electronPluginGetAssetBaseUrl = defineInvokeEventa<string>('eventa:invoke:electron:plugins:asset-base-url')
@@ -0,0 +1,45 @@
import { defineInvokeEventa } from '@moeru/eventa'
/**
* Plugin capability state change payload reported by renderer or plugin code.
*
* Use when:
* - Updating one plugin capability lifecycle state through the host bridge
*
* Expects:
* - `key` matches a capability known to the plugin host or plugin SDK
*
* Returns:
* - N/A
*/
export interface PluginCapabilityPayload {
key: string
state: 'announced' | 'ready' | 'degraded' | 'withdrawn'
metadata?: Record<string, unknown>
}
/**
* Plugin capability snapshot stored by the host.
*
* Use when:
* - Inspecting plugin capability lifecycle state in renderer tooling
*
* Expects:
* - `updatedAt` is a millisecond timestamp from the host process
*
* Returns:
* - N/A
*/
export interface PluginCapabilityState {
key: string
state: 'announced' | 'ready' | 'degraded' | 'withdrawn'
metadata?: Record<string, unknown>
updatedAt: number
}
export const pluginProtocolListProvidersEventName = 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers'
export const pluginProtocolListProviders = defineInvokeEventa<Array<{ name: string }>>(pluginProtocolListProvidersEventName)
// TODO: Replace these manually duplicated IPC types with re-exports from
// @proj-airi/plugin-sdk (CapabilityDescriptor) once stage-ui and the shared
// eventa layer can depend on the SDK without introducing unwanted coupling.
export const electronPluginUpdateCapability = defineInvokeEventa<PluginCapabilityState, PluginCapabilityPayload>('eventa:invoke:electron:plugins:capability:update')
@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest'
import {
electronPluginGetAssetBaseUrl,
electronPluginInspect,
electronPluginInvokeTool,
electronPluginList,
electronPluginListAgentTools,
electronPluginListXsaiTools,
electronPluginLoad,
electronPluginLoadEnabled,
electronPluginSetAutoReload,
electronPluginSetEnabled,
electronPluginUnload,
electronPluginUpdateCapability,
pluginProtocolListProviders,
pluginProtocolListProvidersEventName,
} from '../index'
import {
electronPluginGetAssetBaseUrl as electronPluginGetAssetBaseUrlFromAssets,
} from './assets'
import {
electronPluginUpdateCapability as electronPluginUpdateCapabilityFromCapabilities,
pluginProtocolListProvidersEventName as pluginProtocolListProvidersEventNameFromCapabilities,
pluginProtocolListProviders as pluginProtocolListProvidersFromCapabilities,
} from './capabilities'
import {
electronPluginInspect as electronPluginInspectFromHost,
electronPluginList as electronPluginListFromHost,
electronPluginLoadEnabled as electronPluginLoadEnabledFromHost,
electronPluginLoad as electronPluginLoadFromHost,
electronPluginSetAutoReload as electronPluginSetAutoReloadFromHost,
electronPluginSetEnabled as electronPluginSetEnabledFromHost,
electronPluginUnload as electronPluginUnloadFromHost,
} from './host'
import {
electronPluginInvokeTool as electronPluginInvokeToolFromTools,
electronPluginListAgentTools as electronPluginListAgentToolsFromTools,
electronPluginListXsaiTools as electronPluginListXsaiToolsFromTools,
} from './tools'
/**
* Characterizes the Eventa domain split while keeping the barrel compatible.
*
* Use when:
* - Refactoring plugin IPC contracts into focused shared modules
* - Verifying existing `shared/eventa` imports still resolve to the same definitions
*
* Expects:
* - Domain modules remain the source of truth for plugin IPC contracts
* - The compatibility barrel re-exports those exact contract objects
*
* Returns:
* - N/A
*
* @example
* describe('plugin Eventa domain modules', () => {
* expect(electronPluginList).toBe(electronPluginListFromHost)
* })
*/
describe('plugin Eventa domain modules', () => {
/**
* Keeps plugin host IPC definitions source-compatible across the split.
*
* Use when:
* - Consumers still import plugin host IPC from `shared/eventa`
*
* Expects:
* - The barrel to re-export the same Eventa definitions from `plugin/host.ts`
*
* Returns:
* - N/A
*
* @example
* it('re-exports plugin host contracts through the compatibility barrel', () => {
* expect(electronPluginInspect).toBe(electronPluginInspectFromHost)
* })
*/
it('re-exports plugin host contracts through the compatibility barrel', () => {
expect(electronPluginList).toBe(electronPluginListFromHost)
expect(electronPluginSetEnabled).toBe(electronPluginSetEnabledFromHost)
expect(electronPluginSetAutoReload).toBe(electronPluginSetAutoReloadFromHost)
expect(electronPluginLoadEnabled).toBe(electronPluginLoadEnabledFromHost)
expect(electronPluginLoad).toBe(electronPluginLoadFromHost)
expect(electronPluginUnload).toBe(electronPluginUnloadFromHost)
expect(electronPluginInspect).toBe(electronPluginInspectFromHost)
})
/**
* Keeps plugin capability, tool, and asset IPC grouped under focused modules.
*
* Use when:
* - Verifying the split ownership for non-host plugin IPC contracts
*
* Expects:
* - The barrel to re-export the same Eventa definitions from the focused modules
*
* Returns:
* - N/A
*
* @example
* it('re-exports plugin capability, tool, and asset contracts through the barrel', () => {
* expect(pluginProtocolListProviders).toBe(pluginProtocolListProvidersFromCapabilities)
* })
*/
it('re-exports plugin capability, tool, and asset contracts through the barrel', () => {
expect(pluginProtocolListProvidersEventName).toBe(pluginProtocolListProvidersEventNameFromCapabilities)
expect(pluginProtocolListProviders).toBe(pluginProtocolListProvidersFromCapabilities)
expect(electronPluginUpdateCapability).toBe(electronPluginUpdateCapabilityFromCapabilities)
expect(electronPluginListAgentTools).toBe(electronPluginListAgentToolsFromTools)
expect(electronPluginListXsaiTools).toBe(electronPluginListXsaiToolsFromTools)
expect(electronPluginInvokeTool).toBe(electronPluginInvokeToolFromTools)
expect(electronPluginGetAssetBaseUrl).toBe(electronPluginGetAssetBaseUrlFromAssets)
})
})
@@ -0,0 +1,195 @@
import type { PluginCapabilityState } from './capabilities'
import { defineInvokeEventa } from '@moeru/eventa'
/**
* Window sizing metadata forwarded through plugin widget payloads.
*
* Use when:
* - A plugin module wants the host to size an extension UI widget window
*
* Expects:
* - Dimensions are pixel values understood by the Electron window layer
*
* Returns:
* - N/A
*/
interface PluginModuleWidgetWindowSize {
width: number
height: number
minWidth?: number
minHeight?: number
maxWidth?: number
maxHeight?: number
}
/**
* Plugin-driven widget payload forwarded into the extension UI host.
*
* Use when:
* - A plugin module mounts its widget UI inside the renderer
*
* Expects:
* - `moduleId` matches a registered plugin module binding
* - Records remain structured-clone-safe for Eventa transport
*
* Returns:
* - N/A
*/
export interface PluginModuleWidgetPayload {
moduleId: string
title?: string
widgetComponent?: string
componentProps?: Record<string, any>
payload?: Record<string, any>
windowSize?: PluginModuleWidgetWindowSize
}
/**
* Renderer-facing plugin manifest summary.
*
* Use when:
* - Listing discovered plugins in devtools or settings surfaces
*
* Expects:
* - `path` points to the manifest file on disk
*
* Returns:
* - N/A
*/
export interface PluginManifestSummary {
name: string
entrypoints: Record<string, string | undefined>
path: string
enabled: boolean
autoReload: boolean
loaded: boolean
isNew: boolean
}
/**
* Snapshot of the current plugin manifest registry.
*
* Use when:
* - Renderer code needs the latest enabled and loaded plugin list
*
* Expects:
* - `plugins` is a stable snapshot derived from the current registry state
*
* Returns:
* - N/A
*/
export interface PluginRegistrySnapshot {
root: string
plugins: PluginManifestSummary[]
}
/**
* Active plugin session summary.
*
* Use when:
* - Inspecting the live plugin host runtime state
*
* Expects:
* - `id` stays stable for the lifetime of one started plugin session
*
* Returns:
* - N/A
*/
export interface PluginHostSessionSummary {
id: string
manifestName: string
phase: string
runtime: 'electron' | 'node' | 'web'
moduleId: string
}
/**
* Capability summary exposed by one registered kit.
*
* Use when:
* - Renderer tooling needs to show what actions a kit supports
*
* Expects:
* - `actions` contains unique action identifiers
*
* Returns:
* - N/A
*/
export interface PluginHostKitCapabilitySummary {
key: string
actions: string[]
}
/**
* Registered kit summary exposed by the plugin host.
*
* Use when:
* - Inspecting kit registration state from renderer tooling
*
* Expects:
* - `capabilities` matches the installed kit descriptor state
*
* Returns:
* - N/A
*/
export interface PluginHostKitSummary {
kitId: string
version: string
capabilities: PluginHostKitCapabilitySummary[]
runtimes: Array<'electron' | 'node' | 'web'>
}
/**
* Registered plugin module binding summary.
*
* Use when:
* - Inspecting plugin modules and deriving renderer-side extension UI state
*
* Expects:
* - `config` is JSON-compatible and structured-clone-safe
*
* Returns:
* - N/A
*/
export interface PluginHostModuleSummary {
moduleId: string
ownerSessionId: string
ownerPluginId: string
kitId: string
kitModuleType: string
state: 'announced' | 'active' | 'degraded' | 'withdrawn'
runtime: 'electron' | 'node' | 'web'
revision: number
updatedAt: number
config: Record<string, unknown>
}
/**
* Full plugin host inspection snapshot.
*
* Use when:
* - Renderer devtools need registry, session, kit, and module state together
*
* Expects:
* - All arrays are snapshots captured at `refreshedAt`
*
* Returns:
* - N/A
*/
export interface PluginHostDebugSnapshot {
registry: PluginRegistrySnapshot
sessions: PluginHostSessionSummary[]
kits: PluginHostKitSummary[]
modules: PluginHostModuleSummary[]
capabilities: PluginCapabilityState[]
refreshedAt: number
}
export const electronPluginList = defineInvokeEventa<PluginRegistrySnapshot>('eventa:invoke:electron:plugins:list')
export const electronPluginSetEnabled = defineInvokeEventa<PluginRegistrySnapshot, { name: string, enabled: boolean, path?: string }>('eventa:invoke:electron:plugins:set-enabled')
export const electronPluginSetAutoReload = defineInvokeEventa<PluginRegistrySnapshot, { name: string, enabled: boolean }>('eventa:invoke:electron:plugins:set-auto-reload')
export const electronPluginLoadEnabled = defineInvokeEventa<PluginRegistrySnapshot>('eventa:invoke:electron:plugins:load-enabled')
export const electronPluginLoad = defineInvokeEventa<PluginRegistrySnapshot, { name: string }>('eventa:invoke:electron:plugins:load')
export const electronPluginUnload = defineInvokeEventa<PluginRegistrySnapshot, { name: string }>('eventa:invoke:electron:plugins:unload')
export const electronPluginInspect = defineInvokeEventa<PluginHostDebugSnapshot>('eventa:invoke:electron:plugins:inspect')
@@ -0,0 +1,50 @@
import { defineInvokeEventa } from '@moeru/eventa'
/**
* Renderer-facing plugin tool descriptor used by agent tooling UIs.
*
* Use when:
* - Listing plugin-backed tools for discovery or debugging
*
* Expects:
* - Activation metadata is already normalized for renderer display
*
* Returns:
* - N/A
*/
export interface ElectronPluginToolDescriptor {
id: string
title: string
description: string
activation: {
keywords: string[]
patterns: string[]
}
}
/**
* Serialized xsai tool definition exposed by the plugin host.
*
* Use when:
* - Registering plugin-backed xsai tools in the renderer
*
* Expects:
* - `parameters` is a provider-compliant JSON Schema object
*
* Returns:
* - N/A
*/
export interface ElectronPluginXsaiToolDefinition {
ownerPluginId: string
name: string
description: string
parameters: Record<string, unknown>
}
export const electronPluginListAgentTools = defineInvokeEventa<ElectronPluginToolDescriptor[]>('eventa:invoke:electron:plugins:tools:list')
export const electronPluginListXsaiTools = defineInvokeEventa<ElectronPluginXsaiToolDefinition[]>('eventa:invoke:electron:plugins:tools:list-xsai')
export const electronPluginInvokeTool = defineInvokeEventa<unknown, {
ownerPluginId: string
name: string
input: unknown
}>('eventa:invoke:electron:plugins:tools:invoke')
@@ -1,3 +1,7 @@
import type { ContextInit } from '@proj-airi/plugin-sdk'
import type { TamagotchiToolContext } from './index'
import { object, optional, string } from 'valibot'
import { describe, expect, it, vi } from 'vitest'
@@ -12,9 +16,19 @@ describe('plugin-sdk-tamagotchi', () => {
it('should allow a plugin to define a gamelet and toolset without raw kit or module calls', async () => {
const registerBinding = vi.fn()
const registerTool = vi.fn()
const openGamelet = vi.fn()
const configureGamelet = vi.fn()
const closeGamelet = vi.fn()
const isGameletOpen = vi.fn(() => true)
const ctx = {
const ctx: Pick<ContextInit, 'apis'> & TamagotchiToolContext = {
apis: {
gamelets: {
open: openGamelet,
configure: configureGamelet,
close: closeGamelet,
isOpen: isGameletOpen,
},
tools: {
register: registerTool,
},
@@ -39,11 +53,15 @@ describe('plugin-sdk-tamagotchi', () => {
announce: registerBinding,
update: registerBinding,
activate: registerBinding,
withdraw: registerBinding,
},
providers: {
listProviders: async () => [],
},
},
}
const gamelet = await defineGamelet(ctx as never, {
const gamelet = await defineGamelet(ctx, {
id: 'chess',
title: 'Chess',
entrypoint: './ui/index.html',
@@ -55,7 +73,7 @@ describe('plugin-sdk-tamagotchi', () => {
],
})
await defineToolset(ctx as never, {
await defineToolset(ctx, {
tools: [
{
id: 'play_chess',
@@ -115,5 +133,100 @@ describe('plugin-sdk-tamagotchi', () => {
}),
}),
}))
await registerTool.mock.calls[0]?.[0].execute({})
expect(openGamelet).not.toHaveBeenCalled()
expect(configureGamelet).not.toHaveBeenCalled()
expect(closeGamelet).not.toHaveBeenCalled()
expect(isGameletOpen).not.toHaveBeenCalled()
})
/**
* @example
* expect(openGamelet).toHaveBeenCalledWith('chess', { opening: 'sicilian' })
* expect(configureGamelet).toHaveBeenCalledWith('chess', { side: 'black' })
*/
it('passes host-backed gamelet operations through defineToolset execution context', async () => {
const registerTool = vi.fn()
const openGamelet = vi.fn()
const configureGamelet = vi.fn()
const closeGamelet = vi.fn()
const isGameletOpen = vi.fn(() => true)
const ctx: TamagotchiToolContext = {
apis: {
gamelets: {
open: openGamelet,
configure: configureGamelet,
close: closeGamelet,
isOpen: isGameletOpen,
},
tools: {
register: registerTool,
},
},
}
await defineToolset(ctx, {
tools: [
{
id: 'drive_chess',
title: 'Drive Chess',
description: 'Drive a host-backed chess gamelet.',
inputSchema: object({}),
async isAvailable(context) {
return await context.gamelets.isOpen('chess')
},
async execute(_input, context) {
await context.gamelets.open('chess', { opening: 'sicilian' })
await context.gamelets.configure('chess', { side: 'black' })
await context.gamelets.close('chess')
return { ok: true }
},
},
],
})
const registration = registerTool.mock.calls[0]?.[0]
expect(registration).toBeDefined()
await expect(registration?.availability?.()).resolves.toBe(true)
await expect(registration?.execute({})).resolves.toEqual({ ok: true })
expect(isGameletOpen).toHaveBeenCalledWith('chess')
expect(openGamelet).toHaveBeenCalledWith('chess', { opening: 'sicilian' })
expect(configureGamelet).toHaveBeenCalledWith('chess', { side: 'black' })
expect(closeGamelet).toHaveBeenCalledWith('chess')
})
/**
* @example
* await expect(defineToolset({ apis: { tools: { register: registerTool } } } as never, options)).rejects.toThrow(/gamelet API/i)
*/
it('fails with a clear error when the tamagotchi gamelet API is not available', async () => {
const registerTool = vi.fn()
await expect(defineToolset({
apis: {
tools: {
register: registerTool,
},
},
} as never, {
tools: [
{
id: 'drive_chess',
title: 'Drive Chess',
description: 'Drive a host-backed chess gamelet.',
inputSchema: object({}),
async execute() {
return { ok: true }
},
},
],
})).rejects.toThrow(/gamelet API/i)
expect(registerTool).not.toHaveBeenCalled()
})
})
@@ -6,6 +6,46 @@ import { hostDataRecordSchema } from '@proj-airi/plugin-sdk/plugin-host'
import { parse } from 'valibot'
import { toJsonSchema } from 'xsschema'
/**
* Describes the stage-tamagotchi gamelet API expected on `ctx.apis`.
*
* Use when:
* - Tool execution wants to open, configure, close, or inspect host-managed gamelet surfaces
* - Runtime validation needs a structural contract independent from `@proj-airi/plugin-sdk`
*
* Expects:
* - The stage-tamagotchi host contribution installs `gamelets` on the plugin session API object
*
* Returns:
* - The host-backed gamelet control surface exposed to tool callbacks
*/
export interface ToolExecutionGameletApi {
open: (id: string, params?: HostDataRecord) => Promise<void>
configure: (id: string, patch: HostDataRecord) => Promise<void>
close: (id: string) => Promise<void>
isOpen: (id: string) => Promise<boolean> | boolean
}
/**
* Describes the tamagotchi-flavored plugin context accepted by {@link defineToolset}.
*
* Use when:
* - A plugin host exposes tool registration plus the stage-owned `gamelets` surface
* - Tests want to model the runtime shape without relying on baked-in SDK typing
*
* Expects:
* - `apis.tools.register` is available
* - `apis.gamelets` is installed by the stage-tamagotchi host contribution
*
* Returns:
* - A context shape compatible with the tamagotchi tool helper
*/
export interface TamagotchiToolContext {
apis: Pick<ContextInit['apis'], 'tools'> & {
gamelets: ToolExecutionGameletApi
}
}
/**
* Describes the host services available while checking or executing a plugin tool.
*
@@ -19,12 +59,7 @@ import { toJsonSchema } from 'xsschema'
* - A runtime capability surface for tool execution
*/
export interface ToolExecutionContext {
gamelets: {
open: (id: string, params?: Record<string, unknown>) => Promise<void>
configure: (id: string, patch: Record<string, unknown>) => Promise<void>
close: (id: string) => Promise<void>
isOpen: (id: string) => boolean
}
gamelets: ToolExecutionGameletApi
// TODO:
// Add character/runtime orchestration APIs after the gamelet/tool path is stable.
@@ -85,14 +120,36 @@ export interface DefineToolsetOptions<TInputSchema = unknown> {
tools: Array<PluginToolDefinition<TInputSchema>>
}
function createToolExecutionContext(): ToolExecutionContext {
function isToolExecutionGameletApi(value: unknown): value is ToolExecutionGameletApi {
if (!value || typeof value !== 'object') {
return false
}
const candidate = value as Partial<Record<keyof ToolExecutionGameletApi, unknown>>
return typeof candidate.open === 'function'
&& typeof candidate.configure === 'function'
&& typeof candidate.close === 'function'
&& typeof candidate.isOpen === 'function'
}
function getToolExecutionGameletApi(
ctx: Pick<ContextInit, 'apis'> | TamagotchiToolContext,
): ToolExecutionGameletApi {
const gamelets = (ctx.apis as Record<string, unknown>).gamelets
if (!isToolExecutionGameletApi(gamelets)) {
throw new Error('stage-tamagotchi gamelet API is not available on `ctx.apis.gamelets`.')
}
return gamelets
}
function createToolExecutionContext(
ctx: Pick<ContextInit, 'apis'> | TamagotchiToolContext,
): ToolExecutionContext {
return {
gamelets: {
async open() {},
async configure() {},
async close() {},
isOpen: () => false,
},
gamelets: getToolExecutionGameletApi(ctx),
}
}
@@ -185,10 +242,10 @@ async function serializeToolParameters(inputSchema: unknown): Promise<HostDataRe
* - Resolves after all tool registrations complete
*/
export async function defineToolset(
ctx: Pick<ContextInit, 'apis'>,
ctx: Pick<ContextInit, 'apis'> | TamagotchiToolContext,
options: DefineToolsetOptions,
): Promise<void> {
const executionContext = createToolExecutionContext()
const executionContext = createToolExecutionContext(ctx)
for (const definition of options.tools) {
await ctx.apis.tools.register({
@@ -188,6 +188,7 @@ describe('for PluginHost', () => {
const kitRegistryResourceKey = 'proj-airi:plugin-sdk:resources:kits'
const toolRegistryResourceKey = 'proj-airi:plugin-sdk:resources:tools'
const widgetKitBindingsResourceKey = 'proj-airi:plugin-sdk:resources:kits:kit.widget:bindings'
const customSessionApiPingEventName = 'proj-airi:plugin-sdk:apis:client:test-session-api:ping'
const testManifest = {
apiVersion: 'v1' as const,
kind: 'manifest.plugin.airi.moeru.ai' as const,
@@ -232,6 +233,16 @@ describe('for PluginHost', () => {
],
} satisfies ModulePermissionDeclaration,
}
const customSessionApiManifest = {
...testManifest,
permissions: {
...testManifest.permissions,
apis: [
...(testManifest.permissions.apis ?? []),
{ key: customSessionApiPingEventName, actions: ['invoke'] },
],
} satisfies ModulePermissionDeclaration,
}
const deniedKitReadManifest = {
...testManifest,
permissions: {
@@ -304,8 +315,8 @@ describe('for PluginHost', () => {
await expect(host.init(session.id)).rejects.toThrow('Plugin initialization aborted by plugin: test-plugin-no-connect')
const latest = host.getSession(session.id)
expect(latest?.phase).toBe('failed')
expect(session.phase).toBe('stopped')
expect(host.getSession(session.id)).toBeUndefined()
})
it('should expose runtime-compatible kits through bound plugin apis', async () => {
@@ -366,6 +377,54 @@ describe('for PluginHost', () => {
})).resolves.toBeUndefined()
})
it('should let contributions install custom session api namespaces', async () => {
const installContribution = vi.fn()
const callCustomNamespace = vi.fn(({ ownerPluginId, message }: { ownerPluginId: string, message: string }) => {
return `${ownerPluginId}:${message}`
})
const host = new PluginHost({
runtime: 'electron',
transport: { kind: 'in-memory' },
contributions: [{
install(context) {
installContribution()
context.registerSessionApi('testSessionApi', ({ session, assertPermission }) => ({
async ping(message: string) {
assertPermission({
area: 'apis',
action: 'invoke',
key: customSessionApiPingEventName,
})
return callCustomNamespace({
ownerPluginId: session.ownerPluginId,
message,
})
},
}))
},
}],
})
reportPluginCapability(host, {
key: providersCapability,
state: 'ready',
metadata: { source: 'test' },
})
const session = await host.start(customSessionApiManifest, { cwd: '' })
const testSessionApi = (session.apis as Record<string, unknown>).testSessionApi as {
ping: (message: string) => Promise<string>
}
expect(installContribution).toHaveBeenCalledTimes(1)
expect(testSessionApi).toBeDefined()
await expect(testSessionApi.ping('hello')).resolves.toBe(`${session.identity.plugin.id}:hello`)
expect(callCustomNamespace).toHaveBeenCalledWith({
ownerPluginId: session.identity.plugin.id,
message: 'hello',
})
})
it('should register available plugin tools and expose serialized xsai schemas', async () => {
const host = new PluginHost({
runtime: 'electron',
@@ -454,6 +513,167 @@ describe('for PluginHost', () => {
)
})
it('should hide and reject tools registered by stopped sessions', async () => {
const host = new PluginHost({
runtime: 'electron',
transport: { kind: 'in-memory' },
})
reportPluginCapability(host, {
key: providersCapability,
state: 'ready',
metadata: { source: 'test' },
})
const session = await host.start(dynamicApiManifest, { cwd: '' })
await session.apis.tools.register({
tool: {
id: 'play_chess',
title: 'Play Chess',
description: 'Open chess.',
activation: {
keywords: ['chess'],
patterns: ['play.*chess'],
},
parameters: {
type: 'object',
properties: {},
},
},
execute: async () => ({ ok: true }),
})
await expect(host.listAvailableToolDescriptors()).resolves.toEqual([
expect.objectContaining({ id: 'play_chess' }),
])
host.stop(session.id)
await expect(host.listAvailableToolDescriptors()).resolves.toEqual([])
await expect(host.listSerializedXsaiTools()).resolves.toEqual([])
await expect(host.invokeTool(session.identity.plugin.id, 'play_chess', {})).rejects.toThrow(
`Plugin tool not found: ${session.identity.plugin.id}:play_chess`,
)
})
it('should clean up sessions modules and tools when a session-ready hook throws during init', async () => {
const readyHookError = new Error('session-ready hook failed')
const host = new PluginHost({
runtime: 'electron',
transport: { kind: 'in-memory' },
contributions: [{
install(context) {
context.registerLifecycleHook('session-ready', () => {
throw readyHookError
})
},
}],
})
registerWidgetKit(host)
reportPluginCapability(host, {
key: providersCapability,
state: 'ready',
metadata: { source: 'test' },
})
const session = await host.load(dynamicApiManifest, { cwd: '' })
session.plugin = {
...session.plugin,
setupModules: async ({ apis }) => {
await apis.tools.register({
tool: {
id: 'ready_hook_tool',
title: 'Ready Hook Tool',
description: 'Registered before the ready hook throws.',
activation: {
keywords: ['ready'],
patterns: ['ready'],
},
parameters: {
type: 'object',
properties: {},
},
},
execute: async () => ({ ok: true }),
})
await apis.bindings.announce({
moduleId: 'module-ready-hook-failure',
kitId: 'kit.widget',
kitModuleType: 'window',
config: { route: '/widgets/ready-hook-failure' },
})
},
}
await expect(host.init(session.id)).rejects.toThrow('session-ready hook failed')
expect(session.phase).toBe('stopped')
expect(host.getSession(session.id)).toBeUndefined()
expect(host.getBinding('module-ready-hook-failure')).toBeUndefined()
await expect(host.listAvailableToolDescriptors()).resolves.toEqual([])
await expect(host.listSerializedXsaiTools()).resolves.toEqual([])
await expect(host.invokeTool(session.identity.plugin.id, 'ready_hook_tool', {})).rejects.toThrow(
`Plugin tool not found: ${session.identity.plugin.id}:ready_hook_tool`,
)
})
it('should finish stop cleanup before rethrowing a session-stopped hook failure', async () => {
const stoppedHookError = new Error('session-stopped hook failed')
const host = new PluginHost({
runtime: 'electron',
transport: { kind: 'in-memory' },
contributions: [{
install(context) {
context.registerLifecycleHook('session-stopped', () => {
throw stoppedHookError
})
},
}],
})
registerWidgetKit(host)
reportPluginCapability(host, {
key: providersCapability,
state: 'ready',
metadata: { source: 'test' },
})
const session = await host.start(dynamicApiManifest, { cwd: '' })
await session.apis.tools.register({
tool: {
id: 'stopped_hook_tool',
title: 'Stopped Hook Tool',
description: 'Should be cleaned up before stop rethrows.',
activation: {
keywords: ['stop'],
patterns: ['stop'],
},
parameters: {
type: 'object',
properties: {},
},
},
execute: async () => ({ ok: true }),
})
await session.apis.bindings.announce({
moduleId: 'module-stopped-hook-failure',
kitId: 'kit.widget',
kitModuleType: 'window',
config: { route: '/widgets/stopped-hook-failure' },
})
expect(() => host.stop(session.id)).toThrow('session-stopped hook failed')
expect(session.phase).toBe('stopped')
expect(host.getSession(session.id)).toBeUndefined()
expect(host.getBinding('module-stopped-hook-failure')).toBeUndefined()
await expect(host.listAvailableToolDescriptors()).resolves.toEqual([])
await expect(host.listSerializedXsaiTools()).resolves.toEqual([])
await expect(host.invokeTool(session.identity.plugin.id, 'stopped_hook_tool', {})).rejects.toThrow(
`Plugin tool not found: ${session.identity.plugin.id}:stopped_hook_tool`,
)
})
it('should allow plugin to announce update activate and withdraw dynamic bindings through bound apis', async () => {
const host = new PluginHost({
runtime: 'electron',
@@ -999,7 +1219,8 @@ describe('for PluginHost', () => {
},
})).rejects.toThrow('Negotiation rejected:')
expect(host.getSession(session.id)?.phase).toBe('failed')
expect(session.phase).toBe('stopped')
expect(host.getSession(session.id)).toBeUndefined()
})
it('should isolate module status events between plugin sessions', async () => {
+218 -86
View File
@@ -14,9 +14,16 @@ import type {
ModuleIdentity,
ModulePermissionDeclaration,
ModulePermissionGrant,
PluginHostContribution,
PluginHostInstallContext,
PluginHostLifecycleEvent,
PluginHostLifecycleHook,
PluginHostOptions,
PluginHostPermissionRequest,
PluginHostSessionContext,
PluginLoadOptions,
PluginRuntime,
PluginSessionApiFactory,
PluginSessionPhase,
PluginStartOptions,
} from './shared/types'
@@ -562,7 +569,7 @@ export interface PluginHostSession {
host: ReturnType<typeof createPluginContext>
}
/** Bound plugin SDK APIs exposed to plugin code. */
apis: ReturnType<typeof createApis>
apis: PluginHostSessionApis
/** Requested and granted permissions for the session. */
permissions: {
/** Permissions requested by the manifest and runtime declarations. */
@@ -596,6 +603,10 @@ export interface PluginHostBindingListOptions {
type BoundAnnounceBindingInput<C extends HostDataRecord = HostDataRecord> = AnnounceBindingInput<C>
type BoundUpdateBindingInput<C extends HostDataRecord = HostDataRecord> = UpdateBindingInput<C>
const builtInSessionApiNamespaces = new Set(['providers', 'kits', 'bindings', 'tools'])
type PluginHostSessionApis = ReturnType<typeof createApis> & Record<string, unknown>
function omitModuleId<C extends HostDataRecord>(input: BoundUpdateBindingInput<C>) {
return {
state: input.state,
@@ -687,6 +698,14 @@ export class PluginHost {
private readonly permissionResolver?: PluginHostOptions['permissionResolver']
private readonly persistedPermissionGrants = new Map<string, ModulePermissionGrant>()
private readonly resources = new ResourceService()
private readonly sessionApiFactories = new Map<string, PluginSessionApiFactory>()
private readonly lifecycleHooks: Record<PluginHostLifecycleEvent, PluginHostLifecycleHook[]> = {
'session-loaded': [],
'session-ready': [],
'session-stopped': [],
}
private readonly installContext: PluginHostInstallContext
constructor(options: PluginHostOptions = {}) {
this.loader = new FileSystemLoader()
@@ -699,6 +718,11 @@ export class PluginHost {
this.permissionResolver = options.permissionResolver
this.resources.setValue(protocolListProvidersEventName, [] as Array<{ name: string }>)
this.markCapabilityReady(protocolListProvidersEventName, { source: 'plugin-host' })
this.installContext = this.createInstallContext()
for (const contribution of options.contributions ?? []) {
this.installContribution(contribution)
}
}
private getPermissionScopeKey(session: PluginHostSession) {
@@ -707,12 +731,7 @@ export class PluginHost {
private assertPermission(
session: PluginHostSession,
input: {
area: 'apis' | 'resources' | 'capabilities' | 'processors' | 'pipelines'
action: string
key: string
reason?: string
},
input: PluginHostPermissionRequest,
) {
const allowed = this.permissions.isAllowed(this.getPermissionScopeKey(session), input.area, input.action, input.key)
if (allowed) {
@@ -748,6 +767,173 @@ export class PluginHost {
return session
}
private createSessionContext(session: PluginHostSession): PluginHostSessionContext {
return {
sessionId: session.id,
ownerPluginId: session.identity.plugin.id,
runtime: session.runtime,
}
}
private createInstallContext(): PluginHostInstallContext {
return {
registerSessionApi: (namespace, factory) => {
if (builtInSessionApiNamespaces.has(namespace)) {
throw new Error(`Session API namespace \`${namespace}\` is reserved by PluginHost.`)
}
const currentFactory = this.sessionApiFactories.get(namespace)
if (currentFactory && currentFactory !== factory) {
throw new Error(`Duplicate session API namespace registration for \`${namespace}\`.`)
}
this.sessionApiFactories.set(namespace, factory)
},
registerLifecycleHook: (event, hook) => {
this.lifecycleHooks[event].push(hook)
},
registerKit: kit => this.registerKit(kit),
unregisterKit: kitId => this.unregisterKit(kitId),
setResourceResolver: (key, resolver) => this.setResourceResolver(key, resolver),
setResourceValue: (key, value) => this.setResourceValue(key, value),
announceCapability: (key, metadata) => {
this.announceCapability(key, metadata)
},
markCapabilityReady: (key, metadata) => {
this.markCapabilityReady(key, metadata)
},
markCapabilityDegraded: (key, metadata) => {
this.markCapabilityDegraded(key, metadata)
},
withdrawCapability: (key, metadata) => {
this.withdrawCapability(key, metadata)
},
}
}
private installContribution(contribution: PluginHostContribution) {
contribution.install(this.installContext)
}
private createSessionApis(
session: PluginHostSession,
hostChannel: ReturnType<typeof createPluginContext>,
): PluginHostSessionApis {
const baseApis = createBoundApis(hostChannel, {
kits: {
list: () => {
this.assertPermission(session, {
area: 'apis',
action: 'invoke',
key: pluginKitApiListEventName,
})
this.assertPermission(session, {
area: 'resources',
action: 'read',
key: pluginKitRegistryResourceKey,
})
return this.listKits(session.runtime)
},
getCapabilities: (kitId) => {
this.assertPermission(session, {
area: 'apis',
action: 'invoke',
key: pluginKitApiGetCapabilitiesEventName,
})
this.assertPermission(session, {
area: 'resources',
action: 'read',
key: pluginKitRegistryResourceKey,
})
this.assertKitAvailableForSession(session, kitId)
return this.getKitCapabilities(kitId)
},
},
bindings: {
list: () => {
this.assertPermission(session, {
area: 'apis',
action: 'invoke',
key: pluginBindingApiListEventName,
})
this.assertPermission(session, {
area: 'resources',
action: 'read',
key: pluginBindingRegistryResourceKey,
})
return this.listBindings({ ownerSessionId: session.id })
},
announce: input => this.announceBinding(session.id, input),
activate: input => this.activateBinding(session.id, input.moduleId),
update: input => this.updateBinding(session.id, input.moduleId, input),
withdraw: input => this.withdrawBinding(session.id, input.moduleId),
},
tools: {
register: input => this.registerTool(session.id, input),
},
})
const contributionApis = Object.fromEntries(
[...this.sessionApiFactories.entries()].map(([namespace, factory]) => [
namespace,
factory({
host: this.installContext,
session: this.createSessionContext(session),
assertPermission: input => this.assertPermission(session, input),
}),
]),
)
return {
...baseApis,
...contributionApis,
}
}
private runLifecycleHooks(event: PluginHostLifecycleEvent, session: PluginHostSession) {
for (const hook of this.lifecycleHooks[event]) {
hook({
host: this.installContext,
session: this.createSessionContext(session),
manifest: session.manifest,
})
}
}
private cleanupSession(session: PluginHostSession) {
let lifecycleHookError: unknown
if (session.phase !== 'stopped') {
const canStop = session.lifecycle.getSnapshot().can({ type: 'STOP' })
if (canStop) {
assertTransition(session, 'stopped')
}
else {
session.phase = 'stopped'
}
}
for (const module of this.modules.listByOwner(session.id)) {
this.modules.withdraw(session.id, session.identity.plugin.id, module.moduleId)
this.modules.unbind(session.id, session.identity.plugin.id, module.moduleId)
}
try {
this.runLifecycleHooks('session-stopped', session)
}
catch (error) {
lifecycleHookError = error
}
session.lifecycle.stop()
this.sessionService.remove(session.id)
return lifecycleHookError
}
private getModuleOrThrow(moduleId: string) {
const module = this.modules.get(moduleId)
if (!module) {
@@ -812,7 +998,7 @@ export class PluginHost {
return cloneKitCapabilities(capabilities)
}
getBinding(moduleId: string) {
getBinding(moduleId: string): BindingRecord<HostDataRecord> | undefined {
const module = this.modules.get(moduleId)
if (!module) {
return undefined
@@ -974,8 +1160,20 @@ export class PluginHost {
},
parameters: cloneHostDataRecord(input.tool.parameters),
},
availability: input.availability,
execute: input.execute,
availability: async () => {
if (!this.getSession(session.id)) {
return false
}
return await input.availability?.() ?? true
},
execute: async (toolInput) => {
if (!this.getSession(session.id)) {
throw new Error(`Plugin tool not found: ${session.identity.plugin.id}:${input.tool.id}`)
}
return await input.execute(toolInput)
},
})
}
@@ -1012,65 +1210,7 @@ export class PluginHost {
},
)
let session!: PluginHostSession
const apis = createBoundApis(hostChannel, {
kits: {
list: () => {
this.assertPermission(session, {
area: 'apis',
action: 'invoke',
key: pluginKitApiListEventName,
})
this.assertPermission(session, {
area: 'resources',
action: 'read',
key: pluginKitRegistryResourceKey,
})
return this.listKits(session.runtime)
},
getCapabilities: (kitId) => {
this.assertPermission(session, {
area: 'apis',
action: 'invoke',
key: pluginKitApiGetCapabilitiesEventName,
})
this.assertPermission(session, {
area: 'resources',
action: 'read',
key: pluginKitRegistryResourceKey,
})
this.assertKitAvailableForSession(session, kitId)
return this.getKitCapabilities(kitId)
},
},
bindings: {
list: () => {
this.assertPermission(session, {
area: 'apis',
action: 'invoke',
key: pluginBindingApiListEventName,
})
this.assertPermission(session, {
area: 'resources',
action: 'read',
key: pluginBindingRegistryResourceKey,
})
return this.listBindings({ ownerSessionId: session.id })
},
announce: input => this.announceBinding(session.id, input),
activate: input => this.activateBinding(session.id, input.moduleId),
update: input => this.updateBinding(session.id, input.moduleId, input),
withdraw: input => this.withdrawBinding(session.id, input.moduleId),
},
tools: {
register: input => this.registerTool(session.id, input),
},
})
session = {
const session: PluginHostSession = {
manifest,
plugin: {},
id,
@@ -1084,13 +1224,14 @@ export class PluginHost {
channels: {
host: hostChannel,
},
apis,
apis: {} as PluginHostSessionApis,
permissions: {
requested: permissionSnapshot.requested,
granted: permissionSnapshot.granted,
revision: permissionSnapshot.revision,
},
}
session.apis = this.createSessionApis(session, hostChannel)
defineInvokeHandler(hostChannel, protocolCapabilityWait, async (payload) => {
this.assertPermission(session, {
@@ -1146,6 +1287,7 @@ export class PluginHost {
// Assert lifecycle progression (`loading` -> `loaded`) to keep transition rules explicit.
// This prevents accidental phase drift if the method evolves later.
assertTransition(session, 'loaded')
this.runLifecycleHooks('session-loaded', session)
return session
}
catch (error) {
@@ -1392,6 +1534,7 @@ export class PluginHost {
identity: session.identity,
phase: 'ready',
})
this.runLifecycleHooks('session-ready', session)
return session
}
@@ -1405,6 +1548,8 @@ export class PluginHost {
reason: error instanceof Error ? error.message : 'Plugin host initialization failed.',
})
this.cleanupSession(session)
throw error
}
}
@@ -1591,24 +1736,11 @@ export class PluginHost {
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'
}
const lifecycleHookError = this.cleanupSession(session)
if (lifecycleHookError) {
throw lifecycleHookError
}
for (const module of this.modules.listByOwner(session.id)) {
this.modules.withdraw(session.id, session.identity.plugin.id, module.moduleId)
this.modules.unbind(session.id, session.identity.plugin.id, module.moduleId)
}
session.lifecycle.stop()
this.sessionService.remove(session.id)
return session
}
@@ -9,6 +9,7 @@ import type {
} from '@proj-airi/plugin-protocol/types'
import type { PluginTransport } from '../transports'
import type { KitDescriptor } from './kits'
import { isPlainObject } from 'es-toolkit'
import {
@@ -476,6 +477,169 @@ export interface PluginHostOptions {
requested: ModulePermissionDeclaration
persisted?: ModulePermissionGrant
}) => ModulePermissionGrant | Promise<ModulePermissionGrant>
/** Installable host features that can extend session APIs and register host behavior. @default [] */
contributions?: PluginHostContribution[]
}
/**
* Describes the stable session metadata exposed to host-installed contributions.
*
* Use when:
* - Building contribution-owned session APIs
* - Hooking plugin-session lifecycle work outside the core `PluginHost`
*
* Expects:
* - Values come from the currently executing plugin session
*
* Returns:
* - A minimal session context safe to pass outside `PluginHost`
*/
export interface PluginHostSessionContext {
sessionId: string
ownerPluginId: string
runtime: PluginRuntime
}
/**
* Describes one permission gate that a contribution-owned session API can enforce.
*
* Use when:
* - A contribution method must check host-granted API, resource, or capability access
*
* Expects:
* - The permission key/action pair matches the manifest permission contract
*
* Returns:
* - The permission request consumed by `PluginHost.assertPermission(...)`
*/
export interface PluginHostPermissionRequest {
area: 'apis' | 'resources' | 'capabilities' | 'processors' | 'pipelines'
action: string
key: string
reason?: string
}
/**
* Provides the host-owned registration surface that contributions can use during installation.
*
* Use when:
* - Installing a host feature into `PluginHost`
* - Registering session API namespaces, kits, resources, capabilities, or lifecycle hooks
*
* Expects:
* - Installation happens during `PluginHost` construction
* - Session API namespace names are unique across all contributions and built-in namespaces
*
* Returns:
* - Registration helpers that keep `PluginHost` generic while allowing extensions
*/
export interface PluginHostInstallContext {
registerSessionApi: (namespace: string, factory: PluginSessionApiFactory) => void
registerLifecycleHook: (event: PluginHostLifecycleEvent, hook: PluginHostLifecycleHook) => void
registerKit: (kit: KitDescriptor) => KitDescriptor
unregisterKit: (kitId: string) => KitDescriptor | undefined
setResourceResolver: <T>(key: string, resolver: () => Promise<T> | T) => void
setResourceValue: <T>(key: string, value: T) => void
announceCapability: (key: string, metadata?: Record<string, unknown>) => void
markCapabilityReady: (key: string, metadata?: Record<string, unknown>) => void
markCapabilityDegraded: (key: string, metadata?: Record<string, unknown>) => void
withdrawCapability: (key: string, metadata?: Record<string, unknown>) => void
}
/**
* Describes the context passed into one contribution-owned session API factory.
*
* Use when:
* - Creating a custom namespace that will be attached to `session.apis`
*
* Expects:
* - `session` refers to the plugin session currently being assembled
* - `assertPermission` is called inside contribution methods before privileged work
*
* Returns:
* - The context needed to build one session API namespace
*/
export interface PluginSessionApiFactoryContext {
host: PluginHostInstallContext
session: PluginHostSessionContext
assertPermission: (input: PluginHostPermissionRequest) => void
}
/**
* Builds one custom session API namespace installed by a host contribution.
*
* Use when:
* - Extending `session.apis` with a plugin-host-specific namespace
*
* Expects:
* - The returned value is an object-like namespace safe to expose to plugin code
*
* Returns:
* - The namespace object attached to `session.apis[namespace]`
*/
export type PluginSessionApiFactory<TNamespace = unknown> = (context: PluginSessionApiFactoryContext) => TNamespace
/**
* Enumerates the host lifecycle moments contributions may observe.
*
* Use when:
* - Registering contribution hooks tied to session load, readiness, or stop events
*
* Expects:
* - Hooks are synchronous and should stay lightweight
*
* Returns:
* - The supported lifecycle event names for `registerLifecycleHook(...)`
*/
export type PluginHostLifecycleEvent = 'session-loaded' | 'session-ready' | 'session-stopped'
/**
* Describes the context passed into one contribution lifecycle hook.
*
* Use when:
* - Reacting to a plugin session lifecycle event outside the generic host core
*
* Expects:
* - `session` and `manifest` refer to the active session at the time of the hook
*
* Returns:
* - The snapshot available to contribution lifecycle hooks
*/
export interface PluginHostLifecycleHookContext {
host: PluginHostInstallContext
session: PluginHostSessionContext
manifest: ManifestV1
}
/**
* Handles one contribution-owned lifecycle event emitted by `PluginHost`.
*
* Use when:
* - A contribution needs to observe session loading, readiness, or teardown
*
* Expects:
* - Hooks are synchronous and should throw only for deterministic setup failures
*
* Returns:
* - No value; side effects are owned by the contribution
*/
export type PluginHostLifecycleHook = (context: PluginHostLifecycleHookContext) => void
/**
* Installs one generic host feature into `PluginHost`.
*
* Use when:
* - The host should register extra session APIs or bootstrap runtime-specific behavior
*
* Expects:
* - Installation is idempotent for one host instance
* - Contributions keep domain-specific behavior out of the low-level host core
*
* Returns:
* - No value; the contribution mutates the provided install context
*/
export interface PluginHostContribution {
install: (context: PluginHostInstallContext) => void
}
/**
@@ -38,7 +38,7 @@ export interface PluginApiBindings {
* - `bindings` contains the host-backed callbacks for each enabled API group
*
* Returns:
* - The composed plugin client APIs for resources, kits, bindings, and tools
* - The composed built-in plugin client APIs for resources, kits, bindings, and tools
*/
export function createApis(ctx: EventContext<any, any>, bindings: PluginApiBindings = {}) {
return {