// 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) => Promise>; } 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 { // RPC handle is set externally via setRpc() } async destroy(): Promise { // 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 { const res = await this.r.request['settings.get']({ key }) as { value: string | null }; return res.value; } async setSetting(key: string, value: string): Promise { await this.r.request['settings.set']({ key, value }); } async getAllSettings(): Promise { const res = await this.r.request['settings.getAll']({}) as { settings: SettingsMap }; return res.settings; } // ── Groups ─────────────────────────────────────────────────────────────── async loadGroups(): Promise { // 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>>(); for (const p of projectsRes.projects) { try { const config = JSON.parse(p.config) as Record; 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 { // 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 { 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 { await this.r.request['pty.write']({ sessionId, data }); } async resizePty(sessionId: string, cols: number, rows: number): Promise { await this.r.request['pty.resize']({ sessionId, cols, rows }); } async closePty(sessionId: string): Promise { await this.r.request['pty.close']({ sessionId }); } // ── Files ──────────────────────────────────────────────────────────────── async listDirectory(path: string): Promise { 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 { 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 { 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(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 | 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 | 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 | 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'; }