feat: Agent Orchestrator — multi-project agent dashboard

Tauri + Svelte 5 + Rust application for orchestrating multiple AI coding agents.
Includes Claude, Aider, Codex, and Ollama provider support, multi-agent
communication (btmsg/bttask), session anchors, plugin sandbox, FTS5 search,
Landlock sandboxing, and 507 vitest + 110 cargo tests.
This commit is contained in:
DexterFromLab 2026-03-15 15:45:27 +01:00
commit 3672e92b7e
272 changed files with 68600 additions and 0 deletions

View file

@ -0,0 +1,534 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// --- Mocks ---
const { mockInvoke } = vi.hoisted(() => ({
mockInvoke: vi.fn(),
}));
vi.mock('@tauri-apps/api/core', () => ({
invoke: mockInvoke,
}));
// Mock the plugins store to avoid Svelte 5 rune issues in test context
vi.mock('../stores/plugins.svelte', () => {
const commands: Array<{ pluginId: string; label: string; callback: () => void }> = [];
return {
addPluginCommand: vi.fn((pluginId: string, label: string, callback: () => void) => {
commands.push({ pluginId, label, callback });
}),
removePluginCommands: vi.fn((pluginId: string) => {
const toRemove = commands.filter(c => c.pluginId === pluginId);
for (const cmd of toRemove) {
const idx = commands.indexOf(cmd);
if (idx >= 0) commands.splice(idx, 1);
}
}),
pluginEventBus: {
on: vi.fn(),
off: vi.fn(),
emit: vi.fn(),
clear: vi.fn(),
},
getPluginCommands: () => [...commands],
};
});
import {
loadPlugin,
unloadPlugin,
getLoadedPlugins,
unloadAllPlugins,
} from './plugin-host';
import { addPluginCommand, removePluginCommands, pluginEventBus } from '../stores/plugins.svelte';
import type { PluginMeta } from '../adapters/plugins-bridge';
import type { GroupId, AgentId } from '../types/ids';
// --- Mock Worker ---
/**
* Simulates a Web Worker that runs the plugin host's worker script.
* Instead of actually creating a Blob + Worker, we intercept postMessage
* and simulate the worker-side logic inline.
*/
class MockWorker {
onmessage: ((e: MessageEvent) => void) | null = null;
onerror: ((e: ErrorEvent) => void) | null = null;
private terminated = false;
postMessage(msg: unknown): void {
if (this.terminated) return;
const data = msg as Record<string, unknown>;
if (data.type === 'init') {
this.handleInit(data);
} else if (data.type === 'invoke-callback') {
// Callback invocations from main → worker: no-op in mock
// (the real worker would call the stored callback)
}
}
private handleInit(data: Record<string, unknown>): void {
const code = data.code as string;
const permissions = (data.permissions as string[]) || [];
const meta = data.meta as Record<string, unknown>;
// Build a mock bterminal API that mimics worker-side behavior
// by sending messages back to the main thread (this.sendToMain)
const bterminal: Record<string, unknown> = {
meta: Object.freeze({ ...meta }),
};
if (permissions.includes('palette')) {
let cbId = 0;
bterminal.palette = {
registerCommand: (label: string, callback: () => void) => {
if (typeof label !== 'string' || !label.trim()) {
throw new Error('Command label must be a non-empty string');
}
if (typeof callback !== 'function') {
throw new Error('Command callback must be a function');
}
const id = '__cb_' + (++cbId);
this.sendToMain({ type: 'palette-register', label, callbackId: id });
},
};
}
if (permissions.includes('bttask:read')) {
bterminal.tasks = {
list: () => this.rpc('tasks.list', {}),
comments: (taskId: string) => this.rpc('tasks.comments', { taskId }),
};
}
if (permissions.includes('btmsg:read')) {
bterminal.messages = {
inbox: () => this.rpc('messages.inbox', {}),
channels: () => this.rpc('messages.channels', {}),
};
}
if (permissions.includes('events')) {
let cbId = 0;
bterminal.events = {
on: (event: string, callback: (data: unknown) => void) => {
if (typeof event !== 'string' || typeof callback !== 'function') {
throw new Error('event.on requires (string, function)');
}
const id = '__cb_' + (++cbId);
this.sendToMain({ type: 'event-on', event, callbackId: id });
},
off: (event: string) => {
this.sendToMain({ type: 'event-off', event });
},
};
}
Object.freeze(bterminal);
// Execute the plugin code
try {
const fn = new Function('bterminal', `"use strict"; ${code}`);
fn(bterminal);
this.sendToMain({ type: 'loaded' });
} catch (err) {
this.sendToMain({ type: 'error', message: String(err) });
}
}
private rpcId = 0;
private rpc(method: string, args: Record<string, unknown>): Promise<unknown> {
const id = '__rpc_' + (++this.rpcId);
this.sendToMain({ type: 'rpc', id, method, args });
// In real worker, this would be a pending promise resolved by rpc-result message.
// For tests, return a resolved promise since we test RPC routing separately.
return Promise.resolve([]);
}
private sendToMain(data: unknown): void {
if (this.terminated) return;
// Schedule on microtask to simulate async Worker message delivery
queueMicrotask(() => {
if (this.onmessage) {
this.onmessage(new MessageEvent('message', { data }));
}
});
}
terminate(): void {
this.terminated = true;
this.onmessage = null;
this.onerror = null;
}
addEventListener(): void { /* stub */ }
removeEventListener(): void { /* stub */ }
dispatchEvent(): boolean { return false; }
}
// Install global Worker mock
const originalWorker = globalThis.Worker;
const originalURL = globalThis.URL;
beforeEach(() => {
vi.clearAllMocks();
unloadAllPlugins();
// Mock Worker constructor
(globalThis as Record<string, unknown>).Worker = MockWorker;
// Mock URL.createObjectURL
if (!globalThis.URL) {
(globalThis as Record<string, unknown>).URL = {} as typeof URL;
}
globalThis.URL.createObjectURL = vi.fn(() => 'blob:mock-worker-url');
globalThis.URL.revokeObjectURL = vi.fn();
});
afterEach(() => {
(globalThis as Record<string, unknown>).Worker = originalWorker;
if (originalURL) {
globalThis.URL.createObjectURL = originalURL.createObjectURL;
globalThis.URL.revokeObjectURL = originalURL.revokeObjectURL;
}
});
// --- Helpers ---
function makeMeta(overrides: Partial<PluginMeta> = {}): PluginMeta {
return {
id: overrides.id ?? 'test-plugin',
name: overrides.name ?? 'Test Plugin',
version: overrides.version ?? '1.0.0',
description: overrides.description ?? 'A test plugin',
main: overrides.main ?? 'index.js',
permissions: overrides.permissions ?? [],
};
}
function mockPluginCode(code: string): void {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'plugin_read_file') return Promise.resolve(code);
return Promise.reject(new Error(`Unexpected invoke: ${cmd}`));
});
}
const GROUP_ID = 'test-group' as GroupId;
const AGENT_ID = 'test-agent' as AgentId;
// --- Worker isolation tests ---
describe('plugin-host Worker isolation', () => {
it('plugin code runs in Worker (cannot access main thread globals)', async () => {
// In a real Worker, window/document/globalThis are unavailable.
// Our MockWorker simulates this by running in strict mode.
const meta = makeMeta({ id: 'isolation-test' });
mockPluginCode('// no-op — isolation verified by Worker boundary');
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
});
it('Worker is terminated on unload', async () => {
const meta = makeMeta({ id: 'terminate-test' });
mockPluginCode('// no-op');
await loadPlugin(meta, GROUP_ID, AGENT_ID);
expect(getLoadedPlugins()).toHaveLength(1);
unloadPlugin('terminate-test');
expect(getLoadedPlugins()).toHaveLength(0);
});
it('API object is frozen (cannot add properties)', async () => {
const meta = makeMeta({ id: 'freeze-test', permissions: [] });
mockPluginCode(`
try {
bterminal.hacked = true;
throw new Error('FREEZE FAILED: could add property');
} catch (e) {
if (e.message === 'FREEZE FAILED: could add property') throw e;
}
`);
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
});
it('API object is frozen (cannot delete properties)', async () => {
const meta = makeMeta({ id: 'freeze-delete-test', permissions: [] });
mockPluginCode(`
try {
delete bterminal.meta;
throw new Error('FREEZE FAILED: could delete property');
} catch (e) {
if (e.message === 'FREEZE FAILED: could delete property') throw e;
}
`);
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
});
it('meta is accessible and frozen', async () => {
const meta = makeMeta({ id: 'meta-access', permissions: [] });
mockPluginCode(`
if (bterminal.meta.id !== 'meta-access') {
throw new Error('meta.id mismatch');
}
if (bterminal.meta.name !== 'Test Plugin') {
throw new Error('meta.name mismatch');
}
try {
bterminal.meta.id = 'hacked';
throw new Error('META FREEZE FAILED');
} catch (e) {
if (e.message === 'META FREEZE FAILED') throw e;
}
`);
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
});
});
// --- Permission-gated API tests ---
describe('plugin-host permissions', () => {
describe('palette permission', () => {
it('plugin with palette permission can register commands', async () => {
const meta = makeMeta({ id: 'palette-plugin', permissions: ['palette'] });
mockPluginCode(`
bterminal.palette.registerCommand('Test Command', function() {});
`);
await loadPlugin(meta, GROUP_ID, AGENT_ID);
expect(addPluginCommand).toHaveBeenCalledWith(
'palette-plugin',
'Test Command',
expect.any(Function),
);
});
it('plugin without palette permission has no palette API', async () => {
const meta = makeMeta({ id: 'no-palette-plugin', permissions: [] });
mockPluginCode(`
if (bterminal.palette !== undefined) {
throw new Error('palette API should not be available');
}
`);
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
});
it('palette.registerCommand rejects non-string label', async () => {
const meta = makeMeta({ id: 'bad-label-plugin', permissions: ['palette'] });
mockPluginCode(`
bterminal.palette.registerCommand(123, function() {});
`);
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).rejects.toThrow(
'execution failed',
);
});
it('palette.registerCommand rejects non-function callback', async () => {
const meta = makeMeta({ id: 'bad-cb-plugin', permissions: ['palette'] });
mockPluginCode(`
bterminal.palette.registerCommand('Test', 'not-a-function');
`);
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).rejects.toThrow(
'execution failed',
);
});
it('palette.registerCommand rejects empty label', async () => {
const meta = makeMeta({ id: 'empty-label-plugin', permissions: ['palette'] });
mockPluginCode(`
bterminal.palette.registerCommand(' ', function() {});
`);
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).rejects.toThrow(
'execution failed',
);
});
});
describe('bttask:read permission', () => {
it('plugin with bttask:read can call tasks.list', async () => {
const meta = makeMeta({ id: 'task-plugin', permissions: ['bttask:read'] });
mockPluginCode(`
bterminal.tasks.list();
`);
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
});
it('plugin without bttask:read has no tasks API', async () => {
const meta = makeMeta({ id: 'no-task-plugin', permissions: [] });
mockPluginCode(`
if (bterminal.tasks !== undefined) {
throw new Error('tasks API should not be available');
}
`);
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
});
});
describe('btmsg:read permission', () => {
it('plugin with btmsg:read can call messages.inbox', async () => {
const meta = makeMeta({ id: 'msg-plugin', permissions: ['btmsg:read'] });
mockPluginCode(`
bterminal.messages.inbox();
`);
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
});
it('plugin without btmsg:read has no messages API', async () => {
const meta = makeMeta({ id: 'no-msg-plugin', permissions: [] });
mockPluginCode(`
if (bterminal.messages !== undefined) {
throw new Error('messages API should not be available');
}
`);
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
});
});
describe('events permission', () => {
it('plugin with events permission can subscribe', async () => {
const meta = makeMeta({ id: 'events-plugin', permissions: ['events'] });
mockPluginCode(`
bterminal.events.on('test-event', function(data) {});
`);
await loadPlugin(meta, GROUP_ID, AGENT_ID);
expect(pluginEventBus.on).toHaveBeenCalledWith('test-event', expect.any(Function));
});
it('plugin without events permission has no events API', async () => {
const meta = makeMeta({ id: 'no-events-plugin', permissions: [] });
mockPluginCode(`
if (bterminal.events !== undefined) {
throw new Error('events API should not be available');
}
`);
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
});
});
});
// --- Lifecycle tests ---
describe('plugin-host lifecycle', () => {
it('loadPlugin registers the plugin', async () => {
const meta = makeMeta({ id: 'lifecycle-load' });
mockPluginCode('// no-op');
await loadPlugin(meta, GROUP_ID, AGENT_ID);
const loaded = getLoadedPlugins();
expect(loaded).toHaveLength(1);
expect(loaded[0].id).toBe('lifecycle-load');
});
it('loadPlugin warns on duplicate load and returns early', async () => {
const meta = makeMeta({ id: 'duplicate-load' });
mockPluginCode('// no-op');
await loadPlugin(meta, GROUP_ID, AGENT_ID);
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
await loadPlugin(meta, GROUP_ID, AGENT_ID);
expect(consoleSpy).toHaveBeenCalledWith("Plugin 'duplicate-load' is already loaded");
consoleSpy.mockRestore();
expect(getLoadedPlugins()).toHaveLength(1);
});
it('unloadPlugin removes the plugin and cleans up commands', async () => {
const meta = makeMeta({ id: 'lifecycle-unload', permissions: ['palette'] });
mockPluginCode(`
bterminal.palette.registerCommand('Cmd1', function() {});
`);
await loadPlugin(meta, GROUP_ID, AGENT_ID);
expect(getLoadedPlugins()).toHaveLength(1);
unloadPlugin('lifecycle-unload');
expect(getLoadedPlugins()).toHaveLength(0);
expect(removePluginCommands).toHaveBeenCalledWith('lifecycle-unload');
});
it('unloadPlugin is no-op for unknown plugin', () => {
unloadPlugin('nonexistent');
expect(getLoadedPlugins()).toHaveLength(0);
});
it('unloadAllPlugins clears all loaded plugins', async () => {
mockPluginCode('// no-op');
const meta1 = makeMeta({ id: 'all-1' });
await loadPlugin(meta1, GROUP_ID, AGENT_ID);
const meta2 = makeMeta({ id: 'all-2' });
await loadPlugin(meta2, GROUP_ID, AGENT_ID);
expect(getLoadedPlugins()).toHaveLength(2);
unloadAllPlugins();
expect(getLoadedPlugins()).toHaveLength(0);
});
it('loadPlugin cleans up commands on execution error', async () => {
const meta = makeMeta({ id: 'error-cleanup' });
mockPluginCode('throw new Error("plugin crash");');
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).rejects.toThrow(
"Plugin 'error-cleanup' execution failed",
);
expect(removePluginCommands).toHaveBeenCalledWith('error-cleanup');
expect(getLoadedPlugins()).toHaveLength(0);
});
it('loadPlugin throws on file read failure', async () => {
const meta = makeMeta({ id: 'read-fail' });
mockInvoke.mockRejectedValue(new Error('file not found'));
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).rejects.toThrow(
"Failed to read plugin 'read-fail'",
);
});
it('unloadPlugin cleans up event subscriptions', async () => {
const meta = makeMeta({ id: 'events-cleanup', permissions: ['events'] });
mockPluginCode(`
bterminal.events.on('my-event', function() {});
`);
await loadPlugin(meta, GROUP_ID, AGENT_ID);
expect(pluginEventBus.on).toHaveBeenCalledWith('my-event', expect.any(Function));
unloadPlugin('events-cleanup');
expect(pluginEventBus.off).toHaveBeenCalledWith('my-event', expect.any(Function));
});
});
// --- RPC routing tests ---
describe('plugin-host RPC routing', () => {
it('tasks.list RPC is routed to main thread', async () => {
const meta = makeMeta({ id: 'rpc-tasks', permissions: ['bttask:read'] });
mockPluginCode(`bterminal.tasks.list();`);
// Mock the bttask bridge
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'plugin_read_file') return Promise.resolve('bterminal.tasks.list();');
if (cmd === 'bttask_list') return Promise.resolve([]);
return Promise.reject(new Error(`Unexpected: ${cmd}`));
});
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
});
it('messages.inbox RPC is routed to main thread', async () => {
const meta = makeMeta({ id: 'rpc-messages', permissions: ['btmsg:read'] });
mockPluginCode(`bterminal.messages.inbox();`);
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'plugin_read_file') return Promise.resolve('bterminal.messages.inbox();');
if (cmd === 'btmsg_get_unread') return Promise.resolve([]);
return Promise.reject(new Error(`Unexpected: ${cmd}`));
});
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
});
});

