feat(plugin-sdk): kits api, binding api, and better comments

This commit is contained in:
Neko Ayaka
2026-04-21 16:46:04 +08:00
parent a406ffb67c
commit 0294dad6ef
40 changed files with 3488 additions and 19 deletions
+3
View File
@@ -46,5 +46,8 @@
"nanoid": "catalog:",
"valibot": "^1.3.1",
"xstate": "^5.30.0"
},
"devDependencies": {
"es-toolkit": "catalog:"
}
}
+37
View File
@@ -2,6 +2,19 @@ import type { EventContext } from '@moeru/eventa'
import { createContext } from '@moeru/eventa'
/**
* Holds the active plugin-sdk channel contexts for the current process.
*
* Use when:
* - Bootstrapping local or remote plugin transports
* - Reading the current control-plane or data-plane Eventa context
*
* Expects:
* - Callers replace the fallback contexts with a concrete transport during startup
*
* Returns:
* - Mutable host and data channel references shared by the SDK runtime
*/
export const channels = {
/**
* Channel for talking to Plugin Host.
@@ -21,10 +34,34 @@ export const channels = {
data: createContext(),
}
/**
* Replaces the active control-plane channel used to talk to Plugin Host.
*
* Use when:
* - A runtime has created its concrete host transport context
*
* Expects:
* - `context` is compatible with the current plugin transport implementation
*
* Returns:
* - Nothing. Future reads from {@link channels}.host use the provided context.
*/
export function setActiveHostChannel(context: EventContext<any, any>) {
channels.host = context
}
/**
* Replaces the active data-plane channel used for plugin-to-plugin or stage messaging.
*
* Use when:
* - A runtime has created its concrete data transport context
*
* Expects:
* - `context` is compatible with the current plugin transport implementation
*
* Returns:
* - Nothing. Future reads from {@link channels}.data use the provided context.
*/
export function setActiveDataChannel(context: EventContext<any, any>) {
channels.data = context
}
@@ -1,10 +1,34 @@
import { createContext } from '@moeru/eventa/adapters/event-target'
/**
* Creates a control-plane Eventa context backed by a local `EventTarget`.
*
* Use when:
* - A browser-like runtime wants an in-process host channel transport
*
* Expects:
* - `eventTarget` dispatches and listens for the Eventa adapter event format
*
* Returns:
* - An Eventa context that can be assigned to the active host channel
*/
export function createEventTargetHostChannel(eventTarget: EventTarget) {
// TODO: implement actual event target based host channel
return createContext(eventTarget)
}
/**
* Creates a data-plane Eventa context backed by a local `EventTarget`.
*
* Use when:
* - A browser-like runtime wants an in-process shared data channel transport
*
* Expects:
* - `eventTarget` dispatches and listens for the Eventa adapter event format
*
* Returns:
* - An Eventa context that can be assigned to the active data channel
*/
export function createEventTargetDataChannel(eventTarget: EventTarget) {
// TODO: implement actual event target based data channel
return createContext(eventTarget)
@@ -1,10 +1,34 @@
import { createContext } from '@moeru/eventa/adapters/websocket/native'
/**
* Creates a control-plane Eventa context backed by a native `WebSocket`.
*
* Use when:
* - A remote plugin talks to the host over a WebSocket transport
*
* Expects:
* - `webSocket` is already connected and managed by the caller
*
* Returns:
* - An Eventa context that can be assigned to the active host channel
*/
export function createWebSocketHostChannel(webSocket: WebSocket) {
// TODO: make sure to setup proper event handling on the webSocket
return createContext(webSocket)
}
/**
* Creates a data-plane Eventa context backed by a native `WebSocket`.
*
* Use when:
* - A remote plugin needs a WebSocket-backed shared data channel
*
* Expects:
* - `webSocket` is already connected and managed by the caller
*
* Returns:
* - An Eventa context that can be assigned to the active data channel
*/
export function createWebSocketDataChannel(webSocket: WebSocket) {
// TODO: make sure to setup proper event handling on the webSocket
return createContext(webSocket)
@@ -1,3 +1,16 @@
import type { EventContext } from '@moeru/eventa'
/**
* Describes the control-plane Eventa context used between a plugin and its host.
*
* Use when:
* - Typing `ContextInit.channels.host`
* - Passing a host-backed Eventa context through plugin bootstrap code
*
* Expects:
* - The context transports plugin-host lifecycle and RPC traffic
*
* Returns:
* - An Eventa context whose raw transport payload may be exposed through `raw`
*/
export type ChannelHost = EventContext<unknown, { raw?: any }>
+15
View File
@@ -1 +1,16 @@
console.warn('@proj-airi/plugin-sdk is currently working in progress. APIs may change without warning.')
export * from './plugin'
/**
* Re-exports the plugin bootstrap contracts from the package root.
*
* Use when:
* - Consumers want the high-level plugin authoring types from `@proj-airi/plugin-sdk`
*
* Expects:
* - Downstream code imports from the package root instead of the internal path
*
* Returns:
* - The `ContextInit` and `Plugin` types from `./plugin/shared`
*/
export type { ContextInit, Plugin } from './plugin/shared'
@@ -185,6 +185,9 @@ describe('for FileSystemPluginHost', () => {
describe('for PluginHost', () => {
const providersCapability = 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers'
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 testManifest = {
apiVersion: 'v1' as const,
kind: 'manifest.plugin.airi.moeru.ai' as const,
@@ -205,6 +208,52 @@ describe('for PluginHost', () => {
electron: join(import.meta.dirname, 'testdata', 'test-normal-plugin.ts'),
},
}
const dynamicApiManifest = {
...testManifest,
permissions: {
...testManifest.permissions,
apis: [
...(testManifest.permissions.apis ?? []),
{ key: 'proj-airi:plugin-sdk:apis:client:kits:list', actions: ['invoke'] },
{ key: 'proj-airi:plugin-sdk:apis:client:kits:get-capabilities', actions: ['invoke'] },
{ key: 'proj-airi:plugin-sdk:apis:client:bindings:list', actions: ['invoke'] },
{ key: 'proj-airi:plugin-sdk:apis:client:bindings:announce', actions: ['invoke'] },
{ key: 'proj-airi:plugin-sdk:apis:client:bindings:activate', actions: ['invoke'] },
{ key: 'proj-airi:plugin-sdk:apis:client:bindings:update', actions: ['invoke'] },
{ key: 'proj-airi:plugin-sdk:apis:client:bindings:withdraw', actions: ['invoke'] },
{ key: 'proj-airi:plugin-sdk:apis:client:tools:register', actions: ['invoke'] },
],
resources: [
...(testManifest.permissions.resources ?? []),
{ key: kitRegistryResourceKey, actions: ['read'] },
{ key: toolRegistryResourceKey, actions: ['write'] },
{ key: 'proj-airi:plugin-sdk:resources:bindings', actions: ['read'] },
{ key: widgetKitBindingsResourceKey, actions: ['read', 'write'] },
],
} satisfies ModulePermissionDeclaration,
}
const deniedKitReadManifest = {
...testManifest,
permissions: {
...testManifest.permissions,
apis: [
...(testManifest.permissions.apis ?? []),
{ key: 'proj-airi:plugin-sdk:apis:client:kits:list', actions: ['invoke'] },
],
} satisfies ModulePermissionDeclaration,
}
function registerWidgetKit(host: PluginHost) {
return host.registerKit({
kitId: 'kit.widget',
version: '1.0.0',
capabilities: [
{ key: 'kit.widget.module', actions: ['announce', 'activate', 'update', 'withdraw'] },
{ key: 'kit.widget.channel', actions: ['publish', 'subscribe'] },
],
runtimes: ['electron', 'web'],
})
}
it('should run plugin lifecycle to ready in-memory', async () => {
const host = new PluginHost({
@@ -259,6 +308,380 @@ describe('for PluginHost', () => {
expect(latest?.phase).toBe('failed')
})
it('should expose runtime-compatible kits through bound plugin apis', async () => {
const host = new PluginHost({
runtime: 'electron',
transport: { kind: 'in-memory' },
})
const widgetKit = registerWidgetKit(host)
host.registerKit({
kitId: 'kit.node-only',
version: '1.0.0',
capabilities: [{ key: 'kit.node-only.module', actions: ['announce'] }],
runtimes: ['node'],
})
reportPluginCapability(host, {
key: providersCapability,
state: 'ready',
metadata: { source: 'test' },
})
const session = await host.start(dynamicApiManifest, { cwd: '' })
const kits = await session.apis.kits.list()
const capabilities = await session.apis.kits.getCapabilities('kit.widget')
expect(kits).toEqual([widgetKit])
expect(capabilities).toEqual(widgetKit.capabilities)
})
it('should expose plugin tool client bindings on the plugin session api surface', 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: '' })
expect(session.apis.tools).toBeDefined()
await expect(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 }),
})).resolves.toBeUndefined()
})
it('should register available plugin tools and expose serialized xsai schemas', 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: {
opening: {
type: 'string',
},
},
},
},
availability: () => true,
execute: async input => ({ ok: true, input }),
})
await session.apis.tools.register({
tool: {
id: 'end_play_chess',
title: 'End Play Chess',
description: 'End chess.',
activation: {
keywords: ['end chess'],
patterns: ['end.*chess'],
},
parameters: {
type: 'object',
properties: {},
},
},
availability: () => false,
execute: async () => ({ ok: true, ended: true }),
})
await expect(host.listAvailableToolDescriptors()).resolves.toEqual([
{
id: 'play_chess',
title: 'Play Chess',
description: 'Open chess.',
activation: {
keywords: ['chess'],
patterns: ['play.*chess'],
},
},
])
await expect(host.listSerializedXsaiTools()).resolves.toEqual([
{
ownerPluginId: session.identity.plugin.id,
name: 'play_chess',
description: 'Open chess.',
parameters: {
type: 'object',
properties: {
opening: {
type: 'string',
},
},
},
},
])
await expect(host.invokeTool(session.identity.plugin.id, 'play_chess', { opening: 'sicilian' })).resolves.toEqual({
ok: true,
input: { opening: 'sicilian' },
})
await expect(host.invokeTool(session.identity.plugin.id, 'missing_tool', {})).rejects.toThrow(
`Plugin tool not found: ${session.identity.plugin.id}:missing_tool`,
)
})
it('should allow plugin to announce update activate and withdraw dynamic bindings through bound apis', async () => {
const host = new PluginHost({
runtime: 'electron',
transport: { kind: 'in-memory' },
})
registerWidgetKit(host)
reportPluginCapability(host, {
key: providersCapability,
state: 'ready',
metadata: { source: 'test' },
})
const session = await host.start(dynamicApiManifest, { cwd: '' })
expect('degrade' in session.apis.bindings).toBe(false)
expect(await session.apis.bindings.list()).toEqual([])
const announced = await session.apis.bindings.announce({
moduleId: 'module-a',
kitId: 'kit.widget',
kitModuleType: 'window',
config: { route: '/widgets' },
})
const listedAfterAnnounce = await session.apis.bindings.list()
expect(announced.moduleId).toBe('module-a')
expect(host.listBindings().some(item => item.moduleId === 'module-a')).toBe(true)
expect(listedAfterAnnounce).toEqual([
expect.objectContaining({
moduleId: 'module-a',
state: 'announced',
config: { route: '/widgets' },
}),
])
const activated = await session.apis.bindings.activate({ moduleId: 'module-a' })
const listedAfterActivate = await session.apis.bindings.list()
const updated = await session.apis.bindings.update({
moduleId: 'module-a',
config: {
route: '/widgets/main',
width: 420,
},
})
const listedAfterUpdate = await session.apis.bindings.list()
const withdrawn = await session.apis.bindings.withdraw({ moduleId: 'module-a' })
const listedAfterWithdraw = await session.apis.bindings.list()
expect(activated.state).toBe('active')
expect(listedAfterActivate).toEqual([
expect.objectContaining({
moduleId: 'module-a',
state: 'active',
}),
])
expect(updated.config).toEqual({
route: '/widgets/main',
width: 420,
})
expect(listedAfterUpdate).toEqual([
expect.objectContaining({
moduleId: 'module-a',
state: 'active',
config: {
route: '/widgets/main',
width: 420,
},
}),
])
expect(withdrawn.state).toBe('withdrawn')
expect(listedAfterWithdraw).toEqual([
expect.objectContaining({
moduleId: 'module-a',
state: 'withdrawn',
config: {
route: '/widgets/main',
width: 420,
},
}),
])
})
it('should let a test plugin consume injected kit and binding apis during init', async () => {
const host = new PluginHost({
runtime: 'electron',
transport: { kind: 'in-memory' },
})
registerWidgetKit(host)
const session = await host.start({
...dynamicApiManifest,
name: 'test-plugin-injected-host-apis',
entrypoints: {
electron: join(import.meta.dirname, 'testdata', 'test-injected-host-apis-plugin.ts'),
},
}, { cwd: '' })
expect(session.phase).toBe('ready')
expect(host.listBindings()).toEqual([
expect.objectContaining({
moduleId: 'test-injected-host-apis-module',
ownerSessionId: session.id,
ownerPluginId: session.identity.plugin.id,
kitId: 'kit.widget',
kitModuleType: 'window',
state: 'active',
config: {
route: '/widgets/injected-host-apis',
observedKitIds: ['kit.widget'],
observedCapabilityKeys: ['kit.widget.channel', 'kit.widget.module'],
},
}),
])
})
it('should reuse dynamic binding ids after stop cleanup and reload', async () => {
const host = new PluginHost({
runtime: 'electron',
transport: { kind: 'in-memory' },
})
registerWidgetKit(host)
reportPluginCapability(host, {
key: providersCapability,
state: 'ready',
metadata: { source: 'test' },
})
const session = await host.start(dynamicApiManifest, { cwd: '' })
await session.apis.bindings.announce({
moduleId: 'module-reuse',
kitId: 'kit.widget',
kitModuleType: 'window',
config: { route: '/widgets/reuse' },
})
const reloaded = await host.reload(session.id, { cwd: '' })
expect(host.getBinding('module-reuse')).toBeUndefined()
expect(host.listBindings().some(item => item.moduleId === 'module-reuse')).toBe(false)
const reused = await reloaded.apis.bindings.announce({
moduleId: 'module-reuse',
kitId: 'kit.widget',
kitModuleType: 'window',
config: { route: '/widgets/reuse-2' },
})
expect(reused.ownerSessionId).toBe(reloaded.id)
expect(reused.config).toEqual({ route: '/widgets/reuse-2' })
})
it('should isolate plugin-facing kit and module snapshots from plugin-side mutation', async () => {
const host = new PluginHost({
runtime: 'electron',
transport: { kind: 'in-memory' },
})
registerWidgetKit(host)
reportPluginCapability(host, {
key: providersCapability,
state: 'ready',
metadata: { source: 'test' },
})
const session = await host.start(dynamicApiManifest, { cwd: '' })
const listedKits = await session.apis.kits.list()
listedKits[0].kitId = 'kit.mutated'
listedKits[0].capabilities[0].actions.push('tampered')
listedKits[0].runtimes.push('node')
const listedCapabilities = await session.apis.kits.getCapabilities('kit.widget')
listedCapabilities[0].actions.push('shadow-write')
const announced = await session.apis.bindings.announce({
moduleId: 'module-snapshot',
kitId: 'kit.widget',
kitModuleType: 'window',
config: { route: '/widgets/snapshot' },
})
announced.config.route = '/widgets/tampered'
const listedModules = await session.apis.bindings.list()
listedModules[0].config.route = '/widgets/list-tampered'
expect(await session.apis.kits.list()).toEqual([
expect.objectContaining({
kitId: 'kit.widget',
capabilities: [
expect.objectContaining({
key: 'kit.widget.module',
actions: ['announce', 'activate', 'update', 'withdraw'],
}),
expect.objectContaining({
key: 'kit.widget.channel',
actions: ['publish', 'subscribe'],
}),
],
runtimes: ['electron', 'web'],
}),
])
expect(await session.apis.kits.getCapabilities('kit.widget')).toEqual([
{ key: 'kit.widget.module', actions: ['announce', 'activate', 'update', 'withdraw'] },
{ key: 'kit.widget.channel', actions: ['publish', 'subscribe'] },
])
expect(await session.apis.bindings.list()).toEqual([
expect.objectContaining({
moduleId: 'module-snapshot',
config: { route: '/widgets/snapshot' },
}),
])
})
it('should deny new kit apis when resource read permission is missing', async () => {
const host = new PluginHost({
runtime: 'electron',
transport: { kind: 'in-memory' },
})
registerWidgetKit(host)
reportPluginCapability(host, {
key: providersCapability,
state: 'ready',
metadata: { source: 'test' },
})
const session = await host.start(deniedKitReadManifest, { cwd: '' })
await expect(session.apis.kits.list()).rejects.toThrow('Permission denied: resources.read "proj-airi:plugin-sdk:resources:kits"')
})
it('should reject non in-memory transport for MVP', async () => {
const host = new PluginHost({
runtime: 'electron',
+449 -15
View File
@@ -1,8 +1,13 @@
import type { ActorRefFrom } from 'xstate'
import type { createApis } from '../plugin/apis/client'
import type { AnnounceBindingInput, UpdateBindingInput } from '../plugin/apis/client/bindings'
import type { RegisterToolInput } from '../plugin/apis/client/tools'
import type { Plugin } from '../plugin/shared'
import type { BindingRecord, KitCapabilityDescriptor, KitDescriptor } from './shared'
import type {
HostDataRecord,
HostDataValue,
ManifestV1,
ModuleCompatibilityRequest,
ModuleConfigEnvelope,
@@ -41,6 +46,25 @@ import {
import { createActor, createMachine } from 'xstate'
import { createApis as createBoundApis } from '../plugin/apis/client'
import {
getKitBindingResourceKey,
pluginBindingApiActivateEventName,
pluginBindingApiAnnounceEventName,
pluginBindingApiListEventName,
pluginBindingApiUpdateEventName,
pluginBindingApiWithdrawEventName,
pluginBindingRegistryResourceKey,
} from '../plugin/apis/client/bindings'
import {
pluginKitApiGetCapabilitiesEventName,
pluginKitApiListEventName,
pluginKitRegistryResourceKey,
} from '../plugin/apis/client/kits'
import {
pluginToolApiRegisterEventName,
pluginToolRegistryResourceKey,
} from '../plugin/apis/client/tools'
import {
protocolCapabilitySnapshot,
protocolCapabilitySnapshotEventName,
@@ -54,10 +78,13 @@ import {
import { createPluginContext } from './runtimes/node'
import { FileSystemLoader } from './runtimes/node/loaders'
import {
BindingsRegistryService,
DependencyService,
KitRegistryService,
PermissionService,
PluginSessionService,
ResourceService,
ToolRegistryService,
} from './runtimes/shared'
/**
@@ -495,44 +522,153 @@ class PermissionDeniedError extends Error {
}
}
/**
* Describes the host-owned state tracked for one plugin session.
*
* Use when:
* - Reading session snapshots from `PluginHost`
* - Passing session state through host tests or orchestration code
*
* Expects:
* - `id` and `identity` stay stable for the lifetime of the session
*
* Returns:
* - The full session snapshot including transport, phase, bound APIs, and granted permissions
*/
export interface PluginHostSession {
/** Manifest used to load the plugin. */
manifest: ManifestV1
/** Loaded plugin hooks for the active session. */
plugin: Plugin
/** Unique host-generated session id. */
id: string
/** Monotonic index assigned when the session was created. */
index: number
/** Working directory used to resolve relative entrypoints. */
cwd: string
/** Protocol identity emitted on plugin lifecycle events. */
identity: ModuleIdentity
/** Current host lifecycle phase for the session. */
phase: PluginSessionPhase
/** XState actor that drives the session lifecycle transitions. */
lifecycle: ActorRefFrom<typeof pluginLifecycleMachine>
/** Transport used by the session Eventa context. */
transport: PluginTransport
/** Runtime used to load and run the plugin. */
runtime: PluginRuntime
/** Host-owned Eventa channels injected into the plugin context. */
channels: {
/** Control-plane Eventa context used for lifecycle and RPC traffic. */
host: ReturnType<typeof createPluginContext>
}
/** Bound plugin SDK APIs exposed to plugin code. */
apis: ReturnType<typeof createApis>
/** Requested and granted permissions for the session. */
permissions: {
/** Permissions requested by the manifest and runtime declarations. */
requested: ModulePermissionDeclaration
/** Permissions actually granted by the host. */
granted: ModulePermissionGrant
/** Permission snapshot revision number. */
revision: number
}
}
/**
* In-memory Plugin Host MVP.
* Filters the binding list returned by `PluginHost.listBindings(...)`.
*
* Procedure placement:
* - `load(...)` covers step 0 and step 1 preparation:
* - create channel gateway/context
* - prepare per-plugin isolated runtime resources
* - load plugin module from manifest entrypoint
* - `init(...)` covers protocol/lifecycle step 2 onwards:
* - authentication
* - compatibility negotiation
* - registry sync + announce/prepare/configure/ready flow
* Use when:
* - Narrowing the host binding snapshot by owner session or kit
*
* The design intentionally keeps `load` and `init` separate so callers can:
* - inspect/patch session state before booting,
* - batch-load many plugins first, then initialize deterministically.
* Expects:
* - Omitted fields mean "do not filter by this dimension"
*
* Returns:
* - Optional filter criteria for the in-memory binding registry
*/
export interface PluginHostBindingListOptions {
/** Limit results to bindings owned by one plugin session. */
ownerSessionId?: string
/** Limit results to bindings declared against one kit. */
kitId?: string
}
type BoundAnnounceBindingInput<C extends HostDataRecord = HostDataRecord> = AnnounceBindingInput<C>
type BoundUpdateBindingInput<C extends HostDataRecord = HostDataRecord> = UpdateBindingInput<C>
function omitModuleId<C extends HostDataRecord>(input: BoundUpdateBindingInput<C>) {
return {
state: input.state,
config: input.config,
}
}
function cloneHostDataValue<T extends HostDataValue>(value: T): T {
if (Array.isArray(value)) {
return value.map(item => cloneHostDataValue(item)) as T
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [key, cloneHostDataValue(item as HostDataValue)]),
) as T
}
return value
}
function cloneHostDataRecord<T extends HostDataRecord>(record: T): T {
return cloneHostDataValue(record)
}
function cloneKitCapabilities(capabilities: KitCapabilityDescriptor[]): KitCapabilityDescriptor[] {
return capabilities.map(capability => ({
key: capability.key,
actions: [...capability.actions],
}))
}
function cloneKitDescriptor<TKit extends KitDescriptor>(kit: TKit): TKit {
return {
...kit,
runtimes: [...kit.runtimes],
capabilities: cloneKitCapabilities(kit.capabilities),
}
}
function cloneBindingRecord<C extends HostDataRecord>(module: BindingRecord<C>): BindingRecord<C> {
return {
...module,
config: cloneHostDataRecord(module.config),
}
}
/**
* Orchestrates plugin loading, session lifecycle, bindings, tools, resources, and permissions.
*
* Use when:
* - Running plugins inside the in-memory host implementation
* - Tests or applications need one place to load, initialize, start, stop, and query plugin sessions
*
* Expects:
* - Plugins are loaded from manifest entrypoints through {@link FileSystemLoader}
* - Each session gets its own Eventa context, permission scope, and lifecycle actor
*
* Returns:
* - A host instance that exposes session management plus access to kits, bindings, tools, and capabilities
*
* Call stack:
*
* caller
* -> {@link PluginHost.load}
* -> {@link FileSystemLoader.resolveEntrypointFor}
* -> {@link FileSystemLoader.loadPluginFor}
* -> {@link PluginHost.init}
* -> permission resolution + protocol negotiation
* -> binding of {@link createApis} into plugin context
* -> {@link PluginHost.start}
* -> {@link PluginHost.load}
* -> {@link PluginHost.init}
*/
export class PluginHost {
private readonly loader: FileSystemLoader
@@ -544,6 +680,9 @@ export class PluginHost {
private readonly supportedProtocolVersions: string[]
private readonly supportedApiVersions: string[]
private readonly dependencies = new DependencyService()
private readonly kits = new KitRegistryService()
private readonly modules = new BindingsRegistryService()
private readonly tools = new ToolRegistryService()
private readonly permissions = new PermissionService()
private readonly permissionResolver?: PluginHostOptions['permissionResolver']
private readonly persistedPermissionGrants = new Map<string, ModulePermissionGrant>()
@@ -600,6 +739,37 @@ export class PluginHost {
throw error
}
private getSessionOrThrow(sessionId: string) {
const session = this.sessionService.get(sessionId)
if (!session) {
throw new Error(`Unknown plugin session: ${sessionId}`)
}
return session
}
private getModuleOrThrow(moduleId: string) {
const module = this.modules.get(moduleId)
if (!module) {
throw new Error(`Module \`${moduleId}\` was not found.`)
}
return module
}
private assertKitAvailableForSession(session: PluginHostSession, kitId: string) {
const kit = this.kits.get(kitId)
if (!kit) {
throw new Error(`Kit \`${kitId}\` is not registered.`)
}
if (!kit.runtimes.includes(session.runtime)) {
throw new Error(`Kit \`${kitId}\` is not available for runtime \`${session.runtime}\`.`)
}
return kit
}
listSessions() {
return this.sessionService.list()
}
@@ -608,6 +778,207 @@ export class PluginHost {
return this.sessionService.get(sessionId)
}
registerKit(kit: KitDescriptor) {
return this.kits.register(kit)
}
unregisterKit(kitId: string) {
return this.kits.remove(kitId)
}
getKit(kitId: string) {
const kit = this.kits.get(kitId)
if (!kit) {
return undefined
}
return cloneKitDescriptor(kit)
}
listKits(runtime?: PluginRuntime) {
const kits = runtime
? this.kits.listByRuntime(runtime)
: this.kits.list()
return kits.map(kit => cloneKitDescriptor(kit))
}
getKitCapabilities(kitId: string): KitCapabilityDescriptor[] {
const capabilities = this.kits.get(kitId)?.capabilities
if (!capabilities) {
return []
}
return cloneKitCapabilities(capabilities)
}
getBinding(moduleId: string) {
const module = this.modules.get(moduleId)
if (!module) {
return undefined
}
return cloneBindingRecord(module)
}
listBindings(options: PluginHostBindingListOptions = {}) {
return this.modules.list().filter((module) => {
if (options.ownerSessionId && module.ownerSessionId !== options.ownerSessionId) {
return false
}
if (options.kitId && module.kitId !== options.kitId) {
return false
}
return true
}).map(module => cloneBindingRecord(module))
}
async listAvailableToolDescriptors() {
return await this.tools.listAvailableDescriptors()
}
async listSerializedXsaiTools() {
return await this.tools.listSerializedXsaiTools()
}
async invokeTool(ownerPluginId: string, toolId: string, input: unknown) {
return await this.tools.invoke(ownerPluginId, toolId, input)
}
announceBinding<C extends HostDataRecord = HostDataRecord>(
sessionId: string,
input: BoundAnnounceBindingInput<C>,
): BindingRecord<C> {
const session = this.getSessionOrThrow(sessionId)
const kit = this.assertKitAvailableForSession(session, input.kitId)
this.assertPermission(session, {
area: 'apis',
action: 'invoke',
key: pluginBindingApiAnnounceEventName,
})
this.assertPermission(session, {
area: 'resources',
action: 'write',
key: getKitBindingResourceKey(kit.kitId),
reason: `Module announce requires write access to kit \`${kit.kitId}\`.`,
})
return cloneBindingRecord(this.modules.bind({
...input,
ownerSessionId: session.id,
ownerPluginId: session.identity.plugin.id,
runtime: session.runtime,
}) as BindingRecord<C>)
}
activateBinding(sessionId: string, moduleId: string) {
const session = this.getSessionOrThrow(sessionId)
const module = this.getModuleOrThrow(moduleId)
this.assertPermission(session, {
area: 'apis',
action: 'invoke',
key: pluginBindingApiActivateEventName,
})
this.assertPermission(session, {
area: 'resources',
action: 'write',
key: getKitBindingResourceKey(module.kitId),
reason: `Module activation requires write access to kit \`${module.kitId}\`.`,
})
return cloneBindingRecord(this.modules.activate(session.id, session.identity.plugin.id, moduleId))
}
updateBinding<C extends HostDataRecord = HostDataRecord>(
sessionId: string,
moduleId: string,
patch: UpdateBindingInput<C> | Omit<UpdateBindingInput<C>, 'moduleId'>,
) {
const session = this.getSessionOrThrow(sessionId)
const module = this.getModuleOrThrow(moduleId)
this.assertPermission(session, {
area: 'apis',
action: 'invoke',
key: pluginBindingApiUpdateEventName,
})
this.assertPermission(session, {
area: 'resources',
action: 'write',
key: getKitBindingResourceKey(module.kitId),
reason: `Module update requires write access to kit \`${module.kitId}\`.`,
})
const normalizedPatch = 'moduleId' in patch ? omitModuleId(patch) : patch
return cloneBindingRecord(this.modules.update(session.id, session.identity.plugin.id, moduleId, normalizedPatch))
}
degradeBinding(sessionId: string, moduleId: string) {
const session = this.getSessionOrThrow(sessionId)
const module = this.getModuleOrThrow(moduleId)
this.assertPermission(session, {
area: 'resources',
action: 'write',
key: getKitBindingResourceKey(module.kitId),
reason: `Module degradation requires write access to kit \`${module.kitId}\`.`,
})
return cloneBindingRecord(this.modules.degrade(session.id, session.identity.plugin.id, moduleId))
}
withdrawBinding(sessionId: string, moduleId: string) {
const session = this.getSessionOrThrow(sessionId)
const module = this.getModuleOrThrow(moduleId)
this.assertPermission(session, {
area: 'apis',
action: 'invoke',
key: pluginBindingApiWithdrawEventName,
})
this.assertPermission(session, {
area: 'resources',
action: 'write',
key: getKitBindingResourceKey(module.kitId),
reason: `Module withdrawal requires write access to kit \`${module.kitId}\`.`,
})
return cloneBindingRecord(this.modules.withdraw(session.id, session.identity.plugin.id, moduleId))
}
registerTool(sessionId: string, input: RegisterToolInput) {
const session = this.getSessionOrThrow(sessionId)
this.assertPermission(session, {
area: 'apis',
action: 'invoke',
key: pluginToolApiRegisterEventName,
})
this.assertPermission(session, {
area: 'resources',
action: 'write',
key: pluginToolRegistryResourceKey,
})
this.tools.register({
ownerSessionId: session.id,
ownerPluginId: session.identity.plugin.id,
tool: {
...input.tool,
activation: {
keywords: [...input.tool.activation.keywords],
patterns: [...input.tool.activation.patterns],
},
parameters: cloneHostDataRecord(input.tool.parameters),
},
availability: input.availability,
execute: input.execute,
})
}
async load(manifest: ManifestV1, options: PluginLoadOptions = {}): Promise<PluginHostSession> {
// Step 0 (channel gateway preparation): resolve runtime and transport for this plugin.
const runtime = options.runtime ?? this.runtime
@@ -641,7 +1012,65 @@ export class PluginHost {
},
)
const session: PluginHostSession = {
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 = {
manifest,
plugin: {},
id,
@@ -655,7 +1084,7 @@ export class PluginHost {
channels: {
host: hostChannel,
},
apis: createBoundApis(hostChannel),
apis,
permissions: {
requested: permissionSnapshot.requested,
granted: permissionSnapshot.granted,
@@ -1173,6 +1602,11 @@ export class PluginHost {
}
}
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,18 @@ export * from '../../shared'
export * from '../../transports'
export * from './loaders'
/**
* Creates the Eventa context used by node-side plugin host sessions.
*
* Use when:
* - Bootstrapping a node runtime plugin session
*
* Expects:
* - `transport` describes a transport supported by the node runtime
*
* Returns:
* - A node-compatible Eventa context, or throws if the transport is not implemented
*/
export function createPluginContext(transport: PluginTransport): EventContext<any, any> {
switch (transport.kind) {
case 'in-memory':
@@ -38,6 +38,19 @@ async function coercePluginFromModule(moduleValue: unknown): Promise<Plugin> {
throw new Error('Failed to resolve plugin module. The entrypoint must export either definePlugin(...) or Plugin hooks.')
}
/**
* Loads plugin entrypoints from the local filesystem for the current runtime.
*
* Use when:
* - The host needs to resolve a manifest entrypoint path
* - The host needs to import either a lazy `definePlugin(...)` export or a concrete plugin module
*
* Expects:
* - Entry points are valid importable module paths for the active runtime
*
* Returns:
* - Filesystem-backed helpers for resolving and loading plugin entrypoints
*/
export class FileSystemLoader {
/**
* Resolve a manifest entrypoint for the requested runtime.
@@ -0,0 +1,169 @@
import { describe, expect, it } from 'vitest'
import { BindingsRegistryService } from './bindings'
describe('bindingsRegistryService', () => {
it('rejects ownership violations when updating a module from another session', () => {
const service = new BindingsRegistryService()
service.bind({
moduleId: 'm1',
ownerSessionId: 'session-a',
ownerPluginId: 'plugin-a',
kitId: 'kit.widget',
kitModuleType: 'panel',
config: {},
runtime: 'electron',
})
expect(() => service.update('session-b', 'plugin-a', 'm1', { config: { size: 'l' } })).toThrowError(/ownership/i)
})
it('tracks lifecycle transitions with revision bumps and preserved ownership', () => {
const service = new BindingsRegistryService()
const announced = service.bind({
moduleId: 'm2',
ownerSessionId: 'session-a',
ownerPluginId: 'plugin-a',
kitId: 'kit.widget',
kitModuleType: 'panel',
config: { mountPoint: 'widgets' },
runtime: 'web',
})
const activated = service.activate('session-a', 'plugin-a', 'm2')
const updated = service.update('session-a', 'plugin-a', 'm2', { config: { mountPoint: 'widgets', width: 320 } })
const withdrawn = service.withdraw('session-a', 'plugin-a', 'm2')
expect(announced.state).toBe('announced')
expect(activated.state).toBe('active')
expect(updated.revision).toBeGreaterThan(activated.revision)
expect(updated.config).toEqual({ mountPoint: 'widgets', width: 320 })
expect(withdrawn.state).toBe('withdrawn')
expect(service.listByOwner('session-a')).toHaveLength(1)
})
it('rejects invalid lifecycle transitions after withdrawal', () => {
const service = new BindingsRegistryService()
service.bind({
moduleId: 'm3',
ownerSessionId: 'session-a',
ownerPluginId: 'plugin-a',
kitId: 'kit.widget',
kitModuleType: 'panel',
config: {},
runtime: 'electron',
})
service.withdraw('session-a', 'plugin-a', 'm3')
expect(() => service.activate('session-a', 'plugin-a', 'm3')).toThrowError(/invalid binding lifecycle transition/i)
})
it('rejects duplicate module ids from a different owner session', () => {
const service = new BindingsRegistryService()
service.bind({
moduleId: 'm4',
ownerSessionId: 'session-a',
ownerPluginId: 'plugin-a',
kitId: 'kit.widget',
kitModuleType: 'panel',
config: {},
runtime: 'electron',
})
expect(() =>
service.bind({
moduleId: 'm4',
ownerSessionId: 'session-b',
ownerPluginId: 'plugin-b',
kitId: 'kit.widget',
kitModuleType: 'panel',
config: {},
runtime: 'electron',
}),
).toThrowError(/module id collision/i)
})
it('returns the existing record for an idempotent duplicate bind from the same owner', () => {
const service = new BindingsRegistryService()
const original = service.bind({
moduleId: 'm5',
ownerSessionId: 'session-a',
ownerPluginId: 'plugin-a',
kitId: 'kit.widget',
kitModuleType: 'panel',
config: { mountPoint: 'widgets' },
runtime: 'electron',
})
const duplicate = service.bind({
moduleId: 'm5',
ownerSessionId: 'session-a',
ownerPluginId: 'plugin-a',
kitId: 'kit.widget',
kitModuleType: 'dialog',
config: { mountPoint: 'mutated', width: 480 },
runtime: 'web',
})
expect(duplicate).toBe(original)
expect(duplicate.kitModuleType).toBe('panel')
expect(duplicate.runtime).toBe('electron')
expect(duplicate.config).toEqual({ mountPoint: 'widgets' })
})
it('rejects module reuse with the same session but a different owner plugin', () => {
const service = new BindingsRegistryService()
service.bind({
moduleId: 'm6',
ownerSessionId: 'session-a',
ownerPluginId: 'plugin-a',
kitId: 'kit.widget',
kitModuleType: 'panel',
config: {},
runtime: 'electron',
})
expect(() =>
service.bind({
moduleId: 'm6',
ownerSessionId: 'session-a',
ownerPluginId: 'plugin-b',
kitId: 'kit.widget',
kitModuleType: 'panel',
config: {},
runtime: 'electron',
}),
).toThrowError(/module id collision/i)
})
it('removes a withdrawn binding with unbind for teardown flows', () => {
const service = new BindingsRegistryService()
service.bind({
moduleId: 'm7',
ownerSessionId: 'session-a',
ownerPluginId: 'plugin-a',
kitId: 'kit.widget',
kitModuleType: 'panel',
config: {},
runtime: 'electron',
})
service.withdraw('session-a', 'plugin-a', 'm7')
expect(service.unbind('session-a', 'plugin-a', 'm7')).toEqual(
expect.objectContaining({
moduleId: 'm7',
state: 'withdrawn',
}),
)
expect(service.has('m7')).toBe(false)
})
})
@@ -0,0 +1,473 @@
import type { BindingRecord, BindingState } from '../../../shared/bindings'
import type { HostDataRecord, PluginRuntime } from '../../../shared/types'
/**
* Declares the host-owned data needed to create one binding record.
*
* Use when:
* - A plugin session contributes a concrete runtime instance through a kit
* - Higher-level kit helpers need to persist their low-level binding into the host registry
*
* Expects:
* - `moduleId` is stable within the owning plugin session
* - `kitId` points at a host-registered kit that defines the binding family
* - `kitModuleType` is a kit-defined subtype key, not a host-wide enum
* - `config` is transport-safe and already normalized by the caller
*
* Returns:
* - A serializable payload that {@link BindingsRegistryService.bind} stores as canonical binding state
*/
export interface BindingInput<C extends HostDataRecord = HostDataRecord> {
moduleId: string
ownerSessionId: string
ownerPluginId: string
kitId: string
kitModuleType: string
runtime: PluginRuntime
config: C
}
/**
* Describes an incremental change to a binding record.
*
* Use when:
* - A kit-specific API needs to change binding lifecycle state
* - A plugin updates binding configuration after initial registration
*
* Expects:
* - `state` follows the host lifecycle rules for the current record
* - `config` only contains fields that should be shallow-merged into the current config
*
* Returns:
* - A partial mutation applied by {@link BindingsRegistryService.update} or {@link BindingsRegistryService.transition}
*/
export interface BindingUpdatePatch<C extends HostDataRecord = HostDataRecord> {
state?: BindingState
config?: Partial<C>
}
/**
* Identifies the plugin session that owns a binding record.
*
* Use when:
* - Enforcing that only the original plugin session mutates or removes a binding
* - Comparing current callers against stored binding ownership
*
* Expects:
* - `ownerSessionId` is the ephemeral runtime session id
* - `ownerPluginId` is the stable plugin identity across sessions
*
* Returns:
* - A compact identity tuple used in collision and ownership checks
*/
export interface BindingOwnerIdentity {
ownerSessionId: string
ownerPluginId: string
}
const allowedBindingTransitions: Record<BindingState, readonly BindingState[]> = {
announced: ['active', 'degraded', 'withdrawn'],
active: ['degraded', 'withdrawn', 'active'],
degraded: ['active', 'withdrawn', 'degraded'],
withdrawn: ['withdrawn'],
}
function createOwnershipError(
moduleId: string,
expected: BindingOwnerIdentity,
actual: BindingOwnerIdentity,
) {
return new Error(
`Ownership violation for module \`${moduleId}\`: owned by \`${expected.ownerSessionId}/${expected.ownerPluginId}\`, not \`${actual.ownerSessionId}/${actual.ownerPluginId}\`.`,
)
}
function createModuleCollisionError(
moduleId: string,
expected: BindingOwnerIdentity,
actual: BindingOwnerIdentity,
) {
return new Error(
`Module id collision for \`${moduleId}\`: owned by \`${expected.ownerSessionId}/${expected.ownerPluginId}\`, not \`${actual.ownerSessionId}/${actual.ownerPluginId}\`.`,
)
}
function createInvalidTransitionError(moduleId: string, from: BindingState, to: BindingState) {
return new Error(`Invalid binding lifecycle transition for \`${moduleId}\`: \`${from}\` -> \`${to}\`.`)
}
/**
* Stores the host's canonical binding records for dynamically contributed kit instances.
*
* Use when:
* - A plugin contributes runtime instances through higher-level kit APIs
* - The host needs an ownership-aware registry that tracks lifecycle and config for those instances
* - Adapters, permission checks, and debug tooling need one authoritative binding table
*
* Expects:
* - Callers treat a binding as the low-level host record behind a higher-level contribution API
* - Kits define the meaning of `kitModuleType` and validate kit-specific config outside this service
* - Binding ids stay unique per owner and are reused intentionally, not accidentally
*
* Returns:
* - Stable {@link BindingRecord} snapshots representing bound runtime contributions
*
* A binding is the concrete link between a plugin-owned runtime instance and a host-registered kit.
* The host keeps kits generic: a kit only describes capabilities, supported runtimes, and allowed
* operations. That is not enough to render UI, route lifecycle, or enforce ownership for a specific
* plugin contribution. The missing piece is a binding record saying:
*
* - plugin session `X` owns runtime instance `moduleId`
* - that instance is attached to kit `kitId`
* - within that kit it behaves as subtype `kitModuleType`
* - here is its current generic config payload and lifecycle state
*
* Why kits require bindings:
*
* - Multiple plugins can use the same kit at the same time, so the host needs per-instance records.
* - Permission checks are kit-scoped, but ownership and lifecycle are instance-scoped.
* - Surface adapters need something concrete to mount, update, degrade, or withdraw.
*
* Higher-level kit APIs should normally sit on top of this service instead of exposing it directly.
* For example, a future `createWidgetApi(ctx)` could call `bind(...)`, `activate(...)`, and `update(...)`
* internally while presenting a simpler `register('widget-1')` API to plugin authors. In that model:
*
* - kits define the contract
* - bindings persist concrete instances of that contract
* - adapters consume binding records to produce UI/runtime behavior
*
* This is similar to VS Code's contribution system in spirit, but not in exact mechanics. VS Code uses
* declarative contribution points in extension manifests and the host interprets them at load time. AIRI's
* binding registry is more runtime-oriented:
*
* - contributions can appear after startup
* - they can transition through host-managed lifecycle states
* - they are session-owned and can be withdrawn or rebound on reload
*
* Examples:
*
* 1. One plugin binds once:
* - before: `{}`
* - `bind({ moduleId: 'widget-main', kitId: 'kit.widget', kitModuleType: 'window', ... })`
* - after: `{ 'widget-main' => { state: 'announced', revision: 1, ... } }`
*
* 2. The same plugin binds the same id again:
* - before: `{ 'widget-main' => { state: 'announced', revision: 1, runtime: 'electron' } }`
* - `bind(...)` with the same owner but different config
* - after: unchanged record is returned. This preserves idempotency and prevents silent mutation during rebind.
*
* 3. Another plugin tries to bind the same id:
* - before: `{ 'widget-main' => owned by session-a/plugin-a }`
* - `bind(...)` from session-b/plugin-b
* - after: throws collision error, registry remains unchanged
*
* 4. One plugin binds many times under one kit:
* - `widget-main`, `widget-sidebar`, `widget-dialog`
* - after: three binding records, all under `kit.widget`, each with independent lifecycle and config
*
* 5. One plugin binds across multiple kits:
* - `chat-sidebar` under `kit.chat`
* - `widget-main` under `kit.widget`
* - after: one registry, multiple kit families, each record still resolved by the same ownership rules
*/
export class BindingsRegistryService<C extends HostDataRecord = HostDataRecord> {
private readonly bindings = new Map<string, BindingRecord<C>>()
/**
* Creates or reuses one binding record for a plugin-owned runtime instance.
*
* Use when:
* - A plugin or kit helper needs to declare that a concrete instance now exists
* - Rebinding the same id from the same owner should behave idempotently
*
* Expects:
* - `input` already identifies the intended owner and kit family
* - Rebinding the same id from a different owner is a collision
*
* Returns:
* - The newly stored binding record, or the existing record for idempotent same-owner rebinding
*/
bind(input: BindingInput<C>) {
const current = this.bindings.get(input.moduleId)
if (current) {
if (
current.ownerSessionId !== input.ownerSessionId
|| current.ownerPluginId !== input.ownerPluginId
) {
throw createModuleCollisionError(
input.moduleId,
{
ownerSessionId: current.ownerSessionId,
ownerPluginId: current.ownerPluginId,
},
{
ownerSessionId: input.ownerSessionId,
ownerPluginId: input.ownerPluginId,
},
)
}
return current
}
const record: BindingRecord<C> = {
moduleId: input.moduleId,
ownerSessionId: input.ownerSessionId,
ownerPluginId: input.ownerPluginId,
kitId: input.kitId,
kitModuleType: input.kitModuleType,
state: 'announced',
runtime: input.runtime,
revision: 1,
updatedAt: Date.now(),
config: input.config,
}
this.bindings.set(record.moduleId, record)
return record
}
/**
* Looks up one binding record by its runtime instance id.
*
* Use when:
* - Higher-level host flows need to inspect the current canonical binding state
*
* Expects:
* - `moduleId` is a binding id previously created by {@link bind}
*
* Returns:
* - The stored binding record, or `undefined` if it does not exist
*/
get(moduleId: string) {
return this.bindings.get(moduleId)
}
/**
* Checks whether the registry currently contains a binding id.
*
* Use when:
* - Callers need a quick existence check before a larger operation
*
* Expects:
* - `moduleId` is the binding id to inspect
*
* Returns:
* - `true` when the binding exists in the registry
*/
has(moduleId: string) {
return this.bindings.has(moduleId)
}
/**
* Lists every binding record currently tracked by the host.
*
* Use when:
* - Debug tools, snapshots, or adapters need the full binding table
*
* Expects:
* - Callers treat the returned array as a read-only snapshot
*
* Returns:
* - All stored binding records in insertion order
*/
list() {
return [...this.bindings.values()]
}
/**
* Lists bindings owned by one plugin session.
*
* Use when:
* - Stopping or reloading a session
* - Inspecting one plugin's currently active contributions
*
* Expects:
* - `ownerSessionId` is the session-scoped owner id stored in each binding
*
* Returns:
* - All binding records whose owner session matches the input
*/
listByOwner(ownerSessionId: string) {
return this.list().filter(binding => binding.ownerSessionId === ownerSessionId)
}
/**
* Lists bindings attached to one kit family.
*
* Use when:
* - A kit adapter needs to enumerate all currently bound instances
* - Debug tooling needs to inspect one kit's contribution footprint
*
* Expects:
* - `kitId` matches the `kitId` stored on each binding record
*
* Returns:
* - All binding records attached to the requested kit
*/
listByKit(kitId: string) {
return this.list().filter(binding => binding.kitId === kitId)
}
/**
* Applies a shallow config and/or state update to an existing binding.
*
* Use when:
* - A kit helper wants to mutate config after the initial bind
* - A caller wants transition semantics and config merge in one operation
*
* Expects:
* - The caller owns the binding
* - Any requested `state` is valid from the current lifecycle state
*
* Returns:
* - The updated binding record with incremented revision and timestamp
*/
update(ownerSessionId: string, ownerPluginId: string, moduleId: string, patch: BindingUpdatePatch<C>) {
return this.transition({ ownerSessionId, ownerPluginId }, moduleId, patch.state, patch)
}
/**
* Transitions a bound instance into the `active` lifecycle state.
*
* Use when:
* - The host or a higher-level kit API has completed setup for a bound instance
*
* Expects:
* - The binding exists and is owned by the caller
*
* Returns:
* - The updated active binding record
*/
activate(ownerSessionId: string, ownerPluginId: string, moduleId: string) {
return this.transition({ ownerSessionId, ownerPluginId }, moduleId, 'active')
}
/**
* Transitions a bound instance into the `degraded` lifecycle state.
*
* Use when:
* - A previously healthy binding loses a dependency or adapter guarantee
*
* Expects:
* - The binding exists and is owned by the caller
*
* Returns:
* - The updated degraded binding record
*/
degrade(ownerSessionId: string, ownerPluginId: string, moduleId: string) {
return this.transition({ ownerSessionId, ownerPluginId }, moduleId, 'degraded')
}
/**
* Transitions a bound instance into the `withdrawn` lifecycle state.
*
* Use when:
* - A plugin wants the host to stop treating a binding as live before eventual removal
*
* Expects:
* - The binding exists and is owned by the caller
*
* Returns:
* - The updated withdrawn binding record
*/
withdraw(ownerSessionId: string, ownerPluginId: string, moduleId: string) {
return this.transition({ ownerSessionId, ownerPluginId }, moduleId, 'withdrawn')
}
/**
* Performs the shared ownership checks and lifecycle transition logic for one binding.
*
* Use when:
* - A caller needs a custom lifecycle transition beyond the convenience helpers
*
* Expects:
* - `owner` matches the stored binding owner
* - `state`, when provided, is legal from the current lifecycle state
*
* Returns:
* - The next canonical binding record written back into the registry
*/
transition(
owner: BindingOwnerIdentity,
moduleId: string,
state?: BindingState,
patch: BindingUpdatePatch<C> = {},
) {
const current = this.bindings.get(moduleId)
if (!current) {
throw new Error(`Module \`${moduleId}\` was not found.`)
}
if (
current.ownerSessionId !== owner.ownerSessionId
|| current.ownerPluginId !== owner.ownerPluginId
) {
throw createOwnershipError(
moduleId,
{
ownerSessionId: current.ownerSessionId,
ownerPluginId: current.ownerPluginId,
},
owner,
)
}
const nextState = state ?? current.state
if (!allowedBindingTransitions[current.state].includes(nextState)) {
throw createInvalidTransitionError(moduleId, current.state, nextState)
}
const next: BindingRecord<C> = {
...current,
state: nextState,
revision: current.revision + 1,
updatedAt: Date.now(),
config: patch.config ? ({ ...current.config, ...patch.config } as C) : current.config,
}
this.bindings.set(moduleId, next)
return next
}
/**
* Physically removes a withdrawn-or-obsolete binding record from the registry.
*
* Use when:
* - Stopping or reloading a plugin session after lifecycle cleanup
* - The host wants to forget a binding entirely, not merely mark it withdrawn
*
* Expects:
* - The caller owns the binding being removed
* - Callers normally withdraw first, then unbind during teardown
*
* Returns:
* - The removed binding record, or `undefined` when nothing existed
*/
unbind(ownerSessionId: string, ownerPluginId: string, moduleId: string) {
const current = this.bindings.get(moduleId)
if (!current) {
return undefined
}
if (
current.ownerSessionId !== ownerSessionId
|| current.ownerPluginId !== ownerPluginId
) {
throw createOwnershipError(
moduleId,
{
ownerSessionId: current.ownerSessionId,
ownerPluginId: current.ownerPluginId,
},
{
ownerSessionId,
ownerPluginId,
},
)
}
this.bindings.delete(moduleId)
return current
}
}
@@ -1,5 +1,18 @@
import type { CapabilityDescriptor } from '../../../../plugin/apis/protocol'
/**
* Tracks capability lifecycle state and waits for readiness across plugin sessions.
*
* Use when:
* - The host needs to announce, ready, degrade, or withdraw named capabilities
* - Plugins need to wait for another capability before continuing startup
*
* Expects:
* - Capability keys are stable across lifecycle transitions
*
* Returns:
* - An in-memory dependency registry with snapshot and wait primitives
*/
export class DependencyService {
private readonly capabilities = new Map<string, CapabilityDescriptor>()
private readonly capabilityWaiters = new Map<string, Set<(descriptor: CapabilityDescriptor) => void>>()
@@ -1,4 +1,7 @@
export * from './bindings'
export * from './dependencies'
export * from './kits'
export * from './permissions'
export * from './resources'
export * from './sessions'
export * from './tools'
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest'
import { KitRegistryService } from './kits'
describe('kitRegistryService', () => {
it('registers kits and resolves compatible kits by runtime', () => {
const service = new KitRegistryService()
const widgetKit = service.register({
kitId: 'kit.widget',
version: '1.0.0',
capabilities: [
{ key: 'kit.widget.module', actions: ['announce', 'activate'] },
],
runtimes: ['electron', 'web'],
})
service.register({
kitId: 'kit.system',
version: '1.0.0',
capabilities: [{ key: 'kit.system.channel', actions: ['publish'] }],
runtimes: ['node'],
})
expect(widgetKit.kitId).toBe('kit.widget')
expect(service.get('kit.widget')).toBe(widgetKit)
expect(service.list()).toHaveLength(2)
expect(service.listByRuntime('web')).toEqual([widgetKit])
})
it('rejects conflicting duplicate kit registration', () => {
const service = new KitRegistryService()
service.register({
kitId: 'kit.widget',
version: '1.0.0',
capabilities: [{ key: 'kit.widget.module', actions: ['announce'] }],
runtimes: ['electron'],
})
expect(() =>
service.register({
kitId: 'kit.widget',
version: '1.0.1',
capabilities: [{ key: 'kit.widget.module', actions: ['announce', 'activate'] }],
runtimes: ['electron', 'web'],
}),
).toThrowError(/duplicate kit registration/i)
})
it('accepts semantically equivalent duplicate kit registration with reordered arrays', () => {
const service = new KitRegistryService()
const original = service.register({
kitId: 'kit.widget',
version: '1.0.0',
capabilities: [
{ key: 'kit.widget.module', actions: ['announce', 'activate'] },
{ key: 'kit.widget.panel', actions: ['withdraw'] },
],
runtimes: ['electron', 'web'],
})
const duplicate = service.register({
kitId: 'kit.widget',
version: '1.0.0',
capabilities: [
{ key: 'kit.widget.panel', actions: ['withdraw'] },
{ key: 'kit.widget.module', actions: ['activate', 'announce'] },
],
runtimes: ['web', 'electron'],
})
expect(duplicate).toBe(original)
})
})
@@ -0,0 +1,81 @@
import type { KitDescriptor } from '../../../shared/kits'
import type { PluginRuntime } from '../../../shared/types'
function normalizeKitDescriptor(kit: KitDescriptor) {
return {
kitId: kit.kitId,
version: kit.version,
runtimes: [...new Set(kit.runtimes)].sort(),
capabilities: kit.capabilities
.map(capability => ({
key: capability.key,
actions: [...new Set(capability.actions)].sort(),
}))
.sort((left, right) => left.key.localeCompare(right.key)),
}
}
function isSemanticallyEqualKitDescriptor(left: KitDescriptor, right: KitDescriptor) {
return JSON.stringify(normalizeKitDescriptor(left)) === JSON.stringify(normalizeKitDescriptor(right))
}
function createKitCollisionError(kitId: string) {
return new Error(`Duplicate kit registration for \`${kitId}\` conflicts with an existing descriptor.`)
}
/**
* Stores host-registered kit descriptors and exposes runtime-filtered lookups.
*
* Use when:
* - The host needs to register, read, and remove kit contracts
* - Plugin-facing kit APIs need runtime-compatible descriptor snapshots
*
* Expects:
* - `kitId` is unique unless the descriptor is semantically identical
*
* Returns:
* - An in-memory kit registry with duplicate collision detection
*/
export class KitRegistryService<TKit extends KitDescriptor = KitDescriptor> {
private readonly kits = new Map<string, TKit>()
register(kit: TKit) {
const current = this.kits.get(kit.kitId)
if (!current) {
this.kits.set(kit.kitId, kit)
return kit
}
if (!isSemanticallyEqualKitDescriptor(current, kit)) {
throw createKitCollisionError(kit.kitId)
}
return current
}
get(kitId: string) {
return this.kits.get(kitId)
}
has(kitId: string) {
return this.kits.has(kitId)
}
remove(kitId: string) {
const kit = this.kits.get(kitId)
if (!kit) {
return undefined
}
this.kits.delete(kitId)
return kit
}
list() {
return [...this.kits.values()]
}
listByRuntime(runtime: PluginRuntime) {
return this.list().filter(kit => kit.runtimes.includes(runtime))
}
}
@@ -252,6 +252,20 @@ function mergePermissionDeclarations(
}
}
/**
* Tracks requested and granted permissions for plugin sessions.
*
* Use when:
* - The host needs to initialize permission state for a session
* - Runtime-declared permissions must be merged with persisted or host-granted scopes
* - Callers need to check whether one action is allowed for one scope
*
* Expects:
* - Permission declarations use the protocol key and action model
*
* Returns:
* - An in-memory permission store with initialize, merge, and query helpers
*/
export class PermissionService {
private readonly store = new Map<string, PermissionSnapshot>()
@@ -1,3 +1,15 @@
/**
* Resolves one resource value lazily on demand.
*
* Use when:
* - A resource should be computed or fetched only when requested
*
* Expects:
* - The resolver returns the same logical resource shape each time
*
* Returns:
* - The resolved resource value, synchronously or asynchronously
*/
export type ResourceResolver<T> = () => Promise<T> | T
/**
@@ -14,6 +14,19 @@ function createModuleIdentity(name: string, index: number): ModuleIdentity {
}
}
/**
* Stores plugin sessions and generates deterministic session identities.
*
* Use when:
* - The host needs to track loaded plugin sessions by id
* - New plugin sessions need a generated session id and module identity
*
* Expects:
* - `TSession` has a stable `id` field used as the registry key
*
* Returns:
* - An in-memory session registry with identity generation helpers
*/
export class PluginSessionService<TSession extends { id: string }> {
private readonly sessions = new Map<string, TSession>()
private sessionCounter = 0
@@ -0,0 +1,99 @@
import type {
PluginToolDefinitionRecord,
RegisteredPluginToolDescriptor,
SerializedXsaiToolDefinition,
} from '../../../shared'
/**
* Stores one plugin tool registration inside the in-memory host runtime.
*
* Use when:
* - Tracking tool ownership and availability per plugin session
*
* Expects:
* - `ownerPluginId` and `tool.id` together are unique
*
* Returns:
* - A host-managed record used for listing and invocation
*/
export interface ToolRegistryRecord {
ownerSessionId: string
ownerPluginId: string
tool: PluginToolDefinitionRecord
availability?: () => Promise<boolean> | boolean
execute: (input: unknown) => Promise<unknown> | unknown
}
/**
* In-memory registry for plugin-contributed tools.
*
* Use when:
* - The host needs to list plugin tools for UI and xsai consumers
* - The host needs to dispatch a tool invocation back to its owning plugin
*
* Expects:
* - Callers filter by ownership through `ownerPluginId`
*
* Returns:
* - Serialisable metadata views and invoke routing
*/
export class ToolRegistryService {
private readonly tools = new Map<string, ToolRegistryRecord>()
register(record: ToolRegistryRecord) {
const key = `${record.ownerPluginId}:${record.tool.id}`
this.tools.set(key, record)
return record
}
async listAvailableDescriptors() {
const items: RegisteredPluginToolDescriptor[] = []
for (const record of this.tools.values()) {
if (await record.availability?.() === false) {
continue
}
items.push({
id: record.tool.id,
title: record.tool.title,
description: record.tool.description,
activation: {
keywords: [...record.tool.activation.keywords],
patterns: [...record.tool.activation.patterns],
},
})
}
return items
}
async listSerializedXsaiTools() {
const items: SerializedXsaiToolDefinition[] = []
for (const record of this.tools.values()) {
if (await record.availability?.() === false) {
continue
}
items.push({
ownerPluginId: record.ownerPluginId,
name: record.tool.id,
description: record.tool.description,
parameters: structuredClone(record.tool.parameters),
})
}
return items
}
async invoke(ownerPluginId: string, toolId: string, input: unknown) {
const key = `${ownerPluginId}:${toolId}`
const record = this.tools.get(key)
if (!record) {
throw new Error(`Plugin tool not found: ${key}`)
}
return await record.execute(input)
}
}
@@ -8,6 +8,18 @@ export * from '../../core'
export * from '../../shared'
export * from '../../transports'
/**
* Creates the Eventa context used by web-side plugin host sessions.
*
* Use when:
* - Bootstrapping a web runtime plugin session
*
* Expects:
* - `transport` describes a transport supported by the web runtime
*
* Returns:
* - A web-compatible Eventa context, or throws if the transport is not implemented
*/
export function createPluginContext(transport: PluginTransport): EventContext<any, any> {
switch (transport.kind) {
case 'in-memory':
@@ -0,0 +1,84 @@
import { parse } from 'valibot'
import { describe, expect, it } from 'vitest'
import { bindingRecordSchema } from './bindings'
describe('bindingRecordSchema', () => {
it('accepts generic host-level module record without business coupling', () => {
const parsed = parse(bindingRecordSchema, {
moduleId: 'board-main',
ownerSessionId: 'plugin-session-1',
ownerPluginId: 'demo-plugin',
kitId: 'kit.widget',
kitModuleType: 'panel',
state: 'announced',
runtime: 'electron',
revision: 1,
updatedAt: Date.now(),
config: { mountPoint: 'widgets' },
})
expect(parsed.kitModuleType).toBe('panel')
expect(parsed.state).toBe('announced')
})
it('rejects an unsupported module state', () => {
expect(() =>
parse(bindingRecordSchema, {
moduleId: 'board-main',
ownerSessionId: 'plugin-session-1',
ownerPluginId: 'demo-plugin',
kitId: 'kit.widget',
kitModuleType: 'panel',
state: 'booting',
runtime: 'electron',
revision: 1,
updatedAt: 1712500000000,
config: { mountPoint: 'widgets' },
}),
).toThrowError()
})
it('rejects a negative revision', () => {
expect(() =>
parse(bindingRecordSchema, {
moduleId: 'board-main',
ownerSessionId: 'plugin-session-1',
ownerPluginId: 'demo-plugin',
kitId: 'kit.widget',
kitModuleType: 'panel',
state: 'announced',
runtime: 'electron',
revision: -1,
updatedAt: 1712500000000,
config: { mountPoint: 'widgets' },
}),
).toThrowError()
})
it('rejects transport-unsafe module config values', () => {
class ConfigShape {
public mountPoint = 'widgets'
}
expect(() =>
parse(bindingRecordSchema, {
moduleId: 'board-main',
ownerSessionId: 'plugin-session-1',
ownerPluginId: 'demo-plugin',
kitId: 'kit.widget',
kitModuleType: 'panel',
state: 'announced',
runtime: 'electron',
revision: 1,
updatedAt: 1712500000000,
config: {
mountPoint: new ConfigShape(),
callback: () => undefined,
symbol: Symbol('nope'),
big: 1n,
},
}),
).toThrowError()
})
})
@@ -0,0 +1,100 @@
import type { InferOutput } from 'valibot'
import type { HostDataRecord } from './types'
import { object, picklist, string } from 'valibot'
import { hostDataRecordSchema, nonNegativeIntegerSchema, pluginRuntimeValues } from './types'
/**
* Lists the valid lifecycle states for one host-managed binding record.
*
* Use when:
* - Validating binding state values
* - Narrowing `BindingState` to the canonical lifecycle literals
*
* Expects:
* - State transitions follow the host binding lifecycle rules
*
* Returns:
* - The canonical ordered list of binding lifecycle values
*/
export const bindingStateValues = ['announced', 'active', 'degraded', 'withdrawn'] as const
/**
* Validates the serializable shape of one binding record.
*
* Use when:
* - Parsing or validating host-owned binding registry snapshots
*
* Expects:
* - `config` is JSON-like host data and timestamps are non-negative integers
*
* Returns:
* - A Valibot schema for one binding record
*/
export const bindingRecordSchema = object({
moduleId: string(),
ownerSessionId: string(),
ownerPluginId: string(),
kitId: string(),
kitModuleType: string(),
state: picklist(bindingStateValues),
runtime: picklist(pluginRuntimeValues),
revision: nonNegativeIntegerSchema,
updatedAt: nonNegativeIntegerSchema,
config: hostDataRecordSchema,
})
/**
* Describes one valid binding lifecycle state.
*
* Use when:
* - Typing host-owned binding records
*
* Expects:
* - Values come from {@link bindingStateValues}
*
* Returns:
* - The union of valid binding state literals
*/
export type BindingState = typeof bindingStateValues[number]
/**
* Describes one host-managed binding record.
*
* Use when:
* - Reading binding registry state from the host
* - Returning binding snapshots through plugin APIs
*
* Expects:
* - `moduleId` is unique within the registry
* - `kitId` and `kitModuleType` identify the higher-level contract being bound
*
* Returns:
* - A serializable binding snapshot including lifecycle metadata and config
*/
export interface BindingRecord<C extends HostDataRecord = HostDataRecord> {
moduleId: string
ownerSessionId: string
ownerPluginId: string
kitId: string
kitModuleType: string
state: BindingState
runtime: (typeof pluginRuntimeValues)[number]
revision: number
updatedAt: number
config: C
}
/**
* Describes the validated output shape of {@link bindingRecordSchema}.
*
* Use when:
* - You need the exact schema-derived output type instead of the generic interface
*
* Expects:
* - Values have already passed through {@link bindingRecordSchema}
*
* Returns:
* - The inferred Valibot output type for one binding record
*/
export type BindingRecordOutput = InferOutput<typeof bindingRecordSchema>
@@ -1 +1,4 @@
export * from './bindings'
export * from './kits'
export * from './tools'
export * from './types'
@@ -0,0 +1,34 @@
import { parse } from 'valibot'
import { describe, expect, it } from 'vitest'
import { kitDescriptorSchema } from './kits'
describe('kitDescriptorSchema', () => {
it('accepts a generic host-level kit descriptor without business coupling', () => {
const parsed = parse(kitDescriptorSchema, {
kitId: 'kit.widget',
version: '1.0.0',
capabilities: [
{
key: 'kit.widget.module',
actions: ['announce', 'activate', 'update', 'withdraw'],
},
],
runtimes: ['electron', 'web'],
})
expect(parsed.kitId).toBe('kit.widget')
expect(parsed.runtimes).toContain('electron')
})
it('rejects an unsupported runtime', () => {
expect(() =>
parse(kitDescriptorSchema, {
kitId: 'kit.widget',
version: '1.0.0',
capabilities: [],
runtimes: ['browser'],
}),
).toThrowError()
})
})
@@ -0,0 +1,92 @@
import type { InferOutput } from 'valibot'
import { array, description, object, pipe, string } from 'valibot'
import { pluginRuntimeSchema } from './types'
/**
* Validates one declared capability inside a kit descriptor.
*
* Use when:
* - Parsing or validating host-owned kit descriptors
*
* Expects:
* - `key` is stable and `actions` lists the allowed capability actions
*
* Returns:
* - A Valibot schema for one kit capability descriptor
*/
export const kitCapabilitySchema = object({
key: pipe(
string(),
description('Stable capability key exposed by this kit.'),
),
actions: pipe(
array(pipe(
string(),
description('Capability action supported by this kit capability entry.'),
)),
description('Allowed actions for this capability key.'),
),
})
/**
* Validates one host-owned kit descriptor.
*
* Use when:
* - Parsing or validating kit registry snapshots
*
* Expects:
* - `capabilities` and `runtimes` describe where and how the kit can be used
*
* Returns:
* - A Valibot schema for one kit descriptor
*/
export const kitDescriptorSchema = object({
kitId: pipe(
string(),
description('Stable identifier for the host-registered kit.'),
),
version: pipe(
string(),
description('Semantic version of the kit contract.'),
),
capabilities: pipe(
array(kitCapabilitySchema),
description('Capabilities exposed by this kit descriptor.'),
),
runtimes: pipe(
array(pipe(
pluginRuntimeSchema,
description('Runtime supported by this kit descriptor.'),
)),
description('Runtimes where this kit can be used.'),
),
})
/**
* Describes one capability declared by a host kit.
*
* Use when:
* - Reading kit metadata from the registry or plugin APIs
*
* Expects:
* - Values have already been validated by {@link kitCapabilitySchema}
*
* Returns:
* - The inferred kit capability descriptor type
*/
export type KitCapabilityDescriptor = InferOutput<typeof kitCapabilitySchema>
/**
* Describes one host-registered kit contract.
*
* Use when:
* - Reading kit metadata from the registry or plugin APIs
*
* Expects:
* - Values have already been validated by {@link kitDescriptorSchema}
*
* Returns:
* - The inferred kit descriptor type
*/
export type KitDescriptor = InferOutput<typeof kitDescriptorSchema>
@@ -0,0 +1,67 @@
import type { HostDataRecord } from './types'
/**
* Describes the user-facing metadata for a plugin-contributed tool.
*
* Use when:
* - Listing plugin tools in renderer or devtools surfaces
* - Exposing activation hints without the execution handler
*
* Expects:
* - `id` is stable and unique within the owning plugin
*
* Returns:
* - A serializable descriptor suitable for host and renderer registries
*/
export interface RegisteredPluginToolDescriptor {
id: string
title: string
description: string
activation: {
keywords: string[]
patterns: string[]
}
}
/**
* Describes the JSON-schema side of an xsai-compatible tool.
*
* Use when:
* - Serializing plugin tools across Electron boundaries
* - Reconstructing proxy `rawTool(...)` instances in the renderer
*
* Expects:
* - `parameters` is a provider-safe JSON Schema object
*
* Returns:
* - A serializable tool contract without executable callbacks
*/
export interface SerializedXsaiToolDefinition {
ownerPluginId: string
name: string
description: string
parameters: HostDataRecord
}
/**
* Captures the single source-of-truth definition submitted by a plugin.
*
* Use when:
* - Registering tools from plugin runtimes into the host
*
* Expects:
* - `parameters` already contains a serialized input schema
*
* Returns:
* - A host-owned record that can be derived into UI metadata and xsai schemas
*/
export interface PluginToolDefinitionRecord {
id: string
title: string
description: string
activation: {
keywords: string[]
patterns: string[]
}
parameters: HostDataRecord
}
@@ -10,23 +10,209 @@ import type {
import type { PluginTransport } from '../transports'
import { isPlainObject } from 'es-toolkit'
import {
array,
boolean,
check,
finite,
lazy,
literal,
minValue,
number,
object,
optional,
picklist,
pipe,
record,
safeInteger,
string,
union,
} from 'valibot'
export type PluginRuntime = 'electron' | 'node' | 'web'
/**
* Lists the supported plugin runtimes recognized by the host.
*
* Use when:
* - Validating manifest entrypoints or host runtime configuration
* - Narrowing `PluginRuntime` to the canonical literals
*
* Expects:
* - Runtime-specific code branches use one of these exact values
*
* Returns:
* - The canonical runtime literals used throughout plugin-sdk
*/
export const pluginRuntimeValues = ['electron', 'node', 'web'] as const
/**
* Describes one supported plugin runtime.
*
* Use when:
* - Typing host runtime configuration and manifest runtime selection
*
* Expects:
* - Values come from {@link pluginRuntimeValues}
*
* Returns:
* - The union of valid runtime literals
*/
export type PluginRuntime = typeof pluginRuntimeValues[number]
/**
* Validates one runtime literal from {@link pluginRuntimeValues}.
*
* Use when:
* - Parsing runtime values from host options or descriptors
*
* Expects:
* - Inputs are runtime strings such as `electron`, `node`, or `web`
*
* Returns:
* - A Valibot schema for one plugin runtime literal
*/
export const pluginRuntimeSchema = picklist(pluginRuntimeValues)
/**
* Describes a JSON-like array accepted by plugin-host shared data schemas.
*
* Use when:
* - Typing serializable arrays inside binding config, resource payloads, or tool schemas
*
* Expects:
* - Every element is a {@link HostDataValue}
*
* Returns:
* - A recursive array interface for host-safe data
*/
export interface HostDataArray extends Array<HostDataValue> {}
/**
* Describes a JSON-like object accepted by plugin-host shared data schemas.
*
* Use when:
* - Typing serializable records inside binding config, resource payloads, or tool schemas
*
* Expects:
* - Every property value is a {@link HostDataValue}
*
* Returns:
* - A recursive record interface for host-safe data
*/
export interface HostDataRecord {
[key: string]: HostDataValue
}
/**
* Describes the recursive JSON-like value model accepted by the host.
*
* Use when:
* - Typing payloads that must stay serializable across plugin boundaries
*
* Expects:
* - Values are limited to primitives, arrays, or plain-object records
*
* Returns:
* - The recursive union used across shared host data structures
*/
export type HostDataValue
= | null
| string
| number
| boolean
| HostDataArray
| HostDataRecord
/**
* Creates the recursive Valibot schema used for one {@link HostDataValue}.
*
* Use when:
* - You need a fresh recursive schema instance for nested host data validation
*
* Expects:
* - Values are plain JSON-like data and not class instances
*
* Returns:
* - A Valibot schema covering the full `HostDataValue` recursion
*/
export function createHostDataValueSchema() {
return union([
literal(null),
string(),
boolean(),
pipe(number(), finite()),
array(lazy(createHostDataValueSchema)),
pipe(record(string(), lazy(createHostDataValueSchema)), check(isPlainObject)),
])
}
/**
* Validates one recursive host-safe value.
*
* Use when:
* - Parsing individual payload values shared across the host boundary
*
* Expects:
* - Inputs conform to the {@link HostDataValue} model
*
* Returns:
* - A Valibot schema instance for one host-safe value
*/
export const hostDataValueSchema = createHostDataValueSchema()
/**
* Validates one plain-object host-safe record.
*
* Use when:
* - Parsing config objects, metadata records, and JSON-schema-like payloads
*
* Expects:
* - Inputs are plain objects with {@link HostDataValue} values
*
* Returns:
* - A Valibot schema for one host-safe record
*/
export const hostDataRecordSchema = pipe(record(string(), lazy(createHostDataValueSchema)), check(isPlainObject))
/**
* Validates one non-negative safe integer used for timestamps and revisions.
*
* Use when:
* - Parsing revision counters and host-generated timestamps
*
* Expects:
* - Inputs are safe integers greater than or equal to zero
*
* Returns:
* - A Valibot schema for non-negative safe integers
*/
export const nonNegativeIntegerSchema = pipe(number(), safeInteger(), minValue(0))
/**
* Re-exports the protocol module phase literals used by the host.
*
* Use when:
* - Typing module lifecycle phases shared with `@proj-airi/plugin-protocol`
*
* Expects:
* - Values follow the protocol package lifecycle model
*
* Returns:
* - The protocol-defined module phase union
*/
export type ModulePhase = ProtocolModulePhase
/**
* Describes all phases a plugin session can occupy inside `PluginHost`.
*
* Use when:
* - Typing `PluginHostSession.phase`
* - Checking host lifecycle transitions
*
* Expects:
* - Protocol phases are extended with host-only bootstrap and shutdown phases
*
* Returns:
* - The full plugin-session lifecycle union
*/
export type PluginSessionPhase
= | 'loading'
| 'loaded'
@@ -36,29 +222,135 @@ export type PluginSessionPhase
| ModulePhase
| 'stopped'
/**
* Re-exports the protocol plugin identity model used by the host.
*
* Use when:
* - Typing per-plugin identity values stored on sessions and events
*
* Expects:
* - Values originate from the protocol identity generator or host session service
*
* Returns:
* - The protocol-defined plugin identity type
*/
export type PluginIdentity = ProtocolPluginIdentity
/**
* Re-exports the protocol module identity model used by the host.
*
* Use when:
* - Typing plugin session identities and protocol event payloads
*
* Expects:
* - Values originate from the protocol identity generator or host session service
*
* Returns:
* - The protocol-defined module identity type
*/
export type ModuleIdentity = ProtocolModuleIdentity
/**
* Re-exports the protocol configuration envelope used for plugin configuration state.
*
* Use when:
* - Typing configuration payloads stored or emitted by the host
*
* Expects:
* - `C` describes the full configuration object carried in the envelope
*
* Returns:
* - The protocol-defined configuration envelope type
*/
export type ModuleConfigEnvelope<C = Record<string, unknown>> = ProtocolModuleConfigEnvelope<C>
/**
* Re-exports the protocol compatibility request payload type.
*
* Use when:
* - Typing compatibility negotiation messages in the host
*
* Expects:
* - Values conform to the protocol event payload
*
* Returns:
* - The protocol-defined compatibility request type
*/
export type ModuleCompatibilityRequest = ProtocolEvents['module:compatibility:request']
/**
* Re-exports the protocol compatibility result payload type.
*
* Use when:
* - Typing compatibility negotiation responses in the host
*
* Expects:
* - Values conform to the protocol event payload
*
* Returns:
* - The protocol-defined compatibility result type
*/
export type ModuleCompatibilityResult = ProtocolEvents['module:compatibility:result']
/**
* Re-exports the protocol permission declaration model used by manifests and runtime permission flow.
*
* Use when:
* - Typing requested permissions in plugin manifests and host sessions
*
* Expects:
* - Values conform to the protocol permission declaration model
*
* Returns:
* - The protocol-defined permission declaration type
*/
export type ModulePermissionDeclaration = ProtocolModulePermissionDeclaration
/**
* Re-exports the protocol permission grant model used by host policy resolution.
*
* Use when:
* - Typing granted or persisted permissions in the host
*
* Expects:
* - Values conform to the protocol permission grant model
*
* Returns:
* - The protocol-defined permission grant type
*/
export type ModulePermissionGrant = ProtocolModulePermissionGrant
/**
* Describes a version-1 plugin manifest consumed by `PluginHost`.
*
* Use when:
* - Loading a plugin from disk or another runtime
* - Typing manifest values in tests and host options
*
* Expects:
* - `kind` and `apiVersion` match the current manifest format
*
* Returns:
* - The structured plugin manifest contract understood by the host
*/
export interface ManifestV1 {
/** Manifest schema version expected by the current host implementation. */
apiVersion: 'v1'
/** Manifest kind discriminator used to identify AIRI plugin manifests. */
kind: 'manifest.plugin.airi.moeru.ai'
/** Stable plugin name used for identity generation and display. */
name: string
/** Requested permissions that the host will evaluate and grant. */
permissions: ModulePermissionDeclaration
/** Runtime-specific module entrypoints that the host can resolve and import. */
entrypoints: {
/** Fallback entrypoint used when no runtime-specific path is provided. */
default?: string
/** Electron-specific entrypoint path. */
electron?: string
/** Node-specific entrypoint path. */
node?: string
/** Web-specific entrypoint path. */
web?: string
}
}
@@ -72,6 +364,18 @@ const localizableSchema = union([
}),
])
/**
* Validates a version-1 plugin manifest.
*
* Use when:
* - Parsing plugin manifests before loading them into the host
*
* Expects:
* - Inputs follow the `ManifestV1` shape including permission declarations and entrypoints
*
* Returns:
* - A Valibot schema for the AIRI plugin manifest format
*/
export const manifestV1Schema = object({
apiVersion: literal('v1'),
kind: literal('manifest.plugin.airi.moeru.ai'),
@@ -121,18 +425,51 @@ export const manifestV1Schema = object({
}),
})
/**
* Configures how the host resolves and loads a plugin entrypoint.
*
* Use when:
* - Calling `PluginHost.load(...)` or loader helpers directly
*
* Expects:
* - Omitted fields fall back to host defaults
*
* Returns:
* - Runtime and working-directory overrides for one load operation
*/
export interface PluginLoadOptions {
/** Working directory used to resolve relative manifest entrypoints. */
cwd?: string
/** Runtime used when selecting a manifest entrypoint. */
runtime?: PluginRuntime
}
/**
* Configures one `PluginHost` instance.
*
* Use when:
* - Constructing a host with specific runtime, transport, or permission behavior
*
* Expects:
* - Omitted fields fall back to the host defaults documented below
*
* Returns:
* - The host bootstrap options consumed by {@link import('../core').PluginHost}
*/
export interface PluginHostOptions {
/** Runtime used when callers do not override it per load/start call. @default 'electron' */
runtime?: PluginRuntime
/** Transport used when callers do not override it per load/start call. @default { kind: 'in-memory' } */
transport?: PluginTransport
/** Protocol version advertised during compatibility negotiation. @default 'v1' */
protocolVersion?: string
/** Plugin SDK API version advertised during compatibility negotiation. @default 'v1' */
apiVersion?: string
/** Additional protocol versions the host is willing to negotiate. @default [] */
supportedProtocolVersions?: string[]
/** Additional API versions the host is willing to negotiate. @default [] */
supportedApiVersions?: string[]
/** Callback that decides the granted permission set for one plugin session. */
permissionResolver?: (payload: {
identity: ModuleIdentity
manifest: ManifestV1
@@ -141,11 +478,29 @@ export interface PluginHostOptions {
}) => ModulePermissionGrant | Promise<ModulePermissionGrant>
}
/**
* Configures one `PluginHost.start(...)` or `PluginHost.init(...)` call.
*
* Use when:
* - Starting a session with runtime, compatibility, or capability-wait overrides
*
* Expects:
* - Omitted fields fall back to host defaults or method-local defaults
*
* Returns:
* - Per-start overrides for initialization behavior
*/
export interface PluginStartOptions {
/** Working directory used to resolve relative manifest entrypoints. */
cwd?: string
/** Runtime override used for this specific start operation. */
runtime?: PluginRuntime
/** Whether initialization should stop in configuration-needed instead of auto-readying. */
requireConfiguration?: boolean
/** Compatibility ranges sent during protocol negotiation. */
compatibility?: Omit<ModuleCompatibilityRequest, 'protocolVersion' | 'apiVersion'>
/** Capability keys that must become ready before the session can proceed. */
requiredCapabilities?: string[]
/** Wait timeout applied to each required capability. @default 15000 */
capabilityWaitTimeoutMs?: number
}
@@ -0,0 +1,42 @@
import type { ContextInit } from '../../plugin/shared'
/**
* Exercises host-injected plugin APIs during initialization.
*
* Use when:
* - Verifying that a plugin can consume injected kit and binding APIs
* - Testing end-to-end plugin host bindings from a real plugin entrypoint
*
* Expects:
* - The host exposes `kit.widget` to the plugin runtime
* - The manifest grants the plugin read and write permissions for the relevant resources
*
* Returns:
* - Resolves after persisting the observed host state into a dynamic module config
*/
export async function init({ apis }: ContextInit): Promise<void> {
const kits = await apis.kits.list()
const widgetCapabilities = await apis.kits.getCapabilities('kit.widget')
await apis.bindings.announce({
moduleId: 'test-injected-host-apis-module',
kitId: 'kit.widget',
kitModuleType: 'window',
config: {
route: '/widgets/injected-host-apis',
},
})
await apis.bindings.activate({
moduleId: 'test-injected-host-apis-module',
})
await apis.bindings.update({
moduleId: 'test-injected-host-apis-module',
config: {
route: '/widgets/injected-host-apis',
observedKitIds: kits.map(kit => kit.kitId),
observedCapabilityKeys: widgetCapabilities.map(capability => capability.key).sort(),
},
})
}
@@ -1,3 +1,16 @@
/**
* Describes the transport selected for one plugin host session.
*
* Use when:
* - Creating a plugin context for a specific runtime
* - Configuring how a plugin communicates with the host
*
* Expects:
* - `kind` matches the runtime-specific adapter chosen by the caller
*
* Returns:
* - A discriminated union describing the active transport and its required handles
*/
export type PluginTransport
= | { kind: 'in-memory' }
| { kind: 'websocket', url: string, protocols?: string[] }
@@ -0,0 +1,250 @@
import type { EventContext } from '@moeru/eventa'
import type { BindingUpdatePatch } from '../../../../plugin-host/runtimes/shared'
import type { BindingRecord } from '../../../../plugin-host/shared'
import type { HostDataRecord } from '../../../../plugin-host/shared/types'
/**
* Identifies the bound API call used to list host-managed bindings.
*
* Use when:
* - Declaring permissions for `apis.bindings.list()`
*
* Expects:
* - Host and plugin agree on this event name
*
* Returns:
* - The permission/event key string for listing bindings
*/
export const pluginBindingApiListEventName = 'proj-airi:plugin-sdk:apis:client:bindings:list'
/**
* Identifies the bound API call used to create a new binding record.
*
* Use when:
* - Declaring permissions for `apis.bindings.announce()`
*
* Expects:
* - Host and plugin agree on this event name
*
* Returns:
* - The permission/event key string for announcing bindings
*/
export const pluginBindingApiAnnounceEventName = 'proj-airi:plugin-sdk:apis:client:bindings:announce'
/**
* Identifies the bound API call used to activate an existing binding.
*
* Use when:
* - Declaring permissions for `apis.bindings.activate()`
*
* Expects:
* - Host and plugin agree on this event name
*
* Returns:
* - The permission/event key string for activating bindings
*/
export const pluginBindingApiActivateEventName = 'proj-airi:plugin-sdk:apis:client:bindings:activate'
/**
* Identifies the bound API call used to update an existing binding.
*
* Use when:
* - Declaring permissions for `apis.bindings.update()`
*
* Expects:
* - Host and plugin agree on this event name
*
* Returns:
* - The permission/event key string for updating bindings
*/
export const pluginBindingApiUpdateEventName = 'proj-airi:plugin-sdk:apis:client:bindings:update'
/**
* Identifies the bound API call used to withdraw an existing binding.
*
* Use when:
* - Declaring permissions for `apis.bindings.withdraw()`
*
* Expects:
* - Host and plugin agree on this event name
*
* Returns:
* - The permission/event key string for withdrawing bindings
*/
export const pluginBindingApiWithdrawEventName = 'proj-airi:plugin-sdk:apis:client:bindings:withdraw'
/**
* Identifies the shared resource namespace that exposes the binding registry.
*
* Use when:
* - Declaring read permissions for `apis.bindings.list()`
*
* Expects:
* - The host stores binding records under this resource key
*
* Returns:
* - The resource key string for the global bindings registry
*/
export const pluginBindingRegistryResourceKey = 'proj-airi:plugin-sdk:resources:bindings'
/**
* Builds the kit-scoped resource key used for binding write access.
*
* Use when:
* - Declaring per-kit binding permissions
*
* Expects:
* - `kitId` matches the host-registered kit identifier
*
* Returns:
* - The resource key string for bindings owned by the given kit
*/
export function getKitBindingResourceKey(kitId: string) {
return `proj-airi:plugin-sdk:resources:kits:${kitId}:bindings`
}
/**
* Describes the payload required to declare a new binding instance.
*
* Use when:
* - Calling `apis.bindings.announce(...)`
*
* Expects:
* - `moduleId` is unique within the host registry
* - `kitId` and `kitModuleType` identify the higher-level kit contract being bound
*
* Returns:
* - A serializable binding declaration payload
*/
export interface AnnounceBindingInput<C extends HostDataRecord = HostDataRecord> {
moduleId: string
kitId: string
kitModuleType: string
config: C
}
/**
* Identifies which binding should transition to the active state.
*
* Use when:
* - Calling `apis.bindings.activate(...)`
*
* Expects:
* - `moduleId` points at an existing host-managed binding
*
* Returns:
* - A minimal activation request payload
*/
export interface ActivateBindingInput {
moduleId: string
}
/**
* Describes a partial update to one binding record.
*
* Use when:
* - Calling `apis.bindings.update(...)`
*
* Expects:
* - `moduleId` identifies the existing binding being changed
* - Any provided patch fields are valid for the target binding
*
* Returns:
* - A serializable binding update payload
*/
export interface UpdateBindingInput<C extends HostDataRecord = HostDataRecord> extends BindingUpdatePatch<C> {
moduleId: string
}
/**
* Identifies which binding should transition to the withdrawn state.
*
* Use when:
* - Calling `apis.bindings.withdraw(...)`
*
* Expects:
* - `moduleId` points at an existing host-managed binding
*
* Returns:
* - A minimal withdrawal request payload
*/
export interface WithdrawBindingInput {
moduleId: string
}
/**
* Defines the host-side callbacks needed by the low-level bindings client.
*
* Use when:
* - Wiring `session.apis.bindings` to host-owned registry logic
*
* Expects:
* - Each callback returns the cloned binding record state observed by plugin code
*
* Returns:
* - The callback contract consumed by {@link createBindings}
*/
export interface BindingClientBindings<C extends HostDataRecord = HostDataRecord> {
list: () => Promise<BindingRecord<C>[]> | BindingRecord<C>[]
announce: (input: AnnounceBindingInput<C>) => Promise<BindingRecord<C>> | BindingRecord<C>
activate: (input: ActivateBindingInput) => Promise<BindingRecord<C>> | BindingRecord<C>
update: (input: UpdateBindingInput<C>) => Promise<BindingRecord<C>> | BindingRecord<C>
withdraw: (input: WithdrawBindingInput) => Promise<BindingRecord<C>> | BindingRecord<C>
}
function createMissingBindingError(method: string) {
return new Error(`Plugin binding API binding missing for \`${method}\`.`)
}
function requireBinding<TBinding>(binding: TBinding | undefined, method: string): TBinding {
if (!binding) {
throw createMissingBindingError(method)
}
return binding
}
/**
* Creates the low-level bindings client exposed on `session.apis`.
*
* Use when:
* - Building the plugin SDK API object for a specific session
*
* Expects:
* - `bindings` comes from a host that manages binding records
*
* Returns:
* - A minimal `bindings.*` client that forwards to the bound host callbacks
*/
export function createBindings<C extends HostDataRecord = HostDataRecord>(
_ctx: EventContext<any, any>,
bindings?: BindingClientBindings<C>,
) {
return {
async list() {
return await requireBinding(bindings, 'bindings.list').list()
},
async announce(input: AnnounceBindingInput<C>) {
return await requireBinding(bindings, 'bindings.announce').announce(input)
},
async activate(input: ActivateBindingInput) {
return await requireBinding(bindings, 'bindings.activate').activate(input)
},
async update(input: UpdateBindingInput<C>) {
return await requireBinding(bindings, 'bindings.update').update(input)
},
async withdraw(input: WithdrawBindingInput) {
return await requireBinding(bindings, 'bindings.withdraw').withdraw(input)
},
}
}
/**
* Describes the concrete client object returned by {@link createBindings}.
*
* Use when:
* - Typing `apis.bindings`
*
* Expects:
* - The caller uses the same method set as the runtime-created bindings client
*
* Returns:
* - The inferred bindings client surface
*/
export type BindingClient = ReturnType<typeof createBindings>
@@ -1,12 +1,68 @@
import type { EventContext } from '@moeru/eventa'
import { createProviders } from './resources'
import type { BindingClientBindings } from './bindings'
import type { KitClientBindings } from './kits'
import type { ToolClientBindings } from './tools'
export function createApis(ctx: EventContext<any, any>) {
import { createBindings } from './bindings'
import { createKits } from './kits'
import { createResources } from './resources'
import { createTools } from './tools'
/**
* Collects the host-provided callbacks that back the plugin client API surface.
*
* Use when:
* - Binding `session.apis` to host-owned implementations
*
* Expects:
* - Each optional binding group is supplied when that API family should be available
*
* Returns:
* - A map of callback groups consumed by {@link createApis}
*/
export interface PluginApiBindings {
kits?: KitClientBindings
bindings?: BindingClientBindings
tools?: ToolClientBindings
}
/**
* Creates the low-level plugin API surface exposed to plugin code.
*
* Use when:
* - Building `ContextInit.apis` for a plugin session
*
* Expects:
* - `ctx` is the Eventa context for the current plugin session
* - `bindings` contains the host-backed callbacks for each enabled API group
*
* Returns:
* - The composed plugin client APIs for resources, kits, bindings, and tools
*/
export function createApis(ctx: EventContext<any, any>, bindings: PluginApiBindings = {}) {
return {
providers: createProviders(ctx),
...createResources(ctx),
kits: createKits(ctx, bindings.kits),
bindings: createBindings(ctx, bindings.bindings),
tools: createTools(ctx, bindings.tools),
}
}
/**
* Describes the concrete API object returned by {@link createApis}.
*
* Use when:
* - Typing `ContextInit.apis`
*
* Expects:
* - The caller uses the same shape as the runtime-created API object
*
* Returns:
* - The inferred plugin API client surface
*/
export type PluginApis = ReturnType<typeof createApis>
export * from './bindings'
export * from './kits'
export * from './resources'
export * from './tools'
@@ -0,0 +1,113 @@
import type { EventContext } from '@moeru/eventa'
import type { KitCapabilityDescriptor, KitDescriptor } from '../../../../plugin-host/shared'
/**
* Identifies the bound API call used to list runtime-compatible kits.
*
* Use when:
* - Declaring permissions for `apis.kits.list()`
*
* Expects:
* - Host and plugin agree on this event name
*
* Returns:
* - The permission/event key string for listing kits
*/
export const pluginKitApiListEventName = 'proj-airi:plugin-sdk:apis:client:kits:list'
/**
* Identifies the bound API call used to read one kit's capability descriptors.
*
* Use when:
* - Declaring permissions for `apis.kits.getCapabilities()`
*
* Expects:
* - Host and plugin agree on this event name
*
* Returns:
* - The permission/event key string for reading kit capabilities
*/
export const pluginKitApiGetCapabilitiesEventName = 'proj-airi:plugin-sdk:apis:client:kits:get-capabilities'
/**
* Identifies the shared resource namespace that exposes host kit descriptors.
*
* Use when:
* - Declaring read permissions for kit discovery calls
*
* Expects:
* - The host stores kit descriptors under this resource key
*
* Returns:
* - The resource key string for the kit registry
*/
export const pluginKitRegistryResourceKey = 'proj-airi:plugin-sdk:resources:kits'
/**
* Defines the host-side callbacks needed by the low-level kit client.
*
* Use when:
* - Wiring `session.apis.kits` to host-owned kit registry logic
*
* Expects:
* - `list` returns runtime-filtered kit descriptors
* - `getCapabilities` returns only the capabilities for the requested kit
*
* Returns:
* - The callback contract consumed by {@link createKits}
*/
export interface KitClientBindings<TKit extends KitDescriptor = KitDescriptor> {
list: () => Promise<TKit[]> | TKit[]
getCapabilities: (kitId: string) => Promise<KitCapabilityDescriptor[]> | KitCapabilityDescriptor[]
}
function createMissingBindingError(method: string) {
return new Error(`Plugin kit API binding missing for \`${method}\`.`)
}
function requireBinding<TBinding>(binding: TBinding | undefined, method: string): TBinding {
if (!binding) {
throw createMissingBindingError(method)
}
return binding
}
/**
* Creates the low-level kit client exposed on `session.apis`.
*
* Use when:
* - Building the plugin SDK API object for a specific session
*
* Expects:
* - `bindings` comes from a host that manages kit descriptors
*
* Returns:
* - A minimal `kits.*` client that forwards to the bound host callbacks
*/
export function createKits<TKit extends KitDescriptor = KitDescriptor>(
_ctx: EventContext<any, any>,
bindings?: KitClientBindings<TKit>,
) {
return {
async list() {
return await requireBinding(bindings, 'kits.list').list()
},
async getCapabilities(kitId: string) {
return await requireBinding(bindings, 'kits.getCapabilities').getCapabilities(kitId)
},
}
}
/**
* Describes the concrete client object returned by {@link createKits}.
*
* Use when:
* - Typing `apis.kits`
*
* Expects:
* - The caller uses the same method set as the runtime-created kits client
*
* Returns:
* - The inferred kits client surface
*/
export type KitClient = ReturnType<typeof createKits>
@@ -1 +1,23 @@
import type { EventContext } from '@moeru/eventa'
import { createProviders } from './providers'
/**
* Creates the low-level resource API groups exposed on `session.apis`.
*
* Use when:
* - Building the plugin SDK API object for a specific session
*
* Expects:
* - `ctx` is the Eventa context for the current plugin session
*
* Returns:
* - The resource client groups currently supported by the SDK
*/
export function createResources(ctx: EventContext<any, any>) {
return {
providers: createProviders(ctx),
}
}
export { createProviders } from './providers'
@@ -5,6 +5,18 @@ import { defineInvoke } from '@moeru/eventa'
import { protocolCapabilityWait } from '../../../protocol/capabilities'
import { protocolListProviders, protocolListProvidersEventName } from '../../../protocol/resources/providers'
/**
* Creates the provider resource client used by plugins to query available providers.
*
* Use when:
* - A plugin needs to read the current provider list from the host
*
* Expects:
* - The host exposes the providers capability and corresponding list RPC
*
* Returns:
* - A client with `listProviders()` that waits for capability readiness before invoking
*/
export function createProviders(ctx: EventContext<any, any>) {
return {
async listProviders() {
@@ -0,0 +1,111 @@
import type { EventContext } from '@moeru/eventa'
import type { PluginToolDefinitionRecord } from '../../../../plugin-host/shared'
/**
* Identifies the bound API call used to register plugin tools.
*
* Use when:
* - Declaring permissions for `apis.tools.register()`
*
* Expects:
* - Host and plugin agree on this event name
*
* Returns:
* - The permission/event key string for tool registration
*/
export const pluginToolApiRegisterEventName = 'proj-airi:plugin-sdk:apis:client:tools:register'
/**
* Identifies the shared resource namespace that stores plugin tool records.
*
* Use when:
* - Declaring write permissions for tool registration
*
* Expects:
* - The host stores tool definitions under this resource key
*
* Returns:
* - The resource key string for the tool registry
*/
export const pluginToolRegistryResourceKey = 'proj-airi:plugin-sdk:resources:tools'
/**
* Carries a low-level plugin tool registration request into the host.
*
* Use when:
* - A plugin has already normalized its tool metadata and JSON Schema
*
* Expects:
* - `tool` is serializable and validated by the caller
* - `execute` accepts JSON-compatible input from the host
*
* Returns:
* - A registration payload consumed by the bound host implementation
*/
export interface RegisterToolInput {
tool: PluginToolDefinitionRecord
availability?: () => Promise<boolean> | boolean
execute: (input: unknown) => Promise<unknown> | unknown
}
/**
* Defines the host-side callbacks needed by the low-level plugin tool client.
*
* Use when:
* - Wiring plugin session APIs to host-owned registries
*
* Expects:
* - `register` stores or forwards the tool definition in the host
*
* Returns:
* - The bound client methods used by {@link createTools}
*/
export interface ToolClientBindings {
register: (input: RegisterToolInput) => Promise<void> | void
}
function createMissingBindingError(method: string) {
return new Error(`Plugin tool API binding missing for \`${method}\`.`)
}
function requireBinding<TBinding>(binding: TBinding | undefined, method: string): TBinding {
if (!binding) {
throw createMissingBindingError(method)
}
return binding
}
/**
* Creates the low-level plugin tool client surface exposed on `session.apis`.
*
* Use when:
* - Building the plugin SDK API object for a specific session
*
* Expects:
* - `bindings` comes from a host that knows how to store tool registrations
*
* Returns:
* - A minimal `tools.register(...)` client
*/
export function createTools(_ctx: EventContext<any, any>, bindings?: ToolClientBindings) {
return {
async register(input: RegisterToolInput) {
return await requireBinding(bindings, 'tools.register').register(input)
},
}
}
/**
* Describes the concrete client object returned by {@link createTools}.
*
* Use when:
* - Typing `apis.tools`
*
* Expects:
* - The caller uses the same method set as the runtime-created tools client
*
* Returns:
* - The inferred tools client surface
*/
export type ToolClient = ReturnType<typeof createTools>
@@ -1,5 +1,18 @@
import { defineInvokeEventa } from '@moeru/eventa'
/**
* Describes one capability snapshot exposed by the host dependency registry.
*
* Use when:
* - Reading capability readiness from `protocolCapabilityWait`
* - Inspecting the full capability snapshot from `protocolCapabilitySnapshot`
*
* Expects:
* - `key` is stable across the capability lifecycle
*
* Returns:
* - A serializable view of capability state and optional metadata
*/
export interface CapabilityDescriptor {
key: string
state: 'announced' | 'ready' | 'degraded' | 'withdrawn'
@@ -7,12 +20,60 @@ export interface CapabilityDescriptor {
updatedAt: number
}
/**
* Identifies the control-plane RPC used to wait for one capability.
*
* Use when:
* - Declaring permissions or invoking the wait RPC directly
*
* Expects:
* - Host and plugin agree on this event name
*
* Returns:
* - The permission/event key string for capability waiting
*/
export const protocolCapabilityWaitEventName = 'proj-airi:plugin-sdk:apis:protocol:capabilities:wait'
/**
* Defines the control-plane RPC that blocks until a capability becomes ready.
*
* Use when:
* - A plugin needs another host capability before continuing initialization
*
* Expects:
* - The host implements the matching invoke handler
*
* Returns:
* - A typed Eventa invoke descriptor for waiting on one capability
*/
export const protocolCapabilityWait = defineInvokeEventa<CapabilityDescriptor, { key: string, timeoutMs?: number }>(
protocolCapabilityWaitEventName,
)
/**
* Identifies the control-plane RPC used to snapshot all capabilities.
*
* Use when:
* - Declaring permissions or invoking the snapshot RPC directly
*
* Expects:
* - Host and plugin agree on this event name
*
* Returns:
* - The permission/event key string for capability snapshots
*/
export const protocolCapabilitySnapshotEventName = 'proj-airi:plugin-sdk:apis:protocol:capabilities:snapshot'
/**
* Defines the control-plane RPC that returns the current capability snapshot.
*
* Use when:
* - A plugin or host tool wants the current state of all capabilities
*
* Expects:
* - The host implements the matching invoke handler
*
* Returns:
* - A typed Eventa invoke descriptor for reading all capability descriptors
*/
export const protocolCapabilitySnapshot = defineInvokeEventa<CapabilityDescriptor[]>(
protocolCapabilitySnapshotEventName,
)
@@ -1,8 +1,44 @@
import { defineInvokeEventa } from '@moeru/eventa'
/**
* Identifies the control-plane RPC used to list available providers.
*
* Use when:
* - Declaring permissions for provider discovery
*
* Expects:
* - Host and plugin agree on this event name
*
* Returns:
* - The permission/event key string for provider listing
*/
export const protocolListProvidersEventName = 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers'
/**
* Defines the control-plane RPC that returns the current provider list.
*
* Use when:
* - A plugin needs to inspect which providers are currently available
*
* Expects:
* - The host implements the matching invoke handler
*
* Returns:
* - A typed Eventa invoke descriptor for listing provider names
*/
export const protocolListProviders = defineInvokeEventa<{ name: string }[]>(protocolListProvidersEventName)
/**
* Groups provider-related protocol RPCs into one namespaced object.
*
* Use when:
* - Passing provider protocol helpers around as a single object
*
* Expects:
* - Consumers call `listProviders` for provider discovery
*
* Returns:
* - The provider protocol helper object
*/
export const protocolProviders = {
listProviders: protocolListProviders,
}
+12
View File
@@ -1,5 +1,17 @@
import type { Plugin } from './shared'
/**
* Declares a lazily constructed plugin definition with stable metadata.
*
* Use when:
* - A plugin entrypoint wants to expose metadata and deferred setup together
*
* Expects:
* - `setup` returns a {@link Plugin} object when the host loads the entrypoint
*
* Returns:
* - A serializable plugin definition that loaders can recognize and execute
*/
export function definePlugin(name: string, version: string, setup: () => Promise<Plugin> | Plugin): {
name: string
version: string
+44
View File
@@ -1,6 +1,20 @@
import type { ChannelHost } from '../channels/shared'
import type { PluginApis } from './apis/client'
/**
* Describes the host-provided context injected into plugin hooks.
*
* Use when:
* - Implementing `Plugin.init`
* - Implementing `Plugin.setupModules`
*
* Expects:
* - `channels.host` is the control-plane Eventa context for the session
* - `apis` contains the host-bound plugin API surface for that session
*
* Returns:
* - A stable bootstrap object shared across plugin lifecycle hooks
*/
export interface ContextInit {
channels: {
host: ChannelHost
@@ -8,13 +22,43 @@ export interface ContextInit {
apis: PluginApis
}
/**
* Defines the hook surface implemented by a plugin module.
*
* Use when:
* - Exporting plugin behavior from a runtime entrypoint
*
* Expects:
* - Hooks are optional, but at least one meaningful hook should be provided by a real plugin
*
* Returns:
* - A plugin lifecycle object consumed by the plugin host loader
*/
export interface Plugin {
/**
* Performs plugin initialization against the injected host context.
*
* Use when:
* - The plugin needs to announce state, wait for capabilities, or register resources during boot
*
* Expects:
* - The host has already created the plugin session and bound `initContext`
*
* Returns:
* - `false` to abort startup, or nothing to continue initialization
*/
init?: (initContext: ContextInit) => Promise<void | undefined | false>
/**
* Declares additional modules or bindings after basic initialization.
*
* Use when:
* - The plugin wants to expose dynamic bindings after its initial boot logic
*
* Expects:
* - The host has already created the plugin session and bound `initContext`
*
* Returns:
* - Nothing. The host observes any side effects performed through `initContext.apis`.
*/
setupModules?: (initContext: ContextInit) => Promise<void | undefined>
}