agent-orchestrator/src/lib/backend/ElectrobunAdapter.ts
Hibryda c86f669f96 feat: @agor/types package + BackendAdapter + TauriAdapter + ElectrobunAdapter
- packages/types/: shared type definitions (agent, project, btmsg, bttask,
  health, settings, protocol, backend interface)
- BackendAdapter: capability-flagged interface, compile-time selected
- TauriAdapter: wraps Tauri invoke/listen
- ElectrobunAdapter: wraps Electrobun RPC
- src/lib/backend/backend.ts: adapter singleton + setBackendForTesting()
- pnpm-workspace.yaml: workspace setup
2026-03-22 03:34:04 +01:00

350 lines
14 KiB
TypeScript

// ElectrobunAdapter — implements BackendAdapter for Electrobun backend
// Wraps the Electrobun RPC pattern (request/response + message listeners)
import type {
BackendAdapter,
BackendCapabilities,
UnsubscribeFn,
AgentStartOptions,
AgentMessage,
AgentStatus,
FileEntry,
FileContent,
PtyCreateOptions,
SettingsMap,
GroupsFile,
GroupConfig,
GroupId,
} from '@agor/types';
// ── Capabilities ─────────────────────────────────────────────────────────────
const ELECTROBUN_CAPABILITIES: BackendCapabilities = {
supportsPtyMultiplexing: true, // via agor-ptyd daemon
supportsPluginSandbox: true, // Web Worker sandbox works in any webview
supportsNativeMenus: false, // Electrobun has limited menu support
supportsOsKeychain: false, // No keyring crate — uses file-based secrets
supportsFileDialogs: false, // No native dialog plugin
supportsAutoUpdater: false, // Custom updater via GitHub Releases check
supportsDesktopNotifications: false, // No notify-rust — uses in-app toasts only
supportsTelemetry: false, // No OTLP export in Electrobun backend
};
// ── RPC handle type (matches ui-electrobun/src/mainview/rpc.ts) ──────────────
interface RpcRequestFns {
[method: string]: (params: Record<string, unknown>) => Promise<Record<string, unknown>>;
}
interface RpcHandle {
request: RpcRequestFns;
addMessageListener: (event: string, handler: (payload: unknown) => void) => void;
removeMessageListener?: (event: string, handler: (payload: unknown) => void) => void;
}
// ── Adapter ──────────────────────────────────────────────────────────────────
export class ElectrobunAdapter implements BackendAdapter {
readonly capabilities = ELECTROBUN_CAPABILITIES;
private rpc: RpcHandle | null = null;
private messageHandlers: Array<{ event: string; handler: (payload: unknown) => void }> = [];
/** Inject the Electrobun RPC handle (set from main.ts after Electroview.defineRPC()) */
setRpc(rpc: RpcHandle): void {
this.rpc = rpc;
}
private get r(): RpcHandle {
if (!this.rpc) {
throw new Error('[ElectrobunAdapter] RPC not initialized. Call setRpc() first.');
}
return this.rpc;
}
async init(): Promise<void> {
// RPC handle is set externally via setRpc()
}
async destroy(): Promise<void> {
// Remove all registered message listeners
if (this.rpc?.removeMessageListener) {
for (const { event, handler } of this.messageHandlers) {
this.rpc.removeMessageListener(event, handler);
}
}
this.messageHandlers = [];
this.rpc = null;
}
// ── Settings ─────────────────────────────────────────────────────────────
async getSetting(key: string): Promise<string | null> {
const res = await this.r.request['settings.get']({ key }) as { value: string | null };
return res.value;
}
async setSetting(key: string, value: string): Promise<void> {
await this.r.request['settings.set']({ key, value });
}
async getAllSettings(): Promise<SettingsMap> {
const res = await this.r.request['settings.getAll']({}) as { settings: SettingsMap };
return res.settings;
}
// ── Groups ───────────────────────────────────────────────────────────────
async loadGroups(): Promise<GroupsFile> {
// Electrobun stores groups differently — reconstruct GroupsFile from flat list
const res = await this.r.request['groups.list']({}) as {
groups: Array<{ id: string; name: string; icon: string; position: number }>;
};
// Load projects per group from settings
const projectsRes = await this.r.request['settings.getProjects']({}) as {
projects: Array<{ id: string; config: string }>;
};
const projectsByGroup = new Map<string, Array<Record<string, unknown>>>();
for (const p of projectsRes.projects) {
try {
const config = JSON.parse(p.config) as Record<string, unknown>;
const groupId = String(config['groupId'] ?? 'default');
if (!projectsByGroup.has(groupId)) projectsByGroup.set(groupId, []);
projectsByGroup.get(groupId)!.push(config);
} catch { /* skip invalid */ }
}
const groups: GroupConfig[] = res.groups.map((g) => ({
id: g.id as GroupId,
name: g.name,
projects: (projectsByGroup.get(g.id) ?? []) as unknown as GroupConfig['projects'],
}));
return {
version: 1,
groups,
activeGroupId: (groups[0]?.id ?? 'default') as GroupId,
};
}
async saveGroups(groupsFile: GroupsFile): Promise<void> {
// Save groups list
for (const group of groupsFile.groups) {
await this.r.request['groups.create']({
id: group.id,
name: group.name,
icon: '',
position: groupsFile.groups.indexOf(group),
});
}
// Save projects per group
for (const group of groupsFile.groups) {
for (const project of group.projects) {
await this.r.request['settings.setProject']({
id: project.id,
config: JSON.stringify({ ...project, groupId: group.id }),
});
}
}
}
// ── Agent ────────────────────────────────────────────────────────────────
async startAgent(options: AgentStartOptions): Promise<{ ok: boolean; error?: string }> {
const res = await this.r.request['agent.start']({
sessionId: options.sessionId,
provider: options.provider ?? 'claude',
prompt: options.prompt,
cwd: options.cwd,
model: options.model,
systemPrompt: options.systemPrompt,
maxTurns: options.maxTurns,
permissionMode: options.permissionMode,
claudeConfigDir: options.claudeConfigDir,
extraEnv: options.extraEnv,
additionalDirectories: options.additionalDirectories,
worktreeName: options.worktreeName,
}) as { ok: boolean; error?: string };
return res;
}
async stopAgent(sessionId: string): Promise<{ ok: boolean; error?: string }> {
const res = await this.r.request['agent.stop']({ sessionId }) as { ok: boolean; error?: string };
return res;
}
async sendPrompt(sessionId: string, prompt: string): Promise<{ ok: boolean; error?: string }> {
const res = await this.r.request['agent.prompt']({ sessionId, prompt }) as { ok: boolean; error?: string };
return res;
}
// ── PTY ──────────────────────────────────────────────────────────────────
async createPty(options: PtyCreateOptions): Promise<string> {
const res = await this.r.request['pty.create']({
sessionId: options.sessionId,
cols: options.cols,
rows: options.rows,
cwd: options.cwd,
shell: options.shell,
args: options.args,
}) as { ok: boolean; error?: string };
if (!res.ok) throw new Error(res.error ?? 'PTY creation failed');
return options.sessionId;
}
async writePty(sessionId: string, data: string): Promise<void> {
await this.r.request['pty.write']({ sessionId, data });
}
async resizePty(sessionId: string, cols: number, rows: number): Promise<void> {
await this.r.request['pty.resize']({ sessionId, cols, rows });
}
async closePty(sessionId: string): Promise<void> {
await this.r.request['pty.close']({ sessionId });
}
// ── Files ────────────────────────────────────────────────────────────────
async listDirectory(path: string): Promise<FileEntry[]> {
const res = await this.r.request['files.list']({ path }) as {
entries: Array<{ name: string; type: string; size: number }>;
error?: string;
};
if (res.error) throw new Error(res.error);
return res.entries.map((e) => ({
name: e.name,
path: `${path}/${e.name}`,
isDir: e.type === 'dir',
size: e.size,
}));
}
async readFile(path: string): Promise<FileContent> {
const res = await this.r.request['files.read']({ path }) as {
content?: string;
encoding: string;
size: number;
error?: string;
};
if (res.error) throw new Error(res.error);
if (res.encoding === 'base64') {
return { type: 'Binary', message: `Binary file (${res.size} bytes)` };
}
return { type: 'Text', content: res.content ?? '' };
}
async writeFile(path: string, content: string): Promise<void> {
const res = await this.r.request['files.write']({ path, content }) as { ok: boolean; error?: string };
if (!res.ok) throw new Error(res.error ?? 'Write failed');
}
// ── Events ───────────────────────────────────────────────────────────────
/** Subscribe to an RPC message event; returns unsubscribe function. */
private listenMsg<T>(event: string, handler: (payload: T) => void): UnsubscribeFn {
const wrapped = handler as (payload: unknown) => void;
this.r.addMessageListener(event, wrapped);
this.messageHandlers.push({ event, handler: wrapped });
return () => {
this.r.removeMessageListener?.(event, wrapped);
this.messageHandlers = this.messageHandlers.filter((h) => h.handler !== wrapped);
};
}
onAgentMessage(callback: (sessionId: string, messages: AgentMessage[]) => void): UnsubscribeFn {
return this.listenMsg<{
sessionId: string;
messages: Array<{ id: string; type: string; parentId?: string; content: unknown; timestamp: number }>;
}>('agent.message', (p) => {
const converted = p.messages.map(convertWireMessage).filter((m): m is AgentMessage => m !== null);
if (converted.length > 0) callback(p.sessionId, converted);
});
}
onAgentStatus(callback: (sessionId: string, status: AgentStatus, error?: string) => void): UnsubscribeFn {
return this.listenMsg<{ sessionId: string; status: string; error?: string }>('agent.status', (p) => {
callback(p.sessionId, normalizeStatus(p.status), p.error);
});
}
onAgentCost(
callback: (sessionId: string, costUsd: number, inputTokens: number, outputTokens: number) => void,
): UnsubscribeFn {
return this.listenMsg<{ sessionId: string; costUsd: number; inputTokens: number; outputTokens: number }>(
'agent.cost', (p) => callback(p.sessionId, p.costUsd, p.inputTokens, p.outputTokens),
);
}
onPtyOutput(callback: (sessionId: string, data: string) => void): UnsubscribeFn {
return this.listenMsg<{ sessionId: string; data: string }>('pty.output', (p) => callback(p.sessionId, p.data));
}
onPtyClosed(callback: (sessionId: string, exitCode: number | null) => void): UnsubscribeFn {
return this.listenMsg<{ sessionId: string; exitCode: number | null }>('pty.closed', (p) => callback(p.sessionId, p.exitCode));
}
}
// ── Helpers ──────────────────────────────────────────────────────────────────
type MsgRole = 'user' | 'assistant' | 'tool-call' | 'tool-result' | 'thinking' | 'system';
function convertWireMessage(raw: {
id: string; type: string; parentId?: string; content: unknown; timestamp: number;
}): AgentMessage | null {
const c = raw.content as Record<string, unknown> | undefined;
switch (raw.type) {
case 'text':
return { id: raw.id, role: 'assistant', content: String(c?.['text'] ?? ''), timestamp: raw.timestamp };
case 'thinking':
return { id: raw.id, role: 'thinking', content: String(c?.['text'] ?? ''), timestamp: raw.timestamp };
case 'tool_call': {
const name = String(c?.['name'] ?? 'Tool');
const input = c?.['input'] as Record<string, unknown> | undefined;
return {
id: raw.id, role: 'tool-call',
content: formatToolContent(name, input),
toolName: name,
toolInput: input ? JSON.stringify(input, null, 2) : undefined,
timestamp: raw.timestamp,
};
}
case 'tool_result': {
const output = c?.['output'];
return {
id: raw.id, role: 'tool-result',
content: typeof output === 'string' ? output : JSON.stringify(output, null, 2),
timestamp: raw.timestamp,
};
}
case 'init':
return { id: raw.id, role: 'system', content: `Session initialized`, timestamp: raw.timestamp };
case 'error':
return { id: raw.id, role: 'system', content: `Error: ${String(c?.['message'] ?? 'Unknown')}`, timestamp: raw.timestamp };
case 'cost':
case 'status':
case 'compaction':
return null;
default:
return null;
}
}
function formatToolContent(name: string, input: Record<string, unknown> | undefined): string {
if (!input) return '';
if (name === 'Bash' && typeof input['command'] === 'string') return input['command'] as string;
if (typeof input['file_path'] === 'string') return input['file_path'] as string;
return JSON.stringify(input, null, 2);
}
function normalizeStatus(status: string): AgentStatus {
if (status === 'idle' || status === 'starting' || status === 'running' || status === 'done' || status === 'error') {
return status;
}
return 'idle';
}