View file

@ -0,0 +1,339 @@
/**
* Plugin Host Web Worker sandbox for BTerminal plugins.
*
* Each plugin runs in a dedicated Web Worker, providing true process-level
* isolation from the main thread. The Worker has no access to the DOM,
* Tauri IPC, or any main-thread state.
*
* Communication:
* - Main Worker: plugin code, permissions, callback invocations
* - Worker Main: API call proxies (palette, tasks, messages, events)
*
* On unload, the Worker is terminated all plugin state is destroyed.
*/
import type { PluginMeta } from '../adapters/plugins-bridge';
import { readPluginFile } from '../adapters/plugins-bridge';
import { listTasks, getTaskComments } from '../adapters/bttask-bridge';
import {
getUnreadMessages,
getChannels,
} from '../adapters/btmsg-bridge';
import {
addPluginCommand,
removePluginCommands,
pluginEventBus,
} from '../stores/plugins.svelte';
import type { GroupId, AgentId } from '../types/ids';
interface LoadedPlugin {
meta: PluginMeta;
worker: Worker;
callbacks: Map<string, () => void>;
eventSubscriptions: Array<{ event: string; handler: (data: unknown) => void }>;
cleanup: () => void;
}
const loadedPlugins = new Map<string, LoadedPlugin>();
/**
* Build the Worker script as an inline blob.
* The Worker receives plugin code + permissions and builds a sandboxed bterminal API
* that proxies all calls to the main thread via postMessage.
*/
function buildWorkerScript(): string {
return `
"use strict";
// Callback registry for palette commands and event handlers
const _callbacks = new Map();
let _callbackId = 0;
function _nextCallbackId() {
return '__cb_' + (++_callbackId);
}
// Pending RPC calls (for async APIs like tasks.list)
const _pending = new Map();
let _rpcId = 0;
function _rpc(method, args) {
return new Promise((resolve, reject) => {
const id = '__rpc_' + (++_rpcId);
_pending.set(id, { resolve, reject });
self.postMessage({ type: 'rpc', id, method, args });
});
}
// Handle messages from main thread
self.onmessage = function(e) {
const msg = e.data;
if (msg.type === 'init') {
const permissions = msg.permissions || [];
const meta = msg.meta;
// Build the bterminal API based on permissions
const api = { meta: Object.freeze(meta) };
if (permissions.includes('palette')) {
api.palette = {
registerCommand(label, callback) {
if (typeof label !== 'string' || !label.trim()) {
throw new Error('Command label must be a non-empty string');
}
if (typeof callback !== 'function') {
throw new Error('Command callback must be a function');
}
const cbId = _nextCallbackId();
_callbacks.set(cbId, callback);
self.postMessage({ type: 'palette-register', label, callbackId: cbId });
},
};
}
if (permissions.includes('bttask:read')) {
api.tasks = {
list() { return _rpc('tasks.list', {}); },
comments(taskId) { return _rpc('tasks.comments', { taskId }); },
};
}
if (permissions.includes('btmsg:read')) {
api.messages = {
inbox() { return _rpc('messages.inbox', {}); },
channels() { return _rpc('messages.channels', {}); },
};
}
if (permissions.includes('events')) {
api.events = {
on(event, callback) {
if (typeof event !== 'string' || typeof callback !== 'function') {
throw new Error('event.on requires (string, function)');
}
const cbId = _nextCallbackId();
_callbacks.set(cbId, callback);
self.postMessage({ type: 'event-on', event, callbackId: cbId });
},
off(event, callbackId) {
// Worker-side off is a no-op for now (main thread handles cleanup on terminate)
self.postMessage({ type: 'event-off', event, callbackId });
},
};
}
Object.freeze(api);
// Execute the plugin code
try {
const fn = (0, eval)(
'(function(bterminal) { "use strict"; ' + msg.code + '\\n})'
);
fn(api);
self.postMessage({ type: 'loaded' });
} catch (err) {
self.postMessage({ type: 'error', message: String(err) });
}
}
if (msg.type === 'invoke-callback') {
const cb = _callbacks.get(msg.callbackId);
if (cb) {
try {
cb(msg.data);
} catch (err) {
self.postMessage({ type: 'callback-error', callbackId: msg.callbackId, message: String(err) });
}
}
}
if (msg.type === 'rpc-result') {
const pending = _pending.get(msg.id);
if (pending) {
_pending.delete(msg.id);
if (msg.error) {
pending.reject(new Error(msg.error));
} else {
pending.resolve(msg.result);
}
}
}
};
`;
}
let workerBlobUrl: string | null = null;
function getWorkerBlobUrl(): string {
if (!workerBlobUrl) {
const blob = new Blob([buildWorkerScript()], { type: 'application/javascript' });
workerBlobUrl = URL.createObjectURL(blob);
}
return workerBlobUrl;
}
/**
* Load and execute a plugin in a Web Worker sandbox.
*/
export async function loadPlugin(
meta: PluginMeta,
groupId: GroupId,
agentId: AgentId,
): Promise<void> {
if (loadedPlugins.has(meta.id)) {
console.warn(`Plugin '${meta.id}' is already loaded`);
return;
}
// Read the plugin's entry file
let code: string;
try {
code = await readPluginFile(meta.id, meta.main);
} catch (e) {
throw new Error(`Failed to read plugin '${meta.id}' entry file '${meta.main}': ${e}`);
}
const worker = new Worker(getWorkerBlobUrl(), { type: 'classic' });
const callbacks = new Map<string, () => void>();
const eventSubscriptions: Array<{ event: string; handler: (data: unknown) => void }> = [];
// Set up message handler before sending init
const loadResult = await new Promise<void>((resolve, reject) => {
const onMessage = async (e: MessageEvent) => {
const msg = e.data;
switch (msg.type) {
case 'loaded':
resolve();
break;
case 'error':
// Clean up any commands/events registered before the crash
removePluginCommands(meta.id);
for (const sub of eventSubscriptions) {
pluginEventBus.off(sub.event, sub.handler);
}
worker.terminate();
reject(new Error(`Plugin '${meta.id}' execution failed: ${msg.message}`));
break;
case 'palette-register': {
const cbId = msg.callbackId as string;
const invokeCallback = () => {
worker.postMessage({ type: 'invoke-callback', callbackId: cbId });
};
callbacks.set(cbId, invokeCallback);
addPluginCommand(meta.id, msg.label, invokeCallback);
break;
}
case 'event-on': {
const cbId = msg.callbackId as string;
const handler = (data: unknown) => {
worker.postMessage({ type: 'invoke-callback', callbackId: cbId, data });
};
eventSubscriptions.push({ event: msg.event, handler });
pluginEventBus.on(msg.event, handler);
break;
}
case 'event-off': {
const idx = eventSubscriptions.findIndex(s => s.event === msg.event);
if (idx >= 0) {
pluginEventBus.off(eventSubscriptions[idx].event, eventSubscriptions[idx].handler);
eventSubscriptions.splice(idx, 1);
}
break;
}
case 'rpc': {
const { id, method, args } = msg;
try {
let result: unknown;
switch (method) {
case 'tasks.list':
result = await listTasks(groupId);
break;
case 'tasks.comments':
result = await getTaskComments(args.taskId);
break;
case 'messages.inbox':
result = await getUnreadMessages(agentId);
break;
case 'messages.channels':
result = await getChannels(groupId);
break;
default:
throw new Error(`Unknown RPC method: ${method}`);
}
worker.postMessage({ type: 'rpc-result', id, result });
} catch (err) {
worker.postMessage({
type: 'rpc-result',
id,
error: err instanceof Error ? err.message : String(err),
});
}
break;
}
case 'callback-error':
console.error(`Plugin '${meta.id}' callback error:`, msg.message);
break;
}
};
worker.onmessage = onMessage;
worker.onerror = (err) => {
reject(new Error(`Plugin '${meta.id}' worker error: ${err.message}`));
};
// Send init message with plugin code, permissions, and meta
worker.postMessage({
type: 'init',
code,
permissions: meta.permissions,
meta: { id: meta.id, name: meta.name, version: meta.version, description: meta.description },
});
});
// If we get here, the plugin loaded successfully
const cleanup = () => {
removePluginCommands(meta.id);
for (const sub of eventSubscriptions) {
pluginEventBus.off(sub.event, sub.handler);
}
eventSubscriptions.length = 0;
callbacks.clear();
worker.terminate();
};
loadedPlugins.set(meta.id, { meta, worker, callbacks, eventSubscriptions, cleanup });
}
/**
* Unload a plugin, terminating its Worker.
*/
export function unloadPlugin(id: string): void {
const plugin = loadedPlugins.get(id);
if (!plugin) return;
plugin.cleanup();
loadedPlugins.delete(id);
}
/**
* Get all currently loaded plugins.
*/
export function getLoadedPlugins(): PluginMeta[] {
return Array.from(loadedPlugins.values()).map(p => p.meta);
}
/**
* Unload all plugins.
*/
export function unloadAllPlugins(): void {
for (const [id] of loadedPlugins) {
unloadPlugin(id);
}
}